From 8cbb6c01307215bf4f80985d4e1ed11085d9e14c Mon Sep 17 00:00:00 2001 From: QuantumGhost Date: Fri, 30 Oct 2026 02:43:31 +0000 Subject: [PATCH] feat: HITL IM integration (#44864) Co-authored-by: zxhlyh Co-authored-by: JzoNg Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- .github/workflows/knowledge-fs-ci.yml | 3 - .github/workflows/web-tests.yml | 4 +- api/.env.example | 3 + api/.importlinter | 2 + api/configs/deploy/__init__.py | 14 + api/controllers/common/human_input.py | 2 + .../common/human_input_v2_contracts.py | 705 +++ .../common/human_input_v2_errors.py | 122 + .../common/human_input_v2_migration.py | 431 ++ api/controllers/common/schema.py | 3 +- api/controllers/console/__init__.py | 16 + .../console/app/workflow_human_input_v2.py | 90 + api/controllers/console/app/workflow_run.py | 2 + .../console/human_input_v2/__init__.py | 0 .../console/human_input_v2/_common.py | 7 + .../console/human_input_v2/_decorators.py | 72 + .../console/human_input_v2/channel.py | 696 +++ .../console/human_input_v2/config_version.py | 93 + .../console/human_input_v2/contact.py | 984 ++++ .../console/human_input_v2/entities.py | 0 .../console/human_input_v2/errors.py | 32 + .../console/human_input_v2/form.py | 111 + .../human_input_v2/provider_updates.py | 176 + .../console/human_input_v2/provider_views.py | 115 + .../console/human_input_v2/providers.py | 192 + api/controllers/console/workspace/error.py | 14 + .../service_api/app/human_input_form.py | 83 +- api/controllers/web/__init__.py | 12 + .../web/human_input_form_access_request.py | 189 + api/controllers/web/human_input_v2.py | 120 + .../web/human_input_v2_file_upload.py | 202 + .../web/human_input_v2_im_webhook.py | 45 + .../common/workflow_response_converter.py | 235 +- api/core/app/apps/workflow_app_runner.py | 40 +- api/core/app/entities/queue_entities.py | 4 + api/core/app/entities/task_entities.py | 3 + api/core/entities/execution_extra_content.py | 3 +- api/core/human_input/__init__.py | 5 + api/core/human_input/enums.py | 12 + api/core/human_input_v2/__init__.py | 1 + api/core/human_input_v2/entities.py | 140 + .../human_input_v2/im_integration/__init__.py | 109 + .../im_integration/adapters/__init__.py | 99 + .../im_integration/adapters/avatar.py | 71 + .../im_integration/adapters/credentials.py | 151 + .../im_integration/adapters/dingtalk.py | 595 +++ .../im_integration/adapters/dingtalk_redis.py | 91 + .../im_integration/adapters/entities.py | 271 ++ .../im_integration/adapters/factory.py | 38 + .../im_integration/adapters/feishu_lark.py | 1983 ++++++++ .../adapters/message_locator.py | 50 + .../im_integration/adapters/ms_teams.py | 991 ++++ .../im_integration/adapters/protocols.py | 164 + .../im_integration/adapters/slack.py | 1405 ++++++ .../im_integration/adapters/wecom.py | 617 +++ .../im_integration/binding_commands.py | 47 + .../im_integration/change_log.py | 99 + .../human_input_v2/im_integration/ports.py | 155 + .../human_input_v2/im_integration/records.py | 24 + .../im_integration/sync_reconciliation.py | 626 +++ .../im_integration/sync_records.py | 187 + .../im_message_inbox/__init__.py | 31 + .../im_message_inbox/contracts.py | 111 + api/core/human_input_v2/resolved_form.py | 100 + api/core/human_input_v2/shared/__init__.py | 39 + api/core/human_input_v2/shared/values.py | 62 + .../repositories/human_input_repository.py | 44 + api/core/tools/entities/tool_entities.py | 1 - api/core/workflow/human_input_adapter.py | 90 +- api/core/workflow/node_factory.py | 20 +- .../workflow/nodes/human_input/boundary.py | 43 +- .../workflow/nodes/human_input/callback.py | 8 +- .../workflow/nodes/human_input/entities.py | 55 +- api/core/workflow/nodes/human_input/enums.py | 21 +- .../nodes/human_input/pause_reason.py | 3 + .../nodes/human_input/session_binding.py | 24 +- .../workflow/nodes/human_input_v2/entities.py | 123 + .../workflow/nodes/human_input_v2/events.py | 29 + .../nodes/human_input_v2/form_compiler.py | 169 + .../nodes/human_input_v2/migration.py | 273 ++ .../workflow/nodes/human_input_v2/node.py | 189 + .../nodes/human_input_v2/presentation.py | 38 + .../workflow/nodes/human_input_v2/runtime.py | 85 + .../workflow_tool_container_handler.py | 2 +- api/dev/check_knowledge_fs_coverage.py | 2 + api/docker/entrypoint.sh | 4 +- api/extensions/ext_application_services.py | 4 +- api/extensions/ext_blueprints.py | 9 + api/extensions/ext_celery.py | 3 + api/extensions/ext_redis.py | 23 + api/factories/file_url_factory.py | 102 + .../human_input_v2_form_presentation.py | 146 + api/fields/pagination.py | 12 + api/fields/timestamp.py | 24 + api/libs/helper.py | 79 +- ...26_1646-e8b4a1c6d920_add_human_input_v2.py | 792 ++++ ...009b8_rename_hitlv2_integration_columns.py | 143 + ...30f_backfill_account_contact_identities.py | 42 + ...b50b0_add_hitlv2_ordered_lookup_indexes.py | 27 + api/models/__init__.py | 38 + api/models/agent_config_entities.py | 6 +- api/models/engine.py | 2 +- api/models/human_input.py | 27 +- api/models/human_input_v2.py | 1403 ++++++ api/models/model.py | 89 +- api/models/types.py | 138 +- api/models/workflow.py | 17 +- api/openapi/markdown/console-openapi.md | 2056 +++++++- api/openapi/markdown/service-openapi.md | 5 + api/openapi/markdown/web-openapi.md | 416 +- api/pyproject.toml | 11 +- api/repositories/account/repository.py | 4 + .../api_workflow_run_repository.py | 54 + api/repositories/entities/workflow_pause.py | 15 +- .../execution_extra_content_repository.py | 14 +- api/repositories/human_input_v2/__init__.py | 1 + .../human_input_v2/audit_repository.py | 25 + .../human_input_v2/channel_credentials.py | 18 + api/repositories/human_input_v2/contact.py | 199 + .../delivery_attempt_repository.py | 61 + .../human_input_v2/delivery_repository.py | 175 + .../human_input_v2/email_channel/__init__.py | 39 + .../human_input_v2/email_channel/entities.py | 84 + .../human_input_v2/email_channel/mappers.py | 55 + .../human_input_v2/email_channel/ports.py | 117 + .../email_channel/repository.py | 136 + .../human_input_v2/file_upload_repository.py | 20 + .../human_input_v2/form_repository.py | 206 + .../human_input_v2/im_binding_repository.py | 152 + .../human_input_v2/im_channel_repository.py | 115 + .../human_input_v2/im_identity_repository.py | 120 + .../human_input_v2/im_integration/__init__.py | 7 + .../human_input_v2/im_integration/mappers.py | 300 ++ .../im_integration/repository.py | 248 + .../im_message_inbox/__init__.py | 1 + .../im_message_inbox/mappers.py | 69 + .../im_message_inbox/repository.py | 80 + .../human_input_v2/recipient_repository.py | 135 + .../sqlalchemy_audit_repository.py | 71 + .../sqlalchemy_contact_repository.py | 574 +++ .../sqlalchemy_delivery_attempt_repository.py | 78 + .../sqlalchemy_delivery_repository.py | 141 + .../sqlalchemy_file_upload_repository.py | 41 + .../sqlalchemy_form_repository.py | 489 ++ .../sqlalchemy_im_binding_repository.py | 400 ++ .../sqlalchemy_im_channel_repository.py | 319 ++ .../sqlalchemy_im_identity_repository.py | 273 ++ .../sqlalchemy_recipient_repository.py | 119 + .../installation_state_repository.py | 12 +- .../sqlalchemy_api_workflow_run_repository.py | 195 +- ...hemy_execution_extra_content_repository.py | 25 +- .../workspace/workspace_repository.py | 6 + api/services/account/service.py | 6 +- .../human_input_contact_composition.py | 31 + .../enterprise/human_input_contact_service.py | 126 + api/services/enterprise/rbac_service.py | 2 +- api/services/entities/feature_entities.py | 3 + api/services/feature_service.py | 11 + .../human_input_form_delivery_provider.py | 348 ++ api/services/human_input_service.py | 19 +- api/services/human_input_v2/__init__.py | 1 + api/services/human_input_v2/composition.py | 104 + .../human_input_v2/contact_service.py | 188 + .../human_input_v2/credential_update.py | 169 + .../human_input_v2/delivery_service.py | 441 ++ .../email_channel_management_composition.py | 18 + .../email_channel_management_service.py | 244 + api/services/human_input_v2/email_client.py | 50 + .../human_input_v2/email_credential_codec.py | 16 + .../human_input_v2/email_otp_composition.py | 40 + .../human_input_v2/email_otp_service.py | 365 ++ api/services/human_input_v2/errors.py | 72 + .../human_input_v2/file_upload_service.py | 136 + api/services/human_input_v2/form_service.py | 556 +++ .../human_input_v2/im_card_service.py | 149 + .../human_input_v2/im_channel_service.py | 474 ++ .../im_channel_service_composition.py | 24 + .../im_contact_sync/__init__.py | 27 + .../im_contact_sync/binding_service.py | 194 + .../im_contact_sync/composition.py | 213 + .../im_contact_sync/coordinator.py | 849 ++++ .../human_input_v2/im_contact_sync/errors.py | 8 + .../im_contact_sync/reconciliation.py | 70 + .../human_input_v2/im_contact_sync/service.py | 164 + .../human_input_v2/im_contact_sync/worker.py | 35 + .../human_input_v2/im_credential_codec.py | 46 + .../im_message_inbox/__init__.py | 1 + .../im_message_inbox/processor.py | 37 + .../human_input_v2/im_message_inbox/sink.py | 127 + .../im_message_inbox/storage.py | 72 + .../im_message_inbox/telemetry.py | 86 + .../im_tenant_credential_cipher.py | 18 + .../human_input_v2/im_webhook_runtime.py | 178 + .../message_template_test_composition.py | 48 + .../message_template_test_service.py | 231 + .../human_input_v2/node_data_migration.py | 198 + api/services/human_input_v2/resend_channel.py | 186 + api/services/human_input_v2/runtime.py | 87 + .../human_input_v2/submission_service.py | 810 ++++ .../workspace_member_email_lookup.py | 49 + api/services/message_extra_content_service.py | 121 + api/services/message_query_adapters.py | 12 +- api/services/rbac/builtin.py | 2 +- .../workflow_event_snapshot_service.py | 250 +- api/services/workflow_pause_service.py | 82 + api/services/workflow_run_agg.py | 32 +- api/services/workflow_run_service.py | 41 +- api/services/workflow_tool_source_service.py | 7 + api/tasks/human_input_im_card_tasks.py | 31 + api/tasks/human_input_timeout_tasks.py | 48 +- api/tasks/im_contact_sync_tasks.py | 56 + api/tasks/im_message_inbox_tasks.py | 91 + api/tasks/mail_human_input_delivery_task.py | 258 +- api/tests/conftest.py | 4 + .../test_legacy_model_type_migration.py | 204 +- api/tests/e2e_tests/containers/conftest.py | 429 +- .../console/test_human_input_channel_api.py | 222 + .../workspace/test_im_contact_sync_api.py | 430 ++ .../workspace/test_im_contact_sync_live.py | 176 + .../layers/test_pause_state_persist_layer.py | 2 +- .../im_provider/test_dingtalk_live.py | 317 ++ .../test_dingtalk_redis_token_provider.py | 112 + .../test_feishu_lark_card_event_flow.py | 679 +++ .../test_ms_teams_card_event_flow.py | 185 + .../test_ms_teams_external_integration.py | 401 ++ .../human_input_v2/im_provider/test_slack.py | 1016 ++++ .../im_provider/test_wecom_live.py | 292 ++ .../test_human_input_v2_form_repository.py | 107 + .../human_input_v2/test_email_otp_redis.py | 426 ++ .../containers/libs/test_redis_counter.py | 52 + .../models/test_pydantic_model_json.py | 116 + .../test_email_channel_repository.py | 160 + .../test_im_channel_repository.py | 150 + .../test_im_message_inbox_repository.py | 168 + ..._sqlalchemy_api_workflow_run_repository.py | 17 +- .../test_workflow_run_repository.py | 30 +- ...message_service_execution_extra_content.py | 6 +- .../test_message_service_extra_contents.py | 4 +- .../services/test_workflow_run_service.py | 1 + .../test_workflow_event_snapshot_service.py | 40 +- .../tasks/test_delete_account_task.py | 5 +- .../containers/test_container_fixtures.py | 101 + .../test_workflow_pause_integration.py | 4 +- .../containers/trigger/test_trigger_e2e.py | 4 +- .../entities/test_private_workflow_pause.py | 18 +- .../dev/test_knowledge_fs_ci_workflow.py | 1 - .../test_knowledge_fs_migration_chain.py | 62 - .../contracts/test_sqlite_fixtures.py | 25 +- api/tests/fixtures/im_provider/README.md | 51 + .../dingtalk/sanitized_protocol.json | 192 + .../feishu_lark/sanitized_directory_user.json | 32 + .../ms_teams/sanitized_avatar_not_found.json | 11 + .../ms_teams/sanitized_avatar_success.json | 8 + .../ms_teams_webhook_activity.json | 35 + .../slack/sanitized_directory_user.json | 46 + .../im_provider/wecom/sanitized_protocol.json | 168 + .../test_im_control_plane_concurrency.py | 178 + .../test_portable_delivery_binding_writes.py | 243 + .../human_input_v2/test_stale_im_sync_runs.py | 339 ++ .../unit_tests/configs/test_app_config.py | 28 + .../common/test_access_response.py | 11 +- .../common/test_human_input_v2_contracts.py | 400 ++ .../common/test_human_input_v2_migration.py | 347 ++ .../controllers/common/test_schema.py | 17 + .../console/app/test_model_config.py | 1 - .../console/app/test_workflow_run.py | 10 +- .../console/human_input_v2/test_contact.py | 174 + .../console/test_human_input_channel_api.py | 651 +++ ...test_human_input_channel_config_version.py | 54 + .../test_human_input_channel_openapi.py | 136 + .../test_human_input_channel_providers.py | 417 ++ .../console/test_human_input_channel_wire.py | 500 ++ .../test_human_input_message_template.py | 163 + .../console/test_human_input_v2_form_read.py | 371 ++ .../workspace/test_contact_transactions.py | 101 + .../test_human_input_node_data_migration.py | 637 +++ .../workspace/test_im_contact_sync_api.py | 863 ++++ .../service_api/app/test_human_input_form.py | 2 +- .../test_human_input_form_compatibility.py | 773 +++ .../service_api/app/test_workflow_events.py | 24 +- .../controllers/service_api/test___init__.py | 28 + .../web/test_human_input_form_v2_stub.py | 29 + .../web/test_human_input_v2_form_read.py | 499 ++ .../web/test_human_input_v2_otp_access.py | 37 + .../test_generate_task_pipeline.py | 1 + .../test_workflow_response_converter.py | 102 +- .../core/app/apps/test_workflow_app_runner.py | 4 +- .../workflow/test_generate_task_pipeline.py | 1 + .../unit_tests/core/app/llm/test_quota.py | 19 +- .../im_message_inbox/test_contracts.py | 50 + .../feishu_lark_card_action_stream.json | 73 + .../feishu_lark_card_action_webhook.json | 157 + ...eishu_lark_card_action_without_inputs.json | 50 + .../im_provider/fixtures/lark/README.md | 122 + .../lark/sanitized_card_action_stream.json | 28 + .../lark/sanitized_card_action_webhook.json | 42 + .../fixtures/lark/sanitized_protocol.json | 670 +++ .../fixtures/lark/sanitized_stream_ack.json | 5 + .../lark/sanitized_url_challenge.json | 17 + .../fixtures/ms_teams_card_submit.json | 37 + ...lock_actions_live_capture_socket_mode.json | 191 + ...k_block_actions_radio_buttons_webhook.json | 145 + .../slack_block_actions_socket_mode.json | 155 + ...ock_actions_static_select_socket_mode.json | 154 + .../fixtures/slack_block_actions_webhook.json | 148 + .../im_provider/test_avatar_batch.py | 82 + .../im_provider/test_contracts.py | 253 + .../im_provider/test_dingtalk_adapter.py | 1272 +++++ .../test_dingtalk_redis_token_provider.py | 338 ++ .../im_provider/test_dingtalk_sdk_boundary.py | 497 ++ .../im_provider/test_feishu_lark_adapter.py | 1767 +++++++ .../im_provider/test_feishu_lark_branches.py | 332 ++ .../test_feishu_lark_card_events.py | 1236 +++++ .../test_feishu_lark_credentials.py | 117 + .../test_feishu_lark_sdk_boundary.py | 2008 ++++++++ .../im_provider/test_feishu_lark_stream.py | 1317 ++++++ ...test_feishu_lark_url_challenge_contract.py | 217 + .../im_provider/test_feishu_lark_webhook.py | 549 +++ .../im_provider/test_lark_sdk_boundary.py | 742 +++ .../test_message_locator_conformance.py | 399 ++ .../im_provider/test_ms_teams_adapter.py | 1727 +++++++ .../im_provider/test_ms_teams_card_events.py | 566 +++ .../test_ms_teams_credential_alignment.py | 26 + .../im_provider/test_ms_teams_sdk_boundary.py | 1062 +++++ .../im_provider/test_slack_adapter.py | 1473 ++++++ .../im_provider/test_slack_card_events.py | 945 ++++ .../test_slack_requirement_conformance.py | 1504 ++++++ .../im_provider/test_slack_sdk_boundary.py | 1371 ++++++ .../im_provider/test_wecom_adapter.py | 362 ++ .../test_wecom_credential_alignment.py | 89 + .../im_provider/test_wecom_directory.py | 589 +++ .../im_provider/test_wecom_messaging.py | 207 + .../im_provider/test_wecom_sdk_boundary.py | 102 + .../test_im_reconciliation_change_log.py | 23 + .../test_im_sync_reconciliation.py | 476 ++ .../test_im_sync_reconciliation_contract.py | 77 + .../test_im_sync_reconciliation_properties.py | 1558 ++++++ .../human_input_v2/test_import_boundaries.py | 88 + .../core/human_input_v2/test_resolved_form.py | 189 + .../core/human_input_v2/test_shared_values.py | 82 + .../test_human_input_repository.py | 156 +- ...qlalchemy_workflow_execution_repository.py | 3 + .../nodes/human_input/test_callback.py | 25 +- .../nodes/human_input/test_entities.py | 4 +- .../nodes/human_input/test_pause_reason.py | 4 +- .../human_input_v2/test_form_compiler.py | 231 + .../nodes/human_input_v2/test_migration.py | 571 +++ .../nodes/human_input_v2/test_node.py | 509 ++ .../core/workflow/test_human_input_adapter.py | 32 + .../dev/test_check_knowledge_fs_coverage.py | 44 + .../dev/test_generate_swagger_specs.py | 2 + ..._api_workflow_node_execution_repository.py | 184 +- ...tore_workflow_node_execution_repository.py | 11 +- .../extensions/test_ext_blueprints.py | 54 +- .../factories/test_file_url_factory.py | 155 + .../test_human_input_v2_form_presentation.py | 194 + api/tests/unit_tests/fields/test_timestamp.py | 47 + ...1c9ba48be8e4_add_uuidv7_function_in_sql.py | 111 - ...c2f91b30_add_knowledge_fs_control_plane.py | 120 - ...0-b7f2a9d41c60_add_knowledge_fs_cutover.py | 141 - ..._add_knowledge_fs_cleanup_authorization.py | 157 - ...add_knowledge_fs_remote_freeze_evidence.py | 66 - ...416_add_knowledge_fs_cleanup_completion.py | 104 - ...57_replace_agent_runtime_sessions_with_.py | 144 - ..._make_home_snapshot_references_nullable.py | 181 - ...a4b2d60_add_knowledge_fs_staged_uploads.py | 71 - ...b2c_add_knowledge_fs_space_tag_bindings.py | 60 - ...7d9e10_add_knowledge_fs_icon_background.py | 62 - ...8c1d7e920_add_knowledge_fs_upgrade_jobs.py | 70 - ...17_1740-89919253ca7a_remove_agent_drive.py | 184 - ...fcf5f5a6e_clean_legacy_agent_soul_files.py | 125 - ...20b69_simplify_agent_v2_output_contract.py | 98 - ...5e4f3a_add_normalized_email_to_accounts.py | 98 - ...5578e028b2f2_migrate_legacy_model_types.py | 86 - ...dataset_api_token_binding_resource_type.py | 119 - ...1c902_expand_normalized_email_providers.py | 107 - ...c6f4a90d8b31_add_workflow_pause_task_id.py | 163 - ...c2980f3e6_add_application_agent_tracing.py | 69 - ...t_active_knowledge_fs_upgrades_on_mysql.py | 58 - ...901_add_upgrade_availability_checkpoint.py | 70 - ...a5c102_add_upgrade_reconciliation_lease.py | 60 - api/tests/unit_tests/models/test_agent.py | 7 +- .../models/test_human_input_im_identity.py | 34 + api/tests/unit_tests/models/test_model.py | 4 +- .../models/test_pydantic_model_json.py | 288 ++ api/tests/unit_tests/models/test_types.py | 10 + .../email_channel/test_repository.py | 265 ++ .../im_integration/test_im_sync_queries.py | 223 + .../im_integration/test_mappers.py | 180 + .../im_integration/test_repository.py | 90 + .../im_message_inbox/test_repository.py | 248 + .../human_input_v2/test_contact_contracts.py | 97 + .../human_input_v2/test_contact_repository.py | 614 +++ .../human_input_v2/test_form_repository.py | 505 ++ .../test_im_binding_repository.py | 443 ++ .../test_im_channel_repository_contract.py | 540 +++ .../test_im_identity_repository.py | 278 ++ .../test_recipient_delivery_repositories.py | 805 ++++ .../test_sqlalchemy_form_repository.py | 99 + ..._sqlalchemy_api_workflow_run_repository.py | 190 +- ...nowledge_fs_capability_issuance_auditor.py | 38 +- .../workspace/test_workspace_repository.py | 34 + .../services/account/test_login_adapters.py | 72 +- .../services/account/test_service.py | 27 + .../im_message_inbox/test_processor.py | 95 + .../test_provider_composition.py | 327 ++ .../im_message_inbox/test_sink.py | 294 ++ .../im_message_inbox/test_telemetry.py | 48 + .../human_input_v2/test_composition.py | 273 ++ .../test_contact_capability_boundaries.py | 220 + .../test_contact_im_binding_service.py | 794 ++++ .../test_email_credential_codec.py | 86 + .../human_input_v2/test_email_otp_service.py | 666 +++ .../test_file_upload_service.py | 327 ++ .../test_form_delivery_runtime.py | 4199 +++++++++++++++++ .../test_im_channel_reconciliation_service.py | 771 +++ .../human_input_v2/test_im_channel_service.py | 1092 +++++ .../test_im_contact_sync_composition.py | 349 ++ .../test_im_contact_sync_contracts.py | 25 + .../test_im_contact_sync_worker.py | 143 + .../test_im_provider_adapter.py | 129 + .../human_input_v2/test_im_sync_service.py | 231 + .../test_im_tenant_credential_cipher.py | 69 + .../human_input_v2/test_im_webhook_runtime.py | 898 ++++ .../test_message_template_test_service.py | 714 +++ .../test_node_data_migration.py | 699 +++ .../test_runtime_service_api.py | 98 + .../human_input_v2/test_submission_service.py | 211 + .../services/test_feature_service_im.py | 25 + ..._input_email_channel_management_service.py | 559 +++ ...test_human_input_form_delivery_provider.py | 338 ++ .../test_human_input_resend_channel.py | 186 + .../services/test_message_query_adapters.py | 149 +- .../services/test_message_query_service.py | 7 +- .../test_workflow_event_snapshot_service.py | 147 +- .../services/test_workflow_run_agg.py | 93 +- .../services/test_workflow_run_service.py | 216 +- .../test_workflow_tool_source_service.py | 33 + .../tracing/test_provider_dispatch.py | 3 + .../services/trigger/test_webhook_service.py | 7 +- .../services/workspace/test_member_service.py | 81 +- .../tasks/test_im_message_inbox_tasks.py | 137 + .../test_mail_human_input_delivery_task.py | 296 +- .../unit_tests/test_worker_entrypoint.py | 14 + api/uv.lock | 200 +- docker/.env.example | 1 + docker/README.md | 12 + docker/envs/core-services/shared.env.example | 1 + docker/envs/core-services/web.env.example | 5 + docker/nginx/conf.d/default.conf.template | 8 +- oxlint-suppressions.json | 23 +- .../generated/api/console/agent/types.gen.ts | 1 + .../generated/api/console/agent/zod.gen.ts | 5 +- .../generated/api/console/apps/errors.gen.ts | 88 +- .../generated/api/console/apps/orpc.gen.ts | 306 +- .../generated/api/console/apps/types.gen.ts | 82 +- .../generated/api/console/apps/zod.gen.ts | 230 +- .../generated/api/console/errors.gen.ts | 202 +- .../api/console/features/types.gen.ts | 1 + .../generated/api/console/features/zod.gen.ts | 1 + .../generated/api/console/form/errors.gen.ts | 46 +- .../generated/api/console/form/orpc.gen.ts | 72 +- .../generated/api/console/form/types.gen.ts | 174 +- .../generated/api/console/form/zod.gen.ts | 208 +- .../api/console/installed-apps/types.gen.ts | 1 + .../api/console/installed-apps/zod.gen.ts | 1 + .../generated/api/console/snippets/zod.gen.ts | 4 +- .../api/console/workflow/types.gen.ts | 1 + .../generated/api/console/workflow/zod.gen.ts | 1 + .../api/console/workspaces/errors.gen.ts | 128 +- .../api/console/workspaces/orpc.gen.ts | 1624 +++++-- .../api/console/workspaces/types.gen.ts | 1886 ++++++++ .../api/console/workspaces/zod.gen.ts | 1978 ++++++++ .../generated/api/service/types.gen.ts | 1 + .../generated/api/service/zod.gen.ts | 5 + .../contracts/generated/api/web/errors.gen.ts | 217 +- .../contracts/generated/api/web/orpc.gen.ts | 409 +- .../contracts/generated/api/web/types.gen.ts | 436 ++ .../contracts/generated/api/web/zod.gen.ts | 383 +- .../scripts/compose-console-openapi.mjs | 3 + .../integration/console-preparation.test.mjs | 28 + .../integration/console-type-differences.json | 64 + web/.env.example | 5 + web/__tests__/env.spec.ts | 33 + .../contacts/__tests__/page.spec.tsx | 45 + web/app/(commonLayout)/contacts/page.tsx | 13 + .../__tests__/release-gate.spec.tsx | 54 + .../form-v2/[token]/__tests__/form.spec.tsx | 49 + .../form-v2/[token]/form.tsx | 14 + .../form-v2/[token]/page.tsx | 14 + .../form/[token]/__tests__/form.spec.tsx | 80 +- .../(humanInputLayout)/form/[token]/form.tsx | 66 +- .../form/[token]/loaded-form-content.tsx | 4 +- .../form/[token]/use-form-submit.ts | 11 +- .../human-input/[formToken]/page.tsx | 17 + .../config-modal/__tests__/index.spec.tsx | 1 + .../item/__tests__/index.hitl-v2.spec.tsx | 206 + .../item/__tests__/index.spec.tsx | 102 +- .../app/text-generate/item/index.tsx | 29 +- .../__tests__/chat-wrapper.hitl-v2.spec.tsx | 224 + .../__tests__/chat-wrapper.spec.tsx | 22 +- .../chat/chat-with-history/chat-wrapper.tsx | 14 +- .../__tests__/executed-action.spec.tsx | 37 + .../__tests__/human-input-form.spec.tsx | 52 + .../__tests__/unsubmitted.spec.tsx | 34 + .../human-input-content/executed-action.tsx | 5 +- .../human-input-content/human-input-form.tsx | 32 +- .../__tests__/chat-wrapper.hitl-v2.spec.tsx | 452 ++ .../__tests__/chat-wrapper.spec.tsx | 16 +- .../chat/embedded-chatbot/chat-wrapper.tsx | 12 +- .../file-uploader/__tests__/hooks.spec.ts | 104 +- .../components/base/file-uploader/hooks.ts | 32 +- .../base/file-uploader/upload-context.ts | 9 + .../__tests__/focus-transfer.browser.spec.tsx | 71 + .../__tests__/input-field.spec.tsx | 111 +- .../hitl-input-block-replacement-block.tsx | 1 + .../plugins/hitl-input-block/input-field.tsx | 7 +- .../__tests__/billing-browser-fixture.tsx | 2 +- .../__tests__/index.hitl-v2.spec.tsx | 378 ++ .../header/account-setting/constants.ts | 2 + .../header/account-setting/index.tsx | 56 +- .../__tests__/index.browser.spec.tsx | 7 +- .../__tests__/delete-member.browser.spec.tsx | 59 +- .../members-page/__tests__/index.spec.tsx | 213 +- .../account-setting/members-page/index.tsx | 234 +- .../__tests__/index.hitl-v2.spec.tsx | 217 + .../members-page/invite-modal/index.tsx | 165 +- .../__tests__/index.browser.spec.tsx | 7 +- .../main-nav/__tests__/index.spec.tsx | 29 + web/app/components/main-nav/index.tsx | 37 +- web/app/components/main-nav/routes.ts | 17 +- .../components/plugins/plugin-auth/types.ts | 4 +- .../hooks/use-available-nodes-meta-data.ts | 4 +- .../workflow-events-subscription.spec.ts | 149 + .../workflow-stream-handlers.spec.ts | 85 +- .../use-result-sender.hitl-v2.spec.ts | 425 ++ .../use-webapp-workflow-events.spec.ts | 166 + .../result/hooks/use-result-sender.ts | 18 +- .../hooks/use-webapp-workflow-events.ts | 72 + .../share/text-generation/result/index.tsx | 3 +- .../result/workflow-events-subscription.ts | 150 + .../result/workflow-stream-handlers.ts | 7 + .../snippets/components/snippet-main.tsx | 2 + .../workflow-app/components/workflow-main.tsx | 5 +- .../workflow/__tests__/node-catalog.spec.ts | 137 +- web/app/components/workflow/block-icon.tsx | 2 + .../block-selector/__tests__/blocks.spec.tsx | 140 +- .../workflow/block-selector/blocks.tsx | 103 +- .../use-insert-snippet.hitl-v2.spec.tsx | 213 + .../snippets/use-insert-snippet.ts | 18 +- web/app/components/workflow/constants.ts | 1 + web/app/components/workflow/constants/node.ts | 2 + .../__tests__/history-status-title.spec.tsx | 40 + .../workflow/header/running-title.tsx | 4 +- .../__tests__/use-available-blocks.spec.ts | 3 + .../hooks/__tests__/use-checklist.spec.tsx | 83 +- .../use-fetch-workflow-inspect-vars.spec.ts | 53 +- .../use-nodes-interactions.hitl-v2.spec.ts | 480 ++ .../__tests__/use-nodes-interactions.spec.ts | 151 +- .../workflow/hooks/use-nodes-interactions.ts | 82 +- .../workflow/hooks/use-workflow-history.ts | 4 + ...workflow-node-human-input-required.spec.ts | 11 + web/app/components/workflow/node-catalog.ts | 52 +- .../nodes/__tests__/index.hitl-v2.spec.tsx | 91 + .../components/before-run-form/index.tsx | 11 +- .../variable/__tests__/utils.spec.ts | 110 + .../nodes/_base/components/variable/utils.ts | 81 +- .../__tests__/use-last-run.hitl-v2.spec.ts | 188 + .../workflow-panel/last-run/use-last-run.ts | 22 +- .../hooks/__tests__/use-one-step-run.spec.ts | 46 + .../nodes/_base/hooks/use-one-step-run.ts | 5 +- .../components/workflow/nodes/components.ts | 6 +- .../workflow/nodes/human-input-router.tsx | 15 + .../workflow/nodes/human-input-v2/README.md | 27 + .../__tests__/channel-fixtures.ts | 34 + .../__tests__/contact-provider.spec.ts | 150 + .../__tests__/debug-mode.spec.tsx | 241 + .../human-input-v2/__tests__/default.spec.ts | 216 + .../__tests__/fixtures/contact-provider.ts | 44 + .../form-content-restoration.spec.tsx | 65 + .../__tests__/message-template-body.spec.tsx | 73 + .../__tests__/message-template.spec.tsx | 779 +++ .../human-input-v2/__tests__/node.spec.tsx | 173 + .../human-input-v2/__tests__/panel.spec.tsx | 171 + .../__tests__/recipient-utils.spec.ts | 189 + .../__tests__/recipients.spec.tsx | 1667 +++++++ .../human-input-v2/__tests__/router.spec.tsx | 92 + .../human-input-v2/__tests__/types.spec.ts | 21 + .../__tests__/use-config.spec.ts | 254 + .../human-input-v2/components/debug-mode.tsx | 220 + .../components/message-template-body.tsx | 156 + .../components/message-template.tsx | 546 +++ .../components/recipient-avatar.tsx | 59 + .../components/recipient-contact-preview.tsx | 122 + .../human-input-v2/components/recipients.tsx | 989 ++++ .../nodes/human-input-v2/contact-provider.ts | 88 + .../workflow/nodes/human-input-v2/default.ts | 73 + .../nodes/human-input-v2/disabled.tsx | 18 + .../human-input-v2/message-template-utils.ts | 4 + .../nodes/human-input-v2/migration/README.md | 11 + .../migration/__tests__/executor.spec.ts | 495 ++ .../__tests__/fixtures/v1-batch.json | 258 + .../migration/__tests__/locales.spec.ts | 51 + .../__tests__/migration-banner.spec.tsx | 57 + .../__tests__/migration-dialog.spec.tsx | 100 + .../migration/__tests__/policy.spec.ts | 149 + .../migration/__tests__/provider.spec.tsx | 483 ++ .../nodes/human-input-v2/migration/context.ts | 16 + .../human-input-v2/migration/executor.ts | 223 + .../migration/migration-banner.tsx | 50 + .../migration/migration-dialog.tsx | 98 + .../nodes/human-input-v2/migration/policy.ts | 123 + .../human-input-v2/migration/provider.tsx | 228 + .../nodes/human-input-v2/migration/types.ts | 74 + .../workflow/nodes/human-input-v2/node.tsx | 147 + .../workflow/nodes/human-input-v2/panel.tsx | 75 + .../nodes/human-input-v2/recipient-utils.ts | 173 + .../workflow/nodes/human-input-v2/types.ts | 71 + .../nodes/human-input-v2/use-config.ts | 89 + .../human-input-v2/use-configured-channels.ts | 29 + .../__tests__/human-input.hitl-v2.spec.tsx | 163 + .../nodes/human-input/__tests__/node.spec.tsx | 44 +- .../__tests__/panel.hitl-v2.spec.tsx | 367 ++ .../human-input/__tests__/panel.spec.tsx | 10 +- .../form-content-preview.hitl-v2.spec.tsx | 184 + .../form-content.hitl-v2.browser.spec.tsx | 142 + .../single-run-form.hitl-v2.spec.tsx | 117 + .../__tests__/timeout.hitl-v2.spec.tsx | 63 + .../__tests__/test-email-sender.spec.tsx | 8 +- .../delivery-method/mail-body-input.tsx | 17 +- .../components/form-content-preview.tsx | 112 +- .../components/single-run-form.tsx | 12 +- .../nodes/human-input/components/timeout.tsx | 4 +- .../human-input/components/user-action.tsx | 13 +- .../workflow/nodes/human-input/default.ts | 50 +- .../use-form-content.hitl-v2.spec.ts | 120 + ...use-single-run-form-params.hitl-v2.spec.ts | 371 ++ .../nodes/human-input/hooks/use-config.ts | 77 +- .../hooks/use-single-run-form-params.ts | 107 +- .../workflow/nodes/human-input/node.tsx | 46 +- .../workflow/nodes/human-input/panel.tsx | 270 +- .../shared/__tests__/panel-sections.spec.tsx | 163 + .../nodes/human-input/shared/constants.ts | 1 + .../nodes/human-input/shared/default-utils.ts | 37 + .../human-input/shared/node-branches.tsx | 39 + .../human-input/shared/panel-sections.tsx | 295 ++ .../nodes/human-input/shared/types.ts | 186 + .../nodes/human-input/shared/use-config.ts | 61 + .../{hooks => shared}/use-form-content.ts | 34 +- .../workflow/nodes/human-input/types.ts | 173 +- .../__tests__/use-interactions.spec.tsx | 4 +- .../workflow/nodes/panel-components.ts | 17 +- web/app/components/workflow/nodes/panel.tsx | 3 +- .../__tests__/human-input-form-list.spec.tsx | 30 + .../workflow-preview.hitl-v2.spec.tsx | 212 + .../panel/__tests__/workflow-preview.spec.tsx | 83 +- .../__tests__/components.spec.tsx | 69 +- .../__tests__/hooks.spec.tsx | 12 +- .../__tests__/hooks/misc.spec.ts | 75 +- .../workflow/panel/debug-and-preview/hooks.ts | 5 +- .../workflow/panel/human-input-form-list.tsx | 7 +- .../workflow/panel/workflow-preview.tsx | 20 +- .../workflow/run/__tests__/status.spec.tsx | 126 +- web/app/components/workflow/run/status.tsx | 23 +- .../workflow/selection-contextmenu.tsx | 1 + web/app/components/workflow/types.ts | 1 + .../__tests__/elk-layout.hitl-v2.spec.ts | 107 + .../workflow/utils/__tests__/node.spec.ts | 15 + .../components/workflow/utils/elk-layout.ts | 4 +- web/app/components/workflow/utils/node.ts | 11 +- web/config/index.ts | 1 + web/docker/entrypoint.sh | 1 + web/env.ts | 9 + web/features/contacts/im-capability.ts | 5 + web/features/contacts/im-platform/README.md | 39 + .../__tests__/feature-flag.spec.ts | 31 + .../__tests__/fixtures/channel-client.ts | 136 + .../__tests__/fixtures/repository.ts | 488 ++ .../__tests__/fixtures/scenarios.ts | 553 +++ .../__tests__/management-surface.spec.tsx | 2110 +++++++++ .../__tests__/mock-repository.spec.ts | 1190 +++++ .../__tests__/provider-form-adapters.spec.ts | 30 + .../__tests__/repository-hooks.spec.tsx | 569 +++ .../__tests__/sync-details-dialog.spec.tsx | 359 ++ .../im-platform/account-setting-page.tsx | 28 + .../contacts/im-platform/binding-dialog.tsx | 526 +++ .../contacts/im-platform/callback-setup.tsx | 120 + .../im-platform/composition-context.ts | 27 + .../contacts/im-platform/composition.tsx | 50 + web/features/contacts/im-platform/context.ts | 5 + .../contacts/im-platform/credential-draft.ts | 14 + .../im-platform/email-config-dialog.tsx | 396 ++ .../contacts/im-platform/feature-flag.ts | 4 + web/features/contacts/im-platform/hooks.ts | 419 ++ .../im-platform/management-surface.tsx | 459 ++ .../contacts/im-platform/provider-card.tsx | 96 + .../im-platform/provider-form-adapters.ts | 64 + .../contacts/im-platform/query-keys.ts | 37 + .../contacts/im-platform/repository.ts | 756 +++ .../im-platform/sync-details-dialog.tsx | 394 ++ .../im-platform/sync-run-url-state.ts | 32 + .../contacts/im-platform/sync-section.tsx | 161 + web/features/contacts/im-platform/types.ts | 280 ++ web/features/contacts/management/README.md | 32 + .../management/__tests__/composition.spec.tsx | 202 + .../__tests__/contact-im-channels.spec.tsx | 119 + .../__tests__/directory-page.browser.spec.tsx | 69 + .../__tests__/directory-page.spec.tsx | 1874 ++++++++ .../external-contact-dialog.spec.tsx | 515 ++ .../management/__tests__/feature-flag.spec.ts | 27 + .../__tests__/fixtures/composition.tsx | 36 + .../__tests__/fixtures/repository.ts | 325 ++ .../__tests__/fixtures/scenarios.ts | 210 + .../__tests__/member-removal-content.spec.tsx | 219 + .../__tests__/mock-repository.spec.ts | 864 ++++ .../platform-contact-picker-dialog.spec.tsx | 301 ++ .../contacts/management/assets/dingtalk.svg | 12 + .../contacts/management/assets/email.svg | 5 + .../contacts/management/assets/feishu.svg | 7 + .../contacts/management/assets/slack.svg | 8 + .../management/assets/user-community.svg | 5 + .../contacts/management/channel-icon.tsx | 61 + .../contacts/management/channel-utils.ts | 16 + .../management/composition-context.ts | 28 + .../contacts/management/composition.tsx | 76 + .../management/contact-details-panel.tsx | 793 ++++ .../contacts/management/directory-page.tsx | 760 +++ .../management/external-contact-dialog.tsx | 459 ++ .../contacts/management/feature-flag.ts | 3 + web/features/contacts/management/hooks.ts | 388 ++ .../member-invite-contact-upgrade-dialog.tsx | 57 + .../management/member-removal-content.tsx | 152 + .../platform-contact-picker-dialog.tsx | 306 ++ .../platform-contact-upgrade-dialog.tsx | 57 + .../contacts/management/query-keys.ts | 31 + .../contacts/management/relative-time.ts | 22 + .../contacts/management/repository.ts | 326 ++ web/features/contacts/management/types.ts | 166 + web/features/human-input/README.md | 28 + web/features/human-input/console/README.md | 26 + .../console/__tests__/definition.spec.ts | 186 + .../console/__tests__/form.spec.tsx | 449 ++ .../human-input/console/definition.ts | 63 + web/features/human-input/console/form.tsx | 192 + web/features/human-input/console/links.ts | 14 + web/features/human-input/form/README.md | 29 + .../__tests__/loaded-form-content.spec.tsx | 201 + .../human-input/form}/branding-footer.tsx | 10 +- .../human-input/form}/form-status-card.tsx | 17 +- .../human-input/form/loaded-form-content.tsx | 394 ++ .../form/normalize-legacy-definition.ts | 15 + web/features/human-input/form/types.ts | 29 + web/features/human-input/public/README.md | 49 + .../public/__tests__/form.spec.tsx | 237 + .../public/__tests__/locales.spec.ts | 57 + .../public/__tests__/mock-transport.spec.ts | 341 ++ .../public/__tests__/real-upload.spec.ts | 88 + .../public/__tests__/session-reducer.spec.ts | 139 + .../__tests__/upload-strategies.spec.ts | 119 + .../__tests__/use-form-session.spec.tsx | 430 ++ web/features/human-input/public/errors.ts | 80 + web/features/human-input/public/form.tsx | 160 + .../human-input/public/legacy-upload.ts | 9 + .../human-input/public/mock-transport.ts | 228 + .../human-input/public/otp-verification.tsx | 93 + .../human-input/public/real-transport.ts | 84 + .../human-input/public/session-reducer.ts | 136 + .../human-input/public/transport-selector.ts | 29 + web/features/human-input/public/types.ts | 82 + web/features/human-input/public/upload.ts | 65 + .../human-input/public/use-form-session.ts | 251 + web/i18n/__tests__/load-resource.spec.ts | 12 + web/i18n/load-resource.ts | 15 +- web/i18n/locales/ar-TN/contacts.json | 9 + web/i18n/locales/ar-TN/share.json | 4 + web/i18n/locales/ar-TN/workflow.json | 30 + web/i18n/locales/az-AZ/contacts.json | 9 + web/i18n/locales/az-AZ/share.json | 4 + web/i18n/locales/az-AZ/workflow.json | 14 + web/i18n/locales/de-DE/contacts.json | 9 + web/i18n/locales/de-DE/share.json | 4 + web/i18n/locales/de-DE/workflow.json | 30 + web/i18n/locales/en-US/contacts.json | 358 ++ web/i18n/locales/en-US/share.json | 27 + .../locales/en-US/workflow-human-input.json | 1 + web/i18n/locales/en-US/workflow.json | 128 + web/i18n/locales/es-ES/contacts.json | 9 + web/i18n/locales/es-ES/share.json | 4 + web/i18n/locales/es-ES/workflow.json | 30 + web/i18n/locales/fa-IR/contacts.json | 9 + web/i18n/locales/fa-IR/share.json | 4 + web/i18n/locales/fa-IR/workflow.json | 30 + web/i18n/locales/fr-FR/contacts.json | 9 + web/i18n/locales/fr-FR/share.json | 4 + web/i18n/locales/fr-FR/workflow.json | 30 + web/i18n/locales/hi-IN/contacts.json | 9 + web/i18n/locales/hi-IN/share.json | 4 + web/i18n/locales/hi-IN/workflow.json | 30 + web/i18n/locales/id-ID/contacts.json | 9 + web/i18n/locales/id-ID/share.json | 4 + web/i18n/locales/id-ID/workflow.json | 30 + web/i18n/locales/it-IT/contacts.json | 9 + web/i18n/locales/it-IT/share.json | 4 + web/i18n/locales/it-IT/workflow.json | 30 + web/i18n/locales/ja-JP/contacts.json | 9 + web/i18n/locales/ja-JP/share.json | 4 + web/i18n/locales/ja-JP/workflow.json | 30 + web/i18n/locales/ko-KR/contacts.json | 9 + web/i18n/locales/ko-KR/share.json | 4 + web/i18n/locales/ko-KR/workflow.json | 30 + web/i18n/locales/lo-LA/contacts.json | 9 + web/i18n/locales/lo-LA/share.json | 4 + web/i18n/locales/lo-LA/workflow.json | 30 + web/i18n/locales/nl-NL/contacts.json | 9 + web/i18n/locales/nl-NL/share.json | 4 + web/i18n/locales/nl-NL/workflow.json | 30 + web/i18n/locales/pl-PL/contacts.json | 9 + web/i18n/locales/pl-PL/share.json | 4 + web/i18n/locales/pl-PL/workflow.json | 30 + web/i18n/locales/pt-BR/contacts.json | 9 + web/i18n/locales/pt-BR/share.json | 4 + web/i18n/locales/pt-BR/workflow.json | 30 + web/i18n/locales/ro-RO/contacts.json | 9 + web/i18n/locales/ro-RO/share.json | 4 + web/i18n/locales/ro-RO/workflow.json | 30 + web/i18n/locales/ru-RU/contacts.json | 9 + web/i18n/locales/ru-RU/share.json | 4 + web/i18n/locales/ru-RU/workflow.json | 30 + web/i18n/locales/sl-SI/contacts.json | 9 + web/i18n/locales/sl-SI/share.json | 4 + web/i18n/locales/sl-SI/workflow.json | 30 + web/i18n/locales/th-TH/contacts.json | 9 + web/i18n/locales/th-TH/share.json | 4 + web/i18n/locales/th-TH/workflow.json | 30 + web/i18n/locales/tr-TR/contacts.json | 9 + web/i18n/locales/tr-TR/share.json | 4 + web/i18n/locales/tr-TR/workflow.json | 30 + web/i18n/locales/uk-UA/contacts.json | 9 + web/i18n/locales/uk-UA/share.json | 4 + web/i18n/locales/uk-UA/workflow.json | 30 + web/i18n/locales/vi-VN/contacts.json | 9 + web/i18n/locales/vi-VN/share.json | 4 + web/i18n/locales/vi-VN/workflow.json | 30 + web/i18n/locales/zh-Hans/contacts.json | 358 ++ web/i18n/locales/zh-Hans/share.json | 27 + .../locales/zh-Hans/workflow-human-input.json | 1 + web/i18n/locales/zh-Hans/workflow.json | 128 + web/i18n/locales/zh-Hant/contacts.json | 9 + web/i18n/locales/zh-Hant/share.json | 4 + web/i18n/locales/zh-Hant/workflow.json | 30 + web/i18n/resources.generated.d.ts | 516 ++ web/i18n/resources.ts | 1 + web/scripts/check-i18n.js | 16 +- web/scripts/gen-doc-paths.ts | 2 +- .../share-human-input-submit.spec.ts | 78 + .../share-human-input-upload.spec.ts | 23 +- .../webapp-human-input-submit.spec.ts | 87 + .../workflow-human-input-submit.spec.ts | 38 + web/service/console/client.spec.ts | 22 + web/service/console/index.ts | 15 +- web/service/console/openapi-url.ts | 5 + web/service/console/query-policies.ts | 153 + web/service/human-input-v2.ts | 32 + web/service/share.ts | 42 +- web/service/use-share.ts | 2 +- web/service/webapp-human-input.ts | 44 + web/service/workflow.ts | 9 +- web/types/workflow.ts | 2 + 868 files changed, 145761 insertions(+), 6399 deletions(-) create mode 100644 api/controllers/common/human_input_v2_contracts.py create mode 100644 api/controllers/common/human_input_v2_errors.py create mode 100644 api/controllers/common/human_input_v2_migration.py create mode 100644 api/controllers/console/app/workflow_human_input_v2.py create mode 100644 api/controllers/console/human_input_v2/__init__.py create mode 100644 api/controllers/console/human_input_v2/_common.py create mode 100644 api/controllers/console/human_input_v2/_decorators.py create mode 100644 api/controllers/console/human_input_v2/channel.py create mode 100644 api/controllers/console/human_input_v2/config_version.py create mode 100644 api/controllers/console/human_input_v2/contact.py create mode 100644 api/controllers/console/human_input_v2/entities.py create mode 100644 api/controllers/console/human_input_v2/errors.py create mode 100644 api/controllers/console/human_input_v2/form.py create mode 100644 api/controllers/console/human_input_v2/provider_updates.py create mode 100644 api/controllers/console/human_input_v2/provider_views.py create mode 100644 api/controllers/console/human_input_v2/providers.py create mode 100644 api/controllers/web/human_input_form_access_request.py create mode 100644 api/controllers/web/human_input_v2.py create mode 100644 api/controllers/web/human_input_v2_file_upload.py create mode 100644 api/controllers/web/human_input_v2_im_webhook.py create mode 100644 api/core/human_input/__init__.py create mode 100644 api/core/human_input/enums.py create mode 100644 api/core/human_input_v2/__init__.py create mode 100644 api/core/human_input_v2/entities.py create mode 100644 api/core/human_input_v2/im_integration/__init__.py create mode 100644 api/core/human_input_v2/im_integration/adapters/__init__.py create mode 100644 api/core/human_input_v2/im_integration/adapters/avatar.py create mode 100644 api/core/human_input_v2/im_integration/adapters/credentials.py create mode 100644 api/core/human_input_v2/im_integration/adapters/dingtalk.py create mode 100644 api/core/human_input_v2/im_integration/adapters/dingtalk_redis.py create mode 100644 api/core/human_input_v2/im_integration/adapters/entities.py create mode 100644 api/core/human_input_v2/im_integration/adapters/factory.py create mode 100644 api/core/human_input_v2/im_integration/adapters/feishu_lark.py create mode 100644 api/core/human_input_v2/im_integration/adapters/message_locator.py create mode 100644 api/core/human_input_v2/im_integration/adapters/ms_teams.py create mode 100644 api/core/human_input_v2/im_integration/adapters/protocols.py create mode 100644 api/core/human_input_v2/im_integration/adapters/slack.py create mode 100644 api/core/human_input_v2/im_integration/adapters/wecom.py create mode 100644 api/core/human_input_v2/im_integration/binding_commands.py create mode 100644 api/core/human_input_v2/im_integration/change_log.py create mode 100644 api/core/human_input_v2/im_integration/ports.py create mode 100644 api/core/human_input_v2/im_integration/records.py create mode 100644 api/core/human_input_v2/im_integration/sync_reconciliation.py create mode 100644 api/core/human_input_v2/im_integration/sync_records.py create mode 100644 api/core/human_input_v2/im_message_inbox/__init__.py create mode 100644 api/core/human_input_v2/im_message_inbox/contracts.py create mode 100644 api/core/human_input_v2/resolved_form.py create mode 100644 api/core/human_input_v2/shared/__init__.py create mode 100644 api/core/human_input_v2/shared/values.py create mode 100644 api/core/workflow/nodes/human_input_v2/entities.py create mode 100644 api/core/workflow/nodes/human_input_v2/events.py create mode 100644 api/core/workflow/nodes/human_input_v2/form_compiler.py create mode 100644 api/core/workflow/nodes/human_input_v2/migration.py create mode 100644 api/core/workflow/nodes/human_input_v2/node.py create mode 100644 api/core/workflow/nodes/human_input_v2/presentation.py create mode 100644 api/core/workflow/nodes/human_input_v2/runtime.py create mode 100644 api/factories/file_url_factory.py create mode 100644 api/factories/human_input_v2_form_presentation.py create mode 100644 api/fields/pagination.py create mode 100644 api/fields/timestamp.py create mode 100644 api/migrations/versions/2026_10_26_1646-e8b4a1c6d920_add_human_input_v2.py create mode 100644 api/migrations/versions/2026_10_27_1718-ad1816d009b8_rename_hitlv2_integration_columns.py create mode 100644 api/migrations/versions/2026_10_27_2355-5f44dd69330f_backfill_account_contact_identities.py create mode 100644 api/migrations/versions/2026_10_28_1039-0cc3158b50b0_add_hitlv2_ordered_lookup_indexes.py create mode 100644 api/models/human_input_v2.py create mode 100644 api/repositories/human_input_v2/__init__.py create mode 100644 api/repositories/human_input_v2/audit_repository.py create mode 100644 api/repositories/human_input_v2/channel_credentials.py create mode 100644 api/repositories/human_input_v2/contact.py create mode 100644 api/repositories/human_input_v2/delivery_attempt_repository.py create mode 100644 api/repositories/human_input_v2/delivery_repository.py create mode 100644 api/repositories/human_input_v2/email_channel/__init__.py create mode 100644 api/repositories/human_input_v2/email_channel/entities.py create mode 100644 api/repositories/human_input_v2/email_channel/mappers.py create mode 100644 api/repositories/human_input_v2/email_channel/ports.py create mode 100644 api/repositories/human_input_v2/email_channel/repository.py create mode 100644 api/repositories/human_input_v2/file_upload_repository.py create mode 100644 api/repositories/human_input_v2/form_repository.py create mode 100644 api/repositories/human_input_v2/im_binding_repository.py create mode 100644 api/repositories/human_input_v2/im_channel_repository.py create mode 100644 api/repositories/human_input_v2/im_identity_repository.py create mode 100644 api/repositories/human_input_v2/im_integration/__init__.py create mode 100644 api/repositories/human_input_v2/im_integration/mappers.py create mode 100644 api/repositories/human_input_v2/im_integration/repository.py create mode 100644 api/repositories/human_input_v2/im_message_inbox/__init__.py create mode 100644 api/repositories/human_input_v2/im_message_inbox/mappers.py create mode 100644 api/repositories/human_input_v2/im_message_inbox/repository.py create mode 100644 api/repositories/human_input_v2/recipient_repository.py create mode 100644 api/repositories/human_input_v2/sqlalchemy_audit_repository.py create mode 100644 api/repositories/human_input_v2/sqlalchemy_contact_repository.py create mode 100644 api/repositories/human_input_v2/sqlalchemy_delivery_attempt_repository.py create mode 100644 api/repositories/human_input_v2/sqlalchemy_delivery_repository.py create mode 100644 api/repositories/human_input_v2/sqlalchemy_file_upload_repository.py create mode 100644 api/repositories/human_input_v2/sqlalchemy_form_repository.py create mode 100644 api/repositories/human_input_v2/sqlalchemy_im_binding_repository.py create mode 100644 api/repositories/human_input_v2/sqlalchemy_im_channel_repository.py create mode 100644 api/repositories/human_input_v2/sqlalchemy_im_identity_repository.py create mode 100644 api/repositories/human_input_v2/sqlalchemy_recipient_repository.py create mode 100644 api/services/enterprise/human_input_contact_composition.py create mode 100644 api/services/enterprise/human_input_contact_service.py create mode 100644 api/services/human_input_form_delivery_provider.py create mode 100644 api/services/human_input_v2/__init__.py create mode 100644 api/services/human_input_v2/composition.py create mode 100644 api/services/human_input_v2/contact_service.py create mode 100644 api/services/human_input_v2/credential_update.py create mode 100644 api/services/human_input_v2/delivery_service.py create mode 100644 api/services/human_input_v2/email_channel_management_composition.py create mode 100644 api/services/human_input_v2/email_channel_management_service.py create mode 100644 api/services/human_input_v2/email_client.py create mode 100644 api/services/human_input_v2/email_credential_codec.py create mode 100644 api/services/human_input_v2/email_otp_composition.py create mode 100644 api/services/human_input_v2/email_otp_service.py create mode 100644 api/services/human_input_v2/errors.py create mode 100644 api/services/human_input_v2/file_upload_service.py create mode 100644 api/services/human_input_v2/form_service.py create mode 100644 api/services/human_input_v2/im_card_service.py create mode 100644 api/services/human_input_v2/im_channel_service.py create mode 100644 api/services/human_input_v2/im_channel_service_composition.py create mode 100644 api/services/human_input_v2/im_contact_sync/__init__.py create mode 100644 api/services/human_input_v2/im_contact_sync/binding_service.py create mode 100644 api/services/human_input_v2/im_contact_sync/composition.py create mode 100644 api/services/human_input_v2/im_contact_sync/coordinator.py create mode 100644 api/services/human_input_v2/im_contact_sync/errors.py create mode 100644 api/services/human_input_v2/im_contact_sync/reconciliation.py create mode 100644 api/services/human_input_v2/im_contact_sync/service.py create mode 100644 api/services/human_input_v2/im_contact_sync/worker.py create mode 100644 api/services/human_input_v2/im_credential_codec.py create mode 100644 api/services/human_input_v2/im_message_inbox/__init__.py create mode 100644 api/services/human_input_v2/im_message_inbox/processor.py create mode 100644 api/services/human_input_v2/im_message_inbox/sink.py create mode 100644 api/services/human_input_v2/im_message_inbox/storage.py create mode 100644 api/services/human_input_v2/im_message_inbox/telemetry.py create mode 100644 api/services/human_input_v2/im_tenant_credential_cipher.py create mode 100644 api/services/human_input_v2/im_webhook_runtime.py create mode 100644 api/services/human_input_v2/message_template_test_composition.py create mode 100644 api/services/human_input_v2/message_template_test_service.py create mode 100644 api/services/human_input_v2/node_data_migration.py create mode 100644 api/services/human_input_v2/resend_channel.py create mode 100644 api/services/human_input_v2/runtime.py create mode 100644 api/services/human_input_v2/submission_service.py create mode 100644 api/services/human_input_v2/workspace_member_email_lookup.py create mode 100644 api/services/message_extra_content_service.py create mode 100644 api/services/workflow_pause_service.py create mode 100644 api/tasks/human_input_im_card_tasks.py create mode 100644 api/tasks/im_contact_sync_tasks.py create mode 100644 api/tasks/im_message_inbox_tasks.py create mode 100644 api/tests/e2e_tests/containers/controllers/console/test_human_input_channel_api.py create mode 100644 api/tests/e2e_tests/containers/controllers/console/workspace/test_im_contact_sync_api.py create mode 100644 api/tests/e2e_tests/containers/controllers/console/workspace/test_im_contact_sync_live.py create mode 100644 api/tests/e2e_tests/containers/core/human_input_v2/im_provider/test_dingtalk_live.py create mode 100644 api/tests/e2e_tests/containers/core/human_input_v2/im_provider/test_dingtalk_redis_token_provider.py create mode 100644 api/tests/e2e_tests/containers/core/human_input_v2/im_provider/test_feishu_lark_card_event_flow.py create mode 100644 api/tests/e2e_tests/containers/core/human_input_v2/im_provider/test_ms_teams_card_event_flow.py create mode 100644 api/tests/e2e_tests/containers/core/human_input_v2/im_provider/test_ms_teams_external_integration.py create mode 100644 api/tests/e2e_tests/containers/core/human_input_v2/im_provider/test_slack.py create mode 100644 api/tests/e2e_tests/containers/core/human_input_v2/im_provider/test_wecom_live.py create mode 100644 api/tests/e2e_tests/containers/core/repositories/test_human_input_v2_form_repository.py create mode 100644 api/tests/e2e_tests/containers/human_input_v2/test_email_otp_redis.py create mode 100644 api/tests/e2e_tests/containers/libs/test_redis_counter.py create mode 100644 api/tests/e2e_tests/containers/models/test_pydantic_model_json.py create mode 100644 api/tests/e2e_tests/containers/repositories/human_input_v2/test_email_channel_repository.py create mode 100644 api/tests/e2e_tests/containers/repositories/human_input_v2/test_im_channel_repository.py create mode 100644 api/tests/e2e_tests/containers/repositories/human_input_v2/test_im_message_inbox_repository.py create mode 100644 api/tests/e2e_tests/containers/test_container_fixtures.py delete mode 100644 api/tests/e2e_tests/contracts/migrations/test_knowledge_fs_migration_chain.py create mode 100644 api/tests/fixtures/im_provider/README.md create mode 100644 api/tests/fixtures/im_provider/dingtalk/sanitized_protocol.json create mode 100644 api/tests/fixtures/im_provider/feishu_lark/sanitized_directory_user.json create mode 100644 api/tests/fixtures/im_provider/ms_teams/sanitized_avatar_not_found.json create mode 100644 api/tests/fixtures/im_provider/ms_teams/sanitized_avatar_success.json create mode 100644 api/tests/fixtures/im_provider/ms_teams_webhook_activity.json create mode 100644 api/tests/fixtures/im_provider/slack/sanitized_directory_user.json create mode 100644 api/tests/fixtures/im_provider/wecom/sanitized_protocol.json create mode 100644 api/tests/integration_tests/repositories/human_input_v2/test_im_control_plane_concurrency.py create mode 100644 api/tests/integration_tests/repositories/human_input_v2/test_portable_delivery_binding_writes.py create mode 100644 api/tests/integration_tests/repositories/human_input_v2/test_stale_im_sync_runs.py create mode 100644 api/tests/unit_tests/controllers/common/test_human_input_v2_contracts.py create mode 100644 api/tests/unit_tests/controllers/common/test_human_input_v2_migration.py create mode 100644 api/tests/unit_tests/controllers/console/human_input_v2/test_contact.py create mode 100644 api/tests/unit_tests/controllers/console/test_human_input_channel_api.py create mode 100644 api/tests/unit_tests/controllers/console/test_human_input_channel_config_version.py create mode 100644 api/tests/unit_tests/controllers/console/test_human_input_channel_openapi.py create mode 100644 api/tests/unit_tests/controllers/console/test_human_input_channel_providers.py create mode 100644 api/tests/unit_tests/controllers/console/test_human_input_channel_wire.py create mode 100644 api/tests/unit_tests/controllers/console/test_human_input_message_template.py create mode 100644 api/tests/unit_tests/controllers/console/test_human_input_v2_form_read.py create mode 100644 api/tests/unit_tests/controllers/console/workspace/test_contact_transactions.py create mode 100644 api/tests/unit_tests/controllers/console/workspace/test_human_input_node_data_migration.py create mode 100644 api/tests/unit_tests/controllers/console/workspace/test_im_contact_sync_api.py create mode 100644 api/tests/unit_tests/controllers/service_api/app/test_human_input_form_compatibility.py create mode 100644 api/tests/unit_tests/controllers/web/test_human_input_form_v2_stub.py create mode 100644 api/tests/unit_tests/controllers/web/test_human_input_v2_form_read.py create mode 100644 api/tests/unit_tests/controllers/web/test_human_input_v2_otp_access.py create mode 100644 api/tests/unit_tests/core/human_input_v2/im_message_inbox/test_contracts.py create mode 100644 api/tests/unit_tests/core/human_input_v2/im_provider/fixtures/feishu_lark_card_action_stream.json create mode 100644 api/tests/unit_tests/core/human_input_v2/im_provider/fixtures/feishu_lark_card_action_webhook.json create mode 100644 api/tests/unit_tests/core/human_input_v2/im_provider/fixtures/feishu_lark_card_action_without_inputs.json create mode 100644 api/tests/unit_tests/core/human_input_v2/im_provider/fixtures/lark/README.md create mode 100644 api/tests/unit_tests/core/human_input_v2/im_provider/fixtures/lark/sanitized_card_action_stream.json create mode 100644 api/tests/unit_tests/core/human_input_v2/im_provider/fixtures/lark/sanitized_card_action_webhook.json create mode 100644 api/tests/unit_tests/core/human_input_v2/im_provider/fixtures/lark/sanitized_protocol.json create mode 100644 api/tests/unit_tests/core/human_input_v2/im_provider/fixtures/lark/sanitized_stream_ack.json create mode 100644 api/tests/unit_tests/core/human_input_v2/im_provider/fixtures/lark/sanitized_url_challenge.json create mode 100644 api/tests/unit_tests/core/human_input_v2/im_provider/fixtures/ms_teams_card_submit.json create mode 100644 api/tests/unit_tests/core/human_input_v2/im_provider/fixtures/slack_block_actions_live_capture_socket_mode.json create mode 100644 api/tests/unit_tests/core/human_input_v2/im_provider/fixtures/slack_block_actions_radio_buttons_webhook.json create mode 100644 api/tests/unit_tests/core/human_input_v2/im_provider/fixtures/slack_block_actions_socket_mode.json create mode 100644 api/tests/unit_tests/core/human_input_v2/im_provider/fixtures/slack_block_actions_static_select_socket_mode.json create mode 100644 api/tests/unit_tests/core/human_input_v2/im_provider/fixtures/slack_block_actions_webhook.json create mode 100644 api/tests/unit_tests/core/human_input_v2/im_provider/test_avatar_batch.py create mode 100644 api/tests/unit_tests/core/human_input_v2/im_provider/test_contracts.py create mode 100644 api/tests/unit_tests/core/human_input_v2/im_provider/test_dingtalk_adapter.py create mode 100644 api/tests/unit_tests/core/human_input_v2/im_provider/test_dingtalk_redis_token_provider.py create mode 100644 api/tests/unit_tests/core/human_input_v2/im_provider/test_dingtalk_sdk_boundary.py create mode 100644 api/tests/unit_tests/core/human_input_v2/im_provider/test_feishu_lark_adapter.py create mode 100644 api/tests/unit_tests/core/human_input_v2/im_provider/test_feishu_lark_branches.py create mode 100644 api/tests/unit_tests/core/human_input_v2/im_provider/test_feishu_lark_card_events.py create mode 100644 api/tests/unit_tests/core/human_input_v2/im_provider/test_feishu_lark_credentials.py create mode 100644 api/tests/unit_tests/core/human_input_v2/im_provider/test_feishu_lark_sdk_boundary.py create mode 100644 api/tests/unit_tests/core/human_input_v2/im_provider/test_feishu_lark_stream.py create mode 100644 api/tests/unit_tests/core/human_input_v2/im_provider/test_feishu_lark_url_challenge_contract.py create mode 100644 api/tests/unit_tests/core/human_input_v2/im_provider/test_feishu_lark_webhook.py create mode 100644 api/tests/unit_tests/core/human_input_v2/im_provider/test_lark_sdk_boundary.py create mode 100644 api/tests/unit_tests/core/human_input_v2/im_provider/test_message_locator_conformance.py create mode 100644 api/tests/unit_tests/core/human_input_v2/im_provider/test_ms_teams_adapter.py create mode 100644 api/tests/unit_tests/core/human_input_v2/im_provider/test_ms_teams_card_events.py create mode 100644 api/tests/unit_tests/core/human_input_v2/im_provider/test_ms_teams_credential_alignment.py create mode 100644 api/tests/unit_tests/core/human_input_v2/im_provider/test_ms_teams_sdk_boundary.py create mode 100644 api/tests/unit_tests/core/human_input_v2/im_provider/test_slack_adapter.py create mode 100644 api/tests/unit_tests/core/human_input_v2/im_provider/test_slack_card_events.py create mode 100644 api/tests/unit_tests/core/human_input_v2/im_provider/test_slack_requirement_conformance.py create mode 100644 api/tests/unit_tests/core/human_input_v2/im_provider/test_slack_sdk_boundary.py create mode 100644 api/tests/unit_tests/core/human_input_v2/im_provider/test_wecom_adapter.py create mode 100644 api/tests/unit_tests/core/human_input_v2/im_provider/test_wecom_credential_alignment.py create mode 100644 api/tests/unit_tests/core/human_input_v2/im_provider/test_wecom_directory.py create mode 100644 api/tests/unit_tests/core/human_input_v2/im_provider/test_wecom_messaging.py create mode 100644 api/tests/unit_tests/core/human_input_v2/im_provider/test_wecom_sdk_boundary.py create mode 100644 api/tests/unit_tests/core/human_input_v2/test_im_reconciliation_change_log.py create mode 100644 api/tests/unit_tests/core/human_input_v2/test_im_sync_reconciliation.py create mode 100644 api/tests/unit_tests/core/human_input_v2/test_im_sync_reconciliation_contract.py create mode 100644 api/tests/unit_tests/core/human_input_v2/test_im_sync_reconciliation_properties.py create mode 100644 api/tests/unit_tests/core/human_input_v2/test_import_boundaries.py create mode 100644 api/tests/unit_tests/core/human_input_v2/test_resolved_form.py create mode 100644 api/tests/unit_tests/core/human_input_v2/test_shared_values.py create mode 100644 api/tests/unit_tests/core/workflow/nodes/human_input_v2/test_form_compiler.py create mode 100644 api/tests/unit_tests/core/workflow/nodes/human_input_v2/test_migration.py create mode 100644 api/tests/unit_tests/core/workflow/nodes/human_input_v2/test_node.py create mode 100644 api/tests/unit_tests/factories/test_file_url_factory.py create mode 100644 api/tests/unit_tests/factories/test_human_input_v2_form_presentation.py create mode 100644 api/tests/unit_tests/fields/test_timestamp.py delete mode 100644 api/tests/unit_tests/migrations/versions/test_2025_07_02_2332-1c9ba48be8e4_add_uuidv7_function_in_sql.py delete mode 100644 api/tests/unit_tests/migrations/versions/test_2026_07_21_1200-a4e7c2f91b30_add_knowledge_fs_control_plane.py delete mode 100644 api/tests/unit_tests/migrations/versions/test_2026_07_21_1300-b7f2a9d41c60_add_knowledge_fs_cutover.py delete mode 100644 api/tests/unit_tests/migrations/versions/test_2026_07_21_1400-c8e31b7d52a4_add_knowledge_fs_cleanup_authorization.py delete mode 100644 api/tests/unit_tests/migrations/versions/test_2026_07_21_1500-d4f6e8a1c305_add_knowledge_fs_remote_freeze_evidence.py delete mode 100644 api/tests/unit_tests/migrations/versions/test_2026_07_21_1600-e5a7c9b2d416_add_knowledge_fs_cleanup_completion.py delete mode 100644 api/tests/unit_tests/migrations/versions/test_2026_07_23_0203-f6e4c5686857_replace_agent_runtime_sessions_with_.py delete mode 100644 api/tests/unit_tests/migrations/versions/test_2026_07_28_2331-e4708db55c1d_make_home_snapshot_references_nullable.py delete mode 100644 api/tests/unit_tests/migrations/versions/test_2026_08_10_1200-7c1e9a4b2d60_add_knowledge_fs_staged_uploads.py delete mode 100644 api/tests/unit_tests/migrations/versions/test_2026_08_13_1200-9d4e6f8a1b2c_add_knowledge_fs_space_tag_bindings.py delete mode 100644 api/tests/unit_tests/migrations/versions/test_2026_08_17_1200-4f8b2c7d9e10_add_knowledge_fs_icon_background.py delete mode 100644 api/tests/unit_tests/migrations/versions/test_2026_08_17_1200-f3a8c1d7e920_add_knowledge_fs_upgrade_jobs.py delete mode 100644 api/tests/unit_tests/migrations/versions/test_2026_08_17_1740-89919253ca7a_remove_agent_drive.py delete mode 100644 api/tests/unit_tests/migrations/versions/test_2026_08_20_0938-fbdfcf5f5a6e_clean_legacy_agent_soul_files.py delete mode 100644 api/tests/unit_tests/migrations/versions/test_2026_08_21_1422-925e75620b69_simplify_agent_v2_output_contract.py delete mode 100644 api/tests/unit_tests/migrations/versions/test_2026_08_25_1200-9b7c6d5e4f3a_add_normalized_email_to_accounts.py delete mode 100644 api/tests/unit_tests/migrations/versions/test_2026_08_27_1200-5578e028b2f2_migrate_legacy_model_types.py delete mode 100644 api/tests/unit_tests/migrations/versions/test_2026_09_03_1000-9a4e7d1c2b60_add_dataset_api_token_binding_resource_type.py delete mode 100644 api/tests/unit_tests/migrations/versions/test_2026_09_10_1200-d8e4a6b1c902_expand_normalized_email_providers.py delete mode 100644 api/tests/unit_tests/migrations/versions/test_2026_10_10_1200-c6f4a90d8b31_add_workflow_pause_task_id.py delete mode 100644 api/tests/unit_tests/migrations/versions/test_2026_10_15_0330-a17c2980f3e6_add_application_agent_tracing.py delete mode 100644 api/tests/unit_tests/migrations/versions/test_2026_10_21_1300-3edd6cd27f54_limit_active_knowledge_fs_upgrades_on_mysql.py delete mode 100644 api/tests/unit_tests/migrations/versions/test_2026_10_21_1400-a8c2e6f4b901_add_upgrade_availability_checkpoint.py delete mode 100644 api/tests/unit_tests/migrations/versions/test_2026_10_21_1500-b9d3f7a5c102_add_upgrade_reconciliation_lease.py create mode 100644 api/tests/unit_tests/models/test_human_input_im_identity.py create mode 100644 api/tests/unit_tests/models/test_pydantic_model_json.py create mode 100644 api/tests/unit_tests/repositories/human_input_v2/email_channel/test_repository.py create mode 100644 api/tests/unit_tests/repositories/human_input_v2/im_integration/test_im_sync_queries.py create mode 100644 api/tests/unit_tests/repositories/human_input_v2/im_integration/test_mappers.py create mode 100644 api/tests/unit_tests/repositories/human_input_v2/im_integration/test_repository.py create mode 100644 api/tests/unit_tests/repositories/human_input_v2/im_message_inbox/test_repository.py create mode 100644 api/tests/unit_tests/repositories/human_input_v2/test_contact_contracts.py create mode 100644 api/tests/unit_tests/repositories/human_input_v2/test_contact_repository.py create mode 100644 api/tests/unit_tests/repositories/human_input_v2/test_form_repository.py create mode 100644 api/tests/unit_tests/repositories/human_input_v2/test_im_binding_repository.py create mode 100644 api/tests/unit_tests/repositories/human_input_v2/test_im_channel_repository_contract.py create mode 100644 api/tests/unit_tests/repositories/human_input_v2/test_im_identity_repository.py create mode 100644 api/tests/unit_tests/repositories/human_input_v2/test_recipient_delivery_repositories.py create mode 100644 api/tests/unit_tests/repositories/human_input_v2/test_sqlalchemy_form_repository.py create mode 100644 api/tests/unit_tests/services/human_input_v2/im_message_inbox/test_processor.py create mode 100644 api/tests/unit_tests/services/human_input_v2/im_message_inbox/test_provider_composition.py create mode 100644 api/tests/unit_tests/services/human_input_v2/im_message_inbox/test_sink.py create mode 100644 api/tests/unit_tests/services/human_input_v2/im_message_inbox/test_telemetry.py create mode 100644 api/tests/unit_tests/services/human_input_v2/test_composition.py create mode 100644 api/tests/unit_tests/services/human_input_v2/test_contact_capability_boundaries.py create mode 100644 api/tests/unit_tests/services/human_input_v2/test_contact_im_binding_service.py create mode 100644 api/tests/unit_tests/services/human_input_v2/test_email_credential_codec.py create mode 100644 api/tests/unit_tests/services/human_input_v2/test_email_otp_service.py create mode 100644 api/tests/unit_tests/services/human_input_v2/test_file_upload_service.py create mode 100644 api/tests/unit_tests/services/human_input_v2/test_form_delivery_runtime.py create mode 100644 api/tests/unit_tests/services/human_input_v2/test_im_channel_reconciliation_service.py create mode 100644 api/tests/unit_tests/services/human_input_v2/test_im_channel_service.py create mode 100644 api/tests/unit_tests/services/human_input_v2/test_im_contact_sync_composition.py create mode 100644 api/tests/unit_tests/services/human_input_v2/test_im_contact_sync_contracts.py create mode 100644 api/tests/unit_tests/services/human_input_v2/test_im_contact_sync_worker.py create mode 100644 api/tests/unit_tests/services/human_input_v2/test_im_provider_adapter.py create mode 100644 api/tests/unit_tests/services/human_input_v2/test_im_sync_service.py create mode 100644 api/tests/unit_tests/services/human_input_v2/test_im_tenant_credential_cipher.py create mode 100644 api/tests/unit_tests/services/human_input_v2/test_im_webhook_runtime.py create mode 100644 api/tests/unit_tests/services/human_input_v2/test_message_template_test_service.py create mode 100644 api/tests/unit_tests/services/human_input_v2/test_node_data_migration.py create mode 100644 api/tests/unit_tests/services/human_input_v2/test_runtime_service_api.py create mode 100644 api/tests/unit_tests/services/human_input_v2/test_submission_service.py create mode 100644 api/tests/unit_tests/services/test_feature_service_im.py create mode 100644 api/tests/unit_tests/services/test_human_input_email_channel_management_service.py create mode 100644 api/tests/unit_tests/services/test_human_input_form_delivery_provider.py create mode 100644 api/tests/unit_tests/services/test_human_input_resend_channel.py create mode 100644 api/tests/unit_tests/services/test_workflow_tool_source_service.py create mode 100644 api/tests/unit_tests/tasks/test_im_message_inbox_tasks.py create mode 100644 api/tests/unit_tests/test_worker_entrypoint.py create mode 100644 web/app/(commonLayout)/contacts/__tests__/page.spec.tsx create mode 100644 web/app/(commonLayout)/contacts/page.tsx create mode 100644 web/app/(humanInputLayout)/__tests__/release-gate.spec.tsx create mode 100644 web/app/(humanInputLayout)/form-v2/[token]/__tests__/form.spec.tsx create mode 100644 web/app/(humanInputLayout)/form-v2/[token]/form.tsx create mode 100644 web/app/(humanInputLayout)/form-v2/[token]/page.tsx create mode 100644 web/app/(humanInputLayout)/human-input/[formToken]/page.tsx create mode 100644 web/app/components/app/text-generate/item/__tests__/index.hitl-v2.spec.tsx create mode 100644 web/app/components/base/chat/chat-with-history/__tests__/chat-wrapper.hitl-v2.spec.tsx create mode 100644 web/app/components/base/chat/chat/answer/human-input-content/__tests__/executed-action.spec.tsx create mode 100644 web/app/components/base/chat/embedded-chatbot/__tests__/chat-wrapper.hitl-v2.spec.tsx create mode 100644 web/app/components/base/prompt-editor/__tests__/focus-transfer.browser.spec.tsx create mode 100644 web/app/components/header/account-setting/__tests__/index.hitl-v2.spec.tsx create mode 100644 web/app/components/header/account-setting/members-page/invite-modal/__tests__/index.hitl-v2.spec.tsx create mode 100644 web/app/components/share/text-generation/result/__tests__/workflow-events-subscription.spec.ts create mode 100644 web/app/components/share/text-generation/result/hooks/__tests__/use-result-sender.hitl-v2.spec.ts create mode 100644 web/app/components/share/text-generation/result/hooks/__tests__/use-webapp-workflow-events.spec.ts create mode 100644 web/app/components/share/text-generation/result/hooks/use-webapp-workflow-events.ts create mode 100644 web/app/components/share/text-generation/result/workflow-events-subscription.ts create mode 100644 web/app/components/workflow/block-selector/snippets/__tests__/use-insert-snippet.hitl-v2.spec.tsx create mode 100644 web/app/components/workflow/header/__tests__/history-status-title.spec.tsx create mode 100644 web/app/components/workflow/hooks/__tests__/use-nodes-interactions.hitl-v2.spec.ts create mode 100644 web/app/components/workflow/nodes/__tests__/index.hitl-v2.spec.tsx create mode 100644 web/app/components/workflow/nodes/_base/components/workflow-panel/last-run/__tests__/use-last-run.hitl-v2.spec.ts create mode 100644 web/app/components/workflow/nodes/human-input-router.tsx create mode 100644 web/app/components/workflow/nodes/human-input-v2/README.md create mode 100644 web/app/components/workflow/nodes/human-input-v2/__tests__/channel-fixtures.ts create mode 100644 web/app/components/workflow/nodes/human-input-v2/__tests__/contact-provider.spec.ts create mode 100644 web/app/components/workflow/nodes/human-input-v2/__tests__/debug-mode.spec.tsx create mode 100644 web/app/components/workflow/nodes/human-input-v2/__tests__/default.spec.ts create mode 100644 web/app/components/workflow/nodes/human-input-v2/__tests__/fixtures/contact-provider.ts create mode 100644 web/app/components/workflow/nodes/human-input-v2/__tests__/form-content-restoration.spec.tsx create mode 100644 web/app/components/workflow/nodes/human-input-v2/__tests__/message-template-body.spec.tsx create mode 100644 web/app/components/workflow/nodes/human-input-v2/__tests__/message-template.spec.tsx create mode 100644 web/app/components/workflow/nodes/human-input-v2/__tests__/node.spec.tsx create mode 100644 web/app/components/workflow/nodes/human-input-v2/__tests__/panel.spec.tsx create mode 100644 web/app/components/workflow/nodes/human-input-v2/__tests__/recipient-utils.spec.ts create mode 100644 web/app/components/workflow/nodes/human-input-v2/__tests__/recipients.spec.tsx create mode 100644 web/app/components/workflow/nodes/human-input-v2/__tests__/router.spec.tsx create mode 100644 web/app/components/workflow/nodes/human-input-v2/__tests__/types.spec.ts create mode 100644 web/app/components/workflow/nodes/human-input-v2/__tests__/use-config.spec.ts create mode 100644 web/app/components/workflow/nodes/human-input-v2/components/debug-mode.tsx create mode 100644 web/app/components/workflow/nodes/human-input-v2/components/message-template-body.tsx create mode 100644 web/app/components/workflow/nodes/human-input-v2/components/message-template.tsx create mode 100644 web/app/components/workflow/nodes/human-input-v2/components/recipient-avatar.tsx create mode 100644 web/app/components/workflow/nodes/human-input-v2/components/recipient-contact-preview.tsx create mode 100644 web/app/components/workflow/nodes/human-input-v2/components/recipients.tsx create mode 100644 web/app/components/workflow/nodes/human-input-v2/contact-provider.ts create mode 100644 web/app/components/workflow/nodes/human-input-v2/default.ts create mode 100644 web/app/components/workflow/nodes/human-input-v2/disabled.tsx create mode 100644 web/app/components/workflow/nodes/human-input-v2/message-template-utils.ts create mode 100644 web/app/components/workflow/nodes/human-input-v2/migration/README.md create mode 100644 web/app/components/workflow/nodes/human-input-v2/migration/__tests__/executor.spec.ts create mode 100644 web/app/components/workflow/nodes/human-input-v2/migration/__tests__/fixtures/v1-batch.json create mode 100644 web/app/components/workflow/nodes/human-input-v2/migration/__tests__/locales.spec.ts create mode 100644 web/app/components/workflow/nodes/human-input-v2/migration/__tests__/migration-banner.spec.tsx create mode 100644 web/app/components/workflow/nodes/human-input-v2/migration/__tests__/migration-dialog.spec.tsx create mode 100644 web/app/components/workflow/nodes/human-input-v2/migration/__tests__/policy.spec.ts create mode 100644 web/app/components/workflow/nodes/human-input-v2/migration/__tests__/provider.spec.tsx create mode 100644 web/app/components/workflow/nodes/human-input-v2/migration/context.ts create mode 100644 web/app/components/workflow/nodes/human-input-v2/migration/executor.ts create mode 100644 web/app/components/workflow/nodes/human-input-v2/migration/migration-banner.tsx create mode 100644 web/app/components/workflow/nodes/human-input-v2/migration/migration-dialog.tsx create mode 100644 web/app/components/workflow/nodes/human-input-v2/migration/policy.ts create mode 100644 web/app/components/workflow/nodes/human-input-v2/migration/provider.tsx create mode 100644 web/app/components/workflow/nodes/human-input-v2/migration/types.ts create mode 100644 web/app/components/workflow/nodes/human-input-v2/node.tsx create mode 100644 web/app/components/workflow/nodes/human-input-v2/panel.tsx create mode 100644 web/app/components/workflow/nodes/human-input-v2/recipient-utils.ts create mode 100644 web/app/components/workflow/nodes/human-input-v2/types.ts create mode 100644 web/app/components/workflow/nodes/human-input-v2/use-config.ts create mode 100644 web/app/components/workflow/nodes/human-input-v2/use-configured-channels.ts create mode 100644 web/app/components/workflow/nodes/human-input/__tests__/human-input.hitl-v2.spec.tsx create mode 100644 web/app/components/workflow/nodes/human-input/__tests__/panel.hitl-v2.spec.tsx create mode 100644 web/app/components/workflow/nodes/human-input/components/__tests__/form-content-preview.hitl-v2.spec.tsx create mode 100644 web/app/components/workflow/nodes/human-input/components/__tests__/form-content.hitl-v2.browser.spec.tsx create mode 100644 web/app/components/workflow/nodes/human-input/components/__tests__/single-run-form.hitl-v2.spec.tsx create mode 100644 web/app/components/workflow/nodes/human-input/components/__tests__/timeout.hitl-v2.spec.tsx create mode 100644 web/app/components/workflow/nodes/human-input/hooks/__tests__/use-form-content.hitl-v2.spec.ts create mode 100644 web/app/components/workflow/nodes/human-input/hooks/__tests__/use-single-run-form-params.hitl-v2.spec.ts create mode 100644 web/app/components/workflow/nodes/human-input/shared/__tests__/panel-sections.spec.tsx create mode 100644 web/app/components/workflow/nodes/human-input/shared/constants.ts create mode 100644 web/app/components/workflow/nodes/human-input/shared/default-utils.ts create mode 100644 web/app/components/workflow/nodes/human-input/shared/node-branches.tsx create mode 100644 web/app/components/workflow/nodes/human-input/shared/panel-sections.tsx create mode 100644 web/app/components/workflow/nodes/human-input/shared/types.ts create mode 100644 web/app/components/workflow/nodes/human-input/shared/use-config.ts rename web/app/components/workflow/nodes/human-input/{hooks => shared}/use-form-content.ts (73%) create mode 100644 web/app/components/workflow/panel/__tests__/workflow-preview.hitl-v2.spec.tsx create mode 100644 web/app/components/workflow/utils/__tests__/elk-layout.hitl-v2.spec.ts create mode 100644 web/features/contacts/im-capability.ts create mode 100644 web/features/contacts/im-platform/README.md create mode 100644 web/features/contacts/im-platform/__tests__/feature-flag.spec.ts create mode 100644 web/features/contacts/im-platform/__tests__/fixtures/channel-client.ts create mode 100644 web/features/contacts/im-platform/__tests__/fixtures/repository.ts create mode 100644 web/features/contacts/im-platform/__tests__/fixtures/scenarios.ts create mode 100644 web/features/contacts/im-platform/__tests__/management-surface.spec.tsx create mode 100644 web/features/contacts/im-platform/__tests__/mock-repository.spec.ts create mode 100644 web/features/contacts/im-platform/__tests__/provider-form-adapters.spec.ts create mode 100644 web/features/contacts/im-platform/__tests__/repository-hooks.spec.tsx create mode 100644 web/features/contacts/im-platform/__tests__/sync-details-dialog.spec.tsx create mode 100644 web/features/contacts/im-platform/account-setting-page.tsx create mode 100644 web/features/contacts/im-platform/binding-dialog.tsx create mode 100644 web/features/contacts/im-platform/callback-setup.tsx create mode 100644 web/features/contacts/im-platform/composition-context.ts create mode 100644 web/features/contacts/im-platform/composition.tsx create mode 100644 web/features/contacts/im-platform/context.ts create mode 100644 web/features/contacts/im-platform/credential-draft.ts create mode 100644 web/features/contacts/im-platform/email-config-dialog.tsx create mode 100644 web/features/contacts/im-platform/feature-flag.ts create mode 100644 web/features/contacts/im-platform/hooks.ts create mode 100644 web/features/contacts/im-platform/management-surface.tsx create mode 100644 web/features/contacts/im-platform/provider-card.tsx create mode 100644 web/features/contacts/im-platform/provider-form-adapters.ts create mode 100644 web/features/contacts/im-platform/query-keys.ts create mode 100644 web/features/contacts/im-platform/repository.ts create mode 100644 web/features/contacts/im-platform/sync-details-dialog.tsx create mode 100644 web/features/contacts/im-platform/sync-run-url-state.ts create mode 100644 web/features/contacts/im-platform/sync-section.tsx create mode 100644 web/features/contacts/im-platform/types.ts create mode 100644 web/features/contacts/management/README.md create mode 100644 web/features/contacts/management/__tests__/composition.spec.tsx create mode 100644 web/features/contacts/management/__tests__/contact-im-channels.spec.tsx create mode 100644 web/features/contacts/management/__tests__/directory-page.browser.spec.tsx create mode 100644 web/features/contacts/management/__tests__/directory-page.spec.tsx create mode 100644 web/features/contacts/management/__tests__/external-contact-dialog.spec.tsx create mode 100644 web/features/contacts/management/__tests__/feature-flag.spec.ts create mode 100644 web/features/contacts/management/__tests__/fixtures/composition.tsx create mode 100644 web/features/contacts/management/__tests__/fixtures/repository.ts create mode 100644 web/features/contacts/management/__tests__/fixtures/scenarios.ts create mode 100644 web/features/contacts/management/__tests__/member-removal-content.spec.tsx create mode 100644 web/features/contacts/management/__tests__/mock-repository.spec.ts create mode 100644 web/features/contacts/management/__tests__/platform-contact-picker-dialog.spec.tsx create mode 100644 web/features/contacts/management/assets/dingtalk.svg create mode 100644 web/features/contacts/management/assets/email.svg create mode 100644 web/features/contacts/management/assets/feishu.svg create mode 100644 web/features/contacts/management/assets/slack.svg create mode 100644 web/features/contacts/management/assets/user-community.svg create mode 100644 web/features/contacts/management/channel-icon.tsx create mode 100644 web/features/contacts/management/channel-utils.ts create mode 100644 web/features/contacts/management/composition-context.ts create mode 100644 web/features/contacts/management/composition.tsx create mode 100644 web/features/contacts/management/contact-details-panel.tsx create mode 100644 web/features/contacts/management/directory-page.tsx create mode 100644 web/features/contacts/management/external-contact-dialog.tsx create mode 100644 web/features/contacts/management/feature-flag.ts create mode 100644 web/features/contacts/management/hooks.ts create mode 100644 web/features/contacts/management/member-invite-contact-upgrade-dialog.tsx create mode 100644 web/features/contacts/management/member-removal-content.tsx create mode 100644 web/features/contacts/management/platform-contact-picker-dialog.tsx create mode 100644 web/features/contacts/management/platform-contact-upgrade-dialog.tsx create mode 100644 web/features/contacts/management/query-keys.ts create mode 100644 web/features/contacts/management/relative-time.ts create mode 100644 web/features/contacts/management/repository.ts create mode 100644 web/features/contacts/management/types.ts create mode 100644 web/features/human-input/README.md create mode 100644 web/features/human-input/console/README.md create mode 100644 web/features/human-input/console/__tests__/definition.spec.ts create mode 100644 web/features/human-input/console/__tests__/form.spec.tsx create mode 100644 web/features/human-input/console/definition.ts create mode 100644 web/features/human-input/console/form.tsx create mode 100644 web/features/human-input/console/links.ts create mode 100644 web/features/human-input/form/README.md create mode 100644 web/features/human-input/form/__tests__/loaded-form-content.spec.tsx rename web/{app/(humanInputLayout)/form/[token] => features/human-input/form}/branding-footer.tsx (71%) rename web/{app/(humanInputLayout)/form/[token] => features/human-input/form}/form-status-card.tsx (67%) create mode 100644 web/features/human-input/form/loaded-form-content.tsx create mode 100644 web/features/human-input/form/normalize-legacy-definition.ts create mode 100644 web/features/human-input/form/types.ts create mode 100644 web/features/human-input/public/README.md create mode 100644 web/features/human-input/public/__tests__/form.spec.tsx create mode 100644 web/features/human-input/public/__tests__/locales.spec.ts create mode 100644 web/features/human-input/public/__tests__/mock-transport.spec.ts create mode 100644 web/features/human-input/public/__tests__/real-upload.spec.ts create mode 100644 web/features/human-input/public/__tests__/session-reducer.spec.ts create mode 100644 web/features/human-input/public/__tests__/upload-strategies.spec.ts create mode 100644 web/features/human-input/public/__tests__/use-form-session.spec.tsx create mode 100644 web/features/human-input/public/errors.ts create mode 100644 web/features/human-input/public/form.tsx create mode 100644 web/features/human-input/public/legacy-upload.ts create mode 100644 web/features/human-input/public/mock-transport.ts create mode 100644 web/features/human-input/public/otp-verification.tsx create mode 100644 web/features/human-input/public/real-transport.ts create mode 100644 web/features/human-input/public/session-reducer.ts create mode 100644 web/features/human-input/public/transport-selector.ts create mode 100644 web/features/human-input/public/types.ts create mode 100644 web/features/human-input/public/upload.ts create mode 100644 web/features/human-input/public/use-form-session.ts create mode 100644 web/i18n/locales/ar-TN/contacts.json create mode 100644 web/i18n/locales/az-AZ/contacts.json create mode 100644 web/i18n/locales/de-DE/contacts.json create mode 100644 web/i18n/locales/en-US/contacts.json create mode 100644 web/i18n/locales/es-ES/contacts.json create mode 100644 web/i18n/locales/fa-IR/contacts.json create mode 100644 web/i18n/locales/fr-FR/contacts.json create mode 100644 web/i18n/locales/hi-IN/contacts.json create mode 100644 web/i18n/locales/id-ID/contacts.json create mode 100644 web/i18n/locales/it-IT/contacts.json create mode 100644 web/i18n/locales/ja-JP/contacts.json create mode 100644 web/i18n/locales/ko-KR/contacts.json create mode 100644 web/i18n/locales/lo-LA/contacts.json create mode 100644 web/i18n/locales/nl-NL/contacts.json create mode 100644 web/i18n/locales/pl-PL/contacts.json create mode 100644 web/i18n/locales/pt-BR/contacts.json create mode 100644 web/i18n/locales/ro-RO/contacts.json create mode 100644 web/i18n/locales/ru-RU/contacts.json create mode 100644 web/i18n/locales/sl-SI/contacts.json create mode 100644 web/i18n/locales/th-TH/contacts.json create mode 100644 web/i18n/locales/tr-TR/contacts.json create mode 100644 web/i18n/locales/uk-UA/contacts.json create mode 100644 web/i18n/locales/vi-VN/contacts.json create mode 100644 web/i18n/locales/zh-Hans/contacts.json create mode 100644 web/i18n/locales/zh-Hant/contacts.json create mode 100644 web/service/__tests__/share-human-input-submit.spec.ts create mode 100644 web/service/__tests__/webapp-human-input-submit.spec.ts create mode 100644 web/service/__tests__/workflow-human-input-submit.spec.ts create mode 100644 web/service/human-input-v2.ts create mode 100644 web/service/webapp-human-input.ts diff --git a/.github/workflows/knowledge-fs-ci.yml b/.github/workflows/knowledge-fs-ci.yml index 0d5a290f32f..6883917c087 100644 --- a/.github/workflows/knowledge-fs-ci.yml +++ b/.github/workflows/knowledge-fs-ci.yml @@ -83,7 +83,6 @@ jobs: - 'api/services/knowledge_fs_capability.py' - 'api/tests/unit_tests/dev/test_generate_knowledge_fs_contract.py' - 'api/tests/unit_tests/extensions/test_ext_celery.py' - - 'api/tests/unit_tests/migrations/versions/test_2026_10_21_1500-b9d3f7a5c102_add_upgrade_reconciliation_lease.py' - 'api/tests/unit_tests/models/test_agent_config_entities.py' - 'api/tests/unit_tests/controllers/console/workspace/rbac/test_roles.py' - 'api/tests/unit_tests/core/agent/test_base_agent_runner.py' @@ -257,7 +256,6 @@ jobs: test_touchpoints=( api/tests/unit_tests/extensions/test_ext_celery.py - api/tests/unit_tests/migrations/versions/test_2026_10_21_1500-b9d3f7a5c102_add_upgrade_reconciliation_lease.py api/tests/unit_tests/models/test_agent_config_entities.py api/tests/unit_tests/controllers/console/datasets/test_datasets.py api/tests/unit_tests/controllers/console/test_wraps.py @@ -286,7 +284,6 @@ jobs: /core/workflow/ /dev/ /extensions/ - /migrations/ /models/ /repositories/ /services/ diff --git a/.github/workflows/web-tests.yml b/.github/workflows/web-tests.yml index 82697e50d12..0820a942a8a 100644 --- a/.github/workflows/web-tests.yml +++ b/.github/workflows/web-tests.yml @@ -13,7 +13,8 @@ jobs: openapi-sdk-test: name: OpenAPI SDK and Next.js adapters runs-on: depot-ubuntu-24.04-4 - timeout-minutes: 15 + # Leave time for contract checks after a cold toolchain installation. + timeout-minutes: 20 steps: - name: Checkout code uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -21,6 +22,7 @@ jobs: persist-credentials: false - name: Setup web environment + timeout-minutes: 15 uses: ./.github/actions/setup-web - name: Setup Python for in-memory Console schema export diff --git a/api/.env.example b/api/.env.example index 16226a49fd8..e051a8ca8f5 100644 --- a/api/.env.example +++ b/api/.env.example @@ -43,6 +43,9 @@ INTERNAL_FILES_URL=http://host.docker.internal:5001 # TRIGGER URL TRIGGER_URL=http://localhost:5001 +# Required deployment-wide transport for Human Input IM provider events +HUMAN_INPUT_IM_EVENT_TRANSPORT_MODE=WEBHOOK + # The time in seconds after the signature is rejected FILES_ACCESS_TIMEOUT=300 diff --git a/api/.importlinter b/api/.importlinter index 63c90c9f621..6f7a91ab446 100644 --- a/api/.importlinter +++ b/api/.importlinter @@ -29,6 +29,8 @@ layers = # error makes this list shrink whenever a legacy dependency is removed. unmatched_ignore_imports_alerting = error ignore_imports = + # The Dify node factory composes the Human Input runtime for workflow execution. + core.workflow.node_factory -> services.human_input_v2.composition # Legacy generators delegate configured file defaults to the application owner during core migration. core.app.apps.base_app_generator -> services.app.file_input_defaults core.app.apps.advanced_chat.app_generator -> services.app.file_input_defaults diff --git a/api/configs/deploy/__init__.py b/api/configs/deploy/__init__.py index 638b3dacfd5..82fa3bd3f43 100644 --- a/api/configs/deploy/__init__.py +++ b/api/configs/deploy/__init__.py @@ -1,9 +1,18 @@ +from enum import StrEnum + from pydantic import Field from pydantic_settings import BaseSettings from enums import DeploymentEdition +class IMEventTransportMode(StrEnum): + """Deployment-wide transport used for inbound IM provider events.""" + + WEBHOOK = "WEBHOOK" + STREAM = "STREAM" + + class DeploymentConfig(BaseSettings): """ Configuration settings for application deployment @@ -30,6 +39,11 @@ class DeploymentConfig(BaseSettings): default=DeploymentEdition.COMMUNITY, ) + HUMAN_INPUT_IM_EVENT_TRANSPORT_MODE: IMEventTransportMode = Field( + IMEventTransportMode.WEBHOOK, + description="Required deployment-wide transport for Human Input IM provider events.", + ) + INIT_PASSWORD: str = Field( description="Password required before initializing a self-hosted deployment", default="", diff --git a/api/controllers/common/human_input.py b/api/controllers/common/human_input.py index 3bb2684df43..873f7ee0af8 100644 --- a/api/controllers/common/human_input.py +++ b/api/controllers/common/human_input.py @@ -25,6 +25,8 @@ HUMAN_INPUT_FORM_INPUT_EXAMPLE = { class HumanInputFormSubmitPayload(BaseModel): + """Legacy Human Input v1 submit payload shared by existing runtime surfaces.""" + inputs: dict[str, JsonValue] = Field( description=( "Submitted human input values keyed by output variable name. " diff --git a/api/controllers/common/human_input_v2_contracts.py b/api/controllers/common/human_input_v2_contracts.py new file mode 100644 index 00000000000..b098adfb66d --- /dev/null +++ b/api/controllers/common/human_input_v2_contracts.py @@ -0,0 +1,705 @@ +"""Shared Human Input v2 transport contracts. + +Request DTOs use normal Pydantic coercion and forbid unknown fields. Migration +input is the sole compatibility exception: it ignores unknown legacy fields, +defaults a missing version to ``"1"``, rejects any other explicit version, and +rejects duplicate node IDs. Its transport shape mirrors the frontend migration +adapter so the generated client can replace the temporary mock without changing +frontend orchestration. +Public v2 keeps its email-proof DTO. Service API uses the existing form DTO for +both node versions. +""" + +from __future__ import annotations + +from http import HTTPStatus +from typing import Annotated, Literal, Self + +from pydantic import BaseModel, ConfigDict, Discriminator, Field, JsonValue, model_validator + +from controllers.common.human_input_v2_migration import LegacyHITLv1NodeData +from core.human_input_v2.entities import ( + ContactId, + DebugChannel, + HumanInputContactType, + IMBindingId, + IMBindingScope, + IMIdentityBindingStatus, + IMIdentityId, + IMProvider, + IMSyncRemovalReason, + IMSyncResultType, + IMSyncRunId, + IMSyncRunStatus, + OrganizationCandidateId, +) +from core.workflow.nodes.human_input.entities import FormInputConfig, UserActionConfig +from core.workflow.nodes.human_input_v2.entities import HumanInputNodeData as HITLv2NodeData +from fields.base import ResponseModel +from fields.pagination import PaginationParamsMixin, PaginationResultMixin +from fields.timestamp import Timestamp +from libs.helper import EmailStr + + +class _NoExtraModel(BaseModel): + model_config = ConfigDict(extra="forbid") + + +class _RequestModel(BaseModel): + """Base request model that forbids unknown fields while accepting JSON-native values.""" + + model_config = ConfigDict(extra="forbid") + + +class _MigrationInputModel(BaseModel): + """Forward-compatible migration input that ignores fields unknown to this backend version.""" + + model_config = ConfigDict(extra="ignore") + + +class ContactListQuery(PaginationParamsMixin, _NoExtraModel): + """Query params for listing contacts in the workspace directory.""" + + group: HumanInputContactType | None = Field( + default=None, + description="Optional contact type filter. None means all contacts.", + ) + keyword: str | None = Field(default=None, description="Free-text search against contact name or email.") + + +class ContactOptionsQuery(PaginationParamsMixin, _NoExtraModel): + """Query params for selecting contacts in workflow editors.""" + + keyword: str | None = Field(default=None, description="Free-text search against selectable contact names.") + + +class OrganizationCandidatesQuery(PaginationParamsMixin, _NoExtraModel): + """Query params for searching organization member candidates.""" + + keyword: str | None = Field(default=None, description="Free-text search against candidate name or email.") + + +ExternalContactName = Annotated[ + str, + Field( + min_length=1, + max_length=255, + description="Display name shown in the contact directory.", + ), +] + +ExternalContactEmail = Annotated[ + EmailStr, + Field( + description="Primary email used for delivery and identity verification.", + ), +] + +ExternalContactAvatar = Annotated[ + str, + Field( + description=( + "Optional avatar file ID. Upload the avatar image first via " + "`POST /console/api/files/upload`, then use the returned file id here." + " Set to empty string for resetting to default avatar." + ), + ), +] + + +class ExternalContactCreateRequest(_RequestModel): + """Request body for creating or updating one external contact.""" + + name: ExternalContactName + email: ExternalContactEmail + avatar: ExternalContactAvatar | None = None + + +class ExternalContactUpdateRequest(_RequestModel): + """Request body for creating or updating one external contact.""" + + name: ExternalContactName | None = None + email: ExternalContactEmail | None = None + avatar: ExternalContactAvatar | None = None + + +class IMBinding(BaseModel): + id: IMBindingId = Field(description="Unique IM binding identifier.") + provider: IMProvider = Field(description="Provider of the IM binding.") + scope: IMBindingScope = Field(description="Scope of the IM binding.") + + +class HumanInputContactSummary(BaseModel): + """A trimmed version of `HumanInputContact` that only includes the fields needed for workflow orchestration.""" + + id: ContactId = Field(description="Unique contact identifier.") + name: str = Field(description="Display name shown in the contact directory.") + avatar_url: str = Field(default="", description="URL of the contact's avatar.") + created_at: Timestamp = Field(description="Timestamp when the contact was created.") + + +class HumanInputContact(BaseModel): + """One contact entity returned by contact-related APIs.""" + + id: ContactId = Field(description="Unique contact identifier.") + type: HumanInputContactType = Field(description="Resolved contact type in the current workspace scope.") + name: str = Field(description="Display name shown in the contact directory.") + email: str | None = Field(default=None, description="Primary contact email if one exists.") + avatar_url: str = Field(default="", description="URL of the contact's avatar.") + # the `im_bindings` field is always empty for EXTERNAL contacts + im_bindings: list[IMBinding] = Field( + default_factory=list[IMBinding], + description=( + "IM bindings that are bound to this contact. " + "Currently, only one IM binding is supported. " + "There is at most one IM binding per IM provider." + ), + ) + + created_at: Timestamp = Field(description="Timestamp when the contact was created.") + + +class ContactOption(ResponseModel): + """Least-privilege contact projection returned to workflow editors.""" + + id: ContactId = Field(description="Unique contact identifier persisted in workflow recipient configuration.") + type: HumanInputContactType = Field(description="Resolved contact type in the current workspace scope.") + name: str = Field(description="Display name shown in the contact picker.") + avatar_url: str | None = Field(default=None, description="Signed avatar URL if one is available.") + email: str | None = Field(default=None, description="Current deliverable Email, when available.") + + +class ExternalContactCreateResponse(ResponseModel): + contact: HumanInputContact = Field(description="The created external contact.") + + +class ExternalContactUpdateResponse(ResponseModel): + contact: HumanInputContact = Field(description="The updated external contact. Fields are values after updating.") + + +class OrganizationCandidate(ResponseModel): + """One organization member candidate that may become a platform contact.""" + + id: OrganizationCandidateId = Field(description="Organization candidate identifier.") + name: str = Field(description="Display name shown in the candidate list.") + email: str = Field(description="Primary organization email used for matching.") + avatar_url: str | None = Field(default=None, description="Signed avatar URL if one is available.") + + +class ListContactsResponse(PaginationResultMixin, ResponseModel): + """Paginated response body for contact list APIs.""" + + data: list[HumanInputContact] = Field(description="Contacts returned for the current page.") + + +class ListContactOptionsResponse(PaginationResultMixin, ResponseModel): + """Paginated editor-safe contact picker response.""" + + data: list[ContactOption] = Field(description="Selectable contacts returned for the current page.") + + +class ContactIMIdentityDetail(BaseModel): + """Display fields for the identity selected by an effective contact binding.""" + + id: IMIdentityId + provider_user_id: str + display_name: str | None + email: str | None + + +class ContactIMBindingDetail(BaseModel): + id: IMBindingId + provider: IMProvider + scope: IMBindingScope + identity: ContactIMIdentityDetail + + +class GetContactResponse(ResponseModel): + """Response body for one contact resolved in the current workspace scope.""" + + contact: HumanInputContact = Field(description="Contact resolved as workspace, platform, or external.") + im_binding_details: list[ContactIMBindingDetail] = Field( + description="Effective IM bindings and their identities in the current workspace, for the contact detail view." + ) + + +class ListOrganizationCandidatesResponse(PaginationResultMixin, ResponseModel): + """Paginated response body for organization candidate search.""" + + data: list[OrganizationCandidate] = Field( + description="Organization member candidates returned for the current page." + ) + + +class AddPlatformContactsRequest(_RequestModel): + """Request body for adding one or more organization members as platform contacts.""" + + candidate_ids: list[OrganizationCandidateId] = Field( + ..., + min_length=1, + description="Organization candidate identifiers to project into the current workspace as platform contacts.", + ) + + +class AddPlatformContactsResponse(ResponseModel): + """Response body for adding platform contacts.""" + + data: list[HumanInputContact] = Field(description="Contacts created by the current add operation.") + + +class RemoveContactsRequest(_RequestModel): + """Request body for batch-removing platform or external contacts.""" + + contact_ids: list[ContactId] = Field( + ..., + min_length=1, + description="Contact identifiers selected for removal from the contact directory surface.", + ) + + +class RemoveContactsResponse(ResponseModel): + """Response body returned after batch-removing contacts.""" + + removed_contact_ids: list[ContactId] = Field(description="Contact identifiers removed by the current operation.") + + +class IMSyncRunResultCounts(ResponseModel): + """Aggregate result counts for one IM sync run.""" + + added: int = Field(description="Number of entries newly matched and bound.") + not_matched: int = Field(description="Number of entries that could not be matched.") + failed: int = Field(description="Number of entries that failed to reconcile.") + removed: int = Field(description="Number of entries whose prior binding was removed.") + skipped: int = Field(description="Number of entries intentionally skipped.") + + +class IMSyncRun(ResponseModel): + """One IM sync run snapshot. + + The latest-only UI displays ``finished_at`` as the explicit sync time. The + transport contract intentionally does not expose a ``started_by`` actor. + """ + + id: IMSyncRunId = Field(description="Unique sync run identifier.") + status: IMSyncRunStatus = Field(description="Current lifecycle state of the sync run.") + started_at: Timestamp | None = Field( + default=None, description="Unix timestamp in seconds when the sync run started." + ) + finished_at: Timestamp | None = Field( + default=None, + description=( + "Unix timestamp in seconds when the sync run finished. " + "This is the sync time displayed by the latest-only UI and is None while the run is unfinished." + ), + ) + error_message: str | None = Field( + default=None, + description="Terminal error message. Present only when the sync run status is `failed`.", + ) + result_counts: IMSyncRunResultCounts = Field( + description="Aggregate reconciliation counts for the current run snapshot.", + ) + provider: IMProvider = Field(description="IM provider associated with the sync run.") + channel_id: str = Field(description="Channel identifier captured when the sync run was created.") + channel_config_version: int = Field( + ge=1, + description="Channel configuration revision captured when the sync run was created.", + ) + + +class CreateIMSyncRunResponse(ResponseModel): + """Response body returned after creating one sync run.""" + + run: IMSyncRun = Field(description="Newly created sync run snapshot.") + + +class IMContactSyncErrorResponse(ResponseModel): + """Stable expected application failure returned by IM sync and binding routes.""" + + code: str = Field(description="Machine-readable application error code.") + message: str = Field(description="Operator-safe error message.") + status: int = Field(description="HTTP status code associated with this failure.") + + +class IMDirectoryEntry(_RequestModel): + """Normalized provider-side account observed during an IM sync run. + + The entry is run-scoped input to identity and binding reconciliation. It does + not represent a stable Dify identity and must not be referenced by bindings + or runtime authorization. Sync results may retain a snapshot for display, + diagnostics, and audit; durable references use IMIdentity or IMBinding IDs. + """ + + provider_user_id: str + display_name: str | None = None + email: str | None = None + + +class IMIdentitySnapshot(_RequestModel): + """Last known persistent IM identity state retained by a sync result.""" + + identity_id: IMIdentityId + provider_user_id: str + display_name: str | None = None + email: str | None = None + + +class IMSyncResultAdded(BaseModel): + type: Literal[IMSyncResultType.ADDED] = IMSyncResultType.ADDED + + contact: HumanInputContactSummary = Field(description="The contact that associated with this sync result.") + entry: IMDirectoryEntry = Field(description="Provider directory entry observed during the current sync run.") + + +class IMSyncResultRemoved(BaseModel): + type: Literal[IMSyncResultType.REMOVED] = IMSyncResultType.REMOVED + + contact: HumanInputContactSummary = Field(description="The contact that associated with this sync result.") + last_known_identity: IMIdentitySnapshot = Field( + description="Last known persistent IM identity state before its binding was removed." + ) + reason: IMSyncRemovalReason = Field(description="Reason the existing IM binding was removed.") + + +class IMSyncResultFailed(BaseModel): + type: Literal[IMSyncResultType.FAILED] = IMSyncResultType.FAILED + + entry: IMDirectoryEntry | None = Field( + None, description="Provider directory entry observed before this reconciliation failure, if available." + ) + reason: str = Field(description="Reason the binding failed to sync.") + + +class IMSyncResultSkipped(BaseModel): + type: Literal[IMSyncResultType.SKIPPED] = IMSyncResultType.SKIPPED + + entry: IMDirectoryEntry | None = Field( + None, description="Provider directory entry observed before reconciliation was skipped, if available." + ) + contact: HumanInputContactSummary = Field(description="The contact that associated with this sync result.") + + +class IMSyncResultNotMatched(BaseModel): + type: Literal[IMSyncResultType.NOT_MATCHED] = IMSyncResultType.NOT_MATCHED + + entry: IMDirectoryEntry | None = Field( + None, description="Provider directory entry that could not be matched, if available." + ) + + +IMSyncResult = Annotated[ + IMSyncResultAdded | IMSyncResultRemoved | IMSyncResultFailed | IMSyncResultNotMatched | IMSyncResultSkipped, + Discriminator("type"), +] + + +class IMSyncResultItem(ResponseModel): + """One paginated reconciliation result entry for the latest sync run.""" + + # The current implementation does not return IM binding status for other IM providers. + # According to the design, we should return IM binding status for all configured IM providers. + # However, this version allows only one configured IM provider, so this model excludes + # the IM binding status for other IM providers. + + id: str = Field(description="Unique synchronization result identifier.") + result: IMSyncResult = Field(description="Result bucket this entry belongs to.") + + +class GetLatestIMSyncRunResponse(ResponseModel): + """Response body for reading the latest IM sync run summary.""" + + run: IMSyncRun = Field(description="Latest sync run summary.") + + +class ListLatestIMSyncRunResultsQuery(PaginationParamsMixin, _NoExtraModel): + """Query params for reading paginated latest-run results.""" + + result: IMSyncResultType = Field( + ..., + description=( + "Required result bucket to paginate from the latest sync run. " + "There is no `all` bucket or unfiltered results mode." + ), + ) + + +class ListLatestIMSyncRunResultsResponse(PaginationResultMixin, ResponseModel): + """Page-based latest-run results without cursor state or a repeated run summary.""" + + data: list[IMSyncResultItem] = Field( + description="Result entries returned with page, limit, and total metadata for the selected bucket." + ) + + +class ListIMIdentitiesQuery(PaginationParamsMixin, _NoExtraModel): + """Query params for searching synced IM identities.""" + + keyword: str | None = Field( + default=None, + description="Free-text search against identity display name, email, or provider user ID.", + ) + + +class IMIdentity(ResponseModel): + """One synced IM identity that may be bound or overridden.""" + + id: IMIdentityId = Field(description="Internal IM identity record identifier.") + provider: IMProvider = Field(description="IM provider that owns this identity.") + provider_user_id: str = Field(description="Provider-side user identifier.") + display_name: str | None = Field(default=None, description="Display name returned by the provider.") + email: str | None = Field(default=None, description="Email returned by the provider, if any.") + binding_status: IMIdentityBindingStatus = Field( + description="Whether this IM identity is currently bound to a contact." + ) + + +class ListIMIdentitiesResponse(PaginationResultMixin, ResponseModel): + """Paginated response body for synced IM identity search.""" + + data: list[IMIdentity] = Field(description="IM identities returned for the current page.") + + +class SetContactIMOverrideRequest(_RequestModel): + """Request body for setting one workspace-scoped IM override.""" + + identity_id: IMIdentityId = Field(description="Synced IM identity identifier selected as the workspace override.") + + +class SetContactIMOverrideResponse(ResponseModel): + """Response body returned after setting one contact IM override.""" + + contact: HumanInputContact = Field(description="Contact snapshot after the override is applied.") + + +class ResetContactIMOverrideResponse(ResponseModel): + """Response body returned after resetting one contact IM override.""" + + contact: HumanInputContact = Field(description="Contact snapshot after the override is cleared.") + + +class CreateIMBindingRequest(_RequestModel): + """Request body for setting one workspace-scoped IM override.""" + + identity_id: IMIdentityId = Field(description="Synced IM identity identifier selected as the workspace override.") + + +class CreateIMBindingResponse(ResponseModel): + """Response body returned after binding one IM identity to the workspace.""" + + contact: HumanInputContact = Field(description="Contact snapshot after the IM identity is bound.") + + +class DeleteIMBindingResponse(ResponseModel): + pass + + +class MessageTemplateTestRequest(_RequestModel): + """Request body for sending one message-template test notification.""" + + channel: DebugChannel = Field(description="Target debug delivery channel used for the test send.") + inputs: dict[str, JsonValue] = Field( + default_factory=dict, + description="Variable values used when rendering the message template preview.", + ) + + +class MessageTemplateTestResponse(ResponseModel): + """Response body returned after one message-template test send.""" + + +class FormAccessRequestResponse(ResponseModel): + """Response body returned after creating one OTP challenge.""" + + expires_in_seconds: int = Field(description="Seconds until the current OTP challenge expires.") + resend_after_seconds: int = Field(description="Seconds until another OTP challenge may be requested.") + challenge_token: str = Field(description="The token used to complete the OTP challenge.") + + +class FormDefinitionResponse(ResponseModel): + """Response body containing a resolved human-input form definition.""" + + form_content: str | None = Field(default=None, description="Rendered form body shown to the approver.") + inputs: list[FormInputConfig] = Field(default_factory=list, description="Resolved form input definitions.") + resolved_default_values: dict[str, str] = Field( + default_factory=dict, + description="Default values after variable resolution and stringification.", + ) + user_actions: list[UserActionConfig] = Field( + default_factory=list, + description="Action buttons that can complete the form.", + ) + expiration_time: int = Field(description="Unix timestamp when the current form expires.") + + +class BatchGetContactsQuery(_NoExtraModel): + contact_ids: list[ContactId] = Field(..., description="List of contact IDs to retrieve.") + + +class BatchGetContactsResponse(ResponseModel): + data: list[HumanInputContactSummary] = Field(..., description="List of retrieved human input contacts.") + + +class BatchGetContactOptionsQuery(_NoExtraModel): + contact_ids: list[ContactId] = Field(..., description="Contact IDs persisted in workflow recipient configuration.") + + +class BatchGetContactOptionsResponse(ResponseModel): + data: list[ContactOption] = Field(..., description="Selectable contacts resolved in request order.") + + +class HumanInputV2FormSubmitRequest(_RequestModel): + """Public Human Input v2 submit payload, independent from the v1 form contract.""" + + inputs: dict[str, JsonValue] = Field(description="Submitted form values keyed by output variable name.") + action: str = Field(description="Identifier of the selected Human Input v2 action.") + challenge_token: str | None = Field( + default=None, + description="OTP challenge token returned by the Human Input v2 access-request endpoint.", + ) + otp_code: str | None = Field( + default=None, + description="OTP code required when the current Human Input v2 approver uses email proof.", + ) + + @model_validator(mode="after") + def validate_complete_email_proof(self) -> Self: + has_challenge_token = self.challenge_token is not None + has_otp_code = self.otp_code is not None + if has_challenge_token != has_otp_code: + raise ValueError("challenge_token and otp_code must be provided together") + return self + + +class FormUploadTokenResponse(ResponseModel): + """Response body returned when issuing a Human Input v2 upload token.""" + + upload_token: str + expires_at: int + + +class FormSubmitResponse(ResponseModel): + """Empty response body returned after a Human Input v2 form submission.""" + + +# =================== Node migration related entities =================== + + +class NodeDataMigrationInput(_MigrationInputModel): + """One legacy node submitted through the frontend migration adapter boundary.""" + + node_id: str = Field( + ..., description="The identifier of node to migrate. Used to associate between request and response" + ) + node_data: LegacyHITLv1NodeData = Field(..., description="The legacy Human Input node data to migrate.") + + +class NodeDataMigrationPayload(_MigrationInputModel): + """Complete legacy-node batch submitted for one migration attempt.""" + + nodes: list[NodeDataMigrationInput] = Field(min_length=1) + + @model_validator(mode="after") + def validate_unique_node_ids(self) -> Self: + node_ids = [node.node_id for node in self.nodes] + if len(node_ids) != len(set(node_ids)): + raise ValueError("node_id must be unique within one migration request") + return self + + +class NodeDataMigrationResult(ResponseModel): + """One converted node returned with its frontend correlation identifier.""" + + node_id: str = Field(description="The identifier of the migrated node.") + node_data: HITLv2NodeData = Field(description="The complete converted Human Input v2 node data.") + + +class NodeDataMigrationResponse(ResponseModel): + """Successful all-node conversion response.""" + + data: list[NodeDataMigrationResult] + + +NodeDataMigrationBlockerCode = Literal[ + "unsupported-version", + "invalid-default-value", + "configured-disabled-method", + "unsupported-delivery-method", + "invalid-email-configuration", + "invalid-email", + "unresolved-member", + "conflicting-email-templates", + "missing-recipients", +] + + +class NodeDataMigrationBlocker(ResponseModel): + """Stable node-scoped reason why the backend cannot produce lossless v2 data.""" + + node_id: str = Field(description="The identifier of the node that failed migration.") + node_title: str = Field(description="The node title used for actionable frontend feedback.") + code: NodeDataMigrationBlockerCode = Field(description="Machine-readable migration blocker code.") + method_id: str | None = Field(default=None, description="Legacy delivery method related to the blocker.") + value: str | None = Field(default=None, description="Safe legacy value related to the blocker.") + + +class NodeDataMigrationFailureResponse(ResponseModel): + """Whole-batch failure response without partial converted node data.""" + + code: Literal["hitl_node_data_migration_failure"] = "hitl_node_data_migration_failure" + message: str = Field(..., description="overall error messages") + status: Literal[HTTPStatus.BAD_REQUEST] = HTTPStatus.BAD_REQUEST + blockers: list[NodeDataMigrationBlocker] = Field( + ..., description="Node-scoped blockers that caused the whole batch to fail." + ) + + +__all__ = [ + "AddPlatformContactsRequest", + "AddPlatformContactsResponse", + "BatchGetContactOptionsQuery", + "BatchGetContactOptionsResponse", + "ContactListQuery", + "ContactOption", + "ContactOptionsQuery", + "CreateIMSyncRunResponse", + "ExternalContactCreateRequest", + "ExternalContactUpdateRequest", + "FormAccessRequestResponse", + "FormDefinitionResponse", + "FormSubmitResponse", + "FormUploadTokenResponse", + "GetContactResponse", + "GetLatestIMSyncRunResponse", + "HumanInputContact", + "HumanInputContactType", + "HumanInputV2FormSubmitRequest", + "IMContactSyncErrorResponse", + "IMIdentity", + "IMIdentityBindingStatus", + "IMProvider", + "IMSyncRemovalReason", + "IMSyncResultItem", + "IMSyncResultType", + "IMSyncRun", + "IMSyncRunResultCounts", + "IMSyncRunStatus", + "ListContactOptionsResponse", + "ListContactsResponse", + "ListIMIdentitiesQuery", + "ListIMIdentitiesResponse", + "ListLatestIMSyncRunResultsQuery", + "ListLatestIMSyncRunResultsResponse", + "ListOrganizationCandidatesResponse", + "MessageTemplateTestRequest", + "MessageTemplateTestResponse", + "NodeDataMigrationFailureResponse", + "NodeDataMigrationPayload", + "NodeDataMigrationResponse", + "OrganizationCandidate", + "OrganizationCandidatesQuery", + "RemoveContactsRequest", + "RemoveContactsResponse", + "ResetContactIMOverrideResponse", + "SetContactIMOverrideRequest", + "SetContactIMOverrideResponse", +] diff --git a/api/controllers/common/human_input_v2_errors.py b/api/controllers/common/human_input_v2_errors.py new file mode 100644 index 00000000000..3db08fd1407 --- /dev/null +++ b/api/controllers/common/human_input_v2_errors.py @@ -0,0 +1,122 @@ +"""HTTP errors shared by the Human Input v2 approval transports.""" + +from typing import override + +from controllers.common.errors import NotFoundError +from libs.exception import BaseHTTPException +from services.human_input_v2.email_otp_service import EmailOtpError, OtpFailure +from services.human_input_v2.submission_service import HumanInputFormError, HumanInputFormErrorCode + + +class HumanInputV2AlreadySubmittedError(BaseHTTPException): + error_code = "already_submitted" + code = 409 + + +class HumanInputV2ExpiredError(BaseHTTPException): + error_code = "expired" + code = 410 + + +class HumanInputV2WorkflowNotActiveError(BaseHTTPException): + error_code = "workflow_not_active" + code = 409 + + +class HumanInputV2InvalidInputError(BaseHTTPException): + error_code = "invalid_input" + code = 400 + + +class PublicHumanInputV2NotFoundError(BaseHTTPException): + error_code = "human_input_form_not_found" + code = 404 + + +class PublicHumanInputV2ExpiredError(BaseHTTPException): + error_code = "human_input_form_expired" + code = 412 + + +class PublicHumanInputV2SubmittedError(BaseHTTPException): + error_code = "human_input_form_submitted" + code = 412 + + +class HumanInputAccessRateLimitError(BaseHTTPException): + error_code = "human_input_access_rate_limit_exceeded" + code = 429 + + def __init__(self, *, retry_after: int | None) -> None: + super().__init__(description="Unable to complete email verification.") + self._retry_after = retry_after + + @override + def get_headers(self, environ=None, scope=None): + headers = super().get_headers(environ, scope) + if self._retry_after is not None: + headers.append(("Retry-After", str(self._retry_after))) + return headers + + +class HumanInputAccessDeliveryError(BaseHTTPException): + error_code = "human_input_access_delivery_failed" + code = 503 + + +class HumanInputV2UnavailableError(BaseHTTPException): + error_code = "human_input_v2_unavailable" + code = 503 + + +class HumanInputInvalidOtpError(BaseHTTPException): + error_code = "human_input_invalid_otp" + code = 400 + + +class HumanInputStaleChallengeError(BaseHTTPException): + error_code = "human_input_challenge_stale" + code = 400 + + +class HumanInputExpiredChallengeError(BaseHTTPException): + error_code = "human_input_challenge_expired" + code = 400 + + +def form_http_error(error: HumanInputFormError, *, message: str) -> BaseHTTPException: + error_type = { + HumanInputFormErrorCode.NOT_FOUND: NotFoundError, + HumanInputFormErrorCode.ALREADY_SUBMITTED: HumanInputV2AlreadySubmittedError, + HumanInputFormErrorCode.EXPIRED: HumanInputV2ExpiredError, + HumanInputFormErrorCode.WORKFLOW_NOT_ACTIVE: HumanInputV2WorkflowNotActiveError, + HumanInputFormErrorCode.INVALID_INPUT: HumanInputV2InvalidInputError, + }[error.code] + return error_type(description=message) + + +def public_form_http_error(error: HumanInputFormError) -> BaseHTTPException: + error_type = { + HumanInputFormErrorCode.NOT_FOUND: PublicHumanInputV2NotFoundError, + HumanInputFormErrorCode.ALREADY_SUBMITTED: PublicHumanInputV2SubmittedError, + HumanInputFormErrorCode.EXPIRED: PublicHumanInputV2ExpiredError, + HumanInputFormErrorCode.WORKFLOW_NOT_ACTIVE: HumanInputV2WorkflowNotActiveError, + HumanInputFormErrorCode.INVALID_INPUT: HumanInputV2InvalidInputError, + }[error.code] + return error_type(description="The form is unavailable or the submission could not be accepted.") + + +def email_otp_http_error(error: EmailOtpError) -> BaseHTTPException: + if error.reason == OtpFailure.RATE_LIMIT: + return HumanInputAccessRateLimitError(retry_after=error.retry_after) + error_type = { + OtpFailure.NOT_FOUND: PublicHumanInputV2NotFoundError, + OtpFailure.EXPIRED: PublicHumanInputV2ExpiredError, + OtpFailure.SUBMITTED: PublicHumanInputV2SubmittedError, + OtpFailure.DELIVERY: HumanInputAccessDeliveryError, + OtpFailure.UNAVAILABLE: HumanInputV2UnavailableError, + OtpFailure.INVALID: HumanInputInvalidOtpError, + OtpFailure.STALE: HumanInputStaleChallengeError, + OtpFailure.CHALLENGE_EXPIRED: HumanInputExpiredChallengeError, + }[error.reason] + return error_type(description="Unable to complete email verification.") diff --git a/api/controllers/common/human_input_v2_migration.py b/api/controllers/common/human_input_v2_migration.py new file mode 100644 index 00000000000..d93ef387d86 --- /dev/null +++ b/api/controllers/common/human_input_v2_migration.py @@ -0,0 +1,431 @@ +"""Transport compatibility and preflight parsing for HITL v1 node migration.""" + +from collections.abc import Mapping +from typing import override +from uuid import UUID + +from pydantic import ( + BaseModel, + ConfigDict, + Field, + GetJsonSchemaHandler, + JsonValue, + RootModel, + TypeAdapter, + ValidationError, + model_validator, +) +from pydantic.json_schema import JsonSchemaValue +from pydantic_core import CoreSchema, PydanticCustomError + +from core.workflow.human_input_adapter import ( + EmailDeliveryMethod, + WebAppDeliveryMethod, + adapt_human_input_node_data_for_graph, +) +from core.workflow.nodes.human_input.entities import HumanInputNodeDataFull +from core.workflow.nodes.human_input_v2.migration import ( + LegacyDeliveryParseIssue, + LegacyNodeDataPreflight, + MigrationBlockerCode, +) +from graphon.entities.base_node_data import DefaultValue +from graphon.entities.exc import DefaultValueTypeError + + +class LegacyHITLv1NodeData(RootModel[LegacyNodeDataPreflight]): + """Refine historical wire aliases into the formal V1 model at HTTP ingress. + + Only delivery/default preflight is migration-specific. All authoring fields + and constraints come from the same model that executes V1 workflows. + """ + + @model_validator(mode="before") + @classmethod + def parse_v1(cls, value: object) -> LegacyNodeDataPreflight: + return _parse_node_data(value) + + @classmethod + @override + def __get_pydantic_json_schema__( + cls, + _core_schema: CoreSchema, + handler: GetJsonSchemaHandler, + ) -> JsonSchemaValue: + return handler(TypeAdapter(HumanInputNodeDataFull).core_schema) + + +class _TransportDeliveryMethod(BaseModel): + """Permissive envelope used only to classify compatibility input.""" + + model_config = ConfigDict(extra="allow") + + __pydantic_extra__: dict[str, JsonValue] = Field(init=False) # pyrefly: ignore[bad-override-mutable-attribute] + + id: str | None = None + type: str + enabled: bool = True + config: JsonValue = Field(default_factory=dict) + + +def _json_value_is_material(value: JsonValue) -> bool: + if value is None or value is False or value == "": + return False + if isinstance(value, list | dict): + return bool(value) + return True + + +def _disabled_email_config_is_historical_default(config: JsonValue) -> bool: + if not isinstance(config, Mapping): + return False + if not set(config).issubset({"recipients", "subject", "body", "debug_mode"}): + return False + if "subject" in config and config["subject"] != "": + return False + if "body" in config and config["body"] != "": + return False + if "debug_mode" in config and config["debug_mode"] is not False: + return False + + if "recipients" not in config: + return True + recipients = config["recipients"] + if not isinstance(recipients, Mapping): + return False + if not set(recipients).issubset({"items", "whole_workspace", "include_bound_group"}): + return False + if "items" in recipients and recipients["items"] != []: + return False + if "whole_workspace" in recipients and recipients["whole_workspace"] is not False: + return False + return "include_bound_group" not in recipients or recipients["include_bound_group"] is False + + +def _disabled_method_is_configured(method: _TransportDeliveryMethod) -> bool: + if method.__pydantic_extra__: + return True + if "config" not in method.model_fields_set: + return False + if method.type == "email": + return not _disabled_email_config_is_historical_default(method.config) + if method.type == "webapp": + return not isinstance(method.config, Mapping) or bool(method.config) + return _json_value_is_material(method.config) + + +def _transport_method_value(method: _TransportDeliveryMethod) -> dict[str, JsonValue]: + method_value = method.model_dump(mode="python") + if "id" not in method.model_fields_set: + method_value.pop("id", None) + return method_value + + +def _parse_default_values( + raw_default_values: JsonValue, +) -> tuple[list[DefaultValue] | None, tuple[LegacyDeliveryParseIssue, ...]]: + if raw_default_values is None: + return None, () + if isinstance(raw_default_values, list): + try: + return [DefaultValue.model_validate(raw_default) for raw_default in raw_default_values], () + except (ValidationError, DefaultValueTypeError): + pass + return ( + None, + ( + LegacyDeliveryParseIssue( + code=MigrationBlockerCode.INVALID_DEFAULT_VALUE, + value="default_value", + ), + ), + ) + + +def _normalize_email_compatibility_aliases(method: _TransportDeliveryMethod) -> dict[str, JsonValue]: + # Reuse the runtime's historical user_id/whole_workspace normalization. + normalized = adapt_human_input_node_data_for_graph({"delivery_methods": [_transport_method_value(method)]}) + return TypeAdapter(list[dict[str, JsonValue]]).validate_python(normalized["delivery_methods"])[0] + + +def _email_configuration_error_field(method: _TransportDeliveryMethod) -> str: + config = method.config + if not isinstance(config, Mapping) or not isinstance(config.get("recipients"), Mapping): + return "recipients" + if not isinstance(config.get("subject"), str): + return "subject" + if not isinstance(config.get("body"), str): + return "body" + return "recipients" + + +def _parse_method( + raw_method: JsonValue, + method_position: int, +) -> tuple[ + EmailDeliveryMethod | WebAppDeliveryMethod | None, + tuple[LegacyDeliveryParseIssue, ...], + tuple[int, ...], +]: + try: + transport_method = _TransportDeliveryMethod.model_validate(raw_method) + except ValidationError: + return ( + None, + ( + LegacyDeliveryParseIssue( + code=MigrationBlockerCode.UNSUPPORTED_DELIVERY_METHOD, + method_position=method_position, + ), + ), + (), + ) + + if "id" in transport_method.model_fields_set and transport_method.id is None: + return ( + None, + ( + LegacyDeliveryParseIssue( + code=MigrationBlockerCode.UNSUPPORTED_DELIVERY_METHOD, + method_position=method_position, + value=transport_method.type, + ), + ), + (), + ) + + if transport_method.id is not None: + try: + UUID(transport_method.id) + except ValueError: + return ( + None, + ( + LegacyDeliveryParseIssue( + code=MigrationBlockerCode.UNSUPPORTED_DELIVERY_METHOD, + method_position=method_position, + method_id=transport_method.id, + value=transport_method.type, + ), + ), + (), + ) + + if transport_method.type not in {"email", "webapp"}: + return ( + None, + ( + LegacyDeliveryParseIssue( + code=MigrationBlockerCode.UNSUPPORTED_DELIVERY_METHOD, + method_position=method_position, + method_id=transport_method.id, + value=transport_method.type, + ), + ), + (), + ) + + if not transport_method.enabled: + if _disabled_method_is_configured(transport_method): + return ( + None, + ( + LegacyDeliveryParseIssue( + code=MigrationBlockerCode.CONFIGURED_DISABLED_METHOD, + method_position=method_position, + method_id=transport_method.id, + value=transport_method.type, + ), + ), + (), + ) + return None, (), () + + if transport_method.type == "webapp": + try: + return WebAppDeliveryMethod.model_validate(_transport_method_value(transport_method)), (), () + except ValidationError: + return ( + None, + ( + LegacyDeliveryParseIssue( + code=MigrationBlockerCode.UNSUPPORTED_DELIVERY_METHOD, + method_position=method_position, + method_id=transport_method.id, + value=transport_method.type, + ), + ), + (), + ) + + method_value = _normalize_email_compatibility_aliases(transport_method) + config = method_value.get("config") + if not isinstance(config, Mapping): + return ( + None, + ( + LegacyDeliveryParseIssue( + code=MigrationBlockerCode.INVALID_EMAIL_CONFIGURATION, + method_position=method_position, + method_id=transport_method.id, + value="recipients", + ), + ), + (), + ) + recipients = config.get("recipients") + if not isinstance(recipients, Mapping): + return ( + None, + ( + LegacyDeliveryParseIssue( + code=MigrationBlockerCode.INVALID_EMAIL_CONFIGURATION, + method_position=method_position, + method_id=transport_method.id, + value="recipients", + ), + ), + (), + ) + raw_items = recipients.get("items", []) + if not isinstance(raw_items, list): + return ( + None, + ( + LegacyDeliveryParseIssue( + code=MigrationBlockerCode.INVALID_EMAIL_CONFIGURATION, + method_position=method_position, + method_id=transport_method.id, + value="recipients", + ), + ), + (), + ) + + valid_items: list[JsonValue] = [] + valid_item_positions: list[int] = [] + issues: list[LegacyDeliveryParseIssue] = [] + for recipient_position, raw_recipient in enumerate(raw_items): + if not isinstance(raw_recipient, Mapping): + issues.append( + LegacyDeliveryParseIssue( + code=MigrationBlockerCode.INVALID_EMAIL_CONFIGURATION, + method_position=method_position, + method_id=transport_method.id, + value="recipients", + recipient_position=recipient_position, + ) + ) + continue + recipient_type = raw_recipient.get("type") + if recipient_type == "external": + if not isinstance(raw_recipient.get("email"), str): + issues.append( + LegacyDeliveryParseIssue( + code=MigrationBlockerCode.INVALID_EMAIL, + method_position=method_position, + method_id=transport_method.id, + recipient_position=recipient_position, + ) + ) + continue + elif recipient_type == "member": + if not isinstance(raw_recipient.get("reference_id"), str): + issues.append( + LegacyDeliveryParseIssue( + code=MigrationBlockerCode.UNRESOLVED_MEMBER, + method_position=method_position, + method_id=transport_method.id, + recipient_position=recipient_position, + ) + ) + continue + else: + issues.append( + LegacyDeliveryParseIssue( + code=MigrationBlockerCode.INVALID_EMAIL_CONFIGURATION, + method_position=method_position, + method_id=transport_method.id, + value="recipients", + recipient_position=recipient_position, + ) + ) + continue + valid_items.append(dict(raw_recipient)) + valid_item_positions.append(recipient_position) + + normalized_method_value = { + **method_value, + "config": { + **config, + "recipients": { + **recipients, + "items": valid_items, + }, + }, + } + try: + return ( + EmailDeliveryMethod.model_validate(normalized_method_value), + tuple(issues), + tuple(valid_item_positions), + ) + except ValidationError: + issues.append( + LegacyDeliveryParseIssue( + code=MigrationBlockerCode.INVALID_EMAIL_CONFIGURATION, + method_position=method_position, + method_id=transport_method.id, + value=_email_configuration_error_field(transport_method), + ) + ) + return None, tuple(issues), () + + +def _parse_node_data(raw_node_data: object) -> LegacyNodeDataPreflight: + node_value = TypeAdapter(dict[str, JsonValue]).validate_python(raw_node_data) + if node_value.get("version", "1") != "1": + raise PydanticCustomError("unsupported_version", 'version must be "1"') + default_value, default_value_issues = _parse_default_values(node_value.get("default_value")) + raw_methods = TypeAdapter(list[JsonValue]).validate_python(node_value.get("delivery_methods", [])) + delivery_methods: list[EmailDeliveryMethod | WebAppDeliveryMethod] = [] + method_positions: list[int] = [] + recipient_positions: list[tuple[int, ...]] = [] + issues = list(default_value_issues) + for method_position, raw_method in enumerate(raw_methods): + method, method_issues, method_recipient_positions = _parse_method(raw_method, method_position) + if method is not None: + delivery_methods.append(method) + method_positions.append(method_position) + recipient_positions.append(method_recipient_positions) + issues.extend(method_issues) + + # text_input predates the shared paragraph model. Normalize only this alias; + # the formal discriminated union owns every current input and field constraint. + raw_inputs = node_value.get("inputs") + if isinstance(raw_inputs, list): + node_value["inputs"] = [ + {**input_value, "type": "paragraph"} + if isinstance(input_value, dict) and input_value.get("type") == "text_input" + else input_value + for input_value in raw_inputs + ] + canonical_node_data = HumanInputNodeDataFull.model_validate( + { + **node_value, + "default_value": default_value, + "delivery_methods": delivery_methods, + } + ) + return LegacyNodeDataPreflight( + node_data=canonical_node_data, + method_positions=tuple(method_positions), + recipient_positions=tuple(recipient_positions), + issues=tuple(issues), + ) + + +def preflight_legacy_human_input_node_data( + transport_node_data: LegacyHITLv1NodeData, +) -> LegacyNodeDataPreflight: + return transport_node_data.root diff --git a/api/controllers/common/schema.py b/api/controllers/common/schema.py index f520345fcca..71a0325496f 100644 --- a/api/controllers/common/schema.py +++ b/api/controllers/common/schema.py @@ -75,7 +75,8 @@ SCHEMA_NAME_OVERRIDES: Mapping[str, str] = { "core.app.entities.task_entities.WorkflowAppBlockingResponse.Data": "WorkflowAppBlockingResponseData", } -_REF_ID_SUFFIX = re.compile(r":\d+(?=$|[\[\],])") +# Pydantic can append metadata such as "_Strict(strict=False)" after a process-local type ID. +_REF_ID_SUFFIX = re.compile(r":\d+(?=$|[\[\],_])") _MODULE_PREFIX = re.compile(r"(?:[^.[\],]+\.)+([^.[\],]+)") # Normalized component name -> (component name, fully qualified source type, schema), per namespace. diff --git a/api/controllers/console/__init__.py b/api/controllers/console/__init__.py index 1ea93ae9999..9af9886a7be 100644 --- a/api/controllers/console/__init__.py +++ b/api/controllers/console/__init__.py @@ -181,6 +181,7 @@ def _register_controllers() -> tuple[ModuleType, ...]: workflow_app_log, workflow_comment, workflow_draft_variable, + workflow_human_input_v2, workflow_node_output_inspector, workflow_run, workflow_statistic, @@ -232,6 +233,9 @@ def _register_controllers() -> tuple[ModuleType, ...]: saved_message, trial, ) + from .human_input_v2 import channel as human_input_channel + from .human_input_v2 import contact as human_input_contact + from .human_input_v2 import form as human_input_v2_form from .knowledge_fs import features as knowledge_fs_features from .knowledge_fs import resources as knowledge_fs_resources from .snippets import snippet_workflow, snippet_workflow_draft_variable @@ -264,6 +268,8 @@ def _register_controllers() -> tuple[ModuleType, ...]: extension, feature, human_input_form, + human_input_channel, + human_input_v2_form, init_validate, notification, onboarding, @@ -299,6 +305,7 @@ def _register_controllers() -> tuple[ModuleType, ...]: workflow_app_log, workflow_comment, workflow_draft_variable, + workflow_human_input_v2, workflow_node_output_inspector, workflow_run, workflow_statistic, @@ -343,6 +350,7 @@ def _register_controllers() -> tuple[ModuleType, ...]: account, agent_providers, endpoint, + human_input_contact, load_balancing_config, members, model_providers, @@ -363,6 +371,8 @@ def _register_controllers() -> tuple[ModuleType, ...]: extension, feature, human_input_form, + human_input_channel, + human_input_v2_form, init_validate, notification, onboarding, @@ -398,6 +408,7 @@ def _register_controllers() -> tuple[ModuleType, ...]: workflow_app_log, workflow_comment, workflow_draft_variable, + workflow_human_input_v2, workflow_node_output_inspector, workflow_run, workflow_statistic, @@ -442,6 +453,7 @@ def _register_controllers() -> tuple[ModuleType, ...]: account, agent_providers, endpoint, + human_input_contact, load_balancing_config, members, model_providers, @@ -501,7 +513,10 @@ __all__ = [ "forgot_password", "generator", "hit_testing", + "human_input_channel", + "human_input_contact", "human_input_form", + "human_input_v2_form", "init_validate", "installed_app", "knowledge_fs_features", @@ -551,6 +566,7 @@ __all__ = [ "workflow_app_log", "workflow_comment", "workflow_draft_variable", + "workflow_human_input_v2", "workflow_node_output_inspector", "workflow_run", "workflow_run_archive", diff --git a/api/controllers/console/app/workflow_human_input_v2.py b/api/controllers/console/app/workflow_human_input_v2.py new file mode 100644 index 00000000000..f5975ee8114 --- /dev/null +++ b/api/controllers/console/app/workflow_human_input_v2.py @@ -0,0 +1,90 @@ +"""Draft Human Input v2 message-template tests for the authenticated editor.""" + +from __future__ import annotations + +from http import HTTPStatus + +from flask_restx import Resource + +from controllers.common.human_input_v2_contracts import ( + MessageTemplateTestRequest, + MessageTemplateTestResponse, +) +from controllers.common.rbac import PlainApp, RBACCheck +from controllers.common.schema import register_response_schema_models, register_schema_models +from controllers.console import console_ns +from controllers.console.wraps import ( + RBACPermission, + account_initialization_required, + edit_permission_required, + rbac_permission_required, + setup_required, + with_current_user, +) +from libs.exception import BaseHTTPException +from libs.login import login_required +from models import Account +from models.model import App, AppMode +from services.human_input_v2.message_template_test_composition import build_message_template_test_service +from services.human_input_v2.message_template_test_service import MessageTemplateSendError, MessageTemplateTestError + +from .wraps import get_app_model + +register_schema_models(console_ns, MessageTemplateTestRequest) +register_response_schema_models(console_ns, MessageTemplateTestResponse) + + +class MessageTemplateTestHttpError(BaseHTTPException): + code = HTTPStatus.BAD_REQUEST + error_code = "message_template_test_invalid" + + +class MessageTemplateSendHttpError(BaseHTTPException): + code = HTTPStatus.BAD_GATEWAY + error_code = "message_template_test_send_failed" + + +def _send_test(current_user: Account, app_model: App, node_id: str) -> dict[str, object]: + payload = MessageTemplateTestRequest.model_validate(console_ns.payload or {}) + service = build_message_template_test_service( + tenant_id=app_model.tenant_id, account_id=current_user.id, account_email=current_user.email + ) + try: + service.send_test(app_id=app_model.id, node_id=node_id, channel=payload.channel, inputs=payload.inputs) + except MessageTemplateSendError as error: + raise MessageTemplateSendHttpError(str(error)) from None + except MessageTemplateTestError as error: + raise MessageTemplateTestHttpError(str(error)) from None + return MessageTemplateTestResponse().model_dump(mode="json") + + +@console_ns.route("/apps//workflows/draft/human-input/nodes//message-template/test") +class WorkflowDraftMessageTemplateTestApi(Resource): + @console_ns.expect(console_ns.models[MessageTemplateTestRequest.__name__]) + @console_ns.response(200, "Success", console_ns.models[MessageTemplateTestResponse.__name__]) + @setup_required + @login_required + @account_initialization_required + @rbac_permission_required(RBACCheck(RBACPermission.APP_TEST_AND_RUN, PlainApp())) + @get_app_model(mode=[AppMode.WORKFLOW]) + @with_current_user + @edit_permission_required + def post(self, current_user: Account, app_model: App, node_id: str): + return _send_test(current_user, app_model, node_id) + + +@console_ns.route( + "/apps//advanced-chat/workflows/draft/human-input/nodes//message-template/test" +) +class AdvancedChatDraftMessageTemplateTestApi(Resource): + @console_ns.expect(console_ns.models[MessageTemplateTestRequest.__name__]) + @console_ns.response(200, "Success", console_ns.models[MessageTemplateTestResponse.__name__]) + @setup_required + @login_required + @account_initialization_required + @rbac_permission_required(RBACCheck(RBACPermission.APP_TEST_AND_RUN, PlainApp())) + @get_app_model(mode=[AppMode.ADVANCED_CHAT]) + @with_current_user + @edit_permission_required + def post(self, current_user: Account, app_model: App, node_id: str): + return _send_test(current_user, app_model, node_id) diff --git a/api/controllers/console/app/workflow_run.py b/api/controllers/console/app/workflow_run.py index c661e2511b4..b7ecf38d614 100644 --- a/api/controllers/console/app/workflow_run.py +++ b/api/controllers/console/app/workflow_run.py @@ -104,6 +104,7 @@ def _triggered_from(value: str | None) -> WorkflowRunTriggeredFrom: class HumanInputPauseTypeResponse(ResponseModel): type: Literal["human_input"] form_id: str + form_version: Literal["1", "2"] = "1" backstage_input_url: str | None = None @@ -408,6 +409,7 @@ class ConsoleWorkflowPauseDetailsApi(Resource): "pause_type": { "type": "human_input", "form_id": node.form_id, + "form_version": node.form_version, "backstage_input_url": _build_backstage_input_url(node.form_token), }, } diff --git a/api/controllers/console/human_input_v2/__init__.py b/api/controllers/console/human_input_v2/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/api/controllers/console/human_input_v2/_common.py b/api/controllers/console/human_input_v2/_common.py new file mode 100644 index 00000000000..107d70f8f82 --- /dev/null +++ b/api/controllers/console/human_input_v2/_common.py @@ -0,0 +1,7 @@ +from pydantic import BaseModel, ConfigDict + + +class StrictModel(BaseModel): + """Base request model that forbids unknown fields while accepting JSON-native values.""" + + model_config = ConfigDict(extra="forbid", strict=True, hide_input_in_errors=True) diff --git a/api/controllers/console/human_input_v2/_decorators.py b/api/controllers/console/human_input_v2/_decorators.py new file mode 100644 index 00000000000..f61e642ea32 --- /dev/null +++ b/api/controllers/console/human_input_v2/_decorators.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +from collections.abc import Callable +from functools import wraps +from http import HTTPStatus + +from flask import Response, abort +from werkzeug.exceptions import Forbidden + +from controllers.console.wraps import ( + account_initialization_required, + setup_required, +) +from libs.login import login_required +from models import Account +from models.account import TenantAccountRole +from services.feature_service import FeatureService + + +def workspace_channel_api_admission[**P, R](view: Callable[P, R]) -> Callable[P, R]: + """Reject Workspace Channel transport when IM is centrally managed before authentication.""" + + @wraps(view) + def admitted(*args: P.args, **kwargs: P.kwargs) -> R: + if FeatureService.is_im_centrally_managed(): + abort(HTTPStatus.NOT_IMPLEMENTED) + return view(*args, **kwargs) + + return admitted + + +def require_admin_or_owner[**P, R](view: Callable[P, R]) -> Callable[P, R | Response]: + """Apply the complete Console workspace owner/administrator guard stack.""" + + @wraps(view) + def admin_or_owner_required(*args: P.args, **kwargs: P.kwargs) -> R: + from libs.login import current_user + + user = current_user._get_current_object() + # Keep this check synchronized with the non-RBAC branch in + # controllers.console.wraps.is_admin_or_owner_required. + # TODO(QuantumGhost): Introduce RBAC authorization for Human Input channel management. + if not isinstance(user, Account) or not TenantAccountRole.is_privileged_role(user.role): + raise Forbidden() + return view(*args, **kwargs) + + return setup_required( + login_required( + account_initialization_required( + admin_or_owner_required, + ) + ) + ) + + +def require_editor[**P, R](view: Callable[P, R]) -> Callable[P, R | Response]: + """Require an editing workspace role independently of RBAC configuration.""" + + @wraps(view) + def editor_required(*args: P.args, **kwargs: P.kwargs) -> R: + from libs.login import current_user + + user = current_user._get_current_object() + # Contact selection follows workspace roles even when RBAC is enabled. + if not isinstance(user, Account) or not TenantAccountRole.is_editing_role(user.role): + raise Forbidden() + return view(*args, **kwargs) + + return setup_required(login_required(account_initialization_required(editor_required))) + + +__all__ = ["require_admin_or_owner", "require_editor", "workspace_channel_api_admission"] diff --git a/api/controllers/console/human_input_v2/channel.py b/api/controllers/console/human_input_v2/channel.py new file mode 100644 index 00000000000..6316c56453e --- /dev/null +++ b/api/controllers/console/human_input_v2/channel.py @@ -0,0 +1,696 @@ +"""Canonical Console transport for Human Input Email and IM Channels.""" + +from __future__ import annotations + +from collections.abc import Sequence +from enum import StrEnum +from http import HTTPStatus +from typing import Annotated, Literal +from uuid import UUID + +from flask import request +from flask.typing import ResponseReturnValue +from flask_restx import Resource +from pydantic import Field, StringConstraints + +from controllers.common.schema import ( + query_params_from_model, + register_response_schema_models, + register_schema_models, +) +from controllers.console import console_ns +from controllers.console.wraps import with_current_tenant_id, with_current_user +from core.human_input_v2.entities import ( + ChannelKind, + EmailProviderType, + IMProvider, +) +from core.human_input_v2.shared import ( + AccountId, + EmailProviderId, + NormalizedEmail, + TenantId, + WorkspaceScope, +) +from fields.base import ResponseModel +from fields.timestamp import Timestamp +from libs.helper import EmailStr +from models.account import Account +from repositories.human_input_v2.email_channel import EmailChannelView, EmailConfigurationSnapshot +from repositories.human_input_v2.email_channel.entities import ResendCandidate +from repositories.human_input_v2.im_channel_repository import IMChannelId, IMChannelStatus +from services.human_input_v2.credential_update import ResendChannelUpdate, Unchanged +from services.human_input_v2.email_channel_management_composition import ( + build_human_input_email_channel_management_service, +) +from services.human_input_v2.errors import ( + ChannelAlreadyConfiguredError, + ChannelNotFoundError, + ChannelProviderError, + ProviderConfigurationUpdatedError, + ReplacementRequiredError, +) +from services.human_input_v2.im_channel_service import IMChannelView, WorkspaceIMChannelService +from services.human_input_v2.im_channel_service_composition import ( + build_workspace_im_channel_service, +) + +from ._common import StrictModel +from ._decorators import require_admin_or_owner, workspace_channel_api_admission +from .config_version import ( + InvalidConfigVersionError, + decode_email_config_version, + decode_im_config_version, + encode_email_config_version, + encode_im_config_version, +) +from .errors import ( + ChannelAlreadyConfiguredHttpError, + ChannelNotFoundHttpError, + ChannelProviderBadRequestHttpError, + ChannelProviderConfigurationUpdatedHttpError, + ChannelReplacementRequiredHttpError, +) +from .provider_updates import EmailProviderCredentialsUpdateInput, IMProviderCredentialsUpdateInput, UnchangedInput +from .provider_views import ( + IMProviderCredentialsResponse, + Redacted, + ResendCredentialsResponse, + im_credentials_response, +) +from .providers import EmailProviderCredentials, IMProviderCredentialsInput + +type ChannelId = Annotated[str, StringConstraints(strip_whitespace=True, min_length=1)] +type ConfigVersion = Annotated[str, StringConstraints(strip_whitespace=True, min_length=1)] + + +class WorkspaceChannelResource(Resource): + """Apply the edition gate outside every canonical handler decorator stack.""" + + method_decorators = [workspace_channel_api_admission] + + +class ChannelStatus(StrEnum): + CONFIGURED = "configured" + CONNECTED = "connected" + INVALID_CREDENTIALS = "invalid_credentials" + CONNECTION_FAILURE = "connection_failure" + + +class ConnectionMode(StrEnum): + CUSTOM_APP = "custom_app" + + +class ChannelTestResponse(ResponseModel): + status: ChannelStatus + status_description: str + + +class ChannelDeleteResponse(ResponseModel): + channel_id: ChannelId + + +class ChannelConflictResponse(ResponseModel): + code: Literal["replacement_required", "provider_configuration_updated"] + message: str + status: Literal[HTTPStatus.CONFLICT] = HTTPStatus.CONFLICT + + +class ChannelProvider(ResponseModel): + provider: EmailProviderType | IMProvider + connection_mode: Literal[ConnectionMode.CUSTOM_APP] = ConnectionMode.CUSTOM_APP + + +class ListChannelProvidersResponse(ResponseModel): + email_providers: Sequence[ChannelProvider] + im_providers: Sequence[ChannelProvider] + + +class ChannelSummary(ResponseModel): + id: ChannelId + created_at: Timestamp + updated_at: Timestamp + kind: Literal[ChannelKind.EMAIL, ChannelKind.IM] + provider: EmailProviderType | IMProvider = Field( + description="The provider of the Channel. The concrete provider type depends on the `kind` field." + ) + status: ChannelStatus + status_description: str = Field( + description="Human-readable status description. Empty when the status is `configured` or `connected`." + ) + display_identifier: str = Field(description="The display identifier of the Channel.") + webhook_url: str | None = Field( + description=( + "Webhook URL to configure on the provider side. None when webhook configuration is not required " + "or the provider does not support webhooks." + ) + ) + config_version: ConfigVersion = Field( + description="The current opaque configuration version used for optimistic concurrency control." + ) + + +class ListChannelsResponse(ResponseModel): + channels: Sequence[ChannelSummary] + + +type _SenderName = Annotated[str, StringConstraints(strip_whitespace=True, min_length=1, max_length=255)] + + +class _EmailChannelCandidatePayload(StrictModel): + sender_email: EmailStr + sender_name: _SenderName + credentials: EmailProviderCredentials + + def to_owner_candidate(self) -> ResendCandidate: + return ResendCandidate( + sender_email=NormalizedEmail(self.sender_email), + sender_name=self.sender_name, + credentials=self.credentials.to_owner_credentials(), + ) + + +class EmailChannelTestPayload(_EmailChannelCandidatePayload): + pass + + +class EmailChannelCreatePayload(_EmailChannelCandidatePayload): + pass + + +class EmailChannelMutationResponse(ResponseModel): + summary: ChannelSummary + + +class EmailChannelDetailResponse(ResponseModel): + summary: ChannelSummary + sender_name: str + sender_email: str + credentials: ResendCredentialsResponse + + +class EmailChannelUpdatePayload(StrictModel): + sender_email: EmailStr | UnchangedInput + sender_name: _SenderName | UnchangedInput + credentials: EmailProviderCredentialsUpdateInput + expected_config_version: ConfigVersion + + def to_owner_update(self) -> ResendChannelUpdate: + return ResendChannelUpdate( + sender_email=( + Unchanged.VALUE if isinstance(self.sender_email, UnchangedInput) else NormalizedEmail(self.sender_email) + ), + sender_name=Unchanged.VALUE if isinstance(self.sender_name, UnchangedInput) else self.sender_name, + credentials=self.credentials.to_owner_update(), + ) + + +class ChannelDeleteQuery(StrictModel): + expected_config_version: ConfigVersion + + +class IMChannelTestPayload(StrictModel): + credentials: IMProviderCredentialsInput + + +class IMChannelCreatePayload(StrictModel): + credentials: IMProviderCredentialsInput + + +class IMChannelMutationResponse(ResponseModel): + summary: ChannelSummary + + +class IMChannelDetailResponse(ResponseModel): + summary: ChannelSummary + credentials: IMProviderCredentialsResponse + + +class IMChannelUpdatePayload(StrictModel): + credentials: IMProviderCredentialsUpdateInput + expected_config_version: ConfigVersion + + +class IMChannelReplacementPayload(StrictModel): + credentials: IMProviderCredentialsInput + expected_config_version: ConfigVersion + + +register_schema_models( + console_ns, + ChannelDeleteQuery, + EmailChannelTestPayload, + EmailChannelCreatePayload, + EmailChannelUpdatePayload, + IMChannelTestPayload, + IMChannelCreatePayload, + IMChannelUpdatePayload, + IMChannelReplacementPayload, +) +register_response_schema_models( + console_ns, + ChannelTestResponse, + ChannelDeleteResponse, + ChannelConflictResponse, + ChannelProvider, + ListChannelProvidersResponse, + ChannelSummary, + ListChannelsResponse, + EmailChannelMutationResponse, + EmailChannelDetailResponse, + IMChannelMutationResponse, + IMChannelDetailResponse, +) + + +def _workspace_scope(tenant_id: str) -> WorkspaceScope: + return WorkspaceScope(id=TenantId(tenant_id)) + + +def _actor_id(account: Account) -> AccountId: + return AccountId(account.id) + + +def _channel_provider_response(provider: EmailProviderType | IMProvider) -> ChannelProvider: + return ChannelProvider(provider=provider) + + +def _email_channel_summary_response(view: EmailChannelView) -> ChannelSummary: + return ChannelSummary( + id=view.id, + created_at=view.created_at, + updated_at=view.updated_at, + kind=ChannelKind.EMAIL, + provider=view.provider, + status=ChannelStatus.CONFIGURED, + status_description="", + display_identifier=" ".join(part for part in (view.sender_name, view.sender_email) if part), + webhook_url=None, + config_version=encode_email_config_version(view.revision), + ) + + +def _im_channel_summary_response(view: IMChannelView) -> ChannelSummary: + status, status_description = _im_channel_status(view) + return ChannelSummary( + id=view.id, + created_at=view.created_at, + updated_at=view.updated_at, + kind=ChannelKind.IM, + provider=view.provider, + status=status, + status_description=status_description, + display_identifier=view.app_identifier, + webhook_url=view.webhook_url, + config_version=encode_im_config_version(view.id, view.config_version), + ) + + +def _im_channel_status(view: IMChannelView) -> tuple[ChannelStatus, str]: + if view.status is IMChannelStatus.CONNECTED: + return ChannelStatus.CONNECTED, "" + if view.status is IMChannelStatus.INVALID_CREDENTIALS: + return ( + ChannelStatus.INVALID_CREDENTIALS, + view.status_reason or "The configured credentials are no longer accepted.", + ) + return ( + ChannelStatus.CONNECTION_FAILURE, + view.status_reason or "The configured provider connection is unavailable.", + ) + + +def _email_revision(value: str, channel_id: EmailProviderId) -> EmailConfigurationSnapshot: + try: + return decode_email_config_version(value, channel_id) + except InvalidConfigVersionError as error: + raise ChannelProviderConfigurationUpdatedHttpError() from error + + +def _im_config_version(value: str, channel_id: IMChannelId) -> int: + try: + return decode_im_config_version(value, channel_id) + except InvalidConfigVersionError as error: + raise ChannelProviderConfigurationUpdatedHttpError() from error + + +def _workspace_im_channel_service(tenant_id: str, account: Account) -> WorkspaceIMChannelService: + return build_workspace_im_channel_service(TenantId(tenant_id), _actor_id(account)) + + +@console_ns.route("/workspaces/current/human-input/v2/channel-providers") +class ListChannelProvidersApi(WorkspaceChannelResource): + @console_ns.response(200, "Success", console_ns.models[ListChannelProvidersResponse.__name__]) + @require_admin_or_owner + @with_current_user + @with_current_tenant_id + def get(self, tenant_id: str, current_user: Account) -> ResponseReturnValue: + email_providers = build_human_input_email_channel_management_service().available_providers() + im_providers = _workspace_im_channel_service(tenant_id, current_user).available_providers() + return ListChannelProvidersResponse( + email_providers=[_channel_provider_response(provider) for provider in email_providers], + im_providers=[_channel_provider_response(provider) for provider in im_providers], + ).model_dump(mode="json") + + +@console_ns.route("/workspaces/current/human-input/v2/channels") +class ListChannelsApi(WorkspaceChannelResource): + @console_ns.response(200, "Success", console_ns.models[ListChannelsResponse.__name__]) + @require_admin_or_owner + @with_current_user + @with_current_tenant_id + def get(self, tenant_id: str, current_user: Account) -> ResponseReturnValue: + workspace_scope = _workspace_scope(tenant_id) + email = build_human_input_email_channel_management_service().get_current(workspace_scope) + im = _workspace_im_channel_service(tenant_id, current_user).get_current() + channels: list[ChannelSummary] = [] + if email is not None: + channels.append(_email_channel_summary_response(email)) + if im is not None: + channels.append(_im_channel_summary_response(im)) + return ListChannelsResponse(channels=channels).model_dump(mode="json") + + +@console_ns.route("/workspaces/current/human-input/v2/channels/email/test") +class EmailChannelTestApi(WorkspaceChannelResource): + @console_ns.expect(console_ns.models[EmailChannelTestPayload.__name__]) + @console_ns.response(200, "Success", console_ns.models[ChannelTestResponse.__name__]) + @require_admin_or_owner + @with_current_tenant_id + def post(self, tenant_id: str) -> ResponseReturnValue: + request_body = EmailChannelTestPayload.model_validate(console_ns.payload or {}) + candidate = request_body.to_owner_candidate() + try: + build_human_input_email_channel_management_service().test( + _workspace_scope(tenant_id), + candidate, + ) + except ChannelProviderError as error: + return ChannelTestResponse( + status=ChannelStatus(error.kind.value), + status_description=error.status_description, + ).model_dump(mode="json") + return ChannelTestResponse( + status=ChannelStatus.CONNECTED, + status_description="", + ).model_dump(mode="json") + + +@console_ns.route("/workspaces/current/human-input/v2/channels/email//test") +class EmailChannelSavedTestApi(WorkspaceChannelResource): + @console_ns.expect(console_ns.models[EmailChannelUpdatePayload.__name__]) + @console_ns.response(200, "Success", console_ns.models[ChannelTestResponse.__name__]) + @console_ns.response(409, "Configuration conflict", console_ns.models[ChannelConflictResponse.__name__]) + @require_admin_or_owner + @with_current_tenant_id + def post(self, tenant_id: str, channel_id: UUID) -> ResponseReturnValue: + request_body = EmailChannelUpdatePayload.model_validate(console_ns.payload or {}) + addressed_id = EmailProviderId(str(channel_id)) + try: + build_human_input_email_channel_management_service().test_saved( + _workspace_scope(tenant_id), + addressed_id, + _email_revision(request_body.expected_config_version, addressed_id), + request_body.to_owner_update(), + ) + except ChannelNotFoundError as error: + raise ChannelNotFoundHttpError() from error + except ProviderConfigurationUpdatedError as error: + raise ChannelProviderConfigurationUpdatedHttpError() from error + except ChannelProviderError as error: + return ChannelTestResponse( + status=ChannelStatus(error.kind.value), + status_description=error.status_description, + ).model_dump(mode="json") + return ChannelTestResponse(status=ChannelStatus.CONNECTED, status_description="").model_dump(mode="json") + + +@console_ns.route("/workspaces/current/human-input/v2/channels/email") +class EmailChannelCreateApi(WorkspaceChannelResource): + @console_ns.expect(console_ns.models[EmailChannelCreatePayload.__name__]) + @console_ns.response(200, "Success", console_ns.models[EmailChannelMutationResponse.__name__]) + @require_admin_or_owner + @with_current_user + @with_current_tenant_id + def post(self, tenant_id: str, current_user: Account) -> ResponseReturnValue: + request_body = EmailChannelCreatePayload.model_validate(console_ns.payload or {}) + try: + snapshot = build_human_input_email_channel_management_service().create( + _workspace_scope(tenant_id), + _actor_id(current_user), + request_body.to_owner_candidate(), + ) + except ChannelAlreadyConfiguredError as error: + raise ChannelAlreadyConfiguredHttpError() from error + except ChannelProviderError as error: + raise ChannelProviderBadRequestHttpError(error.status_description) from error + return EmailChannelMutationResponse(summary=_email_channel_summary_response(snapshot)).model_dump(mode="json") + + +@console_ns.route("/workspaces/current/human-input/v2/channels/email/") +class EmailChannelApi(WorkspaceChannelResource): + @console_ns.response(200, "Success", console_ns.models[EmailChannelDetailResponse.__name__]) + @require_admin_or_owner + @with_current_tenant_id + def get(self, tenant_id: str, channel_id: UUID) -> ResponseReturnValue: + try: + snapshot = build_human_input_email_channel_management_service().get( + _workspace_scope(tenant_id), + EmailProviderId(str(channel_id)), + ) + except ChannelNotFoundError as error: + raise ChannelNotFoundHttpError() from error + return EmailChannelDetailResponse( + summary=_email_channel_summary_response(snapshot), + sender_name=snapshot.sender_name, + sender_email=snapshot.sender_email, + credentials=ResendCredentialsResponse(provider=EmailProviderType.RESEND, api_key=Redacted()), + ).model_dump(mode="json") + + @console_ns.expect(console_ns.models[EmailChannelUpdatePayload.__name__]) + @console_ns.response(200, "Success", console_ns.models[EmailChannelMutationResponse.__name__]) + @console_ns.response(409, "Configuration conflict", console_ns.models[ChannelConflictResponse.__name__]) + @require_admin_or_owner + @with_current_user + @with_current_tenant_id + def put(self, tenant_id: str, current_user: Account, channel_id: UUID) -> ResponseReturnValue: + request_body = EmailChannelUpdatePayload.model_validate(console_ns.payload or {}) + addressed_id = EmailProviderId(str(channel_id)) + try: + snapshot = build_human_input_email_channel_management_service().update( + _workspace_scope(tenant_id), + addressed_id, + _email_revision(request_body.expected_config_version, addressed_id), + _actor_id(current_user), + request_body.to_owner_update(), + ) + except ChannelNotFoundError as error: + raise ChannelNotFoundHttpError() from error + except ProviderConfigurationUpdatedError as error: + raise ChannelProviderConfigurationUpdatedHttpError() from error + except ChannelProviderError as error: + raise ChannelProviderBadRequestHttpError(error.status_description) from error + return EmailChannelMutationResponse(summary=_email_channel_summary_response(snapshot)).model_dump(mode="json") + + @console_ns.doc(params=query_params_from_model(ChannelDeleteQuery)) + @console_ns.response(200, "Success", console_ns.models[ChannelDeleteResponse.__name__]) + @console_ns.response(409, "Configuration conflict", console_ns.models[ChannelConflictResponse.__name__]) + @require_admin_or_owner + @with_current_tenant_id + def delete(self, tenant_id: str, channel_id: UUID) -> ResponseReturnValue: + query = ChannelDeleteQuery.model_validate(request.args.to_dict(flat=True)) + addressed_id = EmailProviderId(str(channel_id)) + try: + deleted_id = build_human_input_email_channel_management_service().delete( + _workspace_scope(tenant_id), + addressed_id, + _email_revision(query.expected_config_version, addressed_id), + ) + except ChannelNotFoundError as error: + raise ChannelNotFoundHttpError() from error + except ProviderConfigurationUpdatedError as error: + raise ChannelProviderConfigurationUpdatedHttpError() from error + return ChannelDeleteResponse(channel_id=deleted_id).model_dump(mode="json") + + +@console_ns.route("/workspaces/current/human-input/v2/channels/im/test") +class IMChannelTestApi(WorkspaceChannelResource): + @console_ns.expect(console_ns.models[IMChannelTestPayload.__name__]) + @console_ns.response(200, "Success", console_ns.models[ChannelTestResponse.__name__]) + @require_admin_or_owner + @with_current_user + @with_current_tenant_id + def post(self, tenant_id: str, current_user: Account) -> ResponseReturnValue: + request_body = IMChannelTestPayload.model_validate(console_ns.payload or {}) + try: + _workspace_im_channel_service(tenant_id, current_user).test(request_body.credentials.to_owner_credentials()) + except ChannelProviderError as error: + return ChannelTestResponse( + status=ChannelStatus(error.kind.value), + status_description=error.status_description, + ).model_dump(mode="json") + return ChannelTestResponse( + status=ChannelStatus.CONNECTED, + status_description="", + ).model_dump(mode="json") + + +@console_ns.route("/workspaces/current/human-input/v2/channels/im//test") +class IMChannelSavedTestApi(WorkspaceChannelResource): + @console_ns.expect(console_ns.models[IMChannelUpdatePayload.__name__]) + @console_ns.response(200, "Success", console_ns.models[ChannelTestResponse.__name__]) + @console_ns.response(400, "The test provider must match the saved IM Channel.") + @console_ns.response(409, "Configuration conflict", console_ns.models[ChannelConflictResponse.__name__]) + @require_admin_or_owner + @with_current_user + @with_current_tenant_id + def post(self, tenant_id: str, current_user: Account, channel_id: UUID) -> ResponseReturnValue: + request_body = IMChannelUpdatePayload.model_validate(console_ns.payload or {}) + addressed_id = IMChannelId(str(channel_id)) + try: + _workspace_im_channel_service(tenant_id, current_user).test_saved( + addressed_id, + _im_config_version(request_body.expected_config_version, addressed_id), + request_body.credentials.to_owner_update(), + ) + except ChannelNotFoundError as error: + raise ChannelNotFoundHttpError() from error + except ProviderConfigurationUpdatedError as error: + raise ChannelProviderConfigurationUpdatedHttpError() from error + except ReplacementRequiredError as error: + raise ChannelProviderBadRequestHttpError("The test provider must match the saved IM Channel.") from error + except ChannelProviderError as error: + return ChannelTestResponse( + status=ChannelStatus(error.kind.value), + status_description=error.status_description, + ).model_dump(mode="json") + return ChannelTestResponse(status=ChannelStatus.CONNECTED, status_description="").model_dump(mode="json") + + +@console_ns.route("/workspaces/current/human-input/v2/channels/im") +class IMChannelCreateApi(WorkspaceChannelResource): + @console_ns.expect(console_ns.models[IMChannelCreatePayload.__name__]) + @console_ns.response(200, "Success", console_ns.models[IMChannelMutationResponse.__name__]) + @require_admin_or_owner + @with_current_user + @with_current_tenant_id + def post(self, tenant_id: str, current_user: Account) -> ResponseReturnValue: + request_body = IMChannelCreatePayload.model_validate(console_ns.payload or {}) + try: + snapshot = _workspace_im_channel_service(tenant_id, current_user).create( + request_body.credentials.to_owner_credentials() + ) + except ChannelAlreadyConfiguredError as error: + raise ChannelAlreadyConfiguredHttpError() from error + except ChannelProviderError as error: + raise ChannelProviderBadRequestHttpError(error.status_description) from error + return IMChannelMutationResponse(summary=_im_channel_summary_response(snapshot)).model_dump(mode="json") + + +@console_ns.route("/workspaces/current/human-input/v2/channels/im/") +class IMChannelApi(WorkspaceChannelResource): + @console_ns.response(200, "Success", console_ns.models[IMChannelDetailResponse.__name__]) + @require_admin_or_owner + @with_current_user + @with_current_tenant_id + def get(self, tenant_id: str, current_user: Account, channel_id: UUID) -> ResponseReturnValue: + try: + detail = _workspace_im_channel_service(tenant_id, current_user).get_detail(IMChannelId(str(channel_id))) + except ChannelNotFoundError as error: + raise ChannelNotFoundHttpError() from error + return IMChannelDetailResponse( + summary=_im_channel_summary_response(detail.summary), + credentials=im_credentials_response(detail.credentials), + ).model_dump(mode="json") + + @console_ns.expect(console_ns.models[IMChannelUpdatePayload.__name__]) + @console_ns.response(200, "Success", console_ns.models[IMChannelMutationResponse.__name__]) + @console_ns.response(409, "Configuration conflict", console_ns.models[ChannelConflictResponse.__name__]) + @require_admin_or_owner + @with_current_user + @with_current_tenant_id + def put(self, tenant_id: str, current_user: Account, channel_id: UUID) -> ResponseReturnValue: + request_body = IMChannelUpdatePayload.model_validate(console_ns.payload or {}) + addressed_id = IMChannelId(str(channel_id)) + try: + snapshot = _workspace_im_channel_service(tenant_id, current_user).update( + addressed_id, + _im_config_version(request_body.expected_config_version, addressed_id), + request_body.credentials.to_owner_update(), + ) + except ChannelNotFoundError as error: + raise ChannelNotFoundHttpError() from error + except ReplacementRequiredError as error: + raise ChannelReplacementRequiredHttpError() from error + except ProviderConfigurationUpdatedError as error: + raise ChannelProviderConfigurationUpdatedHttpError() from error + except ChannelProviderError as error: + raise ChannelProviderBadRequestHttpError(error.status_description) from error + return IMChannelMutationResponse(summary=_im_channel_summary_response(snapshot)).model_dump(mode="json") + + @console_ns.doc(params=query_params_from_model(ChannelDeleteQuery)) + @console_ns.response(200, "Success", console_ns.models[ChannelDeleteResponse.__name__]) + @console_ns.response(409, "Configuration conflict", console_ns.models[ChannelConflictResponse.__name__]) + @require_admin_or_owner + @with_current_user + @with_current_tenant_id + def delete(self, tenant_id: str, current_user: Account, channel_id: UUID) -> ResponseReturnValue: + query = ChannelDeleteQuery.model_validate(request.args.to_dict(flat=True)) + addressed_id = IMChannelId(str(channel_id)) + try: + deleted_id = _workspace_im_channel_service(tenant_id, current_user).delete( + addressed_id, + _im_config_version(query.expected_config_version, addressed_id), + ) + except ChannelNotFoundError as error: + raise ChannelNotFoundHttpError() from error + except ProviderConfigurationUpdatedError as error: + raise ChannelProviderConfigurationUpdatedHttpError() from error + return ChannelDeleteResponse(channel_id=deleted_id).model_dump(mode="json") + + +@console_ns.route("/workspaces/current/human-input/v2/channels/im//replacement") +class IMChannelReplacementApi(WorkspaceChannelResource): + @console_ns.expect(console_ns.models[IMChannelReplacementPayload.__name__]) + @console_ns.response(200, "Success", console_ns.models[IMChannelMutationResponse.__name__]) + @console_ns.response(409, "Configuration conflict", console_ns.models[ChannelConflictResponse.__name__]) + @require_admin_or_owner + @with_current_user + @with_current_tenant_id + def post(self, tenant_id: str, current_user: Account, channel_id: UUID) -> ResponseReturnValue: + request_body = IMChannelReplacementPayload.model_validate(console_ns.payload or {}) + addressed_id = IMChannelId(str(channel_id)) + try: + snapshot = _workspace_im_channel_service(tenant_id, current_user).replace( + addressed_id, + _im_config_version(request_body.expected_config_version, addressed_id), + request_body.credentials.to_owner_credentials(), + ) + except ChannelNotFoundError as error: + raise ChannelNotFoundHttpError() from error + except ProviderConfigurationUpdatedError as error: + raise ChannelProviderConfigurationUpdatedHttpError() from error + except ChannelProviderError as error: + raise ChannelProviderBadRequestHttpError(error.status_description) from error + return IMChannelMutationResponse(summary=_im_channel_summary_response(snapshot)).model_dump(mode="json") + + +__all__ = [ + "ChannelConflictResponse", + "ChannelDeleteQuery", + "ChannelDeleteResponse", + "ChannelProvider", + "ChannelSummary", + "ChannelTestResponse", + "EmailChannelCreatePayload", + "EmailChannelDetailResponse", + "EmailChannelTestPayload", + "EmailChannelUpdatePayload", + "IMChannelCreatePayload", + "IMChannelDetailResponse", + "IMChannelReplacementPayload", + "IMChannelTestPayload", + "IMChannelUpdatePayload", + "ListChannelProvidersResponse", + "ListChannelsResponse", +] diff --git a/api/controllers/console/human_input_v2/config_version.py b/api/controllers/console/human_input_v2/config_version.py new file mode 100644 index 00000000000..8417ff31406 --- /dev/null +++ b/api/controllers/console/human_input_v2/config_version.py @@ -0,0 +1,93 @@ +"""Transport-only encoding for owner-native Channel revision tokens.""" + +from __future__ import annotations + +import base64 +import json +from typing import Literal + +from core.human_input_v2.entities import ChannelKind +from core.human_input_v2.shared import EmailProviderId +from repositories.human_input_v2.email_channel import EmailConfigurationSnapshot +from repositories.human_input_v2.im_channel_repository import IMChannelId + +_FORMAT_VERSION = 1 +type _ConfigVersionChannel = Literal[ + ChannelKind.EMAIL, + ChannelKind.IM, +] + + +class InvalidConfigVersionError(ValueError): + """The opaque transport value does not name the addressed owner revision.""" + + +def encode_email_config_version(snapshot: EmailConfigurationSnapshot) -> str: + return _encode(ChannelKind.EMAIL, snapshot.configuration_id, snapshot.config_version) + + +def decode_email_config_version( + value: str, + channel_id: EmailProviderId, +) -> EmailConfigurationSnapshot: + revision = _decode(value, ChannelKind.EMAIL, channel_id) + return EmailConfigurationSnapshot(channel_id, revision) + + +def encode_im_config_version(channel_id: IMChannelId, config_version: int) -> str: + return _encode(ChannelKind.IM, channel_id, config_version) + + +def decode_im_config_version( + value: str, + channel_id: IMChannelId, +) -> int: + return _decode(value, ChannelKind.IM, channel_id) + + +def _encode( + kind: _ConfigVersionChannel, + channel_id: EmailProviderId | IMChannelId, + config_version: int, +) -> str: + serialized = json.dumps( + (_FORMAT_VERSION, kind.value, str(channel_id), config_version), + separators=(",", ":"), + ).encode("utf-8") + return base64.urlsafe_b64encode(serialized).rstrip(b"=").decode("ascii") + + +def _decode( + value: str, + expected_kind: _ConfigVersionChannel, + expected_channel_id: EmailProviderId | IMChannelId, +) -> int: + try: + padding = "=" * (-len(value) % 4) + serialized = base64.b64decode(value + padding, altchars=b"-_", validate=True) + decoded = json.loads(serialized) + except (UnicodeEncodeError, ValueError, json.JSONDecodeError): + raise InvalidConfigVersionError("invalid configuration version") from None + + if not isinstance(decoded, list) or len(decoded) != 4: + raise InvalidConfigVersionError("invalid configuration version") + format_version, kind, channel_id, config_version = decoded + if ( + format_version != _FORMAT_VERSION + or kind != expected_kind.value + or channel_id != str(expected_channel_id) + or isinstance(config_version, bool) + or not isinstance(config_version, int) + or config_version < 1 + ): + raise InvalidConfigVersionError("invalid configuration version") + return config_version + + +__all__ = [ + "InvalidConfigVersionError", + "decode_email_config_version", + "decode_im_config_version", + "encode_email_config_version", + "encode_im_config_version", +] diff --git a/api/controllers/console/human_input_v2/contact.py b/api/controllers/console/human_input_v2/contact.py new file mode 100644 index 00000000000..9f06e9aa3f9 --- /dev/null +++ b/api/controllers/console/human_input_v2/contact.py @@ -0,0 +1,984 @@ +"""Workspace Human Input v2 Contact management and editor APIs.""" + +from __future__ import annotations + +from http import HTTPStatus +from typing import Never +from uuid import UUID + +from flask import abort, request +from flask_restx import Resource +from sqlalchemy.orm import Session + +from controllers.common.human_input_v2_contracts import ( + AddPlatformContactsRequest, + AddPlatformContactsResponse, + BatchGetContactOptionsQuery, + BatchGetContactOptionsResponse, + BatchGetContactsQuery, + BatchGetContactsResponse, + ContactListQuery, + ContactOption, + ContactOptionsQuery, + CreateIMBindingRequest, + CreateIMBindingResponse, + CreateIMSyncRunResponse, + DeleteIMBindingResponse, + ExternalContactCreateRequest, + ExternalContactCreateResponse, + ExternalContactUpdateRequest, + ExternalContactUpdateResponse, + GetContactResponse, + GetLatestIMSyncRunResponse, + HumanInputContact, + HumanInputContactType, + IMContactSyncErrorResponse, + IMProvider, + IMSyncResultType, + IMSyncRunStatus, + ListContactOptionsResponse, + ListContactsResponse, + ListIMIdentitiesQuery, + ListIMIdentitiesResponse, + ListLatestIMSyncRunResultsQuery, + ListLatestIMSyncRunResultsResponse, + ListOrganizationCandidatesResponse, + NodeDataMigrationFailureResponse, + NodeDataMigrationPayload, + NodeDataMigrationResponse, + OrganizationCandidatesQuery, + RemoveContactsRequest, + RemoveContactsResponse, + ResetContactIMOverrideResponse, + SetContactIMOverrideRequest, + SetContactIMOverrideResponse, +) +from controllers.common.human_input_v2_migration import preflight_legacy_human_input_node_data +from controllers.common.schema import ( + query_params_from_model, + query_params_from_request, + register_enum_models, + register_response_schema_models, + register_schema_models, +) +from controllers.common.session import with_session +from controllers.console import console_ns +from controllers.console.workspace.error import IMBindingReadOnlyError, WorkspaceIMOverrideNotAllowedError +from controllers.console.wraps import ( + account_initialization_required, + edit_permission_required, + only_edition_enterprise, + setup_required, + with_current_tenant_id, + with_current_user, +) +from core.human_input_v2.im_integration import ( + ContactIMBindingView, + IMBindingCommandError, + IMBindingCommandErrorCode, + IMSyncRun, + SyncContactSnapshot, + SyncResultFact, +) +from core.human_input_v2.shared import ( + AccountId, + ContactId, + DeploymentScope, + DirectoryScope, + IMIdentityId, + TenantId, + WorkspaceScope, +) +from libs.datetime_utils import naive_utc_now +from libs.helper import build_avatar_url, dump_response +from libs.login import login_required +from libs.uuid_utils import uuidv7 +from models.account import Account +from repositories.human_input_v2.contact import ( + CandidateId, + Contact, + ContactError, + ContactErrorCode, + ContactQuery, + ContactType, + ExternalContact, +) +from repositories.human_input_v2.sqlalchemy_contact_repository import ( + SQLAlchemyContactIMBindingRepository, + SQLAlchemyContactRepository, +) +from repositories.human_input_v2.sqlalchemy_im_channel_repository import ( + DeploymentIMChannelReader, + WorkspaceIMChannelReader, +) +from repositories.human_input_v2.sqlalchemy_im_identity_repository import SQLAlchemyIMIdentityRepository +from services.enterprise.human_input_contact_composition import build_enterprise_contact_management_service +from services.feature_service import FeatureService +from services.human_input_v2.composition import build_human_input_node_data_migration_service +from services.human_input_v2.contact_service import ContactManagementService, ContactWithIMBindings +from services.human_input_v2.im_contact_sync.composition import build_im_contact_sync_application +from services.human_input_v2.im_contact_sync.errors import IMWriteUnavailableError +from services.human_input_v2.im_contact_sync.service import ( + IMChannelNotConfiguredError, + IMSyncDispatchUnavailableError, + IMSyncRevisionChangedError, + IMSyncRunNotFoundError, +) +from services.human_input_v2.node_data_migration import MigrationNode, NodeDataMigrationFailure + +from ._decorators import require_admin_or_owner, require_editor + +register_enum_models( + console_ns, + HumanInputContactType, + IMSyncRunStatus, + IMSyncResultType, + IMProvider, +) +register_schema_models( + console_ns, + ContactListQuery, + ContactOptionsQuery, + BatchGetContactOptionsQuery, + OrganizationCandidatesQuery, + AddPlatformContactsRequest, + ExternalContactCreateRequest, + ExternalContactUpdateRequest, + RemoveContactsRequest, + ListIMIdentitiesQuery, + ListLatestIMSyncRunResultsQuery, + SetContactIMOverrideRequest, + CreateIMBindingRequest, + NodeDataMigrationPayload, +) +register_response_schema_models( + console_ns, + HumanInputContact, + ContactOption, + GetContactResponse, + ExternalContactCreateResponse, + ExternalContactUpdateResponse, + AddPlatformContactsResponse, + ListContactsResponse, + ListContactOptionsResponse, + BatchGetContactOptionsResponse, + RemoveContactsResponse, + ListIMIdentitiesResponse, + CreateIMSyncRunResponse, + IMContactSyncErrorResponse, + GetLatestIMSyncRunResponse, + ListLatestIMSyncRunResultsResponse, + ListOrganizationCandidatesResponse, + ResetContactIMOverrideResponse, + SetContactIMOverrideResponse, + CreateIMBindingResponse, + DeleteIMBindingResponse, + BatchGetContactsResponse, + NodeDataMigrationResponse, + NodeDataMigrationFailureResponse, +) + + +_IM_BINDING_ERROR_STATUS = { + IMBindingCommandErrorCode.INTEGRATION_NOT_CONFIGURED: HTTPStatus.NOT_FOUND, + IMBindingCommandErrorCode.CONTACT_NOT_FOUND: HTTPStatus.NOT_FOUND, + IMBindingCommandErrorCode.IDENTITY_NOT_FOUND: HTTPStatus.NOT_FOUND, + IMBindingCommandErrorCode.BINDING_NOT_FOUND: HTTPStatus.NOT_FOUND, + IMBindingCommandErrorCode.BINDING_CONFLICT: HTTPStatus.CONFLICT, + IMBindingCommandErrorCode.INVALID_SCOPE: HTTPStatus.UNPROCESSABLE_ENTITY, +} + + +def _workspace_scope(tenant_id: str) -> WorkspaceScope: + return WorkspaceScope(id=TenantId(tenant_id)) + + +def _im_binding_scope(tenant_id: str) -> DirectoryScope: + if FeatureService.is_im_centrally_managed(): + return DeploymentScope() + return _workspace_scope(tenant_id) + + +type _IMApplicationError = ( + IMBindingCommandError + | IMChannelNotConfiguredError + | IMSyncDispatchUnavailableError + | IMSyncRevisionChangedError + | IMSyncRunNotFoundError + | IMWriteUnavailableError +) + + +def _im_application_error_response(error: _IMApplicationError) -> tuple[dict[str, object], HTTPStatus]: + if isinstance(error, IMBindingCommandError): + status = _IM_BINDING_ERROR_STATUS[error.code] + code = error.code.value + elif isinstance(error, IMChannelNotConfiguredError): + status = HTTPStatus.NOT_FOUND + code = "im_integration_not_configured" + elif isinstance(error, IMSyncRunNotFoundError): + status = HTTPStatus.NOT_FOUND + code = "im_sync_run_not_found" + elif isinstance(error, IMSyncDispatchUnavailableError): + status = HTTPStatus.SERVICE_UNAVAILABLE + code = "im_sync_dispatch_unavailable" + elif isinstance(error, IMWriteUnavailableError): + status = HTTPStatus.SERVICE_UNAVAILABLE + code = "im_write_unavailable" + else: + status = HTTPStatus.CONFLICT + code = "im_sync_revision_changed" + return ( + dump_response( + IMContactSyncErrorResponse, + {"code": code, "message": str(error), "status": status}, + ), + status, + ) + + +def _sync_run_payload(run: IMSyncRun) -> dict[str, object]: + return { + "id": run.id, + "status": run.status, + "started_at": run.started_at, + "finished_at": run.finished_at, + "error_message": run.error_message, + "result_counts": { + "added": run.added_count, + "not_matched": run.not_matched_count, + "failed": run.failed_count, + "removed": run.removed_count, + "skipped": run.skipped_count, + }, + "provider": run.provider, + "channel_id": run.channel_revision.channel_id, + "channel_config_version": run.channel_revision.config_version, + } + + +def _directory_entry_payload(result: SyncResultFact) -> dict[str, object] | None: + if result.provider_user_id is None: + return None + return { + "provider_user_id": result.provider_user_id, + "display_name": result.display_name, + "email": result.email, + } + + +def _avatar_url(avatar_file_id: str | None) -> str: + return build_avatar_url(avatar_file_id) or "" + + +def _sync_contact_payload(contact: SyncContactSnapshot, fallback_created_at) -> dict[str, object]: + # Historical result snapshots predate the Contact creation-time field. + created_at = contact.created_at if contact.created_at is not None else fallback_created_at + return { + "id": contact.contact_id, + "name": contact.name, + "avatar_url": _avatar_url(contact.avatar_file_id), + "created_at": created_at, + } + + +def _sync_result_payload(result: SyncResultFact) -> dict[str, object]: + entry = _directory_entry_payload(result) + if result.result_type is IMSyncResultType.NOT_MATCHED: + return {"type": result.result_type, "entry": entry} + if result.result_type is IMSyncResultType.FAILED: + return { + "type": result.result_type, + "entry": entry, + "reason": result.reason_message or result.reason_code or "sync_failed", + } + if result.result_type is IMSyncResultType.ADDED: + if result.contact_snapshot is None or entry is None: + raise RuntimeError("added sync result is missing its persisted display snapshot") + return { + "type": result.result_type, + "contact": _sync_contact_payload(result.contact_snapshot, result.created_at), + "entry": entry, + } + if result.result_type is IMSyncResultType.SKIPPED: + if result.contact_snapshot is None: + raise RuntimeError("skipped sync result is missing its persisted Contact snapshot") + return { + "type": result.result_type, + "entry": entry, + "contact": _sync_contact_payload(result.contact_snapshot, result.created_at), + } + if result.contact_snapshot is None or result.identity_snapshot is None or result.removal_reason is None: + raise RuntimeError("removed sync result is missing its persisted display snapshot") + return { + "type": result.result_type, + "contact": _sync_contact_payload(result.contact_snapshot, result.created_at), + "last_known_identity": { + "identity_id": result.identity_snapshot.identity_id, + "provider_user_id": result.identity_snapshot.provider_user_id, + "display_name": result.identity_snapshot.display_name, + "email": result.identity_snapshot.email, + }, + "reason": result.removal_reason, + } + + +def _contact_binding_payload(contact: ContactIMBindingView) -> dict[str, object]: + return { + "id": contact.id, + "type": contact.type, + "name": contact.name, + "email": contact.email, + "avatar_url": _avatar_url(contact.avatar_file_id), + "im_bindings": [ + {"id": binding.id, "provider": binding.provider, "scope": binding.scope} for binding in contact.im_bindings + ], + "created_at": contact.created_at, + } + + +def _contact_avatar_url(contact: Contact) -> str: + return _avatar_url(contact.avatar_file_id) + + +def _contact_payload(view: ContactWithIMBindings) -> dict[str, object]: + contact = view.contact + return { + "id": contact.id, + "type": contact.type, + "name": contact.name, + "email": contact.email, + "avatar_url": _contact_avatar_url(contact), + "im_bindings": [ + {"id": binding.id, "provider": binding.provider, "scope": binding.scope} for binding in view.im_bindings + ], + "created_at": contact.created_at, + } + + +def _contact_option_payload(contact: Contact) -> dict[str, object]: + return { + "id": contact.id, + "type": contact.type, + "name": contact.name, + "avatar_url": _contact_avatar_url(contact) or None, + "email": contact.email, + } + + +def _contact_summary_payload(view: ContactWithIMBindings) -> dict[str, object]: + contact = view.contact + return { + "id": contact.id, + "name": contact.name, + "avatar_url": _contact_avatar_url(contact), + "created_at": contact.created_at, + } + + +def _contact_service(session: Session, tenant_id: TenantId) -> ContactManagementService: + repository = SQLAlchemyContactRepository(session) + if FeatureService.is_im_centrally_managed(): + channel = DeploymentIMChannelReader(session).get() + else: + channel = WorkspaceIMChannelReader(session, tenant_id).get() + return ContactManagementService( + repository, + SQLAlchemyContactIMBindingRepository(session, channel), + SQLAlchemyIMIdentityRepository(session, channel.id) if channel is not None else None, + ) + + +def _raise_contact_error(error: ContactError) -> Never: + if error.code in {ContactErrorCode.NOT_FOUND, ContactErrorCode.ACCOUNT_NOT_FOUND}: + abort(HTTPStatus.NOT_FOUND, str(error)) + if error.code is ContactErrorCode.CONFLICT: + abort(HTTPStatus.CONFLICT, str(error)) + abort(HTTPStatus.UNPROCESSABLE_ENTITY, str(error)) + raise AssertionError("unreachable") + + +@console_ns.route("/workspaces/current/human-input/v2/contacts") +class WorkspaceContactsApi(Resource): + @console_ns.doc(params=query_params_from_model(ContactListQuery)) + @console_ns.response(200, "Success", console_ns.models[ListContactsResponse.__name__]) + @require_admin_or_owner + @with_current_tenant_id + @with_session(write=False) + def get(self, session: Session, tenant_id: str): + query = ContactListQuery.model_validate(request.args.to_dict(flat=True)) + contact_query = ContactQuery( + keyword=query.keyword or "", + contact_type=ContactType(query.group.value) if query.group is not None else None, + ) + service = _contact_service(session, TenantId(tenant_id)) + page, views = service.list_contacts( + TenantId(tenant_id), + page=query.page, + limit=query.limit, + query=contact_query, + ) + return dump_response( + ListContactsResponse, + { + "data": [_contact_payload(view) for view in views], + "page": page.page, + "limit": page.limit, + "total": service.count_contacts(TenantId(tenant_id), contact_query), + }, + ) + + +@console_ns.route("/workspaces/current/human-input/v2/contacts/") +class WorkspaceContactApi(Resource): + """Read one contact only when it resolves in the current workspace scope.""" + + @console_ns.response(200, "Success", console_ns.models[GetContactResponse.__name__]) + @console_ns.response(404, "Contact not found or absent in the current workspace") + @require_admin_or_owner + @with_current_tenant_id + @with_session(write=False) + def get(self, session: Session, tenant_id: str, contact_id: str): + details = _contact_service(session, TenantId(tenant_id)).get_contact_details( + TenantId(tenant_id), ContactId(contact_id) + ) + if details is None: + abort(HTTPStatus.NOT_FOUND, "Contact not found") + return dump_response( + GetContactResponse, + { + "contact": _contact_payload(details.contact), + "im_binding_details": [ + { + "id": detail.binding.id, + "provider": detail.binding.provider, + "scope": detail.binding.scope, + "identity": detail.identity, + } + for detail in details.im_binding_details + ], + }, + ) + + +@console_ns.route("/workspaces/current/human-input/v2/contact-options") +class WorkspaceContactOptionsApi(Resource): + """Search the current workspace's selectable Contact projection for workflow editors.""" + + @console_ns.doc( + params=query_params_from_model(ContactOptionsQuery), + description=( + "List editor-safe Contact options for static recipient selection. " + "The projection omits IM bindings and management metadata; unavailable Contacts are omitted." + ), + ) + @console_ns.response(200, "Success", console_ns.models[ListContactOptionsResponse.__name__]) + @require_editor + @with_current_tenant_id + @with_session(write=False) + def get(self, session: Session, tenant_id: str): + query = ContactOptionsQuery.model_validate(request.args.to_dict(flat=True)) + service = _contact_service(session, TenantId(tenant_id)) + page = service.list_contact_options( + TenantId(tenant_id), + page=query.page, + limit=query.limit, + keyword=query.keyword or "", + ) + return dump_response( + ListContactOptionsResponse, + { + "data": [_contact_option_payload(contact) for contact in page.items], + "page": page.page, + "limit": page.limit, + "total": service.count_contacts(TenantId(tenant_id), ContactQuery(keyword=query.keyword or "")), + }, + ) + + +@console_ns.route("/workspaces/current/human-input/v2/organization-candidates") +class WorkspaceOrganizationCandidatesApi(Resource): + @console_ns.doc(params=query_params_from_model(OrganizationCandidatesQuery)) + @console_ns.response(200, "Success", console_ns.models[ListOrganizationCandidatesResponse.__name__]) + @require_admin_or_owner + @only_edition_enterprise + @with_current_tenant_id + @with_session(write=False) + def get(self, session: Session, tenant_id: str): + query = OrganizationCandidatesQuery.model_validate(request.args.to_dict(flat=True)) + service = build_enterprise_contact_management_service(session) + candidates = service.list_organization_candidates( + page=query.page, + limit=query.limit, + keyword=query.keyword or "", + ) + return dump_response( + ListOrganizationCandidatesResponse, + { + "data": [ + { + "id": candidate.id, + "name": candidate.name, + "email": candidate.email, + "avatar_url": _avatar_url(candidate.avatar_file_id) or None, + } + for candidate in candidates + ], + "page": query.page, + "limit": query.limit, + "total": service.count_organization_candidates(query.keyword or ""), + }, + ) + + +@console_ns.route("/workspaces/current/human-input/v2/contacts/platform") +class WorkspacePlatformContactsApi(Resource): + @console_ns.expect(console_ns.models[AddPlatformContactsRequest.__name__]) + @console_ns.response(200, "Success", console_ns.models[AddPlatformContactsResponse.__name__]) + @require_admin_or_owner + @only_edition_enterprise + @with_current_user + @with_current_tenant_id + @with_session + def post(self, session: Session, tenant_id: str, current_user: Account): + request_body = AddPlatformContactsRequest.model_validate(console_ns.payload or {}) + try: + contacts = build_enterprise_contact_management_service(session).add_platform_contacts( + TenantId(tenant_id), + [CandidateId(candidate_id) for candidate_id in request_body.candidate_ids], + AccountId(current_user.id), + ) + except ContactError as error: + _raise_contact_error(error) + return dump_response(AddPlatformContactsResponse, {"data": [_contact_payload(contact) for contact in contacts]}) + + +@console_ns.route("/workspaces/current/human-input/v2/contacts/external") +class WorkspaceExternalContactsApi(Resource): + @console_ns.expect(console_ns.models[ExternalContactCreateRequest.__name__]) + @console_ns.response(200, "Success", console_ns.models[ExternalContactCreateResponse.__name__]) + @require_admin_or_owner + @with_current_tenant_id + @with_session + def post(self, session: Session, tenant_id: str): + request_body = ExternalContactCreateRequest.model_validate(console_ns.payload or {}) + try: + contact = _contact_service(session, TenantId(tenant_id)).create_external_contact( + TenantId(tenant_id), + contact_id=ContactId(str(uuidv7())), + name=request_body.name, + email=str(request_body.email), + avatar_file_id=request_body.avatar or None, + now=naive_utc_now(), + ) + except ContactError as error: + _raise_contact_error(error) + return dump_response(ExternalContactCreateResponse, {"contact": _contact_payload(contact)}) + + +@console_ns.route("/workspaces/current/human-input/v2/contacts/external/") +class WorkspaceExternalContactApi(Resource): + @console_ns.expect(console_ns.models[ExternalContactUpdateRequest.__name__]) + @console_ns.response(200, "Success", console_ns.models[ExternalContactUpdateResponse.__name__]) + @require_admin_or_owner + @with_current_tenant_id + @with_session + def patch(self, session: Session, tenant_id: str, contact_id: str): + request_body = ExternalContactUpdateRequest.model_validate(console_ns.payload or {}) + service = _contact_service(session, TenantId(tenant_id)) + try: + current = service.get_contact(TenantId(tenant_id), ContactId(contact_id)) + if current is None or current.contact.type is not ContactType.EXTERNAL: + abort(HTTPStatus.NOT_FOUND, "External Contact not found") + assert current is not None + current_contact = current.contact + if current_contact.email is None: + raise RuntimeError("External Contact is missing its required Email") + avatar_file_id = current_contact.avatar_file_id + if "avatar" in request_body.model_fields_set: + avatar_file_id = request_body.avatar or None + contact = service.update_external_contact( + TenantId(tenant_id), + external_contact=ExternalContact( + id=current_contact.id, + name=request_body.name or current_contact.name, + email=str(request_body.email) if request_body.email is not None else current_contact.email, + avatar_file_id=avatar_file_id, + created_at=current_contact.created_at, + ), + ) + except ContactError as error: + _raise_contact_error(error) + return dump_response(ExternalContactUpdateResponse, {"contact": _contact_payload(contact)}) + + +@console_ns.route("/workspaces/current/human-input/v2/contacts/remove") +class WorkspaceContactsRemoveApi(Resource): + @console_ns.expect(console_ns.models[RemoveContactsRequest.__name__]) + @console_ns.response(200, "Success", console_ns.models[RemoveContactsResponse.__name__]) + @require_admin_or_owner + @with_current_tenant_id + @with_session + def post(self, session: Session, tenant_id: str): + request_body = RemoveContactsRequest.model_validate(console_ns.payload or {}) + try: + contact_ids = [ContactId(contact_id) for contact_id in request_body.contact_ids] + if FeatureService.is_im_centrally_managed(): + removed_ids = build_enterprise_contact_management_service(session).remove_contacts( + TenantId(tenant_id), + contact_ids, + ) + else: + removed_ids = _contact_service(session, TenantId(tenant_id)).remove_contacts( + TenantId(tenant_id), contact_ids + ) + except ContactError as error: + _raise_contact_error(error) + except ValueError as error: + abort(HTTPStatus.UNPROCESSABLE_ENTITY, str(error)) + return dump_response(RemoveContactsResponse, {"removed_contact_ids": removed_ids}) + + +@console_ns.route("/workspaces/current/human-input/v2/im-sync-runs") +class WorkspaceIMSyncRunsApi(Resource): + @console_ns.response(200, "Success", console_ns.models[CreateIMSyncRunResponse.__name__]) + @console_ns.response(404, "IM Integration not configured", console_ns.models[IMContactSyncErrorResponse.__name__]) + @console_ns.response(409, "IM Integration revision changed", console_ns.models[IMContactSyncErrorResponse.__name__]) + @console_ns.response( + 503, + "IM synchronization temporarily unavailable", + console_ns.models[IMContactSyncErrorResponse.__name__], + ) + @require_admin_or_owner + @with_current_user + @with_current_tenant_id + def post(self, tenant_id: str, current_user: Account): + try: + run = build_im_contact_sync_application().sync_service.create_or_get_active_run( + _workspace_scope(tenant_id), + AccountId(current_user.id), + ) + except ( + IMChannelNotConfiguredError, + IMSyncDispatchUnavailableError, + IMSyncRevisionChangedError, + IMWriteUnavailableError, + ) as error: + return _im_application_error_response(error) + return dump_response(CreateIMSyncRunResponse, {"run": _sync_run_payload(run)}) + + +@console_ns.route("/workspaces/current/human-input/v2/im-sync-runs/latest") +class WorkspaceLatestIMSyncRunApi(Resource): + @console_ns.doc( + description=( + "Return the latest IM sync run summary. The UI uses finished_at as the explicit sync time; " + "the response does not include started_by." + ) + ) + @console_ns.response(200, "Success", console_ns.models[GetLatestIMSyncRunResponse.__name__]) + @console_ns.response(404, "Latest IM sync run not found", console_ns.models[IMContactSyncErrorResponse.__name__]) + @require_admin_or_owner + @with_current_tenant_id + def get(self, tenant_id: str): + try: + run = build_im_contact_sync_application().sync_service.get_latest_run(_workspace_scope(tenant_id)) + except (IMChannelNotConfiguredError, IMSyncRunNotFoundError) as error: + return _im_application_error_response(error) + return dump_response(GetLatestIMSyncRunResponse, {"run": _sync_run_payload(run)}) + + +@console_ns.route("/workspaces/current/human-input/v2/im-sync-runs/latest/results") +class WorkspaceLatestIMSyncRunResultsApi(Resource): + @console_ns.doc( + params=query_params_from_model(ListLatestIMSyncRunResultsQuery), + description=( + "Return one required result bucket from the latest IM sync run using page and limit pagination. " + "There is no all filter; the response contains page, limit, and total metadata without a run summary." + ), + ) + @console_ns.response(200, "Success", console_ns.models[ListLatestIMSyncRunResultsResponse.__name__]) + @console_ns.response(404, "Latest IM sync run not found", console_ns.models[IMContactSyncErrorResponse.__name__]) + @require_admin_or_owner + @with_current_tenant_id + def get(self, tenant_id: str): + query = ListLatestIMSyncRunResultsQuery.model_validate(request.args.to_dict(flat=True)) + try: + result_page = build_im_contact_sync_application().sync_service.list_latest_results( + _workspace_scope(tenant_id), + query.result, + page=query.page, + limit=query.limit, + ) + except (IMChannelNotConfiguredError, IMSyncRunNotFoundError) as error: + return _im_application_error_response(error) + return dump_response( + ListLatestIMSyncRunResultsResponse, + { + "data": [{"id": result.id, "result": _sync_result_payload(result)} for result in result_page.items], + "page": result_page.page, + "limit": result_page.limit, + "total": result_page.total, + }, + ) + + +@console_ns.route("/workspaces/current/human-input/v2/im-identities") +class WorkspaceIMIdentitiesApi(Resource): + @console_ns.doc(params=query_params_from_model(ListIMIdentitiesQuery)) + @console_ns.response(200, "Success", console_ns.models[ListIMIdentitiesResponse.__name__]) + @console_ns.response(404, "IM Integration not configured", console_ns.models[IMContactSyncErrorResponse.__name__]) + @require_admin_or_owner + @with_current_tenant_id + def get(self, tenant_id: str): + query = ListIMIdentitiesQuery.model_validate(request.args.to_dict(flat=True)) + try: + identity_page = build_im_contact_sync_application().sync_service.search_identities( + _im_binding_scope(tenant_id), + keyword=query.keyword, + page=query.page, + limit=query.limit, + ) + except IMChannelNotConfiguredError as error: + return _im_application_error_response(error) + return dump_response( + ListIMIdentitiesResponse, + { + "data": identity_page.items, + "page": identity_page.page, + "limit": identity_page.limit, + "total": identity_page.total, + }, + ) + + +@console_ns.route("/workspaces/current/human-input/v2/contacts//im-override") +class WorkspaceContactIMOverrideApi(Resource): + @console_ns.doc( + description=( + "Set or reset the IM override for a contact. " + "This endpoint is used to override the IM identity for a contact in the workspace." + ), + ) + @console_ns.expect(console_ns.models[SetContactIMOverrideRequest.__name__]) + @console_ns.response(200, "Success", console_ns.models[SetContactIMOverrideResponse.__name__]) + @console_ns.response(403, "Workspace overrides require centrally managed IM bindings") + @console_ns.response( + 404, + "Contact or IM identity not found", + console_ns.models[IMContactSyncErrorResponse.__name__], + ) + @console_ns.response(409, "IM binding conflict", console_ns.models[IMContactSyncErrorResponse.__name__]) + @console_ns.response(422, "Invalid binding scope", console_ns.models[IMContactSyncErrorResponse.__name__]) + @console_ns.response(503, "IM write unavailable", console_ns.models[IMContactSyncErrorResponse.__name__]) + @require_admin_or_owner + @with_current_user + @with_current_tenant_id + def put(self, tenant_id: str, current_user: Account, contact_id: str): + if not FeatureService.is_im_centrally_managed(): + raise WorkspaceIMOverrideNotAllowedError() + request_body = SetContactIMOverrideRequest.model_validate(console_ns.payload or {}) + tenant_id = TenantId(tenant_id) + try: + contact = build_im_contact_sync_application().binding_service.set_workspace_override( + organization_scope=_im_binding_scope(tenant_id), + tenant_id=tenant_id, + contact_id=ContactId(contact_id), + identity_id=IMIdentityId(request_body.identity_id), + bound_by_account_id=AccountId(current_user.id), + ) + except (IMBindingCommandError, IMWriteUnavailableError) as error: + return _im_application_error_response(error) + return dump_response(SetContactIMOverrideResponse, {"contact": _contact_binding_payload(contact)}) + + @console_ns.doc( + description=( + "Reset the IM override for a contact. " + "This endpoint is used to clear the IM identity override for a contact in the workspace." + ), + ) + @console_ns.response(200, "Success", console_ns.models[ResetContactIMOverrideResponse.__name__]) + @console_ns.response(403, "Workspace overrides require centrally managed IM bindings") + @console_ns.response(404, "Contact not found", console_ns.models[IMContactSyncErrorResponse.__name__]) + @console_ns.response(422, "Invalid binding scope", console_ns.models[IMContactSyncErrorResponse.__name__]) + @console_ns.response(503, "IM write unavailable", console_ns.models[IMContactSyncErrorResponse.__name__]) + @require_admin_or_owner + @with_current_tenant_id + def delete(self, tenant_id: str, contact_id: str): + if not FeatureService.is_im_centrally_managed(): + raise WorkspaceIMOverrideNotAllowedError() + tenant_id = TenantId(tenant_id) + try: + contact = build_im_contact_sync_application().binding_service.reset_workspace_override( + organization_scope=_im_binding_scope(tenant_id), + tenant_id=tenant_id, + contact_id=ContactId(contact_id), + ) + except (IMBindingCommandError, IMWriteUnavailableError) as error: + return _im_application_error_response(error) + return dump_response(ResetContactIMOverrideResponse, {"contact": _contact_binding_payload(contact)}) + + +@console_ns.route("/workspaces/current/human-input/v2/contacts//im-bindings") +class WorkspaceContactIMBindingsApi(Resource): + @console_ns.doc( + description=( + "Set a default IM binding for a contact, atomically replacing any default binding for either " + "the contact or the selected identity. Identical assignments preserve their binding ID; " + "changed assignments receive a new binding ID. " + "This endpoint is not used for creating workspace IM override. " + "For that purpose, use WorkspaceContactIMOverrideApi.put instead." + ), + ) + @console_ns.expect(console_ns.models[CreateIMBindingRequest.__name__]) + @console_ns.response(200, "Success", console_ns.models[CreateIMBindingResponse.__name__]) + @console_ns.response(403, "Centrally managed IM bindings cannot be edited from a workspace") + @console_ns.response( + 404, + "Contact or IM identity not found", + console_ns.models[IMContactSyncErrorResponse.__name__], + ) + @console_ns.response(409, "IM binding conflict", console_ns.models[IMContactSyncErrorResponse.__name__]) + @console_ns.response(503, "IM write unavailable", console_ns.models[IMContactSyncErrorResponse.__name__]) + @require_admin_or_owner + @with_current_user + @with_current_tenant_id + def put(self, tenant_id: str, current_user: Account, contact_id: str): + if FeatureService.is_im_centrally_managed(): + raise IMBindingReadOnlyError() + request_body = CreateIMBindingRequest.model_validate(console_ns.payload or {}) + tenant_id = TenantId(tenant_id) + try: + contact = build_im_contact_sync_application().binding_service.set_organization_binding( + organization_scope=WorkspaceScope(id=tenant_id), + tenant_id=tenant_id, + contact_id=ContactId(contact_id), + identity_id=IMIdentityId(request_body.identity_id), + bound_by_account_id=AccountId(current_user.id), + ) + except (IMBindingCommandError, IMWriteUnavailableError) as error: + return _im_application_error_response(error) + return dump_response(CreateIMBindingResponse, {"contact": _contact_binding_payload(contact)}) + + @console_ns.response(200, "Success", console_ns.models[DeleteIMBindingResponse.__name__]) + @console_ns.response(403, "Centrally managed IM bindings cannot be edited from a workspace") + @console_ns.response(404, "IM binding not found", console_ns.models[IMContactSyncErrorResponse.__name__]) + @console_ns.response(503, "IM write unavailable", console_ns.models[IMContactSyncErrorResponse.__name__]) + @console_ns.doc( + description=( + "Delete the current default IM binding for the contact identified by the path. " + "Workspace IM overrides are not modified. " + "This endpoint is not used for resetting workspace IM override. For that purpose, use " + "WorkspaceContactIMOverrideApi.delete instead." + ), + ) + @require_admin_or_owner + @with_current_tenant_id + def delete(self, tenant_id: str, contact_id: UUID): + if FeatureService.is_im_centrally_managed(): + raise IMBindingReadOnlyError() + try: + build_im_contact_sync_application().binding_service.delete_organization_binding( + organization_scope=_workspace_scope(tenant_id), + contact_id=ContactId(str(contact_id)), + ) + except (IMBindingCommandError, IMWriteUnavailableError) as error: + return _im_application_error_response(error) + return dump_response(DeleteIMBindingResponse, {}) + + +@console_ns.route("/workspaces/current/human-input/v2/contacts/batch") +class BatchGetContactsAPI(Resource): + @console_ns.doc( + params=query_params_from_model(BatchGetContactsQuery), + description=( + "Admin-only batch lookup for Contact management clients. " + "Workflow editors must use the editor-safe contact-options/batch projection." + ), + ) + @console_ns.response(200, "Success", console_ns.models[BatchGetContactsResponse.__name__]) + @require_admin_or_owner + @with_current_tenant_id + @with_session(write=False) + def get(self, session: Session, tenant_id: str): + query = query_params_from_request(BatchGetContactsQuery, list_fields=("contact_ids",)) + contacts = _contact_service(session, TenantId(tenant_id)).get_contacts( + TenantId(tenant_id), + [ContactId(contact_id) for contact_id in query.contact_ids], + ) + return dump_response( + BatchGetContactsResponse, + {"data": [_contact_summary_payload(contact) for contact in contacts]}, + ) + + +@console_ns.route("/workspaces/current/human-input/v2/contact-options/batch") +class BatchGetContactOptionsAPI(Resource): + """Resolve persisted Contact IDs through the same editor-safe selection projection.""" + + @console_ns.doc( + params=query_params_from_model(BatchGetContactOptionsQuery), + description=( + "Resolve Contact IDs persisted in workflow recipient configuration. " + "Contacts unavailable in the current workspace are omitted." + ), + ) + @console_ns.response(200, "Success", console_ns.models[BatchGetContactOptionsResponse.__name__]) + @require_editor + @with_current_tenant_id + @with_session(write=False) + def get(self, session: Session, tenant_id: str): + query = query_params_from_request(BatchGetContactOptionsQuery, list_fields=("contact_ids",)) + contacts = _contact_service(session, TenantId(tenant_id)).get_contact_options( + TenantId(tenant_id), + [ContactId(contact_id) for contact_id in query.contact_ids], + ) + return dump_response( + BatchGetContactOptionsResponse, + {"data": [_contact_option_payload(contact) for contact in contacts]}, + ) + + +@console_ns.route("/workspaces/current/human-input/v2/node-data-migration") +class NodeDataMigrationAPI(Resource): + @console_ns.doc( + description=( + "Migrate node data from HITLv1 to HITLv2. " + 'A missing legacy version defaults to "1"; any other explicit version is rejected. ' + "Every legacy Email recipient becomes onetime_email, while whole_workspace becomes " + "all_workspace_contacts. This side-effect-free endpoint only returns migrated node data; " + "the caller owns applying it to the Draft workflow." + ), + ) + @console_ns.expect(console_ns.models[NodeDataMigrationPayload.__name__]) + @console_ns.response(200, "Success", console_ns.models[NodeDataMigrationResponse.__name__]) + @console_ns.response(400, "Migration failed", console_ns.models[NodeDataMigrationFailureResponse.__name__]) + @setup_required + @login_required + @account_initialization_required + @edit_permission_required + @with_current_tenant_id + def post(self, tenant_id: str): + request_body = NodeDataMigrationPayload.model_validate(console_ns.payload or {}) + outcome = build_human_input_node_data_migration_service().migrate( + tenant_id=TenantId(tenant_id), + nodes=tuple( + MigrationNode.from_preflight( + node.node_id, + preflight_legacy_human_input_node_data(node.node_data), + ) + for node in request_body.nodes + ), + ) + if isinstance(outcome, NodeDataMigrationFailure): + return ( + dump_response( + NodeDataMigrationFailureResponse, + { + "message": "Human Input node-data migration failed.", + "blockers": outcome.blockers, + }, + ), + HTTPStatus.BAD_REQUEST, + ) + return dump_response(NodeDataMigrationResponse, outcome) diff --git a/api/controllers/console/human_input_v2/entities.py b/api/controllers/console/human_input_v2/entities.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/api/controllers/console/human_input_v2/errors.py b/api/controllers/console/human_input_v2/errors.py new file mode 100644 index 00000000000..a1695759366 --- /dev/null +++ b/api/controllers/console/human_input_v2/errors.py @@ -0,0 +1,32 @@ +from http import HTTPStatus + +from libs.exception import BaseHTTPException + + +class ChannelAlreadyConfiguredHttpError(BaseHTTPException): + error_code = "conflict" + description = "A Channel is already configured for this kind." + code = HTTPStatus.CONFLICT + + +class ChannelNotFoundHttpError(BaseHTTPException): + error_code = "not_found" + description = "Channel not found." + code = HTTPStatus.NOT_FOUND + + +class ChannelProviderConfigurationUpdatedHttpError(BaseHTTPException): + error_code = "provider_configuration_updated" + description = "The Channel configuration was updated." + code = HTTPStatus.CONFLICT + + +class ChannelProviderBadRequestHttpError(BaseHTTPException): + error_code = "bad_request" + code = HTTPStatus.BAD_REQUEST + + +class ChannelReplacementRequiredHttpError(BaseHTTPException): + error_code = "replacement_required" + description = "Explicit IM Channel replacement is required." + code = HTTPStatus.CONFLICT diff --git a/api/controllers/console/human_input_v2/form.py b/api/controllers/console/human_input_v2/form.py new file mode 100644 index 00000000000..5a16f06ae7b --- /dev/null +++ b/api/controllers/console/human_input_v2/form.py @@ -0,0 +1,111 @@ +"""Account-authenticated Human Input v2 approval transport.""" + +from flask import request +from flask_restx import Resource +from pydantic import BaseModel, ConfigDict, JsonValue + +from controllers.common.human_input_v2_errors import ( + HumanInputV2AlreadySubmittedError, + HumanInputV2ExpiredError, + HumanInputV2InvalidInputError, + HumanInputV2WorkflowNotActiveError, + form_http_error, +) +from controllers.common.schema import register_response_schema_models, register_schema_models +from controllers.console import console_ns +from controllers.console.wraps import account_initialization_required, setup_required, with_current_user +from core.db.session_factory import session_factory +from core.human_input_v2.resolved_form import ResolvedForm +from core.human_input_v2.shared.values import AccountId +from fields.base import ResponseModel +from libs.datetime_utils import to_utc_timestamp +from libs.external_api import error_responses +from libs.helper import dump_response, extract_remote_ip +from libs.login import login_required +from models.account import Account +from services.human_input_v2.submission_service import ( + HumanInputFormError, + HumanInputSubmissionService, +) + + +class ConsoleHumanInputV2SubmitPayload(BaseModel): + model_config = ConfigDict(extra="forbid", strict=True) + + inputs: dict[str, JsonValue] + action: str + + +class ConsoleHumanInputV2SubmitResponse(ResponseModel): + pass + + +class ConsoleHumanInputV2FormDefinitionResponse(ResponseModel): + """Frozen form definition and effective expiration in UTC epoch seconds.""" + + resolved_form: ResolvedForm + expiration_time: int + + +register_schema_models(console_ns, ConsoleHumanInputV2SubmitPayload) +register_response_schema_models( + console_ns, ConsoleHumanInputV2SubmitResponse, ConsoleHumanInputV2FormDefinitionResponse +) + + +@console_ns.route("/form/human-input/v2/") +class ConsoleHumanInputV2FormApi(Resource): + @error_responses(HumanInputV2AlreadySubmittedError, HumanInputV2ExpiredError) + @setup_required + @login_required + @account_initialization_required + @with_current_user + @console_ns.doc( + "get_console_human_input_v2_form", + description="Read a frozen v2 form using the current Account's Contact authorization.", + responses={401: "Login required", 404: "Form not found", 409: "Already submitted", 410: "Expired"}, + ) + @console_ns.response(200, "Form retrieved", console_ns.models[ConsoleHumanInputV2FormDefinitionResponse.__name__]) + def get(self, current_user: Account, form_token: str): + service = HumanInputSubmissionService(session_factory=session_factory.get_session_maker()) + try: + form = service.get_console_form( + account_id=AccountId(current_user.id), + form_token=form_token, + ip_address=extract_remote_ip(request) or None, + ) + except HumanInputFormError as exc: + raise form_http_error(exc, message="The form could not be retrieved.") from exc + return dump_response( + ConsoleHumanInputV2FormDefinitionResponse, + { + "resolved_form": form.resolved_form, + "expiration_time": to_utc_timestamp(min(form.expiration_time, form.global_timeout_deadline)), + }, + ) + + @error_responses( + HumanInputV2AlreadySubmittedError, + HumanInputV2ExpiredError, + HumanInputV2WorkflowNotActiveError, + HumanInputV2InvalidInputError, + ) + @setup_required + @login_required + @account_initialization_required + @with_current_user + @console_ns.expect(console_ns.models[ConsoleHumanInputV2SubmitPayload.__name__]) + @console_ns.response(200, "Approval accepted", console_ns.models[ConsoleHumanInputV2SubmitResponse.__name__]) + def post(self, current_user: Account, form_token: str): + payload = ConsoleHumanInputV2SubmitPayload.model_validate(console_ns.payload or {}) + service = HumanInputSubmissionService(session_factory=session_factory.get_session_maker()) + try: + service.submit_console_form( + account_id=AccountId(current_user.id), + form_token=form_token, + action=payload.action, + inputs=payload.inputs, + ) + except HumanInputFormError as exc: + raise form_http_error(exc, message="The approval could not be accepted.") from exc + return ConsoleHumanInputV2SubmitResponse().model_dump(mode="json") diff --git a/api/controllers/console/human_input_v2/provider_updates.py b/api/controllers/console/human_input_v2/provider_updates.py new file mode 100644 index 00000000000..c893a82c3b8 --- /dev/null +++ b/api/controllers/console/human_input_v2/provider_updates.py @@ -0,0 +1,176 @@ +"""Console credential edit DTOs; omission is never a retention instruction.""" + +from typing import Annotated, Literal + +from pydantic import Field, SecretStr, ValidationInfo, field_validator + +from core.human_input_v2.entities import EmailProviderType, IMProvider +from services.human_input_v2.credential_update import ( + DingTalkCredentialsUpdate, + FeishuCredentialsUpdate, + LarkCredentialsUpdate, + MSTeamsCredentialsUpdate, + ResendCredentialsUpdate, + SlackCredentialsUpdate, + Unchanged, + WeComCredentialsUpdate, +) + +from ._common import StrictModel +from .providers import ( + _SLACK_APP_TOKEN_PREFIX, + _SLACK_BOT_TOKEN_PREFIX, + _OptionalSecret, + _RequiredSecret, + _SecretValidationMixin, +) + +type _Identifier = Annotated[str, Field(min_length=1, pattern=r"\S")] +type _UUIDString = Annotated[ + str, Field(pattern=r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$") +] + + +class UnchangedInput(StrictModel): + """Retain the addressed saved field; valid only for updates and saved tests.""" + + type: Literal["unchanged"] + + +def _updated_value[T](edit: T | UnchangedInput) -> T | Unchanged: + return Unchanged.VALUE if isinstance(edit, UnchangedInput) else edit + + +def _secret_update(edit: SecretStr | UnchangedInput) -> str | Unchanged: + return Unchanged.VALUE if isinstance(edit, UnchangedInput) else edit.get_secret_value() + + +def _optional_secret_update(edit: SecretStr | None | UnchangedInput) -> str | None | Unchanged: + return None if edit is None else _secret_update(edit) + + +class FeishuCredentialsUpdateInput(_SecretValidationMixin, StrictModel): + provider: Literal[IMProvider.FEISHU] + app_id: _Identifier | UnchangedInput + app_secret: _RequiredSecret | UnchangedInput + verification_token: _OptionalSecret | UnchangedInput + encrypt_key: _OptionalSecret | UnchangedInput + + def to_owner_update(self) -> FeishuCredentialsUpdate: + return FeishuCredentialsUpdate( + app_id=_updated_value(self.app_id), + app_secret=_secret_update(self.app_secret), + verification_token=_optional_secret_update(self.verification_token), + encrypt_key=_optional_secret_update(self.encrypt_key), + ) + + +class LarkCredentialsUpdateInput(_SecretValidationMixin, StrictModel): + provider: Literal[IMProvider.LARK] + app_id: _Identifier | UnchangedInput + app_secret: _RequiredSecret | UnchangedInput + verification_token: _OptionalSecret | UnchangedInput + encrypt_key: _OptionalSecret | UnchangedInput + + def to_owner_update(self) -> LarkCredentialsUpdate: + return LarkCredentialsUpdate( + app_id=_updated_value(self.app_id), + app_secret=_secret_update(self.app_secret), + verification_token=_optional_secret_update(self.verification_token), + encrypt_key=_optional_secret_update(self.encrypt_key), + ) + + +class SlackCredentialsUpdateInput(_SecretValidationMixin, StrictModel): + provider: Literal[IMProvider.SLACK] + client_id: _Identifier | UnchangedInput + client_secret: _RequiredSecret | UnchangedInput + signing_secret: _RequiredSecret | UnchangedInput + bot_token: _RequiredSecret | UnchangedInput + app_token: _OptionalSecret | UnchangedInput + + @field_validator("bot_token", "app_token") + @classmethod + def require_token_prefix( + cls, token: SecretStr | None | UnchangedInput, info: ValidationInfo + ) -> SecretStr | None | UnchangedInput: + if isinstance(token, SecretStr): + prefix = _SLACK_BOT_TOKEN_PREFIX if info.field_name == "bot_token" else _SLACK_APP_TOKEN_PREFIX + if not token.get_secret_value().startswith(prefix): + raise ValueError(f"Slack token must start with {prefix}") + return token + + def to_owner_update(self) -> SlackCredentialsUpdate: + return SlackCredentialsUpdate( + client_id=_updated_value(self.client_id), + client_secret=_secret_update(self.client_secret), + signing_secret=_secret_update(self.signing_secret), + bot_token=_secret_update(self.bot_token), + app_token=_optional_secret_update(self.app_token), + ) + + +class DingTalkCredentialsUpdateInput(_SecretValidationMixin, StrictModel): + provider: Literal[IMProvider.DING_TALK] + corp_id: _Identifier | UnchangedInput + client_id: _Identifier | UnchangedInput + client_secret: _RequiredSecret | UnchangedInput + + def to_owner_update(self) -> DingTalkCredentialsUpdate: + return DingTalkCredentialsUpdate( + corp_id=_updated_value(self.corp_id), + client_id=_updated_value(self.client_id), + client_secret=_secret_update(self.client_secret), + ) + + +class MSTeamsCredentialsUpdateInput(_SecretValidationMixin, StrictModel): + provider: Literal[IMProvider.MS_TEAMS] + tenant_id: _UUIDString | UnchangedInput + client_id: _UUIDString | UnchangedInput + client_secret: _RequiredSecret | UnchangedInput + + def to_owner_update(self) -> MSTeamsCredentialsUpdate: + return MSTeamsCredentialsUpdate( + tenant_id=_updated_value(self.tenant_id), + client_id=_updated_value(self.client_id), + client_secret=_secret_update(self.client_secret), + ) + + +class WeComCredentialsUpdateInput(_SecretValidationMixin, StrictModel): + provider: Literal[IMProvider.WE_COM] + corp_id: _Identifier | UnchangedInput + agent_id: str | UnchangedInput + secret: _RequiredSecret | UnchangedInput + + def to_owner_update(self) -> WeComCredentialsUpdate: + return WeComCredentialsUpdate( + corp_id=_updated_value(self.corp_id), + agent_id=_updated_value(self.agent_id), + secret=_secret_update(self.secret), + ) + + +type IMProviderCredentialsUpdateInput = Annotated[ + FeishuCredentialsUpdateInput + | LarkCredentialsUpdateInput + | SlackCredentialsUpdateInput + | DingTalkCredentialsUpdateInput + | MSTeamsCredentialsUpdateInput + | WeComCredentialsUpdateInput, + Field(discriminator="provider"), +] + + +class ResendCredentialsUpdateInput(_SecretValidationMixin, StrictModel): + provider: Literal[EmailProviderType.RESEND] + api_key: _RequiredSecret | UnchangedInput + + def to_owner_update(self) -> ResendCredentialsUpdate: + return ResendCredentialsUpdate( + api_key=_secret_update(self.api_key), + ) + + +type EmailProviderCredentialsUpdateInput = Annotated[ResendCredentialsUpdateInput, Field(discriminator="provider")] diff --git a/api/controllers/console/human_input_v2/provider_views.py b/api/controllers/console/human_input_v2/provider_views.py new file mode 100644 index 00000000000..d6f5a38e031 --- /dev/null +++ b/api/controllers/console/human_input_v2/provider_views.py @@ -0,0 +1,115 @@ +"""Credential projections exposing identifiers while keeping secrets redacted.""" + +from typing import Annotated, Literal + +from pydantic import Field + +from core.human_input_v2.entities import EmailProviderType, IMProvider +from core.human_input_v2.im_integration.adapters.credentials import ( + DingTalkCredentials, + FeishuCredentials, + IMProviderCredentials, + LarkCredentials, + MSTeamsCredentials, + SlackCredentials, +) +from fields.base import ResponseModel + + +class Redacted(ResponseModel): + """A configured credential whose stored value is omitted.""" + + type: Literal["redacted"] = "redacted" + + +class FeishuLarkCredentialsResponse(ResponseModel): + provider: Literal[IMProvider.FEISHU, IMProvider.LARK] + app_id: str + app_secret: Redacted + verification_token: Redacted | None + encrypt_key: Redacted | None + + +class SlackCredentialsResponse(ResponseModel): + provider: Literal[IMProvider.SLACK] + client_id: str + client_secret: Redacted + signing_secret: Redacted + bot_token: Redacted + app_token: Redacted | None + + +class DingTalkCredentialsResponse(ResponseModel): + provider: Literal[IMProvider.DING_TALK] + corp_id: str + client_id: str + client_secret: Redacted + + +class MSTeamsCredentialsResponse(ResponseModel): + provider: Literal[IMProvider.MS_TEAMS] + tenant_id: str + client_id: str + client_secret: Redacted + + +class WeComCredentialsResponse(ResponseModel): + provider: Literal[IMProvider.WE_COM] + corp_id: str + agent_id: str + secret: Redacted + + +type IMProviderCredentialsResponse = Annotated[ + FeishuLarkCredentialsResponse + | SlackCredentialsResponse + | DingTalkCredentialsResponse + | MSTeamsCredentialsResponse + | WeComCredentialsResponse, + Field(discriminator="provider"), +] + + +class ResendCredentialsResponse(ResponseModel): + provider: Literal[EmailProviderType.RESEND] + api_key: Redacted + + +def im_credentials_response(credentials: IMProviderCredentials) -> IMProviderCredentialsResponse: + if isinstance(credentials, (FeishuCredentials, LarkCredentials)): + return FeishuLarkCredentialsResponse( + provider=credentials.provider, + app_id=credentials.app_id, + app_secret=Redacted(), + verification_token=Redacted() if credentials.verification_token is not None else None, + encrypt_key=Redacted() if credentials.encrypt_key is not None else None, + ) + if isinstance(credentials, SlackCredentials): + return SlackCredentialsResponse( + provider=credentials.provider, + client_id=credentials.client_id, + client_secret=Redacted(), + signing_secret=Redacted(), + bot_token=Redacted(), + app_token=Redacted() if credentials.app_token is not None else None, + ) + if isinstance(credentials, DingTalkCredentials): + return DingTalkCredentialsResponse( + provider=credentials.provider, + corp_id=credentials.corp_id, + client_id=credentials.client_id, + client_secret=Redacted(), + ) + if isinstance(credentials, MSTeamsCredentials): + return MSTeamsCredentialsResponse( + provider=credentials.provider, + tenant_id=credentials.tenant_id, + client_id=credentials.client_id, + client_secret=Redacted(), + ) + return WeComCredentialsResponse( + provider=credentials.provider, + corp_id=credentials.corp_id, + agent_id=credentials.agent_id, + secret=Redacted(), + ) diff --git a/api/controllers/console/human_input_v2/providers.py b/api/controllers/console/human_input_v2/providers.py new file mode 100644 index 00000000000..c0c33ad20cd --- /dev/null +++ b/api/controllers/console/human_input_v2/providers.py @@ -0,0 +1,192 @@ +"""Canonical Console v2 provider credential DTOs and owner mappings.""" + +from __future__ import annotations + +from typing import Annotated, Literal + +from pydantic import Field, SecretStr, field_validator + +from core.human_input_v2.entities import EmailProviderType, IMProvider +from core.human_input_v2.im_integration.adapters.credentials import ( + DingTalkCredentials, + FeishuCredentials, + LarkCredentials, + MSTeamsCredentials, + SlackCredentials, + WeComCredentials, +) +from repositories.human_input_v2.email_channel.entities import ResendCredentials as _ResendCredentials + +from ._common import StrictModel + +type _RequiredSecret = Annotated[SecretStr, Field(min_length=1)] +type _OptionalSecret = Annotated[SecretStr, Field(min_length=1)] | None + +_SLACK_BOT_TOKEN_PREFIX = "xoxb-" +_SLACK_APP_TOKEN_PREFIX = "xapp-" + + +def _secret_value(secret: SecretStr) -> str: + return secret.get_secret_value() + + +def _optional_secret_value(secret: SecretStr | None) -> str | None: + return secret.get_secret_value() if secret is not None else None + + +class _SecretValidationMixin: + @field_validator("*", mode="after", check_fields=False) + @classmethod + def reject_blank_secrets(cls, value: object) -> object: + if isinstance(value, SecretStr) and not value.get_secret_value().strip(): + raise ValueError("secret must not be blank") + return value + + +class _FeishuLarkCredentialsInputBase(_SecretValidationMixin, StrictModel): + app_id: str = Field(min_length=1, description="Feishu or Lark application identifier.") + app_secret: _RequiredSecret = Field(description="Feishu or Lark application secret.") + verification_token: _OptionalSecret = Field(default=None, description="Optional callback verification token.") + encrypt_key: _OptionalSecret = Field(default=None, description="Optional callback encryption key.") + + +class FeishuCredentialsInput(_FeishuLarkCredentialsInputBase): + provider: Literal[IMProvider.FEISHU] = Field(description="Feishu credential discriminator.") + + def to_owner_credentials(self) -> FeishuCredentials: + return FeishuCredentials( + provider=self.provider, + app_id=self.app_id, + app_secret=_secret_value(self.app_secret), + verification_token=_optional_secret_value(self.verification_token), + encrypt_key=_optional_secret_value(self.encrypt_key), + ) + + +class LarkCredentialsInput(_FeishuLarkCredentialsInputBase): + provider: Literal[IMProvider.LARK] = Field(description="Lark credential discriminator.") + + def to_owner_credentials(self) -> LarkCredentials: + return LarkCredentials( + provider=self.provider, + app_id=self.app_id, + app_secret=_secret_value(self.app_secret), + verification_token=_optional_secret_value(self.verification_token), + encrypt_key=_optional_secret_value(self.encrypt_key), + ) + + +class SlackCredentialsInput(_SecretValidationMixin, StrictModel): + provider: Literal[IMProvider.SLACK] = Field(description="Slack credential discriminator.") + client_id: str = Field(min_length=1, description="Slack OAuth client identifier.") + client_secret: _RequiredSecret = Field(description="Slack OAuth client secret.") + signing_secret: _RequiredSecret = Field(description="Slack callback signing secret.") + bot_token: _RequiredSecret = Field(description="Slack bot API token.") + app_token: _OptionalSecret = Field( + default=None, + description="Optional Slack app-level token required only for Socket Mode.", + ) + + @field_validator("bot_token") + @classmethod + def require_bot_token_prefix(cls, token: SecretStr) -> SecretStr: + if not token.get_secret_value().startswith(_SLACK_BOT_TOKEN_PREFIX): + raise ValueError("Slack bot token must start with xoxb-") + return token + + @field_validator("app_token") + @classmethod + def require_app_token_prefix(cls, token: SecretStr | None) -> SecretStr | None: + if token is not None and not token.get_secret_value().startswith(_SLACK_APP_TOKEN_PREFIX): + raise ValueError("Slack app token must start with xapp-") + return token + + def to_owner_credentials(self) -> SlackCredentials: + return SlackCredentials( + provider=self.provider, + client_id=self.client_id, + client_secret=_secret_value(self.client_secret), + signing_secret=_secret_value(self.signing_secret), + bot_token=_secret_value(self.bot_token), + app_token=_optional_secret_value(self.app_token), + ) + + +class DingTalkCredentialsInput(_SecretValidationMixin, StrictModel): + provider: Literal[IMProvider.DING_TALK] = Field(description="DingTalk credential discriminator.") + corp_id: str = Field(min_length=1, description="DingTalk corporation identifier.") + client_id: str = Field(min_length=1, description="DingTalk application client identifier.") + client_secret: _RequiredSecret = Field(description="DingTalk application client secret.") + + def to_owner_credentials(self) -> DingTalkCredentials: + return DingTalkCredentials( + provider=self.provider, + corp_id=self.corp_id, + client_id=self.client_id, + client_secret=_secret_value(self.client_secret), + ) + + +class MSTeamsCredentialsInput(_SecretValidationMixin, StrictModel): + provider: Literal[IMProvider.MS_TEAMS] = Field(description="Microsoft Teams credential discriminator.") + tenant_id: str = Field(min_length=1, description="Microsoft Entra tenant identifier.") + client_id: str = Field(min_length=1, description="Microsoft Teams application client identifier.") + client_secret: _RequiredSecret = Field(description="Microsoft Teams application client secret.") + + def to_owner_credentials(self) -> MSTeamsCredentials: + return MSTeamsCredentials( + provider=self.provider, + tenant_id=self.tenant_id, + client_id=self.client_id, + client_secret=_secret_value(self.client_secret), + ) + + +class WeComCredentialsInput(_SecretValidationMixin, StrictModel): + provider: Literal[IMProvider.WE_COM] = Field(description="WeCom credential discriminator.") + corp_id: str = Field(min_length=1, description="WeCom corporation identifier.") + agent_id: str = Field(min_length=1, description="WeCom application agent identifier.") + secret: _RequiredSecret = Field(description="WeCom application secret.") + + def to_owner_credentials(self) -> WeComCredentials: + return WeComCredentials( + provider=self.provider, + corp_id=self.corp_id, + agent_id=self.agent_id, + secret=_secret_value(self.secret), + ) + + +type IMProviderCredentialsInput = Annotated[ + FeishuCredentialsInput + | LarkCredentialsInput + | SlackCredentialsInput + | DingTalkCredentialsInput + | MSTeamsCredentialsInput + | WeComCredentialsInput, + Field(discriminator="provider"), +] + + +class ResendCredentials(_SecretValidationMixin, StrictModel): + provider: Literal[EmailProviderType.RESEND] = EmailProviderType.RESEND + api_key: _RequiredSecret = Field(description="Resend API key.") + + def to_owner_credentials(self) -> _ResendCredentials: + return _ResendCredentials.model_construct(api_key=_secret_value(self.api_key)) + + +type EmailProviderCredentials = Annotated[ResendCredentials, Field(discriminator="provider")] + + +__all__ = [ + "DingTalkCredentialsInput", + "EmailProviderCredentials", + "FeishuCredentialsInput", + "IMProviderCredentialsInput", + "LarkCredentialsInput", + "MSTeamsCredentialsInput", + "ResendCredentials", + "SlackCredentialsInput", + "WeComCredentialsInput", +] diff --git a/api/controllers/console/workspace/error.py b/api/controllers/console/workspace/error.py index 0fa0b89dd8a..1ea8a4a7194 100644 --- a/api/controllers/console/workspace/error.py +++ b/api/controllers/console/workspace/error.py @@ -1,3 +1,5 @@ +from http import HTTPStatus + from libs.exception import BaseHTTPException @@ -55,6 +57,18 @@ class InvalidMemberRoleError(BaseHTTPException): code = 400 +class IMBindingReadOnlyError(BaseHTTPException): + error_code = "im_binding_read_only" + description = "Centrally managed IM bindings cannot be edited from a workspace." + code = HTTPStatus.FORBIDDEN + + +class WorkspaceIMOverrideNotAllowedError(BaseHTTPException): + error_code = "workspace_im_override_not_allowed" + description = "Workspace IM overrides require centrally managed bindings." + code = HTTPStatus.FORBIDDEN + + class PluginOperationError(BaseHTTPException): error_code = "plugin_error" description = "Plugin operation failed." diff --git a/api/controllers/service_api/app/human_input_form.py b/api/controllers/service_api/app/human_input_form.py index 1f74c49122a..0956cdf384b 100644 --- a/api/controllers/service_api/app/human_input_form.py +++ b/api/controllers/service_api/app/human_input_form.py @@ -8,9 +8,9 @@ paused human input forms in workflow/chatflow runs. import json import logging from collections.abc import Sequence -from typing import Annotated +from typing import Annotated, NoReturn -from flask import Response +from flask import Response, request from flask_restx import Resource from pydantic import ConfigDict, WithJsonSchema from werkzeug.exceptions import BadRequest, NotFound @@ -21,20 +21,30 @@ from controllers.console.wraps import model_validate from controllers.service_api import service_api_ns from controllers.service_api.schema import expect_with_user from controllers.service_api.wraps import FetchUserArg, WhereisUserArg, validate_app_token +from core.db.session_factory import session_factory +from core.human_input_v2.shared.values import TenantId from core.workflow.human_input_policy import HumanInputSurface, is_recipient_type_allowed_for_surface from core.workflow.nodes.human_input.entities import FormInputConfig, UserActionConfig from extensions.ext_database import db +from factories.human_input_v2_form_presentation import build_form_presentation from fields.base import ResponseModel from libs.external_api import error_responses -from libs.helper import to_timestamp +from libs.helper import extract_remote_ip, to_timestamp from models.model import App, EndUser +from repositories.human_input_v2.form_repository import Form as V2Form from services.human_input_service import ( Form, FormExpiredError, FormNotFoundError, FormSubmittedError, - HumanInputService, InvalidFormDataError, + ServiceAPIHumanInputService, +) +from services.human_input_v2.submission_service import ( + HumanInputFormError, + HumanInputFormErrorCode, + HumanInputSubmissionService, + ServiceAPIFormActor, ) logger = logging.getLogger(__name__) @@ -56,7 +66,11 @@ class HumanInputFormSubmitResponse(ResponseModel): register_schema_models(service_api_ns, HumanInputFormSubmitPayload) -register_response_schema_models(service_api_ns, HumanInputFormDefinitionResponse, HumanInputFormSubmitResponse) +register_response_schema_models( + service_api_ns, + HumanInputFormDefinitionResponse, + HumanInputFormSubmitResponse, +) def _jsonify_form_definition(form: Form, *, inputs: Sequence[FormInputConfig] = ()) -> Response: @@ -83,6 +97,37 @@ def _ensure_form_is_allowed_for_service_api(form: Form) -> None: raise NotFound("Form not found") +def _jsonify_v2_form_definition(form: V2Form) -> Response: + presentation = build_form_presentation(form.resolved_form) + response = HumanInputFormDefinitionResponse( + form_content=presentation.form_content, + inputs=presentation.inputs, + resolved_default_values=presentation.resolved_default_values, + user_actions=presentation.user_actions, + expiration_time=to_timestamp(min(form.expiration_time, form.global_timeout_deadline)), + ) + return Response(response.model_dump_json(), mimetype="application/json") + + +def _raise_form_error(error: HumanInputFormError) -> NoReturn: + # Keep the existing Service API errors instead of leaking Web App v2's + # 409/410 statuses or its transport-specific error codes. + match error.code: + case HumanInputFormErrorCode.NOT_FOUND: + raise NotFound("Form not found") from error + case HumanInputFormErrorCode.ALREADY_SUBMITTED: + assert error.form_id is not None + raise FormSubmittedError(error.form_id) from error + case HumanInputFormErrorCode.EXPIRED: + assert error.form_id is not None + raise FormExpiredError(error.form_id) from error + case HumanInputFormErrorCode.INVALID_INPUT: + description = str(error.__cause__) if error.__cause__ is not None else "Invalid form data" + raise InvalidFormDataError(description) from error + case HumanInputFormErrorCode.WORKFLOW_NOT_ACTIVE: + raise BadRequest("The workflow is not paused with an active checkpoint.") from error + + @service_api_ns.route("/form/human_input/") class WorkflowHumanInputFormApi(Resource): @error_responses(FormExpiredError, FormSubmittedError) @@ -121,10 +166,19 @@ class WorkflowHumanInputFormApi(Resource): ) @validate_app_token def get(self, app_model: App, form_token: str): - service = HumanInputService(db.engine) + service = ServiceAPIHumanInputService(db.engine) form = service.get_form_by_token(form_token) if form is None: - raise NotFound("Form not found") + v2_service = HumanInputSubmissionService(session_factory=session_factory.get_session_maker()) + try: + v2_form = v2_service.get_service_api_form( + actor=ServiceAPIFormActor(tenant_id=TenantId(app_model.tenant_id), app_id=app_model.id), + form_token=form_token, + ip_address=extract_remote_ip(request) or None, + ) + except HumanInputFormError as error: + _raise_form_error(error) + return _jsonify_v2_form_definition(v2_form) _ensure_form_belongs_to_app(form, app_model) _ensure_form_is_allowed_for_service_api(form) @@ -176,10 +230,21 @@ class WorkflowHumanInputFormApi(Resource): @validate_app_token(fetch_user_arg=FetchUserArg(fetch_from=WhereisUserArg.JSON, required=True)) @model_validate(HumanInputFormSubmitPayload) def post(self, payload: HumanInputFormSubmitPayload, app_model: App, end_user: EndUser, form_token: str): - service = HumanInputService(db.engine) + service = ServiceAPIHumanInputService(db.engine) form = service.get_form_by_token(form_token) if form is None: - raise NotFound("Form not found") + v2_service = HumanInputSubmissionService(session_factory=session_factory.get_session_maker()) + try: + v2_service.submit_service_api_form( + actor=ServiceAPIFormActor(tenant_id=TenantId(app_model.tenant_id), app_id=app_model.id), + end_user_id=end_user.id, + form_token=form_token, + action=payload.action, + inputs=payload.inputs, + ) + except HumanInputFormError as error: + _raise_form_error(error) + return {}, 200 _ensure_form_belongs_to_app(form, app_model) _ensure_form_is_allowed_for_service_api(form) diff --git a/api/controllers/web/__init__.py b/api/controllers/web/__init__.py index bc10b2da90e..90e1627c0ab 100644 --- a/api/controllers/web/__init__.py +++ b/api/controllers/web/__init__.py @@ -56,6 +56,9 @@ def _register_controllers() -> tuple[ModuleType, ...]: forgot_password, human_input_file_upload, human_input_form, + human_input_form_access_request, + human_input_v2, + human_input_v2_file_upload, login, message, passport, @@ -76,6 +79,9 @@ def _register_controllers() -> tuple[ModuleType, ...]: forgot_password, human_input_file_upload, human_input_form, + human_input_form_access_request, + human_input_v2, + human_input_v2_file_upload, login, message, passport, @@ -97,6 +103,9 @@ def _register_controllers() -> tuple[ModuleType, ...]: forgot_password, human_input_file_upload, human_input_form, + human_input_form_access_request, + human_input_v2, + human_input_v2_file_upload, login, message, passport, @@ -121,6 +130,9 @@ __all__ = [ "forgot_password", "human_input_file_upload", "human_input_form", + "human_input_form_access_request", + "human_input_v2", + "human_input_v2_file_upload", "login", "message", "passport", diff --git a/api/controllers/web/human_input_form_access_request.py b/api/controllers/web/human_input_form_access_request.py new file mode 100644 index 00000000000..ff4bec550c4 --- /dev/null +++ b/api/controllers/web/human_input_form_access_request.py @@ -0,0 +1,189 @@ +"""Public web Human Input v2 form transport. + +The versioned public routes are separate from legacy v1 forms and Web App +passport-authenticated forms. Each runtime path must reject tokens owned by the other +version when the service implementation is added. +""" + +from __future__ import annotations + +from typing import NoReturn + +from flask import request +from flask_restx import Resource + +from controllers.common.human_input_v2_contracts import ( + FormAccessRequestResponse, + FormDefinitionResponse, + FormSubmitResponse, + FormUploadTokenResponse, + HumanInputV2FormSubmitRequest, +) +from controllers.common.human_input_v2_errors import ( + HumanInputAccessDeliveryError, + HumanInputAccessRateLimitError, + HumanInputExpiredChallengeError, + HumanInputInvalidOtpError, + HumanInputStaleChallengeError, + HumanInputV2InvalidInputError, + HumanInputV2UnavailableError, + HumanInputV2WorkflowNotActiveError, + PublicHumanInputV2ExpiredError, + PublicHumanInputV2NotFoundError, + PublicHumanInputV2SubmittedError, + email_otp_http_error, + public_form_http_error, +) +from controllers.common.schema import register_response_schema_models, register_schema_models +from controllers.console.wraps import model_validate +from controllers.web import web_ns +from controllers.web.human_input_file_upload import InvalidUploadTokenForbiddenError +from core.db.session_factory import session_factory +from factories.human_input_v2_form_presentation import build_form_presentation +from libs.datetime_utils import to_utc_timestamp +from libs.external_api import error_responses +from libs.helper import dump_response, extract_remote_ip +from services.human_input_file_upload_service import InvalidUploadTokenError +from services.human_input_v2.email_otp_composition import build_email_otp_service +from services.human_input_v2.email_otp_service import EmailOtpError +from services.human_input_v2.file_upload_service import build_file_upload_service +from services.human_input_v2.submission_service import ( + HumanInputFormError, + HumanInputSubmissionService, +) + +register_schema_models(web_ns, HumanInputV2FormSubmitRequest) +register_response_schema_models( + web_ns, + FormAccessRequestResponse, + FormDefinitionResponse, + FormSubmitResponse, + FormUploadTokenResponse, +) + + +def _raise_public_form_error(error: HumanInputFormError) -> NoReturn: + raise public_form_http_error(error) from error + + +@web_ns.route("/form/human-input/v2/public/") +class HumanInputV2FormApi(Resource): + """Read or submit a Human Input v2 form without sharing v1 submission logic.""" + + @error_responses(PublicHumanInputV2NotFoundError, PublicHumanInputV2SubmittedError, PublicHumanInputV2ExpiredError) + @web_ns.doc( + description="Read a public v2 form definition by delivery token without an OTP or login session.", + security=[], + responses={404: "Form not found", 412: "Form already submitted or expired"}, + ) + @web_ns.response(200, "Form definition", web_ns.models[FormDefinitionResponse.__name__]) + def get(self, form_token: str): + service = HumanInputSubmissionService(session_factory=session_factory.get_session_maker()) + try: + form = service.get_public_form(form_token=form_token, ip_address=extract_remote_ip(request) or None) + except HumanInputFormError as error: + _raise_public_form_error(error) + presentation = build_form_presentation(form.resolved_form) + return dump_response( + FormDefinitionResponse, + { + "form_content": presentation.form_content, + "inputs": presentation.inputs, + "resolved_default_values": presentation.resolved_default_values, + "user_actions": presentation.user_actions, + "expiration_time": to_utc_timestamp(min(form.expiration_time, form.global_timeout_deadline)), + }, + ) + + @error_responses( + PublicHumanInputV2NotFoundError, + PublicHumanInputV2SubmittedError, + PublicHumanInputV2ExpiredError, + HumanInputV2WorkflowNotActiveError, + HumanInputV2InvalidInputError, + HumanInputExpiredChallengeError, + HumanInputInvalidOtpError, + HumanInputStaleChallengeError, + HumanInputV2UnavailableError, + ) + @web_ns.expect(web_ns.models[HumanInputV2FormSubmitRequest.__name__]) + @web_ns.doc( + description="Submit a form with inputs, an action, and email verification proof.", + security=[], + responses={ + 400: "Invalid form or email proof", + 404: "Form not found", + 409: "Workflow not active", + 412: "Already submitted or expired", + 422: "Invalid request body", + 503: "Email verification unavailable", + }, + ) + @web_ns.response(200, "Form submitted", web_ns.models[FormSubmitResponse.__name__]) + @model_validate(HumanInputV2FormSubmitRequest) + def post(self, payload: HumanInputV2FormSubmitRequest, form_token: str): + service = HumanInputSubmissionService(session_factory=session_factory.get_session_maker()) + try: + service.submit_public_form( + email_otp_service=build_email_otp_service(), + form_token=form_token, + action=payload.action, + inputs=payload.inputs, + challenge_token=payload.challenge_token, + otp_code=payload.otp_code, + ip_address=extract_remote_ip(request) or None, + ) + except EmailOtpError as error: + raise email_otp_http_error(error) from error + except HumanInputFormError as error: + _raise_public_form_error(error) + return FormSubmitResponse().model_dump(mode="json") + + +@web_ns.route("/form/human-input/v2/public//upload-token") +class HumanInputV2FormUploadTokenApi(Resource): + """Issue an upload token for an active Human Input v2 form.""" + + @error_responses( + PublicHumanInputV2NotFoundError, + PublicHumanInputV2SubmittedError, + PublicHumanInputV2ExpiredError, + InvalidUploadTokenForbiddenError, + ) + @web_ns.response(200, "Success", web_ns.models[FormUploadTokenResponse.__name__]) + @web_ns.doc(security=[], responses={404: "Form not found", 412: "Already submitted or expired"}) + def post(self, form_token: str): + try: + grant = build_file_upload_service().issue_public_upload_token(form_token) + except HumanInputFormError as error: + _raise_public_form_error(error) + except InvalidUploadTokenError as error: + raise InvalidUploadTokenForbiddenError() from error + return FormUploadTokenResponse( + upload_token=grant.upload_token, expires_at=to_utc_timestamp(grant.expires_at) + ).model_dump(mode="json") + + +@web_ns.route("/form/human-input/v2/public//access-request") +class FormAccessRequestApi(Resource): + @error_responses( + PublicHumanInputV2NotFoundError, + PublicHumanInputV2SubmittedError, + PublicHumanInputV2ExpiredError, + HumanInputAccessRateLimitError, + HumanInputAccessDeliveryError, + HumanInputV2UnavailableError, + ) + @web_ns.response(200, "Success", web_ns.models[FormAccessRequestResponse.__name__]) + def post(self, form_token: str): + try: + challenge = build_email_otp_service().request_access( + form_token, ip_address=extract_remote_ip(request) or None + ) + except EmailOtpError as error: + raise email_otp_http_error(error) from error + return FormAccessRequestResponse( + challenge_token=challenge.challenge_token, + expires_in_seconds=challenge.expires_in_seconds, + resend_after_seconds=challenge.resend_after_seconds, + ).model_dump(mode="json") diff --git a/api/controllers/web/human_input_v2.py b/api/controllers/web/human_input_v2.py new file mode 100644 index 00000000000..ec4ec94b8b4 --- /dev/null +++ b/api/controllers/web/human_input_v2.py @@ -0,0 +1,120 @@ +"""Passport-authenticated Human Input v2 forms for the original Web App EndUser. + +These routes are separate from the public Email-proof form surface. A delivery +token locates the form; the app passport establishes who may read or submit it. +""" + +from flask import request +from pydantic import BaseModel, ConfigDict, JsonValue +from werkzeug.exceptions import NotFound + +from controllers.common.human_input_v2_errors import ( + HumanInputV2AlreadySubmittedError, + HumanInputV2ExpiredError, + HumanInputV2InvalidInputError, + HumanInputV2WorkflowNotActiveError, + form_http_error, +) +from controllers.common.schema import register_response_schema_models, register_schema_models +from controllers.console.wraps import model_validate +from controllers.web import web_ns +from controllers.web.wraps import WebApiResource +from core.db.session_factory import session_factory +from core.human_input_v2.resolved_form import ResolvedForm +from core.human_input_v2.shared.values import TenantId +from fields.base import ResponseModel +from libs.external_api import error_responses +from libs.helper import dump_response, extract_remote_ip, to_timestamp +from models.model import App, EndUser +from services.human_input_v2.submission_service import ( + HumanInputFormError, + HumanInputSubmissionService, + WebAppFormActor, +) + + +class WebAppHumanInputV2SubmitPayload(BaseModel): + model_config = ConfigDict(extra="forbid", strict=True) + + inputs: dict[str, JsonValue] + action: str + + +class WebAppHumanInputV2FormDefinitionResponse(ResponseModel): + resolved_form: ResolvedForm + expiration_time: int + + +class WebAppHumanInputV2SubmitResponse(ResponseModel): + pass + + +register_schema_models(web_ns, WebAppHumanInputV2SubmitPayload) +register_response_schema_models(web_ns, WebAppHumanInputV2FormDefinitionResponse, WebAppHumanInputV2SubmitResponse) + + +def _web_app_actor(app_model: App, end_user: EndUser) -> WebAppFormActor: + # The passport decoder loads App and EndUser independently. Establish their + # ownership relationship before treating the session as an approval actor. + if end_user.tenant_id != app_model.tenant_id or end_user.app_id != app_model.id: + raise NotFound("Form not found") + return WebAppFormActor(tenant_id=TenantId(app_model.tenant_id), app_id=app_model.id, end_user_id=end_user.id) + + +@web_ns.route("/form/human-input/v2/") +class WebAppHumanInputV2FormApi(WebApiResource): + @error_responses(HumanInputV2AlreadySubmittedError, HumanInputV2ExpiredError) + @web_ns.doc( + "get_web_app_human_input_v2_form", + description="Read a v2 form using the original EndUser's Web App passport.", + responses={401: "Invalid passport", 404: "Form not found", 409: "Already submitted", 410: "Expired"}, + ) + @web_ns.response(200, "Form retrieved", web_ns.models[WebAppHumanInputV2FormDefinitionResponse.__name__]) + def get(self, app_model: App, end_user: EndUser, form_token: str): + actor = _web_app_actor(app_model, end_user) + service = HumanInputSubmissionService(session_factory=session_factory.get_session_maker()) + try: + form = service.get_web_app_form( + actor=actor, form_token=form_token, ip_address=extract_remote_ip(request) or None + ) + except HumanInputFormError as exc: + raise form_http_error(exc, message="The approval could not be accepted.") from exc + return dump_response( + WebAppHumanInputV2FormDefinitionResponse, + { + "resolved_form": form.resolved_form, + "expiration_time": to_timestamp(min(form.expiration_time, form.global_timeout_deadline)), + }, + ) + + @error_responses( + HumanInputV2AlreadySubmittedError, + HumanInputV2ExpiredError, + HumanInputV2WorkflowNotActiveError, + HumanInputV2InvalidInputError, + ) + @web_ns.doc( + "submit_web_app_human_input_v2_form", + description="Submit a v2 form using the original EndUser's Web App passport.", + responses={ + 400: "Invalid inputs", + 401: "Invalid passport", + 404: "Form not found", + 409: "Already submitted or workflow not active", + 410: "Expired", + 422: "Invalid request body", + }, + ) + @web_ns.expect(web_ns.models[WebAppHumanInputV2SubmitPayload.__name__]) + @web_ns.response(200, "Approval accepted", web_ns.models[WebAppHumanInputV2SubmitResponse.__name__]) + @model_validate(WebAppHumanInputV2SubmitPayload) + def post(self, payload: WebAppHumanInputV2SubmitPayload, app_model: App, end_user: EndUser, form_token: str): + actor = _web_app_actor(app_model, end_user) + service = HumanInputSubmissionService(session_factory=session_factory.get_session_maker()) + try: + service.submit_web_app_form( + actor=actor, form_token=form_token, action=payload.action, inputs=payload.inputs + ) + except HumanInputFormError as exc: + raise form_http_error(exc, message="The approval could not be accepted.") from exc + return WebAppHumanInputV2SubmitResponse().model_dump(mode="json") diff --git a/api/controllers/web/human_input_v2_file_upload.py b/api/controllers/web/human_input_v2_file_upload.py new file mode 100644 index 00000000000..667969554c9 --- /dev/null +++ b/api/controllers/web/human_input_v2_file_upload.py @@ -0,0 +1,202 @@ +"""Public bearer-only file upload transport for v2 approval grants.""" + +from flask import request +from flask_restx import Resource +from pydantic import BaseModel, ConfigDict, HttpUrl, ValidationError +from werkzeug.exceptions import BadRequest + +from controllers.common.errors import ( + BlockedFileExtensionError, + FilenameNotExistsError, + FileTooLargeError, + NoFileUploadedError, + RemoteFileAccessDeniedError, + RemoteFileInvalidResponseError, + RemoteFileInvalidUrlError, + RemoteFileNotFoundError, + RemoteFileUnavailableError, + RemoteFileUrlBlockedError, + TooManyFilesError, + UnsupportedFileTypeError, +) +from controllers.common.human_input_v2_errors import ( + PublicHumanInputV2ExpiredError, + PublicHumanInputV2NotFoundError, + PublicHumanInputV2SubmittedError, +) +from controllers.common.schema import register_response_schema_models, register_schema_models +from controllers.web import web_ns +from controllers.web.human_input_file_upload import ( + HumanInputFileUploadFormPayload, + InvalidUploadTokenBadRequestError, + InvalidUploadTokenForbiddenError, + InvalidUploadTokenUnauthorizedError, +) +from controllers.web.human_input_form_access_request import _raise_public_form_error +from fields.file_fields import FileWithSignedUrl +from libs.external_api import error_responses +from libs.helper import dump_response +from services import remote_file_service as remote_errors +from services.errors import file as file_errors +from services.human_input_file_upload_service import InvalidUploadTokenError +from services.human_input_v2.file_upload_service import V2_UPLOAD_TOKEN_PREFIX, build_file_upload_service +from services.human_input_v2.submission_service import HumanInputFormError + +register_response_schema_models(web_ns, FileWithSignedUrl) + + +@web_ns.route("/human-input/v2/files") +class HumanInputV2FileUploadApi(Resource): + @error_responses( + InvalidUploadTokenUnauthorizedError, + InvalidUploadTokenBadRequestError, + InvalidUploadTokenForbiddenError, + PublicHumanInputV2NotFoundError, + PublicHumanInputV2SubmittedError, + PublicHumanInputV2ExpiredError, + NoFileUploadedError, + FilenameNotExistsError, + TooManyFilesError, + FileTooLargeError, + UnsupportedFileTypeError, + BlockedFileExtensionError, + RemoteFileAccessDeniedError, + RemoteFileInvalidResponseError, + RemoteFileInvalidUrlError, + RemoteFileNotFoundError, + RemoteFileUnavailableError, + RemoteFileUrlBlockedError, + ) + @web_ns.doc( + description="Upload a file or a remote URL using a v2 upload grant.", + consumes=["multipart/form-data"], + security=[], + params={ + "Authorization": { + "in": "header", + "type": "string", + "required": True, + "description": "Bearer v2 upload grant", + }, + "file": {"in": "formData", "type": "file", "required": False}, + "url": {"in": "formData", "type": "string", "required": False, "description": "Remote HTTP(S) file URL"}, + }, + responses={ + 400: "Invalid upload", + 401: "Grant required", + 403: "Invalid grant", + 404: "Form not found", + 412: "Form inactive", + }, + ) + @web_ns.response(201, "File uploaded", web_ns.models[FileWithSignedUrl.__name__]) + def post(self): + return _upload_file(remote=False) + + +class HumanInputV2RemoteUploadPayload(BaseModel): + model_config = ConfigDict(extra="forbid") + + url: HttpUrl + + +register_schema_models(web_ns, HumanInputV2RemoteUploadPayload) + + +@web_ns.route("/human-input/v2/files/remote") +class HumanInputV2RemoteFileUploadApi(Resource): + @error_responses( + InvalidUploadTokenUnauthorizedError, + InvalidUploadTokenBadRequestError, + InvalidUploadTokenForbiddenError, + PublicHumanInputV2NotFoundError, + PublicHumanInputV2SubmittedError, + PublicHumanInputV2ExpiredError, + NoFileUploadedError, + FilenameNotExistsError, + TooManyFilesError, + FileTooLargeError, + UnsupportedFileTypeError, + BlockedFileExtensionError, + RemoteFileAccessDeniedError, + RemoteFileInvalidResponseError, + RemoteFileInvalidUrlError, + RemoteFileNotFoundError, + RemoteFileUnavailableError, + RemoteFileUrlBlockedError, + ) + @web_ns.doc( + description="Fetch a remote file using a v2 upload grant.", + security=[], + params={"Authorization": {"in": "header", "type": "string", "required": True}}, + responses={ + 400: "Invalid upload", + 401: "Grant required", + 403: "Invalid grant", + 404: "Form not found", + 412: "Form inactive", + }, + ) + @web_ns.expect(web_ns.models[HumanInputV2RemoteUploadPayload.__name__]) + @web_ns.response(201, "File uploaded", web_ns.models[FileWithSignedUrl.__name__]) + def post(self): + return _upload_file(remote=True) + + +def _upload_file(*, remote: bool): + authorization = request.headers.get("Authorization", "").split() + if len(authorization) != 2: + raise InvalidUploadTokenUnauthorizedError() + scheme, token = authorization + if scheme.lower() != "bearer" or not token.startswith(V2_UPLOAD_TOKEN_PREFIX): + raise InvalidUploadTokenBadRequestError() + service = build_file_upload_service() + try: + context = service.validate_upload_token(token) + except InvalidUploadTokenError as error: + raise InvalidUploadTokenForbiddenError() from error + except HumanInputFormError as error: + _raise_public_form_error(error) + try: + if remote: + payload = HumanInputV2RemoteUploadPayload.model_validate(web_ns.payload or {}) + form = HumanInputFileUploadFormPayload(url=payload.url) + else: + form = HumanInputFileUploadFormPayload.model_validate(request.form.to_dict(flat=True)) + except ValidationError as error: + raise RemoteFileInvalidUrlError() from error + if len(request.files.getlist("file")) > 1 or len(request.files) > 1: + raise TooManyFilesError() + if form.url is not None and request.files: + raise BadRequest("Provide either a file or a URL.") + try: + if form.url is not None: + uploaded = service.upload_remote_file(context=context, url=str(form.url)) + else: + file = request.files.get("file") + if file is None: + raise NoFileUploadedError() + if not file.filename: + raise FilenameNotExistsError() + uploaded = service.upload_local_file( + context=context, filename=file.filename, content=file.read(), mimetype=file.mimetype + ) + except file_errors.FileTooLargeError as error: + raise FileTooLargeError(error.description or "File size exceeded.") from error + except file_errors.UnsupportedFileTypeError as error: + raise UnsupportedFileTypeError() from error + except file_errors.BlockedFileExtensionError as error: + raise BlockedFileExtensionError() from error + except remote_errors.RemoteFileInvalidUrlError as error: + raise RemoteFileInvalidUrlError() from error + except remote_errors.RemoteFileUrlBlockedError as error: + raise RemoteFileUrlBlockedError() from error + except remote_errors.RemoteFileNotFoundError as error: + raise RemoteFileNotFoundError() from error + except remote_errors.RemoteFileAccessDeniedError as error: + raise RemoteFileAccessDeniedError() from error + except remote_errors.RemoteFileUnavailableError as error: + raise RemoteFileUnavailableError() from error + except remote_errors.RemoteFileInvalidResponseError as error: + raise RemoteFileInvalidResponseError() from error + return dump_response(FileWithSignedUrl, uploaded), 201 diff --git a/api/controllers/web/human_input_v2_im_webhook.py b/api/controllers/web/human_input_v2_im_webhook.py new file mode 100644 index 00000000000..255da0bded2 --- /dev/null +++ b/api/controllers/web/human_input_v2_im_webhook.py @@ -0,0 +1,45 @@ +"""Bounded, unauthenticated HTTP transport for Provider-authenticated callbacks.""" + +from typing import Annotated + +from flask import Blueprint, Response, request +from pydantic import BaseModel, Field, ValidationError + +from configs import dify_config +from core.human_input_v2.im_integration.adapters.entities import WebhookRequest +from libs.datetime_utils import naive_utc_now +from libs.helper import extract_remote_ip +from repositories.human_input_v2.im_channel_repository import WebhookId +from services.human_input_v2.im_webhook_runtime import build_im_webhook_ingress_service + +bp = Blueprint("im_provider_webhook", __name__, url_prefix="/callbacks/human-input/v2/im") + + +class _WebhookRoute(BaseModel): + webhook_id: Annotated[str, Field(min_length=32, max_length=32, pattern=r"^[A-Za-z0-9_-]+$")] + + +@bp.post("/") +def handle_im_provider_webhook(webhook_id: str) -> Response: + received_at = naive_utc_now() + try: + route = _WebhookRoute(webhook_id=webhook_id) + except ValidationError: + return Response(status=404) + maximum_bytes = dify_config.WEBHOOK_REQUEST_BODY_MAX_SIZE + if request.content_length is not None and request.content_length > maximum_bytes: + return Response(status=413) + body = request.stream.read(maximum_bytes + 1) + if len(body) > maximum_bytes: + return Response(status=413) + provider_response = build_im_webhook_ingress_service().handle( + WebhookId(route.webhook_id), + WebhookRequest( + method=request.method, + headers=tuple(request.headers.items()), + body=body, + received_at=received_at, + request_ip_address=extract_remote_ip(request) or None, + ), + ) + return Response(provider_response.body, status=provider_response.status_code, headers=provider_response.headers) diff --git a/api/core/app/apps/common/workflow_response_converter.py b/api/core/app/apps/common/workflow_response_converter.py index 880758a8e0c..9f84b7b794e 100644 --- a/api/core/app/apps/common/workflow_response_converter.py +++ b/api/core/app/apps/common/workflow_response_converter.py @@ -2,6 +2,7 @@ import json import logging import time from collections.abc import Mapping, Sequence +from dataclasses import dataclass from datetime import datetime from typing import Any, NewType, TypedDict @@ -55,12 +56,13 @@ from core.workflow.human_input_forms import ( load_form_dispositions_by_form_id, ) from core.workflow.human_input_policy import ( - FormDisposition, HumanInputSurface, enrich_human_input_pause_reasons, - resolve_human_input_pause_reason_inputs, + resolve_variable_select_input_options, ) from core.workflow.nodes.human_input.pause_reason import HumanInputRequired +from core.workflow.nodes.human_input_v2.presentation import build_human_input_v2_pause_reason +from core.workflow.nodes.human_input_v2.runtime import PreparedForm from core.workflow.system_variables import SystemVariableKey, system_variables_to_mapping from core.workflow.workflow_entry import WorkflowEntry from extensions.ext_database import db @@ -73,6 +75,7 @@ from graphon.enums import ( ) from graphon.file import FILE_MODEL_IDENTITY, File from graphon.runtime import RuntimeState +from graphon.runtime.runtime_state_protocol import ReadOnlyVariablePool from graphon.variables.segments import ArrayFileSegment, FileSegment, Segment from graphon.variables.variables import Variable from graphon.workflow_type_encoder import WorkflowRuntimeTypeConverter @@ -108,7 +111,39 @@ class EndUserCreatedByDict(TypedDict): CreatedByDict = AccountCreatedByDict | EndUserCreatedByDict +@dataclass(frozen=True, slots=True) +class _HumanInputPauseOutput: + """Keep a form event paired with its entry in the workflow pause response.""" + + response: HumanInputRequiredResponse + pause_reason: dict[str, Any] + + class WorkflowResponseConverter: + @staticmethod + def human_input_v2_required_data(prepared: PreparedForm, *, node_id: str) -> HumanInputRequiredResponse.Data: + """Project Dify-owned form data into the existing HITL wire contract. + + Live execution supplies the node's snapshot; reconnect loads the form + from its run-owned v2 records. Neither path resolves a v1 token. + """ + reason = build_human_input_v2_pause_reason( + prepared.form.resolved_form, form_id=prepared.form.id, node_id=node_id + ) + return HumanInputRequiredResponse.Data( + form_version=reason.form_version, + form_id=reason.form_id, + node_id=node_id, + node_title=reason.node_title, + form_content=reason.form_content, + inputs=reason.inputs, + actions=reason.actions, + resolved_default_values=reason.resolved_default_values, + expiration_time=to_utc_timestamp(prepared.form.expiration_time), + display_in_ui=prepared.form_token is not None, + form_token=prepared.form_token.get_secret_value() if prepared.form_token is not None else None, + ) + _truncator: BaseTruncator def __init__( @@ -332,75 +367,45 @@ class WorkflowResponseConverter: encoded_outputs = self._encode_outputs(event.outputs) or {} if self._application_generate_entity.invoke_from == InvokeFrom.SERVICE_API: encoded_outputs = {} - variable_pool = graph_runtime_state.variable_pool - resolved_reasons = resolve_human_input_pause_reason_inputs( - event.reasons, - variable_pool=variable_pool, + v1_outputs = iter( + self._human_input_v1_pause_responses( + reasons=[ + reason + for reason in event.reasons + if isinstance(reason, HumanInputRequired) and reason.form_version == "1" + ], + task_id=task_id, + variable_pool=graph_runtime_state.variable_pool, + ) ) - pause_reasons = [reason.model_dump(mode="json") for reason in resolved_reasons] - human_input_form_ids = [reason.form_id for reason in resolved_reasons if isinstance(reason, HumanInputRequired)] - expiration_times_by_form_id: dict[str, datetime] = {} - display_in_ui_by_form_id: dict[str, bool] = {} - dispositions_by_form_id: dict[str, FormDisposition] = {} - if human_input_form_ids: - stmt = select( - HumanInputForm.id, - HumanInputForm.expiration_time, - HumanInputForm.form_definition, - ).where(HumanInputForm.id.in_(human_input_form_ids)) - hitl_surface = _INVOKE_FROM_TO_HITL_SURFACE.get(self._application_generate_entity.invoke_from) - with Session(bind=db.engine) as session: - for form_id, expiration_time, form_definition in session.execute(stmt): - expiration_times_by_form_id[str(form_id)] = expiration_time - try: - definition_payload = json.loads(form_definition) if form_definition else {} - except (TypeError, json.JSONDecodeError): - definition_payload = {} - display_in_ui_by_form_id[str(form_id)] = bool(definition_payload.get("display_in_ui")) - dispositions_by_form_id = load_form_dispositions_by_form_id( - human_input_form_ids, - session=session, - surface=hitl_surface, - ) - - # Reconnect paths must preserve the same pause-reason contract as live streams; - # otherwise clients see schema drift after resume. - pause_reasons = enrich_human_input_pause_reasons( - pause_reasons, - dispositions_by_form_id=dispositions_by_form_id, - expiration_times_by_form_id={ - form_id: to_utc_timestamp(expiration_time) - for form_id, expiration_time in expiration_times_by_form_id.items() - }, + v2_outputs = iter( + self._human_input_v2_pause_responses( + reasons=[ + reason + for reason in event.reasons + if isinstance(reason, HumanInputRequired) and reason.form_version == "2" + ], + forms=event.human_input_v2_forms, + task_id=task_id, + ) ) - responses: list[StreamResponse] = [] - - for reason in resolved_reasons: - if isinstance(reason, HumanInputRequired): - expiration_time = expiration_times_by_form_id.get(reason.form_id) - if expiration_time is None: - raise ValueError(f"HumanInputForm not found for pause reason, form_id={reason.form_id}") - disposition = dispositions_by_form_id.get(reason.form_id) - responses.append( - HumanInputRequiredResponse( - task_id=task_id, - workflow_run_id=run_id, - data=HumanInputRequiredResponse.Data( - form_id=reason.form_id, - node_id=reason.node_id, - node_title=reason.node_title, - form_content=reason.form_content, - inputs=reason.inputs, - actions=reason.actions, - display_in_ui=display_in_ui_by_form_id.get(reason.form_id, False), - form_token=disposition.form_token if disposition else None, - approval_channels=list(disposition.approval_channels) if disposition else [], - resolved_default_values=reason.resolved_default_values, - expiration_time=to_utc_timestamp(expiration_time), - ), - ) - ) + pause_reasons: list[dict[str, Any]] = [] + # Each version owns its complete conversion; merge in the original event + # order, including scheduling reasons interleaved with approval forms. + for reason in event.reasons: + if not isinstance(reason, HumanInputRequired): + pause_reasons.append(reason.model_dump(mode="json")) + continue + match reason.form_version: + case "1": + converted = next(v1_outputs) + case "2": + converted = next(v2_outputs) + case _: + raise ValueError(f"Unsupported Human Input form version: {reason.form_version}") + responses.append(converted.response) + pause_reasons.append(converted.pause_reason) responses.append( WorkflowPauseStreamResponse( @@ -930,3 +935,97 @@ class WorkflowResponseConverter: node_id=event.node_id, ), ) + + def _human_input_v1_pause_responses( + self, + *, + reasons: Sequence[HumanInputRequired], + task_id: str, + variable_pool: ReadOnlyVariablePool, + ) -> list[_HumanInputPauseOutput]: + """Resolve legacy inputs, access channels, and both V1 response payloads.""" + if not reasons: + return [] + run_id = self._ensure_workflow_run_id() + resolved_reasons = [ + reason.model_copy( + update={ + "inputs": resolve_variable_select_input_options(reason.inputs, variable_pool=variable_pool), + } + ) + for reason in reasons + ] + form_ids = [reason.form_id for reason in resolved_reasons] + expiration_times: dict[str, datetime] = {} + display_in_ui: dict[str, bool] = {} + stmt = select(HumanInputForm.id, HumanInputForm.expiration_time, HumanInputForm.form_definition).where( + HumanInputForm.id.in_(form_ids) + ) + surface = _INVOKE_FROM_TO_HITL_SURFACE.get(self._application_generate_entity.invoke_from) + with Session(bind=db.engine) as session: + for form_id, expiration_time, form_definition in session.execute(stmt): + expiration_times[str(form_id)] = expiration_time + try: + definition_payload = json.loads(form_definition) if form_definition else {} + except (TypeError, json.JSONDecodeError): + definition_payload = {} + display_in_ui[str(form_id)] = bool(definition_payload.get("display_in_ui")) + dispositions = load_form_dispositions_by_form_id(form_ids, session=session, surface=surface) + # Preserve the legacy pause-reason fields, which differ from the + # human_input_required data payload (notably display_in_ui). + pause_reasons = enrich_human_input_pause_reasons( + [reason.model_dump(mode="json") for reason in resolved_reasons], + dispositions_by_form_id=dispositions, + expiration_times_by_form_id={ + form_id: to_utc_timestamp(value) for form_id, value in expiration_times.items() + }, + ) + outputs: list[_HumanInputPauseOutput] = [] + for reason, payload in zip(resolved_reasons, pause_reasons, strict=True): + expiration_time = expiration_times.get(reason.form_id) + if expiration_time is None: + raise ValueError(f"HumanInputForm not found for pause reason, form_id={reason.form_id}") + disposition = dispositions.get(reason.form_id) + response = HumanInputRequiredResponse( + task_id=task_id, + workflow_run_id=run_id, + data=HumanInputRequiredResponse.Data( + form_version=reason.form_version, + form_id=reason.form_id, + node_id=reason.node_id, + node_title=reason.node_title, + form_content=reason.form_content, + inputs=reason.inputs, + actions=reason.actions, + display_in_ui=display_in_ui.get(reason.form_id, False), + form_token=disposition.form_token if disposition else None, + approval_channels=list(disposition.approval_channels) if disposition else [], + resolved_default_values=reason.resolved_default_values, + expiration_time=to_utc_timestamp(expiration_time), + ), + ) + outputs.append(_HumanInputPauseOutput(response=response, pause_reason=payload)) + return outputs + + def _human_input_v2_pause_responses( + self, + *, + reasons: Sequence[HumanInputRequired], + forms: Mapping[str, PreparedForm], + task_id: str, + ) -> list[_HumanInputPauseOutput]: + """Build both V2 response payloads from the snapshots emitted by nodes.""" + if not reasons: + return [] + run_id = self._ensure_workflow_run_id() + outputs: list[_HumanInputPauseOutput] = [] + for reason in reasons: + prepared = forms.get(reason.form_id) + if prepared is None: + raise ValueError(f"Human Input v2 node did not supply its form snapshot: {reason.form_id}") + data = self.human_input_v2_required_data(prepared, node_id=reason.node_id) + response = HumanInputRequiredResponse(task_id=task_id, workflow_run_id=run_id, data=data) + payload = reason.model_dump(mode="json") + payload.update(data.model_dump(mode="json")) + outputs.append(_HumanInputPauseOutput(response=response, pause_reason=payload)) + return outputs diff --git a/api/core/app/apps/workflow_app_runner.py b/api/core/app/apps/workflow_app_runner.py index 1246cd49a02..3df06d4b0df 100644 --- a/api/core/app/apps/workflow_app_runner.py +++ b/api/core/app/apps/workflow_app_runner.py @@ -58,6 +58,11 @@ from core.workflow.nodes.human_input.boundary import resolve_human_input_node_id from core.workflow.nodes.human_input.callback import DifyHITLCallback from core.workflow.nodes.human_input.enums import HumanInputFormStatus from core.workflow.nodes.human_input.pause_reason import HumanInputRequired, PauseReason +from core.workflow.nodes.human_input_v2.events import ( + NodeRunHumanInputV2FormFilledEvent, + NodeRunHumanInputV2FormTimeoutEvent, +) +from core.workflow.nodes.human_input_v2.runtime import PreparedForm from core.workflow.system_variables import ( build_bootstrap_variables, default_system_variables, @@ -108,7 +113,7 @@ from graphon.variable_loader import DUMMY_VARIABLE_LOADER, VariableLoader, load_ from graphon.variables.factory import build_segment from models.workflow import Workflow from services.workflow_node_variables import load_additional_node_variables -from tasks.mail_human_input_delivery_task import dispatch_human_input_email_task +from tasks.mail_human_input_delivery_task import dispatch_human_input_form_delivery_task logger = logging.getLogger(__name__) @@ -587,6 +592,7 @@ class WorkflowBasedAppRunner: node_run_index: int = 1, node_execution_snapshots: tuple[NodeExecutionSnapshot, ...] = (), pause_reasons: Sequence[PauseReason] = (), + human_input_v2_forms: Mapping[str, PreparedForm] | None = None, ) -> None: """ Handle event @@ -602,12 +608,13 @@ class WorkflowBasedAppRunner: loop_id = node_metadata.get(WorkflowNodeExecutionMetadataKey.LOOP_ID.value) match event: case NodeRunHumanInputFormFilledEvent(): + form_id = event.form_id if isinstance(event, NodeRunHumanInputV2FormFilledEvent) else event.id self._publish_event( QueueHumanInputFormFilledEvent( - form_id=event.id, + form_id=form_id, node_id=resolve_human_input_node_id( node_id=event.node_id, - form_id=event.id, + form_id=form_id, variable_pool=workflow_entry.graph_engine.runtime_state.variable_pool, ), node_type=event.node_type, @@ -619,12 +626,13 @@ class WorkflowBasedAppRunner: ) ) case NodeRunHumanInputFormTimeoutEvent(): + form_id = event.form_id if isinstance(event, NodeRunHumanInputV2FormTimeoutEvent) else event.id self._publish_event( QueueHumanInputFormTimeoutEvent( - form_id=event.id, + form_id=form_id, node_id=resolve_human_input_node_id( node_id=event.node_id, - form_id=event.id, + form_id=form_id, variable_pool=workflow_entry.graph_engine.runtime_state.variable_pool, ), node_type=event.node_type, @@ -657,12 +665,24 @@ class WorkflowBasedAppRunner: paused_nodes = list( dict.fromkeys(reason.node_id for reason in pause_reasons if isinstance(reason, HumanInputRequired)) ) - self._enqueue_human_input_notifications(pause_reasons) + forms = { + reason.form_id: (human_input_v2_forms or {})[reason.form_id] + for reason in pause_reasons + if isinstance(reason, HumanInputRequired) and reason.form_version == "2" + } + self._enqueue_human_input_v1_notifications( + [ + reason + for reason in pause_reasons + if isinstance(reason, HumanInputRequired) and reason.form_version == "1" + ] + ) self._publish_event( QueueWorkflowPausedEvent( reasons=list(pause_reasons), outputs=event.outputs, paused_nodes=paused_nodes, + human_input_v2_forms=forms, ) ) case NodeRunRetryEvent(): @@ -910,19 +930,17 @@ class WorkflowBasedAppRunner: ) ) - def _enqueue_human_input_notifications(self, reasons: Sequence[object]) -> None: + def _enqueue_human_input_v1_notifications(self, reasons: Sequence[HumanInputRequired]) -> None: for reason in reasons: - if not isinstance(reason, HumanInputRequired): - continue if not reason.form_id: continue try: - dispatch_human_input_email_task.apply_async( + dispatch_human_input_form_delivery_task.apply_async( kwargs={"form_id": reason.form_id, "node_title": reason.node_title}, queue="mail", ) except Exception: # pragma: no cover - defensive logging - logger.exception("Failed to enqueue human input email task for form %s", reason.form_id) + logger.exception("Failed to enqueue human input form delivery task for form %s", reason.form_id) def _publish_event(self, event: AppQueueEvent): self._queue_manager.publish(event, PublishFrom.APPLICATION_MANAGER) diff --git a/api/core/app/entities/queue_entities.py b/api/core/app/entities/queue_entities.py index 2ed3a0902a8..02d3ec0e055 100644 --- a/api/core/app/entities/queue_entities.py +++ b/api/core/app/entities/queue_entities.py @@ -8,6 +8,7 @@ from pydantic import BaseModel, ConfigDict, Field from core.app.entities.agent_strategy import AgentStrategyInfo from core.rag.entities import RetrievalSourceMetadata from core.workflow.nodes.human_input.pause_reason import PauseReason +from core.workflow.nodes.human_input_v2.runtime import PreparedForm from graphon.entities import WorkflowStartReason from graphon.enums import NodeType, WorkflowNodeExecutionMetadataKey from graphon.model_runtime.entities.llm_entities import LLMResult, LLMResultChunk @@ -595,3 +596,6 @@ class QueueWorkflowPausedEvent(AppQueueEvent): reasons: Sequence[PauseReason] = Field(default_factory=list) outputs: Mapping[str, object] = Field(default_factory=dict) paused_nodes: Sequence[str] = Field(default_factory=list) + # Live node snapshots consumed by Dify's response converter, never included + # in the public pause reason or the persisted graph resumption state. + human_input_v2_forms: Mapping[str, PreparedForm] = Field(default_factory=dict, repr=False) diff --git a/api/core/app/entities/task_entities.py b/api/core/app/entities/task_entities.py index b3cd0b3ec3d..5894e8a344e 100644 --- a/api/core/app/entities/task_entities.py +++ b/api/core/app/entities/task_entities.py @@ -285,6 +285,7 @@ class HumanInputRequiredResponse(StreamResponse): Data entity """ + form_version: Literal["1", "2"] = "1" form_id: str node_id: str node_title: str @@ -309,6 +310,7 @@ class HumanInputRequiredPauseReasonPayload(BaseModel): """ TYPE: Literal[DifyHITLEventType.HUMAN_INPUT_REQUIRED] = DifyHITLEventType.HUMAN_INPUT_REQUIRED + form_version: Literal["1", "2"] = "1" form_id: str node_id: str node_title: str @@ -324,6 +326,7 @@ class HumanInputRequiredPauseReasonPayload(BaseModel): @classmethod def from_response_data(cls, data: HumanInputRequiredResponse.Data) -> "HumanInputRequiredPauseReasonPayload": return cls( + form_version=data.form_version, form_id=data.form_id, node_id=data.node_id, node_title=data.node_title, diff --git a/api/core/entities/execution_extra_content.py b/api/core/entities/execution_extra_content.py index 355ce4a46d7..c208dfa6153 100644 --- a/api/core/entities/execution_extra_content.py +++ b/api/core/entities/execution_extra_content.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Mapping, Sequence -from typing import Any, TypeAlias +from typing import Any, Literal, TypeAlias from pydantic import BaseModel, ConfigDict, Field, JsonValue @@ -13,6 +13,7 @@ class HumanInputFormDefinition(BaseModel): model_config = ConfigDict(frozen=True) form_id: str + form_version: Literal["1", "2"] = "1" node_id: str node_title: str form_content: str diff --git a/api/core/human_input/__init__.py b/api/core/human_input/__init__.py new file mode 100644 index 00000000000..0df28a6b5e2 --- /dev/null +++ b/api/core/human_input/__init__.py @@ -0,0 +1,5 @@ +"""Channel-neutral Human Input values shared across runtime versions.""" + +from .enums import ButtonStyle + +__all__ = ["ButtonStyle"] diff --git a/api/core/human_input/enums.py b/api/core/human_input/enums.py new file mode 100644 index 00000000000..a071ea27634 --- /dev/null +++ b/api/core/human_input/enums.py @@ -0,0 +1,12 @@ +"""Channel-neutral Human Input enumerations.""" + +import enum + + +class ButtonStyle(enum.StrEnum): + """Button styles for user actions.""" + + PRIMARY = enum.auto() + DEFAULT = enum.auto() + ACCENT = enum.auto() + GHOST = enum.auto() diff --git a/api/core/human_input_v2/__init__.py b/api/core/human_input_v2/__init__.py new file mode 100644 index 00000000000..c1767271cb5 --- /dev/null +++ b/api/core/human_input_v2/__init__.py @@ -0,0 +1 @@ +"""Channel-neutral Human Input v2 domain values.""" diff --git a/api/core/human_input_v2/entities.py b/api/core/human_input_v2/entities.py new file mode 100644 index 00000000000..b8e801f00b3 --- /dev/null +++ b/api/core/human_input_v2/entities.py @@ -0,0 +1,140 @@ +from enum import StrEnum +from typing import NewType + +from pydantic import NaiveDatetime + +from core.human_input_v2.shared import AccountId, ContactId, NormalizedEmail, TenantId + + +class HumanInputContactType(StrEnum): + """Concrete contact classification resolved in one workspace.""" + + WORKSPACE = "workspace" + PLATFORM = "platform" + EXTERNAL = "external" + + +class ChannelKind(StrEnum): + """Human Input channel kind used by management contracts.""" + + EMAIL = "email" + IM = "im" + WEB = "web" + CONSOLE = "console" + + +class IMProvider(StrEnum): + """IM provider supported by Human Input contact and delivery flows.""" + + FEISHU = "feishu" + SLACK = "slack" + DING_TALK = "ding_talk" + MS_TEAMS = "ms_teams" + WE_COM = "we_com" + LARK = "lark" + + def supports_webhook(self) -> bool: + """Return whether the provider has a static inbound Webhook capability.""" + + return self in { + IMProvider.SLACK, + IMProvider.FEISHU, + IMProvider.LARK, + IMProvider.MS_TEAMS, + } + + +class DebugChannel(StrEnum): + """Delivery selection for editor debugging, resolved to current channel configuration.""" + + EMAIL = "email" + FEISHU = IMProvider.FEISHU.value + SLACK = IMProvider.SLACK.value + DING_TALK = IMProvider.DING_TALK.value + MS_TEAMS = IMProvider.MS_TEAMS.value + WE_COM = IMProvider.WE_COM.value + LARK = IMProvider.LARK.value + + +class IMBindingScope(StrEnum): + """Resolution scope of a contact-to-IM-identity binding.""" + + WORKSPACE = "workspace" + ORGANIZATION = "organization" + + +class IMIdentityBindingStatus(StrEnum): + """Whether a synchronized IM identity is currently bound.""" + + UNBOUND = "unbound" + BOUND = "bound" + + +class IMSyncRunStatus(StrEnum): + """Lifecycle state of a manual IM directory synchronization.""" + + QUEUED = "queued" + RUNNING = "running" + SUCCEEDED = "succeeded" + FAILED = "failed" + + +class IMSyncResultType(StrEnum): + """Stable reconciliation bucket for one synchronized directory entry.""" + + ADDED = "added" + NOT_MATCHED = "not_matched" + FAILED = "failed" + REMOVED = "removed" + SKIPPED = "skipped" + + +class IMSyncRemovalReason(StrEnum): + """Stable reason for removing or replacing a current IM binding.""" + + NOT_PRESENT_IN_DIRECTORY = "not_present_in_directory" + BINDING_INVALIDATED = "binding_invalidated" + BINDING_REPLACED = "binding_replaced" + + +class EmailProviderType(StrEnum): + """Email provider supported by organization-level Human Input delivery.""" + + RESEND = "resend" + + +# Organization candidate identifiers round-trip as stable Contact IDs. +OrganizationCandidateId = ContactId + +# Identifiers for synced IM identiies. This is not the same as user_id or account_id +# on the IM provier side. It is the identifier for the synced IM user record in Dify. +IMIdentityId = NewType("IMIdentityId", str) + +# Identifiers for a full IM user synchorization. +IMSyncRunId = NewType("IMSyncRunId", str) + +# Identifier for an IM binding, an association between an IM identity and a Dify contact. +IMBindingId = NewType("IMBindingId", str) + + +__all__ = [ + "AccountId", + "ChannelKind", + "ContactId", + "DebugChannel", + "EmailProviderType", + "HumanInputContactType", + "IMBindingId", + "IMBindingScope", + "IMIdentityBindingStatus", + "IMIdentityId", + "IMProvider", + "IMSyncRemovalReason", + "IMSyncResultType", + "IMSyncRunId", + "IMSyncRunStatus", + "NaiveDatetime", + "NormalizedEmail", + "OrganizationCandidateId", + "TenantId", +] diff --git a/api/core/human_input_v2/im_integration/__init__.py b/api/core/human_input_v2/im_integration/__init__.py new file mode 100644 index 00000000000..c038645a127 --- /dev/null +++ b/api/core/human_input_v2/im_integration/__init__.py @@ -0,0 +1,109 @@ +"""Infrastructure-free historical synchronization and reconciliation boundary.""" + +from .binding_commands import ContactIMBindingView, IMBindingCommandError, IMBindingCommandErrorCode +from .change_log import ( + IMBindingChangeSnapshot, + IMIdentityChangeSnapshot, + IMReconciliationChange, + IMReconciliationOperation, + IMReconciliationSnapshot, + IMReconciliationSubjectKind, +) +from .ports import ( + ActiveRunDecision, + ActiveRunDecisionKind, + ApplyReconciliationResult, + ApplyReconciliationStatus, + IMSyncRepository, + ResolvedReconciliationWarning, +) +from .records import OpaqueProviderPayload +from .sync_reconciliation import ( + BlockedReconciliation, + ContactEmailMatchState, + CreateIMBinding, + CurrentIMBindingState, + CurrentIMIdentityState, + DeleteIMBinding, + ExistingIMIdentityRef, + IMBindingMutation, + IMIdentityDeletion, + IMIdentityRef, + IMIdentityUpsert, + IMIdentityUpsertKind, + NewIMIdentityRef, + PlanGenerationResult, + PlannedReconciliationWarning, + PlannedSyncResult, + ReconciliationBlock, + ReconciliationBlockCode, + ReconciliationInput, + ReconciliationPlan, + ReconciliationReasonCode, + ReconciliationRunRef, + ReplaceIMBinding, + SyncReconciler, +) +from .sync_records import ( + IMChannelRevision, + IMSyncRun, + StaleRevision, + SyncContactSnapshot, + SynchronizedIMIdentity, + SynchronizedIMIdentityPage, + SyncIdentitySnapshot, + SyncResultFact, + SyncResultPage, +) + +__all__ = [ + "ActiveRunDecision", + "ActiveRunDecisionKind", + "ApplyReconciliationResult", + "ApplyReconciliationStatus", + "BlockedReconciliation", + "ContactEmailMatchState", + "ContactIMBindingView", + "CreateIMBinding", + "CurrentIMBindingState", + "CurrentIMIdentityState", + "DeleteIMBinding", + "ExistingIMIdentityRef", + "IMBindingChangeSnapshot", + "IMBindingCommandError", + "IMBindingCommandErrorCode", + "IMBindingMutation", + "IMChannelRevision", + "IMIdentityChangeSnapshot", + "IMIdentityDeletion", + "IMIdentityRef", + "IMIdentityUpsert", + "IMIdentityUpsertKind", + "IMReconciliationChange", + "IMReconciliationOperation", + "IMReconciliationSnapshot", + "IMReconciliationSubjectKind", + "IMSyncRepository", + "IMSyncRun", + "NewIMIdentityRef", + "OpaqueProviderPayload", + "PlanGenerationResult", + "PlannedReconciliationWarning", + "PlannedSyncResult", + "ReconciliationBlock", + "ReconciliationBlockCode", + "ReconciliationInput", + "ReconciliationPlan", + "ReconciliationReasonCode", + "ReconciliationRunRef", + "ReplaceIMBinding", + "ResolvedReconciliationWarning", + "StaleRevision", + "SyncContactSnapshot", + "SyncIdentitySnapshot", + "SyncReconciler", + "SyncResultFact", + "SyncResultPage", + "SynchronizedIMIdentity", + "SynchronizedIMIdentityPage", +] diff --git a/api/core/human_input_v2/im_integration/adapters/__init__.py b/api/core/human_input_v2/im_integration/adapters/__init__.py new file mode 100644 index 00000000000..6829d6d00cc --- /dev/null +++ b/api/core/human_input_v2/im_integration/adapters/__init__.py @@ -0,0 +1,99 @@ +"""Stable types owned by the IM provider adapter boundary.""" + +from .credentials import ( + DingTalkCredentials, + FeishuCredentials, + IMProviderCredentials, + IMProviderCredentialsAdapter, + LarkCredentials, + MSTeamsCredentials, + SlackCredentials, + WeComCredentials, +) +from .entities import ( + AuthenticatedIMEvent, + CardAssessment, + CorrelationToken, + CredentialTestFailure, + CredentialTestFailureKind, + CredentialTestSuccess, + Directory, + DirectoryEntry, + DirectoryReadFailure, + DynamicCardMessagingError, + EventAcceptance, + IMCardEvent, + IMCardEventDecodeResult, + IMCardEventDecodingError, + IMEventIngressKind, + IMStreamStartError, + IMStreamStopError, + MessageAccepted, + MessageSendingError, + MessageSendingResult, + ProviderUserId, + ReplacementError, + ReplacementErrorKind, + StaticCardIntent, + UnrecognizedIMEvent, + WebhookRequest, + WebhookResponse, +) +from .message_locator import MessageLocator +from .protocols import ( + IMCardEventDecoder, + IMDirectory, + IMDynamicCardMessaging, + IMEventConsumer, + IMEventStream, + IMMessaging, + IMProviderAdapter, + IMWebhookHandler, +) + +__all__ = [ + "AuthenticatedIMEvent", + "CardAssessment", + "CorrelationToken", + "CredentialTestFailure", + "CredentialTestFailureKind", + "CredentialTestSuccess", + "DingTalkCredentials", + "Directory", + "DirectoryEntry", + "DirectoryReadFailure", + "DynamicCardMessagingError", + "EventAcceptance", + "FeishuCredentials", + "IMCardEvent", + "IMCardEventDecodeResult", + "IMCardEventDecoder", + "IMCardEventDecodingError", + "IMDirectory", + "IMDynamicCardMessaging", + "IMEventConsumer", + "IMEventIngressKind", + "IMEventStream", + "IMMessaging", + "IMProviderAdapter", + "IMProviderCredentials", + "IMProviderCredentialsAdapter", + "IMStreamStartError", + "IMStreamStopError", + "IMWebhookHandler", + "LarkCredentials", + "MSTeamsCredentials", + "MessageAccepted", + "MessageLocator", + "MessageSendingError", + "MessageSendingResult", + "ProviderUserId", + "ReplacementError", + "ReplacementErrorKind", + "SlackCredentials", + "StaticCardIntent", + "UnrecognizedIMEvent", + "WeComCredentials", + "WebhookRequest", + "WebhookResponse", +] diff --git a/api/core/human_input_v2/im_integration/adapters/avatar.py b/api/core/human_input_v2/im_integration/adapters/avatar.py new file mode 100644 index 00000000000..7af7f44d541 --- /dev/null +++ b/api/core/human_input_v2/im_integration/adapters/avatar.py @@ -0,0 +1,71 @@ +"""Download provider avatars without exposing transport details in directory data.""" + +from collections.abc import Sequence +from concurrent.futures import ThreadPoolExecutor +from email.message import Message +from http import HTTPStatus +from itertools import batched + +import httpx + +from core.file import remote_fetcher +from core.human_input_v2.im_integration.adapters.entities import Avatar +from core.tools.errors import ToolSSRFError + +_DOWNLOAD_TIMEOUT_SECONDS = 10.0 +_MAX_CONCURRENT_DOWNLOADS = 8 + + +class AvatarReadError(Exception): + """An avatar could not be read, so the directory snapshot must fail.""" + + +def read_avatars( + urls: Sequence[str | None], + *, + headers: dict[str, str] | None = None, +) -> tuple[Avatar | None, ...]: + """Download unique URLs in batches of at most eight, preserving input order. + + Missing URLs and HTTP 404 map to None. Other download errors propagate. + """ + unique_urls = tuple(dict.fromkeys(url for url in urls if url is not None)) + if not unique_urls: + return (None,) * len(urls) + + def download(url: str) -> Avatar | None: + return _read_avatar(url, headers=headers) + + by_url: dict[str, Avatar | None] = {} + with ThreadPoolExecutor(max_workers=_MAX_CONCURRENT_DOWNLOADS) as executor: + for batch in batched(unique_urls, _MAX_CONCURRENT_DOWNLOADS, strict=False): + by_url.update(zip(batch, executor.map(download, batch), strict=True)) + return tuple(by_url[url] if url is not None else None for url in urls) + + +def _read_avatar(url: str, *, headers: dict[str, str] | None) -> Avatar | None: + """Read image bytes through the remote-file boundary; HTTP 404 is None.""" + + response: httpx.Response | None = None + try: + response = remote_fetcher.make_request( + "GET", + url, + headers=headers.copy() if headers is not None else None, + timeout=_DOWNLOAD_TIMEOUT_SECONDS, + max_retries=0, + follow_redirects=True, + ) + if response.status_code == HTTPStatus.NOT_FOUND: + return None + response.raise_for_status() + metadata = Message() + metadata["Content-Type"] = response.headers.get("Content-Type", "application/octet-stream") + if metadata.get_content_maintype() != "image" or not response.content: + raise AvatarReadError("Provider avatar response is not a nonempty image") + return Avatar(mime_type=metadata.get_content_type(), data=response.content) + except (httpx.HTTPError, httpx.InvalidURL, ToolSSRFError) as error: + raise AvatarReadError("Provider avatar could not be downloaded") from error + finally: + if response is not None: + response.close() diff --git a/api/core/human_input_v2/im_integration/adapters/credentials.py b/api/core/human_input_v2/im_integration/adapters/credentials.py new file mode 100644 index 00000000000..0e83894ea3f --- /dev/null +++ b/api/core/human_input_v2/im_integration/adapters/credentials.py @@ -0,0 +1,151 @@ +"""Canonical credentials accepted by IM provider adapters.""" + +from typing import Annotated, Literal + +from pydantic import BaseModel, ConfigDict, Field, TypeAdapter + +from core.human_input_v2.entities import IMProvider + + +class _ProviderCredentials(BaseModel): + """Strict immutable credentials bound to one provider adapter lifetime.""" + + model_config = ConfigDict( + extra="forbid", + frozen=True, + strict=True, + hide_input_in_errors=True, + ) + + +class _FeishuLarkCredentials(_ProviderCredentials): + """Shared resolved credentials for Feishu and Lark adapters.""" + + app_id: str = Field(min_length=1, description="Provider application identifier.") + app_secret: str = Field(min_length=1, repr=False, description="Resolved application secret.") + verification_token: str | None = Field( + default=None, + min_length=1, + repr=False, + description="Resolved callback verification token.", + ) + encrypt_key: str | None = Field( + default=None, + min_length=1, + repr=False, + description="Resolved callback encryption key.", + ) + + +class FeishuCredentials(_FeishuLarkCredentials): + """Resolved Feishu credentials bound for one adapter lifetime.""" + + provider: Literal[IMProvider.FEISHU] = Field(description="Feishu credential discriminator.") + + +class LarkCredentials(_FeishuLarkCredentials): + """Resolved Lark credentials bound for one adapter lifetime.""" + + provider: Literal[IMProvider.LARK] = Field(description="Lark credential discriminator.") + + +class SlackCredentials(_ProviderCredentials): + """Resolved Slack credentials bound for one adapter lifetime.""" + + provider: Literal[IMProvider.SLACK] = Field(description="Slack credential discriminator.") + client_id: str = Field(min_length=1, description="Slack OAuth client identifier.") + client_secret: str = Field(min_length=1, repr=False, description="Resolved Slack OAuth client secret.") + signing_secret: str = Field(min_length=1, repr=False, description="Resolved Slack callback signing secret.") + bot_token: str = Field( + min_length=1, + pattern=r"^xoxb-", + repr=False, + description="Resolved Slack bot API token.", + ) + app_token: str | None = Field( + default=None, + min_length=1, + pattern=r"^xapp-", + repr=False, + description="Optional resolved Slack app-level token required only for Socket Mode.", + ) + + +class DingTalkCredentials(_ProviderCredentials): + """Resolved DingTalk credentials bound for one adapter lifetime.""" + + provider: Literal[IMProvider.DING_TALK] = Field(description="DingTalk credential discriminator.") + corp_id: str = Field(min_length=1, pattern=r"\S", description="DingTalk corporation identifier.") + client_id: str = Field(min_length=1, pattern=r"\S", description="DingTalk application client identifier.") + client_secret: str = Field( + min_length=1, + pattern=r"\S", + repr=False, + description="Resolved DingTalk application client secret.", + ) + + +class MSTeamsCredentials(_ProviderCredentials): + """Resolved Microsoft Teams credentials bound for one adapter lifetime.""" + + provider: Literal[IMProvider.MS_TEAMS] = Field(description="Microsoft Teams credential discriminator.") + tenant_id: str = Field( + min_length=1, + pattern=r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$", + description="Microsoft Entra tenant identifier.", + ) + client_id: str = Field( + min_length=1, + pattern=r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$", + description="Microsoft Teams bot application client identifier.", + ) + client_secret: str = Field( + min_length=1, + repr=False, + description="Resolved Microsoft Teams application client secret.", + ) + + +class WeComCredentials(_ProviderCredentials): + """Resolved WeCom credentials bound for one adapter lifetime.""" + + provider: Literal[IMProvider.WE_COM] = Field(description="WeCom credential discriminator.") + corp_id: str = Field(min_length=1, pattern=r"\S", description="WeCom corporation identifier.") + agent_id: str = Field( + min_length=1, + pattern=r"^[1-9][0-9]*$", + description="WeCom application agent identifier.", + ) + secret: str = Field( + min_length=1, + pattern=r"\S", + repr=False, + description="Resolved WeCom application secret.", + ) + + +type IMProviderCredentials = Annotated[ + ( + FeishuCredentials + | LarkCredentials + | SlackCredentials + | DingTalkCredentials + | MSTeamsCredentials + | WeComCredentials + ), + Field(discriminator="provider"), +] + +IMProviderCredentialsAdapter: TypeAdapter[IMProviderCredentials] = TypeAdapter(IMProviderCredentials) + + +__all__ = [ + "DingTalkCredentials", + "FeishuCredentials", + "IMProviderCredentials", + "IMProviderCredentialsAdapter", + "LarkCredentials", + "MSTeamsCredentials", + "SlackCredentials", + "WeComCredentials", +] diff --git a/api/core/human_input_v2/im_integration/adapters/dingtalk.py b/api/core/human_input_v2/im_integration/adapters/dingtalk.py new file mode 100644 index 00000000000..05028d00865 --- /dev/null +++ b/api/core/human_input_v2/im_integration/adapters/dingtalk.py @@ -0,0 +1,595 @@ +"""Concrete DingTalk adapter for Provider-neutral Human Input IM contracts. + +The root and its ordinary capabilities rely on the contract's external +serialization rule and intentionally contain no synchronization or token +cache. Credential testing bypasses the replaceable capability token provider +so future caching cannot validate stale credentials. +""" + +from __future__ import annotations + +import json +from collections import deque +from collections.abc import Sequence +from dataclasses import dataclass +from typing import Literal, Protocol, override +from urllib import error as urllib_error +from urllib import parse as urllib_parse +from urllib import request as urllib_request + +from alibabacloud_dingtalk.oauth2_1_0.client import Client as OAuthClient +from alibabacloud_dingtalk.oauth2_1_0.models import GetTokenRequest +from alibabacloud_dingtalk.robot_1_0.client import Client as RobotClient +from alibabacloud_dingtalk.robot_1_0.models import BatchSendOTOHeaders, BatchSendOTORequest +from alibabacloud_tea_openapi.models import Config +from alibabacloud_tea_util.models import RuntimeOptions +from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator +from requests.exceptions import Timeout as RequestsTimeout +from Tea.exceptions import TeaException + +from core.human_input_v2.entities import IMProvider +from core.human_input_v2.im_integration.adapters.avatar import read_avatars +from core.human_input_v2.im_integration.adapters.credentials import DingTalkCredentials +from core.human_input_v2.im_integration.adapters.entities import ( + CredentialTestFailure, + CredentialTestFailureKind, + CredentialTestSuccess, + CredentialTestTimeoutError, + Directory, + DirectoryEntry, + DirectoryReadFailure, + IMProviderError, + MessageAccepted, + MessageSendingError, + MessageSendingResult, + ProviderUserId, +) +from core.human_input_v2.im_integration.adapters.message_locator import MessageLocator, _Base64JSONLocatorPayload +from core.human_input_v2.im_integration.adapters.protocols import ( + IMCardEventDecoder, + IMDirectory, + IMDynamicCardMessaging, + IMEventConsumer, + IMEventStream, + IMMessaging, + IMWebhookHandler, +) + +_DEPARTMENT_LIST_URL = "https://oapi.dingtalk.com/topapi/v2/department/listsub" +_USER_LIST_URL = "https://oapi.dingtalk.com/topapi/v2/user/list" +_AUTHORIZATION_SCOPE_URL = "https://oapi.dingtalk.com/auth/scopes" +_ROOT_DEPARTMENT_ID = 1 +_DIRECTORY_PAGE_SIZE = 100 +_HTTP_TIMEOUT_SECONDS = 5.0 +_SDK_TIMEOUT_MILLISECONDS = 5000 +_HTTP_SUCCESS_STATUS_MIN = 200 +_HTTP_SUCCESS_STATUS_MAX_EXCLUSIVE = 300 + +_CREDENTIAL_REJECTED = "DingTalk rejected the credential test." +_AUTHORIZATION_SCOPE_UNVERIFIED = "DingTalk could not verify complete member authorization." +_TENANT_BOUNDARY_UNAVAILABLE = "DingTalk could not verify the configured corporation directory boundary." +_DIRECTORY_READ_FAILED = "DingTalk directory could not be read completely." +_MESSAGE_ACCEPTANCE_UNCONFIRMED = "DingTalk message acceptance could not be confirmed." + + +class AccessTokenProvider(Protocol): + """DingTalk-private seam for a currently usable application token.""" + + def get(self) -> str: + """Return a currently usable access token.""" + ... + + +class _OAuthClient(Protocol): + def get_token(self, corp_id: str, request: GetTokenRequest) -> object: ... + + +class _RobotClient(Protocol): + def batch_send_otowith_options( + self, + request: BatchSendOTORequest, + headers: BatchSendOTOHeaders, + runtime: RuntimeOptions, + ) -> object: ... + + +@dataclass(frozen=True, slots=True) +class _DirectoryHTTPResponse: + body: bytes + http_status: int + + +class _DirectoryHTTPClient(Protocol): + def get(self, url: str, *, access_token: str) -> _DirectoryHTTPResponse: ... + + def post(self, url: str, *, access_token: str, body: dict[str, int]) -> _DirectoryHTTPResponse: ... + + def close(self) -> None: ... + + +@dataclass(frozen=True, slots=True) +class _AccessToken: + value: str + expires_in: int + + +class _AccessTokenResponseBody(BaseModel): + model_config = ConfigDict(strict=True, from_attributes=True) + + access_token: str = Field(min_length=1) + expires_in: int = Field(gt=0) + + +class _AccessTokenResponse(BaseModel): + model_config = ConfigDict(strict=True, from_attributes=True) + + body: _AccessTokenResponseBody + + +class _MessageSendResponseBody(BaseModel): + model_config = ConfigDict(strict=True, from_attributes=True) + + process_query_key: str = Field(pattern=r"\S") + # The SDK may omit rejection lists when no recipients were rejected. + filtered_staff_id_list: Sequence[object] | None = Field(default=None, max_length=0) + flow_controlled_staff_id_list: Sequence[object] | None = Field(default=None, max_length=0) + invalid_staff_id_list: Sequence[object] | None = Field(default=None, max_length=0) + + +class _MessageSendResponse(BaseModel): + model_config = ConfigDict(strict=True, from_attributes=True) + + status_code: int = Field(ge=_HTTP_SUCCESS_STATUS_MIN, lt=_HTTP_SUCCESS_STATUS_MAX_EXCLUSIVE) + body: _MessageSendResponseBody + + +class _DirectoryHTTPError(Exception): + pass + + +class _DirectoryProviderRejectedError(_DirectoryHTTPError): + def __init__(self, provider_error_code: int | None, *, http_status: int) -> None: + super().__init__() + self.provider_error_code = str(provider_error_code) if provider_error_code is not None else None + self.http_status = http_status + + +class _OAuthErrorData(BaseModel): + model_config = ConfigDict(extra="ignore", strict=True) + + status_code: int | None = Field(default=None, alias="statusCode") + + +class _OAuthErrorResponse(BaseModel): + model_config = ConfigDict(extra="ignore", strict=True, hide_input_in_errors=True) + + code: str | None = None + data: _OAuthErrorData | None = None + + +class _ProviderResponse(BaseModel): + model_config = ConfigDict(extra="ignore", strict=True) + + errcode: int | None = None + errmsg: str | None = None + + +class _Department(BaseModel): + model_config = ConfigDict(extra="ignore", strict=True) + + dept_id: int = Field(ge=1) + + +class _DepartmentListResponse(_ProviderResponse): + result: tuple[_Department, ...] | None = None + + +class _AuthorizationOrganizationScope(BaseModel): + model_config = ConfigDict(extra="ignore", strict=True) + + authed_dept: tuple[int, ...] + authed_user: tuple[str, ...] + + +class _AuthorizationScopeResponse(_ProviderResponse): + auth_org_scopes: _AuthorizationOrganizationScope + + +class _DirectoryUser(BaseModel): + model_config = ConfigDict(extra="ignore", strict=True) + + userid: str + name: str | None = None + email: str | None = None + avatar_url: str | None = Field(default=None, alias="avatar") + + @field_validator("avatar_url") + @classmethod + def _normalize_avatar_url(cls, value: str | None) -> str | None: + if value is None: + return None + return value.strip() or None + + +class _UserPage(BaseModel): + model_config = ConfigDict(extra="ignore", strict=True) + + has_more: bool + next_cursor: int | None = None + users: tuple[_DirectoryUser, ...] = Field(alias="list") + + +class _UserListResponse(_ProviderResponse): + result: _UserPage | None = None + + +class _DingTalkLocatorPayload(_Base64JSONLocatorPayload): + model_config = ConfigDict(extra="forbid", frozen=True, strict=True) + + # version of the locator + v: Literal[1] + # provider of the locator + p: Literal[IMProvider.DING_TALK] + # DingTalk process query key returned for the sent message: + # https://open.dingtalk.com/document/orgapp/chatbots-send-one-on-one-chat-messages-in-batches.md + process_query_key: str = Field(min_length=1, pattern=r"\S") + + +class _SDKAccessTokenProvider(AccessTokenProvider): + def __init__(self, credentials: DingTalkCredentials, client: _OAuthClient) -> None: + self._credentials = credentials + self._client = client + + @override + def get(self) -> str: + return _get_access_token(self._client, self._credentials) + + +class _UrllibDirectoryHTTPClient(_DirectoryHTTPClient): + @override + def get(self, url: str, *, access_token: str) -> _DirectoryHTTPResponse: + target = f"{url}?{urllib_parse.urlencode({'access_token': access_token})}" + request = urllib_request.Request(target, method="GET") + with urllib_request.urlopen(request, timeout=_HTTP_TIMEOUT_SECONDS) as response: + return _DirectoryHTTPResponse(body=response.read(), http_status=response.status) + + @override + def post(self, url: str, *, access_token: str, body: dict[str, int]) -> _DirectoryHTTPResponse: + target = f"{url}?{urllib_parse.urlencode({'access_token': access_token})}" + request = urllib_request.Request( + target, + data=json.dumps(body, separators=(",", ":")).encode(), + headers={"Content-Type": "application/json"}, + method="POST", + ) + with urllib_request.urlopen(request, timeout=_HTTP_TIMEOUT_SECONDS) as response: + return _DirectoryHTTPResponse(body=response.read(), http_status=response.status) + + @override + def close(self) -> None: + return None + + +class _DingTalkDirectory(IMDirectory): + def __init__(self, token_provider: AccessTokenProvider, http_client: _DirectoryHTTPClient) -> None: + self._token_provider = token_provider + self._http_client = http_client + + @override + def read_directory(self) -> Directory | DirectoryReadFailure: + try: + access_token = self._token_provider.get() + return self._read_complete_directory(access_token) + except Exception: + return DirectoryReadFailure(_DIRECTORY_READ_FAILED) + + def _read_complete_directory(self, access_token: str) -> Directory: + users: list[_DirectoryUser] = [] + seen_user_ids: set[str] = set() + pending_departments: deque[int] = deque((_ROOT_DEPARTMENT_ID,)) + discovered_departments = {_ROOT_DEPARTMENT_ID} + + while pending_departments: + department_id = pending_departments.popleft() + for child in _department_children(self._http_client, access_token, department_id): + if child.dept_id in discovered_departments: + continue + discovered_departments.add(child.dept_id) + pending_departments.append(child.dept_id) + + cursor = 0 + seen_cursors = {cursor} + while True: + page = _user_page(self._http_client, access_token, department_id, cursor) + for user in page.users: + if not user.userid.strip(): + raise _DirectoryHTTPError + if user.userid in seen_user_ids: + continue + seen_user_ids.add(user.userid) + users.append(user) + if not page.has_more: + break + next_cursor = page.next_cursor + if next_cursor is None or next_cursor in seen_cursors: + raise _DirectoryHTTPError + seen_cursors.add(next_cursor) + cursor = next_cursor + + avatars = read_avatars([user.avatar_url for user in users]) + return Directory( + tuple( + DirectoryEntry( + ProviderUserId(user.userid), + _optional_non_blank(user.name), + _optional_non_blank(user.email), + avatar=avatar, + ) + for user, avatar in zip(users, avatars, strict=True) + ) + ) + + +class _DingTalkMessaging(IMMessaging): + def __init__( + self, + robot_code: str, + token_provider: AccessTokenProvider, + client: _RobotClient, + ) -> None: + self._robot_code = robot_code + self._token_provider = token_provider + self._client = client + + @override + def send_text( + self, provider_user_id: ProviderUserId, body: str, subject: str | None = None + ) -> MessageSendingResult: + if subject is not None: + body = f"{subject}\n\n{body}" + try: + access_token = self._token_provider.get() + response = self._client.batch_send_otowith_options( + BatchSendOTORequest( + msg_key="sampleText", + msg_param=json.dumps({"content": body}, ensure_ascii=False, separators=(",", ":")), + robot_code=self._robot_code, + user_ids=[str(provider_user_id)], + ), + BatchSendOTOHeaders(x_acs_dingtalk_access_token=access_token), + RuntimeOptions( + autoretry=False, + max_attempts=1, + connect_timeout=_SDK_TIMEOUT_MILLISECONDS, + read_timeout=_SDK_TIMEOUT_MILLISECONDS, + ), + ) + accepted_response = _MessageSendResponse.model_validate(response) + return MessageAccepted( + MessageLocator( + _DingTalkLocatorPayload( + v=1, + p=IMProvider.DING_TALK, + process_query_key=accepted_response.body.process_query_key, + ).encode() + ) + ) + except Exception: + return MessageSendingError(_MESSAGE_ACCEPTANCE_UNCONFIRMED) + + +class DingTalkIMProviderAdapter: + """Externally serialized DingTalk capability composition root.""" + + @classmethod + def card_event_decoder(cls) -> IMCardEventDecoder | None: + return None + + def __init__(self, credentials: DingTalkCredentials) -> None: + if not isinstance(credentials, DingTalkCredentials): + raise TypeError("DingTalk adapter requires resolved DingTalk credentials") + self._credentials = credentials + self._credential_test_client = _new_oauth_client() + token_provider = _SDKAccessTokenProvider(credentials, _new_oauth_client()) + self._http_client = _new_http_client() + self._directory = _DingTalkDirectory(token_provider, self._http_client) + self._messaging = _DingTalkMessaging(credentials.client_id, token_provider, _new_robot_client()) + self._closed = False + + @property + def provider(self) -> IMProvider: + return IMProvider.DING_TALK + + def test_credentials(self) -> CredentialTestSuccess | CredentialTestFailure: + try: + return self._test_credentials() + except (TimeoutError, RequestsTimeout) as error: + raise CredentialTestTimeoutError() from error + except urllib_error.HTTPError as error: + try: + rejection = _ProviderResponse.model_validate_json(error.read()) + except ValidationError: + provider_error_code = None + else: + provider_error_code = str(rejection.errcode) if rejection.errcode is not None else None + return CredentialTestFailure( + CredentialTestFailureKind.UNKNOWN, _CREDENTIAL_REJECTED, provider_error_code, http_status=error.code + ) + except urllib_error.URLError as error: + if isinstance(error.reason, TimeoutError): + raise CredentialTestTimeoutError() from error + raise + + def _test_credentials(self) -> CredentialTestSuccess | CredentialTestFailure: + try: + access_token = _get_access_token(self._credential_test_client, self._credentials) + except TeaException as error: + rejection = _OAuthErrorResponse.model_validate({"code": error.code, "data": error.data}) + status_code = rejection.data.status_code if rejection.data is not None else None + if rejection.code is None and status_code is None: + raise + kind = ( + CredentialTestFailureKind.AUTHENTICATION_REJECTED + if status_code in {400, 401, 403} + else CredentialTestFailureKind.UNKNOWN + ) + return CredentialTestFailure(kind, _CREDENTIAL_REJECTED, rejection.code, http_status=status_code) + + try: + complete_authorization = _has_complete_member_authorization(self._http_client, access_token) + except _DirectoryProviderRejectedError as error: + return CredentialTestFailure( + CredentialTestFailureKind.UNKNOWN, + _AUTHORIZATION_SCOPE_UNVERIFIED, + error.provider_error_code, + http_status=error.http_status, + ) + if not complete_authorization: + return CredentialTestFailure( + CredentialTestFailureKind.PERMISSION_DENIED, + _AUTHORIZATION_SCOPE_UNVERIFIED, + ) + baseline_failure = _verify_root_boundary(self._http_client, access_token) + if baseline_failure is not None: + return baseline_failure + return CredentialTestSuccess(IMProvider.DING_TALK, self._credentials.corp_id) + + @property + def directory(self) -> IMDirectory: + return self._directory + + @property + def messaging(self) -> IMMessaging: + return self._messaging + + @property + def dynamic_card_messaging(self) -> IMDynamicCardMessaging | None: + return None + + def create_webhook_handler(self, consumer: IMEventConsumer) -> IMWebhookHandler | None: + del consumer + return None + + def create_stream_handler(self, consumer: IMEventConsumer) -> IMEventStream | None: + del consumer + return None + + def close(self) -> None: + if self._closed: + return + self._closed = True + self._http_client.close() + + +def _new_oauth_client() -> OAuthClient: + return OAuthClient(_sdk_config()) + + +def _new_robot_client() -> RobotClient: + return RobotClient(_sdk_config()) + + +def _new_http_client() -> _DirectoryHTTPClient: + return _UrllibDirectoryHTTPClient() + + +def _sdk_config() -> Config: + return Config( + protocol="https", + connect_timeout=_SDK_TIMEOUT_MILLISECONDS, + read_timeout=_SDK_TIMEOUT_MILLISECONDS, + ) + + +def _get_access_token(client: _OAuthClient, credentials: DingTalkCredentials) -> str: + return _request_access_token(client, credentials).value + + +def _request_access_token(client: _OAuthClient, credentials: DingTalkCredentials) -> _AccessToken: + response = client.get_token( + credentials.corp_id, + GetTokenRequest( + client_id=credentials.client_id, + client_secret=credentials.client_secret, + grant_type="client_credentials", + ), + ) + try: + response_body = _AccessTokenResponse.model_validate(response).body + except ValidationError as error: + raise IMProviderError(f"DingTalk returned an invalid access token response: {error}") from error + return _AccessToken(response_body.access_token, response_body.expires_in) + + +def _verify_root_boundary(http_client: _DirectoryHTTPClient, access_token: str) -> CredentialTestFailure | None: + try: + _department_children(http_client, access_token, _ROOT_DEPARTMENT_ID) + except _DirectoryProviderRejectedError as error: + return CredentialTestFailure( + CredentialTestFailureKind.TENANT_ID_UNAVAILABLE, + _TENANT_BOUNDARY_UNAVAILABLE, + error.provider_error_code, + http_status=error.http_status, + ) + return None + + +def _has_complete_member_authorization( + http_client: _DirectoryHTTPClient, + access_token: str, +) -> bool: + response_body = http_client.get(_AUTHORIZATION_SCOPE_URL, access_token=access_token) + scope = _parse_response(response_body, _AuthorizationScopeResponse).auth_org_scopes + return _ROOT_DEPARTMENT_ID in scope.authed_dept + + +def _department_children( + http_client: _DirectoryHTTPClient, + access_token: str, + department_id: int, +) -> tuple[_Department, ...]: + response_body = http_client.post( + _DEPARTMENT_LIST_URL, + access_token=access_token, + body={"dept_id": department_id}, + ) + payload = _parse_response(response_body, _DepartmentListResponse) + if payload.result is None: + raise IMProviderError("DingTalk did not provide department results.") + return payload.result + + +def _user_page( + http_client: _DirectoryHTTPClient, + access_token: str, + department_id: int, + cursor: int, +) -> _UserPage: + response_body = http_client.post( + _USER_LIST_URL, + access_token=access_token, + body={"dept_id": department_id, "cursor": cursor, "size": _DIRECTORY_PAGE_SIZE}, + ) + payload = _parse_response(response_body, _UserListResponse) + if payload.result is None: + raise IMProviderError("DingTalk did not provide user results.") + return payload.result + + +def _parse_response[ResponseT: _ProviderResponse]( + response_body: _DirectoryHTTPResponse, + response_type: type[ResponseT], +) -> ResponseT: + rejection = _ProviderResponse.model_validate_json(response_body.body) + if rejection.errcode != 0: + raise _DirectoryProviderRejectedError(rejection.errcode, http_status=response_body.http_status) + return response_type.model_validate_json(response_body.body) + + +def _optional_non_blank(value: str | None) -> str | None: + if value is None or not value.strip(): + return None + return value + + +__all__ = ["DingTalkIMProviderAdapter"] diff --git a/api/core/human_input_v2/im_integration/adapters/dingtalk_redis.py b/api/core/human_input_v2/im_integration/adapters/dingtalk_redis.py new file mode 100644 index 00000000000..703a04748a9 --- /dev/null +++ b/api/core/human_input_v2/im_integration/adapters/dingtalk_redis.py @@ -0,0 +1,91 @@ +"""Redis-backed access-token caching for the concrete DingTalk adapter.""" + +from __future__ import annotations + +import hashlib +import logging +from typing import Protocol + +from redis import RedisError + +from core.human_input_v2.im_integration.adapters.credentials import DingTalkCredentials +from core.human_input_v2.im_integration.adapters.dingtalk import ( + _new_oauth_client, + _OAuthClient, + _request_access_token, +) + +logger = logging.getLogger(__name__) + +_CACHE_KEY_PREFIX = "dify:human-input-v2:dingtalk:access-token:" +_EXPIRY_SAFETY_MARGIN_SECONDS = 60 + + +class _RedisCacheClient(Protocol): + def get(self, name: str) -> object: ... + + def ttl(self, name: str) -> object: ... + + def set(self, name: str, value: str, *, ex: int) -> object: ... + + +class RedisCacheAccessTokenProvider: + """Provide DingTalk access tokens with a shared Redis optimization.""" + + def __init__(self, credentials: DingTalkCredentials, cache: _RedisCacheClient) -> None: + if not isinstance(credentials, DingTalkCredentials): + raise TypeError("DingTalk token provider requires resolved DingTalk credentials") + self._credentials = credentials + self._cache = cache + self._client: _OAuthClient = _new_oauth_client() + self._cache_key = _cache_key(credentials.client_id) + + def get(self) -> str: + cached_token = self._read_cached_token() + if cached_token is not None: + return cached_token + + fresh_token = _request_access_token(self._client, self._credentials) + cache_ttl = fresh_token.expires_in - _EXPIRY_SAFETY_MARGIN_SECONDS + if cache_ttl > 0: + try: + self._cache.set(self._cache_key, fresh_token.value, ex=cache_ttl) + except RedisError: + logger.warning("Failed to write DingTalk access-token cache; returning the fresh token.") + return fresh_token.value + + def _read_cached_token(self) -> str | None: + try: + cached_value = self._cache.get(self._cache_key) + cached_token = _decode_cached_token(cached_value) + if cached_token is None: + return None + ttl = self._cache.ttl(self._cache_key) + except UnicodeDecodeError: + logger.warning("Ignored a malformed DingTalk access-token cache entry.") + return None + except RedisError: + logger.warning("Failed to read DingTalk access-token cache; requesting a fresh token.") + return None + + if isinstance(ttl, int) and not isinstance(ttl, bool) and ttl > 0: + return cached_token + return None + + +def _cache_key(client_id: str) -> str: + digest = hashlib.sha256(client_id.encode()).hexdigest() + return f"{_CACHE_KEY_PREFIX}{digest}" + + +def _decode_cached_token(value: object) -> str | None: + if isinstance(value, bytes): + token = value.decode("utf-8") + elif isinstance(value, str): + token = value + else: + return None + return token if token.strip() else None + + +__all__ = ["RedisCacheAccessTokenProvider"] diff --git a/api/core/human_input_v2/im_integration/adapters/entities.py b/api/core/human_input_v2/im_integration/adapters/entities.py new file mode 100644 index 00000000000..ceae0ced1c2 --- /dev/null +++ b/api/core/human_input_v2/im_integration/adapters/entities.py @@ -0,0 +1,271 @@ +"""Stable values, results, and errors shared by IM provider adapters.""" + +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass +from enum import StrEnum +from types import MappingProxyType +from typing import NewType + +from pydantic import ConfigDict, JsonValue, NaiveDatetime, TypeAdapter, ValidationError + +from core.human_input_v2.entities import IMProvider + +from .message_locator import MessageLocator + +ProviderUserId = NewType("ProviderUserId", str) +CorrelationToken = NewType("CorrelationToken", str) + + +class CredentialTestFailureKind(StrEnum): + """Provider failures; UNKNOWN is an unclassified provider error response.""" + + AUTHENTICATION_REJECTED = "authentication_rejected" + PERMISSION_DENIED = "permission_denied" + TENANT_ID_UNAVAILABLE = "tenant_id_unavailable" + UNKNOWN = "UNKNOWN" + + +@dataclass(frozen=True, slots=True) +class CredentialTestSuccess: + """Confirmed provider authentication and stable tenant identity.""" + + provider: IMProvider + provider_tenant_id: str + + +@dataclass(frozen=True, slots=True) +class CredentialTestFailure: + """Provider rejection or confirmed credential limitation, not an execution error.""" + + kind: CredentialTestFailureKind + reason: str + provider_error_code: str | None = None + missing_permissions: tuple[str, ...] = () + http_status: int | None = None + + +class IMProviderError(Exception): + """A provider operation failed with a message suitable for configuration callers.""" + + +class CredentialTestTimeoutError(IMProviderError): + """A credential-test request timed out; the original error is its cause.""" + + def __init__(self) -> None: + super().__init__("The provider credential test request timed out.") + + +@dataclass(frozen=True, slots=True) +class Avatar: + """Downloaded provider image, independent of transport and credentials.""" + + mime_type: str + data: bytes + + +@dataclass(frozen=True, slots=True) +class DirectoryEntry: + """Provider identity and downloaded avatar from one directory snapshot.""" + + provider_user_id: ProviderUserId + display_name: str | None + email: str | None + avatar: Avatar | None = None + + +@dataclass(frozen=True, slots=True) +class Directory: + """Immutable ordered complete provider directory snapshot.""" + + entries: tuple[DirectoryEntry, ...] + + +@dataclass(frozen=True, slots=True) +class DirectoryReadFailure: + """Safe whole-snapshot failure without partial entries.""" + + reason: str + + +@dataclass(frozen=True, slots=True) +class StaticCardIntent: + """Caller-rendered non-interactive replacement presentation.""" + + rendered_content: str + + +@dataclass(frozen=True, slots=True) +class CardAssessment: + """Side-effect-free whole-intent representability decision.""" + + representable: bool + reason: str | None = None + + +@dataclass(frozen=True, slots=True) +class MessageAccepted: + """Confirmed provider acceptance, not end-user delivery proof.""" + + locator: MessageLocator + + +@dataclass(frozen=True, slots=True) +class MessageSendingError: + """Message creation without confirmed provider acceptance.""" + + reason: str + + +type MessageSendingResult = MessageAccepted | MessageSendingError + + +class ReplacementErrorKind(StrEnum): + """Stable exact-message replacement failure categories.""" + + INVALID_REFERENCE = "invalid_reference" + STALE_REFERENCE = "stale_reference" + UNKNOWN = "unknown" + + +@dataclass(frozen=True, slots=True) +class ReplacementError: + """Static replacement failure without accepted mutation content.""" + + kind: ReplacementErrorKind + reason: str + + +class DynamicCardMessagingError(Exception): + """Complete dynamic card intent cannot be represented by the provider.""" + + +@dataclass(frozen=True, slots=True) +class WebhookRequest: + """Framework-neutral request preserving verification-critical values.""" + + method: str + headers: tuple[tuple[str, str], ...] + body: bytes + received_at: NaiveDatetime + # None when the transport cannot determine the request IP. + request_ip_address: str | None = None + + +@dataclass(frozen=True, slots=True) +class WebhookResponse: + """Framework-neutral response containing exact provider ACK facts.""" + + status_code: int + headers: tuple[tuple[str, str], ...] + body: bytes + + +class IMEventIngressKind(StrEnum): + """Ingress contract used to construct the provider payload snapshot.""" + + WEBHOOK = "webhook" + STREAM = "stream" + + +@dataclass(frozen=True, slots=True) +class AuthenticatedIMEvent: + """Authenticated provider evidence before consumer-specific decoding.""" + + provider: IMProvider + provider_tenant_id: str + event_id: str | None + event_type: str | None + occurred_at: NaiveDatetime | None + received_at: NaiveDatetime + ingress_kind: IMEventIngressKind + payload: str + + +_CARD_EVENT_INPUTS_ADAPTER = TypeAdapter( + dict[str, JsonValue], + config=ConfigDict(strict=True, allow_inf_nan=False), +) + + +@dataclass(frozen=True, slots=True) +class IMCardEvent: + """Provider-neutral normalized card interaction.""" + + provider_user_id: ProviderUserId + action_id: str + inputs: Mapping[str, JsonValue] + correlation_token: CorrelationToken + + def __post_init__(self) -> None: + if not isinstance(self.action_id, str) or not self.action_id: + raise ValueError("card event action identifier must not be empty") + if not isinstance(self.inputs, Mapping): + raise TypeError("card event inputs must be a mapping") + try: + copied_inputs = _CARD_EVENT_INPUTS_ADAPTER.validate_python(dict(self.inputs), strict=True) + except ValidationError: + raise TypeError("card event inputs must contain only JSON values") from None + object.__setattr__(self, "inputs", MappingProxyType(copied_inputs)) + + +@dataclass(frozen=True, slots=True) +class UnrecognizedIMEvent: + """Authenticated event outside one decoder's supported card protocol.""" + + +type IMCardEventDecodeResult = IMCardEvent | UnrecognizedIMEvent + + +class IMCardEventDecodingError(ValueError): + """Recognized card event cannot be decoded using the expected schema.""" + + +class EventAcceptance(StrEnum): + """Consumer result controlling provider acknowledgement.""" + + ACCEPTED = "accepted" + NOT_ACCEPTED = "not_accepted" + + +class IMStreamStartError(Exception): + """Operator-safe synchronous stream startup failure.""" + + +class IMStreamStopError(Exception): + """Operator-safe failure to establish the graceful-stop guarantees.""" + + +__all__ = [ + "AuthenticatedIMEvent", + "Avatar", + "CardAssessment", + "CorrelationToken", + "CredentialTestFailure", + "CredentialTestFailureKind", + "CredentialTestSuccess", + "CredentialTestTimeoutError", + "Directory", + "DirectoryEntry", + "DirectoryReadFailure", + "DynamicCardMessagingError", + "EventAcceptance", + "IMCardEvent", + "IMCardEventDecodeResult", + "IMCardEventDecodingError", + "IMEventIngressKind", + "IMProviderError", + "IMStreamStartError", + "IMStreamStopError", + "MessageAccepted", + "MessageSendingError", + "MessageSendingResult", + "ProviderUserId", + "ReplacementError", + "ReplacementErrorKind", + "StaticCardIntent", + "UnrecognizedIMEvent", + "WebhookRequest", + "WebhookResponse", +] diff --git a/api/core/human_input_v2/im_integration/adapters/factory.py b/api/core/human_input_v2/im_integration/adapters/factory.py new file mode 100644 index 00000000000..b4336f55ca8 --- /dev/null +++ b/api/core/human_input_v2/im_integration/adapters/factory.py @@ -0,0 +1,38 @@ +"""Construction of complete IM provider adapters from canonical credentials.""" + +from .credentials import ( + DingTalkCredentials, + FeishuCredentials, + IMProviderCredentials, + LarkCredentials, + MSTeamsCredentials, + SlackCredentials, + WeComCredentials, +) +from .dingtalk import DingTalkIMProviderAdapter +from .feishu_lark import FeishuIMProviderAdapter, LarkIMProviderAdapter +from .ms_teams import MSTeamsIMProviderAdapter +from .protocols import IMProviderAdapter +from .slack import SlackIMProviderAdapter +from .wecom import WeComIMProviderAdapter + + +def build_im_provider_adapter(credentials: IMProviderCredentials) -> IMProviderAdapter: + match credentials: + case SlackCredentials(): + return SlackIMProviderAdapter(credentials) + case FeishuCredentials(): + return FeishuIMProviderAdapter(credentials) + case LarkCredentials(): + return LarkIMProviderAdapter(credentials) + case DingTalkCredentials(): + return DingTalkIMProviderAdapter(credentials) + case MSTeamsCredentials(): + return MSTeamsIMProviderAdapter(credentials) + case WeComCredentials(): + return WeComIMProviderAdapter(credentials) + case _: + raise TypeError("unsupported IM provider credentials") + + +__all__ = ["build_im_provider_adapter"] diff --git a/api/core/human_input_v2/im_integration/adapters/feishu_lark.py b/api/core/human_input_v2/im_integration/adapters/feishu_lark.py new file mode 100644 index 00000000000..437994a2624 --- /dev/null +++ b/api/core/human_input_v2/im_integration/adapters/feishu_lark.py @@ -0,0 +1,1983 @@ +"""Shared Feishu/Lark adapter implementation. + +Provider credentials and capability protocols live beside this implementation +under the adapter-owned package. Feishu and Lark wrappers share every behavior +except their typed credential, provider discriminator, and official SDK domain. +""" + +from __future__ import annotations + +import asyncio +import base64 +import binascii +import hashlib +import hmac +import json +import logging +import threading +from collections import deque +from collections.abc import Awaitable, Callable, Mapping +from concurrent.futures import Future +from contextvars import ContextVar +from dataclasses import dataclass +from datetime import UTC, datetime +from string import punctuation +from typing import ClassVar, Literal, Never, Protocol, override, runtime_checkable +from urllib.parse import parse_qs, urlsplit + +import lark_oapi as lark +from cryptography.hazmat.primitives import padding as symmetric_padding +from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes +from lark_oapi.api import auth, contact, im, tenant +from lark_oapi.channel import FeishuChannel, TransportConfig +from lark_oapi.core.exception import AccessTokenException, ObtainAccessTokenException +from lark_oapi.event.callback.model.p2_card_action_trigger import P2CardActionTrigger, P2CardActionTriggerResponse +from lark_oapi.ws import client as sdk_ws_client_module +from pydantic import AliasPath, BaseModel, ConfigDict, Field, JsonValue, TypeAdapter, ValidationError, field_validator +from requests.exceptions import Timeout as RequestsTimeout +from websockets.asyncio.client import ClientConnection + +from core.human_input import ButtonStyle +from core.human_input_v2.entities import IMProvider +from core.human_input_v2.im_integration.adapters.avatar import read_avatars +from core.human_input_v2.im_integration.adapters.credentials import ( + FeishuCredentials, + LarkCredentials, + _FeishuLarkCredentials, +) +from core.human_input_v2.im_integration.adapters.entities import ( + AuthenticatedIMEvent, + CardAssessment, + CorrelationToken, + CredentialTestFailure, + CredentialTestFailureKind, + CredentialTestSuccess, + CredentialTestTimeoutError, + Directory, + DirectoryEntry, + DirectoryReadFailure, + DynamicCardMessagingError, + EventAcceptance, + IMCardEvent, + IMCardEventDecodeResult, + IMCardEventDecodingError, + IMEventIngressKind, + IMProviderError, + IMStreamStartError, + IMStreamStopError, + MessageAccepted, + MessageSendingError, + MessageSendingResult, + ProviderUserId, + ReplacementError, + ReplacementErrorKind, + StaticCardIntent, + UnrecognizedIMEvent, + WebhookRequest, + WebhookResponse, +) +from core.human_input_v2.im_integration.adapters.message_locator import MessageLocator, _Base64JSONLocatorPayload +from core.human_input_v2.im_integration.adapters.protocols import ( + IMCardEventDecoder, + IMDirectory, + IMDynamicCardMessaging, + IMEventConsumer, + IMMessaging, + IMWebhookHandler, +) +from core.human_input_v2.resolved_form import ( + FileInput, + FileListInput, + MarkdownFragment, + ParagraphInput, + ResolvedForm, + SelectInput, +) + +logger = logging.getLogger(__name__) + +_FEISHU_DOMAIN = "https://open.feishu.cn" +_LARK_DOMAIN = "https://open.larksuite.com" +_DIRECTORY_PAGE_SIZE = 50 +type _DepartmentIdType = Literal["department_id", "open_department_id"] +type _FeishuLarkProvider = Literal[IMProvider.FEISHU, IMProvider.LARK] + + +@dataclass(frozen=True, slots=True) +class _DepartmentIdentity: + value: str + id_type: _DepartmentIdType + + +_ROOT_DEPARTMENT = _DepartmentIdentity("0", "department_id") +_AUTHENTICATION_REJECTED_CODES = frozenset((10014, 99991663, 99991664, 99991665)) +_STALE_MESSAGE_CODES = frozenset((230001, 230011, 230020)) +_JSON_RESPONSE_HEADERS = (("Content-Type", "application/json"),) +_STREAM_READY_TIMEOUT_SECONDS = 30.0 +_STREAM_CLOSE_TIMEOUT_SECONDS = 5.0 +_MILLISECONDS_PER_SECOND = 1_000 +_MICROSECONDS_PER_SECOND = 1_000_000 +_MILLISECOND_TIMESTAMP_DIGITS = 13 +_MICROSECOND_TIMESTAMP_DIGITS = 16 + + +def _log_safe_error(message: str, *, extra: Mapping[str, object] | None = None) -> None: + """Log only static diagnostics, never the active Provider exception.""" + + logger.error(message, extra=extra) + + +class _SDKGateway(Protocol): + """Narrow synchronous boundary over official generated SDK resources. + + Omitting request options delegates authentication to the SDK token lifecycle. + """ + + def request_tenant_access_token(self) -> str: ... + + def query_tenant(self, *, request_option: lark.RequestOption | None = None) -> Mapping[str, object]: ... + + def list_scope( + self, page_token: str | None, *, request_option: lark.RequestOption | None = None + ) -> Mapping[str, object]: ... + + def list_departments( + self, + department: _DepartmentIdentity, + page_token: str | None, + *, + request_option: lark.RequestOption | None = None, + ) -> Mapping[str, object]: ... + + def list_users(self, department: _DepartmentIdentity, page_token: str | None) -> Mapping[str, object]: ... + + def create_message(self, receive_id: str, msg_type: str, content: str) -> Mapping[str, object]: ... + + def patch_message(self, message_id: str, content: str) -> Mapping[str, object]: ... + + +@dataclass(frozen=True, slots=True) +class _SDKEventEnvelope: + native_payload: str + provider_tenant_id: str + event_id: str | None + event_type: str | None + occurred_at: datetime | None + + +type _StreamDeliveryCallback = Callable[[_SDKEventEnvelope, Callable[[], None]], None] + + +@runtime_checkable +class _SDKWireWriter(Protocol): + _write_message: Callable[[bytes], Awaitable[None]] + + +class _OfficialSDKGateway(_SDKGateway): + """Translate official SDK response objects into validation-local mappings.""" + + def __init__(self, credentials: _FeishuLarkCredentials, domain: str) -> None: + client = ( + lark.Client.builder() + .app_id(credentials.app_id) + .app_secret(credentials.app_secret) + .domain(domain) + .enable_set_token(True) + .log_level(lark.LogLevel.ERROR) + .build() + ) + tenant_service = client.tenant + contact_service = client.contact + im_service = client.im + if tenant_service is None or contact_service is None or im_service is None: + raise RuntimeError("official SDK client did not initialize required services") + self._tenant_service = tenant_service + self._contact_service = contact_service + self._im_service = im_service + self._client = client + self._credentials = credentials + + @override + def request_tenant_access_token(self) -> str: + """Authenticate submitted credentials without consulting the SDK token cache.""" + + auth_service = self._client.auth + if auth_service is None: + raise IMProviderError("official SDK client did not initialize authentication") + body = ( + auth.v3.InternalTenantAccessTokenRequestBody.builder() + .app_id(self._credentials.app_id) + .app_secret(self._credentials.app_secret) + .build() + ) + request = auth.v3.InternalTenantAccessTokenRequest.builder().request_body(body).build() + response = auth_service.v3.tenant_access_token.internal(request) + raw = response.raw + if raw is None or raw.content is None or raw.status_code is None: + raise IMProviderError("provider token response is empty") + token_response = _TenantAccessTokenResponse.model_validate_json(raw.content) + if token_response.code is None: + raise IMProviderError("provider token response did not confirm authentication") + if token_response.code != 0: + raise AccessTokenException(raw.status_code, token_response.code, "credential test rejected", "") + if token_response.tenant_access_token is None: + raise IMProviderError("provider token response did not provide an access token") + + return token_response.tenant_access_token + + @override + def query_tenant(self, *, request_option: lark.RequestOption | None = None) -> Mapping[str, object]: + request = tenant.v2.QueryTenantRequest.builder().build() + return _sdk_response_mapping(self._tenant_service.v2.tenant.query(request, request_option)) + + @override + def list_scope( + self, page_token: str | None, *, request_option: lark.RequestOption | None = None + ) -> Mapping[str, object]: + builder = ( + contact.v3.ListScopeRequest.builder() + .page_size(_DIRECTORY_PAGE_SIZE) + .department_id_type("department_id") + .user_id_type("union_id") + ) + if page_token is not None: + builder = builder.page_token(page_token) + return _sdk_response_mapping(self._contact_service.v3.scope.list(builder.build(), request_option)) + + @override + def list_departments( + self, + department: _DepartmentIdentity, + page_token: str | None, + *, + request_option: lark.RequestOption | None = None, + ) -> Mapping[str, object]: + builder = ( + contact.v3.ChildrenDepartmentRequest.builder() + .department_id_type(department.id_type) + .user_id_type("union_id") + .fetch_child(False) + .page_size(_DIRECTORY_PAGE_SIZE) + ) + if page_token is not None: + builder = builder.page_token(page_token) + request = builder.build() + request.paths["department_id"] = department.value + return _sdk_response_mapping(self._contact_service.v3.department.children(request, request_option)) + + @override + def list_users(self, department: _DepartmentIdentity, page_token: str | None) -> Mapping[str, object]: + builder = ( + contact.v3.FindByDepartmentUserRequest.builder() + .department_id(department.value) + .department_id_type(department.id_type) + .user_id_type("union_id") + .page_size(_DIRECTORY_PAGE_SIZE) + ) + if page_token is not None: + builder = builder.page_token(page_token) + return _sdk_response_mapping(self._contact_service.v3.user.find_by_department(builder.build())) + + @override + def create_message(self, receive_id: str, msg_type: str, content: str) -> Mapping[str, object]: + body = ( + im.v1.CreateMessageRequestBody.builder().receive_id(receive_id).msg_type(msg_type).content(content).build() + ) + request = im.v1.CreateMessageRequest.builder().receive_id_type("union_id").request_body(body).build() + return _sdk_response_mapping(self._im_service.v1.message.create(request)) + + @override + def patch_message(self, message_id: str, content: str) -> Mapping[str, object]: + body = im.v1.PatchMessageRequestBody.builder().content(content).build() + request = im.v1.PatchMessageRequest.builder().message_id(message_id).request_body(body).build() + return _sdk_response_mapping(self._im_service.v1.message.patch(request)) + + +def _create_sdk_gateway( + credentials: _FeishuLarkCredentials, + domain: str, +) -> _SDKGateway: + return _OfficialSDKGateway(credentials, domain) + + +class _PerClientSDKWSClient(sdk_ws_client_module.Client): + """Bind the official SDK transport lifecycle to one client-owned loop. + + The 1.7.2 SDK hard-codes a module-level loop in ``start()``, ``_connect()``, + and ``_receive_message_loop()``. It exposes no loop injection point, so + this compatibility seam preserves the official handshake, frame handling, + and reconnect implementation while replacing only those three scheduling + decisions. The exact SDK version and these private shapes are protected by + boundary tests. + """ + + _conn: ClientConnection | None + + def __init__( + self, + *, + credentials: _FeishuLarkCredentials, + domain: str, + event_handler: lark.EventDispatcherHandler, + ) -> None: + self._dify_loop = asyncio.new_event_loop() + asyncio.set_event_loop(self._dify_loop) + try: + super().__init__( + credentials.app_id, + credentials.app_secret, + log_level=lark.LogLevel.ERROR, + event_handler=event_handler, + domain=domain, + auto_reconnect=False, + extra_ua_tags=["channel"], + ) + except Exception: + self._dify_loop.close() + raise + self._dify_lifecycle_lock = threading.Lock() + self._dify_stopping = False + + @override + def start(self) -> None: + with self._dify_lifecycle_lock: + if self._dify_stopping: + self._close_owned_loop() + return + try: + try: + self._dify_loop.run_until_complete(self._connect()) + except Exception: + self._dify_loop.run_until_complete(self._disconnect()) + raise + self._dify_loop.create_task(self._ping_loop()) + self._dify_loop.run_forever() + finally: + self._close_owned_loop() + + @override + async def _connect(self) -> None: + await self._lock.acquire() + try: + if self._conn is not None: + return + connection_url = self._get_conn_url() + parsed_url = urlsplit(connection_url) + query = parse_qs(parsed_url.query) + connection_id = query[sdk_ws_client_module.DEVICE_ID][0] + service_id = query[sdk_ws_client_module.SERVICE_ID][0] + + connection = await sdk_ws_client_module.websockets.connect(connection_url, proxy=None) + self._conn = connection + self._conn_url = connection_url + self._conn_id = connection_id + self._service_id = service_id + self._dify_loop.create_task(self._receive_message_loop()) + except sdk_ws_client_module.InvalidHandshake as exc: + sdk_ws_client_module._parse_ws_conn_exception(exc) + finally: + self._lock.release() + + @override + async def _receive_message_loop(self) -> None: + try: + while True: + connection = self._conn + if connection is None: + raise sdk_ws_client_module.ConnectionClosedException("connection is closed") + message = await connection.recv() + if not isinstance(message, bytes): + raise sdk_ws_client_module.ConnectionClosedException("received a non-binary SDK frame") + self._dify_loop.create_task(self._handle_message(message)) + except Exception: + await self._disconnect() + if self._dify_stopping: + return + if self._auto_reconnect: + await self._reconnect() + return + raise + + def stop(self) -> None: + with self._dify_lifecycle_lock: + self._dify_stopping = True + if self._dify_loop.is_closed(): + return + if self._dify_loop.is_running(): + disconnect = asyncio.run_coroutine_threadsafe(self._disconnect(), self._dify_loop) + try: + disconnect.result(timeout=_STREAM_CLOSE_TIMEOUT_SECONDS) + finally: + self._dify_loop.call_soon_threadsafe(self._dify_loop.stop) + return + self._dify_loop.run_until_complete(self._disconnect()) + + def _close_owned_loop(self) -> None: + pending_tasks = asyncio.all_tasks(self._dify_loop) + for task in pending_tasks: + task.cancel() + if pending_tasks: + self._dify_loop.run_until_complete(asyncio.gather(*pending_tasks, return_exceptions=True)) + self._dify_loop.run_until_complete(self._dify_loop.shutdown_asyncgens()) + self._dify_loop.run_until_complete(self._dify_loop.shutdown_default_executor()) + self._dify_loop.close() + asyncio.set_event_loop(None) + + +class _SynchronousEventChannel(FeishuChannel): + """Official lifecycle wrapper with a synchronous responsibility callback. + + ``FeishuChannel`` exposes ready/stop semantics but its built-in dispatcher + schedules user callbacks after the SDK has already chosen an ACK. The SDK + does not expose public dispatcher injection or wire-write completion hooks, + so the contained compatibility seams below keep the official transport + while making consumer responsibility and completed ACK writes observable. + Their SDK compatibility is protected by focused boundary tests. + """ + + _ws_client: sdk_ws_client_module.Client | None + + def __init__( + self, + *, + credentials: _FeishuLarkCredentials, + domain: str, + callback: _StreamDeliveryCallback, + ) -> None: + self._dify_credentials = credentials + self._dify_callback = callback + self._wire_ack_condition = threading.Condition() + self._pending_wire_acks: set[threading.Event] = set() + self._current_wire_ack: ContextVar[threading.Event | None] = ContextVar( + "feishu_lark_current_wire_ack", + default=None, + ) + self._wire_ack_tracking_enabled = False + self._wire_ack_hook_installed = threading.Event() + self._transport_lifecycle_lock = threading.Lock() + self._transport_stop_requested = threading.Event() + super().__init__( + app_id=credentials.app_id, + app_secret=credentials.app_secret, + encrypt_key=credentials.encrypt_key, + verification_token=credentials.verification_token, + domain=domain, + log_level=lark.LogLevel.ERROR, + # The high-level SDK channel owns dispatcher/background concerns; + # the per-client compatibility transport below owns the WS loop. + transport=TransportConfig(kind="webhook", auto_reconnect=False), + ) + + @override + async def connect_until_ready(self, *, timeout: float | None = 30.0) -> None: + self._wire_ack_tracking_enabled = True + running_loop = asyncio.get_running_loop() + start_future = running_loop.run_in_executor(None, self.start) + deadline = None if timeout is None else running_loop.time() + timeout + try: + while not start_future.done(): + ws_client = self._ws_client + if ws_client is not None and ws_client._conn is not None: + self._install_wire_ack_hook(ws_client) + return + if deadline is not None and running_loop.time() >= deadline: + self.stop() + raise TimeoutError("official SDK stream did not become ready before the deadline") + await asyncio.sleep(0.01) + await start_future + raise RuntimeError("official SDK stream exited before becoming ready") + finally: + self._wire_ack_hook_installed.set() + + @override + def start(self) -> None: + super().start() + self._ready_flag = False + with self._transport_lifecycle_lock: + if self._transport_stop_requested.is_set(): + return + dispatcher = self._dispatcher + if dispatcher is None: + raise RuntimeError("official SDK channel did not initialize its event dispatcher") + ws_client = _PerClientSDKWSClient( + credentials=self._dify_credentials, + domain=self._config.domain, + event_handler=dispatcher, + ) + self._ws_client = ws_client + ws_client.start() + + @override + def stop(self, *, join_timeout: float = 5.0) -> None: + self._transport_stop_requested.set() + with self._transport_lifecycle_lock: + ws_client = self._ws_client + if isinstance(ws_client, _PerClientSDKWSClient): + self._ws_client = None + close_error: Exception | None = None + if isinstance(ws_client, _PerClientSDKWSClient): + try: + ws_client.stop() + except Exception as exc: + close_error = exc + super().stop(join_timeout=join_timeout) + if close_error is not None: + raise close_error + + def _install_wire_ack_hook(self, ws_client: object) -> None: + if not isinstance(ws_client, _SDKWireWriter): + raise RuntimeError("official SDK WebSocket client does not expose the expected write seam") + original_write = ws_client._write_message + + async def write_message(data: bytes) -> None: + delivery = self._current_wire_ack.get() + try: + await original_write(data) + finally: + if delivery is not None: + with self._wire_ack_condition: + self._pending_wire_acks.discard(delivery) + delivery.set() + self._wire_ack_condition.notify_all() + self._current_wire_ack.set(None) + + ws_client._write_message = write_message + self._wire_ack_hook_installed.set() + + def wait_for_pending_wire_acks(self) -> None: + with self._wire_ack_condition: + while self._pending_wire_acks: + self._wire_ack_condition.wait() + + @override + def _build_dispatcher(self): + builder = lark.EventDispatcherHandler.builder( + self._dify_credentials.encrypt_key or "", + self._dify_credentials.verification_token or "", + lark.LogLevel.ERROR, + ) + return builder.register_p2_card_action_trigger(self._on_card_action).build() + + def _on_card_action(self, event: P2CardActionTrigger) -> P2CardActionTriggerResponse: + self._dispatch(event) + return P2CardActionTriggerResponse({}) + + def _dispatch(self, event: P2CardActionTrigger) -> None: + if self._wire_ack_tracking_enabled: + self._wire_ack_hook_installed.wait() + delivery = threading.Event() + with self._wire_ack_condition: + self._pending_wire_acks.add(delivery) + self._current_wire_ack.set(delivery) + + acknowledged = False + + def acknowledge() -> None: + nonlocal acknowledged + acknowledged = True + + self._dify_callback(_sdk_event_envelope(event), acknowledge) + if not acknowledged: + raise RuntimeError("event responsibility was not accepted") + + +class _OfficialSDKStreamClient: + def __init__( + self, + credentials: _FeishuLarkCredentials, + domain: str, + callback: _StreamDeliveryCallback, + ) -> None: + self._channel = _SynchronousEventChannel( + credentials=credentials, + domain=domain, + callback=callback, + ) + self._loop = asyncio.new_event_loop() + self._loop_ready = threading.Event() + self._loop_thread: threading.Thread | None = None + self._lifecycle_lock = threading.Lock() + self._connect_future: Future[None] | None = None + self._stopped = False + + def start(self) -> None: + with self._lifecycle_lock: + if self._stopped: + raise RuntimeError("official SDK stream client was stopped before startup completed") + if self._loop_thread is None: + self._loop_thread = threading.Thread( + target=self._run_loop, + name="feishu-lark-sdk-stream", + daemon=True, + ) + self._loop_thread.start() + + self._loop_ready.wait() + with self._lifecycle_lock: + if self._stopped: + raise RuntimeError("official SDK stream client was stopped before startup completed") + future = asyncio.run_coroutine_threadsafe( + self._channel.connect_until_ready(timeout=_STREAM_READY_TIMEOUT_SECONDS), + self._loop, + ) + self._connect_future = future + future.result() + + def stop(self) -> None: + with self._lifecycle_lock: + if self._stopped: + return + self._stopped = True + loop_thread = self._loop_thread + connect_future = self._connect_future + + try: + self._channel.wait_for_pending_wire_acks() + finally: + try: + self._channel.stop() + finally: + if connect_future is not None and not connect_future.done(): + connect_future.cancel() + if loop_thread is None: + self._loop.close() + else: + self._loop.call_soon_threadsafe(self._loop.stop) + if loop_thread is not threading.current_thread(): + loop_thread.join() + + def _run_loop(self) -> None: + asyncio.set_event_loop(self._loop) + self._loop_ready.set() + try: + self._loop.run_forever() + finally: + self._loop.run_until_complete(self._loop.shutdown_asyncgens()) + self._loop.run_until_complete(self._loop.shutdown_default_executor()) + self._loop.close() + + +def _create_sdk_stream_client( + credentials: _FeishuLarkCredentials, + domain: str, + callback: _StreamDeliveryCallback, +) -> _OfficialSDKStreamClient: + return _OfficialSDKStreamClient(credentials, domain, callback) + + +class _ExternalResponseModel(BaseModel): + model_config = ConfigDict(extra="ignore", hide_input_in_errors=True, strict=True) + + +class _Tenant(_ExternalResponseModel): + tenant_key: str | None = Field(default=None, min_length=1) + + +class _TenantData(_ExternalResponseModel): + tenant: _Tenant + + +class _PermissionViolation(_ExternalResponseModel): + type: str | None = None + subject: str + + +class _PermissionErrorDetails(_ExternalResponseModel): + permission_violations: list[_PermissionViolation] = Field(default_factory=list) + + +class _HTTPResponseMetadata(_ExternalResponseModel): + status_code: int | None = None + + +class _CredentialResponse(_ExternalResponseModel): + code: int | None = None + error: JsonValue = None + raw: _HTTPResponseMetadata | None = None + + +class _TenantAccessTokenResponse(_CredentialResponse): + tenant_access_token: str | None = Field(default=None, min_length=1, pattern=r"\S", repr=False) + + +def _credential_rejection(response: _CredentialResponse, reason: str) -> CredentialTestFailure: + try: + details = _PermissionErrorDetails.model_validate(response.error) + except ValidationError: + missing_permissions = () + else: + missing_permissions = tuple( + sorted( + { + violation.subject + for violation in details.permission_violations + if violation.type == "action" and violation.subject + } + ) + ) + kind = CredentialTestFailureKind.UNKNOWN + if response.code in _AUTHENTICATION_REJECTED_CODES: + kind = CredentialTestFailureKind.AUTHENTICATION_REJECTED + elif missing_permissions: + kind = CredentialTestFailureKind.PERMISSION_DENIED + provider_error_code = str(response.code) if response.code is not None else None + return CredentialTestFailure( + kind, + reason, + provider_error_code, + missing_permissions, + http_status=response.raw.status_code if response.raw is not None else None, + ) + + +class _TenantResponse(_CredentialResponse): + data: _TenantData | None = None + + +class _ScopeData(_ExternalResponseModel): + department_ids: list[str] = Field(default_factory=list) + user_ids: list[str] = Field(default_factory=list) + has_more: bool = Field(strict=True) + page_token: str | None = Field(default=None, strict=True) + + +class _ScopeResponse(_CredentialResponse): + data: _ScopeData | None = None + + +class _DirectoryUser(_ExternalResponseModel): + union_id: str = Field(min_length=1) + name: str | None = None + email: str | None = None + enterprise_email: str | None = None + avatar_url: str | None = Field(default=None, validation_alias=AliasPath("avatar", "avatar_240")) + + @field_validator("avatar_url") + @classmethod + def _normalize_avatar_url(cls, value: str | None) -> str | None: + if value is None: + return None + return value.strip() or None + + +class _DirectoryDepartment(_ExternalResponseModel): + department_id: str | None = None + open_department_id: str | None = None + + +class _UserPageData(_ExternalResponseModel): + items: list[_DirectoryUser] = Field(default_factory=list) + has_more: bool = Field(strict=True) + page_token: str | None = Field(default=None, strict=True) + + +class _UserPageResponse(_ExternalResponseModel): + code: int + data: _UserPageData | None = None + + +class _DepartmentPageData(_ExternalResponseModel): + items: list[_DirectoryDepartment] = Field(default_factory=list) + has_more: bool = Field(strict=True) + page_token: str | None = Field(default=None, strict=True) + + +class _DepartmentPageResponse(_CredentialResponse): + data: _DepartmentPageData | None = None + + +class _MessageData(_ExternalResponseModel): + message_id: str = Field(min_length=1) + + +class _MessageResponse(_ExternalResponseModel): + code: int + data: _MessageData | None = None + + +class _PatchResponse(_ExternalResponseModel): + code: int + + +class _EncryptedWebhookEnvelope(_ExternalResponseModel): + encrypt: str = Field(min_length=1) + + +class _WebhookChallenge(_ExternalResponseModel): + type: Literal["url_verification"] + token: str | None = None + challenge: str + + +class _WebhookEventHeader(_ExternalResponseModel): + token: str | None = None + tenant_key: str = Field(min_length=1) + event_id: str | None = None + event_type: str | None = None + create_time: str | None = None + + +class _WebhookEventEnvelope(_ExternalResponseModel): + schema_version: str = Field(alias="schema") + header: _WebhookEventHeader + + +_FEISHU_LARK_DIFY_ACTION_MARKER = "__dify.human_input.action" + + +@dataclass(frozen=True, slots=True) +class _MSFeishuLarkCardCodec(IMCardEventDecoder): + """Own the credential-free Feishu/Lark card wire contract.""" + + _FORM_NAME = "__dify.human_input" + _CALLBACK_EVENT_TYPE = "card.action.trigger" + _CALLBACK_SCHEMA_VERSION = 1 + _MAX_CARD_SIZE_BYTES = 30 * 1024 + _SUPPORTED_PROVIDERS = frozenset((IMProvider.FEISHU, IMProvider.LARK)) + _SUPPORTED_ACTION_STYLES = frozenset((ButtonStyle.DEFAULT, ButtonStyle.PRIMARY, ButtonStyle.ACCENT)) + _ACTION_MARKER = _FEISHU_LARK_DIFY_ACTION_MARKER + _JSON_OBJECT_ADAPTER: ClassVar[TypeAdapter[dict[str, JsonValue]]] = TypeAdapter( + dict[str, JsonValue], + config=ConfigDict(strict=True, allow_inf_nan=False), + ) + + class _CallbackModel(BaseModel): + model_config = ConfigDict( + allow_inf_nan=False, + extra="ignore", + frozen=True, + hide_input_in_errors=True, + strict=True, + ) + + class _CallbackHeader(_CallbackModel): + event_type: Literal["card.action.trigger"] + + class _CallbackOperator(_CallbackModel): + union_id: str = Field(min_length=1) + + @field_validator("union_id") + @classmethod + def _require_non_blank_union_id(cls, value: str) -> str: + if not value.strip(): + raise ValueError("Feishu/Lark callback union identifier is empty.") + return value + + class _ButtonMetadata(BaseModel): + model_config = ConfigDict( + extra="forbid", + frozen=True, + hide_input_in_errors=True, + strict=True, + ) + + version: Literal[1] + action_id: str = Field(min_length=1) + correlation_token: str + + @field_validator("action_id") + @classmethod + def _require_non_blank_action_id(cls, value: str) -> str: + if not value.strip(): + raise ValueError("Feishu/Lark callback action identifier is empty.") + return value + + class _ButtonValue(BaseModel): + model_config = ConfigDict( + extra="forbid", + frozen=True, + hide_input_in_errors=True, + strict=True, + ) + + # Mypy requires a literal alias for Pydantic's dataclass transform. + dify_action: _MSFeishuLarkCardCodec._ButtonMetadata = Field(alias="__dify.human_input.action") + + class _CallbackAction(_CallbackModel): + tag: Literal["button"] + name: str = Field(min_length=1) + value: _MSFeishuLarkCardCodec._ButtonValue + # Feishu/Lark omit form_value when the card has no input components. + form_value: dict[str, JsonValue] = Field(default_factory=dict) + + @field_validator("name") + @classmethod + def _require_non_blank_action_name(cls, value: str) -> str: + if not value.strip(): + raise ValueError("Feishu/Lark callback action name is empty.") + return value + + class _CallbackEvent(_CallbackModel): + operator: _MSFeishuLarkCardCodec._CallbackOperator + action: _MSFeishuLarkCardCodec._CallbackAction + + class _SubmissionCallback(_CallbackModel): + schema_version: Literal["2.0"] = Field(alias="schema") + header: _MSFeishuLarkCardCodec._CallbackHeader + event: _MSFeishuLarkCardCodec._CallbackEvent + + def assess(self, intent: ResolvedForm) -> CardAssessment: + reason = self._unrepresentable_reason(intent) + if reason is not None: + return CardAssessment(False, reason) + return CardAssessment(True) + + def encode( + self, + intent: ResolvedForm, + correlation_token: CorrelationToken, + ) -> Mapping[str, JsonValue]: + reason = self._unrepresentable_reason(intent) + if reason is not None: + raise DynamicCardMessagingError(reason) + card = self._render_card(intent, correlation_token) + if self._serialized_card_size(card) > self._MAX_CARD_SIZE_BYTES: + raise DynamicCardMessagingError("Feishu/Lark cannot preserve a card beyond the Provider payload limit.") + return card + + @override + def decode(self, event: AuthenticatedIMEvent) -> IMCardEventDecodeResult: + if event.provider not in self._SUPPORTED_PROVIDERS or event.event_type != self._CALLBACK_EVENT_TYPE: + return UnrecognizedIMEvent() + + callback = self._decode_ingress_callback(event.ingress_kind, event.payload) + if callback is None: + raise IMCardEventDecodingError("Feishu/Lark card event payload is invalid.") + action_value = self._recognition_action_value(callback) + if action_value is None: + raise IMCardEventDecodingError("Feishu/Lark card event schema is invalid.") + if self._ACTION_MARKER not in action_value: + return UnrecognizedIMEvent() + + submission = self._validate_submission(callback) + if submission is None: + raise IMCardEventDecodingError("Feishu/Lark card event schema is invalid.") + metadata = submission.event.action.value.dify_action + if submission.event.action.name != metadata.action_id: + raise IMCardEventDecodingError("Feishu/Lark card event schema is invalid.") + return IMCardEvent( + provider_user_id=ProviderUserId(submission.event.operator.union_id), + action_id=metadata.action_id, + inputs=submission.event.action.form_value, + correlation_token=CorrelationToken(metadata.correlation_token), + ) + + @classmethod + def _decode_json_object(cls, serialized_callback: str) -> dict[str, JsonValue] | None: + try: + decoded_callback: JsonValue = json.loads( + serialized_callback, + parse_constant=cls._reject_non_standard_json_constant, + ) + except (json.JSONDecodeError, ValueError, RecursionError): + return None + try: + return cls._JSON_OBJECT_ADAPTER.validate_python(decoded_callback, strict=True) + except ValidationError: + return None + + @classmethod + def _decode_ingress_callback( + cls, + ingress_kind: IMEventIngressKind, + serialized_callback: str, + ) -> dict[str, JsonValue] | None: + if ingress_kind is IMEventIngressKind.WEBHOOK: + return cls._decode_json_object(serialized_callback) + if ingress_kind is IMEventIngressKind.STREAM: + return cls._decode_json_object(serialized_callback) + return None + + @classmethod + def _recognition_action_value(cls, callback: dict[str, JsonValue]) -> dict[str, JsonValue] | None: + if callback.get("schema") != "2.0": + return None + header = callback.get("header") + callback_event = callback.get("event") + if not isinstance(header, dict) or header.get("event_type") != cls._CALLBACK_EVENT_TYPE: + return None + if not isinstance(callback_event, dict): + return None + action = callback_event.get("action") + if not isinstance(action, dict): + return None + action_value = action.get("value") + if not isinstance(action_value, dict): + return {} + return action_value + + @classmethod + def _validate_submission(cls, callback: dict[str, JsonValue]) -> _SubmissionCallback | None: + try: + return cls._SubmissionCallback.model_validate(callback) + except ValidationError: + return None + + @classmethod + def _unrepresentable_reason(cls, intent: ResolvedForm) -> str | None: + if not intent.parts and not intent.actions: + return "Feishu/Lark cannot preserve an empty card." + + input_names: set[str] = set() + for block in intent.parts: + match block: + case MarkdownFragment(text=text): + if not text: + return "Feishu/Lark cannot preserve an empty Markdown block." + continue + case FileInput() | FileListInput(): + return "Feishu/Lark cards cannot represent file inputs." + case ParagraphInput(output_variable_name=input_name): + pass + case SelectInput(output_variable_name=input_name, options=options, default_value=default_value): + if not options or any(not option for option in options): + return "Feishu/Lark cannot preserve one select option." + if len(options) != len(set(options)): + return "Feishu/Lark cannot preserve duplicate select options." + if default_value is not None and default_value not in options: + return "Feishu/Lark cannot preserve one select input default." + if input_name in input_names: + return "Feishu/Lark cannot preserve duplicate card input identifiers." + input_names.add(input_name) + + if any(action.button_style not in cls._SUPPORTED_ACTION_STYLES for action in intent.actions): + return "Feishu/Lark cannot preserve one card action style." + assessment_card = cls._render_card(intent, CorrelationToken("")) + if cls._serialized_card_size(assessment_card) > cls._MAX_CARD_SIZE_BYTES: + return "Feishu/Lark cannot preserve a card beyond the Provider payload limit." + return None + + @classmethod + def _render_card( + cls, + intent: ResolvedForm, + correlation_token: CorrelationToken, + ) -> dict[str, JsonValue]: + rendered_elements: list[JsonValue] = [] + for block in intent.parts: + if isinstance(block, MarkdownFragment): + rendered_elements.append({"tag": "markdown", "content": block.text}) + continue + placeholder: dict[str, JsonValue] = { + "tag": "plain_text", + "content": block.output_variable_name, + } + if isinstance(block, ParagraphInput): + input_element: dict[str, JsonValue] = { + "tag": "input", + "name": block.output_variable_name, + "input_type": "multiline_text", + "width": "fill", + "required": True, + "label": placeholder, + "placeholder": placeholder, + } + if block.default_value is not None: + input_element["default_value"] = block.default_value + rendered_elements.append(input_element) + continue + if isinstance(block, SelectInput): + select_element: dict[str, JsonValue] = { + "tag": "select_static", + "name": block.output_variable_name, + "required": True, + "placeholder": placeholder, + "options": [ + { + "text": {"tag": "plain_text", "content": option}, + "value": option, + } + for option in block.options + ], + } + if block.default_value is not None: + select_element["initial_option"] = block.default_value + rendered_elements.append(select_element) + continue + raise DynamicCardMessagingError("Feishu/Lark cards cannot represent file inputs.") + + if intent.actions: + columns: list[JsonValue] = [] + for action in intent.actions: + if action.button_style is ButtonStyle.PRIMARY: + button_type = "primary_filled" + elif action.button_style is ButtonStyle.ACCENT: + button_type = "danger_filled" + else: + button_type = "default" + columns.append( + { + "tag": "column", + "width": "auto", + "elements": [ + { + "tag": "button", + "name": action.id, + "type": button_type, + "text": {"tag": "plain_text", "content": action.title}, + "form_action_type": "submit", + "behaviors": [ + { + "type": "callback", + "value": { + cls._ACTION_MARKER: { + "version": cls._CALLBACK_SCHEMA_VERSION, + "action_id": action.id, + "correlation_token": str(correlation_token), + } + }, + } + ], + } + ], + } + ) + rendered_elements.append( + { + "tag": "column_set", + "flex_mode": "none", + "horizontal_spacing": "8px", + "horizontal_align": "left", + "columns": columns, + } + ) + + requires_form = any(not isinstance(block, MarkdownFragment) for block in intent.parts) or bool(intent.actions) + body_elements: list[JsonValue] + if requires_form: + body_elements = [{"tag": "form", "name": cls._FORM_NAME, "elements": rendered_elements}] + else: + body_elements = rendered_elements + card: dict[str, JsonValue] = { + "schema": "2.0", + "config": {"update_multi": True}, + "body": {"direction": "vertical", "elements": body_elements}, + } + if intent.title: + card["header"] = {"title": {"tag": "plain_text", "content": intent.title}} + return card + + @staticmethod + def _serialized_card_size(card: Mapping[str, JsonValue]) -> int: + serialized_card = json.dumps( + card, + ensure_ascii=False, + allow_nan=False, + separators=(",", ":"), + ) + return len(serialized_card.encode()) + + @staticmethod + def _reject_non_standard_json_constant(_serialized_constant: str) -> Never: + raise ValueError("non-standard JSON constant") + + +_MS_FEISHU_LARK_CARD_CODEC = _MSFeishuLarkCardCodec() + + +class _FeishuLarkLocatorPayload(_Base64JSONLocatorPayload): + model_config = ConfigDict(extra="forbid", frozen=True, hide_input_in_errors=True, strict=True) + + # version of the locator + v: Literal[1] + # provider of the locator + p: _FeishuLarkProvider + # Feishu/Lark message identifier used to update the card: + # Feishu: https://open.feishu.cn/document/uAjLw4CM/ukTMukTMukTM/reference/im-v1/message/patch + # Lark: https://open.larksuite.com/document/server-docs/im-v1/message-card/patch + message_id: str = Field(min_length=1, pattern=r"\S") + + +class _FeishuLarkDirectory(IMDirectory): + def __init__(self, gateway: _SDKGateway, provider: _FeishuLarkProvider) -> None: + self._gateway = gateway + self._provider = provider + + @override + def read_directory(self) -> Directory | DirectoryReadFailure: + try: + return self._read_complete_directory() + except Exception: + _log_safe_error("Feishu/Lark directory read failed", extra={"im_provider": self._provider.value}) + return DirectoryReadFailure(f"{_provider_name(self._provider)} directory could not be read completely.") + + def _read_complete_directory(self) -> Directory: + users: list[_DirectoryUser] = [] + seen_users: set[str] = set() + seen_departments = {_ROOT_DEPARTMENT} + pending_departments = deque((_ROOT_DEPARTMENT,)) + + while pending_departments: + department = pending_departments.popleft() + for user in self._read_users(department): + if user.union_id in seen_users: + continue + seen_users.add(user.union_id) + users.append(user) + for child in self._read_child_departments(department): + if child in seen_departments: + continue + seen_departments.add(child) + pending_departments.append(child) + avatars = read_avatars([user.avatar_url for user in users]) + return Directory( + tuple( + DirectoryEntry( + provider_user_id=ProviderUserId(user.union_id), + display_name=_optional_string(user.name), + email=_optional_string(user.enterprise_email) or _optional_string(user.email), + avatar=avatar, + ) + for user, avatar in zip(users, avatars, strict=True) + ) + ) + + def _read_users(self, department: _DepartmentIdentity) -> tuple[_DirectoryUser, ...]: + users: list[_DirectoryUser] = [] + page_token: str | None = None + seen_tokens: set[str] = set() + while True: + response = _UserPageResponse.model_validate(self._gateway.list_users(department, page_token)) + if response.code != 0 or response.data is None: + raise ValueError("directory user page was rejected") + users.extend(response.data.items) + page_token = _next_page_token(response.data.has_more, response.data.page_token, seen_tokens) + if page_token is None: + return tuple(users) + + def _read_child_departments(self, parent_department: _DepartmentIdentity) -> tuple[_DepartmentIdentity, ...]: + department_ids: list[_DepartmentIdentity] = [] + page_token: str | None = None + seen_tokens: set[str] = set() + while True: + response = _DepartmentPageResponse.model_validate( + self._gateway.list_departments(parent_department, page_token) + ) + if response.code != 0 or response.data is None: + raise ValueError("directory department page was rejected") + for child_department in response.data.items: + child = _department_identity(child_department) + if child is None: + raise ValueError("directory department identity is unavailable") + department_ids.append(child) + page_token = _next_page_token(response.data.has_more, response.data.page_token, seen_tokens) + if page_token is None: + return tuple(department_ids) + + +class _FeishuLarkMessaging(IMMessaging): + def __init__( + self, + gateway: _SDKGateway, + credentials: _FeishuLarkCredentials, + provider: _FeishuLarkProvider, + ) -> None: + self._gateway = gateway + self._credentials = credentials + self._provider = provider + + @override + def send_text( + self, provider_user_id: ProviderUserId, body: str, subject: str | None = None + ) -> MessageSendingResult: + try: + elements: list[dict[str, str]] = [] + if subject: + escaped_subject = "".join( + f"\\{character}" if character in punctuation else character for character in subject + ) + elements.append({"tag": "markdown", "content": f"# {escaped_subject}"}) + elements.append({"tag": "markdown", "content": body}) + card = {"schema": "2.0", "body": {"elements": elements}} + content = json.dumps(card, ensure_ascii=False, separators=(",", ":")) + response = _MessageResponse.model_validate( + self._gateway.create_message(str(provider_user_id), "interactive", content) + ) + except Exception: + _log_safe_error( + "Feishu/Lark text message acceptance is unknown", extra={"im_provider": self._provider.value} + ) + return MessageSendingError(f"{_provider_name(self._provider)} message acceptance could not be confirmed.") + if response.code != 0 or response.data is None: + return MessageSendingError(f"{_provider_name(self._provider)} message acceptance could not be confirmed.") + return MessageAccepted( + MessageLocator( + _FeishuLarkLocatorPayload( + v=1, + p=self._provider, + message_id=response.data.message_id, + ).encode() + ) + ) + + +class _FeishuLarkDynamicCardMessaging(IMDynamicCardMessaging): + def __init__( + self, + gateway: _SDKGateway, + credentials: _FeishuLarkCredentials, + provider: _FeishuLarkProvider, + ) -> None: + self._gateway = gateway + self._credentials = credentials + self._provider = provider + + @override + def assess(self, intent: ResolvedForm) -> CardAssessment: + return _MS_FEISHU_LARK_CARD_CODEC.assess(intent) + + @override + def send_card( + self, + provider_user_id: ProviderUserId, + intent: ResolvedForm, + correlation_token: CorrelationToken, + ) -> MessageSendingResult: + encoded_card = _MS_FEISHU_LARK_CARD_CODEC.encode(intent, correlation_token) + content = json.dumps(encoded_card, ensure_ascii=False, separators=(",", ":")) + failure = MessageSendingError(f"{_provider_name(self._provider)} card acceptance could not be confirmed.") + try: + provider_response = self._gateway.create_message(str(provider_user_id), "interactive", content) + except Exception: + _log_safe_error( + "Feishu/Lark card acceptance failed at create-message stage", + extra={"im_provider": self._provider.value}, + ) + return failure + try: + response = _MessageResponse.model_validate(provider_response) + except ValidationError: + _log_safe_error( + "Feishu/Lark card acceptance failed at response-validation stage", + extra={"im_provider": self._provider.value}, + ) + return failure + if response.code != 0: + _log_safe_error( + "Feishu/Lark card acceptance failed at provider-response stage", + extra={"im_provider": self._provider.value}, + ) + return failure + if response.data is None: + _log_safe_error( + "Feishu/Lark card acceptance failed at response-validation stage", + extra={"im_provider": self._provider.value}, + ) + return failure + return MessageAccepted( + MessageLocator( + _FeishuLarkLocatorPayload( + v=1, + p=self._provider, + message_id=response.data.message_id, + ).encode() + ) + ) + + @override + def replace_with_static( + self, + locator: MessageLocator, + intent: StaticCardIntent, + ) -> ReplacementError | None: + try: + decoded_locator = _FeishuLarkLocatorPayload.decode(str(locator)) + except (ValueError, binascii.Error, ValidationError): + return ReplacementError( + ReplacementErrorKind.INVALID_REFERENCE, + f"The {_provider_name(self._provider)} message reference is invalid.", + ) + if decoded_locator.p is not self._provider: + return ReplacementError( + ReplacementErrorKind.INVALID_REFERENCE, + f"The {_provider_name(self._provider)} message reference is invalid.", + ) + static_card = { + "schema": "2.0", + "body": {"elements": [{"tag": "markdown", "content": intent.rendered_content}]}, + } + content = json.dumps(static_card, ensure_ascii=False, separators=(",", ":")) + try: + response = _PatchResponse.model_validate(self._gateway.patch_message(decoded_locator.message_id, content)) + except Exception: + _log_safe_error("Feishu/Lark card replacement is unknown", extra={"im_provider": self._provider.value}) + return ReplacementError( + ReplacementErrorKind.UNKNOWN, + f"{_provider_name(self._provider)} replacement acceptance is unknown.", + ) + if response.code in _STALE_MESSAGE_CODES: + return ReplacementError( + ReplacementErrorKind.STALE_REFERENCE, + f"The referenced {_provider_name(self._provider)} card is no longer replaceable.", + ) + if response.code != 0: + return ReplacementError( + ReplacementErrorKind.UNKNOWN, + f"{_provider_name(self._provider)} replacement acceptance is unknown.", + ) + return None + + +class _FeishuLarkWebhookHandler(IMWebhookHandler): + """Concurrent webhook boundary honoring optional provider authentication.""" + + def __init__( + self, + gateway: _SDKGateway, + credentials: _FeishuLarkCredentials, + provider: _FeishuLarkProvider, + consumer: IMEventConsumer, + ) -> None: + self._gateway = gateway + self._verification_token = credentials.verification_token + self._encrypt_key = credentials.encrypt_key + self._provider = provider + self._consumer = consumer + + @override + def handle(self, request: WebhookRequest) -> WebhookResponse: + if request.method != "POST": + return _webhook_response(405, {"code": 1}) + decoded_request = self._decode_request(request) + if decoded_request is None: + return _webhook_response(401, {"code": 1}) + decoded, encrypted, native_payload = decoded_request + + if decoded.get("type") == "url_verification": + try: + challenge = _WebhookChallenge.model_validate(decoded) + except ValidationError: + return _webhook_response(400, {"code": 1}) + if not self._challenge_matches_configuration(challenge.token, decrypted=encrypted): + return _webhook_response(401, {"code": 1}) + return _webhook_response(200, {"challenge": challenge.challenge}) + + try: + envelope = _WebhookEventEnvelope.model_validate(decoded) + except ValidationError: + return _webhook_response(400, {"code": 1}) + if not self._verification_token_matches(envelope.header.token): + return _webhook_response(401, {"code": 1}) + try: + provider_tenant_id = _query_tenant_id(self._gateway) + except Exception: + _log_safe_error("Feishu/Lark Webhook tenant validation failed", extra={"im_provider": self._provider.value}) + return _webhook_response(503, {"code": 1}) + if provider_tenant_id != envelope.header.tenant_key: + return _webhook_response(401, {"code": 1}) + event = AuthenticatedIMEvent( + provider=self._provider, + provider_tenant_id=provider_tenant_id, + event_id=_optional_string(envelope.header.event_id), + event_type=_optional_string(envelope.header.event_type), + occurred_at=_webhook_occurred_at(envelope.header.create_time), + received_at=request.received_at, + ingress_kind=IMEventIngressKind.WEBHOOK, + payload=native_payload, + ) + try: + acceptance = self._consumer.accept(event, request_ip_address=request.request_ip_address) + except Exception: + _log_safe_error( + "Feishu/Lark Webhook consumer failed", + extra={"im_provider": self._provider.value, "provider_tenant_id": provider_tenant_id}, + ) + return _webhook_response(503, {"code": 1}) + if acceptance is not EventAcceptance.ACCEPTED: + return _webhook_response(503, {"code": 1}) + return _webhook_response(200, {"code": 0}) + + def _decode_request( + self, + request: WebhookRequest, + ) -> tuple[dict[str, object], bool, str] | None: + # Authentication must parse Provider JSON for challenge, token, signature, + # and tenant checks. The separate native string is never reconstructed; + # it is persisted exactly so only the codec normalizes card callback facts. + try: + outer = _decode_json_object(request.body) + encrypted = outer.get("encrypt") + if encrypted is None: + native_payload = request.body.decode() + decoded = outer + else: + if self._encrypt_key is None: + return None + envelope = _EncryptedWebhookEnvelope.model_validate(outer) + plaintext = _decrypt_webhook_payload(envelope.encrypt, self._encrypt_key) + native_payload = plaintext.decode() + decoded = _decode_json_object(plaintext) + except (ValueError, ValidationError, UnicodeDecodeError, binascii.Error): + return None + # The official SDK authenticates URL verification with the body token and requires signatures only for events. + if decoded.get("type") == "url_verification": + return decoded, encrypted is not None, native_payload + if self._encrypt_key is not None: + if _valid_webhook_signature(request, self._encrypt_key) is None: + return None + return decoded, encrypted is not None, native_payload + + def _verification_token_matches(self, token: str | None) -> bool: + return self._verification_token is None or ( + token is not None and hmac.compare_digest(token, self._verification_token) + ) + + def _challenge_matches_configuration(self, token: str | None, *, decrypted: bool) -> bool: + if self._verification_token is None: + return decrypted or self._encrypt_key is None + return token is not None and hmac.compare_digest(token, self._verification_token) + + +class _FeishuLarkEventStream: + """One-shot stream owning its callback gate, drain, ACK, and SDK client.""" + + def __init__( + self, + *, + credentials: _FeishuLarkCredentials, + provider: _FeishuLarkProvider, + domain: str, + consumer: IMEventConsumer, + ) -> None: + self._credentials = credentials + self._provider = provider + self._domain = domain + self._consumer = consumer + self._condition = threading.Condition() + self._start_claimed = False + self._start_in_progress = False + self._state: Literal["idle", "starting", "running", "stopping", "stopped", "failed"] = "idle" + self._accepting_callbacks = False + self._in_flight_callbacks = 0 + self._client: _OfficialSDKStreamClient | None = None + self._close_claimed = False + self._close_done = False + self._close_failed = False + + def start(self) -> None: + with self._condition: + if self._start_claimed: + raise IMStreamStartError( + f"This {_provider_name(self._provider)} event stream has already been started." + ) + self._start_claimed = True + self._start_in_progress = True + self._state = "starting" + self._accepting_callbacks = True + + try: + client = _create_sdk_stream_client(self._credentials, self._domain, self._handle_delivery) + with self._condition: + self._client = client + self._condition.notify_all() + if self._state != "starting": + raise RuntimeError("stream start was cancelled") + client.start() + with self._condition: + if self._state != "starting": + raise RuntimeError("stream start was cancelled") + self._state = "running" + self._start_in_progress = False + self._condition.notify_all() + return + except Exception: + self._finish_failed_start() + raise IMStreamStartError( + f"The {_provider_name(self._provider)} event stream could not be started." + ) from None + + def stop(self) -> None: + with self._condition: + if self._state == "stopped": + self._raise_if_close_failed() + return + if self._state == "stopping": + while self._state != "stopped": + self._condition.wait() + self._raise_if_close_failed() + return + if self._state == "idle": + self._start_claimed = True + self._state = "stopping" + self._accepting_callbacks = False + while self._in_flight_callbacks: + self._condition.wait() + while self._client is None and self._start_in_progress: + self._condition.wait() + client = self._claim_client_close() + + if client is not None: + self._close_client(client) + with self._condition: + # A failed start may own the close, so both terminal states must be + # published before stop exposes the synchronous lifecycle boundary. + while self._start_in_progress or not self._close_done: + self._condition.wait() + self._state = "stopped" + self._condition.notify_all() + self._raise_if_close_failed() + + def _finish_failed_start(self) -> None: + with self._condition: + self._accepting_callbacks = False + if self._state != "stopping": + self._state = "failed" + while self._in_flight_callbacks: + self._condition.wait() + client = self._claim_client_close() + self._start_in_progress = False + self._condition.notify_all() + if client is not None: + self._close_client(client) + else: + with self._condition: + while self._close_claimed and not self._close_done: + self._condition.wait() + + def _claim_client_close(self) -> _OfficialSDKStreamClient | None: + if self._close_claimed or self._client is None: + if self._client is None and not self._start_in_progress: + self._close_done = True + self._condition.notify_all() + return None + self._close_claimed = True + return self._client + + def _close_client(self, client: _OfficialSDKStreamClient) -> None: + close_failed = False + try: + client.stop() + except Exception: + close_failed = True + _log_safe_error("Feishu/Lark stream close failed", extra={"im_provider": self._provider.value}) + finally: + with self._condition: + self._close_failed = close_failed + self._close_done = True + self._condition.notify_all() + + def _raise_if_close_failed(self) -> None: + if self._close_failed: + raise IMStreamStopError(f"The {_provider_name(self._provider)} event stream could not be stopped.") + + def _handle_delivery(self, sdk_event: _SDKEventEnvelope, acknowledge: Callable[[], None]) -> None: + with self._condition: + if not self._accepting_callbacks or self._state not in {"starting", "running"}: + return + self._in_flight_callbacks += 1 + try: + event = AuthenticatedIMEvent( + provider=self._provider, + provider_tenant_id=sdk_event.provider_tenant_id, + event_id=sdk_event.event_id, + event_type=sdk_event.event_type, + occurred_at=sdk_event.occurred_at, + received_at=datetime.now(tz=UTC).replace(tzinfo=None), + ingress_kind=IMEventIngressKind.STREAM, + payload=sdk_event.native_payload, + ) + acceptance = self._consumer.accept(event) + if acceptance is EventAcceptance.ACCEPTED: + acknowledge() + except Exception: + _log_safe_error("Feishu/Lark stream callback failed", extra={"im_provider": self._provider.value}) + finally: + with self._condition: + self._in_flight_callbacks -= 1 + self._condition.notify_all() + + +class _FeishuLarkIMProviderAdapter: + """Externally serialized composition root shared by both wrappers.""" + + def __init__( + self, + credentials: _FeishuLarkCredentials, + provider: _FeishuLarkProvider, + domain: str, + ) -> None: + self._credentials = credentials + self._provider = provider + self._domain = domain + self._gateway = _create_sdk_gateway(credentials, domain) + self._directory = _FeishuLarkDirectory(self._gateway, provider) + self._messaging = _FeishuLarkMessaging(self._gateway, credentials, provider) + self._dynamic_card_messaging = _FeishuLarkDynamicCardMessaging(self._gateway, credentials, provider) + self._closed = False + + @classmethod + def card_event_decoder(cls) -> IMCardEventDecoder: + return _MS_FEISHU_LARK_CARD_CODEC + + @property + def provider(self) -> IMProvider: + self._ensure_open() + return self._provider + + def test_credentials(self) -> CredentialTestSuccess | CredentialTestFailure: + try: + return self._test_credentials() + except (AccessTokenException, ObtainAccessTokenException) as error: + return _credential_rejection( + _CredentialResponse( + code=error.code, + raw=_HTTPResponseMetadata(status_code=error.status_code) + if isinstance(error, AccessTokenException) + else None, + ), + f"{_provider_name(self._provider)} rejected the credential test.", + ) + except (TimeoutError, RequestsTimeout) as error: + raise CredentialTestTimeoutError() from error + + def _test_credentials(self) -> CredentialTestSuccess | CredentialTestFailure: + self._ensure_open() + access_token = self._gateway.request_tenant_access_token() + request_option = lark.RequestOption.builder().tenant_access_token(access_token).build() + tenant_payload = self._gateway.query_tenant(request_option=request_option) + tenant_envelope = _CredentialResponse.model_validate(tenant_payload) + if tenant_envelope.code != 0: + return _credential_rejection( + tenant_envelope, f"{_provider_name(self._provider)} rejected the credential test." + ) + tenant_response = _TenantResponse.model_validate(tenant_payload) + if tenant_response.data is None or tenant_response.data.tenant.tenant_key is None: + raise IMProviderError(f"{_provider_name(self._provider)} did not provide a stable tenant identity.") + scope_failure = self._validate_complete_scope(request_option) + if scope_failure is not None: + return scope_failure + department_failure = self._validate_root_department_access(request_option) + if department_failure is not None: + return department_failure + return CredentialTestSuccess(self._provider, tenant_response.data.tenant.tenant_key) + + def _validate_complete_scope(self, request_option: lark.RequestOption) -> CredentialTestFailure | None: + page_token: str | None = None + seen_tokens: set[str] = set() + while True: + scope_payload = self._gateway.list_scope(page_token, request_option=request_option) + scope_envelope = _CredentialResponse.model_validate(scope_payload) + if scope_envelope.code != 0: + return _credential_rejection( + scope_envelope, f"{_provider_name(self._provider)} baseline permissions could not be confirmed." + ) + response = _ScopeResponse.model_validate(scope_payload) + if response.data is None: + raise IMProviderError("directory scope page was rejected") + page_token = _next_page_token(response.data.has_more, response.data.page_token, seen_tokens) + if page_token is None: + return None + + def _validate_root_department_access(self, request_option: lark.RequestOption) -> CredentialTestFailure | None: + department_payload = self._gateway.list_departments(_ROOT_DEPARTMENT, None, request_option=request_option) + department_envelope = _CredentialResponse.model_validate(department_payload) + if department_envelope.code != 0: + return _credential_rejection( + department_envelope, f"{_provider_name(self._provider)} baseline permissions could not be confirmed." + ) + response = _DepartmentPageResponse.model_validate(department_payload) + if response.data is None: + raise IMProviderError("root department access was rejected") + for department in response.data.items: + if _department_identity(department) is None: + raise IMProviderError("root department response contains an invalid identity") + _next_page_token(response.data.has_more, response.data.page_token, set()) + return None + + @property + def directory(self) -> IMDirectory: + self._ensure_open() + return self._directory + + @property + def messaging(self) -> IMMessaging: + self._ensure_open() + return self._messaging + + @property + def dynamic_card_messaging(self) -> IMDynamicCardMessaging: + self._ensure_open() + return self._dynamic_card_messaging + + def create_webhook_handler(self, consumer: IMEventConsumer) -> IMWebhookHandler: + self._ensure_open() + return _FeishuLarkWebhookHandler( + _create_sdk_gateway(self._credentials, self._domain), + self._credentials, + self._provider, + consumer, + ) + + def create_stream_handler(self, consumer: IMEventConsumer) -> _FeishuLarkEventStream: + self._ensure_open() + return _FeishuLarkEventStream( + credentials=self._credentials, + provider=self._provider, + domain=self._domain, + consumer=consumer, + ) + + def close(self) -> None: + self._closed = True + + def _ensure_open(self) -> None: + if self._closed: + raise RuntimeError(f"{_provider_name(self._provider)} adapter is closed") + + +class FeishuIMProviderAdapter(_FeishuLarkIMProviderAdapter): + """Thin typed Feishu wrapper over the shared implementation.""" + + def __init__(self, credentials: FeishuCredentials) -> None: + if not isinstance(credentials, FeishuCredentials): + raise TypeError("Feishu adapter requires resolved Feishu credentials") + super().__init__(credentials, IMProvider.FEISHU, _FEISHU_DOMAIN) + + +class LarkIMProviderAdapter(_FeishuLarkIMProviderAdapter): + """Thin typed Lark wrapper over the shared implementation.""" + + def __init__(self, credentials: LarkCredentials) -> None: + if not isinstance(credentials, LarkCredentials): + raise TypeError("Lark adapter requires resolved Lark credentials") + super().__init__(credentials, IMProvider.LARK, _LARK_DOMAIN) + + +def _query_tenant_id(gateway: _SDKGateway) -> str: + response = _TenantResponse.model_validate(gateway.query_tenant()) + if response.code != 0 or response.data is None or response.data.tenant.tenant_key is None: + raise ValueError("stable tenant identity is unavailable") + return response.data.tenant.tenant_key + + +def _valid_webhook_signature(request: WebhookRequest, encrypt_key: str) -> bytes | None: + timestamps = _header_values(request.headers, "x-lark-request-timestamp") + nonces = _header_values(request.headers, "x-lark-request-nonce") + signatures = _header_values(request.headers, "x-lark-signature") + if len(timestamps) != 1 or len(nonces) != 1 or len(signatures) != 1: + return None + # The Provider SDK treats timestamp and nonce as opaque signature material. + # The verified digest is also the replay identity so ambiguous field boundaries + # cannot produce multiple claims for the same authenticated signature material. + signed = timestamps[0].encode() + nonces[0].encode() + encrypt_key.encode() + request.body + digest = hashlib.sha256(signed).digest() + if not hmac.compare_digest(signatures[0], digest.hex()): + return None + return digest + + +def _decrypt_webhook_payload(encrypted: str, encrypt_key: str) -> bytes: + encrypted_bytes = base64.b64decode(encrypted, validate=True) + block_size = algorithms.AES.block_size // 8 + if len(encrypted_bytes) < block_size * 2 or len(encrypted_bytes) % block_size: + raise ValueError("invalid encrypted Webhook payload") + iv = encrypted_bytes[:block_size] + decryptor = Cipher(algorithms.AES(hashlib.sha256(encrypt_key.encode()).digest()), modes.CBC(iv)).decryptor() + padded = decryptor.update(encrypted_bytes[block_size:]) + decryptor.finalize() + unpadder = symmetric_padding.PKCS7(algorithms.AES.block_size).unpadder() + return unpadder.update(padded) + unpadder.finalize() + + +def _decode_json_object(body: bytes) -> dict[str, object]: + decoded = json.loads(body) + if not isinstance(decoded, dict) or any(not isinstance(key, str) for key in decoded): + raise ValueError("Webhook payload is not a JSON object") + return decoded + + +def _webhook_occurred_at(create_time: str | None) -> datetime | None: + if create_time is None: + return None + try: + timestamp = int(create_time) + if len(create_time) == _MICROSECOND_TIMESTAMP_DIGITS: + units_per_second = _MICROSECONDS_PER_SECOND + elif len(create_time) == _MILLISECOND_TIMESTAMP_DIGITS: + units_per_second = _MILLISECONDS_PER_SECOND + else: + return None + seconds, subsecond_units = divmod(timestamp, units_per_second) + microseconds = subsecond_units * (_MICROSECONDS_PER_SECOND // units_per_second) + return datetime.fromtimestamp(seconds, tz=UTC).replace(microsecond=microseconds, tzinfo=None) + except (OSError, OverflowError, ValueError): + return None + + +def _webhook_response(status_code: int, body: Mapping[str, object]) -> WebhookResponse: + return WebhookResponse( + status_code=status_code, + headers=_JSON_RESPONSE_HEADERS, + body=json.dumps(body, ensure_ascii=False, separators=(",", ":")).encode(), + ) + + +def _header_values(headers: tuple[tuple[str, str], ...], target: str) -> tuple[str, ...]: + return tuple(value for name, value in headers if name.casefold() == target) + + +def _next_page_token(has_more: bool, page_token: str | None, seen_tokens: set[str]) -> str | None: + if not has_more: + return None + if page_token is None or not page_token.strip() or page_token in seen_tokens: + raise IMProviderError("invalid pagination") + seen_tokens.add(page_token) + return page_token + + +def _optional_string(value: str | None) -> str | None: + if value is None or not value.strip(): + return None + return value + + +def _department_identity(department: _DirectoryDepartment) -> _DepartmentIdentity | None: + department_id = _optional_string(department.department_id) + if department_id is not None: + return _DepartmentIdentity(department_id, "department_id") + open_department_id = _optional_string(department.open_department_id) + if open_department_id is not None: + return _DepartmentIdentity(open_department_id, "open_department_id") + return None + + +def _sdk_response_mapping(response: object) -> Mapping[str, object]: + serialized = lark.JSON.marshal(response) + if serialized is None: + raise IMProviderError("SDK response is empty") + decoded = json.loads(serialized) + if not isinstance(decoded, dict) or any(not isinstance(key, str) for key in decoded): + raise IMProviderError("SDK response is not a JSON object") + return decoded + + +def _sdk_event_mapping(event: object) -> Mapping[str, object]: + serialized = lark.JSON.marshal(event) + if serialized is None: + raise ValueError("SDK event is empty") + decoded = json.loads(serialized) + if not isinstance(decoded, dict) or any(not isinstance(key, str) for key in decoded): + raise ValueError("SDK event is not a JSON object") + return decoded + + +def _sdk_event_envelope(event: P2CardActionTrigger) -> _SDKEventEnvelope: + serialized = lark.JSON.marshal(event) + if serialized is None: + raise ValueError("SDK event is empty") + header = event.header + if header is None: + raise ValueError("SDK event header is empty") + provider_tenant_id = _optional_string(header.tenant_key) + if provider_tenant_id is None: + raise ValueError("SDK event tenant identifier is empty") + return _SDKEventEnvelope( + native_payload=serialized, + provider_tenant_id=provider_tenant_id, + event_id=_optional_string(header.event_id), + event_type=_optional_string(header.event_type), + occurred_at=_webhook_occurred_at(header.create_time), + ) + + +def _provider_name(provider: IMProvider) -> str: + return "Feishu" if provider is IMProvider.FEISHU else "Lark" diff --git a/api/core/human_input_v2/im_integration/adapters/message_locator.py b/api/core/human_input_v2/im_integration/adapters/message_locator.py new file mode 100644 index 00000000000..ce71d9fd912 --- /dev/null +++ b/api/core/human_input_v2/im_integration/adapters/message_locator.py @@ -0,0 +1,50 @@ +"""Canonical type and codec for provider-owned message locators.""" + +from __future__ import annotations + +import base64 +import re +from typing import NewType, Self + +from pydantic import BaseModel + +# Opaque, persistable locator for one exact provider message. +# +# Callers may store, compare, and return this value to a compatible adapter, +# but must not parse, alter, or synthesize it. +# +# The value is a plain, versioned serialization of provider-private locator +# facts. It may cross process boundaries and survive adapter recreation. +# Keep this value within a trusted application boundary; it must not cross +# a security boundary. +# "Opaque" constrains caller behavior; it does not imply encryption, signing, +# cryptographic authenticity, or authorization. +MessageLocator = NewType("MessageLocator", str) + +_URLSAFE_BASE64_PATTERN = re.compile(r"^[A-Za-z0-9_-]+$") + + +class _Base64JSONLocatorPayload(BaseModel): + """Base model giving each private payload one canonical string codec.""" + + def encode(self) -> MessageLocator: + serialized_payload = self.model_dump_json().encode("utf-8") + encoded_payload = base64.urlsafe_b64encode(serialized_payload).rstrip(b"=").decode("ascii") + return MessageLocator(encoded_payload) + + @classmethod + def decode(cls, value: MessageLocator | str) -> Self: + if not _URLSAFE_BASE64_PATTERN.fullmatch(value): + raise ValueError("locator must be unpadded URL-safe Base64") + if len(value) % 4 == 1: + raise ValueError("locator has invalid Base64 length") + padding = "=" * (-len(value) % 4) + serialized_payload = base64.b64decode( + value + padding, + altchars=b"-_", + validate=True, + ) + return cls.model_validate_json(serialized_payload) + + +__all__ = ["MessageLocator", "_Base64JSONLocatorPayload"] diff --git a/api/core/human_input_v2/im_integration/adapters/ms_teams.py b/api/core/human_input_v2/im_integration/adapters/ms_teams.py new file mode 100644 index 00000000000..1e87f22867b --- /dev/null +++ b/api/core/human_input_v2/im_integration/adapters/ms_teams.py @@ -0,0 +1,991 @@ +"""Concrete Microsoft Teams adapter for Provider-neutral IM contracts.""" + +from __future__ import annotations + +import asyncio +import binascii +import json +import logging +import re +from collections.abc import Mapping +from contextlib import closing +from dataclasses import dataclass +from datetime import UTC, datetime +from email.message import Message +from http import HTTPStatus +from string import punctuation +from typing import ClassVar, Literal, override +from urllib.parse import quote, urlsplit +from uuid import UUID + +import httpx +from azure.core.exceptions import ClientAuthenticationError, ServiceRequestTimeoutError, ServiceResponseTimeoutError +from azure.identity import ClientSecretCredential +from botbuilder.schema import ( + Activity, + Attachment, + ChannelAccount, + ConversationParameters, + ConversationResourceResponse, + ResourceResponse, +) +from botframework.connector import ConnectorClient +from botframework.connector.auth import ( + JwtTokenValidation, + MicrosoftAppCredentials, + SimpleCredentialProvider, +) +from msrest.exceptions import HttpOperationError +from pydantic import AwareDatetime, BaseModel, ConfigDict, Field, JsonValue, ValidationError, field_validator +from requests.exceptions import Timeout as RequestsTimeout + +from core.human_input import ButtonStyle +from core.human_input_v2.entities import IMProvider +from core.human_input_v2.im_integration.adapters.avatar import AvatarReadError, read_avatars +from core.human_input_v2.im_integration.adapters.credentials import MSTeamsCredentials +from core.human_input_v2.im_integration.adapters.entities import ( + AuthenticatedIMEvent, + CardAssessment, + CorrelationToken, + CredentialTestFailure, + CredentialTestFailureKind, + CredentialTestSuccess, + CredentialTestTimeoutError, + Directory, + DirectoryEntry, + DirectoryReadFailure, + DynamicCardMessagingError, + EventAcceptance, + IMCardEvent, + IMCardEventDecodeResult, + IMCardEventDecodingError, + IMEventIngressKind, + IMProviderError, + MessageAccepted, + MessageSendingError, + MessageSendingResult, + ProviderUserId, + ReplacementError, + ReplacementErrorKind, + StaticCardIntent, + UnrecognizedIMEvent, + WebhookRequest, + WebhookResponse, +) +from core.human_input_v2.im_integration.adapters.message_locator import MessageLocator, _Base64JSONLocatorPayload +from core.human_input_v2.im_integration.adapters.protocols import ( + IMCardEventDecoder, + IMDirectory, + IMDynamicCardMessaging, + IMEventConsumer, + IMEventStream, + IMMessaging, + IMWebhookHandler, +) +from core.human_input_v2.resolved_form import ( + FileInput, + FileListInput, + MarkdownFragment, + ParagraphInput, + ResolvedForm, + SelectInput, +) + +logger = logging.getLogger(__name__) + +_HTTP_TIMEOUT_SECONDS = 10.0 +_GRAPH_SCOPE = "https://graph.microsoft.com/.default" +_GRAPH_USERS_URL = "https://graph.microsoft.com/v1.0/users" +_GRAPH_DIRECTORY_PAGE_SIZE = 999 +_GRAPH_CREDENTIAL_TEST_PAGE_SIZE = 1 +_PUBLIC_TEAMS_SERVICE_URL = "https://smba.trafficmanager.net/teams/" +_JSON_CONTENT_TYPE = "application/json" +_EXTENDED_MARKDOWN_TEXT_FORMAT = "extendedmarkdown" +_TEAMS_BOT_CHANNEL_ID_PREFIX = "28:" +_BOT_TOKEN_REJECTION_PREFIX = "Failed to get access token with error: " +_OAUTH_CREDENTIAL_REJECTION_CODES = frozenset(("invalid_client", "invalid_grant", "unauthorized_client")) + + +class _GraphError(BaseModel): + model_config = ConfigDict(extra="ignore", strict=True) + + code: str | None = None + + +class _GraphErrorResponse(BaseModel): + model_config = ConfigDict(extra="ignore", strict=True) + + error: _GraphError | None = None + + +class _GraphUser(BaseModel): + model_config = ConfigDict(extra="ignore", frozen=True) + + id: str = Field(min_length=1) + display_name: str | None = Field(default=None, alias="displayName") + mail: str | None = None + + @field_validator("display_name", "mail", mode="before") + @classmethod + def normalize_optional_text(cls, value: object) -> object: + if isinstance(value, str) and not value.strip(): + return None + return value + + +class _GraphUsersPage(BaseModel): + model_config = ConfigDict(extra="ignore", frozen=True) + + users: tuple[_GraphUser, ...] = Field(alias="value") + next_link: str | None = Field(default=None, alias="@odata.nextLink") + + +class _TeamsConversation(BaseModel): + model_config = ConfigDict(extra="ignore", frozen=True) + + tenant_id: str | None = Field(default=None, alias="tenantId") + + +class _TeamsTenant(BaseModel): + model_config = ConfigDict(extra="ignore", frozen=True) + + id: str + + +class _TeamsChannelData(BaseModel): + model_config = ConfigDict(extra="ignore", frozen=True) + + tenant: _TeamsTenant | None = None + + +class _TeamsInboundActivity(BaseModel): + model_config = ConfigDict(extra="ignore", frozen=True) + + type: str = Field(min_length=1) + id: str | None = None + timestamp: AwareDatetime | None = None + service_url: str = Field(alias="serviceUrl", min_length=1) + channel_id: Literal["msteams"] = Field(alias="channelId") + conversation: _TeamsConversation + channel_data: _TeamsChannelData = Field(alias="channelData") + + @field_validator("id", mode="before") + @classmethod + def normalize_optional_identifier(cls, value: object) -> object: + if isinstance(value, str) and not value.strip(): + return None + return value + + +class _MSTeamsLocatorPayload(_Base64JSONLocatorPayload): + model_config = ConfigDict(extra="forbid", frozen=True, strict=True) + + # version of the locator + v: Literal[1] + # provider of the locator + p: Literal[IMProvider.MS_TEAMS] + # Bot Framework service endpoint used for subsequent message operations: + # https://learn.microsoft.com/en-us/azure/bot-service/rest-api/bot-framework-rest-connector-api-reference?view=azure-bot-service-4.0 + service_url: str = Field(min_length=1) + # Bot Framework conversation containing the activity: + # https://learn.microsoft.com/en-us/azure/bot-service/rest-api/bot-framework-rest-connector-api-reference?view=azure-bot-service-4.0 + conversation_id: str = Field(min_length=1, pattern=r"\S") + # Bot Framework activity identifier of the exact message: + # https://learn.microsoft.com/en-us/azure/bot-service/rest-api/bot-framework-rest-connector-api-reference?view=azure-bot-service-4.0 + activity_id: str = Field(min_length=1, pattern=r"\S") + + @field_validator("service_url") + @classmethod + def _require_trusted_service_url(cls, value: str) -> str: + if not _trusted_teams_service_url(value): + raise ValueError("service_url must be a trusted Teams service endpoint") + return value + + +class _MSTeamsBotCredentials(MicrosoftAppCredentials): + """Concrete credentials wrapper kept private to this adapter module.""" + + +def _reject_non_standard_json_constant(value: str) -> object: + del value + raise ValueError("non-standard JSON constant") + + +def _canonical_guid(value: str | None) -> str | None: + if value is None: + return None + try: + return str(UUID(value)) + except ValueError: + return None + + +def _permission_error_is_credential_rejection(error: PermissionError) -> bool: + error_text = str(error).strip().casefold() + sdk_error_marker = "error:" + if sdk_error_marker in error_text: + error_text = error_text.split(sdk_error_marker, maxsplit=1)[1].lstrip() + error_code = error_text.split(",", maxsplit=1)[0].split(":", maxsplit=1)[0].strip() + return error_code in _OAUTH_CREDENTIAL_REJECTION_CODES + + +def _microsoft_error_code(error: ClientAuthenticationError | PermissionError) -> str | None: + # Azure Identity and Bot Framework expose OAuth failures as text. Extract + # only the provider's identifier; descriptions may echo submitted secrets. + error_text = str(error) + aadsts_code = re.search(r"\bAADSTS[0-9]+\b", error_text) + if aadsts_code is not None: + return aadsts_code.group() + if error_text.startswith(_BOT_TOKEN_REJECTION_PREFIX): + oauth_code = error_text.removeprefix(_BOT_TOKEN_REJECTION_PREFIX).split(", error_description:", maxsplit=1)[0] + return oauth_code if oauth_code != "Unknown error" else None + return None + + +def _log_safe_error(message: str) -> None: + logger.error(message) + + +def _trusted_graph_users_url(url: str) -> bool: + parsed = urlsplit(url) + return parsed.scheme == "https" and parsed.hostname == "graph.microsoft.com" and parsed.path == "/v1.0/users" + + +def _trusted_teams_service_url(url: object) -> bool: + if not isinstance(url, str): + return False + parsed = urlsplit(url) + return parsed.scheme == "https" and parsed.hostname == "smba.trafficmanager.net" + + +def _non_empty_string(value: object) -> bool: + return isinstance(value, str) and bool(value.strip()) + + +def _header_values(headers: tuple[tuple[str, str], ...], target_name: str) -> tuple[str, ...]: + normalized_target = target_name.casefold() + return tuple(value for name, value in headers if name.casefold() == normalized_target) + + +def _content_type(headers: tuple[tuple[str, str], ...]) -> str | None: + values = _header_values(headers, "content-type") + if len(values) != 1: + return None + parsed = Message() + parsed["Content-Type"] = values[0] + return parsed.get_content_type() + + +def _webhook_response(status_code: int, body: bytes) -> WebhookResponse: + return WebhookResponse( + status_code=status_code, + headers=(("Content-Type", "text/plain; charset=utf-8"),), + body=body, + ) + + +def _mutation_connector(credentials: MicrosoftAppCredentials, base_url: str) -> ConnectorClient: + client = ConnectorClient(credentials, base_url=base_url) + # The SDK retries every HTTP verb by default. Message mutations must retain + # their contract's at-most-once boundary even when acceptance is uncertain. + client.config.retry_policy.retries = 0 + return client + + +def _card_summary(intent: ResolvedForm) -> str: + if intent.title: + return intent.title + for block in intent.parts: + if isinstance(block, MarkdownFragment) and block.text.strip(): + return block.text + return "Human input form" + + +@dataclass(frozen=True, slots=True) +class _MSTeamsCardCodec(IMCardEventDecoder): + """Own the credential-free Microsoft Teams card wire contract.""" + + _CONTENT_TYPE = "application/vnd.microsoft.card.adaptive" + _SCHEMA_URL = "http://adaptivecards.io/schemas/adaptive-card.json" + _CARD_VERSION = "1.5" + _MAX_CARD_SIZE_BYTES = 28 * 1024 + _SUPPORTED_ACTION_STYLES = frozenset((ButtonStyle.DEFAULT, ButtonStyle.PRIMARY, ButtonStyle.ACCENT)) + _METADATA_MEMBER = "__dify.human_input" + _CALLBACK_SCHEMA_VERSION: ClassVar[Literal[1]] = 1 + _APPLICABLE_ACTIVITY_TYPES = frozenset(("invoke", "message")) + + class _CallbackModel(BaseModel): + model_config = ConfigDict( + allow_inf_nan=False, + extra="ignore", + frozen=True, + strict=True, + ) + + class _CallbackActor(_CallbackModel): + # Graph directory users use the Entra object ID, not the Bot Framework ID. + aad_object_id: UUID = Field(alias="aadObjectId", strict=False) + + class _ButtonMetadata(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True, strict=True) + + version: Literal[1] + action_id: str = Field(min_length=1) + correlation_token: str + + class _SubmissionActivity(_CallbackModel): + type: Literal["invoke", "message"] + actor: _MSTeamsCardCodec._CallbackActor = Field(alias="from") + value: dict[str, JsonValue] + + def assess(self, intent: ResolvedForm) -> CardAssessment: + reason = self._unrepresentable_reason(intent) + if reason is not None: + return CardAssessment(False, reason) + return CardAssessment(True) + + def encode( + self, + intent: ResolvedForm, + correlation_token: CorrelationToken, + ) -> Mapping[str, JsonValue]: + reason = self._unrepresentable_reason(intent) + if reason is not None: + raise DynamicCardMessagingError(reason) + card = self._render_card(intent, correlation_token) + if self._serialized_card_size(card) > self._MAX_CARD_SIZE_BYTES: + raise DynamicCardMessagingError("Microsoft Teams cannot preserve a card beyond the Provider payload limit.") + return card + + @override + def decode(self, event: AuthenticatedIMEvent) -> IMCardEventDecodeResult: + if event.provider is not IMProvider.MS_TEAMS or event.event_type not in self._APPLICABLE_ACTIVITY_TYPES: + return UnrecognizedIMEvent() + callback = self._decode_json_object(event.payload) + if callback is None: + raise IMCardEventDecodingError("Microsoft Teams card event payload is invalid.") + + callback_value = callback.get("value") + if not isinstance(callback_value, dict): + return UnrecognizedIMEvent() + if self._METADATA_MEMBER not in callback_value: + return UnrecognizedIMEvent() + + validated_submission = self._validate_submission(callback) + if validated_submission is None: + raise IMCardEventDecodingError("Microsoft Teams card event schema is invalid.") + submission, metadata = validated_submission + if submission.type != event.event_type: + raise IMCardEventDecodingError("Microsoft Teams card event schema is invalid.") + inputs = dict(submission.value) + inputs.pop(self._METADATA_MEMBER) + return IMCardEvent( + provider_user_id=ProviderUserId(str(submission.actor.aad_object_id)), + action_id=metadata.action_id, + inputs=inputs, + correlation_token=CorrelationToken(metadata.correlation_token), + ) + + @classmethod + def _decode_json_object(cls, serialized_callback: str) -> dict[str, object] | None: + try: + decoded_callback = json.loads( + serialized_callback, + parse_constant=_reject_non_standard_json_constant, + ) + except (json.JSONDecodeError, ValueError, RecursionError): + return None + if not isinstance(decoded_callback, dict) or any(not isinstance(key, str) for key in decoded_callback): + return None + return decoded_callback + + @classmethod + def _validate_submission( + cls, + callback: dict[str, object], + ) -> tuple[_SubmissionActivity, _ButtonMetadata] | None: + try: + submission = cls._SubmissionActivity.model_validate(callback) + metadata = cls._ButtonMetadata.model_validate(submission.value[cls._METADATA_MEMBER]) + except (KeyError, ValidationError): + return None + return submission, metadata + + @classmethod + def _unrepresentable_reason(cls, intent: ResolvedForm) -> str | None: + if not intent.parts and not intent.actions: + return "Microsoft Teams cannot preserve an empty card." + + input_names: set[str] = set() + for block in intent.parts: + match block: + case MarkdownFragment(text=text): + if not text: + return "Microsoft Teams cannot preserve an empty Markdown block." + continue + case FileInput() | FileListInput(): + return "Microsoft Teams cards cannot represent file inputs." + case ParagraphInput(output_variable_name=input_name): + pass + case SelectInput(output_variable_name=input_name, options=options, default_value=default_value): + if not options or any(not option for option in options): + return "Microsoft Teams cannot preserve one select input's options." + if len(options) != len(set(options)): + return "Microsoft Teams cannot preserve duplicate select options." + if default_value is not None and default_value not in options: + return "Microsoft Teams cannot preserve one select input default." + if input_name in input_names: + return "Microsoft Teams cannot preserve duplicate card input identifiers." + if input_name == cls._METADATA_MEMBER: + return "Microsoft Teams card input identifier is reserved." + input_names.add(input_name) + + if any(action.button_style not in cls._SUPPORTED_ACTION_STYLES for action in intent.actions): + return "Microsoft Teams cannot preserve one card action style." + assessment_card = cls._render_card(intent, CorrelationToken("")) + if cls._serialized_card_size(assessment_card) > cls._MAX_CARD_SIZE_BYTES: + return "Microsoft Teams cannot preserve a card beyond the Provider payload limit." + return None + + @classmethod + def _render_card( + cls, + intent: ResolvedForm, + correlation_token: CorrelationToken, + ) -> dict[str, JsonValue]: + body: list[JsonValue] = [] + if intent.title: + body.append( + { + "type": "TextBlock", + "text": intent.title, + "wrap": True, + "size": "Medium", + "weight": "Bolder", + } + ) + for block in intent.parts: + if isinstance(block, MarkdownFragment): + body.append({"type": "TextBlock", "text": block.text, "wrap": True}) + continue + if isinstance(block, ParagraphInput): + text_input: dict[str, JsonValue] = { + "type": "Input.Text", + "id": block.output_variable_name, + "label": block.output_variable_name, + "isMultiline": True, + } + if block.default_value is not None: + text_input["value"] = block.default_value + body.append(text_input) + continue + if isinstance(block, SelectInput): + choice_input: dict[str, JsonValue] = { + "type": "Input.ChoiceSet", + "id": block.output_variable_name, + "label": block.output_variable_name, + "choices": [{"title": option, "value": option} for option in block.options], + "style": "compact", + } + if block.default_value is not None: + choice_input["value"] = block.default_value + body.append(choice_input) + continue + raise DynamicCardMessagingError("Microsoft Teams cards cannot represent file inputs.") + + actions: list[JsonValue] = [] + for action in intent.actions: + metadata = cls._ButtonMetadata( + version=cls._CALLBACK_SCHEMA_VERSION, + action_id=action.id, + correlation_token=str(correlation_token), + ) + actions.append( + { + "type": "Action.Submit", + "title": action.title, + "data": {cls._METADATA_MEMBER: metadata.model_dump(mode="json")}, + } + ) + return { + "$schema": cls._SCHEMA_URL, + "type": "AdaptiveCard", + "version": cls._CARD_VERSION, + "body": body, + "actions": actions, + } + + @staticmethod + def _serialized_card_size(card: Mapping[str, JsonValue]) -> int: + return len( + json.dumps( + card, + ensure_ascii=False, + allow_nan=False, + separators=(",", ":"), + ).encode() + ) + + +class _MSTeamsDirectory(IMDirectory): + def __init__(self, credential: ClientSecretCredential, client: httpx.Client) -> None: + self._credential = credential + self._client = client + + @override + def read_directory(self) -> Directory | DirectoryReadFailure: + try: + access_token = self._credential.get_token(_GRAPH_SCOPE).token + return self._read_all_pages(access_token) + except (AvatarReadError, ClientAuthenticationError, httpx.HTTPError, ValidationError, ValueError): + return DirectoryReadFailure("Microsoft Teams directory could not be read completely.") + except Exception: + _log_safe_error("Unexpected Microsoft Teams directory failure") + return DirectoryReadFailure("Microsoft Teams directory could not be read completely.") + + def _read_all_pages(self, access_token: str) -> Directory: + entries: list[DirectoryEntry] = [] + next_url: str | None = None + seen_urls: set[str] = set() + headers = {"Authorization": f"Bearer {access_token}"} + while True: + if next_url is None: + response = self._client.get( + _GRAPH_USERS_URL, + headers=headers, + params={ + "$select": "id,displayName,mail", + "$top": _GRAPH_DIRECTORY_PAGE_SIZE, + }, + ) + else: + response = self._client.get(next_url, headers=headers) + response.raise_for_status() + page = _GraphUsersPage.model_validate(response.json()) + avatars = read_avatars( + [f"{_GRAPH_USERS_URL}/{quote(user.id, safe='')}/photo/$value" for user in page.users], + headers=headers, + ) + entries.extend( + DirectoryEntry( + provider_user_id=ProviderUserId(user.id), + display_name=user.display_name, + email=user.mail, + avatar=avatar, + ) + for user, avatar in zip(page.users, avatars, strict=True) + ) + if page.next_link is None: + return Directory(tuple(entries)) + if not _trusted_graph_users_url(page.next_link) or page.next_link in seen_urls: + raise ValueError("invalid Microsoft Graph pagination") + seen_urls.add(page.next_link) + next_url = page.next_link + + +class _MSTeamsMessaging(IMMessaging): + def __init__(self, credentials: MicrosoftAppCredentials, tenant_id: str, client_id: str) -> None: + self._credentials = credentials + self._tenant_id = tenant_id + self._client_id = client_id + + @override + def send_text( + self, provider_user_id: ProviderUserId, body: str, subject: str | None = None + ) -> MessageSendingResult: + if subject: + # Keep subject content literal inside the Markdown heading. + escaped_subject = "".join( + f"\\{character}" if character in punctuation else character for character in subject + ) + body = f"# {escaped_subject}\n\n{body}" + # Teams' default Markdown renderer does not support headings. + return self._send_activity( + provider_user_id, + Activity(type="message", text=body, text_format=_EXTENDED_MARKDOWN_TEXT_FORMAT), + ) + + def _send_activity( + self, + provider_user_id: ProviderUserId, + activity: Activity, + ) -> MessageSendingResult: + try: + conversation_client = _mutation_connector(self._credentials, _PUBLIC_TEAMS_SERVICE_URL) + conversation = conversation_client.conversations.create_conversation( + ConversationParameters( + is_group=False, + bot=ChannelAccount(id=f"{_TEAMS_BOT_CHANNEL_ID_PREFIX}{self._client_id}"), + members=[ChannelAccount(id=str(provider_user_id))], + tenant_id=self._tenant_id, + ) + ) + if not isinstance(conversation, ConversationResourceResponse): + return MessageSendingError("Microsoft Teams returned no usable personal conversation reference.") + conversation_id = conversation.id + service_url = conversation.service_url or _PUBLIC_TEAMS_SERVICE_URL + if not _non_empty_string(conversation_id) or not _trusted_teams_service_url(service_url): + return MessageSendingError("Microsoft Teams returned no usable personal conversation reference.") + message_client = _mutation_connector(self._credentials, service_url) + response = message_client.conversations.send_to_conversation(conversation_id, activity) + if not isinstance(response, ResourceResponse): + return MessageSendingError("Microsoft Teams returned no exact message reference.") + activity_id = response.id + if not _non_empty_string(activity_id): + return MessageSendingError("Microsoft Teams returned no exact message reference.") + return MessageAccepted( + MessageLocator( + _MSTeamsLocatorPayload( + v=1, + p=IMProvider.MS_TEAMS, + service_url=service_url, + conversation_id=conversation_id, + activity_id=activity_id, + ).encode() + ) + ) + except Exception: + _log_safe_error("Unexpected Microsoft Teams message creation failure") + return MessageSendingError("Microsoft Teams message acceptance could not be confirmed.") + + +class _MSTeamsDynamicCardMessaging(_MSTeamsMessaging, IMDynamicCardMessaging): + def __init__( + self, + credentials: MicrosoftAppCredentials, + tenant_id: str, + client_id: str, + codec: _MSTeamsCardCodec, + ) -> None: + super().__init__(credentials, tenant_id, client_id) + self._codec = codec + + @override + def assess(self, intent: ResolvedForm) -> CardAssessment: + return self._codec.assess(intent) + + @override + def send_card( + self, + provider_user_id: ProviderUserId, + intent: ResolvedForm, + correlation_token: CorrelationToken, + ) -> MessageSendingResult: + content = self._codec.encode(intent, correlation_token) + activity = Activity( + type="message", + summary=_card_summary(intent), + attachments=[Attachment(content_type=self._codec._CONTENT_TYPE, content=dict(content))], + ) + return self._send_activity(provider_user_id, activity) + + @override + def replace_with_static( + self, + locator: MessageLocator, + intent: StaticCardIntent, + ) -> ReplacementError | None: + decoded_locator = self._compatible_card_locator(locator) + if decoded_locator is None: + return ReplacementError( + ReplacementErrorKind.INVALID_REFERENCE, + "The Microsoft Teams message reference is invalid.", + ) + try: + client = _mutation_connector(self._credentials, decoded_locator.service_url) + client.conversations.update_activity( + decoded_locator.conversation_id, + decoded_locator.activity_id, + Activity(type="message", text=intent.rendered_content), + ) + except HttpOperationError as error: + if error.response.status_code in {404, 410}: + return ReplacementError( + ReplacementErrorKind.STALE_REFERENCE, + "The referenced Microsoft Teams card is no longer replaceable.", + ) + return ReplacementError( + ReplacementErrorKind.UNKNOWN, + "Microsoft Teams replacement acceptance is unknown.", + ) + except Exception: + _log_safe_error("Unexpected Microsoft Teams card replacement failure") + return ReplacementError( + ReplacementErrorKind.UNKNOWN, + "Microsoft Teams replacement acceptance is unknown.", + ) + return None + + def _compatible_card_locator(self, locator: MessageLocator) -> _MSTeamsLocatorPayload | None: + try: + return _MSTeamsLocatorPayload.decode(str(locator)) + except (binascii.Error, UnicodeDecodeError, ValueError, ValidationError): + return None + + +class _MSTeamsWebhookHandler(IMWebhookHandler): + def __init__( + self, + *, + tenant_id: str, + client_id: str, + client_secret: str, + consumer: IMEventConsumer, + ) -> None: + self._tenant_id = tenant_id + self._credential_provider = SimpleCredentialProvider(client_id, client_secret) + self._consumer = consumer + + @override + def handle(self, request: WebhookRequest) -> WebhookResponse: + if request.method != "POST": + return _webhook_response(405, b"method not allowed") + if _content_type(request.headers) != _JSON_CONTENT_TYPE: + return _webhook_response(415, b"unsupported content type") + authorization_values = _header_values(request.headers, "authorization") + if len(authorization_values) != 1: + return _webhook_response(401, b"request authentication failed") + + decoded_body = self._decoded_body(request.body) + if decoded_body is None: + return _webhook_response(400, b"invalid Microsoft Teams activity") + try: + activity = _TeamsInboundActivity.model_validate(decoded_body) + except ValidationError: + return _webhook_response(400, b"invalid Microsoft Teams activity") + if not _trusted_teams_service_url(activity.service_url): + return _webhook_response(400, b"invalid Microsoft Teams activity") + if not self._authenticated(activity, authorization_values[0]): + return _webhook_response(401, b"request authentication failed") + if not self._configured_tenant(activity): + return _webhook_response(403, b"Microsoft Teams tenant mismatch") + + event = AuthenticatedIMEvent( + provider=IMProvider.MS_TEAMS, + provider_tenant_id=self._tenant_id, + event_id=activity.id, + event_type=activity.type, + occurred_at=self._occurred_at(activity), + received_at=request.received_at, + ingress_kind=IMEventIngressKind.WEBHOOK, + payload=json.dumps(decoded_body, ensure_ascii=False, allow_nan=False, separators=(",", ":")), + ) + try: + acceptance = self._consumer.accept(event, request_ip_address=request.request_ip_address) + except Exception: + _log_safe_error("Unexpected Microsoft Teams Webhook consumer failure") + return _webhook_response(503, b"event processing failed") + if acceptance is EventAcceptance.ACCEPTED: + return _webhook_response(200, b"") + return _webhook_response(503, b"event not accepted") + + @staticmethod + def _decoded_body(body: bytes) -> dict[str, object] | None: + try: + decoded = json.loads(body, parse_constant=_reject_non_standard_json_constant) + except (UnicodeDecodeError, ValueError): + return None + if not isinstance(decoded, dict) or any(not isinstance(key, str) for key in decoded): + return None + return decoded + + def _authenticated(self, activity: _TeamsInboundActivity, authorization: str) -> bool: + sdk_activity = Activity( + type=activity.type, + channel_id=activity.channel_id, + service_url=activity.service_url, + ) + try: + identity = asyncio.run( + JwtTokenValidation.authenticate_request( + sdk_activity, + authorization, + self._credential_provider, + "", + ) + ) + except Exception: + _log_safe_error("Microsoft Teams Webhook authentication failed") + return False + return identity.is_authenticated + + def _configured_tenant(self, activity: _TeamsInboundActivity) -> bool: + tenant_ids = { + _canonical_guid(tenant_id) + for tenant_id in ( + activity.conversation.tenant_id, + activity.channel_data.tenant.id if activity.channel_data.tenant is not None else None, + ) + if tenant_id is not None + } + configured_tenant_id = _canonical_guid(self._tenant_id) + return configured_tenant_id is not None and None not in tenant_ids and tenant_ids == {configured_tenant_id} + + @staticmethod + def _occurred_at(activity: _TeamsInboundActivity) -> datetime | None: + if activity.timestamp is None: + return None + return activity.timestamp.astimezone(UTC).replace(tzinfo=None) + + +class MSTeamsIMProviderAdapter: + """Externally serialized Microsoft Teams capability composition root.""" + + @classmethod + def card_event_decoder(cls) -> IMCardEventDecoder: + """Return a credential-free decoder independent from root adapter instances.""" + return _MSTeamsCardCodec() + + def __init__(self, credentials: MSTeamsCredentials) -> None: + if not isinstance(credentials, MSTeamsCredentials): + raise TypeError("Microsoft Teams adapter requires resolved Microsoft Teams credentials") + self._credentials = credentials + self._graph_credential = ClientSecretCredential( + credentials.tenant_id, + credentials.client_id, + credentials.client_secret, + ) + self._graph_client = httpx.Client(timeout=_HTTP_TIMEOUT_SECONDS) + self._bot_credentials = _MSTeamsBotCredentials( + credentials.client_id, + credentials.client_secret, + channel_auth_tenant=credentials.tenant_id, + ) + self._directory = _MSTeamsDirectory(self._graph_credential, self._graph_client) + self._messaging = _MSTeamsMessaging( + self._bot_credentials, + credentials.tenant_id, + credentials.client_id, + ) + self._dynamic_card_messaging = _MSTeamsDynamicCardMessaging( + self._bot_credentials, + credentials.tenant_id, + credentials.client_id, + _MSTeamsCardCodec(), + ) + + @property + def provider(self) -> IMProvider: + return IMProvider.MS_TEAMS + + def test_credentials(self) -> CredentialTestSuccess | CredentialTestFailure: + try: + return self._test_credentials() + except ( + TimeoutError, + httpx.TimeoutException, + RequestsTimeout, + ServiceRequestTimeoutError, + ServiceResponseTimeoutError, + ) as error: + raise CredentialTestTimeoutError() from error + + def _test_credentials(self) -> CredentialTestSuccess | CredentialTestFailure: + # Azure Identity and Bot Framework cache tokens within their credential + # instances. Each test must authenticate again, even on the same adapter. + with closing( + ClientSecretCredential( + self._credentials.tenant_id, + self._credentials.client_id, + self._credentials.client_secret, + ) + ) as graph_credential: + return self._verify_credentials(graph_credential) + + def _verify_credentials( + self, graph_credential: ClientSecretCredential + ) -> CredentialTestSuccess | CredentialTestFailure: + try: + graph_token = graph_credential.get_token(_GRAPH_SCOPE).token + except ClientAuthenticationError as error: + provider_error_code = _microsoft_error_code(error) + if provider_error_code is None and error.response is None: + raise + return CredentialTestFailure( + CredentialTestFailureKind.AUTHENTICATION_REJECTED, + "Microsoft rejected the Graph credentials.", + provider_error_code=provider_error_code, + http_status=error.status_code, + ) + + graph_response = self._graph_client.get( + _GRAPH_USERS_URL, + headers={"Authorization": f"Bearer {graph_token}"}, + params={"$select": "id,displayName,mail", "$top": _GRAPH_CREDENTIAL_TEST_PAGE_SIZE}, + ) + if not graph_response.is_success: + try: + rejection = _GraphErrorResponse.model_validate(graph_response.json()) + except (json.JSONDecodeError, ValidationError): + provider_error_code = None + else: + provider_error_code = rejection.error.code if rejection.error is not None else None + kind = CredentialTestFailureKind.UNKNOWN + if graph_response.status_code == HTTPStatus.UNAUTHORIZED: + kind = CredentialTestFailureKind.AUTHENTICATION_REJECTED + elif graph_response.status_code == HTTPStatus.FORBIDDEN: + kind = CredentialTestFailureKind.PERMISSION_DENIED + return CredentialTestFailure( + kind, + "Microsoft Graph rejected the directory access test.", + provider_error_code=provider_error_code, + http_status=graph_response.status_code, + ) + _GraphUsersPage.model_validate(graph_response.json()) + # Teams has no conversation-free Connector read probe. Test its tenant-scoped + # Bot Framework credentials through the SDK without sending a message. + try: + bot_credentials = _MSTeamsBotCredentials( + self._credentials.client_id, + self._credentials.client_secret, + channel_auth_tenant=self._credentials.tenant_id, + ) + bot_token = bot_credentials.get_access_token() + except PermissionError as error: + if not str(error).startswith(_BOT_TOKEN_REJECTION_PREFIX): + raise + kind = ( + CredentialTestFailureKind.AUTHENTICATION_REJECTED + if _permission_error_is_credential_rejection(error) + else CredentialTestFailureKind.UNKNOWN + ) + return CredentialTestFailure( + kind, + "Microsoft rejected the Bot Framework credentials.", + provider_error_code=_microsoft_error_code(error), + ) + + if not bot_token: + raise IMProviderError("Microsoft Bot Framework did not provide an access token.") + return CredentialTestSuccess(IMProvider.MS_TEAMS, str(UUID(self._credentials.tenant_id))) + + @property + def directory(self) -> IMDirectory: + return self._directory + + @property + def messaging(self) -> IMMessaging: + return self._messaging + + @property + def dynamic_card_messaging(self) -> IMDynamicCardMessaging: + return self._dynamic_card_messaging + + def create_webhook_handler(self, consumer: IMEventConsumer) -> IMWebhookHandler: + return _MSTeamsWebhookHandler( + tenant_id=self._credentials.tenant_id, + client_id=self._credentials.client_id, + client_secret=self._credentials.client_secret, + consumer=consumer, + ) + + def create_stream_handler(self, consumer: IMEventConsumer) -> IMEventStream | None: + del consumer + return None + + def close(self) -> None: + self._graph_client.close() + self._graph_credential.close() + + +__all__ = ["MSTeamsIMProviderAdapter"] diff --git a/api/core/human_input_v2/im_integration/adapters/protocols.py b/api/core/human_input_v2/im_integration/adapters/protocols.py new file mode 100644 index 00000000000..f53fccc68a9 --- /dev/null +++ b/api/core/human_input_v2/im_integration/adapters/protocols.py @@ -0,0 +1,164 @@ +"""Capability protocols implemented by IM provider adapters.""" + +from __future__ import annotations + +from typing import Protocol + +from core.human_input_v2.entities import IMProvider +from core.human_input_v2.resolved_form import ResolvedForm + +from .entities import ( + AuthenticatedIMEvent, + CardAssessment, + CorrelationToken, + CredentialTestFailure, + CredentialTestSuccess, + Directory, + DirectoryReadFailure, + EventAcceptance, + IMCardEventDecodeResult, + MessageSendingResult, + ProviderUserId, + ReplacementError, + StaticCardIntent, + WebhookRequest, + WebhookResponse, +) +from .message_locator import MessageLocator + + +class IMDirectory(Protocol): + """Adapter-bound directory capability.""" + + def read_directory(self) -> Directory | DirectoryReadFailure: + """Return identities with downloaded avatars, or fail without partial entries. + + Provider authentication and image retrieval finish before returning. + Missing avatars and HTTP 404 are None; other avatar errors fail the snapshot. + """ + ... + + +class IMMessaging(Protocol): + """Adapter-bound personal text messaging capability.""" + + def send_text( + self, provider_user_id: ProviderUserId, body: str, subject: str | None = None + ) -> MessageSendingResult: + """Send a CommonMark body with an optional plain-text subject, without replay. + + Each adapter owns subject presentation and provider-specific formatting. + """ + ... + + +class IMDynamicCardMessaging(Protocol): + """Adapter-bound complete dynamic card capability.""" + + def assess(self, intent: ResolvedForm) -> CardAssessment: + """Assess every intent fact without provider I/O or side effects.""" + ... + + def send_card( + self, + provider_user_id: ProviderUserId, + intent: ResolvedForm, + correlation_token: CorrelationToken, + ) -> MessageSendingResult: + """Attempt one complete card creation and preserve callback identity.""" + ... + + def replace_with_static( + self, + locator: MessageLocator, + intent: StaticCardIntent, + ) -> ReplacementError | None: + """Replace only the exact compatible referenced card once.""" + ... + + +class IMCardEventDecoder(Protocol): + """Credential-free and thread-safe provider card callback decoder.""" + + def decode(self, event: AuthenticatedIMEvent) -> IMCardEventDecodeResult: + """Normalize one authenticated event without provider or persistence I/O.""" + ... + + +class IMEventConsumer(Protocol): + """Thread-safe consumer of authenticated provider events.""" + + def accept(self, event: AuthenticatedIMEvent, *, request_ip_address: str | None = None) -> EventAcceptance: + """Accept only after processing or taking responsibility for processing.""" + ... + + +class IMWebhookHandler(Protocol): + """Thread-safe framework-neutral provider webhook handler.""" + + def handle(self, request: WebhookRequest) -> WebhookResponse: + """Handle provider URL verification or authenticate and consume an event. + + URL verification returns the provider response without invoking the consumer. + """ + ... + + +class IMEventStream(Protocol): + """Owner-managed inbound event resource with a one-shot lifecycle.""" + + def start(self) -> None: + """Synchronously initialize and start receiving events.""" + ... + + def stop(self) -> None: + """Synchronously drain accepted events and release owned resources.""" + ... + + +class IMProviderAdapter(Protocol): + """Externally serialized provider-bound capability composition root.""" + + @classmethod + def card_event_decoder(cls) -> IMCardEventDecoder | None: + """Return a stateless decoder independent from credentials and root lifecycles.""" + return None + + @property + def provider(self) -> IMProvider: ... + + def test_credentials(self) -> CredentialTestSuccess | CredentialTestFailure: + """Authenticate submitted credentials without reusing previously acquired tokens. + + Execution errors propagate, with timeouts classified separately. + """ + ... + + @property + def directory(self) -> IMDirectory: ... + + @property + def messaging(self) -> IMMessaging: ... + + @property + def dynamic_card_messaging(self) -> IMDynamicCardMessaging | None: ... + + def create_webhook_handler(self, consumer: IMEventConsumer) -> IMWebhookHandler | None: ... + + def create_stream_handler(self, consumer: IMEventConsumer) -> IMEventStream | None: ... + + def close(self) -> None: + """Idempotently end the root lifecycle without invalidating transports.""" + ... + + +__all__ = [ + "IMCardEventDecoder", + "IMDirectory", + "IMDynamicCardMessaging", + "IMEventConsumer", + "IMEventStream", + "IMMessaging", + "IMProviderAdapter", + "IMWebhookHandler", +] diff --git a/api/core/human_input_v2/im_integration/adapters/slack.py b/api/core/human_input_v2/im_integration/adapters/slack.py new file mode 100644 index 00000000000..22ce55852dc --- /dev/null +++ b/api/core/human_input_v2/im_integration/adapters/slack.py @@ -0,0 +1,1405 @@ +"""Concrete Slack adapter for Provider-neutral Human Input IM contracts. + +The root and its ordinary capabilities rely on the contract's external +serialization rule and intentionally contain no synchronization. Socket Mode +delegates connection concurrency and resource shutdown to the Slack SDK. +""" + +from __future__ import annotations + +import binascii +import json +import logging +import re +from collections.abc import Mapping, Sequence +from dataclasses import dataclass, replace +from datetime import UTC, datetime +from email.message import Message +from math import isfinite +from string import punctuation +from typing import Annotated, ClassVar, Literal, Never, Self, override +from urllib.error import URLError +from urllib.parse import parse_qs + +from pydantic import ( + AfterValidator, + BaseModel, + ConfigDict, + Field, + JsonValue, + RootModel, + StringConstraints, + TypeAdapter, + ValidationError, + field_validator, + model_validator, +) +from slack_sdk.errors import SlackApiError, SlackClientError +from slack_sdk.models.blocks import MarkdownBlock +from slack_sdk.signature import Clock, SignatureVerifier +from slack_sdk.socket_mode import SocketModeClient +from slack_sdk.socket_mode.client import BaseSocketModeClient +from slack_sdk.socket_mode.request import SocketModeRequest +from slack_sdk.socket_mode.response import SocketModeResponse +from slack_sdk.web import WebClient +from slack_sdk.web.slack_response import SlackResponse + +from core.human_input import ButtonStyle +from core.human_input_v2.entities import IMProvider +from core.human_input_v2.im_integration.adapters.avatar import AvatarReadError, read_avatars +from core.human_input_v2.im_integration.adapters.credentials import SlackCredentials +from core.human_input_v2.im_integration.adapters.entities import ( + AuthenticatedIMEvent, + CardAssessment, + CorrelationToken, + CredentialTestFailure, + CredentialTestFailureKind, + CredentialTestSuccess, + CredentialTestTimeoutError, + Directory, + DirectoryEntry, + DirectoryReadFailure, + DynamicCardMessagingError, + EventAcceptance, + IMCardEvent, + IMCardEventDecodeResult, + IMCardEventDecodingError, + IMEventIngressKind, + IMProviderError, + IMStreamStartError, + IMStreamStopError, + MessageAccepted, + MessageSendingError, + MessageSendingResult, + ProviderUserId, + ReplacementError, + ReplacementErrorKind, + StaticCardIntent, + UnrecognizedIMEvent, + WebhookRequest, + WebhookResponse, +) +from core.human_input_v2.im_integration.adapters.message_locator import MessageLocator, _Base64JSONLocatorPayload +from core.human_input_v2.im_integration.adapters.protocols import ( + IMCardEventDecoder, + IMDirectory, + IMDynamicCardMessaging, + IMEventConsumer, + IMEventStream, + IMMessaging, + IMWebhookHandler, +) +from core.human_input_v2.resolved_form import ( + FileInput, + FileListInput, + MarkdownFragment, + ParagraphInput, + ResolvedForm, + SelectInput, +) + +logger = logging.getLogger(__name__) + + +def _log_safe_error(message: str, *, extra: Mapping[str, JsonValue] | None = None) -> None: + """Record a static diagnostic without serializing the active exception.""" + + logger.error(message, extra=extra) + + +class _SlackLocatorPayload(_Base64JSONLocatorPayload): + model_config = ConfigDict(extra="forbid", frozen=True, strict=True) + + _TIMESTAMP_PATTERN: ClassVar[re.Pattern[str]] = re.compile(r"^[0-9]+\.[0-9]+$") + + # version of the locator + v: Literal[1] + # provider of the locator + p: Literal[IMProvider.SLACK] + # Slack channel containing the message to update: + # https://docs.slack.dev/reference/methods/chat.update/ + channel_id: str = Field(min_length=1, pattern=r"\S") + # Slack timestamp of the message to update: + # https://docs.slack.dev/reference/methods/chat.update/ + message_ts: str = Field(min_length=1) + + @field_validator("message_ts") + @classmethod + def _validate_message_timestamp(cls, value: str) -> str: + if cls._TIMESTAMP_PATTERN.fullmatch(value) is None: + raise ValueError("message_ts must be a Slack message timestamp") + return value + + +# A slots dataclass replaces the class and can orphan namespaces used by lazy nested Pydantic annotations. +@dataclass(frozen=True) +class _SlackCardCodec(IMCardEventDecoder): + """Own the complete sender-owned Slack card wire contract without I/O.""" + + _MAX_MARKDOWN_TEXT_LENGTH: ClassVar[int] = MarkdownBlock.text_max_length + _MAX_HEADER_TEXT_LENGTH: ClassVar[int] = 150 + _MAX_BLOCK_COUNT: ClassVar[int] = 50 + _MAX_ACTION_COUNT: ClassVar[int] = 25 + _MAX_ACTION_ID_LENGTH: ClassVar[int] = 255 + _MAX_ACTION_TEXT_LENGTH: ClassVar[int] = 75 + _MAX_INPUT_LABEL_LENGTH: ClassVar[int] = 2000 + _MAX_INPUT_INITIAL_VALUE_LENGTH: ClassVar[int] = 3000 + _MAX_STATIC_SELECT_OPTION_COUNT: ClassVar[int] = 100 + _MAX_STATIC_SELECT_OPTION_TEXT_LENGTH: ClassVar[int] = 75 + _MAX_STATIC_SELECT_OPTION_VALUE_LENGTH: ClassVar[int] = 150 + _MAX_ACTION_VALUE_LENGTH: ClassVar[int] = 2000 + _ACTIONS_BLOCK_ID: ClassVar[str] = "__dify.actions" + _INPUT_BLOCK_ID_TEMPLATE: ClassVar[str] = "__dify.input.{}" + _INPUT_BLOCK_ID_PATTERN: ClassVar[re.Pattern[str]] = re.compile(r"__dify\.input\.(?:0|[1-9][0-9]*)") + _STATIC_SELECT_PLACEHOLDER_TEXT: ClassVar[str] = "Select an option" + _CALLBACK_SCHEMA_VERSION: ClassVar[Literal[1]] = 1 + _JSON_OBJECT_ADAPTER: ClassVar[TypeAdapter[dict[str, JsonValue]]] = TypeAdapter(dict[str, JsonValue]) + + class _CallbackModel(BaseModel): + model_config = ConfigDict(extra="ignore", frozen=True, strict=True) + + class _RecognitionAction(_CallbackModel): + model_config = ConfigDict(extra="allow", frozen=True, strict=True) + + block_id: str | None = None + + @field_validator("block_id", mode="before") + @classmethod + def _ignore_non_string_block_id(cls, value: JsonValue) -> str | None: + return value if isinstance(value, str) else None + + class _RecognitionPayload(_CallbackModel): + model_config = ConfigDict(extra="allow", frozen=True, strict=True) + + actions: list[_SlackCardCodec._RecognitionAction | None | str | int | float | bool | list[JsonValue]] = Field( + default_factory=list + ) + + @field_validator("actions", mode="before") + @classmethod + def _normalize_non_list_actions(cls, value: JsonValue) -> JsonValue: + return value if isinstance(value, list) else [] + + @property + def has_dify_submission_action(self) -> bool: + return any( + isinstance(action, _SlackCardCodec._RecognitionAction) + and action.block_id == _SlackCardCodec._ACTIONS_BLOCK_ID + for action in self.actions + ) + + class _SocketModeEnvelope(_CallbackModel): + type: Literal["interactive"] + envelope_id: str = Field(min_length=1) + payload: dict[str, JsonValue] + + class _CallbackUser(_CallbackModel): + id: str = Field(min_length=1) + + class _ButtonMetadata(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True, strict=True) + + version: Literal[1] + action_id: str = Field(min_length=1) + correlation_token: str + + class _EncodedButton(_CallbackModel): + type: Literal["button"] + action_id: str = Field(min_length=1) + value: str = Field(exclude=True) + metadata: _SlackCardCodec._ButtonMetadata = Field(exclude=True) + + @model_validator(mode="before") + @classmethod + def _decode_metadata(cls, value: JsonValue) -> JsonValue: + if not isinstance(value, dict): + return value + serialized_metadata = value.get("value") + if not isinstance(serialized_metadata, str): + return value + decoded_metadata = _SlackCardCodec._decode_json_object(serialized_metadata) + if decoded_metadata is None: + raise ValueError("Slack card action metadata is invalid.") + value_with_metadata = dict(value) + value_with_metadata["metadata"] = decoded_metadata + return value_with_metadata + + @model_validator(mode="after") + def _require_matching_action_identity(self) -> Self: + if self.action_id != self.metadata.action_id: + raise ValueError("Slack card action identity is inconsistent.") + return self + + class _InvokedButton(_EncodedButton): + block_id: str + + @field_validator("block_id") + @classmethod + def _require_submission_block_id(cls, value: str) -> str: + if value != _SlackCardCodec._ACTIONS_BLOCK_ID: + raise ValueError("Slack card action block is invalid.") + return value + + class _PlainTextInputElement(_CallbackModel): + type: Literal["plain_text_input"] + action_id: str = Field(min_length=1) + + class _StaticSelectElement(_CallbackModel): + type: Literal["static_select"] + action_id: str = Field(min_length=1) + + type _InputElement = Annotated[ + _PlainTextInputElement | _StaticSelectElement, + Field(discriminator="type"), + ] + + class _InputBlock(_CallbackModel): + type: Literal["input"] + block_id: str = Field(min_length=1) + element: _SlackCardCodec._InputElement + + class _ActionsBlock(_CallbackModel): + type: Literal["actions"] + block_id: str + elements: list[_SlackCardCodec._EncodedButton] = Field(min_length=1) + + @field_validator("block_id") + @classmethod + def _require_submission_block_id(cls, value: str) -> str: + if value != _SlackCardCodec._ACTIONS_BLOCK_ID: + raise ValueError("Slack card actions block is invalid.") + return value + + @model_validator(mode="after") + def _require_unique_action_ids(self) -> Self: + action_ids = {element.action_id for element in self.elements} + if len(action_ids) != len(self.elements): + raise ValueError("Slack card actions block is ambiguous.") + return self + + class _OtherMessageBlock(_CallbackModel): + type: str + block_id: str | None = None + + @model_validator(mode="after") + def _reject_sender_owned_block_shape(self) -> Self: + if self.type in {"input", "actions"}: + raise ValueError("Slack card message block type is invalid.") + if self.block_id == _SlackCardCodec._ACTIONS_BLOCK_ID or ( + self.block_id is not None and _SlackCardCodec._INPUT_BLOCK_ID_PATTERN.fullmatch(self.block_id) + ): + raise ValueError("Slack card message block identifier is reserved.") + return self + + type _MessageBlock = Annotated[ + _InputBlock | _ActionsBlock | _OtherMessageBlock, + Field(union_mode="left_to_right"), + ] + + class _MessageBlocks(RootModel[list[_MessageBlock]]): + model_config = ConfigDict(frozen=True, strict=True) + + @model_validator(mode="after") + def _require_stable_message_schema(self) -> Self: + if len(self.actions_blocks) != 1: + raise ValueError("Slack card actions schema is invalid.") + + action_ids: set[str] = set() + for input_ordinal, input_block in enumerate(self.inputs): + expected_block_id = _SlackCardCodec._INPUT_BLOCK_ID_TEMPLATE.format(input_ordinal) + if input_block.block_id != expected_block_id or input_block.element.action_id in action_ids: + raise ValueError("Slack card input schema is invalid.") + action_ids.add(input_block.element.action_id) + return self + + @property + def inputs(self) -> tuple[_SlackCardCodec._InputBlock, ...]: + return tuple(block for block in self.root if isinstance(block, _SlackCardCodec._InputBlock)) + + @property + def actions_blocks(self) -> tuple[_SlackCardCodec._ActionsBlock, ...]: + return tuple(block for block in self.root if isinstance(block, _SlackCardCodec._ActionsBlock)) + + @property + def actions(self) -> tuple[_SlackCardCodec._EncodedButton, ...]: + return tuple(self.actions_blocks[0].elements) + + class _CallbackMessage(_CallbackModel): + blocks: _SlackCardCodec._MessageBlocks + + class _SelectedOption(_CallbackModel): + value: str + + class _PlainTextInputState(_CallbackModel): + type: Literal["plain_text_input"] + value: str | None + + class _StaticSelectState(_CallbackModel): + type: Literal["static_select"] + selected_option: _SlackCardCodec._SelectedOption | None + + type _InputState = Annotated[ + _PlainTextInputState | _StaticSelectState, + Field(discriminator="type"), + ] + + class _InputBlockState(RootModel[dict[str, _InputState]]): + model_config = ConfigDict(frozen=True, strict=True) + + @model_validator(mode="after") + def _require_one_input_state(self) -> Self: + if len(self.root) != 1: + raise ValueError("Slack card input state is ambiguous.") + return self + + @property + def action_id(self) -> str: + return next(iter(self.root)) + + @property + def input_state(self) -> _SlackCardCodec._InputState: + return self.root[self.action_id] + + @property + def input_value(self) -> JsonValue: + state = self.input_state + if isinstance(state, _SlackCardCodec._PlainTextInputState): + return state.value + if state.selected_option is None: + return None + return state.selected_option.value + + class _StateValues(RootModel[dict[str, _InputBlockState]]): + model_config = ConfigDict(frozen=True, strict=True) + + class _CallbackState(_CallbackModel): + values: _SlackCardCodec._StateValues = Field(default_factory=lambda: _SlackCardCodec._StateValues({})) + + class _SubmissionPayload(_CallbackModel): + type: Literal["block_actions"] + user: _SlackCardCodec._CallbackUser + actions: list[_SlackCardCodec._InvokedButton] + message: _SlackCardCodec._CallbackMessage + state: _SlackCardCodec._CallbackState | None = None + + @model_validator(mode="after") + def _require_exact_submission_schema(self) -> Self: + if len(self.actions) != 1: + raise ValueError("Slack card invoked action is ambiguous.") + + invoked_action = self.action + if not any( + message_action.action_id == invoked_action.action_id + and message_action.metadata == invoked_action.metadata + for message_action in self.message.blocks.actions + ): + raise ValueError("Slack card invoked action does not match the message schema.") + + expected_inputs = self.message.blocks.inputs + state_values = self.state.values.root if self.state is not None else {} + if set(state_values) != {input_block.block_id for input_block in expected_inputs}: + raise ValueError("Slack card input state does not match the message schema.") + + for input_block in expected_inputs: + block_state = state_values[input_block.block_id] + if ( + block_state.action_id != input_block.element.action_id + or block_state.input_state.type != input_block.element.type + ): + raise ValueError("Slack card input state is inconsistent.") + return self + + @property + def action(self) -> _SlackCardCodec._InvokedButton: + return self.actions[0] + + @property + def normalized_inputs(self) -> dict[str, JsonValue]: + if self.state is None: + return {} + state_values = self.state.values.root + return { + input_block.element.action_id: state_values[input_block.block_id].input_value + for input_block in self.message.blocks.inputs + } + + class _InvalidJsonConstantError(ValueError): + """RFC-invalid non-finite JSON constant without retaining its source text.""" + + class _InvalidJsonNumberError(ValueError): + """JSON number rejected without retaining its source text.""" + + def assess(self, intent: ResolvedForm) -> CardAssessment: + reason = self._unrepresentable_reason(intent) + if reason is not None: + return CardAssessment(False, reason) + return CardAssessment(True) + + def encode( + self, + intent: ResolvedForm, + correlation_token: CorrelationToken, + ) -> Mapping[str, JsonValue]: + return {"blocks": self._render_blocks(intent, correlation_token)} + + @override + def decode(self, event: AuthenticatedIMEvent) -> IMCardEventDecodeResult: + if event.provider is not IMProvider.SLACK or event.event_type != "block_actions": + return UnrecognizedIMEvent() + + callback = self._decode_json_object(event.payload) + if callback is None: + raise IMCardEventDecodingError("Slack card event payload is invalid.") + callback_payload = self._callback_payload(event.ingress_kind, callback) + if callback_payload is None: + raise IMCardEventDecodingError("Slack card event envelope is invalid.") + + recognition = self._recognize_submission(callback_payload) + if recognition is None: + raise IMCardEventDecodingError("Slack card event recognition is invalid.") + if not recognition.has_dify_submission_action: + return UnrecognizedIMEvent() + + submission = self._validate_submission(recognition) + if submission is None: + raise IMCardEventDecodingError("Slack card event schema is invalid.") + return IMCardEvent( + provider_user_id=ProviderUserId(submission.user.id), + action_id=submission.action.action_id, + inputs=submission.normalized_inputs, + correlation_token=CorrelationToken(submission.action.metadata.correlation_token), + ) + + @classmethod + def _decode_json_object(cls, serialized_value: str) -> dict[str, JsonValue] | None: + try: + decoded_value: JsonValue = json.loads( + serialized_value, + parse_constant=cls._reject_invalid_json_constant, + parse_float=cls._decode_json_float, + parse_int=cls._decode_json_integer, + ) + except ( + json.JSONDecodeError, + cls._InvalidJsonConstantError, + cls._InvalidJsonNumberError, + RecursionError, + ): + return None + try: + return cls._JSON_OBJECT_ADAPTER.validate_python(decoded_value, strict=True) + except ValidationError: + return None + + @classmethod + def _callback_payload( + cls, + ingress_kind: IMEventIngressKind, + callback: dict[str, JsonValue], + ) -> dict[str, JsonValue] | None: + if ingress_kind is IMEventIngressKind.WEBHOOK: + if callback.get("type") == "interactive": + return None + return callback + if ingress_kind is IMEventIngressKind.STREAM: + try: + return cls._SocketModeEnvelope.model_validate(callback).payload + except ValidationError: + return None + return None + + @classmethod + def _recognize_submission(cls, callback_payload: dict[str, JsonValue]) -> _RecognitionPayload | None: + try: + return cls._RecognitionPayload.model_validate(callback_payload) + except ValidationError: + return None + + @classmethod + def _validate_submission(cls, recognition: _RecognitionPayload) -> _SubmissionPayload | None: + try: + return cls._SubmissionPayload.model_validate(recognition.model_dump(mode="python")) + except ValidationError: + return None + + @classmethod + def _encode_button_metadata(cls, action_id: str, correlation_token: CorrelationToken) -> str: + metadata = cls._ButtonMetadata( + version=cls._CALLBACK_SCHEMA_VERSION, + action_id=action_id, + correlation_token=str(correlation_token), + ) + return json.dumps(metadata.model_dump(mode="json"), ensure_ascii=False, sort_keys=True, separators=(",", ":")) + + @classmethod + def _render_blocks( + cls, + intent: ResolvedForm, + correlation_token: CorrelationToken, + ) -> list[JsonValue]: + blocks: list[JsonValue] = [] + if intent.title: + blocks.append({"type": "header", "text": {"type": "plain_text", "text": intent.title}}) + input_ordinal = 0 + for block in intent.parts: + if isinstance(block, MarkdownFragment): + blocks.append({"type": "markdown", "text": block.text}) + continue + input_name = block.output_variable_name + input_element: dict[str, JsonValue] = {"action_id": input_name} + if isinstance(block, ParagraphInput): + input_element.update({"type": "plain_text_input", "multiline": True}) + if block.default_value is not None: + input_element["initial_value"] = block.default_value + elif isinstance(block, SelectInput): + options: list[JsonValue] = [ + {"text": {"type": "plain_text", "text": option}, "value": option} for option in block.options + ] + input_element.update( + { + "type": "static_select", + "placeholder": {"type": "plain_text", "text": cls._STATIC_SELECT_PLACEHOLDER_TEXT}, + "options": options, + } + ) + if block.default_value is not None: + input_element["initial_option"] = next( + option + for option in options + if isinstance(option, dict) and option["value"] == block.default_value + ) + else: + raise DynamicCardMessagingError("Slack cards cannot represent file inputs.") + blocks.append( + { + "type": "input", + "block_id": cls._INPUT_BLOCK_ID_TEMPLATE.format(input_ordinal), + "label": {"type": "plain_text", "text": input_name}, + "element": input_element, + } + ) + input_ordinal += 1 + if intent.actions: + action_elements: list[JsonValue] = [] + for action in intent.actions: + action_value = cls._encode_button_metadata(action.id, correlation_token) + if len(action_value) > cls._MAX_ACTION_VALUE_LENGTH: + raise DynamicCardMessagingError("Slack cannot preserve the correlation token.") + action_element: dict[str, JsonValue] = { + "type": "button", + "action_id": action.id, + "text": {"type": "plain_text", "text": action.title}, + "value": action_value, + } + if action.button_style is ButtonStyle.PRIMARY: + action_element["style"] = "primary" + elif action.button_style is ButtonStyle.ACCENT: + action_element["style"] = "danger" + action_elements.append(action_element) + blocks.append( + { + "type": "actions", + "block_id": cls._ACTIONS_BLOCK_ID, + "elements": action_elements, + } + ) + return blocks + + @classmethod + def _unrepresentable_reason(cls, intent: ResolvedForm) -> str | None: + if not intent.parts and not intent.actions: + return "Slack cannot preserve an empty card." + if len(intent.title) > cls._MAX_HEADER_TEXT_LENGTH: + return "Slack cannot preserve the card title of this length." + block_count = len(intent.parts) + (1 if intent.title else 0) + (1 if intent.actions else 0) + if block_count > cls._MAX_BLOCK_COUNT: + return "Slack cannot preserve this number of card controls." + if len(intent.actions) > cls._MAX_ACTION_COUNT: + return "Slack cannot preserve this number of card actions." + + input_names: set[str] = set() + for block in intent.parts: + match block: + case MarkdownFragment(text=text): + if not text or len(text) > cls._MAX_MARKDOWN_TEXT_LENGTH: + return "Slack cannot preserve one Markdown block of this length." + continue + case FileInput() | FileListInput(): + return "Slack cards cannot represent file inputs." + case ParagraphInput(output_variable_name=input_name, default_value=default_value): + if len(input_name) > cls._MAX_ACTION_ID_LENGTH or len(input_name) > cls._MAX_INPUT_LABEL_LENGTH: + return "Slack cannot preserve one card input identifier." + if default_value is not None and len(default_value) > cls._MAX_INPUT_INITIAL_VALUE_LENGTH: + return "Slack cannot preserve one card input default." + case SelectInput(output_variable_name=input_name, options=options, default_value=default_value): + if len(input_name) > cls._MAX_ACTION_ID_LENGTH or len(input_name) > cls._MAX_INPUT_LABEL_LENGTH: + return "Slack cannot preserve one card input identifier." + if not 1 <= len(options) <= cls._MAX_STATIC_SELECT_OPTION_COUNT: + return "Slack cannot preserve one select input's option count." + if any( + not option + or len(option) > cls._MAX_STATIC_SELECT_OPTION_TEXT_LENGTH + or len(option) > cls._MAX_STATIC_SELECT_OPTION_VALUE_LENGTH + for option in options + ): + return "Slack cannot preserve one select option." + if default_value is not None and default_value not in options: + return "Slack cannot preserve one select input default." + if input_name in input_names: + return "Slack cannot preserve duplicate card input identifiers." + input_names.add(input_name) + for action in intent.actions: + if len(action.id) > cls._MAX_ACTION_ID_LENGTH or len(action.title) > cls._MAX_ACTION_TEXT_LENGTH: + return "Slack cannot preserve one card action identifier or title." + if action.button_style not in {ButtonStyle.DEFAULT, ButtonStyle.PRIMARY, ButtonStyle.ACCENT}: + return "Slack cannot preserve one card action style." + return None + + @staticmethod + def _reject_invalid_json_constant(_serialized_constant: str) -> Never: + raise _SlackCardCodec._InvalidJsonConstantError + + @staticmethod + def _decode_json_integer(serialized_integer: str) -> int: + try: + return int(serialized_integer) + except ValueError: + raise _SlackCardCodec._InvalidJsonNumberError from None + + @staticmethod + def _decode_json_float(serialized_float: str) -> float: + decoded_float = float(serialized_float) + if not isfinite(decoded_float): + raise _SlackCardCodec._InvalidJsonNumberError + return decoded_float + + +class _TrustedReceiveTimeClock(Clock): + """SDK clock fixed to the framework's trusted UTC receive time.""" + + def __init__(self, received_at: datetime) -> None: + received_at = received_at.replace(tzinfo=UTC) if received_at.tzinfo is None else received_at.astimezone(UTC) + self._received_timestamp = received_at.timestamp() + + @override + def now(self) -> float: + return self._received_timestamp + + +@dataclass(frozen=True, slots=True) +class _SlackDirectoryPagination: + next_cursor: str | None + + +class _SlackDirectory(IMDirectory): + _PAGE_SIZE: ClassVar[int] = 200 + _AVATAR_URL_ADAPTER: ClassVar[TypeAdapter[str | None]] = TypeAdapter( + Annotated[str | None, StringConstraints(strip_whitespace=True), AfterValidator(lambda value: value or None)] + ) + # Slack-owned special users are protocol exceptions: Slackbot reports `is_bot=false`, and system + # notifications are sent by `USLACK`. + # https://docs.slack.dev/reference/objects/user-object/ + # https://docs.slack.dev/changelog/2026/06/17/system-notifications/ + _OWNED_SPECIAL_USER_IDS: ClassVar[frozenset[str]] = frozenset(("USLACKBOT", "USLACK")) + + def __init__(self, client: WebClient) -> None: + self._client = client + + @override + def read_directory(self) -> Directory | DirectoryReadFailure: + entries: list[DirectoryEntry] = [] + cursor: str | None = None + seen_cursors: set[str] = set() + try: + while True: + if cursor is None: + response = self._client.users_list(limit=self._PAGE_SIZE) + else: + response = self._client.users_list(limit=self._PAGE_SIZE, cursor=cursor) + members = response.get("members") + if not isinstance(members, Sequence) or isinstance(members, (str, bytes, bytearray)): + return DirectoryReadFailure("Slack returned an incomplete directory response.") + page_entries = self._directory_entries(members) + if page_entries is None: + return DirectoryReadFailure("Slack returned an invalid directory entry.") + pagination = self._pagination(response.get("response_metadata")) + if pagination is None: + return DirectoryReadFailure("Slack returned invalid directory pagination.") + next_cursor = pagination.next_cursor + if next_cursor is not None and next_cursor in seen_cursors: + return DirectoryReadFailure("Slack returned invalid directory pagination.") + entries.extend(page_entries) + if next_cursor is None: + return Directory(tuple(entries)) + seen_cursors.add(next_cursor) + cursor = next_cursor + except (SlackClientError, AvatarReadError): + return DirectoryReadFailure("Slack directory could not be read completely.") + + @staticmethod + def _directory_entries(members: Sequence[JsonValue]) -> tuple[DirectoryEntry, ...] | None: + entries: list[DirectoryEntry] = [] + avatar_urls: list[str | None] = [] + for member in members: + if not isinstance(member, Mapping): + return None + provider_user_id = member.get("id") + if member.get("deleted") is True or member.get("is_bot") is True or member.get("is_app_user") is True: + continue + if not isinstance(provider_user_id, str) or not provider_user_id: + return None + if provider_user_id in _SlackDirectory._OWNED_SPECIAL_USER_IDS: + continue + profile = member.get("profile") + if not isinstance(profile, Mapping): + profile = {} + display_name = _first_non_empty_string( + profile.get("display_name_normalized"), + profile.get("real_name_normalized"), + ) + email = _optional_non_empty_string(profile.get("email")) + try: + avatar_url = _SlackDirectory._AVATAR_URL_ADAPTER.validate_python(profile.get("image_192"), strict=True) + except ValidationError: + return None + entries.append(DirectoryEntry(ProviderUserId(provider_user_id), display_name, email)) + avatar_urls.append(avatar_url) + avatars = read_avatars(avatar_urls) + return tuple(replace(entry, avatar=avatar) for entry, avatar in zip(entries, avatars, strict=True)) + + @staticmethod + def _pagination(response_metadata: JsonValue) -> _SlackDirectoryPagination | None: + if not isinstance(response_metadata, Mapping) or "next_cursor" not in response_metadata: + return None + next_cursor = response_metadata.get("next_cursor") + if not isinstance(next_cursor, str): + return None + return _SlackDirectoryPagination(next_cursor or None) + + +class _SlackMessaging(IMMessaging): + def __init__(self, client: WebClient) -> None: + self._client = client + + @override + def send_text( + self, provider_user_id: ProviderUserId, body: str, subject: str | None = None + ) -> MessageSendingResult: + try: + if subject is None: + response = self._client.chat_postMessage(channel=str(provider_user_id), markdown_text=body) + else: + blocks: list[dict[str, JsonValue]] = [] + if subject: + escaped_subject = "".join( + f"\\{character}" if character in punctuation else character for character in subject + ) + blocks.append({"type": "markdown", "text": f"# {escaped_subject}"}) + blocks.append({"type": "markdown", "text": body}) + response = self._client.chat_postMessage( + channel=str(provider_user_id), text=f"{subject}\n\n{body}", blocks=blocks + ) + except SlackClientError: + return MessageSendingError("Slack message acceptance could not be confirmed.") + return _accepted_message(response) + + +class _SlackDynamicCardMessaging(IMDynamicCardMessaging): + _STALE_MESSAGE_ERROR_CODES: ClassVar[frozenset[str]] = frozenset( + ( + "cant_update_message", + "channel_not_found", + "edit_window_closed", + "message_not_found", + ) + ) + + def __init__(self, client: WebClient, codec: _SlackCardCodec) -> None: + self._client = client + self._codec = codec + + @override + def assess(self, intent: ResolvedForm) -> CardAssessment: + return self._codec.assess(intent) + + @override + def send_card( + self, + provider_user_id: ProviderUserId, + intent: ResolvedForm, + correlation_token: CorrelationToken, + ) -> MessageSendingResult: + assessment = self._codec.assess(intent) + if not assessment.representable: + raise DynamicCardMessagingError(assessment.reason or "Slack cannot preserve this card.") + encoded_card = self._codec.encode(intent, correlation_token) + encoded_blocks = encoded_card.get("blocks") + if not isinstance(encoded_blocks, list) or any(not isinstance(block, dict) for block in encoded_blocks): + raise RuntimeError("Slack card codec returned an invalid wire payload.") + blocks = [block for block in encoded_blocks if isinstance(block, dict)] + try: + response = self._client.chat_postMessage( + channel=str(provider_user_id), + blocks=blocks, + ) + except SlackClientError: + return MessageSendingError("Slack card acceptance could not be confirmed.") + return _accepted_message(response) + + @override + def replace_with_static( + self, + locator: MessageLocator, + intent: StaticCardIntent, + ) -> ReplacementError | None: + try: + decoded_locator = _SlackLocatorPayload.decode(str(locator)) + except (binascii.Error, UnicodeDecodeError, ValidationError, ValueError): + return ReplacementError(ReplacementErrorKind.INVALID_REFERENCE, "The Slack message reference is invalid.") + blocks: list[dict[str, str]] = [] + # Top-level text uses mrkdwn; a Markdown block preserves CommonMark. + # Retain text fallback beyond Slack's Markdown limit instead of rejecting the update. + if intent.rendered_content and len(intent.rendered_content) <= MarkdownBlock.text_max_length: + blocks.append({"type": "markdown", "text": intent.rendered_content}) + try: + response = self._client.chat_update( + channel=decoded_locator.channel_id, + ts=decoded_locator.message_ts, + text=intent.rendered_content, + blocks=blocks, + ) + except SlackApiError as error: + error_code = _slack_error_code(error) + if error_code in self._STALE_MESSAGE_ERROR_CODES: + return ReplacementError( + ReplacementErrorKind.STALE_REFERENCE, + "The referenced Slack card is no longer replaceable.", + ) + return ReplacementError(ReplacementErrorKind.UNKNOWN, "Slack replacement acceptance is unknown.") + except SlackClientError: + return ReplacementError(ReplacementErrorKind.UNKNOWN, "Slack replacement acceptance is unknown.") + if response.get("ok") is not True: + return ReplacementError(ReplacementErrorKind.UNKNOWN, "Slack replacement acceptance is unknown.") + return None + + +class _SlackWebhookHandler(IMWebhookHandler): + _JSON_CONTENT_TYPE: ClassVar[str] = "application/json" + _FORM_CONTENT_TYPE: ClassVar[str] = "application/x-www-form-urlencoded" + + def __init__(self, signing_secret: str, consumer: IMEventConsumer) -> None: + self._signing_secret = signing_secret + self._consumer = consumer + + @override + def handle(self, request: WebhookRequest) -> WebhookResponse: + if not self._authenticated(request): + return _webhook_response(401, b"request authentication failed") + if request.method != "POST": + return _webhook_response(405, b"method not allowed") + body = self._decoded_body(request) + if body is None: + return _webhook_response(400, b"invalid Slack request") + if body.get("type") == "url_verification": + challenge = body.get("challenge") + if not isinstance(challenge, str): + return _webhook_response(400, b"invalid Slack challenge") + response_body = json.dumps({"challenge": challenge}, separators=(",", ":")).encode() + return WebhookResponse(200, (("Content-Type", "application/json"),), response_body) + event = _authenticated_event(body, request.received_at, ingress_kind=IMEventIngressKind.WEBHOOK) + if event is None: + return _webhook_response(400, b"invalid Slack event") + try: + acceptance = self._consumer.accept(event, request_ip_address=request.request_ip_address) + except Exception: + _log_safe_error( + "Slack Webhook consumer failed", + extra={"provider_tenant_id": event.provider_tenant_id}, + ) + return _webhook_response(503, b"event processing failed") + if acceptance is EventAcceptance.ACCEPTED: + return _webhook_response(200, b"") + return _webhook_response(503, b"event not accepted") + + def _authenticated(self, request: WebhookRequest) -> bool: + timestamps = _header_values(request.headers, "x-slack-request-timestamp") + signatures = _header_values(request.headers, "x-slack-signature") + if len(timestamps) != 1 or len(signatures) != 1: + return False + try: + verifier = SignatureVerifier( + self._signing_secret, + clock=_TrustedReceiveTimeClock(request.received_at), + ) + return verifier.is_valid( + body=request.body, + timestamp=timestamps[0], + signature=signatures[0], + ) + except (UnicodeDecodeError, ValueError): + return False + except Exception: + _log_safe_error("Unexpected Slack Webhook signature verification failure") + return False + + @staticmethod + def _decoded_body(request: WebhookRequest) -> dict[str, JsonValue] | None: + content_type = _parse_content_type(request.headers) + if content_type == _SlackWebhookHandler._JSON_CONTENT_TYPE: + try: + decoded: JsonValue = json.loads(request.body) + except (UnicodeDecodeError, json.JSONDecodeError): + return None + elif content_type == _SlackWebhookHandler._FORM_CONTENT_TYPE: + try: + form_values = parse_qs(request.body.decode("utf-8"), strict_parsing=True) + except (UnicodeDecodeError, ValueError): + return None + payload_values = form_values.get("payload") + if payload_values is None or len(payload_values) != 1: + return None + try: + decoded = json.loads(payload_values[0]) + except json.JSONDecodeError: + return None + else: + return None + if not isinstance(decoded, dict): + return None + return decoded + + +class _SlackEventStream(IMEventStream): + """One owner-managed Socket Mode client with a one-shot lifecycle.""" + + _WEB_API_TIMEOUT_SECONDS: ClassVar[int] = 5 + _BUSINESS_REQUEST_TYPES: ClassVar[frozenset[str]] = frozenset(("events_api", "interactive", "slash_commands")) + _SDK_LOGGER: ClassVar[logging.Logger] = logging.Logger(f"{__name__}.socket_sdk") + _SDK_LOGGER.addHandler(logging.NullHandler()) + _SDK_LOGGER.propagate = False + + def __init__( + self, + *, + app_token: str, + bot_token: str, + consumer: IMEventConsumer, + ) -> None: + self._app_token = app_token + self._bot_token = bot_token + self._consumer = consumer + self._client: BaseSocketModeClient | None = None + self._start_attempted = False + self._stop_requested = False + self._stopped = False + + @override + def start(self) -> None: + if self._start_attempted or self._stopped: + raise IMStreamStartError("This Slack event stream has already been started or stopped.") + self._start_attempted = True + + client: BaseSocketModeClient | None = None + try: + web_client = WebClient( + token=self._bot_token, + timeout=self._WEB_API_TIMEOUT_SECONDS, + retry_handlers=[], + ) + client = SocketModeClient( + app_token=self._app_token, + logger=self._SDK_LOGGER, + web_client=web_client, + on_error_listeners=[self._handle_socket_error], + on_close_listeners=[self._handle_socket_close], + ) + client.socket_mode_request_listeners.append(self._handle_request) + self._client = client + client.connect() + except SlackClientError: + self._clean_up_failed_start(client) + raise IMStreamStartError("The Slack event stream could not be started.") from None + except Exception: + _log_safe_error("Unexpected Slack Socket Mode startup failure") + self._clean_up_failed_start(client) + raise IMStreamStartError("The Slack event stream could not be started.") from None + + @override + def stop(self) -> None: + if self._stopped: + return + self._stop_requested = True + client = self._client + + if client is None: + self._stopped = True + return + + try: + client.close() + except SlackClientError: + _log_safe_error("Slack Socket Mode shutdown failure") + raise IMStreamStopError("The Slack event stream could not be stopped.") from None + except Exception: + _log_safe_error("Unexpected Slack Socket Mode shutdown failure") + raise IMStreamStopError("The Slack event stream could not be stopped.") from None + + self._client = None + self._stopped = True + + def _clean_up_failed_start(self, client: BaseSocketModeClient | None) -> None: + if client is None: + return + + self._stop_requested = True + try: + client.close() + except SlackClientError: + _log_safe_error("Slack Socket Mode startup cleanup failure") + except Exception: + _log_safe_error("Unexpected Slack Socket Mode startup cleanup failure") + else: + self._client = None + + def _handle_socket_error(self, error: Exception) -> None: + del error + self._handle_remote_disconnect("Slack Socket Mode remote error") + + def _handle_socket_close(self, code: int, reason: str | None = None) -> None: + del code, reason + self._handle_remote_disconnect("Slack Socket Mode remote close") + + def _handle_remote_disconnect(self, log_message: str) -> None: + if self._stop_requested: + return + _log_safe_error(log_message) + + def _handle_request(self, client: BaseSocketModeClient, request: SocketModeRequest) -> None: + if request.type not in self._BUSINESS_REQUEST_TYPES: + return + try: + serialized_request = request.to_dict() + event_body = serialized_request.get("payload") + if not isinstance(event_body, dict): + return + event = _authenticated_event( + event_body, + datetime.now(tz=UTC).replace(tzinfo=None), + ingress_kind=IMEventIngressKind.STREAM, + serialized_body=serialized_request, + ) + if event is None: + return + acceptance = self._consumer.accept(event) + if acceptance is EventAcceptance.ACCEPTED: + client.send_socket_mode_response(SocketModeResponse(envelope_id=request.envelope_id)) + except SlackClientError: + _log_safe_error("Slack Socket Mode event delivery failure") + except Exception: + _log_safe_error("Unexpected Slack Socket Mode callback failure") + + +class SlackIMProviderAdapter: + """Externally serialized Slack capability composition root.""" + + _BASELINE_BOT_SCOPES: ClassVar[frozenset[str]] = frozenset(("chat:write", "users:read", "users:read.email")) + _AUTHENTICATION_ERROR_CODES: ClassVar[frozenset[str]] = frozenset( + ( + "account_inactive", + "invalid_auth", + "not_authed", + "token_expired", + "token_revoked", + ) + ) + + @classmethod + def card_event_decoder(cls) -> IMCardEventDecoder: + """Return a credential-free decoder independent from root adapter instances.""" + return _SlackCardCodec() + + def __init__(self, credentials: SlackCredentials) -> None: + if not isinstance(credentials, SlackCredentials): + raise TypeError("Slack adapter requires resolved Slack credentials") + self._credentials = credentials + self._client = WebClient(token=credentials.bot_token, retry_handlers=[]) + self._directory = _SlackDirectory(self._client) + self._messaging = _SlackMessaging(self._client) + self._dynamic_card_messaging = _SlackDynamicCardMessaging(self._client, _SlackCardCodec()) + self._closed = False + + @property + def provider(self) -> IMProvider: + return IMProvider.SLACK + + def test_credentials(self) -> CredentialTestSuccess | CredentialTestFailure: + try: + return self._test_credentials() + except TimeoutError as error: + raise CredentialTestTimeoutError() from error + except URLError as error: + if isinstance(error.reason, TimeoutError): + raise CredentialTestTimeoutError() from error + raise + + def _test_credentials(self) -> CredentialTestSuccess | CredentialTestFailure: + try: + response = self._client.auth_test() + except SlackApiError as error: + return _slack_credential_failure( + error, self._AUTHENTICATION_ERROR_CODES, "Slack rejected the credential test." + ) + + team_id = response.get("team_id") + if not isinstance(team_id, str) or not team_id: + raise IMProviderError("Slack did not provide a stable workspace identity.") + granted_scopes = _oauth_scopes(response.headers) + if granted_scopes is None: + raise IMProviderError("Slack did not provide its granted OAuth scopes.") + if not self._BASELINE_BOT_SCOPES.issubset(granted_scopes): + return CredentialTestFailure( + CredentialTestFailureKind.PERMISSION_DENIED, + "Slack baseline permissions could not be confirmed.", + missing_permissions=tuple(sorted(self._BASELINE_BOT_SCOPES - granted_scopes)), + http_status=_SlackHTTPResponseMetadata.model_validate(response).status_code, + ) + app_token = self._credentials.app_token + if app_token is None: + return CredentialTestSuccess(IMProvider.SLACK, team_id) + try: + connection_response = self._client.apps_connections_open(app_token=app_token) + except SlackApiError as error: + return _slack_credential_failure( + error, self._AUTHENTICATION_ERROR_CODES, "Slack Socket Mode credentials could not be confirmed." + ) + if connection_response.get("ok") is False: + rejection = _SlackCredentialError.model_validate({"error": connection_response.get("error")}) + return CredentialTestFailure( + CredentialTestFailureKind.UNKNOWN, + "Slack Socket Mode credentials could not be confirmed.", + provider_error_code=rejection.error, + http_status=_SlackHTTPResponseMetadata.model_validate(connection_response).status_code, + ) + if connection_response.get("ok") is not True: + raise IMProviderError("Slack Socket Mode response did not confirm the connection.") + connection_url = connection_response.get("url") + if not isinstance(connection_url, str) or not connection_url.strip(): + raise IMProviderError("Slack Socket Mode response did not provide a connection URL.") + return CredentialTestSuccess(IMProvider.SLACK, team_id) + + @property + def directory(self) -> IMDirectory: + return self._directory + + @property + def messaging(self) -> IMMessaging: + return self._messaging + + @property + def dynamic_card_messaging(self) -> IMDynamicCardMessaging: + return self._dynamic_card_messaging + + def create_webhook_handler(self, consumer: IMEventConsumer) -> IMWebhookHandler: + return _SlackWebhookHandler(self._credentials.signing_secret, consumer) + + def create_stream_handler(self, consumer: IMEventConsumer) -> IMEventStream | None: + app_token = self._credentials.app_token + if app_token is None: + return None + return _SlackEventStream( + app_token=app_token, + bot_token=self._credentials.bot_token, + consumer=consumer, + ) + + def close(self) -> None: + self._closed = True + + +def _first_non_empty_string(*values: JsonValue) -> str | None: + for value in values: + normalized = _optional_non_empty_string(value) + if normalized is not None: + return normalized + return None + + +def _optional_non_empty_string(value: JsonValue) -> str | None: + if not isinstance(value, str) or not value.strip(): + return None + return value + + +def _accepted_message( + response: SlackResponse, +) -> MessageSendingResult: + if response.get("ok") is not True: + return MessageSendingError("Slack message acceptance could not be confirmed.") + channel_id = response.get("channel") + message_ts = response.get("ts") + if ( + not isinstance(channel_id, str) + or not channel_id.strip() + or not isinstance(message_ts, str) + or _SlackLocatorPayload._TIMESTAMP_PATTERN.fullmatch(message_ts) is None + ): + return MessageSendingError("Slack returned no exact message reference.") + return MessageAccepted( + MessageLocator( + _SlackLocatorPayload( + v=1, + p=IMProvider.SLACK, + channel_id=channel_id, + message_ts=message_ts, + ).encode() + ) + ) + + +def _header_values(headers: tuple[tuple[str, str], ...], target_name: str) -> tuple[str, ...]: + return tuple(value for name, value in headers if name.casefold() == target_name) + + +def _parse_content_type(headers: tuple[tuple[str, str], ...]) -> str | None: + values = _header_values(headers, "content-type") + if len(values) != 1: + return None + message = Message() + message["Content-Type"] = values[0] + return message.get_content_type() + + +def _webhook_response(status_code: int, body: bytes) -> WebhookResponse: + return WebhookResponse(status_code, (("Content-Type", "text/plain; charset=utf-8"),), body) + + +def _authenticated_event( + body: Mapping[str, JsonValue], + received_at: datetime, + *, + ingress_kind: IMEventIngressKind, + serialized_body: Mapping[str, JsonValue] | None = None, +) -> AuthenticatedIMEvent | None: + provider_tenant_id = _provider_tenant_id(body) + if provider_tenant_id is None: + return None + inner_event = body.get("event") + event_type = None + if isinstance(inner_event, Mapping): + event_type = _optional_non_empty_string(inner_event.get("type")) + if event_type is None: + event_type = _optional_non_empty_string(body.get("type")) + event_id = _optional_non_empty_string(body.get("event_id")) + occurred_at = None + event_time = body.get("event_time") + if isinstance(event_time, int | float) and not isinstance(event_time, bool): + try: + occurred_at = datetime.fromtimestamp(event_time, tz=UTC).replace(tzinfo=None) + except (OverflowError, OSError, ValueError): + occurred_at = None + try: + serialized = json.dumps( + serialized_body if serialized_body is not None else body, sort_keys=True, separators=(",", ":") + ) + except (TypeError, ValueError): + return None + return AuthenticatedIMEvent( + provider=IMProvider.SLACK, + provider_tenant_id=provider_tenant_id, + event_id=event_id, + event_type=event_type, + occurred_at=occurred_at, + received_at=received_at, + ingress_kind=ingress_kind, + payload=serialized, + ) + + +def _provider_tenant_id(body: Mapping[str, JsonValue]) -> str | None: + direct = _optional_non_empty_string(body.get("team_id")) + if direct is not None: + return direct + team = body.get("team") + if isinstance(team, Mapping): + nested = _optional_non_empty_string(team.get("id")) + if nested is not None: + return nested + payload = body.get("payload") + if isinstance(payload, Mapping): + return _provider_tenant_id(payload) + return None + + +def _slack_error_code(error: SlackApiError) -> str | None: + error_code = error.response.get("error") + return error_code if isinstance(error_code, str) else None + + +class _SlackHTTPResponseMetadata(BaseModel): + model_config = ConfigDict(extra="ignore", strict=True, from_attributes=True) + + status_code: int | None = None + + +class _SlackCredentialError(BaseModel): + model_config = ConfigDict(extra="ignore", strict=True, hide_input_in_errors=True) + + error: str | None = None + + +class _SlackCredentialScopes(BaseModel): + model_config = ConfigDict(extra="ignore", strict=True, hide_input_in_errors=True) + + needed: str + provided: str + + +def _slack_credential_failure( + error: SlackApiError, + authentication_error_codes: frozenset[str], + reason: str, +) -> CredentialTestFailure: + http_status = _SlackHTTPResponseMetadata.model_validate(error.response).status_code + error_code = _slack_error_code(error) + kind = CredentialTestFailureKind.UNKNOWN + if error_code in authentication_error_codes: + kind = CredentialTestFailureKind.AUTHENTICATION_REJECTED + elif error_code == "missing_scope": + kind = CredentialTestFailureKind.PERMISSION_DENIED + try: + rejection = _SlackCredentialError.model_validate( + { + "error": error.response.get("error"), + } + ) + except ValidationError: + return CredentialTestFailure(kind, reason, http_status=http_status) + missing_permissions: tuple[str, ...] = () + if rejection.error == "missing_scope": + try: + scopes = _SlackCredentialScopes.model_validate( + { + "needed": error.response.get("needed"), + "provided": error.response.get("provided"), + } + ) + except ValidationError: + # Keep the provider code when optional scope details are malformed. + return CredentialTestFailure(kind, reason, rejection.error, http_status=http_status) + else: + needed = frozenset(scope.strip() for scope in scopes.needed.split(",") if scope.strip()) + provided = frozenset(scope.strip() for scope in scopes.provided.split(",") if scope.strip()) + missing_permissions = tuple(sorted(needed - provided)) + return CredentialTestFailure(kind, reason, rejection.error, missing_permissions, http_status=http_status) + + +def _oauth_scopes(headers: Mapping[str, str] | None) -> frozenset[str] | None: + if headers is None: + return None + for name, value in headers.items(): + if name.casefold() == "x-oauth-scopes": + return frozenset(scope.strip() for scope in value.split(",") if scope.strip()) + return None + + +__all__ = ["SlackIMProviderAdapter"] diff --git a/api/core/human_input_v2/im_integration/adapters/wecom.py b/api/core/human_input_v2/im_integration/adapters/wecom.py new file mode 100644 index 00000000000..b258c594737 --- /dev/null +++ b/api/core/human_input_v2/im_integration/adapters/wecom.py @@ -0,0 +1,617 @@ +"""Concrete WeCom adapter for Provider-neutral Human Input IM contracts.""" + +from __future__ import annotations + +import time +from email.headerregistry import Address +from json import JSONDecodeError +from typing import Annotated, Literal, Protocol, override + +import httpx +from pydantic import BaseModel, ConfigDict, Field, RootModel, StrictInt, StrictStr, ValidationError, field_validator +from requests.exceptions import Timeout as RequestsTimeout +from wechatpy.enterprise import WeChatClient +from wechatpy.exceptions import WeChatClientException + +from core.human_input_v2.entities import IMProvider +from core.human_input_v2.im_integration.adapters.avatar import read_avatars +from core.human_input_v2.im_integration.adapters.credentials import WeComCredentials +from core.human_input_v2.im_integration.adapters.entities import ( + CredentialTestFailure, + CredentialTestFailureKind, + CredentialTestSuccess, + CredentialTestTimeoutError, + Directory, + DirectoryEntry, + DirectoryReadFailure, + IMProviderError, + MessageAccepted, + MessageSendingError, + MessageSendingResult, + ProviderUserId, +) +from core.human_input_v2.im_integration.adapters.message_locator import MessageLocator, _Base64JSONLocatorPayload +from core.human_input_v2.im_integration.adapters.protocols import ( + IMCardEventDecoder, + IMDirectory, + IMDynamicCardMessaging, + IMEventConsumer, + IMEventStream, + IMMessaging, + IMWebhookHandler, +) + +_SDK_TIMEOUT_SECONDS = 5.0 +_ACCESS_TOKEN_URL = "https://qyapi.weixin.qq.com/cgi-bin/gettoken" + +_AUTHENTICATION_REJECTION_CODES = frozenset({40001, 40013}) +_TENANT_BOUNDARY_REJECTION_CODES = frozenset({40056, 48002, 60011}) + +_CREDENTIAL_TEST_FAILED = "WeCom credential testing could not be completed." +_CREDENTIAL_REJECTED = "WeCom rejected the credential test." +_TENANT_BOUNDARY_UNAVAILABLE = "WeCom could not verify the configured corporation directory boundary." +_DIRECTORY_READ_FAILED = "WeCom directory could not be read completely." +_MESSAGE_ACCEPTANCE_UNCONFIRMED = "WeCom message acceptance could not be confirmed." + + +class AccessTokenProvider(Protocol): + def get(self) -> str: + """Return a currently usable access token.""" + ... + + +class _AgentAPI(Protocol): + def get(self, agent_id: int) -> object: ... + + +class _DepartmentAPI(Protocol): + def get(self, id: int | None = None) -> object: ... + + +class _UserAPI(Protocol): + def get(self, user_id: str) -> object: ... + + def list( + self, + department_id: int, + fetch_child: bool = False, + status: int = 0, + simple: bool = False, + ) -> object: ... + + +class _TagAPI(Protocol): + def get_users(self, tag_id: int) -> object: ... + + +class _MessageAPI(Protocol): + def send_text( + self, + agent_id: int, + user_ids: list[str], + content: str, + party_ids: str = "", + tag_ids: str = "", + safe: int = 0, + ) -> object: ... + + +class _WeComClient(Protocol): + @property + def agent(self) -> _AgentAPI: ... + + @property + def department(self) -> _DepartmentAPI: ... + + @property + def user(self) -> _UserAPI: ... + + @property + def tag(self) -> _TagAPI: ... + + @property + def message(self) -> _MessageAPI: ... + + def fetch_access_token(self) -> object: ... + + +class _AccessTokenResponse(BaseModel): + model_config = ConfigDict(extra="ignore", strict=True) + + errcode: Literal[0] + access_token: str = Field(min_length=1, pattern=r"\S") + expires_in: int = Field(gt=0) + + +class _AccessTokenStatus(BaseModel): + model_config = ConfigDict(extra="ignore", strict=True) + + errcode: StrictInt + + +class _HTTPResponseMetadata(BaseModel): + model_config = ConfigDict(extra="ignore", strict=True, from_attributes=True) + + status_code: int | None = None + + +class _WeComClientWithBoundedTokenFetch(WeChatClient): + @override + def fetch_access_token(self) -> object: + response = httpx.get( + _ACCESS_TOKEN_URL, + params={ + "corpid": self.corp_id, + "corpsecret": self.secret, + }, + timeout=_SDK_TIMEOUT_SECONDS, + ) + response.raise_for_status() + payload = response.json() + status = _AccessTokenStatus.model_validate(payload) + if status.errcode != 0: + raise WeChatClientException(status.errcode, None, response=response) + + token = _AccessTokenResponse.model_validate(payload) + self.session.set(self.access_token_key, token.access_token, token.expires_in) + self.expires_at = int(time.time()) + token.expires_in + return token.model_dump() + + +class _ScopedUser(BaseModel): + model_config = ConfigDict(extra="ignore", frozen=True) + + userid: StrictStr = Field(pattern=r"\S") + + +class _AllowedDepartments(BaseModel): + model_config = ConfigDict(extra="ignore", frozen=True) + + partyid: tuple[Annotated[StrictInt, Field(gt=0)], ...] = () + + +class _AllowedUsers(BaseModel): + model_config = ConfigDict(extra="ignore", frozen=True) + + user: tuple[_ScopedUser, ...] = () + + +class _AllowedTags(BaseModel): + model_config = ConfigDict(extra="ignore", frozen=True) + + tagid: tuple[Annotated[StrictInt, Field(gt=0)], ...] = () + + +class _AgentResponse(BaseModel): + model_config = ConfigDict(extra="ignore", strict=True) + + errcode: Literal[0] + agentid: int = Field(gt=0) + allow_partys: _AllowedDepartments = Field(default_factory=_AllowedDepartments) + allow_userinfos: _AllowedUsers = Field(default_factory=_AllowedUsers) + allow_tags: _AllowedTags = Field(default_factory=_AllowedTags) + + +class _Department(BaseModel): + model_config = ConfigDict(extra="ignore", frozen=True) + + id: StrictInt = Field(gt=0) + parentid: StrictInt = Field(ge=0) + + +class _DepartmentList(RootModel[tuple[_Department, ...]]): + pass + + +class _DirectoryUser(BaseModel): + model_config = ConfigDict(extra="ignore", frozen=True) + + userid: StrictStr + name: StrictStr | None = None + email: StrictStr | None = None + avatar_url: str | None = Field(default=None, alias="avatar") + + @field_validator("avatar_url", mode="before") + @classmethod + def _normalize_avatar_url(cls, value: object) -> str | None: + if not isinstance(value, str): + return None + return value.strip() or None + + @field_validator("email") + @classmethod + def _validate_email(cls, value: str | None) -> str | None: + if value is None or not value.strip(): + return value + if Address(addr_spec=value).addr_spec != value: + raise ValueError("email must be an exact addr-spec") + return value + + +class _DirectoryUserList(RootModel[tuple[_DirectoryUser, ...]]): + pass + + +class _TagResponse(BaseModel): + model_config = ConfigDict(extra="ignore", frozen=True) + + errcode: Literal[0] + userlist: tuple[_ScopedUser, ...] = () + partylist: tuple[StrictInt, ...] = () + + +class _MessageSendResponse(BaseModel): + model_config = ConfigDict(extra="ignore", frozen=True) + + errcode: Literal[0] + invaliduser: StrictStr | None = None + invalidparty: StrictStr | None = None + invalidtag: StrictStr | None = None + msgid: StrictStr + + +class _DirectoryBoundaryError(Exception): + pass + + +class _WeComLocatorPayload(_Base64JSONLocatorPayload): + model_config = ConfigDict(extra="forbid", frozen=True, strict=True) + + # version of the locator + v: Literal[1] + # provider of the locator + p: Literal[IMProvider.WE_COM] + # WeCom application message identifier returned by the send API: + # https://developer.work.weixin.qq.com/document/path/90236 + message_id: StrictStr = Field(pattern=r"\S") + + +class _SDKAccessTokenProvider(AccessTokenProvider): + def __init__(self, credentials: WeComCredentials) -> None: + self._credentials = credentials + + @override + def get(self) -> str: + return _fetch_access_token(_new_client(self._credentials)) + + +class _WeComDirectory(IMDirectory): + def __init__( + self, + credentials: WeComCredentials, + token_provider: AccessTokenProvider, + ) -> None: + self._credentials = credentials + self._token_provider = token_provider + + @override + def read_directory(self) -> Directory | DirectoryReadFailure: + try: + return self._read_complete_directory() + # Provider SDKs may surface transport and decoding failures through + # different exception classes. Directory never exposes partial state. + except Exception: + return DirectoryReadFailure(_DIRECTORY_READ_FAILED) + + def _read_complete_directory(self) -> Directory: + access_token = self._token_provider.get() + client = _new_client(self._credentials, access_token=access_token) + scope = _AgentResponse.model_validate(client.agent.get(int(self._credentials.agent_id))) + if scope.agentid != int(self._credentials.agent_id): + raise _DirectoryBoundaryError + + entries: list[_DirectoryUser] = [] + seen_user_ids: set[str] = set() + seen_department_ids: set[int] = set() + + for department_id in scope.allow_partys.partyid: + _read_department_scope( + client, + department_id, + entries, + seen_user_ids, + seen_department_ids, + ) + + for scoped_user in scope.allow_userinfos.user: + _read_user_details(client, scoped_user.userid, entries, seen_user_ids) + + for tag_id in scope.allow_tags.tagid: + if tag_id <= 0: + raise _DirectoryBoundaryError + tag = _TagResponse.model_validate(client.tag.get_users(tag_id)) + for scoped_user in tag.userlist: + _read_user_details(client, scoped_user.userid, entries, seen_user_ids) + for department_id in tag.partylist: + _read_department_scope( + client, + department_id, + entries, + seen_user_ids, + seen_department_ids, + ) + + avatars = read_avatars([user.avatar_url for user in entries]) + return Directory( + tuple( + DirectoryEntry( + ProviderUserId(user.userid), + _optional_non_blank(user.name), + _optional_non_blank(user.email), + avatar=avatar, + ) + for user, avatar in zip(entries, avatars, strict=True) + ) + ) + + +class _WeComMessaging(IMMessaging): + def __init__( + self, + credentials: WeComCredentials, + token_provider: AccessTokenProvider, + ) -> None: + self._credentials = credentials + self._token_provider = token_provider + + @override + def send_text( + self, provider_user_id: ProviderUserId, body: str, subject: str | None = None + ) -> MessageSendingResult: + if subject is not None: + body = f"{subject}\n\n{body}" + try: + access_token = self._token_provider.get() + client = _new_client(self._credentials, access_token=access_token) + response = _MessageSendResponse.model_validate( + client.message.send_text( + int(self._credentials.agent_id), + [str(provider_user_id)], + body, + ) + ) + if any( + _optional_non_blank(invalid_target) is not None + for invalid_target in (response.invaliduser, response.invalidparty, response.invalidtag) + ): + return MessageSendingError(_MESSAGE_ACCEPTANCE_UNCONFIRMED) + if not response.msgid.strip(): + return MessageSendingError(_MESSAGE_ACCEPTANCE_UNCONFIRMED) + return MessageAccepted( + MessageLocator( + _WeComLocatorPayload( + v=1, + p=IMProvider.WE_COM, + message_id=response.msgid, + ).encode() + ) + ) + # One mutation attempt is the maximum; every SDK/transport/response + # failure is normalized without replay or Provider material. + except Exception: + return MessageSendingError(_MESSAGE_ACCEPTANCE_UNCONFIRMED) + + +class WeComIMProviderAdapter: + """Externally serialized WeCom capability composition root.""" + + @classmethod + def card_event_decoder(cls) -> IMCardEventDecoder | None: + return None + + def __init__(self, credentials: WeComCredentials) -> None: + if not isinstance(credentials, WeComCredentials): + raise TypeError("WeCom adapter requires resolved WeCom credentials") + self._credentials = credentials + token_provider = _SDKAccessTokenProvider(credentials) + self._directory = _WeComDirectory(credentials, token_provider) + self._messaging = _WeComMessaging(credentials, token_provider) + self._closed = False + + @property + def provider(self) -> IMProvider: + return IMProvider.WE_COM + + def test_credentials(self) -> CredentialTestSuccess | CredentialTestFailure: + try: + return self._test_credentials() + except (TimeoutError, httpx.TimeoutException, RequestsTimeout) as error: + raise CredentialTestTimeoutError() from error + except httpx.HTTPStatusError as error: + try: + rejection = _AccessTokenStatus.model_validate(error.response.json()) + except (JSONDecodeError, ValidationError): + provider_error_code = None + else: + provider_error_code = str(rejection.errcode) + return CredentialTestFailure( + CredentialTestFailureKind.UNKNOWN, + _CREDENTIAL_TEST_FAILED, + provider_error_code=provider_error_code, + http_status=error.response.status_code, + ) + + def _test_credentials(self) -> CredentialTestSuccess | CredentialTestFailure: + client = _new_client(self._credentials) + try: + _fetch_access_token(client) + except WeChatClientException as error: + if error.errcode is None and error.response is None: + raise + kind = ( + CredentialTestFailureKind.AUTHENTICATION_REJECTED + if error.errcode in _AUTHENTICATION_REJECTION_CODES + else CredentialTestFailureKind.UNKNOWN + ) + return CredentialTestFailure( + kind, + _CREDENTIAL_REJECTED, + provider_error_code=str(error.errcode) if error.errcode is not None else None, + http_status=_HTTPResponseMetadata.model_validate(error.response).status_code + if error.response is not None + else None, + ) + try: + agent = _AgentResponse.model_validate(client.agent.get(int(self._credentials.agent_id))) + except WeChatClientException as error: + if error.errcode is None and error.response is None: + raise + kind = CredentialTestFailureKind.UNKNOWN + if error.errcode in _TENANT_BOUNDARY_REJECTION_CODES: + kind = CredentialTestFailureKind.PERMISSION_DENIED + elif error.errcode in _AUTHENTICATION_REJECTION_CODES: + kind = CredentialTestFailureKind.AUTHENTICATION_REJECTED + return CredentialTestFailure( + kind, + _TENANT_BOUNDARY_UNAVAILABLE, + provider_error_code=str(error.errcode) if error.errcode is not None else None, + http_status=_HTTPResponseMetadata.model_validate(error.response).status_code + if error.response is not None + else None, + ) + if agent.agentid != int(self._credentials.agent_id): + raise IMProviderError("WeCom returned a different application identity.") + return CredentialTestSuccess(IMProvider.WE_COM, self._credentials.corp_id) + + @property + def directory(self) -> IMDirectory: + return self._directory + + @property + def messaging(self) -> IMMessaging: + return self._messaging + + @property + def dynamic_card_messaging(self) -> IMDynamicCardMessaging | None: + return None + + def create_webhook_handler(self, consumer: IMEventConsumer) -> IMWebhookHandler | None: + del consumer + return None + + def create_stream_handler(self, consumer: IMEventConsumer) -> IMEventStream | None: + del consumer + return None + + def close(self) -> None: + if self._closed: + return + self._closed = True + + +def _new_client( + credentials: WeComCredentials, + *, + access_token: str | None = None, +) -> _WeComClient: + return _WeComClientWithBoundedTokenFetch( + credentials.corp_id, + credentials.secret, + access_token=access_token, + timeout=_SDK_TIMEOUT_SECONDS, + auto_retry=False, + ) + + +def _fetch_access_token(client: _WeComClient) -> str: + response = _AccessTokenResponse.model_validate(client.fetch_access_token()) + return response.access_token + + +def _read_department_scope( + client: _WeComClient, + root_department_id: int, + entries: list[_DirectoryUser], + seen_user_ids: set[str], + seen_department_ids: set[int], +) -> None: + if root_department_id <= 0: + raise _DirectoryBoundaryError + if root_department_id in seen_department_ids: + return + + departments = _DepartmentList.model_validate(client.department.get(root_department_id)).root + _validate_department_topology(root_department_id, departments) + for department in departments: + if department.id in seen_department_ids: + continue + seen_department_ids.add(department.id) + users = _DirectoryUserList.model_validate( + client.user.list( + department.id, + fetch_child=False, + status=0, + simple=False, + ) + ).root + for user in users: + _append_directory_user(user, entries, seen_user_ids) + + +def _validate_department_topology( + root_department_id: int, + departments: tuple[_Department, ...], +) -> None: + parent_by_department: dict[int, int] = {} + for department in departments: + if department.id in parent_by_department: + raise _DirectoryBoundaryError + parent_by_department[department.id] = department.parentid + + if root_department_id not in parent_by_department: + raise _DirectoryBoundaryError + if parent_by_department[root_department_id] in parent_by_department: + raise _DirectoryBoundaryError + + for department_id in parent_by_department: + current_department_id = department_id + visited: set[int] = set() + while current_department_id != root_department_id: + if current_department_id in visited: + raise _DirectoryBoundaryError + visited.add(current_department_id) + parent_id = parent_by_department.get(current_department_id) + if parent_id is None: + raise _DirectoryBoundaryError + current_department_id = parent_id + + +def _read_user_details( + client: _WeComClient, + user_id: str, + entries: list[_DirectoryUser], + seen_user_ids: set[str], +) -> None: + _require_valid_user_id(user_id) + if user_id in seen_user_ids: + return + user = _DirectoryUser.model_validate(client.user.get(user_id)) + if user.userid != user_id: + raise _DirectoryBoundaryError + _append_directory_user(user, entries, seen_user_ids) + + +def _append_directory_user( + user: _DirectoryUser, + entries: list[_DirectoryUser], + seen_user_ids: set[str], +) -> None: + _require_valid_user_id(user.userid) + if user.userid in seen_user_ids: + return + seen_user_ids.add(user.userid) + entries.append(user) + + +def _require_valid_user_id(user_id: str) -> None: + if not user_id.strip(): + raise _DirectoryBoundaryError + + +def _optional_non_blank(value: str | None) -> str | None: + if value is None or not value.strip(): + return None + return value + + +__all__ = ["WeComIMProviderAdapter"] diff --git a/api/core/human_input_v2/im_integration/binding_commands.py b/api/core/human_input_v2/im_integration/binding_commands.py new file mode 100644 index 00000000000..376c34ff99a --- /dev/null +++ b/api/core/human_input_v2/im_integration/binding_commands.py @@ -0,0 +1,47 @@ +"""Transport-neutral results and failures for manual IM binding commands.""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import StrEnum + +from pydantic import NaiveDatetime + +from core.human_input_v2.entities import HumanInputContactType +from core.human_input_v2.shared import ContactId +from repositories.human_input_v2.contact import IMBinding + + +class IMBindingCommandErrorCode(StrEnum): + """Stable application failures suitable for transport-specific mapping.""" + + INTEGRATION_NOT_CONFIGURED = "im_integration_not_configured" + CONTACT_NOT_FOUND = "contact_not_found" + IDENTITY_NOT_FOUND = "im_identity_not_found" + BINDING_NOT_FOUND = "im_binding_not_found" + BINDING_CONFLICT = "im_binding_conflict" + INVALID_SCOPE = "invalid_im_binding_scope" + + +class IMBindingCommandError(RuntimeError): + """Expected manual-binding rejection without persistence implementation detail.""" + + def __init__(self, code: IMBindingCommandErrorCode, message: str) -> None: + super().__init__(message) + self.code = code + + +@dataclass(frozen=True, slots=True) +class ContactIMBindingView: + """Current workspace Contact projection after one binding mutation.""" + + id: ContactId + type: HumanInputContactType + name: str + email: str | None + avatar_file_id: str | None + im_bindings: tuple[IMBinding, ...] + created_at: NaiveDatetime + + +__all__ = ["ContactIMBindingView", "IMBindingCommandError", "IMBindingCommandErrorCode"] diff --git a/api/core/human_input_v2/im_integration/change_log.py b/api/core/human_input_v2/im_integration/change_log.py new file mode 100644 index 00000000000..23bd2baa0d9 --- /dev/null +++ b/api/core/human_input_v2/im_integration/change_log.py @@ -0,0 +1,99 @@ +"""Immutable append-only reconciliation change-log values.""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import StrEnum + +from pydantic import NaiveDatetime + +from core.human_input_v2.entities import IMProvider +from core.human_input_v2.im_integration.adapters.entities import ProviderUserId +from core.human_input_v2.shared import ( + ContactId, + IMBindingId, + IMIdentityId, + IMReconciliationChangeId, + IMSyncRunId, + NormalizedEmail, +) +from repositories.human_input_v2.im_channel_repository import IMChannelId + + +class IMReconciliationSubjectKind(StrEnum): + IDENTITY = "identity" + BINDING = "binding" + + +class IMReconciliationOperation(StrEnum): + CREATE = "create" + UPDATE = "update" + REFRESH = "refresh" + REPLACE = "replace" + DELETE = "delete" + + +@dataclass(frozen=True, slots=True) +class IMIdentityChangeSnapshot: + identity_id: IMIdentityId + provider: IMProvider + provider_user_id: ProviderUserId + display_name: str | None + email: str | None + normalized_email: NormalizedEmail | None + last_seen_sync_run_id: IMSyncRunId | None + + +@dataclass(frozen=True, slots=True) +class IMBindingChangeSnapshot: + binding_id: IMBindingId + identity_id: IMIdentityId + contact_id: ContactId + + +type IMReconciliationSnapshot = IMIdentityChangeSnapshot | IMBindingChangeSnapshot + + +@dataclass(frozen=True, slots=True) +class IMReconciliationChange: + """One append-only identity or IM binding mutation committed by a sync run.""" + + id: IMReconciliationChangeId + channel_id: IMChannelId + sync_run_id: IMSyncRunId + operation_key: str + subject_kind: IMReconciliationSubjectKind + operation: IMReconciliationOperation + reason_code: str + identity_id: IMIdentityId + binding_id: IMBindingId | None + contact_id: ContactId | None + before: IMReconciliationSnapshot | None + after: IMReconciliationSnapshot | None + committed_at: NaiveDatetime + + def __post_init__(self) -> None: + if not self.operation_key: + raise ValueError("operation key must not be empty") + expected_snapshot_type = ( + IMIdentityChangeSnapshot + if self.subject_kind is IMReconciliationSubjectKind.IDENTITY + else IMBindingChangeSnapshot + ) + if self.before is not None and not isinstance(self.before, expected_snapshot_type): + raise ValueError("before snapshot does not match subject kind") + if self.after is not None and not isinstance(self.after, expected_snapshot_type): + raise ValueError("after snapshot does not match subject kind") + if self.operation is IMReconciliationOperation.CREATE and (self.before is not None or self.after is None): + raise ValueError("create requires only an after snapshot") + if self.operation is IMReconciliationOperation.DELETE and (self.before is None or self.after is not None): + raise ValueError("delete requires only a before snapshot") + if self.operation not in (IMReconciliationOperation.CREATE, IMReconciliationOperation.DELETE) and ( + self.before is None or self.after is None + ): + raise ValueError("mutation requires before and after snapshots") + if self.subject_kind is IMReconciliationSubjectKind.IDENTITY: + if self.operation is IMReconciliationOperation.REPLACE or self.binding_id is not None: + raise ValueError("identity change cannot represent an IM binding replacement") + elif self.operation in (IMReconciliationOperation.UPDATE, IMReconciliationOperation.REFRESH): + raise ValueError("IM binding change cannot use an identity-only operation") diff --git a/api/core/human_input_v2/im_integration/ports.py b/api/core/human_input_v2/im_integration/ports.py new file mode 100644 index 00000000000..e0dba15c5b9 --- /dev/null +++ b/api/core/human_input_v2/im_integration/ports.py @@ -0,0 +1,155 @@ +"""Transaction-oriented persistence ports for IM Control Plane invariants. + +Implementations own CAS predicates, Channel row locks, eager loading, +revision-guarded apply, rollback, and append-only result persistence. Generic +table CRUD is intentionally absent. +""" + +from dataclasses import dataclass +from enum import StrEnum +from typing import Protocol + +from pydantic import NaiveDatetime + +from core.human_input_v2.entities import IMProvider, IMSyncResultType +from core.human_input_v2.shared import ( + AccountId, + ContactId, + DirectoryScope, + IMIdentityId, + IMSyncRunId, +) +from repositories.human_input_v2.im_channel_repository import IMChannelId + +from .sync_reconciliation import ReconciliationReasonCode +from .sync_records import ( + IMChannelRevision, + IMSyncRun, + StaleRevision, + SynchronizedIMIdentityPage, + SyncResultFact, + SyncResultPage, +) + + +class ActiveRunDecisionKind(StrEnum): + """Outcome of Channel-locked sync run creation.""" + + # No active run existed and a new revision-bound synchronization run was committed. + CREATED = "created" + + # An active run already exists, so the repository returns it instead of creating a duplicate. + EXISTING_ACTIVE = "existing_active" + + # The captured Channel revision is no longer current and no new run may be created from it. + STALE_REVISION = "stale_revision" + + +@dataclass(frozen=True, slots=True) +class ActiveRunDecision: + """New or existing active run, or a stable stale-revision rejection.""" + + kind: ActiveRunDecisionKind + run: IMSyncRun | None + stale_revision: StaleRevision | None = None + + +class ApplyReconciliationStatus(StrEnum): + """Stable outcome of one idempotent revision-guarded apply.""" + + # The plan was applied to current state and its append-only result facts were committed. + APPLIED = "applied" + + # This completed run was applied earlier; persisted facts are returned for idempotent replay. + ALREADY_APPLIED = "already_applied" + + # Channel configuration changed after capture, so the plan cannot mutate current state. + STALE_REVISION = "stale_revision" + + LOCK_UNAVAILABLE = "lock_unavailable" + LOCK_LOST = "lock_lost" + PRECONDITION_FAILED = "precondition_failed" + DIRECTORY_READ_FAILED = "directory_read_failed" + PLAN_BLOCKED = "plan_blocked" + UNEXPECTED_APPLY_FAILURE = "unexpected_apply_failure" + + +@dataclass(frozen=True, slots=True) +class ResolvedReconciliationWarning: + warning_key: str + reason: ReconciliationReasonCode + identity_ids: tuple[IMIdentityId, ...] + contact_ids: tuple[ContactId, ...] + + +@dataclass(frozen=True, slots=True) +class ApplyReconciliationResult: + """Transport-neutral persisted outcome of one conditional plan apply.""" + + status: ApplyReconciliationStatus + sync_run_id: IMSyncRunId + committed_at: NaiveDatetime | None + result_count: int + change_count: int + warnings: tuple[ResolvedReconciliationWarning, ...] + + +class IMSyncRepository(Protocol): + """Transport-neutral command and query persistence required by IM sync.""" + + def create_or_get_active_run( + self, + channel_revision: IMChannelRevision, + *, + organization_scope: DirectoryScope, + sync_run_id: IMSyncRunId, + started_by_account_id: AccountId | None, + now: NaiveDatetime, + ) -> ActiveRunDecision: + """Lock Channel and return at most one active run.""" + ... + + def load_sync_run(self, sync_run_id: IMSyncRunId) -> IMSyncRun | None: + """Load one persisted run without acquiring the Organization write lock.""" + ... + + def load_latest_sync_run(self, channel_id: IMChannelId) -> IMSyncRun | None: + """Load the latest run for one current Channel.""" + ... + + def page_sync_results( + self, + sync_run_id: IMSyncRunId, + result_type: IMSyncResultType, + *, + page: int, + limit: int, + ) -> SyncResultPage: + """Page one required product-result bucket for one exact run.""" + ... + + def search_identities( + self, + channel_id: IMChannelId, + provider: IMProvider, + *, + keyword: str | None, + page: int, + limit: int, + ) -> SynchronizedIMIdentityPage: + """Search current identities without exposing raw payload or ORM state.""" + ... + + def append_sync_results(self, results: tuple[SyncResultFact, ...]) -> None: + """Append diagnostic result facts without changing current state.""" + ... + + +__all__ = [ + "ActiveRunDecision", + "ActiveRunDecisionKind", + "ApplyReconciliationResult", + "ApplyReconciliationStatus", + "IMSyncRepository", + "ResolvedReconciliationWarning", +] diff --git a/api/core/human_input_v2/im_integration/records.py b/api/core/human_input_v2/im_integration/records.py new file mode 100644 index 00000000000..a05a6c8cb6e --- /dev/null +++ b/api/core/human_input_v2/im_integration/records.py @@ -0,0 +1,24 @@ +"""Historical synchronization payload values.""" + +from __future__ import annotations + +from collections.abc import Mapping + +from pydantic import ConfigDict, JsonValue, RootModel + + +class OpaqueProviderPayload(RootModel[dict[str, JsonValue]]): + """Opaque JSON retained by historical synchronization facts.""" + + model_config = ConfigDict(frozen=True, strict=True, validate_default=True) + + # TODO(QuantumGhost): Remove from_mapping and to_mapping. + @classmethod + def from_mapping(cls, values: Mapping[str, JsonValue]) -> OpaqueProviderPayload: + return cls(dict(values)) + + def to_mapping(self) -> dict[str, JsonValue]: + return dict(self.root) + + +__all__ = ["OpaqueProviderPayload"] diff --git a/api/core/human_input_v2/im_integration/sync_reconciliation.py b/api/core/human_input_v2/im_integration/sync_reconciliation.py new file mode 100644 index 00000000000..d8ffead419d --- /dev/null +++ b/api/core/human_input_v2/im_integration/sync_reconciliation.py @@ -0,0 +1,626 @@ +"""Pure provider directory matching and immutable reconciliation plans.""" + +from __future__ import annotations + +import hashlib +from collections import Counter, defaultdict +from dataclasses import dataclass +from enum import StrEnum + +from core.human_input_v2.entities import IMProvider, IMSyncRemovalReason, IMSyncResultType +from core.human_input_v2.im_integration.adapters.entities import DirectoryEntry, ProviderUserId +from core.human_input_v2.shared import ( + ContactId, + IMBindingId, + IMIdentityId, + IMSyncRunId, + NormalizedEmail, +) + +from .sync_records import IMChannelRevision + + +class IMIdentityUpsertKind(StrEnum): + """Semantic identity outcome selected by the pure planner.""" + + CREATE = "create" + UPDATE = "update" + REFRESH = "refresh" + + +class ReconciliationBlockCode(StrEnum): + """Whole-plan input or current-state invariants that prevent safe apply.""" + + DUPLICATE_PROVIDER_USER_ID = "duplicate_provider_user_id" + DUPLICATE_CURRENT_IDENTITY = "duplicate_current_identity" + INVALID_CURRENT_BINDING = "invalid_current_binding" + INVALID_RECONCILED_BINDING_SET = "invalid_reconciled_binding_set" + + +class ReconciliationReasonCode(StrEnum): + """Stable explanations for reconciliation changes and non-matches.""" + + PROVIDER_USER_ID_MATCH = "provider_user_id_match" + NORMALIZED_EMAIL_MATCH = "normalized_email_match" + MISSING_EMAIL = "missing_email" + NO_CONTACT_MATCH = "no_contact_match" + AMBIGUOUS_CONTACT_EMAIL = "ambiguous_contact_email" + AMBIGUOUS_PROVIDER_EMAIL = "ambiguous_provider_email" + CONTACT_ALREADY_BOUND = "contact_already_bound" + IDENTITY_ABSENT_FROM_DIRECTORY = "identity_absent_from_directory" + BINDING_REPLACED = "binding_replaced" + + +@dataclass(frozen=True, slots=True) +class ReconciliationRunRef: + """Immutable reconciliation namespace captured when a sync run starts.""" + + sync_run_id: IMSyncRunId + channel_revision: IMChannelRevision + provider: IMProvider + + +@dataclass(frozen=True, slots=True) +class CurrentIMIdentityState: + """Current persisted identity facts required by plan generation.""" + + identity_id: IMIdentityId + provider_user_id: ProviderUserId + display_name: str | None + email: str | None + normalized_email: NormalizedEmail | None + last_seen_sync_run_id: IMSyncRunId | None + + +@dataclass(frozen=True, slots=True) +class ContactEmailMatchState: + """A scope-resolved Contact available for automatic email matching.""" + + contact_id: ContactId + display_name: str + email: str | None + normalized_email: NormalizedEmail + avatar_file_id: str | None + + +@dataclass(frozen=True, slots=True) +class CurrentIMBindingState: + """Current Contact-to-IM-identity binding without persistence scope.""" + + binding_id: IMBindingId + identity_id: IMIdentityId + contact_id: ContactId + + +@dataclass(frozen=True, slots=True) +class ReconciliationInput: + """Complete immutable facts loaded before the pure planner is invoked.""" + + run: ReconciliationRunRef + directory_entries: tuple[DirectoryEntry, ...] + current_identities: tuple[CurrentIMIdentityState, ...] + current_bindings: tuple[CurrentIMBindingState, ...] + reconciled_binding_ids: frozenset[IMBindingId] + contacts_for_email_matching: tuple[ContactEmailMatchState, ...] + + +@dataclass(frozen=True, slots=True) +class ExistingIMIdentityRef: + identity_id: IMIdentityId + + +@dataclass(frozen=True, slots=True) +class NewIMIdentityRef: + provider_user_id: ProviderUserId + + +type IMIdentityRef = ExistingIMIdentityRef | NewIMIdentityRef + + +@dataclass(frozen=True, slots=True) +class IMIdentityUpsert: + operation_key: str + kind: IMIdentityUpsertKind + identity_ref: IMIdentityRef + entry: DirectoryEntry + normalized_email: NormalizedEmail | None + before: CurrentIMIdentityState | None + changed_fields: tuple[str, ...] + + +@dataclass(frozen=True, slots=True) +class CreateIMBinding: + operation_key: str + identity_ref: IMIdentityRef + contact_id: ContactId + contact_precondition: ContactEmailMatchState + reason: ReconciliationReasonCode + + def __post_init__(self) -> None: + if self.contact_precondition.contact_id != self.contact_id: + raise ValueError("Contact precondition must describe the binding target") + + +@dataclass(frozen=True, slots=True) +class ReplaceIMBinding: + operation_key: str + before: CurrentIMBindingState + next_identity_ref: IMIdentityRef + contact_precondition: ContactEmailMatchState + reason: ReconciliationReasonCode + removal_reason: IMSyncRemovalReason + + def __post_init__(self) -> None: + if self.contact_precondition.contact_id != self.before.contact_id: + raise ValueError("Contact precondition must describe the binding target") + + +@dataclass(frozen=True, slots=True) +class DeleteIMBinding: + operation_key: str + before: CurrentIMBindingState + reason: ReconciliationReasonCode + removal_reason: IMSyncRemovalReason + + +type IMBindingMutation = CreateIMBinding | ReplaceIMBinding | DeleteIMBinding + + +@dataclass(frozen=True, slots=True) +class IMIdentityDeletion: + operation_key: str + before: CurrentIMIdentityState + reason: ReconciliationReasonCode + + +@dataclass(frozen=True, slots=True) +class PlannedSyncResult: + operation_key: str + result_type: IMSyncResultType + provider_user_id: ProviderUserId | None + identity_ref: IMIdentityRef | None + binding_id: IMBindingId | None + contact_id: ContactId | None + reason_code: str | None + + +@dataclass(frozen=True, slots=True) +class PlannedReconciliationWarning: + warning_key: str + reason: ReconciliationReasonCode + identity_refs: tuple[IMIdentityRef, ...] + contact_ids: tuple[ContactId, ...] + + +@dataclass(frozen=True, slots=True) +class ReconciliationPlan: + """Complete deterministic plan applied in dependency order.""" + + run: ReconciliationRunRef + identity_upserts: tuple[IMIdentityUpsert, ...] + binding_mutations: tuple[IMBindingMutation, ...] + identity_deletions: tuple[IMIdentityDeletion, ...] + sync_results: tuple[PlannedSyncResult, ...] + warnings: tuple[PlannedReconciliationWarning, ...] + + +@dataclass(frozen=True, slots=True) +class ReconciliationBlock: + code: ReconciliationBlockCode + subject_key: str | None + message: str + + +@dataclass(frozen=True, slots=True) +class BlockedReconciliation: + run: ReconciliationRunRef + blockers: tuple[ReconciliationBlock, ...] + + +type PlanGenerationResult = ReconciliationPlan | BlockedReconciliation + + +class SyncReconciler: + """Stateless composite planner with no transport or infrastructure dependencies.""" + + @staticmethod + def generate_plan(reconciliation_input: ReconciliationInput) -> PlanGenerationResult: + blockers = _validate_input(reconciliation_input) + if blockers: + return BlockedReconciliation(reconciliation_input.run, blockers) + + entries = tuple(sorted(reconciliation_input.directory_entries, key=lambda entry: str(entry.provider_user_id))) + identities = tuple( + sorted(reconciliation_input.current_identities, key=lambda identity: str(identity.identity_id)) + ) + current_identity_by_provider_id = {identity.provider_user_id: identity for identity in identities} + directory_provider_ids = {entry.provider_user_id for entry in entries} + + identity_upserts: list[IMIdentityUpsert] = [] + identity_ref_by_provider_id: dict[ProviderUserId, IMIdentityRef] = {} + normalized_email_by_provider_id: dict[ProviderUserId, NormalizedEmail | None] = {} + for entry in entries: + current_identity = current_identity_by_provider_id.get(entry.provider_user_id) + normalized_email = _normalize_email(entry.email) + identity_ref: IMIdentityRef + if current_identity is None: + kind = IMIdentityUpsertKind.CREATE + identity_ref = NewIMIdentityRef(entry.provider_user_id) + changed_fields: tuple[str, ...] = () + else: + identity_ref = ExistingIMIdentityRef(current_identity.identity_id) + changed_fields = _changed_identity_fields(current_identity, entry, normalized_email) + kind = IMIdentityUpsertKind.UPDATE if changed_fields else IMIdentityUpsertKind.REFRESH + identity_ref_by_provider_id[entry.provider_user_id] = identity_ref + normalized_email_by_provider_id[entry.provider_user_id] = normalized_email + identity_upserts.append( + IMIdentityUpsert( + operation_key=_stable_key("identity", kind.value, str(entry.provider_user_id)), + kind=kind, + identity_ref=identity_ref, + entry=entry, + normalized_email=normalized_email, + before=current_identity, + changed_fields=changed_fields, + ) + ) + + current_identity_by_id = {identity.identity_id: identity for identity in identities} + current_binding_by_id = { + binding.binding_id: binding + for binding in sorted(reconciliation_input.current_bindings, key=lambda binding: str(binding.binding_id)) + } + reconciled_bindings = tuple( + current_binding_by_id[binding_id] + for binding_id in sorted(reconciliation_input.reconciled_binding_ids, key=str) + ) + reconciled_binding_by_identity = {binding.identity_id: binding for binding in reconciled_bindings} + reconciled_binding_by_contact = {binding.contact_id: binding for binding in reconciled_bindings} + + preserved_binding_by_provider_id: dict[ProviderUserId, CurrentIMBindingState] = {} + for entry in entries: + identity = current_identity_by_provider_id.get(entry.provider_user_id) + if identity is None: + continue + binding = reconciled_binding_by_identity.get(identity.identity_id) + if binding is not None: + preserved_binding_by_provider_id[entry.provider_user_id] = binding + + unbound_entries_by_email: dict[NormalizedEmail, list[DirectoryEntry]] = defaultdict(list) + for entry in entries: + normalized_email = normalized_email_by_provider_id[entry.provider_user_id] + if entry.provider_user_id not in preserved_binding_by_provider_id and normalized_email is not None: + unbound_entries_by_email[normalized_email].append(entry) + + contacts_by_email: dict[NormalizedEmail, list[ContactEmailMatchState]] = defaultdict(list) + for contact in sorted( + reconciliation_input.contacts_for_email_matching, key=lambda contact: str(contact.contact_id) + ): + contacts_by_email[contact.normalized_email].append(contact) + + binding_mutations: list[IMBindingMutation] = [] + sync_results: list[PlannedSyncResult] = [] + warnings: list[PlannedReconciliationWarning] = [] + replaced_binding_ids: set[IMBindingId] = set() + warned_contact_emails: set[NormalizedEmail] = set() + + for entry in entries: + identity_ref = identity_ref_by_provider_id[entry.provider_user_id] + preserved_binding = preserved_binding_by_provider_id.get(entry.provider_user_id) + if preserved_binding is not None: + sync_results.append( + _planned_result( + IMSyncResultType.SKIPPED, + entry.provider_user_id, + identity_ref, + binding_id=preserved_binding.binding_id, + contact_id=preserved_binding.contact_id, + reason=ReconciliationReasonCode.PROVIDER_USER_ID_MATCH, + ) + ) + continue + + normalized_email = normalized_email_by_provider_id[entry.provider_user_id] + if normalized_email is None: + sync_results.append( + _not_matched_result(entry.provider_user_id, identity_ref, ReconciliationReasonCode.MISSING_EMAIL) + ) + continue + + matching_contacts = contacts_by_email.get(normalized_email, []) + if len(matching_contacts) > 1: + sync_results.append( + _not_matched_result( + entry.provider_user_id, identity_ref, ReconciliationReasonCode.AMBIGUOUS_CONTACT_EMAIL + ) + ) + if normalized_email not in warned_contact_emails: + affected_entries = unbound_entries_by_email[normalized_email] + warnings.append( + PlannedReconciliationWarning( + warning_key=_stable_key( + "warning", + ReconciliationReasonCode.AMBIGUOUS_CONTACT_EMAIL.value, + str(normalized_email), + ), + reason=ReconciliationReasonCode.AMBIGUOUS_CONTACT_EMAIL, + identity_refs=tuple( + identity_ref_by_provider_id[item.provider_user_id] + for item in sorted(affected_entries, key=lambda item: str(item.provider_user_id)) + ), + contact_ids=tuple(contact.contact_id for contact in matching_contacts), + ) + ) + warned_contact_emails.add(normalized_email) + continue + + if not matching_contacts: + sync_results.append( + _not_matched_result(entry.provider_user_id, identity_ref, ReconciliationReasonCode.NO_CONTACT_MATCH) + ) + continue + + if len(unbound_entries_by_email[normalized_email]) > 1: + sync_results.append( + _not_matched_result( + entry.provider_user_id, identity_ref, ReconciliationReasonCode.AMBIGUOUS_PROVIDER_EMAIL + ) + ) + continue + + contact = matching_contacts[0] + occupying_binding = reconciled_binding_by_contact.get(contact.contact_id) + if occupying_binding is not None: + occupying_identity = current_identity_by_id[occupying_binding.identity_id] + if occupying_identity.provider_user_id in directory_provider_ids: + sync_results.append( + _not_matched_result( + entry.provider_user_id, identity_ref, ReconciliationReasonCode.CONTACT_ALREADY_BOUND + ) + ) + continue + binding_mutations.append( + ReplaceIMBinding( + operation_key=_stable_key( + "binding", "replace", str(occupying_binding.binding_id), str(entry.provider_user_id) + ), + before=occupying_binding, + next_identity_ref=identity_ref, + contact_precondition=contact, + reason=ReconciliationReasonCode.NORMALIZED_EMAIL_MATCH, + removal_reason=IMSyncRemovalReason.BINDING_REPLACED, + ) + ) + replaced_binding_ids.add(occupying_binding.binding_id) + else: + binding_mutations.append( + CreateIMBinding( + operation_key=_stable_key( + "binding", "create", str(entry.provider_user_id), str(contact.contact_id) + ), + identity_ref=identity_ref, + contact_id=contact.contact_id, + contact_precondition=contact, + reason=ReconciliationReasonCode.NORMALIZED_EMAIL_MATCH, + ) + ) + sync_results.append( + _planned_result( + IMSyncResultType.ADDED, + entry.provider_user_id, + identity_ref, + binding_id=None, + contact_id=contact.contact_id, + reason=ReconciliationReasonCode.NORMALIZED_EMAIL_MATCH, + ) + ) + + absent_identities = tuple( + identity for identity in identities if identity.provider_user_id not in directory_provider_ids + ) + absent_identity_ids = {identity.identity_id for identity in absent_identities} + for binding in current_binding_by_id.values(): + if binding.identity_id not in absent_identity_ids: + continue + previous_identity = current_identity_by_id[binding.identity_id] + if binding.binding_id in replaced_binding_ids: + removal_reason = IMSyncRemovalReason.BINDING_REPLACED + reason = ReconciliationReasonCode.BINDING_REPLACED + else: + removal_reason = IMSyncRemovalReason.NOT_PRESENT_IN_DIRECTORY + reason = ReconciliationReasonCode.IDENTITY_ABSENT_FROM_DIRECTORY + binding_mutations.append( + DeleteIMBinding( + operation_key=_stable_key("binding", "delete", str(binding.binding_id)), + before=binding, + reason=reason, + removal_reason=removal_reason, + ) + ) + sync_results.append( + _planned_result( + IMSyncResultType.REMOVED, + previous_identity.provider_user_id, + ExistingIMIdentityRef(previous_identity.identity_id), + binding_id=binding.binding_id, + contact_id=binding.contact_id, + reason=reason, + ) + ) + + identity_deletions = tuple( + IMIdentityDeletion( + operation_key=_stable_key("identity", "delete", str(identity.identity_id)), + before=identity, + reason=ReconciliationReasonCode.IDENTITY_ABSENT_FROM_DIRECTORY, + ) + for identity in absent_identities + ) + return ReconciliationPlan( + run=reconciliation_input.run, + identity_upserts=tuple(identity_upserts), + binding_mutations=tuple(binding_mutations), + identity_deletions=identity_deletions, + sync_results=tuple(sync_results), + warnings=tuple(warnings), + ) + + +def _validate_input(reconciliation_input: ReconciliationInput) -> tuple[ReconciliationBlock, ...]: + blockers: list[ReconciliationBlock] = [] + directory_provider_ids = [entry.provider_user_id for entry in reconciliation_input.directory_entries] + for provider_user_id, count in Counter(directory_provider_ids).items(): + if count > 1: + blockers.append( + ReconciliationBlock( + ReconciliationBlockCode.DUPLICATE_PROVIDER_USER_ID, + str(provider_user_id), + "Directory contains a duplicate Provider user ID.", + ) + ) + + current_identity_ids = [identity.identity_id for identity in reconciliation_input.current_identities] + current_provider_ids = [identity.provider_user_id for identity in reconciliation_input.current_identities] + for identity_id, count in Counter(current_identity_ids).items(): + if count > 1: + blockers.append( + ReconciliationBlock( + ReconciliationBlockCode.DUPLICATE_CURRENT_IDENTITY, + f"identity_id:{identity_id}", + "Current identity ID is not unique.", + ) + ) + for provider_user_id, count in Counter(current_provider_ids).items(): + if count > 1: + blockers.append( + ReconciliationBlock( + ReconciliationBlockCode.DUPLICATE_CURRENT_IDENTITY, + f"provider_user_id:{provider_user_id}", + "Current Provider user ID is not unique.", + ) + ) + + current_identity_id_set = set(current_identity_ids) + binding_ids = [binding.binding_id for binding in reconciliation_input.current_bindings] + for binding_id, count in Counter(binding_ids).items(): + if count > 1: + blockers.append( + ReconciliationBlock( + ReconciliationBlockCode.INVALID_CURRENT_BINDING, + str(binding_id), + "Current IM binding ID is not unique.", + ) + ) + for binding in reconciliation_input.current_bindings: + if binding.identity_id not in current_identity_id_set: + blockers.append( + ReconciliationBlock( + ReconciliationBlockCode.INVALID_CURRENT_BINDING, + str(binding.binding_id), + "Current IM binding references an identity outside current identities.", + ) + ) + + current_binding_id_set = set(binding_ids) + for binding_id in reconciliation_input.reconciled_binding_ids - current_binding_id_set: + blockers.append( + ReconciliationBlock( + ReconciliationBlockCode.INVALID_RECONCILED_BINDING_SET, + str(binding_id), + "Reconciled IM binding ID is not present in current bindings.", + ) + ) + reconciled_bindings = [ + binding + for binding in reconciliation_input.current_bindings + if binding.binding_id in reconciliation_input.reconciled_binding_ids + ] + for identity_id, count in Counter(binding.identity_id for binding in reconciled_bindings).items(): + if count > 1: + blockers.append( + ReconciliationBlock( + ReconciliationBlockCode.INVALID_RECONCILED_BINDING_SET, + f"identity_id:{identity_id}", + "Reconciled IM bindings are not one-to-one by identity.", + ) + ) + for contact_id, count in Counter(binding.contact_id for binding in reconciled_bindings).items(): + if count > 1: + blockers.append( + ReconciliationBlock( + ReconciliationBlockCode.INVALID_RECONCILED_BINDING_SET, + f"contact_id:{contact_id}", + "Reconciled IM bindings are not one-to-one by Contact.", + ) + ) + return tuple(sorted(blockers, key=lambda block: (block.code.value, block.subject_key or "", block.message))) + + +def _normalize_email(email: str | None) -> NormalizedEmail | None: + if email is None or not email.strip(): + return None + try: + return NormalizedEmail(email) + except ValueError: + return None + + +def _changed_identity_fields( + current_identity: CurrentIMIdentityState, + entry: DirectoryEntry, + normalized_email: NormalizedEmail | None, +) -> tuple[str, ...]: + desired_values = ( + ("display_name", current_identity.display_name, entry.display_name), + ("email", current_identity.email, entry.email), + ("normalized_email", current_identity.normalized_email, normalized_email), + ) + return tuple( + field_name for field_name, current_value, desired_value in desired_values if current_value != desired_value + ) + + +def _stable_key(kind: str, operation: str, *subjects: str) -> str: + semantic_key = "\x1f".join((kind, operation, *subjects)).encode() + return f"{kind}:{operation}:{hashlib.sha256(semantic_key).hexdigest()}" + + +def _not_matched_result( + provider_user_id: ProviderUserId, + identity_ref: IMIdentityRef, + reason: ReconciliationReasonCode, +) -> PlannedSyncResult: + return _planned_result( + IMSyncResultType.NOT_MATCHED, + provider_user_id, + identity_ref, + binding_id=None, + contact_id=None, + reason=reason, + ) + + +def _planned_result( + result_type: IMSyncResultType, + provider_user_id: ProviderUserId, + identity_ref: IMIdentityRef, + *, + binding_id: IMBindingId | None, + contact_id: ContactId | None, + reason: ReconciliationReasonCode, +) -> PlannedSyncResult: + subjects = ( + str(provider_user_id), + str(binding_id) if binding_id is not None else "", + str(contact_id) if contact_id is not None else "", + reason.value, + ) + return PlannedSyncResult( + operation_key=_stable_key("result", result_type.value, *subjects), + result_type=result_type, + provider_user_id=provider_user_id, + identity_ref=identity_ref, + binding_id=binding_id, + contact_id=contact_id, + reason_code=reason.value, + ) diff --git a/api/core/human_input_v2/im_integration/sync_records.py b/api/core/human_input_v2/im_integration/sync_records.py new file mode 100644 index 00000000000..835627af2ca --- /dev/null +++ b/api/core/human_input_v2/im_integration/sync_records.py @@ -0,0 +1,187 @@ +"""Immutable synchronization run and product-result persistence values.""" + +from __future__ import annotations + +from dataclasses import dataclass, replace + +from pydantic import NaiveDatetime + +from core.human_input_v2.entities import ( + IMIdentityBindingStatus, + IMProvider, + IMSyncRemovalReason, + IMSyncResultType, + IMSyncRunStatus, +) +from core.human_input_v2.shared import ( + AccountId, + ContactId, + IMBindingId, + IMIdentityId, + IMSyncResultId, + IMSyncRunId, + NormalizedEmail, +) +from repositories.human_input_v2.im_channel_repository import IMChannelId + +from .records import OpaqueProviderPayload + + +@dataclass(frozen=True, slots=True) +class IMChannelRevision: + """Historical configuration token captured by one synchronization run.""" + + channel_id: str + config_version: int + + def __post_init__(self) -> None: + if self.config_version < 1: + raise ValueError("config version must be positive") + + +@dataclass(frozen=True, slots=True) +class StaleRevision: + """Stable rejection for a synchronization token that is no longer current.""" + + expected: IMChannelRevision + actual: IMChannelRevision | None + + +@dataclass(frozen=True, slots=True) +class SyncContactSnapshot: + """Immutable Contact display values retained by a historical result.""" + + contact_id: ContactId + name: str + email: str | None + avatar_file_id: str | None + created_at: NaiveDatetime | None = None + + +@dataclass(frozen=True, slots=True) +class SyncIdentitySnapshot: + """Immutable last-known provider identity retained after current deletion.""" + + identity_id: IMIdentityId + provider: IMProvider + provider_user_id: str + display_name: str | None + email: str | None + + +@dataclass(frozen=True, slots=True) +class SyncResultFact: + """Append-only product outcome for one binding decision or diagnostic.""" + + id: IMSyncResultId + channel_id: IMChannelId + sync_run_id: IMSyncRunId + operation_key: str | None + result_type: IMSyncResultType + provider_user_id: str | None + display_name: str | None + email: str | None + normalized_email: NormalizedEmail | None + contact_id: ContactId | None + identity_id: IMIdentityId | None + binding_id: IMBindingId | None + removal_reason: IMSyncRemovalReason | None + reason_code: str | None + reason_message: str | None + directory_entry_payload: OpaqueProviderPayload | None + contact_snapshot: SyncContactSnapshot | None + identity_snapshot: SyncIdentitySnapshot | None + created_at: NaiveDatetime + updated_at: NaiveDatetime + + +@dataclass(frozen=True, slots=True) +class SyncResultPage: + """Stable one-bucket page of product-facing synchronization facts.""" + + items: tuple[SyncResultFact, ...] + page: int + limit: int + total: int + + +@dataclass(frozen=True, slots=True) +class SynchronizedIMIdentity: + """Search-safe current identity projection without Provider raw payload.""" + + id: IMIdentityId + provider: IMProvider + provider_user_id: str + display_name: str | None + email: str | None + binding_status: IMIdentityBindingStatus + + +@dataclass(frozen=True, slots=True) +class SynchronizedIMIdentityPage: + """Stable page of current synchronized identities.""" + + items: tuple[SynchronizedIMIdentity, ...] + page: int + limit: int + total: int + + +@dataclass(frozen=True, slots=True) +class IMSyncRun: + """Independent sync aggregate that captures one complete Channel revision.""" + + id: IMSyncRunId + channel_revision: IMChannelRevision + provider: IMProvider + status: IMSyncRunStatus + added_count: int + not_matched_count: int + failed_count: int + removed_count: int + skipped_count: int + started_by_account_id: AccountId | None + started_at: NaiveDatetime | None + finished_at: NaiveDatetime | None + error_code: str | None + error_message: str | None + created_at: NaiveDatetime + updated_at: NaiveDatetime + + @classmethod + def create( + cls, + *, + sync_run_id: IMSyncRunId, + channel_revision: IMChannelRevision, + provider: IMProvider, + started_by_account_id: AccountId | None, + now: NaiveDatetime, + ) -> IMSyncRun: + return cls( + id=sync_run_id, + channel_revision=channel_revision, + provider=provider, + status=IMSyncRunStatus.QUEUED, + added_count=0, + not_matched_count=0, + failed_count=0, + removed_count=0, + skipped_count=0, + started_by_account_id=started_by_account_id, + started_at=None, + finished_at=None, + error_code=None, + error_message=None, + created_at=now, + updated_at=now, + ) + + @property + def is_active(self) -> bool: + return self.status in (IMSyncRunStatus.QUEUED, IMSyncRunStatus.RUNNING) + + def start(self, now: NaiveDatetime) -> IMSyncRun: + if self.status is not IMSyncRunStatus.QUEUED: + return self + return replace(self, status=IMSyncRunStatus.RUNNING, started_at=now, updated_at=now) diff --git a/api/core/human_input_v2/im_message_inbox/__init__.py b/api/core/human_input_v2/im_message_inbox/__init__.py new file mode 100644 index 00000000000..687680a2acd --- /dev/null +++ b/api/core/human_input_v2/im_message_inbox/__init__.py @@ -0,0 +1,31 @@ +"""Durable IM callback record contracts.""" + +from .contracts import ( + IM_INBOX_PROVIDER_METADATA_MAX_LENGTH, + AcceptanceKind, + IMInboxConsumer, + IMInboxRecord, + IMInboxRecordId, + IMMessageInboxRepository, + InboxAcceptance, + InboxEventValidationError, + InboxPersistenceError, + canonicalize_inbox_event, + validate_inbox_event, + validate_inbox_provider_tenant_id, +) + +__all__ = [ + "IM_INBOX_PROVIDER_METADATA_MAX_LENGTH", + "AcceptanceKind", + "IMInboxConsumer", + "IMInboxRecord", + "IMInboxRecordId", + "IMMessageInboxRepository", + "InboxAcceptance", + "InboxEventValidationError", + "InboxPersistenceError", + "canonicalize_inbox_event", + "validate_inbox_event", + "validate_inbox_provider_tenant_id", +] diff --git a/api/core/human_input_v2/im_message_inbox/contracts.py b/api/core/human_input_v2/im_message_inbox/contracts.py new file mode 100644 index 00000000000..b35c6fd80eb --- /dev/null +++ b/api/core/human_input_v2/im_message_inbox/contracts.py @@ -0,0 +1,111 @@ +"""Domain contracts for durable IM callback records.""" + +from __future__ import annotations + +from dataclasses import dataclass, replace +from datetime import datetime +from enum import StrEnum +from typing import NewType, Protocol + +from core.human_input_v2.im_integration.adapters.entities import AuthenticatedIMEvent +from repositories.human_input_v2.im_channel_repository import IMChannelId + +IM_INBOX_PROVIDER_METADATA_MAX_LENGTH = 128 + + +class InboxEventValidationError(ValueError): + """Provider metadata cannot be represented by the durable inbox contract.""" + + field_name: str + maximum_length: int + + def __init__(self, field_name: str, maximum_length: int) -> None: + self.field_name = field_name + self.maximum_length = maximum_length + super().__init__(f"{field_name} must not exceed {maximum_length} characters") + + +def validate_inbox_provider_tenant_id(provider_tenant_id: str) -> None: + """Reject a Provider tenant identity that cannot fit one inbox record.""" + + if len(provider_tenant_id) > IM_INBOX_PROVIDER_METADATA_MAX_LENGTH: + raise InboxEventValidationError("provider tenant id", IM_INBOX_PROVIDER_METADATA_MAX_LENGTH) + + +def canonicalize_inbox_event(event: AuthenticatedIMEvent) -> AuthenticatedIMEvent: + """Represent a blank Provider event ID as an absent deduplication identity.""" + + if event.event_id is None or event.event_id.strip(): + return event + return replace(event, event_id=None) + + +def validate_inbox_event(event: AuthenticatedIMEvent) -> None: + """Reject Provider metadata that cannot fit one inbox record without truncation.""" + + validate_inbox_provider_tenant_id(event.provider_tenant_id) + if event.event_id is not None and len(event.event_id) > IM_INBOX_PROVIDER_METADATA_MAX_LENGTH: + raise InboxEventValidationError("provider event id", IM_INBOX_PROVIDER_METADATA_MAX_LENGTH) + if event.event_type is not None and len(event.event_type) > IM_INBOX_PROVIDER_METADATA_MAX_LENGTH: + raise InboxEventValidationError("provider event type", IM_INBOX_PROVIDER_METADATA_MAX_LENGTH) + + +IMInboxRecordId = NewType("IMInboxRecordId", str) + + +class AcceptanceKind(StrEnum): + """Whether intake inserted a record or resolved an identified callback.""" + + NEW = "new" + DUPLICATE = "duplicate" + + +@dataclass(frozen=True, slots=True) +class InboxAcceptance: + """Durably committed callback record.""" + + record_id: IMInboxRecordId + kind: AcceptanceKind + + +@dataclass(frozen=True, slots=True) +class IMInboxRecord: + """Persisted callback facts reconstructed for Celery processing.""" + + record_id: IMInboxRecordId + channel_id: IMChannelId + event: AuthenticatedIMEvent + processed_at: datetime | None + # Absent for stream events and callbacks saved before IP capture was added. + request_ip_address: str | None = None + + +class InboxPersistenceError(Exception): + """Expected database failure while storing or updating an inbox record.""" + + +class IMInboxConsumer(Protocol): + """Application callback consumer invoked by the Celery task.""" + + def consume(self, record: IMInboxRecord) -> None: + """Process one callback or raise so Celery can retry the task.""" + + +class IMMessageInboxRepository(Protocol): + """Persistence operations for durable callback facts.""" + + def insert_or_resolve( + self, + channel_id: IMChannelId, + event: AuthenticatedIMEvent, + *, + now: datetime, + request_ip_address: str | None = None, + ) -> InboxAcceptance: + """Commit a callback or resolve its identified duplicate.""" + + def get(self, record_id: IMInboxRecordId) -> IMInboxRecord | None: + """Load one durable callback record.""" + + def mark_processed(self, record_id: IMInboxRecordId, *, processed_at: datetime) -> None: + """Record that callback processing completed successfully.""" diff --git a/api/core/human_input_v2/resolved_form.py b/api/core/human_input_v2/resolved_form.py new file mode 100644 index 00000000000..155038c2d8a --- /dev/null +++ b/api/core/human_input_v2/resolved_form.py @@ -0,0 +1,100 @@ +"""Channel-neutral resolved presentation values for Human Input v2 forms.""" + +from __future__ import annotations + +import abc +from enum import StrEnum +from typing import Annotated, Literal + +from pydantic import BaseModel, ConfigDict, Field + +from core.human_input import ButtonStyle +from graphon.file.enums import FileTransferMethod, FileType + + +class FormPartType(StrEnum): + MARKDOWN_FRAGMENT = "markdown_fragment" + PARAGRAPH_INPUT = "paragraph_input" + SELECT_INPUT = "select_input" + FILE_INPUT = "file_input" + FILE_LIST_INPUT = "file_list_input" + + +class MarkdownFragment(BaseModel): + """One source-ordered Markdown fragment in a resolved form.""" + + model_config = ConfigDict(extra="forbid", frozen=True, strict=True, validate_default=True) + + type: Literal[FormPartType.MARKDOWN_FRAGMENT] = FormPartType.MARKDOWN_FRAGMENT + text: str + + +class _BasicInput(BaseModel, abc.ABC): + model_config = ConfigDict(extra="forbid", frozen=True, strict=True, validate_default=True) + + output_variable_name: str + + +class ParagraphInput(_BasicInput): + """Resolved multiline text input.""" + + type: Literal[FormPartType.PARAGRAPH_INPUT] = FormPartType.PARAGRAPH_INPUT + # Populated only when a default value is configured; otherwise None. + default_value: str | None + + +class SelectInput(_BasicInput): + """Resolved single-choice input with immutable concrete options.""" + + type: Literal[FormPartType.SELECT_INPUT] = FormPartType.SELECT_INPUT + options: tuple[str, ...] + # Populated only when a default value is configured; otherwise None. + default_value: str | None + + +class FileInput(_BasicInput): + """Resolved single-file input constraints.""" + + type: Literal[FormPartType.FILE_INPUT] = FormPartType.FILE_INPUT + allowed_file_types: tuple[FileType, ...] + allowed_file_extensions: tuple[str, ...] + allowed_file_upload_methods: tuple[FileTransferMethod, ...] + + +class FileListInput(_BasicInput): + """Resolved multi-file input constraints and effective number limit.""" + + type: Literal[FormPartType.FILE_LIST_INPUT] = FormPartType.FILE_LIST_INPUT + allowed_file_types: tuple[FileType, ...] + allowed_file_extensions: tuple[str, ...] + allowed_file_upload_methods: tuple[FileTransferMethod, ...] + number_limits: int + + +class UserAction(BaseModel): + """Resolved action values required for presentation and submission validation.""" + + model_config = ConfigDict(extra="forbid", frozen=True, strict=True, validate_default=True) + + id: str + title: str + button_style: ButtonStyle + + +type FormPart = Annotated[ + MarkdownFragment | ParagraphInput | SelectInput | FileInput | FileListInput, + Field(discriminator="type"), +] + + +class ResolvedForm(BaseModel): + """Immutable authoritative presentation snapshot for one v2 form.""" + + model_config = ConfigDict(extra="forbid", frozen=True, strict=True, validate_default=True) + + # An empty string represents a form without a title. + title: str + parts: tuple[FormPart, ...] + actions: tuple[UserAction, ...] + # All non-output variables are resolved; {{#$output.#}} slots remain. + legacy_form_content: str diff --git a/api/core/human_input_v2/shared/__init__.py b/api/core/human_input_v2/shared/__init__.py new file mode 100644 index 00000000000..81e1b136926 --- /dev/null +++ b/api/core/human_input_v2/shared/__init__.py @@ -0,0 +1,39 @@ +"""Stable Human Input v2 values shared across domain contexts. + +This package contains infrastructure-free values only. Feature-specific ownership +and lifecycle rules belong to their bounded context rather than this package. +""" + +from .values import ( + AccountId, + ContactId, + DeploymentScope, + DirectoryScope, + EmailProviderId, + IMBindingId, + IMIdentityId, + IMReconciliationChangeId, + IMSyncResultId, + IMSyncRunId, + NormalizedEmail, + PlatformEntryId, + TenantId, + WorkspaceScope, +) + +__all__ = [ + "AccountId", + "ContactId", + "DeploymentScope", + "DirectoryScope", + "EmailProviderId", + "IMBindingId", + "IMIdentityId", + "IMReconciliationChangeId", + "IMSyncResultId", + "IMSyncRunId", + "NormalizedEmail", + "PlatformEntryId", + "TenantId", + "WorkspaceScope", +] diff --git a/api/core/human_input_v2/shared/values.py b/api/core/human_input_v2/shared/values.py new file mode 100644 index 00000000000..9af727bd901 --- /dev/null +++ b/api/core/human_input_v2/shared/values.py @@ -0,0 +1,62 @@ +"""Primitive-independent identifiers, scopes, email, and time values.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import NewType, override + +AccountId = NewType("AccountId", str) +ContactId = NewType("ContactId", str) +RecipientId = NewType("RecipientId", str) +PlatformEntryId = NewType("PlatformEntryId", str) +TenantId = NewType("TenantId", str) +EmailProviderId = NewType("EmailProviderId", str) +IMIdentityId = NewType("IMIdentityId", str) +IMBindingId = NewType("IMBindingId", str) +IMReconciliationChangeId = NewType("IMReconciliationChangeId", str) +IMSyncRunId = NewType("IMSyncRunId", str) +IMSyncResultId = NewType("IMSyncResultId", str) + + +@dataclass(frozen=True, slots=True) +class NormalizedEmail: + """Case-insensitive canonical email used for identity comparisons.""" + + value: str + + def __post_init__(self) -> None: + if not isinstance(self.value, str): + raise ValueError("value must be a valid email") + normalized = self.value.strip().casefold() + local, separator, domain = normalized.partition("@") + if not separator or not local or not domain or " " in normalized or "@" in domain: + raise ValueError("value must be a valid email") + object.__setattr__(self, "value", normalized) + + @override + def __str__(self) -> str: + return self.value + + def to_primitive(self) -> str: + return self.value + + +@dataclass(frozen=True, slots=True) +class DeploymentScope: + """Deployment-wide owner scope used by EE Organization contacts.""" + + def to_primitive(self) -> dict[str, str]: + return {"kind": "deployment"} + + +@dataclass(frozen=True, slots=True) +class WorkspaceScope: + """Workspace scope whose id is the corresponding Dify Tenant.id.""" + + id: TenantId + + def to_primitive(self) -> dict[str, str]: + return {"kind": "workspace", "id": self.id} + + +type DirectoryScope = DeploymentScope | WorkspaceScope diff --git a/api/core/repositories/human_input_repository.py b/api/core/repositories/human_input_repository.py index 34630d1af1c..c3d42d8a81c 100644 --- a/api/core/repositories/human_input_repository.py +++ b/api/core/repositories/human_input_repository.py @@ -14,6 +14,10 @@ from core.workflow.human_input_adapter import ( EmailDeliveryMethod, EmailRecipients, ExternalRecipient, + InstantMessageChannelRecipient, + InstantMessageDeliveryConfig, + InstantMessageDeliveryMethod, + InstantMessageUserRecipient, InteractiveSurfaceDeliveryMethod, is_human_input_webapp_enabled, ) @@ -32,6 +36,7 @@ from models.human_input import ( HumanInputDelivery, HumanInputForm, HumanInputFormRecipient, + InstantMessageRecipientPayload, RecipientType, StandaloneWebAppRecipientPayload, ) @@ -326,9 +331,48 @@ class HumanInputFormRepositoryImpl: recipients_config=email_recipients_config, ) ) + case InstantMessageDeliveryMethod(): + recipients.extend( + self._build_instant_message_recipients( + form_id=form_id, + delivery_id=delivery_id, + config=delivery_method.config, + ) + ) return _DeliveryAndRecipients(delivery=delivery_model, recipients=recipients) + @staticmethod + def _build_instant_message_recipients( + *, + form_id: str, + delivery_id: str, + config: InstantMessageDeliveryConfig, + ) -> list[HumanInputFormRecipient]: + recipient_models: list[HumanInputFormRecipient] = [] + for recipient in config.recipients.items: + match recipient: + case InstantMessageChannelRecipient(): + payload = InstantMessageRecipientPayload( + provider=config.provider, + recipient_kind=recipient.type, + channel_id=recipient.channel_id, + ) + case InstantMessageUserRecipient(): + payload = InstantMessageRecipientPayload( + provider=config.provider, + recipient_kind=recipient.type, + user_id=recipient.user_id, + ) + recipient_models.append( + HumanInputFormRecipient.new( + form_id=form_id, + delivery_id=delivery_id, + payload=payload, + ) + ) + return recipient_models + def _build_email_recipients( self, session: Session, diff --git a/api/core/tools/entities/tool_entities.py b/api/core/tools/entities/tool_entities.py index 1cab639cd50..5be418cdac9 100644 --- a/api/core/tools/entities/tool_entities.py +++ b/api/core/tools/entities/tool_entities.py @@ -261,7 +261,6 @@ class ToolInvokeMessage(BaseModel): @field_validator("message", mode="before") @classmethod def decode_blob_message(cls, v, info: ValidationInfo): - # 处理 blob 解码 if isinstance(v, dict) and "blob" in v: with contextlib.suppress(Exception): v["blob"] = base64.b64decode(v["blob"]) diff --git a/api/core/workflow/human_input_adapter.py b/api/core/workflow/human_input_adapter.py index ee99d78246e..f68625b1272 100644 --- a/api/core/workflow/human_input_adapter.py +++ b/api/core/workflow/human_input_adapter.py @@ -5,6 +5,8 @@ Dify-owned field spellings and value shapes. Adapt them here before handing the payload to Graphon so Graphon-owned models only see current contracts. """ +# TODO(QuantumGhost): This should be migrated back to human_input.HumanInputNodeData. + from __future__ import annotations import enum @@ -27,6 +29,7 @@ from graphon.variables.template_resolution import convert_template class DeliveryMethodType(enum.StrEnum): WEBAPP = enum.auto() EMAIL = enum.auto() + IM = enum.auto() class EmailRecipientType(enum.StrEnum): @@ -35,6 +38,17 @@ class EmailRecipientType(enum.StrEnum): EXTERNAL = "external" +class InstantMessageProvider(enum.StrEnum): + SLACK = "slack" + TEAMS = "teams" + DISCORD = "discord" + + +class InstantMessageRecipientType(enum.StrEnum): + CHANNEL = "channel" + USER = "user" + + class _InteractiveSurfaceDeliveryConfig(BaseModel): pass @@ -57,6 +71,25 @@ MemberRecipient = BoundRecipient EmailRecipient = Annotated[BoundRecipient | ExternalRecipient, Field(discriminator="type")] +class InstantMessageChannelRecipient(BaseModel): + model_config = ConfigDict(extra="forbid") + + type: Literal[InstantMessageRecipientType.CHANNEL] = InstantMessageRecipientType.CHANNEL + channel_id: str + + +class InstantMessageUserRecipient(BaseModel): + model_config = ConfigDict(extra="forbid") + + type: Literal[InstantMessageRecipientType.USER] = InstantMessageRecipientType.USER + user_id: str + + +InstantMessageRecipient = Annotated[ + InstantMessageChannelRecipient | InstantMessageUserRecipient, Field(discriminator="type") +] + + class EmailRecipients(BaseModel): model_config = ConfigDict(extra="forbid") @@ -67,6 +100,12 @@ class EmailRecipients(BaseModel): items: list[EmailRecipient] = Field(default_factory=list) +class InstantMessageRecipients(BaseModel): + model_config = ConfigDict(extra="forbid") + + items: list[InstantMessageRecipient] = Field(default_factory=list) + + class EmailDeliveryConfig(BaseModel): URL_PLACEHOLDER: ClassVar[str] = "{{#url#}}" _ALLOWED_HTML_TAGS: ClassVar[list[str]] = [ @@ -74,6 +113,12 @@ class EmailDeliveryConfig(BaseModel): "br", "code", "em", + "h1", + "h2", + "h3", + "h4", + "h5", + "h6", "li", "ol", "p", @@ -142,6 +187,14 @@ class EmailDeliveryConfig(BaseModel): return " ".join(sanitized.split()) +class InstantMessageDeliveryConfig(BaseModel): + model_config = ConfigDict(extra="forbid") + + provider: InstantMessageProvider + recipients: InstantMessageRecipients = Field(default_factory=InstantMessageRecipients) + message: str | None = None + + class _DeliveryMethodBase(BaseModel): enabled: bool = True id: uuid.UUID = Field(default_factory=uuid.uuid4) @@ -171,10 +224,31 @@ class EmailDeliveryMethod(_DeliveryMethodBase): return selectors +class InstantMessageDeliveryMethod(_DeliveryMethodBase): + type: Literal[DeliveryMethodType.IM] = DeliveryMethodType.IM + config: InstantMessageDeliveryConfig + + @override + def extract_variable_selectors(self) -> Sequence[Sequence[str]]: + if not self.config.message: + return () + variable_template_parser = VariableTemplateParser(template=self.config.message) + selectors: list[Sequence[str]] = [] + for variable_selector in variable_template_parser.extract_variable_selectors(): + value_selector = list(variable_selector.value_selector) + if len(value_selector) < SELECTORS_LENGTH: + continue + selectors.append(value_selector[:SELECTORS_LENGTH]) + return selectors + + WebAppDeliveryMethod = InteractiveSurfaceDeliveryMethod _WebAppDeliveryConfig = _InteractiveSurfaceDeliveryConfig -DeliveryChannelConfig = Annotated[InteractiveSurfaceDeliveryMethod | EmailDeliveryMethod, Field(discriminator="type")] +DeliveryChannelConfig = Annotated[ + InteractiveSurfaceDeliveryMethod | EmailDeliveryMethod | InstantMessageDeliveryMethod, + Field(discriminator="type"), +] _DELIVERY_METHODS_ADAPTER = TypeAdapter(list[DeliveryChannelConfig]) @@ -205,9 +279,10 @@ def adapt_human_input_node_data_for_graph(node_data: Mapping[str, Any] | BaseMod config_mapping = _copy_mapping(method_mapping.get("config")) if config_mapping is not None: - recipients_mapping = _copy_mapping(config_mapping.get("recipients")) - if recipients_mapping is not None: - config_mapping["recipients"] = _normalize_email_recipients(recipients_mapping) + if method_mapping.get("type") == DeliveryMethodType.EMAIL: + recipients_mapping = _copy_mapping(config_mapping.get("recipients")) + if recipients_mapping is not None: + config_mapping["recipients"] = _normalize_email_recipients(recipients_mapping) method_mapping["config"] = config_mapping normalized_methods.append(method_mapping) @@ -380,6 +455,13 @@ __all__ = [ "EmailRecipientType", "EmailRecipients", "ExternalRecipient", + "InstantMessageChannelRecipient", + "InstantMessageDeliveryConfig", + "InstantMessageDeliveryMethod", + "InstantMessageProvider", + "InstantMessageRecipientType", + "InstantMessageRecipients", + "InstantMessageUserRecipient", "MemberRecipient", "WebAppDeliveryMethod", "_WebAppDeliveryConfig", diff --git a/api/core/workflow/node_factory.py b/api/core/workflow/node_factory.py index fe13c0085bc..15d838b4a6e 100644 --- a/api/core/workflow/node_factory.py +++ b/api/core/workflow/node_factory.py @@ -55,6 +55,7 @@ from core.workflow.nodes.agent_v2.output_adapter import WorkflowAgentOutputAdapt from core.workflow.nodes.agent_v2.runtime_request_builder import WorkflowAgentRuntimeRequestBuilder from core.workflow.nodes.human_input.callback import DifyHITLCallback from core.workflow.nodes.human_input.entities import HumanInputNodeData as DifyHumanInputNodeData +from core.workflow.nodes.human_input_v2.node import HumanInputNode as HumanInputV2Node from core.workflow.system_variables import SystemVariableKey, get_system_text, system_variable_selector from core.workflow.template_rendering import CodeExecutorJinja2TemplateRenderer from core.workflow.workflow_tool_node import DifyWorkflowToolNode @@ -78,6 +79,7 @@ from graphon.nodes.question_classifier.entities import QuestionClassifierNodeDat from graphon.nodes.tool.entities import ToolProviderType from graphon.variables.segments import ArrayObjectSegment, ObjectSegment from models.model import Conversation +from services.human_input_v2.composition import WorkflowHumanInputRuntime from services.knowledge_fs.knowledge_retrieval_v2.automatic_metadata_filter import ( KnowledgeFSAutomaticMetadataFilterExtractor, ) @@ -497,12 +499,18 @@ class DifyNodeFactory(NodeFactory): "file_manager": self._http_request_file_manager, "file_reference_factory": self._file_reference_factory, }, - BuiltinNodeTypes.HUMAN_INPUT: lambda: { - "hitl_callback": self._build_human_input_callback( - node_data=DifyHumanInputNodeData.model_validate(adapted_node_config["data"]), - execution_id_getter=lambda: node.execution_id if node is not None else None, - ), - }, + BuiltinNodeTypes.HUMAN_INPUT: lambda: ( + { + "human_input_runtime": WorkflowHumanInputRuntime(self._dify_context), + } + if node_class is HumanInputV2Node + else { + "hitl_callback": self._build_human_input_callback( + node_data=DifyHumanInputNodeData.model_validate(adapted_node_config["data"]), + execution_id_getter=lambda: node.execution_id if node is not None else None, + ), + } + ), BuiltinNodeTypes.LLM: lambda: self._build_llm_compatible_node_init_kwargs( node_class=node_class, node_data=resolved_node_data, diff --git a/api/core/workflow/nodes/human_input/boundary.py b/api/core/workflow/nodes/human_input/boundary.py index 1781839ba04..277d3727841 100644 --- a/api/core/workflow/nodes/human_input/boundary.py +++ b/api/core/workflow/nodes/human_input/boundary.py @@ -20,14 +20,20 @@ from graphon.variables.segments import StringSegment from .constants import OUTPUT_FIELD_ACTION_ID, OUTPUT_FIELD_ACTION_VALUE, OUTPUT_FIELD_RENDERED_CONTENT, TIMEOUT_HANDLE from .pause_reason import HumanInputRequired +from .session_binding import default_session_binding + + +class HumanInputPauseReasonResolutionError(LookupError): + """Raised when a graph pause reason cannot be resolved into Dify-owned form state.""" class HumanInputFormEventFilter: - """Adapt HITL callback results into Dify's form lifecycle event stream.""" + """Adapt V1 callback results; V2 nodes emit their own form lifecycle events.""" def __init__(self, *, form_repository: HumanInputFormSubmissionRepository) -> None: self._form_repository = form_repository self._node_titles: dict[str, str] = {} + self._v1_node_ids: set[str] = set() self._app_id: str | None = None @property @@ -36,13 +42,21 @@ class HumanInputFormEventFilter: def initialize(self, context: EngineEventFilterContext) -> None: self._node_titles.clear() + # Graphon completion events can retain the default version. Route by + # the concrete node in the graph instead of the event's version field. + self._v1_node_ids = { + node.id + for node in (context.graph.root_node, *context.graph.nodes.values()) + if node.node_type == BuiltinNodeTypes.HUMAN_INPUT and node.version() == "1" + } self._app_id = get_system_text(context.runtime_state.variable_pool, SystemVariableKey.APP_ID) def on_event(self, event: EngineEvent) -> Iterable[EngineEvent]: - if isinstance(event, NodeRunStartedEvent) and event.node_type == BuiltinNodeTypes.HUMAN_INPUT: - self._node_titles[event.id] = event.node_title - elif isinstance(event, NodeRunSucceededEvent) and event.node_type == BuiltinNodeTypes.HUMAN_INPUT: - yield self._completion_event(event) + if isinstance(event, NodeRunStartedEvent | NodeRunSucceededEvent) and event.node_id in self._v1_node_ids: + if isinstance(event, NodeRunStartedEvent): + self._node_titles[event.id] = event.node_title + else: + yield self._completion_event(event) yield event @@ -88,6 +102,25 @@ class HumanInputFormEventFilter: ) +def resolve_human_input_v1_pause_reason( + *, + reason: HitlRequired, + form_repository: HumanInputFormSubmissionRepository, + variable_pool: ReadOnlyVariablePool | None, +) -> HumanInputRequired: + """Load the legacy form from the V1 submission repository.""" + node_version, form_id = default_session_binding.resolve_form_id_from_session_id(session_id=reason.session_id) + if node_version != "1": + raise ValueError("Expected a Human Input v1 session") + record = form_repository.get_by_form_id(form_id) + if record is None: + raise HumanInputPauseReasonResolutionError( + f"missing human input form while enriching pause reason: form_id={form_id}, session_id={reason.session_id}" + ) + + return build_human_input_pause_reason(reason=reason, record=record, variable_pool=variable_pool) + + def human_input_container_selector(form_id: str) -> tuple[str, str]: return ("__dify_workflow_tool_hitl__", form_id) diff --git a/api/core/workflow/nodes/human_input/callback.py b/api/core/workflow/nodes/human_input/callback.py index 820857c8e78..4efc146aac6 100644 --- a/api/core/workflow/nodes/human_input/callback.py +++ b/api/core/workflow/nodes/human_input/callback.py @@ -98,7 +98,9 @@ class DifyHITLCallback: ) if form is None: created = self._create_form(ctx, form_id=form_id) - return PauseRequested(session_id=self._session_binding.issue_session_id_for_form(form_id=created.id)) + return PauseRequested( + session_id=self._session_binding.issue_session_id_for_form(node_version="1", form_id=created.id) + ) status = self._normalize_status(form.status) if status == HumanInputFormStatus.WAITING.value and not form.submitted: @@ -124,7 +126,9 @@ class DifyHITLCallback: raise AssertionError(msg) if not form.submitted: - return PauseRequested(session_id=self._session_binding.issue_session_id_for_form(form_id=form.id)) + return PauseRequested( + session_id=self._session_binding.issue_session_id_for_form(node_version="1", form_id=form.id) + ) selected_action_id = form.selected_action_id if selected_action_id is None: diff --git a/api/core/workflow/nodes/human_input/entities.py b/api/core/workflow/nodes/human_input/entities.py index 115093bd71b..a0981e1f459 100644 --- a/api/core/workflow/nodes/human_input/entities.py +++ b/api/core/workflow/nodes/human_input/entities.py @@ -13,6 +13,7 @@ from typing import Annotated, Any, Literal, Self, assert_never, override from pydantic import BaseModel, Field, NonNegativeInt, field_validator, model_validator +from core.workflow.human_input_adapter import DeliveryChannelConfig from graphon.entities.base_node_data import BaseNodeData from graphon.enums import BuiltinNodeTypes, NodeType from graphon.file.enums import FileTransferMethod, FileType @@ -23,11 +24,17 @@ from graphon.variables.segments import Segment from . import _exc as exc from .enums import ButtonStyle, FormInputType, TimeoutUnit, ValueSourceType -_OUTPUT_VARIABLE_PATTERN = re.compile( +OUTPUT_VARIABLE_PATTERN = re.compile( r"\{\{#\$output\.(?P[a-zA-Z_][a-zA-Z0-9_]{0,29})#\}\}", ) +def extract_output_variable_names(form_content: str) -> tuple[str, ...]: + """Return source-ordered output slots using the authoring DSL grammar.""" + + return tuple(match.group("field_name") for match in OUTPUT_VARIABLE_PATTERN.finditer(form_content)) + + class StringSource(BaseModel): """Default configuration for form inputs.""" @@ -228,7 +235,7 @@ class UserActionConfig(BaseModel): # It also serves as the identifiers of output handle. # # The id must be a valid identifier (satisfy the _IDENTIFIER_PATTERN above.) - id: str = Field(max_length=20, pattern=_IDENTIFIER_PATTERN.pattern) + id: str = Field(max_length=20, pattern=_IDENTIFIER_PATTERN) title: str = Field(max_length=100) button_style: ButtonStyle = ButtonStyle.DEFAULT @@ -245,10 +252,29 @@ class UserActionConfig(BaseModel): return value +def validate_unique_input_names(inputs: Sequence[FormInputConfig]) -> None: + seen_names: set[str] = set() + for form_input in inputs: + name = form_input.output_variable_name + if name in seen_names: + raise ValueError(f"duplicated output_variable_name '{name}' in inputs") + seen_names.add(name) + + +def validate_unique_action_ids(user_actions: Sequence[UserActionConfig]) -> None: + seen_ids: set[str] = set() + for action in user_actions: + action_id = action.id + if action_id in seen_ids: + raise ValueError(f"duplicated user action id '{action_id}'") + seen_ids.add(action_id) + + class HumanInputNodeData(BaseNodeData): """Human Input node data.""" type: NodeType = BuiltinNodeTypes.HUMAN_INPUT + title: str = "" form_content: str = "" inputs: list[FormInputConfig] = Field(default_factory=list[FormInputConfig]) user_actions: list[UserActionConfig] = Field(default_factory=list[UserActionConfig]) @@ -258,25 +284,13 @@ class HumanInputNodeData(BaseNodeData): @field_validator("inputs") @classmethod def _validate_inputs(cls, inputs: list[FormInputConfig]) -> list[FormInputConfig]: - seen_names: set[str] = set() - for form_input in inputs: - name = form_input.output_variable_name - if name in seen_names: - msg = f"duplicated output_variable_name '{name}' in inputs" - raise ValueError(msg) - seen_names.add(name) + validate_unique_input_names(inputs) return inputs @field_validator("user_actions") @classmethod def _validate_user_actions(cls, user_actions: list[UserActionConfig]) -> list[UserActionConfig]: - seen_ids: set[str] = set() - for action in user_actions: - action_id = action.id - if action_id in seen_ids: - msg = f"duplicated user action id '{action_id}'" - raise ValueError(msg) - seen_ids.add(action_id) + validate_unique_action_ids(user_actions) return user_actions def expiration_time(self, start_time: datetime) -> datetime: @@ -289,7 +303,7 @@ class HumanInputNodeData(BaseNodeData): assert_never(self.timeout_unit) def outputs_field_names(self) -> Sequence[str]: - return [match.group("field_name") for match in _OUTPUT_VARIABLE_PATTERN.finditer(self.form_content)] + return list(extract_output_variable_names(self.form_content)) def find_action_text(self, action_id: str) -> str: """Resolve action display text by id.""" @@ -361,3 +375,10 @@ def validate_human_input_submission( missing_list = ", ".join(missing_inputs) msg = f"Missing required inputs: {missing_list}" raise HumanInputSubmissionValidationError(msg) + + +class HumanInputNodeDataFull(HumanInputNodeData): + # This model is the full definition of HumanInputNodeData. + # + # The model above lacks some fields due to migration between Graphon and Dify. This model add them back. + delivery_methods: list[DeliveryChannelConfig] = Field(default_factory=list[DeliveryChannelConfig]) diff --git a/api/core/workflow/nodes/human_input/enums.py b/api/core/workflow/nodes/human_input/enums.py index da8406d9cf2..748adc44a70 100644 --- a/api/core/workflow/nodes/human_input/enums.py +++ b/api/core/workflow/nodes/human_input/enums.py @@ -1,5 +1,7 @@ import enum +from core.human_input import ButtonStyle + class HumanInputFormStatus(enum.StrEnum): """Status of a human input form.""" @@ -25,15 +27,6 @@ class HumanInputFormKind(enum.StrEnum): DELIVERY_TEST = enum.auto() # Form created for delivery tests. -class ButtonStyle(enum.StrEnum): - """Button styles for user actions.""" - - PRIMARY = enum.auto() - DEFAULT = enum.auto() - ACCENT = enum.auto() - GHOST = enum.auto() - - class TimeoutUnit(enum.StrEnum): """Timeout unit for form expiration.""" @@ -74,3 +67,13 @@ class ValueSourceType(enum.StrEnum): VARIABLE = enum.auto() # `CONSTANT` means that the value comes from a static setting in form definition. CONSTANT = enum.auto() + + +__all__ = [ + "ButtonStyle", + "FormInputType", + "HumanInputFormKind", + "HumanInputFormStatus", + "TimeoutUnit", + "ValueSourceType", +] diff --git a/api/core/workflow/nodes/human_input/pause_reason.py b/api/core/workflow/nodes/human_input/pause_reason.py index 26c28691508..b7f301a98ec 100644 --- a/api/core/workflow/nodes/human_input/pause_reason.py +++ b/api/core/workflow/nodes/human_input/pause_reason.py @@ -22,6 +22,9 @@ class DifyHITLEventType(StrEnum): class HumanInputRequired(BaseModel): + # The referenced form determines its storage owner; this is not an entity version. + # Clients use it to choose the matching submission API. + form_version: Literal["1", "2"] = "1" TYPE: Literal[DifyHITLEventType.HUMAN_INPUT_REQUIRED] = DifyHITLEventType.HUMAN_INPUT_REQUIRED form_id: str form_content: str diff --git a/api/core/workflow/nodes/human_input/session_binding.py b/api/core/workflow/nodes/human_input/session_binding.py index 1f71839ae0c..6032e1d2530 100644 --- a/api/core/workflow/nodes/human_input/session_binding.py +++ b/api/core/workflow/nodes/human_input/session_binding.py @@ -1,16 +1,26 @@ class SessionBinding: """Translate between graphon session ids and Dify form ids. - Phase 1 keeps the public graphon `session_id` contract while Dify continues - to persist and query `form_id`. The identity mapping lives here so later - phases can change the translation without scattering equality assumptions. + Existing v1 session ids remain unchanged. V2 ids carry their form version + so consumers do not have to infer ownership from node state or database lookups. """ - def issue_session_id_for_form(self, *, form_id: str) -> str: - return form_id + _V2_PREFIX = "hitlv2:" - def resolve_form_id_from_session_id(self, *, session_id: str) -> str: - return session_id + def issue_session_id_for_form(self, node_version: str, form_id: str) -> str: + match node_version: + case "1": + return form_id + case "2": + return f"{self._V2_PREFIX}{form_id}" + case _: + raise ValueError(f"Unsupported Human Input node version: {node_version}") + + def resolve_form_id_from_session_id(self, session_id: str) -> tuple[str, str]: + """Return the Human Input node version and the unprefixed form id.""" + if session_id.startswith(self._V2_PREFIX): + return "2", session_id.removeprefix(self._V2_PREFIX) + return "1", session_id default_session_binding = SessionBinding() diff --git a/api/core/workflow/nodes/human_input_v2/entities.py b/api/core/workflow/nodes/human_input_v2/entities.py new file mode 100644 index 00000000000..53a218d291f --- /dev/null +++ b/api/core/workflow/nodes/human_input_v2/entities.py @@ -0,0 +1,123 @@ +import enum +import typing +from collections.abc import Sequence +from typing import Annotated, Literal + +from pydantic import AfterValidator, BaseModel, ConfigDict, Discriminator, Field, field_validator + +from core.human_input_v2.entities import DebugChannel +from core.workflow.nodes.human_input.entities import ( + FormInputConfig, + TimeoutUnit, + UserActionConfig, + extract_output_variable_names, + validate_unique_action_ids, + validate_unique_input_names, +) +from graphon.entities.base_node_data import BaseNodeData + + +class RecipientType(enum.StrEnum): + CONTACT = enum.auto() + DYNAMIC_EMAIL = enum.auto() + ONETIME_EMAIL = enum.auto() + INITIATOR = enum.auto() + ALL_WORKSPACE_CONTACTS = enum.auto() + + +class Contact(BaseModel): + type: Literal[RecipientType.CONTACT] = RecipientType.CONTACT + + contact_id: str + + +class DynamicEmail(BaseModel): + type: Literal[RecipientType.DYNAMIC_EMAIL] = RecipientType.DYNAMIC_EMAIL + + selector: Sequence[str] + + +class OnetimeEmail(BaseModel): + type: Literal[RecipientType.ONETIME_EMAIL] = RecipientType.ONETIME_EMAIL + + email: str + + +class Initiator(BaseModel): + type: Literal[RecipientType.INITIATOR] = RecipientType.INITIATOR + + +class AllWorkspaceContacts(BaseModel): + """Migration marker preserving legacy whole-workspace recipient intent. + + Runtime expansion is deliberately owned by the recipient resolver rather + than the node-data migration helper. + """ + + model_config = ConfigDict(extra="forbid", frozen=True) + + type: Literal[RecipientType.ALL_WORKSPACE_CONTACTS] = RecipientType.ALL_WORKSPACE_CONTACTS + + +RecipientConfig = Annotated[ + Contact | DynamicEmail | OnetimeEmail | Initiator | AllWorkspaceContacts, + Discriminator("type"), +] + + +class MessageTemplateConfig(BaseModel): + subject: str + body: str + + +class DebugModeConfig(BaseModel): + enabled: bool = False + channels: Sequence[DebugChannel] + + +HUMAN_INPUT_NODE_TYPE: typing.Final[Literal["human-input"]] = "human-input" +HUMAN_INPUT_V2_VERSION: typing.Final = "2" + + +def _version_validator(version: str) -> str: + if version != HUMAN_INPUT_V2_VERSION: + raise ValueError(f"Human Input v2 requires version='{HUMAN_INPUT_V2_VERSION}'") + return version + + +class HumanInputNodeData(BaseNodeData): + """Human Input node data.""" + + model_config = ConfigDict(extra="ignore", frozen=True, strict=True, validate_default=True) + + # BaseNodeData exposes the discriminator as a class variable, while the + # concrete Pydantic model must validate and serialize an exact wire value. + type: Literal["human-input"] = HUMAN_INPUT_NODE_TYPE # pyrefly: ignore[bad-override] + title: str = "" # pyrefly: ignore[bad-override] + version: Annotated[str, AfterValidator(_version_validator)] = HUMAN_INPUT_V2_VERSION # pyrefly: ignore[bad-override] + + recipients_spec: list[RecipientConfig] + + message_template: MessageTemplateConfig + debug_mode: DebugModeConfig + + form_content: str = "" + inputs: list[FormInputConfig] = Field(default_factory=list[FormInputConfig]) + user_actions: list[UserActionConfig] = Field(default_factory=list[UserActionConfig]) + timeout: int = 36 + timeout_unit: TimeoutUnit = Field(default=TimeoutUnit.HOUR, strict=False) + + @field_validator("inputs") + @classmethod + def _validate_inputs(cls, inputs: list[FormInputConfig]) -> list[FormInputConfig]: + validate_unique_input_names(inputs) + return inputs + + @field_validator("user_actions") + @classmethod + def _validate_user_actions(cls, user_actions: list[UserActionConfig]) -> list[UserActionConfig]: + validate_unique_action_ids(user_actions) + return user_actions + + def output_variable_names(self) -> tuple[str, ...]: + return extract_output_variable_names(self.form_content) diff --git a/api/core/workflow/nodes/human_input_v2/events.py b/api/core/workflow/nodes/human_input_v2/events.py new file mode 100644 index 00000000000..f8719033e9f --- /dev/null +++ b/api/core/workflow/nodes/human_input_v2/events.py @@ -0,0 +1,29 @@ +"""Dify presentation events emitted alongside graphon's pause control events.""" + +from pydantic import Field + +from graphon.engine_events import NodeEvent, NodeRunHumanInputFormFilledEvent, NodeRunHumanInputFormTimeoutEvent + +from .runtime import PreparedForm + + +class NodeRunHumanInputV2FormRequiredEvent(NodeEvent): + """Carry the committed form snapshot to Dify without another database read. + + The initiator token is for the live response only. Pause persistence stores + the form reference; reconnect resolves current persisted form state itself. + """ + + prepared_form: PreparedForm = Field(repr=False) + + +class NodeRunHumanInputV2FormFilledEvent(NodeRunHumanInputFormFilledEvent): + """Keep Dify's form identity distinct from the engine's execution identity.""" + + form_id: str + + +class NodeRunHumanInputV2FormTimeoutEvent(NodeRunHumanInputFormTimeoutEvent): + """Identify the persisted form whose timeout completed this execution.""" + + form_id: str diff --git a/api/core/workflow/nodes/human_input_v2/form_compiler.py b/api/core/workflow/nodes/human_input_v2/form_compiler.py new file mode 100644 index 00000000000..5f4249ce125 --- /dev/null +++ b/api/core/workflow/nodes/human_input_v2/form_compiler.py @@ -0,0 +1,169 @@ +"""Compile Human Input v2 authoring values into an authoritative resolved form.""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import assert_never + +from constants import DEFAULT_FILE_NUMBER_LIMITS +from core.human_input_v2.resolved_form import ( + FileInput, + FileListInput, + FormPart, + MarkdownFragment, + ParagraphInput, + ResolvedForm, + SelectInput, + UserAction, +) +from core.workflow.nodes.human_input.entities import ( + OUTPUT_VARIABLE_PATTERN, + FileInputConfig, + FileListInputConfig, + FormInputConfig, + ParagraphInputConfig, + SelectInputConfig, + StringListSource, + StringSource, +) +from core.workflow.nodes.human_input.enums import ValueSourceType +from graphon.runtime.runtime_state_protocol import ReadOnlyVariablePool +from graphon.variables.segments import Segment +from graphon.variables.template_resolution import convert_template + +from .entities import HumanInputNodeData + + +class MissingResolvedInputError(ValueError): + """An output slot has no corresponding authoring input configuration.""" + + +def compile_resolved_form( + node_data: HumanInputNodeData, + variable_pool: ReadOnlyVariablePool, + *, + resolved_default_values: Mapping[str, object] | None = None, +) -> ResolvedForm: + """Resolve workflow-owned presentation data exactly once at form creation.""" + + legacy_form_content = _resolve_legacy_form_content(node_data.form_content, variable_pool) + inputs_by_name = {input_config.output_variable_name: input_config for input_config in node_data.inputs} + default_values = resolved_default_values or {} + blocks: list[FormPart] = [] + cursor = 0 + for match in OUTPUT_VARIABLE_PATTERN.finditer(legacy_form_content): + fragment = legacy_form_content[cursor : match.start()] + if fragment != "": + blocks.append(MarkdownFragment(text=fragment)) + output_variable_name = match.group("field_name") + input_config = inputs_by_name.get(output_variable_name) + if input_config is None: + raise MissingResolvedInputError(f"resolved form input is missing for output slot '{output_variable_name}'") + blocks.append( + _resolve_input( + input_config, + variable_pool=variable_pool, + resolved_default=default_values.get(output_variable_name), + ) + ) + cursor = match.end() + trailing_fragment = legacy_form_content[cursor:] + if trailing_fragment != "": + blocks.append(MarkdownFragment(text=trailing_fragment)) + + return ResolvedForm( + title=node_data.title, + parts=tuple(blocks), + actions=tuple( + UserAction( + id=action.id, + title=action.title, + button_style=action.button_style, + ) + for action in node_data.user_actions + ), + legacy_form_content=legacy_form_content, + ) + + +def _resolve_legacy_form_content(form_content: str, variable_pool: ReadOnlyVariablePool) -> str: + return convert_template(variable_pool, form_content).markdown + + +def _resolve_input( + input_config: FormInputConfig, + *, + variable_pool: ReadOnlyVariablePool, + resolved_default: object, +) -> ParagraphInput | SelectInput | FileInput | FileListInput: + match input_config: + case ParagraphInputConfig(): + default_value = _resolve_paragraph_default(input_config.default, variable_pool) + if default_value is None and resolved_default is not None: + default_value = _resolved_text(resolved_default, field_name=input_config.output_variable_name) + return ParagraphInput(output_variable_name=input_config.output_variable_name, default_value=default_value) + case SelectInputConfig(): + options = _resolve_select_options(input_config.option_source, variable_pool) + default_value = ( + _resolved_text(resolved_default, field_name=input_config.output_variable_name) + if resolved_default is not None + else None + ) + return SelectInput( + output_variable_name=input_config.output_variable_name, options=options, default_value=default_value + ) + case FileInputConfig(): + return FileInput( + output_variable_name=input_config.output_variable_name, + allowed_file_types=tuple(input_config.allowed_file_types), + allowed_file_extensions=tuple(input_config.allowed_file_extensions), + allowed_file_upload_methods=tuple(input_config.allowed_file_upload_methods), + ) + case FileListInputConfig(): + return FileListInput( + output_variable_name=input_config.output_variable_name, + allowed_file_types=tuple(input_config.allowed_file_types), + allowed_file_extensions=tuple(input_config.allowed_file_extensions), + allowed_file_upload_methods=tuple(input_config.allowed_file_upload_methods), + number_limits=input_config.number_limits or DEFAULT_FILE_NUMBER_LIMITS, + ) + case _: + assert_never(input_config) + + +def _resolve_paragraph_default(source: StringSource | None, variable_pool: ReadOnlyVariablePool) -> str | None: + if source is None: + return None + if source.type is ValueSourceType.CONSTANT: + return source.value + segment = variable_pool.get(source.selector) + if segment is None: + return None + return _resolved_text(segment, field_name="paragraph default") + + +def _resolve_select_options(source: StringListSource, variable_pool: ReadOnlyVariablePool) -> tuple[str, ...]: + if source.type is ValueSourceType.CONSTANT: + return tuple(source.value) + segment = variable_pool.get(source.selector) + if segment is None: + raise ValueError("select option source variable is unavailable") + resolved = segment.to_object() + if not isinstance(resolved, list) or any(not isinstance(option, str) for option in resolved): + raise TypeError("select option source must resolve to a string list") + return tuple(resolved) + + +def _resolved_text(resolved: object, *, field_name: str) -> str | None: + if isinstance(resolved, Segment): + resolved = resolved.to_object() + if resolved is None: + return None + if isinstance(resolved, str): + return resolved + if isinstance(resolved, bool | int | float): + return str(resolved) + raise TypeError(f"{field_name} must resolve to a scalar text value") + + +__all__ = ["MissingResolvedInputError", "compile_resolved_form"] diff --git a/api/core/workflow/nodes/human_input_v2/migration.py b/api/core/workflow/nodes/human_input_v2/migration.py new file mode 100644 index 00000000000..e2417c3cb60 --- /dev/null +++ b/api/core/workflow/nodes/human_input_v2/migration.py @@ -0,0 +1,273 @@ +"""Pure legacy Human Input node-data conversion. + +This module owns only deterministic value conversion. Database access, +workspace scoping, Contact state, and transport response mapping belong to the +application and adapter layers. +""" + +from __future__ import annotations + +import enum +from dataclasses import dataclass + +from core.human_input_v2.entities import DebugChannel +from core.human_input_v2.shared.values import NormalizedEmail +from core.workflow.human_input_adapter import ( + DeliveryChannelConfig, + EmailDeliveryConfig, + EmailDeliveryMethod, + ExternalRecipient, + WebAppDeliveryMethod, +) +from core.workflow.nodes.human_input.entities import HumanInputNodeDataFull + +from .entities import ( + HUMAN_INPUT_NODE_TYPE, + AllWorkspaceContacts, + DebugModeConfig, + HumanInputNodeData, + Initiator, + MessageTemplateConfig, + OnetimeEmail, + RecipientConfig, +) + + +@dataclass(frozen=True, slots=True) +class ResolvedMemberEmail: + member_id: str + email: NormalizedEmail + + +@dataclass(frozen=True, slots=True) +class MemberEmailSnapshot: + """Immutable request-local workspace member Email snapshot.""" + + entries: tuple[ResolvedMemberEmail, ...] = () + + def email_for(self, member_id: str) -> NormalizedEmail | None: + for entry in self.entries: + if entry.member_id == member_id: + return entry.email + return None + + +@dataclass(frozen=True, slots=True) +class NodeMigrationBlocker: + code: MigrationBlockerCode + method_id: str | None = None + value: str | None = None + method_index: int | None = None + recipient_index: int | None = None + + +@dataclass(frozen=True, slots=True) +class NodeMigrationConversion: + node_data: HumanInputNodeData | None + blockers: tuple[NodeMigrationBlocker, ...] + + +class MigrationBlockerCode(enum.StrEnum): + INVALID_DEFAULT_VALUE = "invalid-default-value" + CONFIGURED_DISABLED_METHOD = "configured-disabled-method" + UNSUPPORTED_DELIVERY_METHOD = "unsupported-delivery-method" + INVALID_EMAIL_CONFIGURATION = "invalid-email-configuration" + INVALID_EMAIL = "invalid-email" + UNRESOLVED_MEMBER = "unresolved-member" + CONFLICTING_EMAIL_TEMPLATES = "conflicting-email-templates" + MISSING_RECIPIENTS = "missing-recipients" + + +@dataclass(frozen=True, slots=True) +class LegacyDeliveryParseIssue: + """One transport/preflight issue that did not become canonical v1 data.""" + + code: MigrationBlockerCode + method_position: int | None = None + method_id: str | None = None + value: str | None = None + recipient_position: int | None = None + + +@dataclass(frozen=True, slots=True) +class LegacyNodeDataPreflight: + """Canonical v1 value plus typed issues from the transport boundary.""" + + node_data: HumanInputNodeDataFull + method_positions: tuple[int, ...] + recipient_positions: tuple[tuple[int, ...], ...] + issues: tuple[LegacyDeliveryParseIssue, ...] + + +def _disabled_method_has_material_configuration(method: DeliveryChannelConfig) -> bool: + if isinstance(method, WebAppDeliveryMethod): + return False + if not isinstance(method, EmailDeliveryMethod): + return True + config = method.config + return bool( + config.recipients.items + or config.recipients.include_bound_group + or config.subject + or config.body + or config.debug_mode + ) + + +def _invalid_template_field(config: EmailDeliveryConfig) -> str | None: + if not config.subject.strip(): + return "subject" + if not config.body.strip(): + return "body" + return None + + +def _validated_email_template(config: EmailDeliveryConfig) -> tuple[str, str] | None: + if not config.subject.strip(): + return None + if not config.body.strip(): + return None + return config.subject, config.body + + +def convert_legacy_human_input_node_data( + legacy_node_data: HumanInputNodeDataFull, + member_emails: MemberEmailSnapshot, +) -> NodeMigrationConversion: + """Convert one legacy node against a caller-owned immutable Email snapshot.""" + + recipients: list[RecipientConfig] = [] + blockers: list[NodeMigrationBlocker] = [] + seen_emails: set[str] = set() + has_initiator = False + has_workspace_marker = False + email_debug_enabled = False + email_template: tuple[str, str] | None = None + conflicting_template: tuple[str, int] | None = None + for method_index, method in enumerate(legacy_node_data.delivery_methods): + method_id = str(method.id) + if not method.enabled: + if _disabled_method_has_material_configuration(method): + blockers.append( + NodeMigrationBlocker( + MigrationBlockerCode.CONFIGURED_DISABLED_METHOD, + method_id, + method.type.value, + method_index=method_index, + ) + ) + continue + if isinstance(method, WebAppDeliveryMethod): + if not has_initiator: + recipients.append(Initiator()) + has_initiator = True + continue + if not isinstance(method, EmailDeliveryMethod): + blockers.append( + NodeMigrationBlocker( + MigrationBlockerCode.UNSUPPORTED_DELIVERY_METHOD, + method_id, + method.type.value, + method_index=method_index, + ) + ) + continue + current_template = _validated_email_template(method.config) + if current_template is None: + blockers.append( + NodeMigrationBlocker( + MigrationBlockerCode.INVALID_EMAIL_CONFIGURATION, + method_id, + _invalid_template_field(method.config), + method_index=method_index, + ) + ) + else: + if email_template is None: + email_template = current_template + elif current_template != email_template and conflicting_template is None: + conflicting_template = method_id, method_index + email_debug_enabled = email_debug_enabled or method.config.debug_mode + email_recipients = method.config.recipients + for recipient_index, source in enumerate(email_recipients.items): + if isinstance(source, ExternalRecipient): + try: + normalized_email = str(NormalizedEmail(source.email)) + except ValueError: + blockers.append( + NodeMigrationBlocker( + MigrationBlockerCode.INVALID_EMAIL, + method_id, + source.email, + method_index=method_index, + recipient_index=recipient_index, + ) + ) + continue + else: + member_id = source.reference_id + member_email = member_emails.email_for(member_id) + if member_email is None: + blockers.append( + NodeMigrationBlocker( + MigrationBlockerCode.UNRESOLVED_MEMBER, + method_id, + member_id, + method_index=method_index, + recipient_index=recipient_index, + ) + ) + continue + normalized_email = str(member_email) + if normalized_email in seen_emails: + continue + recipients.append(OnetimeEmail(email=normalized_email)) + seen_emails.add(normalized_email) + if email_recipients.include_bound_group and not has_workspace_marker: + recipients.append(AllWorkspaceContacts()) + has_workspace_marker = True + + if conflicting_template is not None: + conflicting_template_method_id, conflicting_template_method_index = conflicting_template + blockers.append( + NodeMigrationBlocker( + MigrationBlockerCode.CONFLICTING_EMAIL_TEMPLATES, + conflicting_template_method_id, + method_index=conflicting_template_method_index, + ) + ) + if not recipients: + blockers.append(NodeMigrationBlocker(MigrationBlockerCode.MISSING_RECIPIENTS)) + if blockers: + return NodeMigrationConversion(node_data=None, blockers=tuple(blockers)) + + if email_template is None: + # Runtime requires a Request URL even for initiator-only forms. + subject = "Action required" + body = ( + "A request is waiting for your review. Please review the details and submit your decision to continue. " + "Review and respond: {{#url#}}" + ) + else: + subject, body = email_template + + node_data = HumanInputNodeData( + type=HUMAN_INPUT_NODE_TYPE, + title=legacy_node_data.title, + desc=legacy_node_data.desc, + error_strategy=legacy_node_data.error_strategy, + default_value=legacy_node_data.default_value, + retry_config=legacy_node_data.retry_config, + recipients_spec=recipients, + message_template=MessageTemplateConfig(subject=subject, body=body), + debug_mode=DebugModeConfig( + enabled=email_debug_enabled, + channels=(DebugChannel.EMAIL,) if email_debug_enabled else (), + ), + form_content=legacy_node_data.form_content, + inputs=legacy_node_data.inputs, + user_actions=legacy_node_data.user_actions, + timeout=legacy_node_data.timeout, + timeout_unit=legacy_node_data.timeout_unit, + ) + return NodeMigrationConversion(node_data=node_data, blockers=()) diff --git a/api/core/workflow/nodes/human_input_v2/node.py b/api/core/workflow/nodes/human_input_v2/node.py new file mode 100644 index 00000000000..7794217b46a --- /dev/null +++ b/api/core/workflow/nodes/human_input_v2/node.py @@ -0,0 +1,189 @@ +"""Dify-owned Human Input v2 node; services are supplied through its runtime port. + +Only the generic graphon node/event contracts are used. Dify form semantics do +not pass through HITLCallback. Keep v1 registrations and published runs intact. +""" + +from __future__ import annotations + +from collections.abc import Generator, Mapping, Sequence +from typing import assert_never, override + +from core.workflow.nodes.human_input.enums import HumanInputFormStatus +from core.workflow.nodes.human_input.session_binding import default_session_binding +from factories.human_input_v2_form_presentation import render_filled_content +from graphon.engine_events import NodeEvent +from graphon.entities.pause_reason import HitlRequired +from graphon.enums import BuiltinNodeTypes, NodeExecutionType, WorkflowNodeExecutionStatus +from graphon.node_events import NodeEventPayload, NodeRunResult, StreamCompletedEvent +from graphon.node_events.node import PauseRequestedEvent +from graphon.nodes.base.node import Node +from graphon.nodes.base.variable_template_parser import VariableTemplateParser +from graphon.runtime.init_params import InitParams +from graphon.runtime.runtime_state import RuntimeState +from graphon.variables.segments import Segment, StringSegment + +from .entities import HUMAN_INPUT_V2_VERSION, DynamicEmail, HumanInputNodeData +from .events import ( + NodeRunHumanInputV2FormFilledEvent, + NodeRunHumanInputV2FormRequiredEvent, + NodeRunHumanInputV2FormTimeoutEvent, +) +from .runtime import HumanInputDeliveryError, HumanInputRuntime + + +class HumanInputNode(Node[HumanInputNodeData]): + node_type = BuiltinNodeTypes.HUMAN_INPUT + execution_type = NodeExecutionType.BRANCH + + def __init__( + self, + node_id: str, + data: HumanInputNodeData, + *, + init_params: InitParams, + runtime_state: RuntimeState, + human_input_runtime: HumanInputRuntime, + ) -> None: + super().__init__( + node_id=node_id, + data=data, + init_params=init_params, + runtime_state=runtime_state, + ) + self._human_input_runtime = human_input_runtime + + @classmethod + @override + def version(cls) -> str: + return HUMAN_INPUT_V2_VERSION + + @override + def _run(self) -> Generator[NodeEventPayload | NodeEvent]: + """Consume persisted form outcomes; only completion events select edges.""" + try: + prepared = self._human_input_runtime.prepare_form( + node_execution_id=self.execution_id, + node_data=self.node_data, + variable_pool=self.runtime_state.variable_pool, + ) + except HumanInputDeliveryError as error: + yield StreamCompletedEvent( + node_run_result=NodeRunResult( + status=WorkflowNodeExecutionStatus.FAILED, + error=str(error), + error_type=type(error).__name__, + ) + ) + return + + form = prepared.form + resolved = form.resolved_form + match form.status: + case HumanInputFormStatus.WAITING: + # Keep the live Dify presentation separate from graphon's persisted + # pause reason, which carries only the reference needed to resume. + yield NodeRunHumanInputV2FormRequiredEvent( + id=self.execution_id, + node_id=self.id, + node_type=self.node_type, + node_version=self.version(), + prepared_form=prepared, + ) + yield PauseRequestedEvent( + reason=HitlRequired( + session_id=default_session_binding.issue_session_id_for_form( + node_version=self.version(), form_id=form.id + ), + node_id=self.id, + node_title=resolved.title, + ) + ) + case HumanInputFormStatus.SUBMITTED: + submission = form.submission + assert submission is not None, "Submitted form must contain its accepted submission" + action = next(action for action in resolved.actions if action.id == submission.selected_action_id) + inputs = submission.normalized_submission_data + content = render_filled_content(resolved, inputs) + yield NodeRunHumanInputV2FormFilledEvent( + id=self.execution_id, + node_id=self.id, + node_type=self.node_type, + node_version=self.version(), + form_id=form.id, + node_title=resolved.title, + action_id=action.id, + action_text=action.title, + submitted_data=inputs, + rendered_content=content, + ) + outputs = dict(inputs) + outputs.update(_special_outputs(action.id, action.title, content)) + yield StreamCompletedEvent( + node_run_result=NodeRunResult( + status=WorkflowNodeExecutionStatus.SUCCEEDED, + inputs=inputs, + outputs=outputs, + edge_source_handle=action.id, + ) + ) + case HumanInputFormStatus.TIMEOUT: + yield NodeRunHumanInputV2FormTimeoutEvent( + id=self.execution_id, + node_id=self.id, + node_type=self.node_type, + node_version=self.version(), + form_id=form.id, + node_title=resolved.title, + expiration_time=form.expiration_time, + ) + yield StreamCompletedEvent( + node_run_result=NodeRunResult( + status=WorkflowNodeExecutionStatus.SUCCEEDED, + outputs=_special_outputs("", "", resolved.legacy_form_content), + edge_source_handle="__timeout", + ) + ) + case HumanInputFormStatus.EXPIRED: + # A node failure can take an error branch. Global expiration must + # stop the graph itself, without dispatching a completion event. + self.runtime_state.graph_execution.abort(f"Human input form globally expired: {form.id}") + case _: + assert_never(form.status) + + @classmethod + @override + def _extract_variable_selector_to_variable_mapping( + cls, + *, + graph_config: Mapping[str, object], + node_id: str, + node_data: HumanInputNodeData, + ) -> Mapping[str, Sequence[str]]: + """Expose all authoring dependencies to single-node debug execution. + + Include Form Content, input defaults/select options, Dynamic Email, + and Message template subject/body selectors. Output slots and Request + URL are not upstream variables. Preserve complete selectors, including + nested paths. Recipient resolution and rendering still belong to the + runtime; extracting dependencies must not invoke either operation. + """ + selectors: list[Sequence[str]] = [] + for template in (node_data.form_content, node_data.message_template.subject, node_data.message_template.body): + selectors.extend( + item.value_selector for item in VariableTemplateParser(template).extract_variable_selectors() + ) + for form_input in node_data.inputs: + selectors.extend(form_input.extract_variable_selectors()) + for recipient in node_data.recipients_spec: + if isinstance(recipient, DynamicEmail): + selectors.append(recipient.selector) + return {f"{node_id}.#{'.'.join(selector)}#": tuple(selector) for selector in selectors} + + +def _special_outputs(action_id: str, action_value: str, rendered_content: str) -> dict[str, Segment]: + return { + "__action_id": StringSegment(value=action_id), + "__action_value": StringSegment(value=action_value), + "__rendered_content": StringSegment(value=rendered_content), + } diff --git a/api/core/workflow/nodes/human_input_v2/presentation.py b/api/core/workflow/nodes/human_input_v2/presentation.py new file mode 100644 index 00000000000..c72e2aad2b8 --- /dev/null +++ b/api/core/workflow/nodes/human_input_v2/presentation.py @@ -0,0 +1,38 @@ +"""Bridge frozen v2 form presentation to graph pause reasons.""" + +from collections.abc import Mapping + +from core.human_input_v2.resolved_form import ResolvedForm +from core.workflow.nodes.human_input.pause_reason import HumanInputRequired +from core.workflow.nodes.human_input.session_binding import default_session_binding +from factories.human_input_v2_form_presentation import build_form_presentation +from graphon.entities.pause_reason import HitlRequired + +from .runtime import PreparedForm + + +def resolve_human_input_v2_pause_reason( + *, reason: HitlRequired, forms: Mapping[str, PreparedForm] +) -> HumanInputRequired: + """Resolve a v2 pause exclusively from its preceding node event.""" + node_version, form_id = default_session_binding.resolve_form_id_from_session_id(session_id=reason.session_id) + if node_version != "2": + raise ValueError("Expected a Human Input v2 session") + prepared = forms.get(form_id) + if prepared is None: + raise ValueError(f"Human Input v2 form snapshot is missing: node_id={reason.node_id}, form_id={form_id}") + return build_human_input_v2_pause_reason(prepared.form.resolved_form, form_id=form_id, node_id=reason.node_id) + + +def build_human_input_v2_pause_reason(form: ResolvedForm, *, form_id: str, node_id: str) -> HumanInputRequired: + presentation = build_form_presentation(form) + return HumanInputRequired( + form_version="2", + form_id=form_id, + node_id=node_id, + node_title=form.title, + form_content=presentation.form_content, + inputs=presentation.inputs, + actions=presentation.user_actions, + resolved_default_values=presentation.resolved_default_values, + ) diff --git a/api/core/workflow/nodes/human_input_v2/runtime.py b/api/core/workflow/nodes/human_input_v2/runtime.py new file mode 100644 index 00000000000..fc77a203074 --- /dev/null +++ b/api/core/workflow/nodes/human_input_v2/runtime.py @@ -0,0 +1,85 @@ +"""The Human Input operation needed by a workflow node.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Protocol + +from pydantic import SecretStr + +from graphon.runtime.runtime_state_protocol import ReadOnlyVariablePool +from repositories.human_input_v2.form_repository import Form + +from .entities import HumanInputNodeData + + +@dataclass(frozen=True, slots=True) +class PreparedForm: + """An existing or newly delivered form and its initiator stream token. + + form_token belongs only to the Current Initiator delivery selected by this + node's recipient configuration. It is None without that configuration or + without a usable, verified initiator. Never substitute another recipient's + token. The token locates the form; submission still requires authentication. + """ + + form: Form + form_token: SecretStr | None = field(repr=False) + + +class HumanInputDeliveryError(Exception): + """No delivery succeeded; the node must emit failed execution, not pause.""" + + +class HumanInputRuntime(Protocol): + """Run-bound approval lifecycle composing transaction-owning services. + + The composition boundary binds tenant, app, workflow run, authenticated + initiator, and debugging identity. The node supplies only execution-local + facts. Recipient resolution, channels, transactions, notification execution, + and delivery-result aggregation remain behind this interface. + Its implementation owns no transaction; each service commits its own work + before the runtime invokes the next service. + + Form is the existing detached snapshot, not a repository capability. Reuse + its persisted status and typed submission. PreparedForm adds only the stream + token that Form does not contain; it does not define another state machine. + """ + + def prepare_form( + self, + *, + node_execution_id: str, + node_data: HumanInputNodeData, + variable_pool: ReadOnlyVariablePool, + ) -> PreparedForm: + """Initialize or revisit the approval for this exact node execution. + + Look up the form first. If it exists, return its frozen content, status, + submission, and initiator token without changing its content or recipients. + Recover unconfirmed deliveries from the frozen notification plan; never + resend a confirmed success. Duplicate notifications are allowed when a + provider accepted a send but its result was not committed locally. + + Otherwise create Form and resolved/merged Recipients in one transaction. + After commit, deliver independently to each Recipient; shared endpoints + across different recipients do not suppress a delivery. Determine and + persist each recipient's deliveries before provider I/O. Perform + delivery and record each actual result within this node invocation; + do not return success merely because deliveries were created or queued. + + Raise HumanInputDeliveryError if every delivery failed or no approval + path exists. A usable Current Initiator interaction surface counts as + a delivery path without an external provider call. Partial failures are + warnings when another delivery succeeded. Return after aggregating the + results. Delivery success leaves the form waiting for human input. + + Only Current Initiator configuration permits a stream form_token; Email + and IM deliveries never supply the token for Workflow/Chatflow streams. + Preserve this rule on reentry using the original recipient grant. + + Expiration handling is separate from this lookup/create operation. + Persisted TIMEOUT selects the timeout branch; EXPIRED belongs to + whole-workflow termination. Do not recompute terminal outcomes here. + """ + ... diff --git a/api/core/workflow/workflow_tool_container_handler.py b/api/core/workflow/workflow_tool_container_handler.py index aecc24440f8..571f28eb1a2 100644 --- a/api/core/workflow/workflow_tool_container_handler.py +++ b/api/core/workflow/workflow_tool_container_handler.py @@ -226,7 +226,7 @@ class WorkflowToolContainerHandler: frame.state.graph_execution.exceptions_count -= 1 if isinstance(event, NodeRunPauseRequestedEvent) and isinstance(event.reason, HitlRequired): # Keep source identity in Graphon; persist form ownership for Dify's response boundary. - form_id = default_session_binding.resolve_form_id_from_session_id(session_id=event.reason.session_id) + _, form_id = default_session_binding.resolve_form_id_from_session_id(session_id=event.reason.session_id) self._root_runtime_state().variable_pool.add(human_input_container_selector(form_id), frame.container_id) # Preserve source ownership before outer containers add their own metadata. event.node_run_result.process_data = { diff --git a/api/dev/check_knowledge_fs_coverage.py b/api/dev/check_knowledge_fs_coverage.py index 9092d101239..0bde50a6174 100644 --- a/api/dev/check_knowledge_fs_coverage.py +++ b/api/dev/check_knowledge_fs_coverage.py @@ -276,6 +276,8 @@ def collect_changed_glue_lines( "--no-ext-diff", "--no-color", "--unified=0", + "--src-prefix=a/", + "--dst-prefix=b/", base, "--", *glue_paths, diff --git a/api/docker/entrypoint.sh b/api/docker/entrypoint.sh index 44902ac5182..51dbe8332ac 100755 --- a/api/docker/entrypoint.sh +++ b/api/docker/entrypoint.sh @@ -35,10 +35,10 @@ if [[ "${MODE}" == "worker" ]]; then if [[ -z "${CELERY_QUEUES}" ]]; then if [[ "${DEPLOYMENT_EDITION:-COMMUNITY}" == "CLOUD" ]]; then # Cloud edition: separate queues for dataset and trigger tasks - DEFAULT_QUEUES="api_token,dataset,dataset_summary,priority_dataset,priority_pipeline,pipeline,mail,ops_trace,app_deletion,app_rbac,plugin,workflow_storage,conversation,workflow_professional,workflow_team,workflow_sandbox,schedule_poller,schedule_executor,triggered_workflow_dispatcher,trigger_refresh_publisher,trigger_refresh_executor,retention,workflow_based_app_execution,knowledge_fs_lifecycle" + DEFAULT_QUEUES="api_token,dataset,dataset_summary,priority_dataset,priority_pipeline,pipeline,mail,human_input_delivery,human_input_contact_sync,ops_trace,app_deletion,app_rbac,plugin,workflow_storage,conversation,workflow_professional,workflow_team,workflow_sandbox,schedule_poller,schedule_executor,triggered_workflow_dispatcher,trigger_refresh_publisher,trigger_refresh_executor,retention,workflow_based_app_execution,knowledge_fs_lifecycle" else # Self-hosted editions: dataset, pipeline and workflow have separate queues - DEFAULT_QUEUES="api_token,dataset,dataset_summary,priority_dataset,priority_pipeline,pipeline,mail,ops_trace,app_deletion,app_rbac,plugin,workflow_storage,conversation,workflow,schedule_poller,schedule_executor,triggered_workflow_dispatcher,trigger_refresh_publisher,trigger_refresh_executor,retention,workflow_based_app_execution,knowledge_fs_lifecycle" + DEFAULT_QUEUES="api_token,dataset,dataset_summary,priority_dataset,priority_pipeline,pipeline,mail,human_input_delivery,human_input_contact_sync,ops_trace,app_deletion,app_rbac,plugin,workflow_storage,conversation,workflow,schedule_poller,schedule_executor,triggered_workflow_dispatcher,trigger_refresh_publisher,trigger_refresh_executor,retention,workflow_based_app_execution,knowledge_fs_lifecycle" fi else DEFAULT_QUEUES="${CELERY_QUEUES}" diff --git a/api/extensions/ext_application_services.py b/api/extensions/ext_application_services.py index 86f5b4bc3f3..289f0f34ccf 100644 --- a/api/extensions/ext_application_services.py +++ b/api/extensions/ext_application_services.py @@ -755,7 +755,8 @@ def build_application_services( messages=messages, files=message_files, extra_contents=ExecutionExtraContentReader( - repository=SQLAlchemyExecutionExtraContentRepository(session_maker=database_client) + repository=SQLAlchemyExecutionExtraContentRepository(session_maker=database_client), + session_factory=database_client, ), ), message_file_previews=MessageFilePreviewService( @@ -829,6 +830,7 @@ def build_application_services( workflow_runs=WorkflowRunService( workflow_runs=workflow_run_repository, node_executions=workflow_node_execution_repository, + session_factory=database_client, ), workspaces=workspace_services, workflow_app_logs=WorkflowAppLogQueryService( diff --git a/api/extensions/ext_blueprints.py b/api/extensions/ext_blueprints.py index cb03efc84c2..b16d60e3c21 100644 --- a/api/extensions/ext_blueprints.py +++ b/api/extensions/ext_blueprints.py @@ -1,6 +1,7 @@ """Register API blueprints with their browser-facing CORS policies.""" from configs import dify_config +from configs.deploy import IMEventTransportMode from constants import ( HEADER_NAME_APP_CODE, HEADER_NAME_CATALOG, @@ -42,6 +43,14 @@ def _apply_cors_once(bp, /, **cors_kwargs): def init_app(app: DifyApp): # register blueprint routers + from services.human_input_v2.im_webhook_runtime import configure_im_webhook_runtime + + configure_im_webhook_runtime(app) + if dify_config.HUMAN_INPUT_IM_EVENT_TRANSPORT_MODE is IMEventTransportMode.WEBHOOK: + from controllers.web.human_input_v2_im_webhook import bp as im_provider_webhook_bp + + app.register_blueprint(im_provider_webhook_bp) + from controllers.console import bp as console_app_bp from controllers.files import bp as files_bp from controllers.inner_api import bp as inner_api_bp diff --git a/api/extensions/ext_celery.py b/api/extensions/ext_celery.py index 1660aee5174..f9019d65224 100644 --- a/api/extensions/ext_celery.py +++ b/api/extensions/ext_celery.py @@ -200,6 +200,9 @@ def init_app(app: DifyApp) -> Celery: "tasks.install_default_plugins_task", # tenant default plugin installation "tasks.refresh_billing_vector_space_task", # billing vector-space cache refresh "tasks.workflow_run_archive_download_tasks", # workflow-run archive download preparation + "tasks.im_message_inbox_tasks", # durable IM callback processing + "tasks.human_input_im_card_tasks", # post-submission IM card updates + "tasks.im_contact_sync_tasks", # durable IM Contact directory synchronization "tasks.knowledge_fs_initial_source_preview_tasks", # datasource previews use the standard dataset queue "tasks.knowledge_fs_agent_investigation_tasks", "tasks.knowledge_fs_failed_retrieval_tasks", # best-effort Workflow quality capture uses dataset workers diff --git a/api/extensions/ext_redis.py b/api/extensions/ext_redis.py index e339105221c..65011540a05 100644 --- a/api/extensions/ext_redis.py +++ b/api/extensions/ext_redis.py @@ -46,6 +46,17 @@ class _ScriptRegistrar(Protocol): def register_script(self, script: str) -> Callable[..., Any]: ... +_INCR_IF_EXISTS_LUA = """ +-- Exactly 1 key and no arguments. +-- KEYS[1]: Existing integer counter key, including the deployment prefix. +-- Returns the incremented integer, or nil (Lua false) if the key is missing. +-- INCR preserves TTL; checking existence atomically avoids recreating an +-- expired/deleted counter without an expiry. +if redis.call('EXISTS', KEYS[1]) == 0 then return false end +return redis.call('INCR', KEYS[1]) +""" + + class RedisClientWrapper: """ A wrapper class for the Redis client that addresses the issue where the global @@ -176,6 +187,13 @@ class RedisClientWrapper: def hset(self, name: str | bytes, *args: Any, **kwargs: Any) -> Any: return self._require_client().hset(_serialize_redis_name_arg(name, self._get_prefix()), *args, **kwargs) + def incr_if_exists(self, name: str) -> int | None: + """Increment an existing integer counter, preserving TTL; return None if absent.""" + count = self.eval(_INCR_IF_EXISTS_LUA, 1, _serialize_redis_name(name, self._get_prefix())) + if count is not None and not isinstance(count, int): + raise ValueError("Invalid Redis counter response") + return count + def hgetall(self, name: str | bytes) -> Any: return self._require_client().hgetall(_serialize_redis_name_arg(name, self._get_prefix())) @@ -225,6 +243,11 @@ class RedisClientWrapper: def pubsub(self) -> PubSub: return self._require_client().pubsub() + def eval(self, script: str, numkeys: int, *args: str | int) -> Any: + # Lua callers supply physical names, matching the historically delegated API. + # Redis script responses are dynamically shaped at this SDK boundary. + return self._require_client().execute_command("EVAL", script, numkeys, *args) + def pipeline(self, transaction: bool = True, shard_hint: str | None = None) -> Any: return self._require_client().pipeline(transaction=transaction, shard_hint=shard_hint) diff --git a/api/factories/file_url_factory.py b/api/factories/file_url_factory.py new file mode 100644 index 00000000000..382856a87cc --- /dev/null +++ b/api/factories/file_url_factory.py @@ -0,0 +1,102 @@ +"""Refresh hosted file links in messages and frozen form display content.""" + +import base64 +import hashlib +import hmac +import re +from urllib.parse import parse_qs, parse_qsl, quote, unquote_plus, urlsplit + +from configs import dify_config +from core.tools.signature import sign_tool_file, sign_upload_file_preview_url +from graphon.file import helpers as file_helpers + +# Agent answers contain Markdown links, backticked links, and bare URLs. Keep +# relative /files/ links too; skipping those formats previously left internal +# file hosts in browser-facing answers. Refs #40788. +_CONTENT_URL = re.compile(r"""https?://[^\s<>"'`()\[\]]+|(?"'`()\[\]]+""") +_FILE_PREVIEW_PATH = re.compile( + r"/files/(?:(?P[\w-]+)/(?Pfile|image)-preview" + r"|(?Ptools|datasources)/(?P[\w-]+)(?P\.[^/]+)?)$" +) + + +def re_sign_file_urls( + content: str, + *, + preserve_url: bool = False, + replacements: dict[str, str] | None = None, +) -> str: + """Refresh display links, reusing replacements across related content. + + Messages retain their existing external-host and legacy-preview conversion. + Frozen forms preserve the original URL and renew only authentic signatures. + """ + if not content: + return content + if replacements is None: + replacements = {} + + def replace_url(match: re.Match[str]) -> str: + candidate = match.group() + url = candidate.rstrip(".,;") + if url not in replacements: + replacements[url] = _re_sign_file_url(url, preserve_url=preserve_url) + return replacements[url] + candidate[len(url) :] + + return _CONTENT_URL.sub(replace_url, content) + + +def _re_sign_file_url(url: str, *, preserve_url: bool) -> str: + parsed = urlsplit(url) + path = _FILE_PREVIEW_PATH.search(parsed.path) + if path is None: + return url + query = parse_qs(parsed.query, keep_blank_values=True) + if any(len(query.get(key, [])) != 1 for key in ("timestamp", "nonce", "sign")): + return url + file_id = path.group("upload_id") or path.group("generated_id") + preview_kind = path.group("preview_kind") or "file" + if preserve_url: + if not any( + url.startswith(f"{base_url}/files/") + for base_url in (dify_config.FILES_URL, dify_config.INTERNAL_FILES_URL, "") + ): + return url + # Ignore expiry, but do not grant access to arbitrary paths embedded in + # user-authored content merely because they resemble hosted file URLs. + payload = f"{preview_kind}-preview|{file_id}|{query['timestamp'][0]}|{query['nonce'][0]}" + expected = base64.urlsafe_b64encode( + hmac.new(dify_config.SECRET_KEY.encode(), payload.encode(), hashlib.sha256).digest() + ) + if not hmac.compare_digest(query["sign"][0].encode(), expected): + return url + + if path.group("generated_id"): + if not preserve_url and path.group("generated_kind") != "tools": + return url + extension = path.group("extension") or "" + if not preserve_url: + extension = f".{extension.rsplit('.', 1)[-1]}" if extension else ".bin" + if len(extension) > 10: + extension = ".bin" + signed_url = sign_tool_file(tool_file_id=file_id, extension=extension) + elif preserve_url and preview_kind == "image": + signed_url = sign_upload_file_preview_url(upload_file_id=file_id, extension="") + else: + # Messages keep converting deprecated image-preview links to file-preview. + signed_url = file_helpers.get_signed_file_url(file_id) + + if not preserve_url: + if "as_attachment" in url: + signed_url += "&as_attachment=true" + return signed_url + + signature_query = dict(parse_qsl(urlsplit(signed_url).query)) + # Preserve download options and unrelated query encoding in frozen content. + parameters: list[str] = [] + for parameter in parsed.query.split("&"): + key = unquote_plus(parameter.partition("=")[0]) + if key in signature_query: + parameter = f"{key}={quote(signature_query[key], safe='')}" + parameters.append(parameter) + return parsed._replace(query="&".join(parameters)).geturl() diff --git a/api/factories/human_input_v2_form_presentation.py b/api/factories/human_input_v2_form_presentation.py new file mode 100644 index 00000000000..5a82c0da33f --- /dev/null +++ b/api/factories/human_input_v2_form_presentation.py @@ -0,0 +1,146 @@ +"""Project frozen forms into the shared Dify form-rendering contract.""" + +from collections.abc import Mapping +from dataclasses import dataclass +from typing import assert_never + +from core.human_input_v2.resolved_form import ( + FileInput, + FileListInput, + MarkdownFragment, + ParagraphInput, + ResolvedForm, + SelectInput, +) +from core.workflow.nodes.human_input.entities import ( + FileInputConfig, + FileListInputConfig, + FormInputConfig, + ParagraphInputConfig, + SelectInputConfig, + StringListSource, + StringSource, + UserActionConfig, +) +from core.workflow.nodes.human_input.enums import ValueSourceType +from factories.file_url_factory import re_sign_file_urls +from graphon.variables.segments import ArrayFileSegment, Segment + + +def refresh_form_file_urls(form: ResolvedForm) -> ResolvedForm: + """Copy display content with fresh file URLs without resolving variables again.""" + refreshed_urls: dict[str, str] = {} + + # Reuse each replacement across fragments and the legacy rendering so both + # representations expose exactly the same signature in a single response. + return form.model_copy( + update={ + "parts": tuple( + part.model_copy( + update={"text": re_sign_file_urls(part.text, preserve_url=True, replacements=refreshed_urls)} + ) + if isinstance(part, MarkdownFragment) + else part + for part in form.parts + ), + "legacy_form_content": re_sign_file_urls( + form.legacy_form_content, preserve_url=True, replacements=refreshed_urls + ), + } + ) + + +@dataclass(frozen=True, slots=True) +class FormPresentation: + form_content: str + inputs: list[FormInputConfig] + user_actions: list[UserActionConfig] + resolved_default_values: dict[str, str] + + +def build_form_presentation(form: ResolvedForm) -> FormPresentation: + """Use the immutable snapshot without reloading workflow configuration.""" + inputs: list[FormInputConfig] = [] + defaults: dict[str, str] = {} + seen: set[str] = set() + for part in form.parts: + if isinstance(part, MarkdownFragment) or part.output_variable_name in seen: + continue + name = part.output_variable_name + seen.add(name) + match part: + case ParagraphInput(): + # Shared form controls require a concrete paragraph default, + # including when the frozen form has no configured default. + default = StringSource( + type=ValueSourceType.CONSTANT, + value=part.default_value if part.default_value is not None else "", + ) + if part.default_value is not None: + defaults[name] = part.default_value + inputs.append(ParagraphInputConfig(output_variable_name=name, default=default)) + case SelectInput(): + inputs.append( + SelectInputConfig( + output_variable_name=name, + option_source=StringListSource( + type=ValueSourceType.CONSTANT, + value=list(part.options), + ), + ) + ) + if part.default_value is not None: + defaults[name] = part.default_value + case FileInput(): + inputs.append( + FileInputConfig( + output_variable_name=name, + allowed_file_types=part.allowed_file_types, + allowed_file_extensions=part.allowed_file_extensions, + allowed_file_upload_methods=part.allowed_file_upload_methods, + ) + ) + case FileListInput(): + inputs.append( + FileListInputConfig( + output_variable_name=name, + allowed_file_types=part.allowed_file_types, + allowed_file_extensions=part.allowed_file_extensions, + allowed_file_upload_methods=part.allowed_file_upload_methods, + number_limits=part.number_limits, + ) + ) + return FormPresentation( + form_content=form.legacy_form_content, + inputs=inputs, + user_actions=[ + UserActionConfig(id=action.id, title=action.title, button_style=action.button_style) + for action in form.actions + ], + resolved_default_values=defaults, + ) + + +def render_filled_content(form: ResolvedForm, inputs: Mapping[str, Segment]) -> str: + """Render accepted inputs consistently across workflow events and delivery channels.""" + # Render source-ordered parts once: submitted text containing output markers + # must not be interpreted as another template substitution. + fragments: list[str] = [] + for part in form.parts: + if isinstance(part, MarkdownFragment): + fragments.append(part.text) + continue + segment = inputs.get(part.output_variable_name) + if segment is None or segment.to_object() is None: + continue + match part: + case ParagraphInput() | SelectInput(): + fragments.append(segment.text) + case FileInput(): + fragments.append("[file]") + case FileListInput(): + assert isinstance(segment, ArrayFileSegment), "Accepted file-list input must retain its Segment type" + fragments.append(f"[{len(segment.value)} files]") + case _: + assert_never(part) + return "".join(fragments) diff --git a/api/fields/pagination.py b/api/fields/pagination.py new file mode 100644 index 00000000000..2889e9c0ba7 --- /dev/null +++ b/api/fields/pagination.py @@ -0,0 +1,12 @@ +from pydantic import Field, PositiveInt + + +class PaginationParamsMixin: + page: PositiveInt = Field(1, description="1-based page number.") + limit: PositiveInt = Field(default=20, le=100, description="Maximum number of records returned per page.") + + +class PaginationResultMixin: + limit: int = Field(description="Page size used for the current query.") + total: int = Field(description="Total number of candidates matching the current query.") + page: int = Field(description="Current 1-based page number.") diff --git a/api/fields/timestamp.py b/api/fields/timestamp.py new file mode 100644 index 00000000000..1d5db632c58 --- /dev/null +++ b/api/fields/timestamp.py @@ -0,0 +1,24 @@ +from datetime import datetime +from typing import Annotated + +from pydantic import BeforeValidator, Field, PlainSerializer + +from libs.datetime_utils import to_utc_timestamp + + +def _require_datetime(value: object) -> datetime: + if not isinstance(value, datetime): + raise ValueError("Timestamp source must be a datetime") + return value + + +def _datetime_to_unix_seconds(value: datetime) -> int: + return to_utc_timestamp(value) + + +Timestamp = Annotated[ + datetime, + BeforeValidator(_require_datetime), + PlainSerializer(_datetime_to_unix_seconds, return_type=int, when_used="json"), + Field(..., description="Unix timestamp in seconds"), +] diff --git a/api/libs/helper.py b/api/libs/helper.py index da21b27b6d3..a1bf6b31d2a 100644 --- a/api/libs/helper.py +++ b/api/libs/helper.py @@ -24,6 +24,7 @@ from typing_extensions import TypedDict from configs import dify_config from core.app.features.rate_limiting.rate_limit import RateLimitGenerator from extensions.ext_redis import redis_client +from extensions.redis_names import serialize_redis_name from graphon.file import helpers as file_helpers from graphon.model_runtime.utils.encoders import jsonable_encoder @@ -561,6 +562,7 @@ class TokenManager: class _RateLimiterRedisClient(Protocol): + def eval(self, script: str, numkeys: int, *args: str | int) -> object: ... def register_script(self, script: str) -> Callable[..., Any]: ... def zadd(self, name: str | bytes, mapping: dict[str | bytes | int | float, float | int | str | bytes]) -> int: ... @@ -579,6 +581,44 @@ def _default_rate_limit_member_factory() -> str: return f"{current_time}:{secrets.token_urlsafe(nbytes=8)}" +_ACQUIRE_RATE_LIMIT_LUA = """ +-- EVAL contract: exactly 1 key and 4 arguments. +-- KEYS[1]: Physical Redis ZSET key for one rate-limit subject. Members are +-- unique request IDs; scores are Redis Unix timestamps in seconds. +-- ARGV[1]: Positive rolling-window duration in seconds. +-- ARGV[2]: Positive maximum number of accepted requests within that window. +-- ARGV[3]: Minimum seconds between accepted requests, from 0 to ARGV[1]. +-- ARGV[4]: Unique, nonempty request ID to consume a slot if allowed; an empty +-- string only queries the delay without consuming a slot. +-- Returns: 0 when allowed; otherwise the positive number of seconds to wait, +-- rounded up and accounting for both the window limit and cooldown. +-- Every call removes scores <= now - window, retaining (now - window, now]. +-- Only an allowed, nonempty request ID is inserted and refreshes the key TTL. +-- Rejected requests and delay queries do not consume quota or refresh TTL. +-- Redis TIME and atomic execution keep these decisions consistent across nodes. +local clock = redis.call('TIME') +local now = tonumber(clock[1]) + tonumber(clock[2]) / 1000000 +local window = tonumber(ARGV[1]) +local maximum = tonumber(ARGV[2]) +local cooldown = tonumber(ARGV[3]) +redis.call('ZREMRANGEBYSCORE', KEYS[1], '-inf', now - window) +local count = redis.call('ZCARD', KEYS[1]) +local wait = 0 +if count >= maximum then + local oldest = redis.call('ZRANGE', KEYS[1], count - maximum, count - maximum, 'WITHSCORES') + wait = math.max(wait, tonumber(oldest[2]) + window - now) +end +local latest = redis.call('ZREVRANGE', KEYS[1], 0, 0, 'WITHSCORES') +if #latest > 0 then wait = math.max(wait, tonumber(latest[2]) + cooldown - now) end +if wait > 0 then return math.ceil(wait) end +if ARGV[4] ~= '' then + redis.call('ZADD', KEYS[1], now, ARGV[4]) + redis.call('EXPIRE', KEYS[1], math.max(window, cooldown)) +end +return 0 +""" + + class RateLimiter: def __init__( self, @@ -622,6 +662,12 @@ class RateLimiter: raise def is_rate_limited(self, email: str) -> bool: + """Legacy non-consuming limit query, retained for existing admission policies. + + New callers should use acquire() for atomic admission or retry_after() + for a delay query. Migrating existing callers requires auditing when + their events should consume quota. + """ key = self._get_key(email) current_time = int(time.time()) window_start_time = current_time - self.time_window @@ -632,6 +678,11 @@ class RateLimiter: return bool(attempts and int(attempts) >= self.max_attempts) def increment_rate_limit(self, email: str): + """Record an event unconditionally, without enforcing admission limits. + + For atomic check-and-consume admission, use acquire() instead of calling + is_rate_limited() and this method separately. + """ key = self._get_key(email) member = self._member_factory() current_time = int(time.time()) @@ -639,11 +690,37 @@ class RateLimiter: self._redis_client.zadd(key, {member: current_time}) self._redis_client.expire(key, self.time_window * 2) + def acquire(self, subject: str, *, cooldown_seconds: int = 0) -> int: + """Atomically consume a slot, or return seconds to wait without consuming it.""" + return self._atomic_wait(subject, cooldown_seconds, self._member_factory()) + + def retry_after(self, subject: str, *, cooldown_seconds: int = 0) -> int: + """Read the combined cooldown/window delay without consuming a slot.""" + return self._atomic_wait(subject, cooldown_seconds, "") + + def _atomic_wait(self, subject: str, cooldown_seconds: int, member: str) -> int: + if self.max_attempts <= 0 or self.time_window <= 0 or not 0 <= cooldown_seconds <= self.time_window: + raise ValueError("Atomic rate limits require positive limits and a cooldown within the window") + # eval bypasses the wrapper's key prefix; Redis time prevents replica clock skew. + remaining = self._redis_client.eval( + _ACQUIRE_RATE_LIMIT_LUA, + 1, + serialize_redis_name(self._get_key(subject)), + self.time_window, + self.max_attempts, + cooldown_seconds, + member, + ) + if not isinstance(remaining, int): + raise ValueError("Invalid Redis rate limit response") + return remaining + def seconds_until_available(self, email: str) -> int: """Seconds until the oldest in-window entry expires, freeing a slot. Defensive floor of 1 second. Caller should only invoke this after - is_rate_limited() returned True. + is_rate_limited() returned True. New callers should use retry_after(), + which returns 0 when no delay is required. """ key = self._get_key(email) oldest = cast(Any, self._redis_client).zrange(key, 0, 0, withscores=True) diff --git a/api/migrations/versions/2026_10_26_1646-e8b4a1c6d920_add_human_input_v2.py b/api/migrations/versions/2026_10_26_1646-e8b4a1c6d920_add_human_input_v2.py new file mode 100644 index 00000000000..d4ddb41bc12 --- /dev/null +++ b/api/migrations/versions/2026_10_26_1646-e8b4a1c6d920_add_human_input_v2.py @@ -0,0 +1,792 @@ +"""Create the Human Input v2 schema. + +Revision ID: e8b4a1c6d920 +Revises: d7e9a1c3f502 +Create Date: 2026-10-26 16:46:14 +""" + +import sqlalchemy as sa +from alembic import op + +from models.types import LongText, StringUUID + +revision = "e8b4a1c6d920" +down_revision = "d7e9a1c3f502" +branch_labels = None +depends_on = None + +_PROVIDER_METADATA_MAX_LENGTH = 128 + + +def _default_fields(table_name: str) -> tuple[sa.Column, sa.Column, sa.Column, sa.PrimaryKeyConstraint]: + return ( + sa.Column("id", StringUUID(), nullable=False), + sa.Column("created_at", sa.DateTime(), server_default=sa.func.current_timestamp(), nullable=False), + sa.Column("updated_at", sa.DateTime(), server_default=sa.func.current_timestamp(), nullable=False), + sa.PrimaryKeyConstraint("id", name=f"{table_name}_pkey"), + ) + + +def upgrade() -> None: + op.create_table( + "human_input_contact_identities", + sa.Column( + "subject_type", + sa.String(length=20), + nullable=False, + comment="Immutable Account or External subject discriminator.", + ), + sa.Column( + "account_id", + StringUUID(), + nullable=True, + comment="Logical accounts.id reference for Account subjects only.", + ), + sa.Column("id", StringUUID(), nullable=False), + sa.Column("created_at", sa.DateTime(), server_default=sa.func.current_timestamp(), nullable=False), + sa.Column("updated_at", sa.DateTime(), server_default=sa.func.current_timestamp(), nullable=False), + sa.PrimaryKeyConstraint("id", name="human_input_contact_identities_pkey"), + sa.UniqueConstraint("account_id", name="human_input_contact_identities_account_id_uq"), + sa.CheckConstraint( + "(subject_type = 'account' AND account_id IS NOT NULL) OR " + "(subject_type = 'external' AND account_id IS NULL)", + name="human_input_contact_identities_subject_type_ck", + ), + comment=( + "Immutable Human Input Contact identities. Mutable Account and External Contact profile facts live " + "with their source owners." + ), + ) + + op.create_table( + "human_input_external_contact_profiles", + sa.Column( + "contact_id", + StringUUID(), + nullable=False, + comment="Logical human_input_contact_identities.id reference for one External subject.", + ), + sa.Column( + "tenant_id", + StringUUID(), + nullable=False, + comment="Owning tenants.id used by every current External Contact lookup.", + ), + sa.Column("name", sa.String(length=255), nullable=False, comment="Workspace-managed display name."), + sa.Column( + "normalized_name", + sa.String(length=255), + nullable=False, + comment="Canonical search value maintained by External Contact writes.", + ), + sa.Column( + "email", + sa.String(length=320), + nullable=False, + comment="Workspace-managed deliverable Email address.", + ), + sa.Column( + "normalized_email", + sa.String(length=320), + nullable=False, + comment="Canonical Email equality value maintained by External Contact writes.", + ), + sa.Column( + "avatar_file_id", + StringUUID(), + nullable=True, + comment="Logical upload_files.id reference owned by the same workspace.", + ), + sa.Column("created_at", sa.DateTime(), server_default=sa.func.current_timestamp(), nullable=False), + sa.Column("updated_at", sa.DateTime(), server_default=sa.func.current_timestamp(), nullable=False), + sa.PrimaryKeyConstraint("contact_id", name="human_input_external_contact_profiles_pkey"), + sa.UniqueConstraint("tenant_id", "normalized_email", name="hiecp_tenant_normalized_email_uq"), + comment=( + "Current workspace-owned External Contact profiles. Deletion removes both this profile and its " + "Contact identity." + ), + ) + op.create_index( + "hiecp_tenant_normalized_name_idx", + "human_input_external_contact_profiles", + ["tenant_id", "normalized_name"], + ) + + op.create_table( + "human_input_platform_contact_workspace_entries", + sa.Column("tenant_id", StringUUID(), nullable=False, comment="Logical foreign key to tenants.id."), + sa.Column( + "contact_id", + StringUUID(), + nullable=False, + comment="Logical foreign key to human_input_contact_identities.id.", + ), + sa.Column( + "added_by_account_id", + StringUUID(), + nullable=False, + comment="Logical foreign key to accounts.id for the administrator who added this directory entry.", + ), + sa.Column("id", StringUUID(), nullable=False), + sa.Column("created_at", sa.DateTime(), server_default=sa.func.current_timestamp(), nullable=False), + sa.Column("updated_at", sa.DateTime(), server_default=sa.func.current_timestamp(), nullable=False), + sa.PrimaryKeyConstraint("id", name="human_input_platform_contact_workspace_entries_pkey"), + sa.UniqueConstraint("tenant_id", "contact_id", name="hipcwe_tenant_contact_uq"), + comment=( + "EE-only workspace allow-list for Organization Account contacts. Workspace membership and External " + "contact ownership must not create rows in this table." + ), + ) + op.create_index( + "hipcwe_tenant_created_at_id_idx", + "human_input_platform_contact_workspace_entries", + ["tenant_id", "created_at", "id"], + ) + op.create_index( + "hipcwe_contact_id_idx", + "human_input_platform_contact_workspace_entries", + ["contact_id"], + ) + + op.create_table( + "human_input_im_channels", + sa.Column("id", StringUUID(), nullable=False), + sa.Column( + "owner_key", + sa.String(length=50), + nullable=False, + comment="Canonical owner slot: workspace: or deployment.", + ), + sa.Column( + "provider", + sa.String(length=20), + nullable=False, + comment="Configured IM provider discriminator.", + ), + sa.Column( + "provider_tenant_id", + sa.String(length=255), + nullable=False, + comment="Confirmed provider-side organization, tenant, or workspace identifier.", + ), + sa.Column( + "encrypted_credentials", + LongText(), + nullable=False, + comment="Versioned opaque encrypted IM Channel credential envelope.", + ), + sa.Column( + "app_identifier", + sa.String(length=255), + nullable=False, + comment="Safe provider application identifier used by credential-free projections.", + ), + sa.Column( + "webhook_id", + sa.String(length=32), + nullable=False, + comment="Server-generated globally unique route ID used to derive webhook URLs.", + ), + sa.Column( + "status", + sa.String(length=20), + nullable=False, + comment="Stored credential-safe Channel status snapshot.", + ), + sa.Column( + "config_version", + sa.Integer(), + nullable=False, + comment="Monotonic numeric version paired with the Channel ID for CAS.", + ), + sa.Column( + "configured_by_account_id", + StringUUID(), + nullable=True, + comment="Latest configuring Dify Account; null for deployment-owned writes.", + ), + sa.Column( + "status_reason", + LongText(), + nullable=True, + comment="Operator-safe status explanation without provider payload or credentials.", + ), + sa.Column( + "created_at", + sa.DateTime(), + server_default=sa.func.current_timestamp(), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(), + server_default=sa.func.current_timestamp(), + nullable=False, + ), + sa.PrimaryKeyConstraint("id", name="human_input_im_channels_pkey"), + sa.UniqueConstraint("owner_key", name="human_input_im_channels_owner_key_uq"), + sa.UniqueConstraint("webhook_id", name="human_input_im_channels_webhook_id_uq"), + sa.CheckConstraint( + "config_version > 0", + name="human_input_im_channels_config_version_positive_ck", + ), + comment=( + "Current owner-scoped Human Input IM Channel configuration. " + "Directory, Binding, Sync, and inbox records remain separately owned." + ), + ) + + op.create_table( + "human_input_im_identities", + sa.Column( + "channel_id", + StringUUID(), + nullable=False, + comment="Logical human_input_im_channels.id reference.", + ), + sa.Column( + "provider_user_id", + sa.String(length=255), + nullable=False, + comment="Provider-native user identifier within the owning Channel.", + ), + sa.Column( + "raw_payload", + LongText(), + nullable=False, + comment="Latest opaque Provider payload retained for diagnostics.", + ), + sa.Column( + "last_seen_sync_run_id", + StringUUID(), + nullable=False, + comment="Logical human_input_im_sync_runs.id reference for the latest observation.", + ), + sa.Column( + "last_seen_at", + sa.DateTime(), + nullable=False, + comment="Timestamp of the latest successful Provider observation.", + ), + sa.Column( + "display_name", + sa.String(length=255), + nullable=True, + comment="Latest canonical non-blank Provider display name.", + ), + sa.Column( + "normalized_name", + sa.String(length=255), + nullable=True, + comment="Canonical display name used by persistence queries.", + ), + sa.Column( + "email", + sa.String(length=320), + nullable=True, + comment="Latest canonical non-blank Provider email.", + ), + sa.Column( + "normalized_email", + sa.String(length=320), + nullable=True, + comment="Canonical email used by matching and persistence queries.", + ), + sa.Column( + "avatar_file_id", + StringUUID(), + nullable=True, + comment="Logical upload_files.id reference for the current IM avatar.", + ), + *_default_fields("human_input_im_identities"), + sa.UniqueConstraint( + "channel_id", + "provider_user_id", + name="human_input_im_identities_channel_provider_user_uq", + ), + sa.CheckConstraint( + "length(trim(provider_user_id)) > 0", + name="human_input_im_identities_provider_user_nonblank", + ), + comment="Current Provider users synchronized through one IM Channel.", + ) + op.create_index( + "hiimi_channel_email_idx", + "human_input_im_identities", + ["channel_id", "normalized_email"], + ) + op.create_index( + "hiimi_channel_name_idx", + "human_input_im_identities", + ["channel_id", "normalized_name"], + ) + op.create_table( + "human_input_im_bindings", + sa.Column( + "channel_id", + StringUUID(), + nullable=False, + comment="Logical human_input_im_channels.id reference.", + ), + sa.Column( + "contact_id", + StringUUID(), + nullable=False, + comment="Logical human_input_contact_identities.id reference.", + ), + sa.Column( + "im_identity_id", + StringUUID(), + nullable=False, + comment="Logical human_input_im_identities.id reference.", + ), + sa.Column( + "bound_by_account_id", + StringUUID(), + nullable=True, + comment="Latest Dify Account that manually selected this Binding, when available.", + ), + *_default_fields("human_input_im_bindings"), + sa.UniqueConstraint( + "channel_id", + "contact_id", + name="human_input_im_bindings_channel_contact_uq", + ), + sa.UniqueConstraint( + "channel_id", + "im_identity_id", + name="human_input_im_bindings_channel_identity_uq", + ), + comment="Default Contact-to-IM-identity Bindings for one IM Channel.", + ) + op.create_table( + "human_input_im_workspace_binding_overrides", + sa.Column( + "channel_id", + StringUUID(), + nullable=False, + comment="Logical human_input_im_channels.id reference.", + ), + sa.Column( + "tenant_id", + StringUUID(), + nullable=False, + comment="Target tenants.id whose effective Binding is overridden.", + ), + sa.Column( + "contact_id", + StringUUID(), + nullable=False, + comment="Logical human_input_contact_identities.id reference.", + ), + sa.Column( + "im_identity_id", + StringUUID(), + nullable=False, + comment="Logical human_input_im_identities.id reference.", + ), + sa.Column( + "bound_by_account_id", + StringUUID(), + nullable=True, + comment="Dify Account that selected this workspace override.", + ), + *_default_fields("human_input_im_workspace_binding_overrides"), + sa.UniqueConstraint( + "channel_id", + "tenant_id", + "contact_id", + name="hiimwbo_channel_tenant_contact_uq", + ), + sa.UniqueConstraint( + "channel_id", + "tenant_id", + "im_identity_id", + name="hiimwbo_channel_tenant_identity_uq", + ), + comment="Workspace-specific Binding overrides for one IM Channel.", + ) + op.create_index( + "hiimwbo_channel_identity_idx", + "human_input_im_workspace_binding_overrides", + ["channel_id", "im_identity_id"], + ) + + op.create_table( + "human_input_im_sync_runs", + sa.Column( + "integration_id", + StringUUID(), + nullable=False, + comment="Logical human_input_im_channels.id owner.", + ), + sa.Column( + "integration_config_version", + sa.Integer(), + nullable=False, + comment="Captured integration configuration revision.", + ), + sa.Column("provider", sa.String(length=20), nullable=False, comment="Captured provider discriminator."), + sa.Column("status", sa.String(length=20), nullable=False, comment="Synchronization lifecycle state."), + sa.Column("added_count", sa.Integer(), nullable=False, comment="Newly matched and bound entries."), + sa.Column("not_matched_count", sa.Integer(), nullable=False, comment="Unmatched entries."), + sa.Column("failed_count", sa.Integer(), nullable=False, comment="Failed entries."), + sa.Column( + "removed_count", + sa.Integer(), + nullable=False, + comment="Removed binding facts, including one unbound-identity fact when applicable.", + ), + sa.Column("skipped_count", sa.Integer(), nullable=False, comment="Intentionally skipped entries."), + sa.Column( + "started_by_account_id", + StringUUID(), + nullable=True, + comment="Logical accounts.id for the trigger actor.", + ), + sa.Column("started_at", sa.DateTime(), nullable=True, comment="Worker start timestamp."), + sa.Column("finished_at", sa.DateTime(), nullable=True, comment="Terminal timestamp."), + sa.Column("error_code", sa.String(length=100), nullable=True, comment="Machine-readable terminal error."), + sa.Column("error_message", LongText(), nullable=True, comment="Operator-safe terminal error."), + *_default_fields("human_input_im_sync_runs"), + sa.CheckConstraint("integration_config_version > 0", name="captured_version_positive"), + sa.CheckConstraint( + "added_count >= 0 AND not_matched_count >= 0 AND failed_count >= 0 AND removed_count >= 0 " + "AND skipped_count >= 0", + name="result_counts_nonnegative", + ), + comment="Manual IM directory synchronization lifecycle and counts.", + ) + op.create_index( + "hiimsr_integration_created_idx", + "human_input_im_sync_runs", + ["integration_id", "created_at", "id"], + ) + op.create_index( + "hiimsr_integration_status_created_idx", + "human_input_im_sync_runs", + ["integration_id", "status", "created_at"], + ) + + op.create_table( + "human_input_im_sync_results", + sa.Column( + "integration_id", + StringUUID(), + nullable=False, + comment="Denormalized logical human_input_im_channels.id.", + ), + sa.Column( + "sync_run_id", + StringUUID(), + nullable=False, + comment="Logical human_input_im_sync_runs.id.", + ), + sa.Column("result_type", sa.String(length=20), nullable=False, comment="Stable result bucket."), + sa.Column("provider_user_id", sa.String(length=255), nullable=True, comment="Observed provider user ID."), + sa.Column("display_name", sa.String(length=255), nullable=True, comment="Observed provider display name."), + sa.Column("email", sa.String(length=320), nullable=True, comment="Observed provider email."), + sa.Column("normalized_email", sa.String(length=320), nullable=True, comment="Normalized matching email."), + sa.Column("contact_id", StringUUID(), nullable=True, comment="Historical logical Contact identity."), + sa.Column( + "im_identity_id", + StringUUID(), + nullable=True, + comment="Historical logical IM identity.", + ), + sa.Column("im_binding_id", StringUUID(), nullable=True, comment="Historical logical IM binding."), + sa.Column("removal_reason", sa.String(length=32), nullable=True, comment="Stable removal reason."), + sa.Column("reason_code", sa.String(length=100), nullable=True, comment="Machine-readable diagnostic reason."), + sa.Column("reason_message", LongText(), nullable=True, comment="Operator-safe diagnostic."), + sa.Column( + "directory_entry_payload", + LongText(), + nullable=True, + comment="Immutable provider entry JSON observed by this run.", + ), + sa.Column( + "contact_snapshot", + LongText(), + nullable=True, + comment="Immutable Contact display snapshot JSON.", + ), + sa.Column( + "identity_snapshot", + LongText(), + nullable=True, + comment="Immutable removed identity snapshot JSON.", + ), + sa.Column( + "operation_key", + sa.String(length=255), + nullable=True, + comment="Deterministic run-local idempotency key; null only for historical results.", + ), + *_default_fields("human_input_im_sync_results"), + sa.UniqueConstraint( + "sync_run_id", + "operation_key", + name="human_input_im_sync_results_run_operation_uq", + ), + comment="Append-only per-entry, removed-binding, and diagnostic IM synchronization outcomes.", + ) + op.create_index( + "hiimsres_run_type_created_idx", + "human_input_im_sync_results", + ["sync_run_id", "result_type", "created_at", "id"], + ) + op.create_index( + "hiimsres_integration_contact_created_idx", + "human_input_im_sync_results", + ["integration_id", "contact_id", "created_at"], + ) + op.create_index( + "hiimsres_integration_identity_created_idx", + "human_input_im_sync_results", + ["integration_id", "im_identity_id", "created_at"], + ) + + op.create_table( + "human_input_email_providers", + sa.Column("provider", sa.String(length=20), nullable=False, comment="Configured email provider discriminator."), + sa.Column("sender_email", sa.String(length=320), nullable=False, comment="Configured sender email address."), + sa.Column( + "encrypted_credentials", + LongText(), + nullable=False, + comment="Encrypted Resend credential Pydantic model serialized as JSON text.", + ), + sa.Column("tenant_id", StringUUID(), nullable=False, comment="Logical foreign key to tenants.id."), + sa.Column("sender_name", sa.String(length=255), nullable=False, comment="Optional sender display name."), + sa.Column( + "configured_by_account_id", + StringUUID(), + nullable=True, + comment="Logical foreign key to accounts.id for the latest configuration write.", + ), + sa.Column( + "config_version", + sa.Integer(), + nullable=False, + server_default=sa.text("1"), + comment="Monotonic Email configuration revision used for compare-and-swap.", + ), + *_default_fields("human_input_email_providers"), + sa.UniqueConstraint("tenant_id", name="human_input_email_providers_tenant_uq"), + comment="Workspace-level Human Input email delivery configuration.", + ) + + op.create_table( + "im_message_event", + sa.Column("integration_id", StringUUID(), nullable=False), + sa.Column("provider", sa.String(length=20), nullable=False), + sa.Column( + "tenant_id", + sa.String(length=_PROVIDER_METADATA_MAX_LENGTH), + nullable=False, + ), + sa.Column( + "event_id", + sa.String(length=_PROVIDER_METADATA_MAX_LENGTH), + nullable=True, + ), + sa.Column("provider_event_time", sa.DateTime(), nullable=True), + sa.Column("received_at", sa.DateTime(), nullable=False), + sa.Column("provider_event_type", sa.String(length=_PROVIDER_METADATA_MAX_LENGTH), nullable=True), + sa.Column("ingress_kind", sa.String(length=20), nullable=False), + sa.Column("payload", LongText(), nullable=False), + sa.Column( + "request_ip_address", + sa.Text(), + nullable=True, + comment="HTTP request IP when available; absent for stream and historical events.", + ), + sa.Column("processed_at", sa.DateTime(), nullable=True), + sa.Column("id", StringUUID(), nullable=False), + sa.Column("created_at", sa.DateTime(), server_default=sa.func.current_timestamp(), nullable=False), + sa.PrimaryKeyConstraint("id", name="im_message_inbox_pkey"), + sa.UniqueConstraint("provider", "event_id", name="im_message_event_provider_event_uq"), + comment="Durable authenticated IM callback records.", + ) + + op.create_table( + "human_input_im_reconciliation_changes", + sa.Column( + "integration_id", + StringUUID(), + nullable=False, + comment="Logical foreign key to human_input_im_channels.id.", + ), + sa.Column( + "sync_run_id", + StringUUID(), + nullable=False, + comment="Logical foreign key to human_input_im_sync_runs.id.", + ), + sa.Column( + "operation_key", + sa.String(length=255), + nullable=False, + comment="Deterministic run-local idempotency key.", + ), + sa.Column("subject_kind", sa.String(length=20), nullable=False), + sa.Column("operation", sa.String(length=20), nullable=False), + sa.Column("reason_code", sa.String(length=100), nullable=False), + sa.Column("im_identity_id", StringUUID(), nullable=False), + sa.Column("committed_at", sa.DateTime(), nullable=False), + sa.Column("im_binding_id", StringUUID(), nullable=True), + sa.Column("contact_id", StringUUID(), nullable=True), + sa.Column("before_snapshot", sa.Text(), nullable=True), + sa.Column("after_snapshot", sa.Text(), nullable=True), + sa.Column("id", StringUUID(), nullable=False), + sa.Column("created_at", sa.DateTime(), server_default=sa.func.current_timestamp(), nullable=False), + sa.Column("updated_at", sa.DateTime(), server_default=sa.func.current_timestamp(), nullable=False), + sa.PrimaryKeyConstraint("id", name="human_input_im_reconciliation_changes_pkey"), + sa.UniqueConstraint( + "sync_run_id", + "operation_key", + name="human_input_im_reconciliation_changes_run_operation_uq", + ), + sa.CheckConstraint( + "before_snapshot IS NOT NULL OR after_snapshot IS NOT NULL", + name="snapshot_present", + ), + sa.CheckConstraint( + "(operation = 'create' AND before_snapshot IS NULL AND after_snapshot IS NOT NULL) OR " + "(operation = 'delete' AND before_snapshot IS NOT NULL AND after_snapshot IS NULL) OR " + "(operation NOT IN ('create', 'delete') AND before_snapshot IS NOT NULL AND after_snapshot IS NOT NULL)", + name="snapshot_operation_shape", + ), + sa.CheckConstraint( + "(subject_kind = 'identity' AND im_binding_id IS NULL) OR " + "(subject_kind = 'binding' AND im_binding_id IS NOT NULL)", + name="subject_identifier_shape", + ), + comment="Append-only IM identity and IM binding reconciliation mutation history.", + ) + op.create_index( + "hiimrc_run_subject_committed_idx", + "human_input_im_reconciliation_changes", + ["sync_run_id", "subject_kind", "committed_at", "id"], + ) + op.create_index( + "hiimrc_integration_committed_idx", + "human_input_im_reconciliation_changes", + ["integration_id", "committed_at", "id"], + ) + + op.create_table( + "hitlv2_forms", + sa.Column("id", StringUUID(), nullable=False), + sa.Column("tenant_id", StringUUID(), nullable=False), + sa.Column("app_id", StringUUID(), nullable=False), + sa.Column("workflow_run_id", StringUUID(), nullable=True), + sa.Column("node_execution_id", StringUUID(), nullable=True), + sa.Column("form_kind", sa.String(20), nullable=False), + sa.Column( + "status", + sa.String(20), + nullable=False, + server_default="waiting", + comment="Persisted form outcome; terminal states are not recomputed from deadlines.", + ), + sa.Column("expiration_time", sa.DateTime(), nullable=False), + sa.Column("global_timeout_deadline", sa.DateTime(), nullable=False), + sa.Column("resolved_form", LongText(), nullable=False), + sa.Column("submitted_at", sa.DateTime(), nullable=True), + sa.Column("submitted_by_recipient_id", StringUUID(), nullable=True), + sa.Column("submitted_by_delivery_id", StringUUID(), nullable=True), + sa.Column("selected_action_id", sa.String(200), nullable=True), + sa.Column("raw_submission_inputs", sa.Text(), nullable=True), + sa.Column("normalized_submission_data", sa.Text(), nullable=True), + sa.Column("created_at", sa.DateTime(), server_default=sa.func.current_timestamp(), nullable=False), + sa.Column("updated_at", sa.DateTime(), server_default=sa.func.current_timestamp(), nullable=False), + sa.PrimaryKeyConstraint("id", name="hitlv2_forms_pkey"), + sa.UniqueConstraint( + "workflow_run_id", + "node_execution_id", + name="hitlv2_forms_execution_uq", + ), + ) + op.create_index( + "hitlv2_forms_expiration_scan_idx", + "hitlv2_forms", + ["tenant_id", "app_id", "form_kind", "status", "id"], + ) + op.create_index("hitlv2_forms_node_deadline_idx", "hitlv2_forms", ["form_kind", "status", "expiration_time", "id"]) + op.create_index( + "hitlv2_forms_global_deadline_idx", "hitlv2_forms", ["form_kind", "status", "global_timeout_deadline", "id"] + ) + + op.create_table( + "hitlv2_recipients", + sa.Column("id", StringUUID(), nullable=False), + sa.Column("tenant_id", StringUUID(), nullable=False), + sa.Column("form_id", StringUUID(), nullable=False), + sa.Column("subject_type", sa.String(20), nullable=False), + sa.Column("subject_value", sa.String(255), nullable=False), + sa.Column("sources", LongText(), nullable=False), + sa.Column("snapshot", LongText(), nullable=False), + sa.Column("created_at", sa.DateTime(), server_default=sa.func.current_timestamp(), nullable=False), + sa.Column("updated_at", sa.DateTime(), server_default=sa.func.current_timestamp(), nullable=False), + sa.PrimaryKeyConstraint("id", name="hitlv2_recipients_pkey"), + sa.UniqueConstraint("form_id", "subject_type", "subject_value", name="hitlv2_recipients_subject_uq"), + ) + + op.create_table( + "hitlv2_deliveries", + sa.Column("id", StringUUID(), primary_key=True), + sa.Column("tenant_id", StringUUID(), nullable=False), + sa.Column("form_id", StringUUID(), nullable=False), + sa.Column("recipient_id", StringUUID(), nullable=False), + sa.Column("token_hash", sa.String(64), nullable=False), + sa.Column("auth_type", sa.Text(), nullable=False), + sa.Column("target_type", sa.String(20), nullable=False), + sa.Column("target_snapshot", LongText(), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.current_timestamp()), + sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.func.current_timestamp()), + ) + op.create_index("hitlv2_deliveries_token_idx", "hitlv2_deliveries", ["token_hash"]) + op.create_index("hitlv2_deliveries_form_target_idx", "hitlv2_deliveries", ["form_id", "target_type"]) + op.create_table( + "hitlv2_delivery_attempts", + sa.Column("id", StringUUID(), primary_key=True), + sa.Column("tenant_id", StringUUID(), nullable=False), + sa.Column("form_id", StringUUID(), nullable=False), + sa.Column("delivery_id", StringUUID(), nullable=False), + sa.Column("status", sa.Text(), nullable=False), + sa.Column("error_message", sa.String(200), nullable=True), + sa.Column("response", LongText(), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.current_timestamp()), + sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.func.current_timestamp()), + ) + + op.create_table( + "hitlv2_audit_events", + sa.Column("id", StringUUID(), primary_key=True), + sa.Column("tenant_id", StringUUID(), nullable=False), + sa.Column("form_id", StringUUID(), nullable=False), + sa.Column("payload", LongText(), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.current_timestamp()), + sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.func.current_timestamp()), + ) + + +def downgrade() -> None: + op.drop_table("hitlv2_audit_events") + op.drop_table("hitlv2_delivery_attempts") + op.drop_table("hitlv2_deliveries") + op.drop_table("hitlv2_recipients") + op.drop_table("hitlv2_forms") + op.drop_table("human_input_im_reconciliation_changes") + op.drop_table("im_message_event") + op.drop_table("human_input_email_providers") + op.drop_table("human_input_im_sync_results") + op.drop_table("human_input_im_sync_runs") + op.drop_table("human_input_im_workspace_binding_overrides") + op.drop_table("human_input_im_bindings") + op.drop_table("human_input_im_identities") + op.drop_table("human_input_im_channels") + op.drop_table("human_input_platform_contact_workspace_entries") + op.drop_table("human_input_external_contact_profiles") + op.drop_table("human_input_contact_identities") diff --git a/api/migrations/versions/2026_10_27_1718-ad1816d009b8_rename_hitlv2_integration_columns.py b/api/migrations/versions/2026_10_27_1718-ad1816d009b8_rename_hitlv2_integration_columns.py new file mode 100644 index 00000000000..426987d50dd --- /dev/null +++ b/api/migrations/versions/2026_10_27_1718-ad1816d009b8_rename_hitlv2_integration_columns.py @@ -0,0 +1,143 @@ +"""Rename HITLv2 integration references to channel + +Revision ID: ad1816d009b8 +Revises: e8b4a1c6d920 +Create Date: 2026-10-27 17:18:24.294086+08:00 + +""" + +import sqlalchemy as sa +from alembic import op + +from models.types import StringUUID + +# revision identifiers, used by Alembic. +revision = "ad1816d009b8" +down_revision = "e8b4a1c6d920" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.alter_column( + "im_message_event", + "integration_id", + new_column_name="channel_id", + existing_type=StringUUID(), + existing_nullable=False, + comment="Logical local Channel routing identifier without physical ownership.", + ) + + with op.batch_alter_table("human_input_im_sync_runs") as batch_op: + batch_op.drop_index("hiimsr_integration_created_idx") + batch_op.drop_index("hiimsr_integration_status_created_idx") + batch_op.drop_constraint(op.f("human_input_im_sync_runs_captured_version_positive_check"), type_="check") + batch_op.alter_column( + "integration_id", + new_column_name="channel_id", + existing_type=StringUUID(), + existing_nullable=False, + comment="Logical foreign key to human_input_im_channels.id.", + ) + batch_op.alter_column( + "integration_config_version", + new_column_name="channel_config_version", + existing_type=sa.Integer(), + existing_nullable=False, + comment="Channel configuration revision captured when this synchronization was created.", + ) + batch_op.create_check_constraint( + op.f("human_input_im_sync_runs_captured_version_positive_check"), "channel_config_version > 0" + ) + batch_op.create_index("hiimsr_channel_created_idx", ["channel_id", "created_at", "id"]) + batch_op.create_index("hiimsr_channel_status_created_idx", ["channel_id", "status", "created_at"]) + + with op.batch_alter_table("human_input_im_sync_results") as batch_op: + batch_op.drop_index("hiimsres_integration_contact_created_idx") + batch_op.drop_index("hiimsres_integration_identity_created_idx") + batch_op.alter_column( + "integration_id", + new_column_name="channel_id", + existing_type=StringUUID(), + existing_nullable=False, + comment=( + "Denormalized logical foreign key to human_input_im_channels.id; " + "must match the channel referenced by sync_run_id." + ), + ) + batch_op.create_index("hiimsres_channel_contact_created_idx", ["channel_id", "contact_id", "created_at"]) + batch_op.create_index("hiimsres_channel_identity_created_idx", ["channel_id", "im_identity_id", "created_at"]) + + with op.batch_alter_table("human_input_im_reconciliation_changes") as batch_op: + batch_op.drop_index("hiimrc_integration_committed_idx") + batch_op.alter_column( + "integration_id", + new_column_name="channel_id", + existing_type=StringUUID(), + existing_nullable=False, + comment="Logical foreign key to human_input_im_channels.id.", + ) + batch_op.create_index("hiimrc_channel_committed_idx", ["channel_id", "committed_at", "id"]) + + +def downgrade() -> None: + with op.batch_alter_table("human_input_im_reconciliation_changes") as batch_op: + batch_op.drop_index("hiimrc_channel_committed_idx") + batch_op.alter_column( + "channel_id", + new_column_name="integration_id", + existing_type=StringUUID(), + existing_nullable=False, + comment="Logical foreign key to human_input_im_channels.id.", + ) + batch_op.create_index("hiimrc_integration_committed_idx", ["integration_id", "committed_at", "id"]) + + with op.batch_alter_table("human_input_im_sync_results") as batch_op: + batch_op.drop_index("hiimsres_channel_contact_created_idx") + batch_op.drop_index("hiimsres_channel_identity_created_idx") + batch_op.alter_column( + "channel_id", + new_column_name="integration_id", + existing_type=StringUUID(), + existing_nullable=False, + comment="Denormalized logical human_input_im_channels.id.", + ) + batch_op.create_index( + "hiimsres_integration_contact_created_idx", ["integration_id", "contact_id", "created_at"] + ) + batch_op.create_index( + "hiimsres_integration_identity_created_idx", ["integration_id", "im_identity_id", "created_at"] + ) + + with op.batch_alter_table("human_input_im_sync_runs") as batch_op: + batch_op.drop_index("hiimsr_channel_created_idx") + batch_op.drop_index("hiimsr_channel_status_created_idx") + batch_op.drop_constraint(op.f("human_input_im_sync_runs_captured_version_positive_check"), type_="check") + batch_op.alter_column( + "channel_config_version", + new_column_name="integration_config_version", + existing_type=sa.Integer(), + existing_nullable=False, + comment="Captured integration configuration revision.", + ) + batch_op.alter_column( + "channel_id", + new_column_name="integration_id", + existing_type=StringUUID(), + existing_nullable=False, + comment="Logical human_input_im_channels.id owner.", + ) + batch_op.create_check_constraint( + op.f("human_input_im_sync_runs_captured_version_positive_check"), "integration_config_version > 0" + ) + batch_op.create_index("hiimsr_integration_created_idx", ["integration_id", "created_at", "id"]) + batch_op.create_index("hiimsr_integration_status_created_idx", ["integration_id", "status", "created_at"]) + + op.alter_column( + "im_message_event", + "channel_id", + new_column_name="integration_id", + existing_type=StringUUID(), + existing_nullable=False, + comment=None, + ) diff --git a/api/migrations/versions/2026_10_27_2355-5f44dd69330f_backfill_account_contact_identities.py b/api/migrations/versions/2026_10_27_2355-5f44dd69330f_backfill_account_contact_identities.py new file mode 100644 index 00000000000..957dfcacf0b --- /dev/null +++ b/api/migrations/versions/2026_10_27_2355-5f44dd69330f_backfill_account_contact_identities.py @@ -0,0 +1,42 @@ +"""Backfill Account Contact identities + +Revision ID: 5f44dd69330f +Revises: ad1816d009b8 +Create Date: 2026-10-27 23:55:43.514434 + +""" + +from alembic import op + +# revision identifiers, used by Alembic. +revision = "5f44dd69330f" +down_revision = "ad1816d009b8" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + match op.get_context().dialect.name: + case "postgresql": + op.execute( + """ + INSERT INTO human_input_contact_identities (id, subject_type, account_id) + SELECT uuidv7(), 'account', id FROM accounts ORDER BY id + ON CONFLICT (account_id) DO NOTHING + """ + ) + case "mysql" | "mariadb": + op.execute( + """ + INSERT INTO human_input_contact_identities (id, subject_type, account_id) + SELECT UUID(), 'account', id FROM accounts ORDER BY id + ON DUPLICATE KEY UPDATE account_id = human_input_contact_identities.account_id + """ + ) + case dialect_name: + raise NotImplementedError(f"Unsupported database dialect for Account Contact backfill: {dialect_name}") + + +def downgrade() -> None: + # Stable Contact IDs may already be referenced by bindings and historical records. + pass diff --git a/api/migrations/versions/2026_10_28_1039-0cc3158b50b0_add_hitlv2_ordered_lookup_indexes.py b/api/migrations/versions/2026_10_28_1039-0cc3158b50b0_add_hitlv2_ordered_lookup_indexes.py new file mode 100644 index 00000000000..3a7f26d207a --- /dev/null +++ b/api/migrations/versions/2026_10_28_1039-0cc3158b50b0_add_hitlv2_ordered_lookup_indexes.py @@ -0,0 +1,27 @@ +"""Add HITLv2 ordered lookup indexes + +Revision ID: 0cc3158b50b0 +Revises: 5f44dd69330f +Create Date: 2026-10-28 10:39:22.447779+08:00 + +""" + +from alembic import op + +# revision identifiers, used by Alembic. +revision = "0cc3158b50b0" +down_revision = "5f44dd69330f" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_index("hitlv2_forms_run_id_idx", "hitlv2_forms", ["workflow_run_id", "id"]) + op.create_index("hitlv2_deliveries_form_id_idx", "hitlv2_deliveries", ["form_id", "id"]) + op.create_index("hiimi_channel_id_idx", "human_input_im_identities", ["channel_id", "id"]) + + +def downgrade() -> None: + op.drop_index("hiimi_channel_id_idx", table_name="human_input_im_identities") + op.drop_index("hitlv2_deliveries_form_id_idx", table_name="hitlv2_deliveries") + op.drop_index("hitlv2_forms_run_id_idx", table_name="hitlv2_forms") diff --git a/api/models/__init__.py b/api/models/__init__.py index 66896d19656..e7cf20bf8cf 100644 --- a/api/models/__init__.py +++ b/api/models/__init__.py @@ -67,6 +67,26 @@ from .enums import ( ) from .execution_extra_content import ExecutionExtraContent, HumanInputContent from .human_input import HumanInputForm, HumanInputFormUploadFile, HumanInputFormUploadToken +from .human_input_v2 import ( + ContactSubjectType, + HumanInputContactIdentity, + HumanInputEmailProvider, + HumanInputExternalContactProfile, + HumanInputIMBinding, + HumanInputIMChannel, + HumanInputIMIdentity, + HumanInputIMReconciliationChange, + HumanInputIMSyncResult, + HumanInputIMSyncRun, + HumanInputPlatformContactWorkspaceEntry, + IMBindingReconciliationSnapshot, + IMIdentityRawPayload, + IMIdentityReconciliationSnapshot, + IMMessageEvent, + IMSyncContactSnapshot, + IMSyncDirectoryEntryPayload, + IMSyncIdentitySnapshot, +) from .knowledge_fs import ( AppKnowledgeFSSpaceJoin, KnowledgeFSApiCredential, @@ -259,6 +279,7 @@ __all__ = [ "BuiltinToolProvider", "CeleryTask", "CeleryTaskSet", + "ContactSubjectType", "Conversation", "ConversationVariable", "CreatorUserRole", @@ -287,10 +308,27 @@ __all__ = [ "ExporleBanner", "ExternalKnowledgeApis", "ExternalKnowledgeBindings", + "HumanInputContactIdentity", "HumanInputContent", + "HumanInputEmailProvider", + "HumanInputExternalContactProfile", "HumanInputForm", "HumanInputFormUploadFile", "HumanInputFormUploadToken", + "HumanInputIMBinding", + "HumanInputIMChannel", + "HumanInputIMIdentity", + "HumanInputIMReconciliationChange", + "HumanInputIMSyncResult", + "HumanInputIMSyncRun", + "HumanInputPlatformContactWorkspaceEntry", + "IMBindingReconciliationSnapshot", + "IMIdentityRawPayload", + "IMIdentityReconciliationSnapshot", + "IMMessageEvent", + "IMSyncContactSnapshot", + "IMSyncDirectoryEntryPayload", + "IMSyncIdentitySnapshot", "IconType", "InstalledApp", "InvitationCode", diff --git a/api/models/agent_config_entities.py b/api/models/agent_config_entities.py index a30b7a99f14..59c9b1daf04 100644 --- a/api/models/agent_config_entities.py +++ b/api/models/agent_config_entities.py @@ -873,7 +873,7 @@ class DeclaredOutputFileConfig(BaseModel): class DeclaredArrayItem(BaseModel): """Per-item shape for an ``array``-typed declared output. - PRD §OUTPUT 配置框 keeps arrays one level deep on first version; nested arrays + The PRD output configuration section limits arrays to one level; nested arrays are rejected so the runtime type checker and JSON Schema stay easy to reason about. Stage 4 §4.2. """ @@ -937,7 +937,7 @@ class DeclaredOutputChildConfig(BaseModel): class DeclaredOutputCheckConfig(BaseModel): """File-output content check via a model-based comparison against a benchmark file. - Per PRD §OUTPUT 配置框, output check is **file-only** and optional. Stage 4 §4.3. + Per the PRD output configuration section, output checks are **file-only** and optional. Stage 4 §4.3. """ model_config = ConfigDict(extra="forbid") @@ -1048,7 +1048,7 @@ class DeclaredOutputConfig(BaseModel): if self.children and self.type != DeclaredOutputType.OBJECT: raise ValueError("children is only allowed for object outputs") - # Per PRD §OUTPUT 配置框: output check is file-only. + # Per the PRD output configuration section, output checks apply only to files. if self.check is not None and self.check.enabled and self.type != DeclaredOutputType.FILE: raise ValueError("output check is only allowed for file outputs") diff --git a/api/models/engine.py b/api/models/engine.py index 05c1cacdcba..28efa7ca356 100644 --- a/api/models/engine.py +++ b/api/models/engine.py @@ -2,7 +2,7 @@ from flask_sqlalchemy import SQLAlchemy from sqlalchemy import MetaData POSTGRES_INDEXES_NAMING_CONVENTION = { - "ix": "%(column_0_label)s_idx", + "ix": "%(table_name)s_%(column_0_N_name)s_idx", "uq": "%(table_name)s_%(column_0_name)s_key", "ck": "%(table_name)s_%(constraint_name)s_check", "fk": "%(table_name)s_%(column_0_name)s_fkey", diff --git a/api/models/human_input.py b/api/models/human_input.py index 99e0e5f65c3..67d687b984a 100644 --- a/api/models/human_input.py +++ b/api/models/human_input.py @@ -3,10 +3,10 @@ from enum import StrEnum from typing import Annotated, Literal, Self, final import sqlalchemy as sa -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, model_validator from sqlalchemy.orm import Mapped, mapped_column, relationship -from core.workflow.human_input_adapter import DeliveryMethodType +from core.workflow.human_input_adapter import DeliveryMethodType, InstantMessageProvider, InstantMessageRecipientType from core.workflow.nodes.human_input.enums import HumanInputFormKind, HumanInputFormStatus from libs.helper import generate_string @@ -133,6 +133,7 @@ class ApprovalChannel(StrEnum): """Where a paused human input form can be approved, surfaced to API callers.""" EMAIL = "email" + IM = "im" WEB_APP = "web_app" CONSOLE = "console" @@ -141,6 +142,7 @@ class RecipientType(StrEnum): # Second value = the approval channel this recipient maps to (surfaced in `approval_channels`). EMAIL_MEMBER = "email_member", ApprovalChannel.EMAIL EMAIL_EXTERNAL = "email_external", ApprovalChannel.EMAIL + INSTANT_MESSAGE = "instant_message", ApprovalChannel.IM # STANDALONE_WEB_APP is used by the standalone web app. # # It's not used while running workflows / chatflows containing HumanInput @@ -180,6 +182,26 @@ class EmailExternalRecipientPayload(BaseModel): email: str +@final +class InstantMessageRecipientPayload(BaseModel): + TYPE: Literal[RecipientType.INSTANT_MESSAGE] = RecipientType.INSTANT_MESSAGE + provider: InstantMessageProvider + recipient_kind: InstantMessageRecipientType + channel_id: str | None = None + user_id: str | None = None + + @model_validator(mode="after") + def validate_target(self) -> Self: + match self.recipient_kind: + case InstantMessageRecipientType.CHANNEL: + if self.channel_id is None or self.user_id is not None: + raise ValueError("channel recipients must include channel_id and must not include user_id") + case InstantMessageRecipientType.USER: + if self.user_id is None or self.channel_id is not None: + raise ValueError("user recipients must include user_id and must not include channel_id") + return self + + @final class StandaloneWebAppRecipientPayload(BaseModel): TYPE: Literal[RecipientType.STANDALONE_WEB_APP] = RecipientType.STANDALONE_WEB_APP @@ -206,6 +228,7 @@ class ConsoleDeliveryPayload(BaseModel): RecipientPayload = Annotated[ EmailMemberRecipientPayload | EmailExternalRecipientPayload + | InstantMessageRecipientPayload | StandaloneWebAppRecipientPayload | ConsoleRecipientPayload | BackstageRecipientPayload, diff --git a/api/models/human_input_v2.py b/api/models/human_input_v2.py new file mode 100644 index 00000000000..557f0077979 --- /dev/null +++ b/api/models/human_input_v2.py @@ -0,0 +1,1403 @@ +"""Persistence models for retained Human Input v2 Contact, channel, IM sync, and inbox capabilities. + +The models use explicit logical owner references where database foreign keys are intentionally absent. +Repository queries must therefore preserve complete owner predicates and explicit loading strategies. +Current IM Identity and Binding rows use their Channel as the persistence boundary. +Historical synchronization rows retain their captured Channel references. +""" + +from __future__ import annotations + +from collections.abc import Sequence +from datetime import datetime +from enum import StrEnum +from typing import Annotated, Literal + +import sqlalchemy as sa +from pydantic import ( + BaseModel, + ConfigDict, + Field, + JsonValue, + NaiveDatetime, + RootModel, + TypeAdapter, +) +from sqlalchemy import orm +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from core.human_input_v2.entities import ( + EmailProviderType as _EmailProviderType, +) +from core.human_input_v2.entities import ( + IMProvider as _IMProvider, +) +from core.human_input_v2.entities import ( + IMSyncRemovalReason as _IMSyncRemovalReason, +) +from core.human_input_v2.entities import ( + IMSyncResultType as _IMSyncResultType, +) +from core.human_input_v2.entities import ( + IMSyncRunStatus as _IMSyncRunStatus, +) +from core.human_input_v2.im_integration.adapters.entities import ( + IMEventIngressKind as _IMEventIngressKind, +) +from core.human_input_v2.im_integration.change_log import ( + IMReconciliationOperation as _IMReconciliationOperation, +) +from core.human_input_v2.im_integration.change_log import ( + IMReconciliationSubjectKind as _IMReconciliationSubjectKind, +) +from core.human_input_v2.im_message_inbox import IM_INBOX_PROVIDER_METADATA_MAX_LENGTH +from core.human_input_v2.resolved_form import ResolvedForm +from core.workflow.nodes.human_input.enums import ( + HumanInputFormKind, + HumanInputFormStatus, +) +from core.workflow.nodes.human_input_v2.entities import RecipientConfig +from libs.datetime_utils import naive_utc_now +from libs.uuid_utils import uuidv7 +from repositories.human_input_v2.channel_credentials import ChannelEncryptedCredentials as _ChannelEncryptedCredentials +from repositories.human_input_v2.delivery_attempt_repository import DeliveryStatus +from repositories.human_input_v2.delivery_repository import ( + DeliveryTargetType, + EmailTargetSnapshot, + IMUserTargetSnapshot, + InitiatorSnapshot, + SubmissionAuthType, + TargetSnapshot, +) +from repositories.human_input_v2.im_channel_repository import IMChannelStatus + +from .base import DefaultFieldsDCMixin, TypeBase +from .types import EnumText, FrozenPydanticModelColumn, LongText, StringUUID + + +class _ImmutableJSONModel(BaseModel): + """Strict immutable base for structured JSON persistence values.""" + + model_config = ConfigDict(extra="forbid", frozen=True, strict=True, validate_default=True) + + +class _ImmutableJSONObject(RootModel[dict[str, JsonValue]]): + """Strict immutable base for intentionally opaque JSON object payloads.""" + + model_config = ConfigDict(frozen=True, strict=True, validate_default=True) + + +class IMIdentityRawPayload(RootModel[dict[str, JsonValue]]): + """Opaque provider identity payload retained only for diagnostics.""" + + model_config = ConfigDict(frozen=True, strict=True, validate_default=True) + + +class IMSyncDirectoryEntryPayload(_ImmutableJSONObject): + """Opaque provider directory entry captured by one synchronization run.""" + + +class IMSyncContactSnapshot(_ImmutableJSONModel): + """Immutable contact values needed to display historical sync results.""" + + contact_id: str = Field(description="Contact identifier captured by the sync result.") + name: str = Field(description="Contact display name captured by the sync result.") + email: str | None = Field(default=None, description="Contact email captured by the sync result.") + avatar_file_id: str | None = Field(default=None, description="Avatar file identifier captured by the result.") + created_at: NaiveDatetime | None = Field( + default=None, + description="Contact creation time captured by new results; absent from historical snapshots.", + ) + + +class IMSyncIdentitySnapshot(_ImmutableJSONModel): + """Immutable last-known IM identity values retained after removal.""" + + identity_id: str = Field(description="IM identity identifier captured by the sync result.") + provider: _IMProvider = Field( + strict=False, + description="Provider that owned the captured IM identity.", + ) + provider_user_id: str = Field(description="Provider user identifier captured by the sync result.") + display_name: str | None = Field(default=None, description="Provider display name captured by the result.") + email: str | None = Field(default=None, description="Provider email captured by the result.") + + +class IMIdentityReconciliationSnapshot(_ImmutableJSONModel): + """Minimal current IM identity state retained by the reconciliation change log.""" + + subject_kind: Literal[_IMReconciliationSubjectKind.IDENTITY] = _IMReconciliationSubjectKind.IDENTITY + identity_id: str + provider: _IMProvider = Field(strict=False) + provider_user_id: str + display_name: str | None = None + email: str | None = None + normalized_email: str | None = None + last_seen_sync_run_id: str | None = None + + +class IMBindingReconciliationSnapshot(_ImmutableJSONModel): + """Minimal current IM binding state retained by the reconciliation change log.""" + + subject_kind: Literal[_IMReconciliationSubjectKind.BINDING] = _IMReconciliationSubjectKind.BINDING + binding_id: str + identity_id: str + contact_id: str + + +type IMReconciliationChangeSnapshot = Annotated[ + IMIdentityReconciliationSnapshot | IMBindingReconciliationSnapshot, + Field(discriminator="subject_kind"), +] + + +_IM_RECONCILIATION_SNAPSHOT_ADAPTER: TypeAdapter[IMReconciliationChangeSnapshot] = TypeAdapter( + IMReconciliationChangeSnapshot +) + + +class ContactSubjectType(StrEnum): + """Immutable Contact subject discriminator, never a current Contact type.""" + + ACCOUNT = "account" + EXTERNAL = "external" + + +class HumanInputContactIdentity(DefaultFieldsDCMixin, TypeBase): + """Immutable Contact identity shared by every durable Contact reference. + + Account profile, membership, Platform visibility, and authorization state + deliberately remain with their source owners. External mutable facts live + in exactly one ``HumanInputExternalContactProfile``. + """ + + __tablename__ = "human_input_contact_identities" + __table_args__ = ( + sa.CheckConstraint( + "(subject_type = 'account' AND account_id IS NOT NULL) OR " + "(subject_type = 'external' AND account_id IS NULL)", + name="human_input_contact_identities_subject_type_ck", + ), + sa.UniqueConstraint( + "account_id", + name="human_input_contact_identities_account_id_uq", + ), + { + "comment": ( + "Immutable Human Input Contact identities. Mutable Account and External Contact profile facts " + "live with their source owners." + ) + }, + ) + + subject_type: Mapped[ContactSubjectType] = mapped_column( + EnumText(ContactSubjectType), + nullable=False, + comment="Immutable Account or External subject discriminator.", + ) + account_id: Mapped[str | None] = mapped_column( + StringUUID, + nullable=True, + default=None, + comment="Logical accounts.id reference for Account subjects only.", + ) + + external_profile: Mapped[HumanInputExternalContactProfile | None] = relationship( + lambda: HumanInputExternalContactProfile, + primaryjoin=lambda: sa.and_( + HumanInputContactIdentity.id == orm.foreign(HumanInputExternalContactProfile.contact_id), + HumanInputContactIdentity.subject_type == ContactSubjectType.EXTERNAL, + ), + back_populates="identity", + viewonly=True, + lazy="raise", + init=False, + ) + + +class HumanInputExternalContactProfile(TypeBase): + """Current workspace-owned profile for one External Contact identity.""" + + __tablename__ = "human_input_external_contact_profiles" + __table_args__ = ( + sa.UniqueConstraint( + "tenant_id", + "normalized_email", + name="hiecp_tenant_normalized_email_uq", + ), + sa.Index( + "hiecp_tenant_normalized_name_idx", + "tenant_id", + "normalized_name", + ), + { + "comment": ( + "Current workspace-owned External Contact profiles. Deletion removes both this profile and its " + "Contact identity." + ) + }, + ) + + contact_id: Mapped[str] = mapped_column( + StringUUID, + primary_key=True, + comment="Logical human_input_contact_identities.id reference for one External subject.", + ) + tenant_id: Mapped[str] = mapped_column( + StringUUID, + nullable=False, + comment="Owning tenants.id used by every current External Contact lookup.", + ) + name: Mapped[str] = mapped_column( + sa.String(255), + nullable=False, + comment="Workspace-managed display name.", + ) + normalized_name: Mapped[str] = mapped_column( + sa.String(255), + nullable=False, + comment="Canonical search value maintained by External Contact writes.", + ) + email: Mapped[str] = mapped_column( + sa.String(320), + nullable=False, + comment="Workspace-managed deliverable Email address.", + ) + normalized_email: Mapped[str] = mapped_column( + sa.String(320), + nullable=False, + comment="Canonical Email equality value maintained by External Contact writes.", + ) + avatar_file_id: Mapped[str | None] = mapped_column( + StringUUID, + nullable=True, + default=None, + comment="Logical upload_files.id reference owned by the same workspace.", + ) + created_at: Mapped[datetime] = mapped_column( + sa.DateTime, + nullable=False, + insert_default=naive_utc_now, + init=False, + server_default=sa.func.current_timestamp(), + ) + updated_at: Mapped[datetime] = mapped_column( + sa.DateTime, + nullable=False, + insert_default=naive_utc_now, + init=False, + server_default=sa.func.current_timestamp(), + onupdate=sa.func.current_timestamp(), + ) + + identity: Mapped[HumanInputContactIdentity] = relationship( + lambda: HumanInputContactIdentity, + primaryjoin=lambda: orm.foreign(HumanInputExternalContactProfile.contact_id) == HumanInputContactIdentity.id, + back_populates="external_profile", + viewonly=True, + lazy="raise", + init=False, + ) + + +class HumanInputPlatformContactWorkspaceEntry(DefaultFieldsDCMixin, TypeBase): + """EE-only workspace allow-list entry for an Organization Account contact. + + The entry does not own the Contact and does not duplicate workspace + membership or the externally resolved Contact type. Its existence means that + an EE Organization Account contact without current membership is explicitly + available in one workspace. The Enterprise Contact repository must ensure the + referenced Contact is an Account subject. Membership changes never mutate + the Contact identity. + """ + + __tablename__ = "human_input_platform_contact_workspace_entries" + __table_args__ = ( + sa.UniqueConstraint( + "tenant_id", + "contact_id", + name="hipcwe_tenant_contact_uq", + ), + sa.Index("hipcwe_tenant_created_at_id_idx", "tenant_id", "created_at", "id"), + sa.Index("hipcwe_contact_id_idx", "contact_id"), + { + "comment": ( + "EE-only workspace allow-list for Organization Account contacts. Workspace membership and External " + "contact ownership must not create rows in this table." + ) + }, + ) + + tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False, comment="Logical foreign key to tenants.id.") + contact_id: Mapped[str] = mapped_column( + StringUUID, + nullable=False, + comment="Logical foreign key to human_input_contact_identities.id.", + ) + added_by_account_id: Mapped[str] = mapped_column( + StringUUID, + nullable=False, + comment="Logical foreign key to accounts.id for the administrator who added this directory entry.", + ) + + +class IMMessageEvent(TypeBase): + """Authenticated callback facts and successful processing time.""" + + __tablename__ = "im_message_event" + __table_args__ = ( + sa.UniqueConstraint( + "provider", + "event_id", + name="im_message_event_provider_event_uq", + ), + {"comment": "Durable authenticated IM callback records."}, + ) + + id: Mapped[str] = mapped_column( + StringUUID, + primary_key=True, + insert_default=lambda: str(uuidv7()), + default_factory=lambda: str(uuidv7()), + init=False, + ) + created_at: Mapped[datetime] = mapped_column( + sa.DateTime, + nullable=False, + insert_default=naive_utc_now, + default_factory=naive_utc_now, + init=False, + server_default=sa.func.current_timestamp(), + ) + + channel_id: Mapped[str] = mapped_column( + StringUUID, + nullable=False, + kw_only=True, + comment="Logical local Channel routing identifier without physical ownership.", + ) + provider: Mapped[_IMProvider] = mapped_column( + EnumText(_IMProvider), + nullable=False, + kw_only=True, + comment="Authenticated Provider discriminator.", + ) + tenant_id: Mapped[str] = mapped_column( + sa.String(IM_INBOX_PROVIDER_METADATA_MAX_LENGTH), + nullable=False, + kw_only=True, + comment="Stable authenticated Provider tenant identifier.", + ) + event_id: Mapped[str | None] = mapped_column( + sa.String(IM_INBOX_PROVIDER_METADATA_MAX_LENGTH), + nullable=True, + default=None, + kw_only=True, + comment="Real Provider event ID when supplied.", + ) + provider_event_time: Mapped[datetime | None] = mapped_column( + sa.DateTime, + nullable=True, + default=None, + kw_only=True, + comment="Provider event timestamp when supplied.", + ) + received_at: Mapped[datetime] = mapped_column( + sa.DateTime, + nullable=False, + kw_only=True, + comment="Dify receive timestamp for this delivery.", + ) + provider_event_type: Mapped[str | None] = mapped_column( + sa.String(IM_INBOX_PROVIDER_METADATA_MAX_LENGTH), + nullable=True, + default=None, + kw_only=True, + comment="Provider-owned event discriminator.", + ) + ingress_kind: Mapped[_IMEventIngressKind] = mapped_column( + EnumText(_IMEventIngressKind), + nullable=False, + kw_only=True, + comment="Ingress contract used to construct the Provider payload snapshot.", + ) + payload: Mapped[str] = mapped_column( + LongText, + nullable=False, + kw_only=True, + comment="Authenticated Provider-native payload.", + ) + request_ip_address: Mapped[str | None] = mapped_column( + sa.Text, + nullable=True, + default=None, + kw_only=True, + comment="HTTP request IP when available; absent for stream and historical events.", + ) + processed_at: Mapped[datetime | None] = mapped_column( + sa.DateTime, + nullable=True, + default=None, + kw_only=True, + comment="Timestamp when callback processing completed successfully.", + ) + + +class HumanInputEmailProvider(DefaultFieldsDCMixin, TypeBase): + """Single workspace-level email provider used for Human Input delivery.""" + + # TODO(QuantumGhost): Rename this model and table to HumanInputEmailChannel. + + __tablename__ = "human_input_email_providers" + __table_args__ = ( + sa.UniqueConstraint("tenant_id", name="human_input_email_providers_tenant_uq"), + {"comment": "Workspace-level Human Input email delivery configuration."}, + ) + + provider: Mapped[_EmailProviderType] = mapped_column( + EnumText(_EmailProviderType), + nullable=False, + comment="Configured email provider discriminator.", + ) + sender_email: Mapped[str] = mapped_column( + sa.String(320), nullable=False, comment="Configured sender email address." + ) + encrypted_credentials: Mapped[_ChannelEncryptedCredentials] = mapped_column( + FrozenPydanticModelColumn(_ChannelEncryptedCredentials), + nullable=False, + comment="Encrypted complete Channel credentials stored as JSON.", + ) + tenant_id: Mapped[str] = mapped_column( + StringUUID, + nullable=False, + comment="Logical foreign key to tenants.id.", + ) + config_version: Mapped[int] = mapped_column( + sa.Integer, + nullable=False, + default=1, + comment="Monotonic Email configuration revision used for compare-and-swap.", + ) + sender_name: Mapped[str] = mapped_column( + sa.String(255), + nullable=False, + default="", + comment="Optional sender display name.", + ) + configured_by_account_id: Mapped[str | None] = mapped_column( + StringUUID, + nullable=True, + default=None, + comment="Logical foreign key to accounts.id for the latest configuration write.", + ) + + +class HumanInputIMChannel(DefaultFieldsDCMixin, TypeBase): + """One current IM Channel in a repository-generated owner slot. + + ``owner_key`` deliberately replaces a polymorphic database foreign key. + Its unique constraint serializes concurrent first creation for both + workspace and deployment owners without Redis or a separate lock row. + """ + + __tablename__ = "human_input_im_channels" + __table_args__ = ( + sa.UniqueConstraint( + "owner_key", + name="human_input_im_channels_owner_key_uq", + ), + sa.UniqueConstraint( + "webhook_id", + name="human_input_im_channels_webhook_id_uq", + ), + sa.CheckConstraint( + "config_version > 0", + name=sa.schema.conv("human_input_im_channels_config_version_positive_ck"), + ), + { + "comment": ( + "Current owner-scoped Human Input IM Channel configuration. " + "Directory, Binding, Sync, and inbox records remain separately owned." + ) + }, + ) + + owner_key: Mapped[str] = mapped_column( + sa.String(50), + nullable=False, + comment="Canonical owner slot: workspace: or deployment.", + ) + provider: Mapped[_IMProvider] = mapped_column( + EnumText(_IMProvider), + nullable=False, + comment="Configured IM provider discriminator.", + ) + provider_tenant_id: Mapped[str] = mapped_column( + sa.String(255), + nullable=False, + comment="Confirmed provider-side organization, tenant, or workspace identifier.", + ) + encrypted_credentials: Mapped[_ChannelEncryptedCredentials] = mapped_column( + FrozenPydanticModelColumn(_ChannelEncryptedCredentials), + nullable=False, + comment="Versioned opaque encrypted IM Channel credential envelope.", + ) + app_identifier: Mapped[str] = mapped_column( + sa.String(255), + nullable=False, + comment="Safe provider application identifier used by credential-free projections.", + ) + webhook_id: Mapped[str] = mapped_column( + sa.String(32), + nullable=False, + comment="Server-generated globally unique route ID used to derive webhook URLs.", + ) + status: Mapped[IMChannelStatus] = mapped_column( + EnumText(IMChannelStatus), + nullable=False, + default=IMChannelStatus.CONNECTED, + comment="Stored credential-safe Channel status snapshot.", + ) + config_version: Mapped[int] = mapped_column( + sa.Integer, + nullable=False, + default=1, + comment="Monotonic numeric version paired with the Channel ID for CAS.", + ) + configured_by_account_id: Mapped[str | None] = mapped_column( + StringUUID, + nullable=True, + default=None, + comment="Latest configuring Dify Account; null for deployment-owned writes.", + ) + status_reason: Mapped[str | None] = mapped_column( + LongText, + nullable=True, + default=None, + comment="Operator-safe status explanation without provider payload or credentials.", + ) + + +class HumanInputIMIdentity(DefaultFieldsDCMixin, TypeBase): + """Current Provider user synchronized through one IM Channel. + + Searchable and match-critical fields are stored in columns. ``raw_payload`` + retains the Provider response for diagnostics without making it query state. + Channel ownership and Provider configuration remain on the parent Channel. + """ + + __tablename__ = "human_input_im_identities" + __table_args__ = ( + sa.UniqueConstraint( + "channel_id", + "provider_user_id", + name="human_input_im_identities_channel_provider_user_uq", + ), + sa.CheckConstraint( + "length(trim(provider_user_id)) > 0", + name="human_input_im_identities_provider_user_nonblank", + ), + sa.Index("hiimi_channel_id_idx", "channel_id", "id"), + sa.Index("hiimi_channel_email_idx", "channel_id", "normalized_email"), + sa.Index("hiimi_channel_name_idx", "channel_id", "normalized_name"), + { + "comment": ( + "Current Provider users synchronized through one IM Channel. " + "Channel ownership remains outside this table." + ) + }, + ) + + channel_id: Mapped[str] = mapped_column( + StringUUID, + nullable=False, + comment="Logical human_input_im_channels.id reference.", + ) + provider_user_id: Mapped[str] = mapped_column( + sa.String(255), + nullable=False, + comment="Provider-native user identifier within the owning Channel.", + ) + raw_payload: Mapped[IMIdentityRawPayload] = mapped_column( + FrozenPydanticModelColumn(IMIdentityRawPayload), + nullable=False, + comment="Latest opaque Provider payload retained for diagnostics.", + ) + last_seen_sync_run_id: Mapped[str] = mapped_column( + StringUUID, + nullable=False, + comment="Logical human_input_im_sync_runs.id reference for the latest observation.", + ) + last_seen_at: Mapped[datetime] = mapped_column( + sa.DateTime, + nullable=False, + comment="Timestamp of the latest successful Provider observation.", + ) + display_name: Mapped[str | None] = mapped_column( + sa.String(255), + nullable=True, + default=None, + comment="Latest canonical non-blank Provider display name.", + ) + normalized_name: Mapped[str | None] = mapped_column( + sa.String(255), + nullable=True, + default=None, + comment="Canonical display name used by persistence queries.", + ) + email: Mapped[str | None] = mapped_column( + sa.String(320), + nullable=True, + default=None, + comment="Latest canonical non-blank Provider email.", + ) + normalized_email: Mapped[str | None] = mapped_column( + sa.String(320), + nullable=True, + default=None, + comment="Canonical email used by matching and persistence queries.", + ) + avatar_file_id: Mapped[str | None] = mapped_column( + StringUUID, + nullable=True, + default=None, + comment="Logical upload_files.id reference for the current IM avatar.", + ) + + +class HumanInputIMBinding(DefaultFieldsDCMixin, TypeBase): + """Default Contact-to-IM-identity Binding for one IM Channel.""" + + __tablename__ = "human_input_im_bindings" + __table_args__ = ( + sa.UniqueConstraint( + "channel_id", + "contact_id", + name="human_input_im_bindings_channel_contact_uq", + ), + sa.UniqueConstraint( + "channel_id", + "im_identity_id", + name="human_input_im_bindings_channel_identity_uq", + ), + {"comment": "Default Contact-to-IM-identity Bindings for one IM Channel."}, + ) + + channel_id: Mapped[str] = mapped_column( + StringUUID, + nullable=False, + comment="Logical human_input_im_channels.id reference.", + ) + contact_id: Mapped[str] = mapped_column( + StringUUID, + nullable=False, + comment="Logical human_input_contact_identities.id reference.", + ) + im_identity_id: Mapped[str] = mapped_column( + StringUUID, + nullable=False, + comment="Logical human_input_im_identities.id reference.", + ) + bound_by_account_id: Mapped[str | None] = mapped_column( + StringUUID, + nullable=True, + default=None, + comment="Latest Dify Account that manually selected this Binding, when available.", + ) + + +class HumanInputIMBindingWorkspaceOverride(DefaultFieldsDCMixin, TypeBase): + """Workspace-specific override for one default IM Binding.""" + + __tablename__ = "human_input_im_workspace_binding_overrides" + __table_args__ = ( + sa.UniqueConstraint( + "channel_id", + "tenant_id", + "contact_id", + name="hiimwbo_channel_tenant_contact_uq", + ), + sa.UniqueConstraint( + "channel_id", + "tenant_id", + "im_identity_id", + name="hiimwbo_channel_tenant_identity_uq", + ), + sa.Index("hiimwbo_channel_identity_idx", "channel_id", "im_identity_id"), + {"comment": "Workspace-specific Binding overrides for one IM Channel."}, + ) + + channel_id: Mapped[str] = mapped_column( + StringUUID, + nullable=False, + comment="Logical human_input_im_channels.id reference.", + ) + tenant_id: Mapped[str] = mapped_column( + StringUUID, + nullable=False, + comment="Target tenants.id whose effective Binding is overridden.", + ) + contact_id: Mapped[str] = mapped_column( + StringUUID, + nullable=False, + comment="Logical human_input_contact_identities.id reference.", + ) + im_identity_id: Mapped[str] = mapped_column( + StringUUID, + nullable=False, + comment="Logical human_input_im_identities.id reference.", + ) + bound_by_account_id: Mapped[str | None] = mapped_column( + StringUUID, + nullable=True, + default=None, + comment="Dify Account that selected this workspace override.", + ) + + +class HumanInputIMSyncRun(DefaultFieldsDCMixin, TypeBase): + """One manually triggered organization IM directory synchronization. + + Preventing parallel queued or running rows is a service transaction invariant: + lock the owning channel row before creating a run. A portable partial + unique index is deliberately not used. A worker may apply current identities + and bindings only while ``channel_config_version`` still matches the + owning channel. + """ + + __tablename__ = "human_input_im_sync_runs" + __table_args__ = ( + sa.CheckConstraint("channel_config_version > 0", name="captured_version_positive"), + sa.CheckConstraint( + "added_count >= 0 AND not_matched_count >= 0 AND failed_count >= 0 AND removed_count >= 0 " + "AND skipped_count >= 0", + name="result_counts_nonnegative", + ), + sa.Index("hiimsr_channel_created_idx", "channel_id", "created_at", "id"), + sa.Index( + "hiimsr_channel_status_created_idx", + "channel_id", + "status", + "created_at", + ), + {"comment": "Manual IM directory synchronization lifecycle and aggregate counts."}, + ) + channel_id: Mapped[str] = mapped_column( + StringUUID, + nullable=False, + comment="Logical foreign key to human_input_im_channels.id.", + ) + channel_config_version: Mapped[int] = mapped_column( + sa.Integer, + nullable=False, + comment="Channel configuration revision captured when this synchronization was created.", + ) + provider: Mapped[_IMProvider] = mapped_column( + EnumText(_IMProvider), + nullable=False, + comment="Provider snapshot used by this synchronization.", + ) + status: Mapped[_IMSyncRunStatus] = mapped_column( + EnumText(_IMSyncRunStatus), + nullable=False, + comment="Current synchronization lifecycle state.", + ) + added_count: Mapped[int] = mapped_column( + sa.Integer, + nullable=False, + default=0, + comment="Number of entries newly matched and bound.", + ) + not_matched_count: Mapped[int] = mapped_column( + sa.Integer, + nullable=False, + default=0, + comment="Number of entries requiring later manual handling.", + ) + failed_count: Mapped[int] = mapped_column( + sa.Integer, + nullable=False, + default=0, + comment="Number of entries that failed reconciliation.", + ) + removed_count: Mapped[int] = mapped_column( + sa.Integer, + nullable=False, + default=0, + comment="Number of removed binding facts, including one unbound-identity fact when applicable.", + ) + skipped_count: Mapped[int] = mapped_column( + sa.Integer, + nullable=False, + default=0, + comment="Number of entries intentionally skipped.", + ) + started_by_account_id: Mapped[str | None] = mapped_column( + StringUUID, + nullable=True, + default=None, + comment="Logical foreign key to accounts.id for the administrator who started this run.", + ) + started_at: Mapped[datetime | None] = mapped_column( + sa.DateTime, + nullable=True, + default=None, + comment="Timestamp when a worker started processing the run.", + ) + finished_at: Mapped[datetime | None] = mapped_column( + sa.DateTime, + nullable=True, + default=None, + comment="Terminal completion timestamp.", + ) + error_code: Mapped[str | None] = mapped_column( + sa.String(100), + nullable=True, + default=None, + comment="Machine-readable terminal failure code.", + ) + error_message: Mapped[str | None] = mapped_column( + LongText, + nullable=True, + default=None, + comment="Operator-safe terminal failure summary.", + ) + + results: Mapped[list[HumanInputIMSyncResult]] = relationship( + lambda: HumanInputIMSyncResult, + primaryjoin=lambda: HumanInputIMSyncRun.id == orm.foreign(HumanInputIMSyncResult.sync_run_id), + back_populates="sync_run", + viewonly=True, + lazy="raise", + init=False, + ) + + +class HumanInputIMSyncResult(DefaultFieldsDCMixin, TypeBase): + """Immutable reconciliation result for one entry, removed binding, or diagnostic. + + Provider identifiers and normalized email remain queryable columns. JSON is + limited to the raw provider input and immutable display snapshots. + """ + + __tablename__ = "human_input_im_sync_results" + __table_args__ = ( + sa.UniqueConstraint( + "sync_run_id", + "operation_key", + name="human_input_im_sync_results_run_operation_uq", + ), + sa.Index( + "hiimsres_run_type_created_idx", + "sync_run_id", + "result_type", + "created_at", + "id", + ), + sa.Index( + "hiimsres_channel_contact_created_idx", + "channel_id", + "contact_id", + "created_at", + ), + sa.Index( + "hiimsres_channel_identity_created_idx", + "channel_id", + "im_identity_id", + "created_at", + ), + {"comment": "Append-only per-entry, removed-binding, and diagnostic IM synchronization outcomes."}, + ) + + channel_id: Mapped[str] = mapped_column( + StringUUID, + nullable=False, + comment=( + "Denormalized logical foreign key to human_input_im_channels.id; " + "must match the channel referenced by sync_run_id." + ), + ) + sync_run_id: Mapped[str] = mapped_column( + StringUUID, + nullable=False, + comment="Logical foreign key to human_input_im_sync_runs.id.", + ) + result_type: Mapped[_IMSyncResultType] = mapped_column( + EnumText(_IMSyncResultType), + nullable=False, + comment="Stable result bucket used by pagination.", + ) + operation_key: Mapped[str | None] = mapped_column( + sa.String(255), + nullable=True, + default=None, + comment="Deterministic run-local idempotency key; null only for historical results.", + ) + provider_user_id: Mapped[str | None] = mapped_column( + sa.String(255), + nullable=True, + default=None, + comment="External provider user identifier observed for this result; not a logical foreign key.", + ) + display_name: Mapped[str | None] = mapped_column( + sa.String(255), + nullable=True, + default=None, + comment="Provider display name observed for this result.", + ) + email: Mapped[str | None] = mapped_column( + sa.String(320), + nullable=True, + default=None, + comment="Provider email observed for this result.", + ) + normalized_email: Mapped[str | None] = mapped_column( + sa.String(320), + nullable=True, + default=None, + comment="Lower-cased provider email used during matching.", + ) + contact_id: Mapped[str | None] = mapped_column( + StringUUID, + nullable=True, + default=None, + comment="Logical foreign key to human_input_contact_identities.id, when a contact was matched.", + ) + im_identity_id: Mapped[str | None] = mapped_column( + StringUUID, + nullable=True, + default=None, + comment=( + "Historical logical foreign key to human_input_im_identities.id; the target row may be deleted after sync." + ), + ) + im_binding_id: Mapped[str | None] = mapped_column( + StringUUID, + nullable=True, + default=None, + comment=( + "Historical logical foreign key to human_input_im_bindings.id; the target row may be deleted or replaced." + ), + ) + removal_reason: Mapped[_IMSyncRemovalReason | None] = mapped_column( + EnumText(_IMSyncRemovalReason), + nullable=True, + default=None, + comment="Stable removal reason for removed results only.", + ) + reason_code: Mapped[str | None] = mapped_column( + sa.String(100), + nullable=True, + default=None, + comment="Machine-readable failure, skip, or mismatch reason.", + ) + reason_message: Mapped[str | None] = mapped_column( + LongText, + nullable=True, + default=None, + comment="Operator-safe detail for diagnostics.", + ) + directory_entry_payload: Mapped[IMSyncDirectoryEntryPayload | None] = mapped_column( + FrozenPydanticModelColumn(IMSyncDirectoryEntryPayload), + nullable=True, + default=None, + comment="Immutable provider entry payload Pydantic model observed during this run.", + ) + contact_snapshot: Mapped[IMSyncContactSnapshot | None] = mapped_column( + FrozenPydanticModelColumn(IMSyncContactSnapshot), + nullable=True, + default=None, + comment="Immutable contact snapshot Pydantic model used for historical display.", + ) + identity_snapshot: Mapped[IMSyncIdentitySnapshot | None] = mapped_column( + FrozenPydanticModelColumn(IMSyncIdentitySnapshot), + nullable=True, + default=None, + comment="Immutable last-known IM identity Pydantic model for removed results.", + ) + + sync_run: Mapped[HumanInputIMSyncRun] = relationship( + lambda: HumanInputIMSyncRun, + primaryjoin=lambda: orm.foreign(HumanInputIMSyncResult.sync_run_id) == HumanInputIMSyncRun.id, + back_populates="results", + viewonly=True, + lazy="raise", + init=False, + ) + identity: Mapped[HumanInputIMIdentity | None] = relationship( + lambda: HumanInputIMIdentity, + primaryjoin=lambda: orm.foreign(HumanInputIMSyncResult.im_identity_id) == HumanInputIMIdentity.id, + viewonly=True, + lazy="raise", + init=False, + ) + binding: Mapped[HumanInputIMBinding | None] = relationship( + lambda: HumanInputIMBinding, + primaryjoin=lambda: orm.foreign(HumanInputIMSyncResult.im_binding_id) == HumanInputIMBinding.id, + viewonly=True, + lazy="raise", + init=False, + ) + + +class HumanInputIMReconciliationChange(DefaultFieldsDCMixin, TypeBase): + """Append-only before/after fact for one committed identity or IM binding mutation.""" + + __tablename__ = "human_input_im_reconciliation_changes" + __table_args__ = ( + sa.UniqueConstraint( + "sync_run_id", + "operation_key", + name="human_input_im_reconciliation_changes_run_operation_uq", + ), + sa.CheckConstraint( + "before_snapshot IS NOT NULL OR after_snapshot IS NOT NULL", + name="snapshot_present", + ), + sa.CheckConstraint( + "(operation = 'create' AND before_snapshot IS NULL AND after_snapshot IS NOT NULL) OR " + "(operation = 'delete' AND before_snapshot IS NOT NULL AND after_snapshot IS NULL) OR " + "(operation NOT IN ('create', 'delete') AND before_snapshot IS NOT NULL AND after_snapshot IS NOT NULL)", + name="snapshot_operation_shape", + ), + sa.CheckConstraint( + "(subject_kind = 'identity' AND im_binding_id IS NULL) OR " + "(subject_kind = 'binding' AND im_binding_id IS NOT NULL)", + name="subject_identifier_shape", + ), + sa.Index( + "hiimrc_run_subject_committed_idx", + "sync_run_id", + "subject_kind", + "committed_at", + "id", + ), + sa.Index("hiimrc_channel_committed_idx", "channel_id", "committed_at", "id"), + {"comment": "Append-only IM identity and IM binding reconciliation mutation history."}, + ) + + channel_id: Mapped[str] = mapped_column( + StringUUID, + nullable=False, + comment="Logical foreign key to human_input_im_channels.id.", + ) + sync_run_id: Mapped[str] = mapped_column( + StringUUID, + nullable=False, + comment="Logical foreign key to human_input_im_sync_runs.id.", + ) + operation_key: Mapped[str] = mapped_column( + sa.String(255), + nullable=False, + comment="Deterministic run-local idempotency key.", + ) + subject_kind: Mapped[_IMReconciliationSubjectKind] = mapped_column( + EnumText(_IMReconciliationSubjectKind), nullable=False + ) + operation: Mapped[_IMReconciliationOperation] = mapped_column(EnumText(_IMReconciliationOperation), nullable=False) + reason_code: Mapped[str] = mapped_column(sa.String(100), nullable=False) + im_identity_id: Mapped[str] = mapped_column(StringUUID, nullable=False) + committed_at: Mapped[datetime] = mapped_column(sa.DateTime, nullable=False) + im_binding_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True, default=None) + contact_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True, default=None) + before_snapshot: Mapped[IMReconciliationChangeSnapshot | None] = mapped_column( + FrozenPydanticModelColumn( + _IM_RECONCILIATION_SNAPSHOT_ADAPTER, + model_types=( + IMIdentityReconciliationSnapshot, + IMBindingReconciliationSnapshot, + ), + ), + nullable=True, + default=None, + ) + after_snapshot: Mapped[IMReconciliationChangeSnapshot | None] = mapped_column( + FrozenPydanticModelColumn( + _IM_RECONCILIATION_SNAPSHOT_ADAPTER, + model_types=( + IMIdentityReconciliationSnapshot, + IMBindingReconciliationSnapshot, + ), + ), + nullable=True, + default=None, + ) + + +class HumanInputForm(DefaultFieldsDCMixin, TypeBase): + __tablename__ = "hitlv2_forms" + __table_args__ = ( + sa.UniqueConstraint( + "workflow_run_id", + "node_execution_id", + name="hitlv2_forms_execution_uq", + ), + sa.Index("hitlv2_forms_run_id_idx", "workflow_run_id", "id"), + # Equality predicates precede the ID cursor used by expiration scans. + sa.Index("hitlv2_forms_expiration_scan_idx", "tenant_id", "app_id", "form_kind", "status", "id"), + sa.Index("hitlv2_forms_node_deadline_idx", "form_kind", "status", "expiration_time", "id"), + sa.Index("hitlv2_forms_global_deadline_idx", "form_kind", "status", "global_timeout_deadline", "id"), + ) + + tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False) + app_id: Mapped[str] = mapped_column(StringUUID, nullable=False) + workflow_run_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True) + # correspond to WorkflowNodeExecutionModel.id + node_execution_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True) + form_kind: Mapped[HumanInputFormKind] = mapped_column( + EnumText(HumanInputFormKind), + nullable=False, + default=HumanInputFormKind.RUNTIME, + kw_only=True, + ) + status: Mapped[HumanInputFormStatus] = mapped_column( + EnumText(HumanInputFormStatus), + nullable=False, + default=HumanInputFormStatus.WAITING, + server_default=HumanInputFormStatus.WAITING.value, + kw_only=True, + comment="Persisted form outcome; terminal states are not recomputed from deadlines.", + ) + # Form level expiration time. + expiration_time: Mapped[datetime] = mapped_column( + sa.DateTime, + nullable=False, + ) + # global timeout deadline for this form / workflow execution. + global_timeout_deadline: Mapped[datetime] = mapped_column( + sa.DateTime, + nullable=False, + ) + # The form definition after resolving all runtime related variables. + resolved_form: Mapped[ResolvedForm] = mapped_column(FrozenPydanticModelColumn(ResolvedForm), nullable=False) + + # The following fields are not `None` once the form has been submitted. + submitted_at: Mapped[datetime | None] = mapped_column(sa.DateTime, nullable=True) + submitted_by_recipient_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True) + submitted_by_delivery_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True, default=None, kw_only=True) + # submission_data + selected_action_id: Mapped[str | None] = mapped_column(sa.String(200), nullable=True) + # original submission inputs data without any transformation or sanitization. + raw_submission_inputs: Mapped[str | None] = mapped_column(sa.Text, nullable=True) + # validated and sanitized submission data, a mapping from submitted form data to + # variable segments. + # + # For special reference types such as File / FileSegment, the File ownership has been + # validated. + normalized_submission_data: Mapped[str | None] = mapped_column(sa.Text, nullable=True) + + +class RecipientSubjectType(StrEnum): + CONTACT = "contact" + EMAIL = "email" + END_USER = "end_user" + + +class RecipientSources(RootModel[Sequence[RecipientConfig]]): + model_config = ConfigDict(frozen=True, strict=True, validate_default=True) + + +class RecipientSnapshot(BaseModel): + model_config = ConfigDict(frozen=True, strict=True, validate_default=True) + # The name of the recipeint when the recipient is created. + # + # For recipeint without a name (Dyanmic Email / Onetime Email / EndUser), this + # Field is set to `None`. + name: str | None = None + + +class HumanInputRecipient(DefaultFieldsDCMixin, TypeBase): + __tablename__ = "hitlv2_recipients" + __table_args__ = ( + sa.UniqueConstraint( + "form_id", + "subject_type", + "subject_value", + name="hitlv2_recipients_subject_uq", + ), + ) + + tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False) + form_id: Mapped[str] = mapped_column(StringUUID, nullable=False) + + subject_type: Mapped[RecipientSubjectType] = mapped_column( + EnumText(RecipientSubjectType), + nullable=False, + ) + # The interpretation of identity_value depends on `subject_value`: + # + # - For RecipientSubjectType.CONTACT, this field stores the value of HumanInputContactIdentity.id + # - For RecipientSubjectType.END_USER, this field stores the value of EndUser.id + # - For RecipientSubjectType.EMAIL, this field stores the normalized email of the recipient. + subject_value: Mapped[str] = mapped_column(sa.String(255), nullable=False) + sources: Mapped[RecipientSources] = mapped_column( + FrozenPydanticModelColumn(RecipientSources), + nullable=False, + ) + snapshot: Mapped[RecipientSnapshot] = mapped_column( + FrozenPydanticModelColumn(RecipientSnapshot), + nullable=False, + ) + + +class DeliveryType(StrEnum): + IM_CARD = "im_card" + COSNOLE_WEB = "console_web" + STANDALONE_WEB = "standalone_web" + + +_TARGET_SNAPSHOT_ADAPTER: TypeAdapter[TargetSnapshot] = TypeAdapter(TargetSnapshot) + + +class HumanInputDelivery(DefaultFieldsDCMixin, TypeBase): + __tablename__ = "hitlv2_deliveries" + __table_args__ = ( + sa.Index("hitlv2_deliveries_token_idx", "token_hash"), + sa.Index("hitlv2_deliveries_form_id_idx", "form_id", "id"), + sa.Index("hitlv2_deliveries_form_target_idx", "form_id", "target_type"), + ) + + tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False) + form_id: Mapped[str] = mapped_column(StringUUID, nullable=False) + + token_hash: Mapped[str] = mapped_column(sa.String(64), nullable=False) + + recipient_id: Mapped[str] = mapped_column(StringUUID, nullable=False) + auth_type: Mapped[SubmissionAuthType] = mapped_column( + EnumText(SubmissionAuthType), + nullable=False, + ) + target_type: Mapped[DeliveryTargetType] = mapped_column(EnumText(DeliveryTargetType), nullable=False) + target_snapshot: Mapped[TargetSnapshot] = mapped_column( + FrozenPydanticModelColumn( + _TARGET_SNAPSHOT_ADAPTER, + model_types=(IMUserTargetSnapshot, EmailTargetSnapshot, InitiatorSnapshot), + ), + nullable=False, + ) + + +class DeliveryResponse(RootModel[dict[str, JsonValue]]): + model_config = ConfigDict(frozen=True, strict=True, validate_default=True) + + +class HumanInputDeliveryAttempt(DefaultFieldsDCMixin, TypeBase): + __tablename__ = "hitlv2_delivery_attempts" + + tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False) + form_id: Mapped[str] = mapped_column(StringUUID, nullable=False) + + delivery_id: Mapped[str] = mapped_column(StringUUID, nullable=False) + status: Mapped[DeliveryStatus] = mapped_column(EnumText(DeliveryStatus), nullable=False) + error_message: Mapped[str | None] = mapped_column(sa.String(200), nullable=True) + response: Mapped[DeliveryResponse] = mapped_column(FrozenPydanticModelColumn(DeliveryResponse), nullable=False) + + +class AuditEventType(StrEnum): + # The form is opened and viewed. + FORM_VIEW = "form_view" + # An Email OTP send operation + OTP_SEND = "otp_send" + # An Email OTP verification + OTP_VERIFICATION = "otp_verification" + # This seems to break the IM abstration??? + IM_CALLBACK_VERIFICATION = "im_callback_verification" + # + FORM_SUBMISSION = "form_submission" + + +class FormViewEvent(_ImmutableJSONModel): + type: Literal[AuditEventType.FORM_VIEW] = AuditEventType.FORM_VIEW + # None means the request IP was unavailable. + viewer_ip_address: str | None + + +class OtpSendEvent(_ImmutableJSONModel): + type: Literal[AuditEventType.OTP_SEND] = AuditEventType.OTP_SEND + + recipient_id: str + recipient_email: str + sender_ip_address: str | None + + +class OtpVerificationEvent(_ImmutableJSONModel): + type: Literal[AuditEventType.OTP_VERIFICATION] = AuditEventType.OTP_VERIFICATION + + recipient_id: str + recipient_email: str + succeeded: bool + submitter_ip_address: str | None + reason: str # wrong verification code or wrong ticket. + + +class IMCallbackVerificationEvent(_ImmutableJSONModel): + # We might need to break the abstration. + type: Literal[AuditEventType.IM_CALLBACK_VERIFICATION] = AuditEventType.IM_CALLBACK_VERIFICATION + + succeeded: bool + request_ip_address: str | None + reason: str # fail reasons + + +class ServiceAPIFormSubmissionEvent(_ImmutableJSONModel): + """Distinguish the approving recipient grant from the Service API caller.""" + + # TODO(QuantumGhost): we need to check what other information needed to be stored + # in the audit event later. + + type: Literal[AuditEventType.FORM_SUBMISSION] = AuditEventType.FORM_SUBMISSION + recipient_id: str + end_user_id: str + + +type AuditEvent = Annotated[ + FormViewEvent | OtpSendEvent | OtpVerificationEvent | IMCallbackVerificationEvent | ServiceAPIFormSubmissionEvent, + Field(discriminator="type"), +] + +AUDIT_EVENT_ADAPTER: TypeAdapter[AuditEvent] = TypeAdapter(AuditEvent) + + +class HumanInputAuditEvent(DefaultFieldsDCMixin, TypeBase): + __tablename__ = "hitlv2_audit_events" + + tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False) + form_id: Mapped[str] = mapped_column(StringUUID, nullable=False) + + payload: Mapped[AuditEvent] = mapped_column( + FrozenPydanticModelColumn( + AUDIT_EVENT_ADAPTER, + model_types=( + FormViewEvent, + OtpSendEvent, + OtpVerificationEvent, + IMCallbackVerificationEvent, + ServiceAPIFormSubmissionEvent, + ), + ), + nullable=False, + ) + + +__all__ = [ + "ContactSubjectType", + "HumanInputContactIdentity", + "HumanInputEmailProvider", + "HumanInputExternalContactProfile", + "HumanInputIMBinding", + "HumanInputIMBindingWorkspaceOverride", + "HumanInputIMChannel", + "HumanInputIMIdentity", + "HumanInputIMReconciliationChange", + "HumanInputIMSyncResult", + "HumanInputIMSyncRun", + "HumanInputPlatformContactWorkspaceEntry", + "IMBindingReconciliationSnapshot", + "IMIdentityRawPayload", + "IMIdentityReconciliationSnapshot", + "IMMessageEvent", + "IMReconciliationChangeSnapshot", + "IMSyncContactSnapshot", + "IMSyncDirectoryEntryPayload", + "IMSyncIdentitySnapshot", +] diff --git a/api/models/model.py b/api/models/model.py index a8888cffbb0..88b1909b03e 100644 --- a/api/models/model.py +++ b/api/models/model.py @@ -2,7 +2,6 @@ from __future__ import annotations import contextlib import json -import re import uuid from collections.abc import Callable, Mapping, Sequence from dataclasses import field @@ -21,11 +20,10 @@ from sqlalchemy.orm import Mapped, Session, mapped_column, relationship, scoped_ from configs import dify_config from constants import DEFAULT_FILE_NUMBER_LIMITS -from core.tools.signature import sign_tool_file from extensions.storage.storage_type import StorageType +from factories.file_url_factory import re_sign_file_urls from graphon.enums import WorkflowExecutionStatus from graphon.file import FILE_MODEL_IDENTITY, File, FileTransferMethod, FileType -from graphon.file import helpers as file_helpers from libs.helper import generate_string # type: ignore[import-not-found] from libs.url_utils import normalize_api_base_url from libs.uuid_utils import uuidv7 @@ -1606,90 +1604,7 @@ class Message(TypeBase): @property def re_sign_file_url_answer(self) -> str: - if not self.answer: - return self.answer - - # Match file URLs in three shapes the agent runtime may produce: - # - Markdown link: [text](https://.../files/tools/.../timestamp=&nonce=&sign=) - # - Backticked link: `https://.../files/tools/.../timestamp=&nonce=&sign=` - # - Bare URL: https://.../files/tools/.../timestamp=&nonce=&sign= - # The original implementation only matched the markdown form, so - # bare and backticked tool file URLs kept the long-lived - # INTERNAL_FILES_URL host and 5xx-ed at serve time. Refs #40788. - # The host prefix is optional so relative `/files/...` URLs that - # the agent returns without a host are also covered. The - # `(?=[)\s`]|$)` at the end of the bare-URL pattern (and the - # closing backtick / paren on the wrapped forms) stops the - # greedy `.*?=.*?` after `&sign=` from running off the end of - # the answer. - url_core = r"(?:https?:\/\/.+?)?\/files\/(tools\/)?[\w-]+.*?timestamp=[^)\s]*?&nonce=[^)\s]*?&sign=[^)\s]*?" - patterns = [ - r"\[!?.*?\]\((" + url_core + r")\)", # [text](url) - r"`(" + url_core + r")`", # `url` - r"(?:^|\s|\()(" + url_core + r")(?:[\s)\].,;]|$)", # bare url - ] - urls: set[str] = set() - for pattern in patterns: - urls.update(m.group(1) for m in re.finditer(pattern, self.answer)) - - if not urls: - return self.answer - - re_sign_file_url_answer = self.answer - for url in urls: - if "files/tools" in url: - # get tool file id - tool_file_id_pattern = r"\/files\/tools\/([\.\w-]+)?\?timestamp=" - result = re.search(tool_file_id_pattern, url) - if not result: - continue - - tool_file_id = result.group(1) - - # get extension - if "." in tool_file_id: - split_result = tool_file_id.split(".") - extension = f".{split_result[-1]}" - if len(extension) > 10: - extension = ".bin" - tool_file_id = split_result[0] - else: - extension = ".bin" - - if not tool_file_id: - continue - - sign_url = sign_tool_file(tool_file_id=tool_file_id, extension=extension) - elif "file-preview" in url: - # get upload file id - upload_file_id_pattern = r"\/files\/([\w-]+)\/file-preview\?timestamp=" - result = re.search(upload_file_id_pattern, url) - if not result: - continue - - upload_file_id = result.group(1) - if not upload_file_id: - continue - sign_url = file_helpers.get_signed_file_url(upload_file_id) - elif "image-preview" in url: - # image-preview is deprecated, use file-preview instead - upload_file_id_pattern = r"\/files\/([\w-]+)\/image-preview\?timestamp=" - result = re.search(upload_file_id_pattern, url) - if not result: - continue - - upload_file_id = result.group(1) - if not upload_file_id: - continue - sign_url = file_helpers.get_signed_file_url(upload_file_id) - else: - continue - # if as_attachment is in the url, add it to the sign_url. - if "as_attachment" in url: - sign_url += "&as_attachment=true" - re_sign_file_url_answer = re_sign_file_url_answer.replace(url, sign_url) - - return re_sign_file_url_answer + return re_sign_file_urls(self.answer) def user_feedback_with_session(self, *, session: Session) -> MessageFeedback | None: return session.scalar( diff --git a/api/models/types.py b/api/models/types.py index c5a9231ad4a..58f26a23528 100644 --- a/api/models/types.py +++ b/api/models/types.py @@ -1,10 +1,11 @@ import enum import json import uuid -from typing import Any, cast, override +import warnings +from typing import Any, cast, overload, override import sqlalchemy as sa -from pydantic import BaseModel +from pydantic import BaseModel, TypeAdapter from sqlalchemy import CHAR, TEXT, VARCHAR, LargeBinary, TypeDecorator from sqlalchemy.dialects.mysql import LONGBLOB, LONGTEXT from sqlalchemy.dialects.postgresql import BYTEA, JSONB, UUID @@ -70,7 +71,19 @@ class LongText(TypeDecorator[str | None]): class JSONModelColumn[T: BaseModel](TypeDecorator[T | None]): - """Store a Pydantic model as dialect-adjusted LongText JSON.""" + """Store coercible Pydantic model input as dialect-adjusted LongText JSON. + + New model-only columns should use ``FrozenPydanticModelColumn``. Existing + uses must not be replaced mechanically because they may depend on: + + - historical ``dict`` or JSON string writes; + - non-strict validation and coercion; + - Pydantic models that are not frozen; + - compatibility with dirty or otherwise non-canonical stored data. + + Audit both callers and persisted values before migrating an existing + column. + """ impl = TEXT cache_ok = True @@ -78,6 +91,13 @@ class JSONModelColumn[T: BaseModel](TypeDecorator[T | None]): _model_class: type[T] def __init__(self, model_class: type[T]): + # Existing persisted columns remain supported until their data and callers are audited. + warnings.warn( + "JSONModelColumn is a legacy coercing TEXT-backed type. " + "Use FrozenPydanticModelColumn for new model-only persistence.", + DeprecationWarning, + stacklevel=2, + ) if not issubclass(model_class, BaseModel): raise TypeError(f"{model_class.__module__}.{model_class.__name__} must be a Pydantic BaseModel subclass") self._model_class = model_class @@ -171,6 +191,118 @@ class AdjustedJSON(TypeDecorator[dict | list | None]): return value +class FrozenPydanticModelColumn[T: BaseModel](TypeDecorator[T]): + """Persist a frozen Pydantic model as JSON in a dialect-adjusted text column. + + Binding serializes the accepted model directly with + ``model_dump_json(warnings="error")``. Loading validates the stored JSON in + strict mode and lets Pydantic validation errors propagate to the caller. + Pass a concrete model class for concrete models. For discriminated unions, + pass a ``TypeAdapter`` together with every allowed concrete model class. + + The backing type is ``TEXT`` on PostgreSQL and SQLite and ``LONGTEXT`` on + MySQL. This is intentional: ``model_dump_json`` already returns JSON text, + while passing that string through SQLAlchemy's ``JSON`` type would encode it + again and persist a JSON string scalar instead of the model's JSON object. + + SQLAlchemy does not track in-place changes made inside a Pydantic model, so + this type only accepts models configured with ``frozen=True``. Persisted + values must be updated by constructing a new model and assigning it to the + ORM attribute as a whole, for example ``record.payload = PayloadModel(...)``. + Otherwise the attribute may not be marked dirty and the change may not be + persisted. + + Pydantic freezing is shallow: nested mutable containers such as ``dict`` and + ``list`` can still be changed in place. Callers must treat nested values as + immutable too instead of mutating paths such as + ``record.payload.root["key"]``. Supporting nested in-place mutation would + require SQLAlchemy's Mutable extension or explicit deep change tracking, + which this type does not provide. + """ + + impl = TEXT + cache_ok = True + + _model_type: type[T] | None + _adapter: TypeAdapter[T] | None + model_types: tuple[type[BaseModel], ...] + + @overload + def __init__( + self, + model_type: type[T], + /, + ) -> None: ... + + @overload + def __init__( + self, + adapter: TypeAdapter[T], + /, + *, + model_types: tuple[type[BaseModel], ...], + ) -> None: ... + + def __init__( + self, + schema: type[T] | TypeAdapter[T], + /, + *, + model_types: tuple[type[BaseModel], ...] | None = None, + ) -> None: + if isinstance(schema, TypeAdapter): + if model_types is None: + raise TypeError("model_types is required when schema is a TypeAdapter") + if not model_types: + raise ValueError("model_types must not be empty") + self._model_type = None + self._adapter = schema + self.model_types = model_types + else: + if model_types is not None: + raise TypeError("model_types must not be provided for a concrete model") + self._model_type = schema + self._adapter = None + self.model_types = (schema,) + + for allowed_model_type in self.model_types: + if not isinstance(allowed_model_type, type) or not issubclass(allowed_model_type, BaseModel): + raise TypeError("model_types must contain only Pydantic BaseModel classes") + model_name = f"{allowed_model_type.__module__}.{allowed_model_type.__qualname__}" + if allowed_model_type.model_config.get("frozen") is not True: + raise TypeError(f"{model_name} must configure frozen=True") + if allowed_model_type.model_config.get("strict") is not True: + raise TypeError(f"{model_name} must configure strict=True") + super().__init__() + + @override + def load_dialect_impl(self, dialect: Dialect) -> TypeEngine[Any]: + if dialect.name == "mysql": + return dialect.type_descriptor(LONGTEXT()) + return dialect.type_descriptor(TEXT()) + + @override + def process_bind_param(self, value: object | None, dialect: Dialect) -> str | None: + del dialect + if value is None: + return None + if not isinstance(value, BaseModel) or not isinstance(value, self.model_types): + allowed_model_names = ", ".join(model_type.__name__ for model_type in self.model_types) + raise TypeError(f"value must be one of these Pydantic models: {allowed_model_names}") + return value.model_dump_json(warnings="error") + + @override + def process_result_value(self, value: str | bytes | bytearray | None, dialect: Dialect) -> T | None: + del dialect + if value is None: + return None + if self._model_type is not None: + return self._model_type.model_validate_json(value, strict=True) + if self._adapter is None: + raise RuntimeError("Pydantic JSON validator is not configured") + return self._adapter.validate_json(value, strict=True) + + class EnumText[T: enum.StrEnum](TypeDecorator[T | None]): impl = VARCHAR cache_ok = True diff --git a/api/models/workflow.py b/api/models/workflow.py index b3945a06794..9ee6420131d 100644 --- a/api/models/workflow.py +++ b/api/models/workflow.py @@ -2220,6 +2220,12 @@ class WorkflowPause(DefaultFieldsDCMixin, TypeBase): class WorkflowPauseReason(DefaultFieldsDCMixin, TypeBase): + """Legacy HITL v1 pause details. New code must use the Graph State snapshot. + + Historical scheduling rows remain readable; new pause kinds must not write + this table. HITL v2 forms are never referenced here. + """ + __tablename__ = "workflow_pause_reasons" # `pause_id` represents the identifier of the pause, @@ -2274,13 +2280,20 @@ class WorkflowPauseReason(DefaultFieldsDCMixin, TypeBase): ) -> "WorkflowPauseReason": match pause_reason: case HitlRequired(): + node_version, form_id = default_session_binding.resolve_form_id_from_session_id( + session_id=pause_reason.session_id + ) + if node_version != "1": + raise ValueError("The legacy pause reason table only accepts HITL v1 forms") return cls( pause_id=pause_id, type_=PauseReasonType.HITL_REQUIRED, - form_id=default_session_binding.resolve_form_id_from_session_id(session_id=pause_reason.session_id), + form_id=form_id, node_id=pause_reason.node_id, ) case HumanInputRequired(): + if pause_reason.form_version != "1": + raise ValueError("The legacy pause reason table only accepts HITL v1 forms") return cls( pause_id=pause_id, type_=PauseReasonType.HITL_REQUIRED, @@ -2302,7 +2315,7 @@ class WorkflowPauseReason(DefaultFieldsDCMixin, TypeBase): ) elif self.type_ == PauseReasonType.HITL_REQUIRED: return HitlRequired( - session_id=default_session_binding.issue_session_id_for_form(form_id=self.form_id), + session_id=default_session_binding.issue_session_id_for_form(node_version="1", form_id=self.form_id), node_id=self.node_id, node_title="", ) diff --git a/api/openapi/markdown/console-openapi.md b/api/openapi/markdown/console-openapi.md index a4dba9dcd61..f27dcd00daf 100644 --- a/api/openapi/markdown/console-openapi.md +++ b/api/openapi/markdown/console-openapi.md @@ -2091,6 +2091,28 @@ Submit human input form preview for advanced chat workflow | 404 | Not Found | **application/json**: [AppNotFoundError](#appnotfounderror)
[ErrorResponse](#errorresponse)
| | default | Error | **application/json**: [ErrorResponse](#errorresponse)
| +### [POST] /apps/{app_id}/advanced-chat/workflows/draft/human-input/nodes/{node_id}/message-template/test +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | | Yes | string (uuid) | +| node_id | path | | Yes | string | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [MessageTemplateTestRequest](#messagetemplatetestrequest)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [MessageTemplateTestResponse](#messagetemplatetestresponse)
| +| 404 | Not Found | **application/json**: [AppNotFoundError](#appnotfounderror)
[ErrorResponse](#errorresponse)
| +| default | Error | **application/json**: [ErrorResponse](#errorresponse)
| + ### [POST] /apps/{app_id}/advanced-chat/workflows/draft/iteration/nodes/{node_id}/run **Run draft workflow iteration node** @@ -4772,6 +4794,28 @@ Submit human input form preview for workflow | 404 | Not Found | **application/json**: [AppNotFoundError](#appnotfounderror)
[ErrorResponse](#errorresponse)
| | default | Error | **application/json**: [ErrorResponse](#errorresponse)
| +### [POST] /apps/{app_id}/workflows/draft/human-input/nodes/{node_id}/message-template/test +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | | Yes | string (uuid) | +| node_id | path | | Yes | string | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [MessageTemplateTestRequest](#messagetemplatetestrequest)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [MessageTemplateTestResponse](#messagetemplatetestresponse)
| +| 404 | Not Found | **application/json**: [AppNotFoundError](#appnotfounderror)
[ErrorResponse](#errorresponse)
| +| default | Error | **application/json**: [ErrorResponse](#errorresponse)
| + ### [POST] /apps/{app_id}/workflows/draft/iteration/nodes/{node_id}/run **Run draft workflow iteration node** @@ -7624,6 +7668,49 @@ Verify password reset code | 429 | Too Many Requests | **application/json**: [EmailPasswordResetLimitError](#emailpasswordresetlimiterror)
[ErrorResponse](#errorresponse)
| | default | Error | **application/json**: [ErrorResponse](#errorresponse)
| +### [GET] /form/human-input/v2/{form_token} +Read a frozen v2 form using the current Account's Contact authorization. + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| form_token | path | | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Form retrieved | **application/json**: [ConsoleHumanInputV2FormDefinitionResponse](#consolehumaninputv2formdefinitionresponse)
| +| 401 | Login required | **application/json**: [ErrorResponse](#errorresponse)
| +| 404 | Form not found | **application/json**: [ErrorResponse](#errorresponse)
| +| 409 | Already submitted | **application/json**: [AlreadySubmittedError](#alreadysubmittederror)
[ErrorResponse](#errorresponse)
| +| 410 | Expired | **application/json**: [ExpiredError](#expirederror)
[ErrorResponse](#errorresponse)
| +| default | Error | **application/json**: [ErrorResponse](#errorresponse)
| + +### [POST] /form/human-input/v2/{form_token} +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| form_token | path | | Yes | string | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [ConsoleHumanInputV2SubmitPayload](#consolehumaninputv2submitpayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Approval accepted | **application/json**: [ConsoleHumanInputV2SubmitResponse](#consolehumaninputv2submitresponse)
| +| 400 | Bad Request | **application/json**: [InvalidInputError](#invalidinputerror)
[ErrorResponse](#errorresponse)
| +| 409 | Conflict | **application/json**: [AlreadySubmittedError](#alreadysubmittederror)
[WorkflowNotActiveError](#workflownotactiveerror)
[ErrorResponse](#errorresponse)
| +| 410 | Gone | **application/json**: [ExpiredError](#expirederror)
[ErrorResponse](#errorresponse)
| +| default | Error | **application/json**: [ErrorResponse](#errorresponse)
| + ### [GET] /form/human_input/{form_token} **Get human input form definition by form token** @@ -14602,6 +14689,571 @@ Update a plugin endpoint | 403 | Admin privileges required | **application/json**: [ErrorResponse](#errorresponse)
| | default | Error | **application/json**: [ErrorResponse](#errorresponse)
| +### [GET] /workspaces/current/human-input/v2/channel-providers +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [ListChannelProvidersResponse](#listchannelprovidersresponse)
| +| default | Error | **application/json**: [ErrorResponse](#errorresponse)
| + +### [GET] /workspaces/current/human-input/v2/channels +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [ListChannelsResponse](#listchannelsresponse)
| +| default | Error | **application/json**: [ErrorResponse](#errorresponse)
| + +### [POST] /workspaces/current/human-input/v2/channels/email +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [EmailChannelCreatePayload](#emailchannelcreatepayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [EmailChannelMutationResponse](#emailchannelmutationresponse)
| +| default | Error | **application/json**: [ErrorResponse](#errorresponse)
| + +### [POST] /workspaces/current/human-input/v2/channels/email/test +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [EmailChannelTestPayload](#emailchanneltestpayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [ChannelTestResponse](#channeltestresponse)
| +| default | Error | **application/json**: [ErrorResponse](#errorresponse)
| + +### [DELETE] /workspaces/current/human-input/v2/channels/email/{channel_id} +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| expected_config_version | query | | Yes | string | +| channel_id | path | | Yes | string (uuid) | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [ChannelDeleteResponse](#channeldeleteresponse)
| +| 409 | Configuration conflict | **application/json**: [ChannelConflictResponse](#channelconflictresponse)
[ErrorResponse](#errorresponse)
| +| default | Error | **application/json**: [ErrorResponse](#errorresponse)
| + +### [GET] /workspaces/current/human-input/v2/channels/email/{channel_id} +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| channel_id | path | | Yes | string (uuid) | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [EmailChannelDetailResponse](#emailchanneldetailresponse)
| +| default | Error | **application/json**: [ErrorResponse](#errorresponse)
| + +### [PUT] /workspaces/current/human-input/v2/channels/email/{channel_id} +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| channel_id | path | | Yes | string (uuid) | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [EmailChannelUpdatePayload](#emailchannelupdatepayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [EmailChannelMutationResponse](#emailchannelmutationresponse)
| +| 409 | Configuration conflict | **application/json**: [ChannelConflictResponse](#channelconflictresponse)
[ErrorResponse](#errorresponse)
| +| default | Error | **application/json**: [ErrorResponse](#errorresponse)
| + +### [POST] /workspaces/current/human-input/v2/channels/email/{channel_id}/test +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| channel_id | path | | Yes | string (uuid) | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [EmailChannelUpdatePayload](#emailchannelupdatepayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [ChannelTestResponse](#channeltestresponse)
| +| 409 | Configuration conflict | **application/json**: [ChannelConflictResponse](#channelconflictresponse)
[ErrorResponse](#errorresponse)
| +| default | Error | **application/json**: [ErrorResponse](#errorresponse)
| + +### [POST] /workspaces/current/human-input/v2/channels/im +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [IMChannelCreatePayload](#imchannelcreatepayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [IMChannelMutationResponse](#imchannelmutationresponse)
| +| default | Error | **application/json**: [ErrorResponse](#errorresponse)
| + +### [POST] /workspaces/current/human-input/v2/channels/im/test +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [IMChannelTestPayload](#imchanneltestpayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [ChannelTestResponse](#channeltestresponse)
| +| default | Error | **application/json**: [ErrorResponse](#errorresponse)
| + +### [DELETE] /workspaces/current/human-input/v2/channels/im/{channel_id} +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| expected_config_version | query | | Yes | string | +| channel_id | path | | Yes | string (uuid) | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [ChannelDeleteResponse](#channeldeleteresponse)
| +| 409 | Configuration conflict | **application/json**: [ChannelConflictResponse](#channelconflictresponse)
[ErrorResponse](#errorresponse)
| +| default | Error | **application/json**: [ErrorResponse](#errorresponse)
| + +### [GET] /workspaces/current/human-input/v2/channels/im/{channel_id} +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| channel_id | path | | Yes | string (uuid) | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [IMChannelDetailResponse](#imchanneldetailresponse)
| +| default | Error | **application/json**: [ErrorResponse](#errorresponse)
| + +### [PUT] /workspaces/current/human-input/v2/channels/im/{channel_id} +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| channel_id | path | | Yes | string (uuid) | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [IMChannelUpdatePayload](#imchannelupdatepayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [IMChannelMutationResponse](#imchannelmutationresponse)
| +| 409 | Configuration conflict | **application/json**: [ChannelConflictResponse](#channelconflictresponse)
[ErrorResponse](#errorresponse)
| +| default | Error | **application/json**: [ErrorResponse](#errorresponse)
| + +### [POST] /workspaces/current/human-input/v2/channels/im/{channel_id}/replacement +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| channel_id | path | | Yes | string (uuid) | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [IMChannelReplacementPayload](#imchannelreplacementpayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [IMChannelMutationResponse](#imchannelmutationresponse)
| +| 409 | Configuration conflict | **application/json**: [ChannelConflictResponse](#channelconflictresponse)
[ErrorResponse](#errorresponse)
| +| default | Error | **application/json**: [ErrorResponse](#errorresponse)
| + +### [POST] /workspaces/current/human-input/v2/channels/im/{channel_id}/test +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| channel_id | path | | Yes | string (uuid) | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [IMChannelUpdatePayload](#imchannelupdatepayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [ChannelTestResponse](#channeltestresponse)
| +| 400 | The test provider must match the saved IM Channel. | **application/json**: [ErrorResponse](#errorresponse)
| +| 409 | Configuration conflict | **application/json**: [ChannelConflictResponse](#channelconflictresponse)
[ErrorResponse](#errorresponse)
| +| default | Error | **application/json**: [ErrorResponse](#errorresponse)
| + +### [GET] /workspaces/current/human-input/v2/contact-options +List editor-safe Contact options for static recipient selection. The projection omits IM bindings and management metadata; unavailable Contacts are omitted. + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| keyword | query | Free-text search against selectable contact names. | No | string | +| limit | query | Maximum number of records returned per page. | No | integer,
**Default:** 20 | +| page | query | 1-based page number. | No | integer,
**Default:** 1 | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [ListContactOptionsResponse](#listcontactoptionsresponse)
| +| default | Error | **application/json**: [ErrorResponse](#errorresponse)
| + +### [GET] /workspaces/current/human-input/v2/contact-options/batch +Resolve Contact IDs persisted in workflow recipient configuration. Contacts unavailable in the current workspace are omitted. + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| contact_ids | query | Contact IDs persisted in workflow recipient configuration. | Yes | [ string ] | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [BatchGetContactOptionsResponse](#batchgetcontactoptionsresponse)
| +| default | Error | **application/json**: [ErrorResponse](#errorresponse)
| + +### [GET] /workspaces/current/human-input/v2/contacts +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| group | query | Optional contact type filter. None means all contacts. | No | string,
**Available values:** "external", "platform", "workspace" | +| keyword | query | Free-text search against contact name or email. | No | string | +| limit | query | Maximum number of records returned per page. | No | integer,
**Default:** 20 | +| page | query | 1-based page number. | No | integer,
**Default:** 1 | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [ListContactsResponse](#listcontactsresponse)
| +| default | Error | **application/json**: [ErrorResponse](#errorresponse)
| + +### [GET] /workspaces/current/human-input/v2/contacts/batch +Admin-only batch lookup for Contact management clients. Workflow editors must use the editor-safe contact-options/batch projection. + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| contact_ids | query | List of contact IDs to retrieve. | Yes | [ string ] | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [BatchGetContactsResponse](#batchgetcontactsresponse)
| +| default | Error | **application/json**: [ErrorResponse](#errorresponse)
| + +### [POST] /workspaces/current/human-input/v2/contacts/external +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [ExternalContactCreateRequest](#externalcontactcreaterequest)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [ExternalContactCreateResponse](#externalcontactcreateresponse)
| +| default | Error | **application/json**: [ErrorResponse](#errorresponse)
| + +### [PATCH] /workspaces/current/human-input/v2/contacts/external/{contact_id} +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| contact_id | path | | Yes | string (uuid) | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [ExternalContactUpdateRequest](#externalcontactupdaterequest)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [ExternalContactUpdateResponse](#externalcontactupdateresponse)
| +| default | Error | **application/json**: [ErrorResponse](#errorresponse)
| + +### [POST] /workspaces/current/human-input/v2/contacts/platform +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [AddPlatformContactsRequest](#addplatformcontactsrequest)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [AddPlatformContactsResponse](#addplatformcontactsresponse)
| +| default | Error | **application/json**: [ErrorResponse](#errorresponse)
| + +### [POST] /workspaces/current/human-input/v2/contacts/remove +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [RemoveContactsRequest](#removecontactsrequest)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [RemoveContactsResponse](#removecontactsresponse)
| +| default | Error | **application/json**: [ErrorResponse](#errorresponse)
| + +### [GET] /workspaces/current/human-input/v2/contacts/{contact_id} +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| contact_id | path | | Yes | string (uuid) | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [GetContactResponse](#getcontactresponse)
| +| 404 | Contact not found or absent in the current workspace | **application/json**: [ErrorResponse](#errorresponse)
| +| default | Error | **application/json**: [ErrorResponse](#errorresponse)
| + +### [DELETE] /workspaces/current/human-input/v2/contacts/{contact_id}/im-bindings +Delete the current default IM binding for the contact identified by the path. Workspace IM overrides are not modified. This endpoint is not used for resetting workspace IM override. For that purpose, use WorkspaceContactIMOverrideApi.delete instead. + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| contact_id | path | | Yes | string (uuid) | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [DeleteIMBindingResponse](#deleteimbindingresponse)
| +| 403 | Centrally managed IM bindings cannot be edited from a workspace | **application/json**: [ErrorResponse](#errorresponse)
| +| 404 | IM binding not found | **application/json**: [IMContactSyncErrorResponse](#imcontactsyncerrorresponse)
[ErrorResponse](#errorresponse)
| +| 503 | IM write unavailable | **application/json**: [IMContactSyncErrorResponse](#imcontactsyncerrorresponse)
[ErrorResponse](#errorresponse)
| +| default | Error | **application/json**: [ErrorResponse](#errorresponse)
| + +### [PUT] /workspaces/current/human-input/v2/contacts/{contact_id}/im-bindings +Set a default IM binding for a contact, atomically replacing any default binding for either the contact or the selected identity. Identical assignments preserve their binding ID; changed assignments receive a new binding ID. This endpoint is not used for creating workspace IM override. For that purpose, use WorkspaceContactIMOverrideApi.put instead. + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| contact_id | path | | Yes | string (uuid) | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [CreateIMBindingRequest](#createimbindingrequest)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [CreateIMBindingResponse](#createimbindingresponse)
| +| 403 | Centrally managed IM bindings cannot be edited from a workspace | **application/json**: [ErrorResponse](#errorresponse)
| +| 404 | Contact or IM identity not found | **application/json**: [IMContactSyncErrorResponse](#imcontactsyncerrorresponse)
[ErrorResponse](#errorresponse)
| +| 409 | IM binding conflict | **application/json**: [IMContactSyncErrorResponse](#imcontactsyncerrorresponse)
[ErrorResponse](#errorresponse)
| +| 503 | IM write unavailable | **application/json**: [IMContactSyncErrorResponse](#imcontactsyncerrorresponse)
[ErrorResponse](#errorresponse)
| +| default | Error | **application/json**: [ErrorResponse](#errorresponse)
| + +### [DELETE] /workspaces/current/human-input/v2/contacts/{contact_id}/im-override +Reset the IM override for a contact. This endpoint is used to clear the IM identity override for a contact in the workspace. + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| contact_id | path | | Yes | string (uuid) | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [ResetContactIMOverrideResponse](#resetcontactimoverrideresponse)
| +| 403 | Workspace overrides require centrally managed IM bindings | **application/json**: [ErrorResponse](#errorresponse)
| +| 404 | Contact not found | **application/json**: [IMContactSyncErrorResponse](#imcontactsyncerrorresponse)
[ErrorResponse](#errorresponse)
| +| 422 | Invalid binding scope | **application/json**: [IMContactSyncErrorResponse](#imcontactsyncerrorresponse)
[ErrorResponse](#errorresponse)
| +| 503 | IM write unavailable | **application/json**: [IMContactSyncErrorResponse](#imcontactsyncerrorresponse)
[ErrorResponse](#errorresponse)
| +| default | Error | **application/json**: [ErrorResponse](#errorresponse)
| + +### [PUT] /workspaces/current/human-input/v2/contacts/{contact_id}/im-override +Set or reset the IM override for a contact. This endpoint is used to override the IM identity for a contact in the workspace. + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| contact_id | path | | Yes | string (uuid) | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [SetContactIMOverrideRequest](#setcontactimoverriderequest)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [SetContactIMOverrideResponse](#setcontactimoverrideresponse)
| +| 403 | Workspace overrides require centrally managed IM bindings | **application/json**: [ErrorResponse](#errorresponse)
| +| 404 | Contact or IM identity not found | **application/json**: [IMContactSyncErrorResponse](#imcontactsyncerrorresponse)
[ErrorResponse](#errorresponse)
| +| 409 | IM binding conflict | **application/json**: [IMContactSyncErrorResponse](#imcontactsyncerrorresponse)
[ErrorResponse](#errorresponse)
| +| 422 | Invalid binding scope | **application/json**: [IMContactSyncErrorResponse](#imcontactsyncerrorresponse)
[ErrorResponse](#errorresponse)
| +| 503 | IM write unavailable | **application/json**: [IMContactSyncErrorResponse](#imcontactsyncerrorresponse)
[ErrorResponse](#errorresponse)
| +| default | Error | **application/json**: [ErrorResponse](#errorresponse)
| + +### [GET] /workspaces/current/human-input/v2/im-identities +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| keyword | query | Free-text search against identity display name, email, or provider user ID. | No | string | +| limit | query | Maximum number of records returned per page. | No | integer,
**Default:** 20 | +| page | query | 1-based page number. | No | integer,
**Default:** 1 | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [ListIMIdentitiesResponse](#listimidentitiesresponse)
| +| 404 | IM Integration not configured | **application/json**: [IMContactSyncErrorResponse](#imcontactsyncerrorresponse)
[ErrorResponse](#errorresponse)
| +| default | Error | **application/json**: [ErrorResponse](#errorresponse)
| + +### [POST] /workspaces/current/human-input/v2/im-sync-runs +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [CreateIMSyncRunResponse](#createimsyncrunresponse)
| +| 404 | IM Integration not configured | **application/json**: [IMContactSyncErrorResponse](#imcontactsyncerrorresponse)
[ErrorResponse](#errorresponse)
| +| 409 | IM Integration revision changed | **application/json**: [IMContactSyncErrorResponse](#imcontactsyncerrorresponse)
[ErrorResponse](#errorresponse)
| +| 503 | IM synchronization temporarily unavailable | **application/json**: [IMContactSyncErrorResponse](#imcontactsyncerrorresponse)
[ErrorResponse](#errorresponse)
| +| default | Error | **application/json**: [ErrorResponse](#errorresponse)
| + +### [GET] /workspaces/current/human-input/v2/im-sync-runs/latest +Return the latest IM sync run summary. The UI uses finished_at as the explicit sync time; the response does not include started_by. + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [GetLatestIMSyncRunResponse](#getlatestimsyncrunresponse)
| +| 404 | Latest IM sync run not found | **application/json**: [IMContactSyncErrorResponse](#imcontactsyncerrorresponse)
[ErrorResponse](#errorresponse)
| +| default | Error | **application/json**: [ErrorResponse](#errorresponse)
| + +### [GET] /workspaces/current/human-input/v2/im-sync-runs/latest/results +Return one required result bucket from the latest IM sync run using page and limit pagination. There is no all filter; the response contains page, limit, and total metadata without a run summary. + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| limit | query | Maximum number of records returned per page. | No | integer,
**Default:** 20 | +| page | query | 1-based page number. | No | integer,
**Default:** 1 | +| result | query | Required result bucket to paginate from the latest sync run. There is no `all` bucket or unfiltered results mode. | Yes | string,
**Available values:** "added", "failed", "not_matched", "removed", "skipped" | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [ListLatestIMSyncRunResultsResponse](#listlatestimsyncrunresultsresponse)
| +| 404 | Latest IM sync run not found | **application/json**: [IMContactSyncErrorResponse](#imcontactsyncerrorresponse)
[ErrorResponse](#errorresponse)
| +| default | Error | **application/json**: [ErrorResponse](#errorresponse)
| + +### [POST] /workspaces/current/human-input/v2/node-data-migration +Migrate node data from HITLv1 to HITLv2. A missing legacy version defaults to "1"; any other explicit version is rejected. Every legacy Email recipient becomes onetime_email, while whole_workspace becomes all_workspace_contacts. This side-effect-free endpoint only returns migrated node data; the caller owns applying it to the Draft workflow. + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [NodeDataMigrationPayload](#nodedatamigrationpayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [NodeDataMigrationResponse](#nodedatamigrationresponse)
| +| 400 | Migration failed | **application/json**: [NodeDataMigrationFailureResponse](#nodedatamigrationfailureresponse)
[ErrorResponse](#errorresponse)
| +| default | Error | **application/json**: [ErrorResponse](#errorresponse)
| + +### [GET] /workspaces/current/human-input/v2/organization-candidates +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| keyword | query | Free-text search against candidate name or email. | No | string | +| limit | query | Maximum number of records returned per page. | No | integer,
**Default:** 20 | +| page | query | 1-based page number. | No | integer,
**Default:** 1 | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [ListOrganizationCandidatesResponse](#listorganizationcandidatesresponse)
| +| default | Error | **application/json**: [ErrorResponse](#errorresponse)
| + ### [GET] /workspaces/current/members #### Parameters @@ -18418,6 +19070,22 @@ Account not link tenant. | ---- | ---- | ----------- | -------- | | result | string | Operation result | Yes | +#### AddPlatformContactsRequest + +Request body for adding one or more organization members as platform contacts. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| candidate_ids | [ string ] | Organization candidate identifiers to project into the current workspace as platform contacts. | Yes | + +#### AddPlatformContactsResponse + +Response body for adding platform contacts. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| data | [ [HumanInputContact](#humaninputcontact) ] | Contacts created by the current add operation. | Yes | + #### AdvancedChatWorkflowRunForListResponse | Name | Type | Description | Required | @@ -20420,6 +21088,17 @@ Agent config version not found. | ---- | ---- | ----------- | -------- | | AgentVersionNotFoundError | | Agent config version not found. | | +#### AllWorkspaceContacts + +Migration marker preserving legacy whole-workspace recipient intent. + +Runtime expansion is deliberately owned by the recipient resolver rather +than the node-data migration helper. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| type | string,
**Default:** all_workspace_contacts | | No | + #### AllowedExtensionsResponse | Name | Type | Description | Required | @@ -20434,6 +21113,12 @@ Auth Token is invalid or account already activated, please check again. | ---- | ---- | ----------- | -------- | | AlreadyActivateError | | Auth Token is invalid or account already activated, please check again. | | +#### AlreadySubmittedError + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| AlreadySubmittedError | | | | + #### Annotation | Name | Type | Description | Required | @@ -22195,6 +22880,24 @@ ExporleBanner status | ---- | ---- | ----------- | -------- | | BannerStatus | string | ExporleBanner status | | +#### BatchGetContactOptionsQuery + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| contact_ids | [ string ] | Contact IDs persisted in workflow recipient configuration. | Yes | + +#### BatchGetContactOptionsResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| data | [ [ContactOption](#contactoption) ] | Selectable contacts resolved in request order. | Yes | + +#### BatchGetContactsResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| data | [ [HumanInputContactSummary](#humaninputcontactsummary) ] | List of retrieved human input contacts. | Yes | + #### BatchImportPayload | Name | Type | Description | Required | @@ -22273,6 +22976,13 @@ Sandbox file download is unavailable. | ---- | ---- | ----------- | -------- | | BindingFileDownloadUnavailableError | | Sandbox file download is unavailable. | | +#### BoundRecipient + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| reference_id | string | | Yes | +| type | string,
**Default:** member | | No | + #### BrandingModel | Name | Type | Description | Required | @@ -22376,6 +23086,67 @@ You cannot transfer ownership to yourself. | email | string | | Yes | | token | string | | Yes | +#### ChannelConflictResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| code | string,
**Available values:** "provider_configuration_updated", "replacement_required" | *Enum:* `"provider_configuration_updated"`, `"replacement_required"` | Yes | +| message | string | | Yes | +| status | integer,
**Default:** 409 | | No | + +#### ChannelDeleteQuery + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| expected_config_version | [ConfigVersion](#configversion) | | Yes | + +#### ChannelDeleteResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| channel_id | [ChannelId](#channelid) | | Yes | + +#### ChannelId + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| ChannelId | string | | | + +#### ChannelProvider + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| connection_mode | string,
**Default:** custom_app | | No | +| provider | [EmailProviderType](#emailprovidertype)
[IMProvider](#improvider) | | Yes | + +#### ChannelStatus + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| ChannelStatus | string | | | + +#### ChannelSummary + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| config_version | [ConfigVersion](#configversion) | The current opaque configuration version used for optimistic concurrency control. | Yes | +| created_at | integer | Unix timestamp in seconds | Yes | +| display_identifier | string | The display identifier of the Channel. | Yes | +| id | [ChannelId](#channelid) | | Yes | +| kind | string,
**Available values:** "email", "im" | *Enum:* `"email"`, `"im"` | Yes | +| provider | [EmailProviderType](#emailprovidertype)
[IMProvider](#improvider) | The provider of the Channel. The concrete provider type depends on the `kind` field. | Yes | +| status | [ChannelStatus](#channelstatus) | | Yes | +| status_description | string | Human-readable status description. Empty when the status is `configured` or `connected`. | Yes | +| updated_at | integer | Unix timestamp in seconds | Yes | +| webhook_url | string | Webhook URL to configure on the provider side. None when webhook configuration is not required or the provider does not support webhooks. | Yes | + +#### ChannelTestResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| status | [ChannelStatus](#channelstatus) | | Yes | +| status_description | string | | Yes | + #### ChatConversationQuery | Name | Type | Description | Required | @@ -22754,6 +23525,12 @@ Condition detail | name | string | Metadata field name to compare against. | Yes | | value | string
[ string ]
number | Value to compare against. Type depends on `comparison_operator`: string for most string operators, array of strings for `in` and `not in`, number for numeric operators, and omit or use `null` for `empty` and `not empty`. | No | +#### ConfigVersion + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| ConfigVersion | string | | | + #### ConfigurateMethod Enum class for configurate method of provider model. @@ -22786,6 +23563,27 @@ Enum class for configurate method of provider model. | ---- | ---- | ----------- | -------- | | ConsoleHumanInputFormSubmitResponse | object | | | +#### ConsoleHumanInputV2FormDefinitionResponse + +Frozen form definition and effective expiration in UTC epoch seconds. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| expiration_time | integer | | Yes | +| resolved_form | [ResolvedForm](#resolvedform) | | Yes | + +#### ConsoleHumanInputV2SubmitPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| action | string | | Yes | +| inputs | object | | Yes | + +#### ConsoleHumanInputV2SubmitResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | + #### ConsoleSegmentListResponse | Name | Type | Description | Required | @@ -22796,6 +23594,66 @@ Enum class for configurate method of provider model. | total | integer | | Yes | | total_pages | integer | | Yes | +#### Contact + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| contact_id | string | | Yes | +| type | string,
**Default:** contact | | No | + +#### ContactIMBindingDetail + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| id | string | | Yes | +| identity | [ContactIMIdentityDetail](#contactimidentitydetail) | | Yes | +| provider | [IMProvider](#improvider) | | Yes | +| scope | [IMBindingScope](#imbindingscope) | | Yes | + +#### ContactIMIdentityDetail + +Display fields for the identity selected by an effective contact binding. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| display_name | string | | Yes | +| email | string | | Yes | +| id | string | | Yes | +| provider_user_id | string | | Yes | + +#### ContactListQuery + +Query params for listing contacts in the workspace directory. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| group | [HumanInputContactType](#humaninputcontacttype) | Optional contact type filter. None means all contacts. | No | +| keyword | string | Free-text search against contact name or email. | No | +| limit | integer,
**Default:** 20 | Maximum number of records returned per page. | No | +| page | integer,
**Default:** 1 | 1-based page number. | No | + +#### ContactOption + +Least-privilege contact projection returned to workflow editors. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| avatar_url | string | Signed avatar URL if one is available. | No | +| email | string | Current deliverable Email, when available. | No | +| id | string | Unique contact identifier persisted in workflow recipient configuration. | Yes | +| name | string | Display name shown in the contact picker. | Yes | +| type | [HumanInputContactType](#humaninputcontacttype) | Resolved contact type in the current workspace scope. | Yes | + +#### ContactOptionsQuery + +Query params for selecting contacts in workflow editors. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| keyword | string | Free-text search against selectable contact names. | No | +| limit | integer,
**Default:** 20 | Maximum number of records returned per page. | No | +| page | integer,
**Default:** 1 | 1-based page number. | No | + #### Conversation | Name | Type | Description | Required | @@ -23069,6 +23927,30 @@ The conversation was not found for this user and app. | mode | string,
**Available values:** "advanced-chat", "agent-chat", "chat", "completion", "workflow" | App mode
*Enum:* `"advanced-chat"`, `"agent-chat"`, `"chat"`, `"completion"`, `"workflow"` | Yes | | name | string | App name | Yes | +#### CreateIMBindingRequest + +Request body for setting one workspace-scoped IM override. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| identity_id | string | Synced IM identity identifier selected as the workspace override. | Yes | + +#### CreateIMBindingResponse + +Response body returned after binding one IM identity to the workspace. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| contact | [HumanInputContact](#humaninputcontact) | Contact snapshot after the IM identity is bound. | Yes | + +#### CreateIMSyncRunResponse + +Response body returned after creating one sync run. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| run | [IMSyncRun](#imsyncrun) | Newly created sync run snapshot. | Yes | + #### CreateSnippetPayload Payload for creating a new snippet. @@ -23933,11 +24815,26 @@ Enum class for datasource provider | start_node_id | string | | Yes | | start_node_title | string | | Yes | +#### DebugChannel + +Delivery selection for editor debugging, resolved to current channel configuration. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| DebugChannel | string | Delivery selection for editor debugging, resolved to current channel configuration. | | + +#### DebugModeConfig + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| channels | [ [DebugChannel](#debugchannel) ] | | Yes | +| enabled | boolean | | No | + #### DeclaredArrayItem Per-item shape for an ``array``-typed declared output. -PRD §OUTPUT 配置框 keeps arrays one level deep on first version; nested arrays +The PRD output configuration section limits arrays to one level; nested arrays are rejected so the runtime type checker and JSON Schema stay easy to reason about. Stage 4 §4.2. @@ -23951,7 +24848,7 @@ about. Stage 4 §4.2. File-output content check via a model-based comparison against a benchmark file. -Per PRD §OUTPUT 配置框, output check is **file-only** and optional. Stage 4 §4.3. +Per the PRD output configuration section, output checks are **file-only** and optional. Stage 4 §4.3. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | @@ -24060,6 +24957,25 @@ Default model entity. | model_type | [ModelType](#modeltype) | | Yes | | provider | [SimpleProviderEntityResponse](#simpleproviderentityresponse) | | Yes | +#### DefaultValue + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| key | string | | Yes | +| type | [DefaultValueType](#defaultvaluetype) | | Yes | +| value | | | No | + +#### DefaultValueType + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| DefaultValueType | string | | | + +#### DeleteIMBindingResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | + #### DeletedTool | Name | Type | Description | Required | @@ -24076,6 +24992,33 @@ Enum representing the deployment edition of the platform. | ---- | ---- | ----------- | -------- | | DeploymentEdition | string | Enum representing the deployment edition of the platform. | | +#### DingTalkCredentialsInput + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| client_id | string | DingTalk application client identifier. | Yes | +| client_secret | [_RequiredSecret](#_requiredsecret) | DingTalk application client secret. | Yes | +| corp_id | string | DingTalk corporation identifier. | Yes | +| provider | string | DingTalk credential discriminator. | Yes | + +#### DingTalkCredentialsResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| client_id | string | | Yes | +| client_secret | [Redacted](#redacted) | | Yes | +| corp_id | string | | Yes | +| provider | string | | Yes | + +#### DingTalkCredentialsUpdateInput + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| client_id | [_Identifier](#_identifier)
[UnchangedInput](#unchangedinput) | | Yes | +| client_secret | [_RequiredSecret](#_requiredsecret)
[UnchangedInput](#unchangedinput) | | Yes | +| corp_id | [_Identifier](#_identifier)
[UnchangedInput](#unchangedinput) | | Yes | +| provider | string | | Yes | + #### DismissNotificationPayload | Name | Type | Description | Required | @@ -24409,6 +25352,13 @@ Portable DSL reference that could not be restored in the target workspace. | message | string | | Yes | | path | string | | Yes | +#### DynamicEmail + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| selector | [ string ] | | Yes | +| type | string,
**Default:** dynamic_email | | No | + #### EducationActivateLimitError Rate limit exceeded @@ -24501,6 +25451,46 @@ Too many email change emails have been sent. Please try again in {minutes} minut | ---- | ---- | ----------- | -------- | | EmailChangeRateLimitExceededError | | Too many email change emails have been sent. Please try again in {minutes} minutes. | | +#### EmailChannelCreatePayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| credentials | [EmailProviderCredentials](#emailprovidercredentials) | | Yes | +| sender_email | string | | Yes | +| sender_name | [_SenderName](#_sendername) | | Yes | + +#### EmailChannelDetailResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| credentials | [ResendCredentialsResponse](#resendcredentialsresponse) | | Yes | +| sender_email | string | | Yes | +| sender_name | string | | Yes | +| summary | [ChannelSummary](#channelsummary) | | Yes | + +#### EmailChannelMutationResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| summary | [ChannelSummary](#channelsummary) | | Yes | + +#### EmailChannelTestPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| credentials | [EmailProviderCredentials](#emailprovidercredentials) | | Yes | +| sender_email | string | | Yes | +| sender_name | [_SenderName](#_sendername) | | Yes | + +#### EmailChannelUpdatePayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| credentials | [EmailProviderCredentialsUpdateInput](#emailprovidercredentialsupdateinput) | | Yes | +| expected_config_version | [ConfigVersion](#configversion) | | Yes | +| sender_email | string
[UnchangedInput](#unchangedinput) | | Yes | +| sender_name | [_SenderName](#_sendername)
[UnchangedInput](#unchangedinput) | | Yes | + #### EmailCodeAccountDeletionRateLimitExceededError Too many account deletion emails have been sent. Please try again in {minutes} minutes. @@ -24560,6 +25550,24 @@ Email code verification is temporarily unavailable. Please try again later. | language | string | | No | | turnstile_token | string | Cloudflare Turnstile token. Required at runtime for Dify Cloud. | No | +#### EmailDeliveryConfig + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| body | string | | Yes | +| debug_mode | boolean | | No | +| recipients | [EmailRecipients](#emailrecipients) | | Yes | +| subject | string | | Yes | + +#### EmailDeliveryMethod + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| config | [EmailDeliveryConfig](#emaildeliveryconfig) | | Yes | +| enabled | boolean,
**Default:** true | | No | +| id | string (uuid) | | No | +| type | string,
**Default:** email | | No | + #### EmailDomainSuspendedError This email domain has been suspended. @@ -24583,6 +25591,33 @@ Too many failed password reset attempts. Please try again in 24 hours. | email | string | | Yes | | language | string | | No | +#### EmailProviderCredentials + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| EmailProviderCredentials | [ResendCredentials](#resendcredentials) | | | + +#### EmailProviderCredentialsUpdateInput + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| EmailProviderCredentialsUpdateInput | [ResendCredentialsUpdateInput](#resendcredentialsupdateinput) | | | + +#### EmailProviderType + +Email provider supported by organization-level Human Input delivery. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| EmailProviderType | string | Email provider supported by organization-level Human Input delivery. | | + +#### EmailRecipients + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| include_bound_group | boolean | | No | +| items | [ [BoundRecipient](#boundrecipient)
[ExternalRecipient](#externalrecipient) ] | | No | + #### EmailRegisterLimitError Too many failed email register attempts. Please try again in 24 hours. @@ -24872,6 +25907,12 @@ stays open because ``HTTPException.data`` may carry extra keys. | params | string | | No | | status | integer | | Yes | +#### ErrorStrategy + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| ErrorStrategy | string | | | + #### EventApiEntity | Name | Type | Description | Required | @@ -24945,6 +25986,12 @@ The type of the parameter | ---- | ---- | ----------- | -------- | | ExecutionContentType | string | | | +#### ExpiredError + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| ExpiredError | | | | + #### ExploreAppMetaResponse Metadata consumed by the installed-app chat UI. @@ -24997,6 +26044,38 @@ Built-in tool icons are URL strings; API-based tool icons are provider-defined p | limit | integer,
**Default:** 20 | Number of items per page | No | | page | integer,
**Default:** 1 | Page number | No | +#### ExternalContactCreateRequest + +Request body for creating or updating one external contact. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| avatar | string | | No | +| email | string | Primary email used for delivery and identity verification. | Yes | +| name | string | Display name shown in the contact directory. | Yes | + +#### ExternalContactCreateResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| contact | [HumanInputContact](#humaninputcontact) | The created external contact. | Yes | + +#### ExternalContactUpdateRequest + +Request body for creating or updating one external contact. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| avatar | string | | No | +| email | string | | No | +| name | string | | No | + +#### ExternalContactUpdateResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| contact | [HumanInputContact](#humaninputcontact) | The updated external contact. Fields are values after updating. | Yes | + #### ExternalDatasetCreatePayload Validated fields required to create an external dataset binding. @@ -25088,6 +26167,13 @@ Flask blueprint initialization. | api_key | string | | No | | endpoint | string | | No | +#### ExternalRecipient + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| email | string | | Yes | +| type | string,
**Default:** external | | No | + #### FeatureModel Effective feature availability and limits for the current workspace. @@ -25104,6 +26190,7 @@ Effective feature availability and limits for the current workspace. | enable_knowledge_fs | boolean | | Yes | | enable_skill | boolean,
**Default:** true | | Yes | | human_input_email_delivery_enabled | boolean | | Yes | +| im_centrally_managed | boolean | | Yes | | is_allow_transfer_workspace | boolean,
**Default:** true | | Yes | | knowledge_pipeline | [KnowledgePipeline](#knowledgepipeline) | | Yes | | members | [LimitationModel](#limitationmodel) | | Yes | @@ -25129,6 +26216,36 @@ Effective feature availability and limits for the current workspace. | dislike | integer | | Yes | | like | integer | | Yes | +#### FeishuCredentialsInput + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| app_id | string | Feishu or Lark application identifier. | Yes | +| app_secret | [_RequiredSecret](#_requiredsecret) | Feishu or Lark application secret. | Yes | +| encrypt_key | [_OptionalSecret](#_optionalsecret) | Optional callback encryption key. | No | +| provider | string | Feishu credential discriminator. | Yes | +| verification_token | [_OptionalSecret](#_optionalsecret) | Optional callback verification token. | No | + +#### FeishuCredentialsUpdateInput + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| app_id | [_Identifier](#_identifier)
[UnchangedInput](#unchangedinput) | | Yes | +| app_secret | [_RequiredSecret](#_requiredsecret)
[UnchangedInput](#unchangedinput) | | Yes | +| encrypt_key | [_OptionalSecret](#_optionalsecret)
[UnchangedInput](#unchangedinput) | | Yes | +| provider | string | | Yes | +| verification_token | [_OptionalSecret](#_optionalsecret)
[UnchangedInput](#unchangedinput) | | Yes | + +#### FeishuLarkCredentialsResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| app_id | string | | Yes | +| app_secret | [Redacted](#redacted) | | Yes | +| encrypt_key | [Redacted](#redacted) | | Yes | +| provider | string,
**Available values:** "feishu", "lark" | *Enum:* `"feishu"`, `"lark"` | Yes | +| verification_token | [Redacted](#redacted) | | Yes | + #### FetchFrom Enum class for fetch from. @@ -25161,6 +26278,18 @@ The file extension is blocked for security reasons. | mimetype | string | | Yes | | size | integer | | Yes | +#### FileInput + +Resolved single-file input constraints. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| allowed_file_extensions | [ string ] | | Yes | +| allowed_file_types | [ [FileType](#filetype) ] | | Yes | +| allowed_file_upload_methods | [ [FileTransferMethod](#filetransfermethod) ] | | Yes | +| output_variable_name | string | | Yes | +| type | string | | No | + #### FileInputConfig | Name | Type | Description | Required | @@ -25171,6 +26300,19 @@ The file extension is blocked for security reasons. | output_variable_name | string | | Yes | | type | string | | No | +#### FileListInput + +Resolved multi-file input constraints and effective number limit. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| allowed_file_extensions | [ string ] | | Yes | +| allowed_file_types | [ [FileType](#filetype) ] | | Yes | +| allowed_file_upload_methods | [ [FileTransferMethod](#filetransfermethod) ] | | Yes | +| number_limits | integer | | Yes | +| output_variable_name | string | | Yes | +| type | string | | No | + #### FileListInputConfig | Name | Type | Description | Required | @@ -25319,6 +26461,12 @@ Model class for form option. | show_on | [ [FormShowOnObject](#formshowonobject) ],
**Default:** | | No | | value | string | | Yes | +#### FormPart + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| FormPart | [MarkdownFragment](#markdownfragment)
[ParagraphInput](#paragraphinput)
[SelectInput](#selectinput)
[FileInput](#fileinput)
[FileListInput](#filelistinput) | | | + #### FormShowOnObject Model class for form show on. @@ -25354,6 +26502,23 @@ Enum class for form type. | ---- | ---- | ----------- | -------- | | GeneratorResponse | | | | +#### GetContactResponse + +Response body for one contact resolved in the current workspace scope. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| contact | [HumanInputContact](#humaninputcontact) | Contact resolved as workspace, platform, or external. | Yes | +| im_binding_details | [ [ContactIMBindingDetail](#contactimbindingdetail) ] | Effective IM bindings and their identities in the current workspace, for the contact detail view. | Yes | + +#### GetLatestIMSyncRunResponse + +Response body for reading the latest IM sync run summary. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| run | [IMSyncRun](#imsyncrun) | Latest sync run summary. | Yes | + #### HitTestingChildChunk | Name | Type | Description | Required | @@ -25445,6 +26610,39 @@ Enum class for form type. | tokens | integer | | Yes | | word_count | integer | | Yes | +#### HumanInputContact + +One contact entity returned by contact-related APIs. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| avatar_url | string | URL of the contact's avatar. | No | +| created_at | integer | Timestamp when the contact was created. | Yes | +| email | string | Primary contact email if one exists. | No | +| id | string | Unique contact identifier. | Yes | +| im_bindings | [ [IMBinding](#imbinding) ] | IM bindings that are bound to this contact. Currently, only one IM binding is supported. There is at most one IM binding per IM provider. | No | +| name | string | Display name shown in the contact directory. | Yes | +| type | [HumanInputContactType](#humaninputcontacttype) | Resolved contact type in the current workspace scope. | Yes | + +#### HumanInputContactSummary + +A trimmed version of `HumanInputContact` that only includes the fields needed for workflow orchestration. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| avatar_url | string | URL of the contact's avatar. | No | +| created_at | integer | Timestamp when the contact was created. | Yes | +| id | string | Unique contact identifier. | Yes | +| name | string | Display name shown in the contact directory. | Yes | + +#### HumanInputContactType + +Concrete contact classification resolved in one workspace. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| HumanInputContactType | string | Concrete contact classification resolved in one workspace. | | + #### HumanInputContent | Name | Type | Description | Required | @@ -25472,6 +26670,7 @@ Enum class for form type. | form_content | string | | Yes | | form_id | string | | Yes | | form_token | string | | No | +| form_version | string,
**Available values:** "1", "2",
**Default:** 1 | *Enum:* `"1"`, `"2"` | No | | inputs | [ [FormInputConfig](#forminputconfig) ] | | No | | node_id | string | | Yes | | node_title | string | | Yes | @@ -25525,6 +26724,8 @@ Enum class for form type. #### HumanInputFormSubmitPayload +Legacy Human Input v1 submit payload shared by existing runtime surfaces. + | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | action | string | ID of the action button the recipient selected. Must match one of the `id` values from the form's `user_actions` list. | Yes | @@ -25544,12 +26745,35 @@ This form has already been submitted by another user, form_id={form_id} | ---- | ---- | ----------- | -------- | | HumanInputFormSubmittedError | | This form has already been submitted by another user, form_id={form_id} | | +#### HumanInputNodeData + +Human Input node data. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| debug_mode | [DebugModeConfig](#debugmodeconfig) | | Yes | +| default_value | [ [DefaultValue](#defaultvalue) ] | | No | +| desc | string | | No | +| error_strategy | [ErrorStrategy](#errorstrategy) | | No | +| form_content | string | | No | +| inputs | [ [FormInputConfig](#forminputconfig) ] | | No | +| message_template | [MessageTemplateConfig](#messagetemplateconfig) | | Yes | +| recipients_spec | [ [Contact](#contact)
[DynamicEmail](#dynamicemail)
[OnetimeEmail](#onetimeemail)
[Initiator](#initiator)
[AllWorkspaceContacts](#allworkspacecontacts) ] | | Yes | +| retry_config | [RetryConfig](#retryconfig) | | No | +| timeout | integer,
**Default:** 36 | | No | +| timeout_unit | [TimeoutUnit_Strict_strict_False_](#timeoutunit_strict_strict_false_) | | No | +| title | string | | No | +| type | string,
**Default:** human-input | | No | +| user_actions | [ [UserActionConfig](#useractionconfig) ] | | No | +| version | string,
**Default:** 2 | | No | + #### HumanInputPauseTypeResponse | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | backstage_input_url | string | | No | | form_id | string | | Yes | +| form_version | string,
**Available values:** "1", "2",
**Default:** 1 | *Enum:* `"1"`, `"2"` | No | | type | string | | Yes | #### I18nObject @@ -25563,6 +26787,248 @@ Model class for i18n object. | pt_BR | string | | No | | zh_Hans | string | | No | +#### IMBinding + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| id | string | Unique IM binding identifier. | Yes | +| provider | [IMProvider](#improvider) | Provider of the IM binding. | Yes | +| scope | [IMBindingScope](#imbindingscope) | Scope of the IM binding. | Yes | + +#### IMBindingScope + +Resolution scope of a contact-to-IM-identity binding. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| IMBindingScope | string | Resolution scope of a contact-to-IM-identity binding. | | + +#### IMChannelCreatePayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| credentials | [IMProviderCredentialsInput](#improvidercredentialsinput) | | Yes | + +#### IMChannelDetailResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| credentials | [IMProviderCredentialsResponse](#improvidercredentialsresponse) | | Yes | +| summary | [ChannelSummary](#channelsummary) | | Yes | + +#### IMChannelMutationResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| summary | [ChannelSummary](#channelsummary) | | Yes | + +#### IMChannelReplacementPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| credentials | [IMProviderCredentialsInput](#improvidercredentialsinput) | | Yes | +| expected_config_version | [ConfigVersion](#configversion) | | Yes | + +#### IMChannelTestPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| credentials | [IMProviderCredentialsInput](#improvidercredentialsinput) | | Yes | + +#### IMChannelUpdatePayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| credentials | [IMProviderCredentialsUpdateInput](#improvidercredentialsupdateinput) | | Yes | +| expected_config_version | [ConfigVersion](#configversion) | | Yes | + +#### IMContactSyncErrorResponse + +Stable expected application failure returned by IM sync and binding routes. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| code | string | Machine-readable application error code. | Yes | +| message | string | Operator-safe error message. | Yes | +| status | integer | HTTP status code associated with this failure. | Yes | + +#### IMDirectoryEntry + +Normalized provider-side account observed during an IM sync run. + +The entry is run-scoped input to identity and binding reconciliation. It does +not represent a stable Dify identity and must not be referenced by bindings +or runtime authorization. Sync results may retain a snapshot for display, +diagnostics, and audit; durable references use IMIdentity or IMBinding IDs. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| display_name | string | | No | +| email | string | | No | +| provider_user_id | string | | Yes | + +#### IMIdentity + +One synced IM identity that may be bound or overridden. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| binding_status | [IMIdentityBindingStatus](#imidentitybindingstatus) | Whether this IM identity is currently bound to a contact. | Yes | +| display_name | string | Display name returned by the provider. | No | +| email | string | Email returned by the provider, if any. | No | +| id | string | Internal IM identity record identifier. | Yes | +| provider | [IMProvider](#improvider) | IM provider that owns this identity. | Yes | +| provider_user_id | string | Provider-side user identifier. | Yes | + +#### IMIdentityBindingStatus + +Whether a synchronized IM identity is currently bound. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| IMIdentityBindingStatus | string | Whether a synchronized IM identity is currently bound. | | + +#### IMIdentitySnapshot + +Last known persistent IM identity state retained by a sync result. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| display_name | string | | No | +| email | string | | No | +| identity_id | string | | Yes | +| provider_user_id | string | | Yes | + +#### IMProvider + +IM provider supported by Human Input contact and delivery flows. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| IMProvider | string | IM provider supported by Human Input contact and delivery flows. | | + +#### IMProviderCredentialsInput + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| IMProviderCredentialsInput | [FeishuCredentialsInput](#feishucredentialsinput)
[LarkCredentialsInput](#larkcredentialsinput)
[SlackCredentialsInput](#slackcredentialsinput)
[DingTalkCredentialsInput](#dingtalkcredentialsinput)
[MSTeamsCredentialsInput](#msteamscredentialsinput)
[WeComCredentialsInput](#wecomcredentialsinput) | | | + +#### IMProviderCredentialsResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| IMProviderCredentialsResponse | [FeishuLarkCredentialsResponse](#feishularkcredentialsresponse)
[SlackCredentialsResponse](#slackcredentialsresponse)
[DingTalkCredentialsResponse](#dingtalkcredentialsresponse)
[MSTeamsCredentialsResponse](#msteamscredentialsresponse)
[WeComCredentialsResponse](#wecomcredentialsresponse) | | | + +#### IMProviderCredentialsUpdateInput + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| IMProviderCredentialsUpdateInput | [FeishuCredentialsUpdateInput](#feishucredentialsupdateinput)
[LarkCredentialsUpdateInput](#larkcredentialsupdateinput)
[SlackCredentialsUpdateInput](#slackcredentialsupdateinput)
[DingTalkCredentialsUpdateInput](#dingtalkcredentialsupdateinput)
[MSTeamsCredentialsUpdateInput](#msteamscredentialsupdateinput)
[WeComCredentialsUpdateInput](#wecomcredentialsupdateinput) | | | + +#### IMSyncRemovalReason + +Stable reason for removing or replacing a current IM binding. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| IMSyncRemovalReason | string | Stable reason for removing or replacing a current IM binding. | | + +#### IMSyncResultAdded + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| contact | [HumanInputContactSummary](#humaninputcontactsummary) | The contact that associated with this sync result. | Yes | +| entry | [IMDirectoryEntry](#imdirectoryentry) | Provider directory entry observed during the current sync run. | Yes | +| type | string,
**Default:** added | | No | + +#### IMSyncResultFailed + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| entry | [IMDirectoryEntry](#imdirectoryentry) | Provider directory entry observed before this reconciliation failure, if available. | No | +| reason | string | Reason the binding failed to sync. | Yes | +| type | string,
**Default:** failed | | No | + +#### IMSyncResultItem + +One paginated reconciliation result entry for the latest sync run. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| id | string | Unique synchronization result identifier. | Yes | +| result | [IMSyncResultAdded](#imsyncresultadded)
[IMSyncResultRemoved](#imsyncresultremoved)
[IMSyncResultFailed](#imsyncresultfailed)
[IMSyncResultNotMatched](#imsyncresultnotmatched)
[IMSyncResultSkipped](#imsyncresultskipped) | Result bucket this entry belongs to. | Yes | + +#### IMSyncResultNotMatched + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| entry | [IMDirectoryEntry](#imdirectoryentry) | Provider directory entry that could not be matched, if available. | No | +| type | string,
**Default:** not_matched | | No | + +#### IMSyncResultRemoved + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| contact | [HumanInputContactSummary](#humaninputcontactsummary) | The contact that associated with this sync result. | Yes | +| last_known_identity | [IMIdentitySnapshot](#imidentitysnapshot) | Last known persistent IM identity state before its binding was removed. | Yes | +| reason | [IMSyncRemovalReason](#imsyncremovalreason) | Reason the existing IM binding was removed. | Yes | +| type | string,
**Default:** removed | | No | + +#### IMSyncResultSkipped + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| contact | [HumanInputContactSummary](#humaninputcontactsummary) | The contact that associated with this sync result. | Yes | +| entry | [IMDirectoryEntry](#imdirectoryentry) | Provider directory entry observed before reconciliation was skipped, if available. | No | +| type | string,
**Default:** skipped | | No | + +#### IMSyncResultType + +Stable reconciliation bucket for one synchronized directory entry. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| IMSyncResultType | string | Stable reconciliation bucket for one synchronized directory entry. | | + +#### IMSyncRun + +One IM sync run snapshot. + +The latest-only UI displays ``finished_at`` as the explicit sync time. The +transport contract intentionally does not expose a ``started_by`` actor. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| channel_config_version | integer | Channel configuration revision captured when the sync run was created. | Yes | +| channel_id | string | Channel identifier captured when the sync run was created. | Yes | +| error_message | string | Terminal error message. Present only when the sync run status is `failed`. | No | +| finished_at | integer | Unix timestamp in seconds when the sync run finished. This is the sync time displayed by the latest-only UI and is None while the run is unfinished. | No | +| id | string | Unique sync run identifier. | Yes | +| provider | [IMProvider](#improvider) | IM provider associated with the sync run. | Yes | +| result_counts | [IMSyncRunResultCounts](#imsyncrunresultcounts) | Aggregate reconciliation counts for the current run snapshot. | Yes | +| started_at | integer | Unix timestamp in seconds when the sync run started. | No | +| status | [IMSyncRunStatus](#imsyncrunstatus) | Current lifecycle state of the sync run. | Yes | + +#### IMSyncRunResultCounts + +Aggregate result counts for one IM sync run. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| added | integer | Number of entries newly matched and bound. | Yes | +| failed | integer | Number of entries that failed to reconcile. | Yes | +| not_matched | integer | Number of entries that could not be matched. | Yes | +| removed | integer | Number of entries whose prior binding was removed. | Yes | +| skipped | integer | Number of entries intentionally skipped. | Yes | + +#### IMSyncRunStatus + +Lifecycle state of a manual IM directory synchronization. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| IMSyncRunStatus | string | Lifecycle state of a manual IM directory synchronization. | | + #### IconInfo Icon information model. @@ -25667,6 +27133,12 @@ Knowledge indexing estimate failed: {message} | notion_info_list | [ [NotionInfo](#notioninfo) ] | | No | | website_info_list | [WebsiteInfo](#websiteinfo) | | No | +#### Initiator + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| type | string,
**Default:** initiator | | No | + #### Inner | Name | Type | Description | Required | @@ -25762,6 +27234,49 @@ The app is not available in this app library. | limit | integer,
**Default:** 20 | Number of installed apps to return | No | | name | string | App name to search for | No | +#### InstantMessageChannelRecipient + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| channel_id | string | | Yes | +| type | string,
**Default:** channel | | No | + +#### InstantMessageDeliveryConfig + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| message | string | | No | +| provider | [InstantMessageProvider](#instantmessageprovider) | | Yes | +| recipients | [InstantMessageRecipients](#instantmessagerecipients) | | No | + +#### InstantMessageDeliveryMethod + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| config | [InstantMessageDeliveryConfig](#instantmessagedeliveryconfig) | | Yes | +| enabled | boolean,
**Default:** true | | No | +| id | string (uuid) | | No | +| type | string,
**Default:** im | | No | + +#### InstantMessageProvider + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| InstantMessageProvider | string | | | + +#### InstantMessageRecipients + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| items | [ [InstantMessageChannelRecipient](#instantmessagechannelrecipient)
[InstantMessageUserRecipient](#instantmessageuserrecipient) ] | | No | + +#### InstantMessageUserRecipient + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| type | string,
**Default:** user | | No | +| user_id | string | | Yes | + #### InstructionGeneratePayload | Name | Type | Description | Required | @@ -25780,6 +27295,15 @@ The app is not available in this app library. | ---- | ---- | ----------- | -------- | | type | string | Instruction template type | Yes | +#### InteractiveSurfaceDeliveryMethod + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| config | [_InteractiveSurfaceDeliveryConfig](#_interactivesurfacedeliveryconfig) | | No | +| enabled | boolean,
**Default:** true | | No | +| id | string (uuid) | | No | +| type | string,
**Default:** webapp | | No | + #### InvalidAccountDeletionCodeError Invalid account deletion code. @@ -25834,6 +27358,12 @@ The email address is not valid. | ---- | ---- | ----------- | -------- | | InvalidFormDataError | | | | +#### InvalidInputError + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| InvalidInputError | | | | + #### InvalidInvitationCodeError Invalid invitation code. @@ -28782,6 +30312,26 @@ Enum class for large language model mode. | ---- | ---- | ----------- | -------- | | LLMMode | string | Enum class for large language model mode. | | +#### LarkCredentialsInput + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| app_id | string | Feishu or Lark application identifier. | Yes | +| app_secret | [_RequiredSecret](#_requiredsecret) | Feishu or Lark application secret. | Yes | +| encrypt_key | [_OptionalSecret](#_optionalsecret) | Optional callback encryption key. | No | +| provider | string | Lark credential discriminator. | Yes | +| verification_token | [_OptionalSecret](#_optionalsecret) | Optional callback verification token. | No | + +#### LarkCredentialsUpdateInput + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| app_id | [_Identifier](#_identifier)
[UnchangedInput](#unchangedinput) | | Yes | +| app_secret | [_RequiredSecret](#_requiredsecret)
[UnchangedInput](#unchangedinput) | | Yes | +| encrypt_key | [_OptionalSecret](#_optionalsecret)
[UnchangedInput](#unchangedinput) | | Yes | +| provider | string | | Yes | +| verification_token | [_OptionalSecret](#_optionalsecret)
[UnchangedInput](#unchangedinput) | | Yes | + #### LatestPluginCache | Name | Type | Description | Required | @@ -28807,6 +30357,24 @@ Enum class for large language model mode. | name | string | | Yes | | settings | object | | Yes | +#### LegacyHITLv1NodeData + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| default_value | [ [DefaultValue](#defaultvalue) ] | | No | +| delivery_methods | [ [InteractiveSurfaceDeliveryMethod](#interactivesurfacedeliverymethod)
[EmailDeliveryMethod](#emaildeliverymethod)
[InstantMessageDeliveryMethod](#instantmessagedeliverymethod) ] | | No | +| desc | string | | No | +| error_strategy | [ErrorStrategy](#errorstrategy) | | No | +| form_content | string | | No | +| inputs | [ [FormInputConfig](#forminputconfig) ] | | No | +| retry_config | [RetryConfig](#retryconfig) | | No | +| timeout | integer,
**Default:** 36 | | No | +| timeout_unit | [TimeoutUnit](#timeoutunit) | | No | +| title | string | | No | +| type | [NodeType](#nodetype) | | No | +| user_actions | [ [UserActionConfig](#useractionconfig) ] | | No | +| version | string,
**Default:** 1 | | No | + #### LicenseLimitationModel - enabled: whether this limit is enforced @@ -28856,6 +30424,94 @@ Unable to create account because the licensed seats limit was exceeded | limit | integer | | Yes | | size | integer | | Yes | +#### ListChannelProvidersResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| email_providers | [ [ChannelProvider](#channelprovider) ] | | Yes | +| im_providers | [ [ChannelProvider](#channelprovider) ] | | Yes | + +#### ListChannelsResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| channels | [ [ChannelSummary](#channelsummary) ] | | Yes | + +#### ListContactOptionsResponse + +Paginated editor-safe contact picker response. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| data | [ [ContactOption](#contactoption) ] | Selectable contacts returned for the current page. | Yes | +| limit | integer | Page size used for the current query. | Yes | +| page | integer | Current 1-based page number. | Yes | +| total | integer | Total number of candidates matching the current query. | Yes | + +#### ListContactsResponse + +Paginated response body for contact list APIs. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| data | [ [HumanInputContact](#humaninputcontact) ] | Contacts returned for the current page. | Yes | +| limit | integer | Page size used for the current query. | Yes | +| page | integer | Current 1-based page number. | Yes | +| total | integer | Total number of candidates matching the current query. | Yes | + +#### ListIMIdentitiesQuery + +Query params for searching synced IM identities. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| keyword | string | Free-text search against identity display name, email, or provider user ID. | No | +| limit | integer,
**Default:** 20 | Maximum number of records returned per page. | No | +| page | integer,
**Default:** 1 | 1-based page number. | No | + +#### ListIMIdentitiesResponse + +Paginated response body for synced IM identity search. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| data | [ [IMIdentity](#imidentity) ] | IM identities returned for the current page. | Yes | +| limit | integer | Page size used for the current query. | Yes | +| page | integer | Current 1-based page number. | Yes | +| total | integer | Total number of candidates matching the current query. | Yes | + +#### ListLatestIMSyncRunResultsQuery + +Query params for reading paginated latest-run results. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| limit | integer,
**Default:** 20 | Maximum number of records returned per page. | No | +| page | integer,
**Default:** 1 | 1-based page number. | No | +| result | [IMSyncResultType](#imsyncresulttype) | Required result bucket to paginate from the latest sync run. There is no `all` bucket or unfiltered results mode. | Yes | + +#### ListLatestIMSyncRunResultsResponse + +Page-based latest-run results without cursor state or a repeated run summary. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| data | [ [IMSyncResultItem](#imsyncresultitem) ] | Result entries returned with page, limit, and total metadata for the selected bucket. | Yes | +| limit | integer | Page size used for the current query. | Yes | +| page | integer | Current 1-based page number. | Yes | +| total | integer | Total number of candidates matching the current query. | Yes | + +#### ListOrganizationCandidatesResponse + +Paginated response body for organization candidate search. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| data | [ [OrganizationCandidate](#organizationcandidate) ] | Organization member candidates returned for the current page. | Yes | +| limit | integer | Page size used for the current query. | Yes | +| page | integer | Current 1-based page number. | Yes | +| total | integer | Total number of candidates matching the current query. | Yes | + #### LoadBalancingCredentialPayload | Name | Type | Description | Required | @@ -28981,6 +30637,42 @@ Unable to create account because the licensed seats limit was exceeded | parameters | object | Server parameters configuration | Yes | | status | [AppMCPServerStatus](#appmcpserverstatus) | Server status | No | +#### MSTeamsCredentialsInput + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| client_id | string | Microsoft Teams application client identifier. | Yes | +| client_secret | [_RequiredSecret](#_requiredsecret) | Microsoft Teams application client secret. | Yes | +| provider | string | Microsoft Teams credential discriminator. | Yes | +| tenant_id | string | Microsoft Entra tenant identifier. | Yes | + +#### MSTeamsCredentialsResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| client_id | string | | Yes | +| client_secret | [Redacted](#redacted) | | Yes | +| provider | string | | Yes | +| tenant_id | string | | Yes | + +#### MSTeamsCredentialsUpdateInput + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| client_id | [_UUIDString](#_uuidstring)
[UnchangedInput](#unchangedinput) | | Yes | +| client_secret | [_RequiredSecret](#_requiredsecret)
[UnchangedInput](#unchangedinput) | | Yes | +| provider | string | | Yes | +| tenant_id | [_UUIDString](#_uuidstring)
[UnchangedInput](#unchangedinput) | | Yes | + +#### MarkdownFragment + +One source-ordered Markdown fragment in a resolved form. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| text | string | | Yes | +| type | string | | No | + #### MaxKeysExceededError The API key limit has been reached. @@ -29213,6 +30905,29 @@ The message was not found for this account and app. | ---- | ---- | ----------- | -------- | | MessageNotFoundError | | The message was not found for this account and app. | | +#### MessageTemplateConfig + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| body | string | | Yes | +| subject | string | | Yes | + +#### MessageTemplateTestRequest + +Request body for sending one message-template test notification. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| channel | [DebugChannel](#debugchannel) | Target debug delivery channel used for the test send. | Yes | +| inputs | object | Variable values used when rendering the message template preview. | No | + +#### MessageTemplateTestResponse + +Response body returned after one message-template test send. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | + #### Meta | Name | Type | Description | Required | @@ -29635,6 +31350,63 @@ Please upload your file. | ---- | ---- | ----------- | -------- | | enabled | boolean | | No | +#### NodeDataMigrationBlocker + +Stable node-scoped reason why the backend cannot produce lossless v2 data. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| code | string,
**Available values:** "configured-disabled-method", "conflicting-email-templates", "invalid-default-value", "invalid-email", "invalid-email-configuration", "missing-recipients", "unresolved-member", "unsupported-delivery-method", "unsupported-version" | Machine-readable migration blocker code.
*Enum:* `"configured-disabled-method"`, `"conflicting-email-templates"`, `"invalid-default-value"`, `"invalid-email"`, `"invalid-email-configuration"`, `"missing-recipients"`, `"unresolved-member"`, `"unsupported-delivery-method"`, `"unsupported-version"` | Yes | +| method_id | string | Legacy delivery method related to the blocker. | No | +| node_id | string | The identifier of the node that failed migration. | Yes | +| node_title | string | The node title used for actionable frontend feedback. | Yes | +| value | string | Safe legacy value related to the blocker. | No | + +#### NodeDataMigrationFailureResponse + +Whole-batch failure response without partial converted node data. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| blockers | [ [NodeDataMigrationBlocker](#nodedatamigrationblocker) ] | Node-scoped blockers that caused the whole batch to fail. | Yes | +| code | string,
**Default:** hitl_node_data_migration_failure | | No | +| message | string | overall error messages | Yes | +| status | integer,
**Default:** 400 | | No | + +#### NodeDataMigrationInput + +One legacy node submitted through the frontend migration adapter boundary. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| node_data | [LegacyHITLv1NodeData](#legacyhitlv1nodedata) | The legacy Human Input node data to migrate. | Yes | +| node_id | string | The identifier of node to migrate. Used to associate between request and response | Yes | + +#### NodeDataMigrationPayload + +Complete legacy-node batch submitted for one migration attempt. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| nodes | [ [NodeDataMigrationInput](#nodedatamigrationinput) ] | | Yes | + +#### NodeDataMigrationResponse + +Successful all-node conversion response. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| data | [ [NodeDataMigrationResult](#nodedatamigrationresult) ] | | Yes | + +#### NodeDataMigrationResult + +One converted node returned with its frontend correlation identifier. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| node_data | [HumanInputNodeData](#humaninputnodedata) | The complete converted Human Input v2 node data. | Yes | +| node_id | string | The identifier of the migrated node. | Yes | + #### NodeIdQuery | Name | Type | Description | Required | @@ -29709,6 +31481,12 @@ Coarse node-level status used by Inspector to pick a banner. | ---- | ---- | ----------- | -------- | | NodeStatus | string | Coarse node-level status used by Inspector to pick a banner. | | +#### NodeType + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| NodeType | string | | | + #### NormalizedEmailAlreadyInUseError An account with an equivalent email address already exists. @@ -29985,6 +31763,13 @@ OAuth schema | redirect_uri | string | | No | | refresh_token | string | | No | +#### OnetimeEmail + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| email | string | | Yes | +| type | string,
**Default:** onetime_email | | No | + #### Option | Name | Type | Description | Required | @@ -29992,6 +31777,27 @@ OAuth schema | label | [I18nObject](#i18nobject) | The label of the option | Yes | | value | string | The value of the option | Yes | +#### OrganizationCandidate + +One organization member candidate that may become a platform contact. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| avatar_url | string | Signed avatar URL if one is available. | No | +| email | string | Primary organization email used for matching. | Yes | +| id | string | Organization candidate identifier. | Yes | +| name | string | Display name shown in the candidate list. | Yes | + +#### OrganizationCandidatesQuery + +Query params for searching organization member candidates. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| keyword | string | Free-text search against candidate name or email. | No | +| limit | integer,
**Default:** 20 | Maximum number of records returned per page. | No | +| page | integer,
**Default:** 1 | 1-based page number. | No | + #### OutputErrorStrategy Per-output failure handling strategy. @@ -30075,6 +31881,16 @@ Too many owner transfer emails have been sent. Please try again in {minutes} min | limit | integer,
**Default:** 20 | | No | | page | integer,
**Default:** 1 | | No | +#### ParagraphInput + +Resolved multiline text input. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| default_value | string | | Yes | +| output_variable_name | string | | Yes | +| type | string | | No | + #### ParagraphInputConfig Form input definition. @@ -31730,6 +33546,14 @@ Recommended app not found. | ---- | ---- | ----------- | -------- | | RecursiveJSONValue | string
integer
number
boolean
object
[ [RecursiveJSONValue](#recursivejsonvalue) ] | | | +#### Redacted + +A configured credential whose stored value is omitted. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| type | string,
**Default:** redacted | | No | + #### RedirectResponse | Name | Type | Description | Required | @@ -31823,6 +33647,22 @@ The remote file URL is not allowed. | ---- | ---- | ----------- | -------- | | RemoteFileUrlBlockedError | | The remote file URL is not allowed. | | +#### RemoveContactsRequest + +Request body for batch-removing platform or external contacts. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| contact_ids | [ string ] | Contact identifiers selected for removal from the contact directory surface. | Yes | + +#### RemoveContactsResponse + +Response body returned after batch-removing contacts. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| removed_contact_ids | [ string ] | Contact identifiers removed by the current operation. | Yes | + #### ReplaceUserAccessPolicies | Name | Type | Description | Required | @@ -31859,6 +33699,46 @@ The remote file URL is not allowed. | reranking_model_name | string | Name of the reranking model. | No | | reranking_provider_name | string | Provider name of the reranking model. | No | +#### ResendCredentials + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| api_key | [_RequiredSecret](#_requiredsecret) | Resend API key. | Yes | +| provider | string | | No | + +#### ResendCredentialsResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| api_key | [Redacted](#redacted) | | Yes | +| provider | string | | Yes | + +#### ResendCredentialsUpdateInput + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| api_key | [_RequiredSecret](#_requiredsecret)
[UnchangedInput](#unchangedinput) | | Yes | +| provider | string | | Yes | + +#### ResetContactIMOverrideResponse + +Response body returned after resetting one contact IM override. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| contact | [HumanInputContact](#humaninputcontact) | Contact snapshot after the override is cleared. | Yes | + +#### ResolvedForm + +Immutable authoritative presentation snapshot for one v2 form. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| actions | [ [UserAction](#useraction) ] | | Yes | +| legacy_form_content | string | | Yes | +| parts | [ [FormPart](#formpart) ] | | Yes | +| title | string | | Yes | + #### ResourceAccessTokenCreatePayload | Name | Type | Description | Required | @@ -32067,6 +33947,16 @@ The remote file URL is not allowed. | title | string | | No | | word_count | integer | | No | +#### RetryConfig + +node retry config + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| max_retries | integer | | No | +| retry_enabled | boolean | | No | +| retry_interval | integer | | No | + #### RoleAlreadyAssignedError Role already assigned. @@ -32358,6 +34248,17 @@ The Roster Agent package exceeds the size limit. | separator | string,
**Default:** | Custom separator for splitting text. | No | +#### SelectInput + +Resolved single-choice input with immutable concrete options. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| default_value | string | | Yes | +| options | [ string ] | | Yes | +| output_variable_name | string | | Yes | +| type | string | | No | + #### SelectInputConfig | Name | Type | Description | Required | @@ -32366,6 +34267,22 @@ The Roster Agent package exceeds the size limit. | output_variable_name | string | | Yes | | type | string | | No | +#### SetContactIMOverrideRequest + +Request body for setting one workspace-scoped IM override. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| identity_id | string | Synced IM identity identifier selected as the workspace override. | Yes | + +#### SetContactIMOverrideResponse + +Response body returned after setting one contact IM override. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| contact | [HumanInputContact](#humaninputcontact) | Contact snapshot after the override is applied. | Yes | + #### ShallowJSONValue | Name | Type | Description | Required | @@ -32872,6 +34789,39 @@ How a draft file's content is stored. | publish_note | string | | No | | version_name | string | | No | +#### SlackCredentialsInput + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| app_token | [_OptionalSecret](#_optionalsecret) | Optional Slack app-level token required only for Socket Mode. | No | +| bot_token | [_RequiredSecret](#_requiredsecret) | Slack bot API token. | Yes | +| client_id | string | Slack OAuth client identifier. | Yes | +| client_secret | [_RequiredSecret](#_requiredsecret) | Slack OAuth client secret. | Yes | +| provider | string | Slack credential discriminator. | Yes | +| signing_secret | [_RequiredSecret](#_requiredsecret) | Slack callback signing secret. | Yes | + +#### SlackCredentialsResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| app_token | [Redacted](#redacted) | | Yes | +| bot_token | [Redacted](#redacted) | | Yes | +| client_id | string | | Yes | +| client_secret | [Redacted](#redacted) | | Yes | +| provider | string | | Yes | +| signing_secret | [Redacted](#redacted) | | Yes | + +#### SlackCredentialsUpdateInput + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| app_token | [_OptionalSecret](#_optionalsecret)
[UnchangedInput](#unchangedinput) | | Yes | +| bot_token | [_RequiredSecret](#_requiredsecret)
[UnchangedInput](#unchangedinput) | | Yes | +| client_id | [_Identifier](#_identifier)
[UnchangedInput](#unchangedinput) | | Yes | +| client_secret | [_RequiredSecret](#_requiredsecret)
[UnchangedInput](#unchangedinput) | | Yes | +| provider | string | | Yes | +| signing_secret | [_RequiredSecret](#_requiredsecret)
[UnchangedInput](#unchangedinput) | | Yes | + #### SnippetDependencyCheckResponse | Name | Type | Description | Required | @@ -33555,6 +35505,22 @@ Available voices | name | string | Voice display name | Yes | | value | string | Voice identifier | Yes | +#### TimeoutUnit + +Timeout unit for form expiration. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| TimeoutUnit | string | Timeout unit for form expiration. | | + +#### TimeoutUnit_Strict_strict_False_ + +Timeout unit for form expiration. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| TimeoutUnit_Strict_strict_False_ | string | Timeout unit for form expiration. | | + #### TokensPerSecondStatisticItem | Name | Type | Description | Required | @@ -34405,6 +36371,14 @@ Model class for provider unadded model configuration. | model | string | | Yes | | model_type | [ModelType](#modeltype) | | Yes | +#### UnchangedInput + +Retain the addressed saved field; valid only for updates and saved tests. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| type | string | | Yes | + #### UnsupportedAudioTypeError Audio type not allowed. @@ -34510,6 +36484,16 @@ Payload for updating a snippet. | count | integer | | Yes | | is_using | boolean | | Yes | +#### UserAction + +Resolved action values required for presentation and submission validation. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| button_style | [ButtonStyle](#buttonstyle) | | Yes | +| id | string | | Yes | +| title | string | | Yes | + #### UserActionConfig User action configuration. @@ -34565,6 +36549,33 @@ in form definition, or a variable while the workflow is running. | is_valid | boolean | | Yes | | token | string | | Yes | +#### WeComCredentialsInput + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| agent_id | string | WeCom application agent identifier. | Yes | +| corp_id | string | WeCom corporation identifier. | Yes | +| provider | string | WeCom credential discriminator. | Yes | +| secret | [_RequiredSecret](#_requiredsecret) | WeCom application secret. | Yes | + +#### WeComCredentialsResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| agent_id | string | | Yes | +| corp_id | string | | Yes | +| provider | string | | Yes | +| secret | [Redacted](#redacted) | | Yes | + +#### WeComCredentialsUpdateInput + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| agent_id | string
[UnchangedInput](#unchangedinput) | | Yes | +| corp_id | [_Identifier](#_identifier)
[UnchangedInput](#unchangedinput) | | Yes | +| provider | string | | Yes | +| secret | [_RequiredSecret](#_requiredsecret)
[UnchangedInput](#unchangedinput) | | Yes | + #### WebAppAccessMode | Name | Type | Description | Required | @@ -35336,6 +37347,12 @@ tenant's default model. The underlying generator never raises — an empty | ---- | ---- | ----------- | -------- | | WorkflowNodeJobMode | string | | | +#### WorkflowNotActiveError + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| WorkflowNotActiveError | | | | + #### WorkflowOnlineUser | Name | Type | Description | Required | @@ -36007,6 +38024,17 @@ Workflow tool configuration | ---- | ---- | ----------- | -------- | | account_ids | [ string ] | | No | +#### _Identifier + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| _Identifier | string | | | + +#### _InteractiveSurfaceDeliveryConfig + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | + #### _MembersInRoleList | Name | Type | Description | Required | @@ -36014,6 +38042,12 @@ Workflow tool configuration | data | [ [MembersInRole](#membersinrole) ] | | No | | pagination | [Pagination](#pagination) | | No | +#### _OptionalSecret + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| _OptionalSecret | string | | | + #### _RBACRoleList | Name | Type | Description | Required | @@ -36034,6 +38068,12 @@ Workflow tool configuration | ---- | ---- | ----------- | -------- | | role_ids | [ string ],
**Default:** | | No | +#### _RequiredSecret + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| _RequiredSecret | string | | | + #### _ResourceAccessScopeRequest | Name | Type | Description | Required | @@ -36059,6 +38099,18 @@ Accepts the payload sent by the Create/Edit Role dialog. | name | string | | Yes | | permission_keys | [ string ],
**Default:** | | No | +#### _SenderName + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| _SenderName | string | | | + +#### _UUIDString + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| _UUIDString | string | | | + ## FastOpenAPI Preview (OpenAPI 3.1) ### Dify API (FastOpenAPI PoC) diff --git a/api/openapi/markdown/service-openapi.md b/api/openapi/markdown/service-openapi.md index 6c0f1fc4dc3..0962c702afd 100644 --- a/api/openapi/markdown/service-openapi.md +++ b/api/openapi/markdown/service-openapi.md @@ -5511,6 +5511,7 @@ You don't have the permission to access the requested resource. | form_content | string | | Yes | | form_id | string | | Yes | | form_token | string | | No | +| form_version | string,
**Available values:** "1", "2",
**Default:** 1 | *Enum:* `"1"`, `"2"` | No | | inputs | [ [FormInputConfig](#forminputconfig) ] | | No | | node_id | string | | Yes | | node_title | string | | Yes | @@ -5545,6 +5546,8 @@ You don't have the permission to access the requested resource. #### HumanInputFormSubmitPayload +Legacy Human Input v1 submit payload shared by existing runtime surfaces. + | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | action | string | ID of the action button the recipient selected. Must match one of the `id` values from the form's `user_actions` list. | Yes | @@ -5552,6 +5555,8 @@ You don't have the permission to access the requested resource. #### HumanInputFormSubmitPayloadWithUser +Legacy Human Input v1 submit payload shared by existing runtime surfaces. + | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | action | string | ID of the action button the recipient selected. Must match one of the `id` values from the form's `user_actions` list. | Yes | diff --git a/api/openapi/markdown/web-openapi.md b/api/openapi/markdown/web-openapi.md index 7a308a8d7b4..3926fc2d228 100644 --- a/api/openapi/markdown/web-openapi.md +++ b/api/openapi/markdown/web-openapi.md @@ -355,6 +355,135 @@ Verify password reset token validity | 429 | Too Many Requests | **application/json**: [EmailPasswordResetLimitError](#emailpasswordresetlimiterror)
[ErrorResponse](#errorresponse)
| | default | Error | **application/json**: [ErrorResponse](#errorresponse)
| +### [GET] /form/human-input/v2/public/{form_token} +Read a public v2 form definition by delivery token without an OTP or login session. + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| form_token | path | | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Form definition | **application/json**: [FormDefinitionResponse](#formdefinitionresponse)
| +| 404 | Form not found | **application/json**: [HumanInputFormNotFoundError](#humaninputformnotfounderror)
[ErrorResponse](#errorresponse)
| +| 412 | Form already submitted or expired | **application/json**: [HumanInputFormExpiredError](#humaninputformexpirederror)
[HumanInputFormSubmittedError](#humaninputformsubmittederror)
[ErrorResponse](#errorresponse)
| +| default | Error | **application/json**: [ErrorResponse](#errorresponse)
| + +### [POST] /form/human-input/v2/public/{form_token} +Submit a form with inputs, an action, and email verification proof. + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| form_token | path | | Yes | string | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [HumanInputV2FormSubmitRequest](#humaninputv2formsubmitrequest)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Form submitted | **application/json**: [FormSubmitResponse](#formsubmitresponse)
| +| 400 | Invalid form or email proof | **application/json**: [HumanInputChallengeExpiredError](#humaninputchallengeexpirederror)
[HumanInputChallengeStaleError](#humaninputchallengestaleerror)
[HumanInputInvalidOtpError](#humaninputinvalidotperror)
[InvalidInputError](#invalidinputerror)
[ErrorResponse](#errorresponse)
| +| 404 | Form not found | **application/json**: [HumanInputFormNotFoundError](#humaninputformnotfounderror)
[ErrorResponse](#errorresponse)
| +| 409 | Workflow not active | **application/json**: [WorkflowNotActiveError](#workflownotactiveerror)
[ErrorResponse](#errorresponse)
| +| 412 | Already submitted or expired | **application/json**: [HumanInputFormExpiredError](#humaninputformexpirederror)
[HumanInputFormSubmittedError](#humaninputformsubmittederror)
[ErrorResponse](#errorresponse)
| +| 422 | Invalid request body | **application/json**: [ErrorResponse](#errorresponse)
| +| 503 | Email verification unavailable | **application/json**: [HumanInputV2UnavailableError](#humaninputv2unavailableerror)
[ErrorResponse](#errorresponse)
| +| default | Error | **application/json**: [ErrorResponse](#errorresponse)
| + +### [POST] /form/human-input/v2/public/{form_token}/access-request +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| form_token | path | | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [FormAccessRequestResponse](#formaccessrequestresponse)
| +| 404 | Not Found | **application/json**: [HumanInputFormNotFoundError](#humaninputformnotfounderror)
[ErrorResponse](#errorresponse)
| +| 412 | Precondition Failed | **application/json**: [HumanInputFormExpiredError](#humaninputformexpirederror)
[HumanInputFormSubmittedError](#humaninputformsubmittederror)
[ErrorResponse](#errorresponse)
| +| 429 | Too Many Requests | **application/json**: [HumanInputAccessRateLimitExceededError](#humaninputaccessratelimitexceedederror)
[ErrorResponse](#errorresponse)
| +| 503 | Service Unavailable | **application/json**: [HumanInputAccessDeliveryFailedError](#humaninputaccessdeliveryfailederror)
[HumanInputV2UnavailableError](#humaninputv2unavailableerror)
[ErrorResponse](#errorresponse)
| +| default | Error | **application/json**: [ErrorResponse](#errorresponse)
| + +### [POST] /form/human-input/v2/public/{form_token}/upload-token +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| form_token | path | | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [FormUploadTokenResponse](#formuploadtokenresponse)
| +| 403 | Forbidden | **application/json**: [InvalidUploadTokenError](#invaliduploadtokenerror)
[ErrorResponse](#errorresponse)
| +| 404 | Form not found | **application/json**: [HumanInputFormNotFoundError](#humaninputformnotfounderror)
[ErrorResponse](#errorresponse)
| +| 412 | Already submitted or expired | **application/json**: [HumanInputFormExpiredError](#humaninputformexpirederror)
[HumanInputFormSubmittedError](#humaninputformsubmittederror)
[ErrorResponse](#errorresponse)
| +| default | Error | **application/json**: [ErrorResponse](#errorresponse)
| + +### [GET] /form/human-input/v2/{form_token} +Read a v2 form using the original EndUser's Web App passport. + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| form_token | path | | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Form retrieved | **application/json**: [WebAppHumanInputV2FormDefinitionResponse](#webapphumaninputv2formdefinitionresponse)
| +| 401 | Invalid passport | **application/json**: [ErrorResponse](#errorresponse)
| +| 404 | Form not found | **application/json**: [ErrorResponse](#errorresponse)
| +| 409 | Already submitted | **application/json**: [AlreadySubmittedError](#alreadysubmittederror)
[ErrorResponse](#errorresponse)
| +| 410 | Expired | **application/json**: [ExpiredError](#expirederror)
[ErrorResponse](#errorresponse)
| +| default | Error | **application/json**: [ErrorResponse](#errorresponse)
| + +### [POST] /form/human-input/v2/{form_token} +Submit a v2 form using the original EndUser's Web App passport. + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| form_token | path | | Yes | string | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [WebAppHumanInputV2SubmitPayload](#webapphumaninputv2submitpayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Approval accepted | **application/json**: [WebAppHumanInputV2SubmitResponse](#webapphumaninputv2submitresponse)
| +| 400 | Invalid inputs | **application/json**: [InvalidInputError](#invalidinputerror)
[ErrorResponse](#errorresponse)
| +| 401 | Invalid passport | **application/json**: [ErrorResponse](#errorresponse)
| +| 404 | Form not found | **application/json**: [ErrorResponse](#errorresponse)
| +| 409 | Already submitted or workflow not active | **application/json**: [AlreadySubmittedError](#alreadysubmittederror)
[WorkflowNotActiveError](#workflownotactiveerror)
[ErrorResponse](#errorresponse)
| +| 410 | Expired | **application/json**: [ExpiredError](#expirederror)
[ErrorResponse](#errorresponse)
| +| 422 | Invalid request body | **application/json**: [ErrorResponse](#errorresponse)
| +| default | Error | **application/json**: [ErrorResponse](#errorresponse)
| + ### [GET] /form/human_input/{form_token} **Get human input form definition by token** @@ -455,6 +584,66 @@ POST /api/form/human_input//upload-token | 502 | Bad Gateway | **application/json**: [RemoteFileInvalidResponseError](#remotefileinvalidresponseerror)
[RemoteFileUnavailableError](#remotefileunavailableerror)
[ErrorResponse](#errorresponse)
| | default | Error | **application/json**: [ErrorResponse](#errorresponse)
| +### [POST] /human-input/v2/files +Upload a file or a remote URL using a v2 upload grant. + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| Authorization | header | Bearer v2 upload grant | Yes | string | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| No | **multipart/form-data**: { **"file"**: binary, **"url"**: string }
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 201 | File uploaded | **application/json**: [FileWithSignedUrl](#filewithsignedurl)
| +| 400 | Invalid upload | **application/json**: [FileExtensionBlockedError](#fileextensionblockederror)
[FilenameNotExistsError](#filenamenotexistserror)
[MalformedUploadTokenError](#malformeduploadtokenerror)
[NoFileUploadedError](#nofileuploadederror)
[RemoteFileAccessDeniedError](#remotefileaccessdeniederror)
[RemoteFileInvalidUrlError](#remotefileinvalidurlerror)
[RemoteFileUrlBlockedError](#remotefileurlblockederror)
[TooManyFilesError](#toomanyfileserror)
[ErrorResponse](#errorresponse)
| +| 401 | Grant required | **application/json**: [UploadTokenRequiredError](#uploadtokenrequirederror)
[ErrorResponse](#errorresponse)
| +| 403 | Invalid grant | **application/json**: [InvalidUploadTokenError](#invaliduploadtokenerror)
[ErrorResponse](#errorresponse)
| +| 404 | Form not found | **application/json**: [HumanInputFormNotFoundError](#humaninputformnotfounderror)
[RemoteFileNotFoundError](#remotefilenotfounderror)
[ErrorResponse](#errorresponse)
| +| 412 | Form inactive | **application/json**: [HumanInputFormExpiredError](#humaninputformexpirederror)
[HumanInputFormSubmittedError](#humaninputformsubmittederror)
[ErrorResponse](#errorresponse)
| +| 413 | Request Entity Too Large | **application/json**: [FileTooLargeError](#filetoolargeerror)
[ErrorResponse](#errorresponse)
| +| 415 | Unsupported Media Type | **application/json**: [UnsupportedFileTypeError](#unsupportedfiletypeerror)
[ErrorResponse](#errorresponse)
| +| 502 | Bad Gateway | **application/json**: [RemoteFileInvalidResponseError](#remotefileinvalidresponseerror)
[RemoteFileUnavailableError](#remotefileunavailableerror)
[ErrorResponse](#errorresponse)
| +| default | Error | **application/json**: [ErrorResponse](#errorresponse)
| + +### [POST] /human-input/v2/files/remote +Fetch a remote file using a v2 upload grant. + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| Authorization | header | | Yes | string | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [HumanInputV2RemoteUploadPayload](#humaninputv2remoteuploadpayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 201 | File uploaded | **application/json**: [FileWithSignedUrl](#filewithsignedurl)
| +| 400 | Invalid upload | **application/json**: [FileExtensionBlockedError](#fileextensionblockederror)
[FilenameNotExistsError](#filenamenotexistserror)
[MalformedUploadTokenError](#malformeduploadtokenerror)
[NoFileUploadedError](#nofileuploadederror)
[RemoteFileAccessDeniedError](#remotefileaccessdeniederror)
[RemoteFileInvalidUrlError](#remotefileinvalidurlerror)
[RemoteFileUrlBlockedError](#remotefileurlblockederror)
[TooManyFilesError](#toomanyfileserror)
[ErrorResponse](#errorresponse)
| +| 401 | Grant required | **application/json**: [UploadTokenRequiredError](#uploadtokenrequirederror)
[ErrorResponse](#errorresponse)
| +| 403 | Invalid grant | **application/json**: [InvalidUploadTokenError](#invaliduploadtokenerror)
[ErrorResponse](#errorresponse)
| +| 404 | Form not found | **application/json**: [HumanInputFormNotFoundError](#humaninputformnotfounderror)
[RemoteFileNotFoundError](#remotefilenotfounderror)
[ErrorResponse](#errorresponse)
| +| 412 | Form inactive | **application/json**: [HumanInputFormExpiredError](#humaninputformexpirederror)
[HumanInputFormSubmittedError](#humaninputformsubmittederror)
[ErrorResponse](#errorresponse)
| +| 413 | Request Entity Too Large | **application/json**: [FileTooLargeError](#filetoolargeerror)
[ErrorResponse](#errorresponse)
| +| 415 | Unsupported Media Type | **application/json**: [UnsupportedFileTypeError](#unsupportedfiletypeerror)
[ErrorResponse](#errorresponse)
| +| 502 | Bad Gateway | **application/json**: [RemoteFileInvalidResponseError](#remotefileinvalidresponseerror)
[RemoteFileUnavailableError](#remotefileunavailableerror)
[ErrorResponse](#errorresponse)
| +| default | Error | **application/json**: [ErrorResponse](#errorresponse)
| + ### [POST] /login **Authenticate user and login** @@ -1030,6 +1219,12 @@ Agent config version not found. | ---- | ---- | ----------- | -------- | | AgentVersionNotFoundError | | Agent config version not found. | | +#### AlreadySubmittedError + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| AlreadySubmittedError | | | | + #### AnyJSONValue | Name | Type | Description | Required | @@ -1283,6 +1478,12 @@ stays open because ``HTTPException.data`` may carry extra keys. | ---- | ---- | ----------- | -------- | | ExecutionContentType | string | | | +#### ExpiredError + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| ExpiredError | | | | + #### FileExtensionBlockedError The file extension is blocked for security reasons. @@ -1291,6 +1492,18 @@ The file extension is blocked for security reasons. | ---- | ---- | ----------- | -------- | | FileExtensionBlockedError | | The file extension is blocked for security reasons. | | +#### FileInput + +Resolved single-file input constraints. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| allowed_file_extensions | [ string ] | | Yes | +| allowed_file_types | [ [FileType](#filetype) ] | | Yes | +| allowed_file_upload_methods | [ [FileTransferMethod](#filetransfermethod) ] | | Yes | +| output_variable_name | string | | Yes | +| type | string | | No | + #### FileInputConfig | Name | Type | Description | Required | @@ -1301,6 +1514,19 @@ The file extension is blocked for security reasons. | output_variable_name | string | | Yes | | type | string | | No | +#### FileListInput + +Resolved multi-file input constraints and effective number limit. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| allowed_file_extensions | [ string ] | | Yes | +| allowed_file_types | [ [FileType](#filetype) ] | | Yes | +| allowed_file_upload_methods | [ [FileTransferMethod](#filetransfermethod) ] | | Yes | +| number_limits | integer | | Yes | +| output_variable_name | string | | Yes | +| type | string | | No | + #### FileListInputConfig | Name | Type | Description | Required | @@ -1396,18 +1622,86 @@ The specified filename does not exist. | email | string | | Yes | | language | string | | No | +#### FormAccessRequestResponse + +Response body returned after creating one OTP challenge. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| challenge_token | string | The token used to complete the OTP challenge. | Yes | +| expires_in_seconds | integer | Seconds until the current OTP challenge expires. | Yes | +| resend_after_seconds | integer | Seconds until another OTP challenge may be requested. | Yes | + +#### FormDefinitionResponse + +Response body containing a resolved human-input form definition. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| expiration_time | integer | Unix timestamp when the current form expires. | Yes | +| form_content | string | Rendered form body shown to the approver. | No | +| inputs | [ [FormInputConfig](#forminputconfig) ] | Resolved form input definitions. | No | +| resolved_default_values | object | Default values after variable resolution and stringification. | No | +| user_actions | [ [UserActionConfig](#useractionconfig) ] | Action buttons that can complete the form. | No | + #### FormInputConfig | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | FormInputConfig | [ParagraphInputConfig](#paragraphinputconfig)
[SelectInputConfig](#selectinputconfig)
[FileInputConfig](#fileinputconfig)
[FileListInputConfig](#filelistinputconfig) | | | +#### FormPart + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| FormPart | [MarkdownFragment](#markdownfragment)
[ParagraphInput](#paragraphinput)
[SelectInput](#selectinput)
[FileInput](#fileinput)
[FileListInput](#filelistinput) | | | + +#### FormSubmitResponse + +Empty response body returned after a Human Input v2 form submission. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | + +#### FormUploadTokenResponse + +Response body returned when issuing a Human Input v2 upload token. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| expires_at | integer | | Yes | +| upload_token | string | | Yes | + #### GeneratedAppResponse | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | GeneratedAppResponse | | | | +#### HumanInputAccessDeliveryFailedError + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| HumanInputAccessDeliveryFailedError | | | | + +#### HumanInputAccessRateLimitExceededError + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| HumanInputAccessRateLimitExceededError | | | | + +#### HumanInputChallengeExpiredError + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| HumanInputChallengeExpiredError | | | | + +#### HumanInputChallengeStaleError + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| HumanInputChallengeStaleError | | | | + #### HumanInputContent | Name | Type | Description | Required | @@ -1436,6 +1730,7 @@ Parsed multipart form fields for HITL uploads. | form_content | string | | Yes | | form_id | string | | Yes | | form_token | string | | No | +| form_version | string,
**Available values:** "1", "2",
**Default:** 1 | *Enum:* `"1"`, `"2"` | No | | inputs | [ [FormInputConfig](#forminputconfig) ] | | No | | node_id | string | | Yes | | node_title | string | | Yes | @@ -1458,6 +1753,12 @@ Parsed multipart form fields for HITL uploads. | ---- | ---- | ----------- | -------- | | HumanInputFormExpiredError | | | | +#### HumanInputFormNotFoundError + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| HumanInputFormNotFoundError | | | | + #### HumanInputFormSubmissionData | Name | Type | Description | Required | @@ -1471,6 +1772,8 @@ Parsed multipart form fields for HITL uploads. #### HumanInputFormSubmitPayload +Legacy Human Input v1 submit payload shared by existing runtime surfaces. + | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | action | string | ID of the action button the recipient selected. Must match one of the `id` values from the form's `user_actions` list. | Yes | @@ -1483,11 +1786,15 @@ Parsed multipart form fields for HITL uploads. #### HumanInputFormSubmittedError -This form has already been submitted by another user, form_id={form_id} +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| HumanInputFormSubmittedError | | | | + +#### HumanInputInvalidOtpError | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| HumanInputFormSubmittedError | | This form has already been submitted by another user, form_id={form_id} | | +| HumanInputInvalidOtpError | | | | #### HumanInputUploadTokenResponse @@ -1496,6 +1803,29 @@ This form has already been submitted by another user, form_id={form_id} | expires_at | integer | | Yes | | upload_token | string | | Yes | +#### HumanInputV2FormSubmitRequest + +Public Human Input v2 submit payload, independent from the v1 form contract. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| action | string | Identifier of the selected Human Input v2 action. | Yes | +| challenge_token | string | OTP challenge token returned by the Human Input v2 access-request endpoint. | No | +| inputs | object | Submitted form values keyed by output variable name. | Yes | +| otp_code | string | OTP code required when the current Human Input v2 approver uses email proof. | No | + +#### HumanInputV2RemoteUploadPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| url | string (uri) | | Yes | + +#### HumanInputV2UnavailableError + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| HumanInputV2UnavailableError | | | | + #### InvalidEmailError The email address is not valid. @@ -1510,6 +1840,12 @@ The email address is not valid. | ---- | ---- | ----------- | -------- | | InvalidFormDataError | | | | +#### InvalidInputError + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| InvalidInputError | | | | + #### InvalidOrExpiredTokenError The token is invalid or has expired. @@ -1605,6 +1941,15 @@ The upload token is malformed. | ---- | ---- | ----------- | -------- | | MalformedUploadTokenError | | The upload token is malformed. | | +#### MarkdownFragment + +One source-ordered Markdown fragment in a resolved form. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| text | string | | Yes | +| type | string | | No | + #### MessageFeedbackPayload | Name | Type | Description | Required | @@ -1696,6 +2041,16 @@ Please check if your Workflow app mode matches the right API route. | ---- | ---- | ----------- | -------- | | NotWorkflowAppError | | Please check if your Workflow app mode matches the right API route. | | +#### ParagraphInput + +Resolved multiline text input. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| default_value | string | | Yes | +| output_variable_name | string | | Yes | +| type | string | | No | + #### ParagraphInputConfig Form input definition. @@ -1857,6 +2212,17 @@ The remote file URL is not allowed. | ---- | ---- | ----------- | -------- | | RemoteFileUrlBlockedError | | The remote file URL is not allowed. | | +#### ResolvedForm + +Immutable authoritative presentation snapshot for one v2 form. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| actions | [ [UserAction](#useraction) ] | | Yes | +| legacy_form_content | string | | Yes | +| parts | [ [FormPart](#formpart) ] | | Yes | +| title | string | | Yes | + #### ResultResponse | Name | Type | Description | Required | @@ -1933,6 +2299,17 @@ The remote file URL is not allowed. | last_id | string | | No | | limit | integer,
**Default:** 20 | | No | +#### SelectInput + +Resolved single-choice input with immutable concrete options. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| default_value | string | | Yes | +| options | [ string ] | | Yes | +| output_variable_name | string | | Yes | +| type | string | | No | + #### SelectInputConfig | Name | Type | Description | Required | @@ -2098,6 +2475,16 @@ Upload token is required. | ---- | ---- | ----------- | -------- | | UploadTokenRequiredError | | Upload token is required. | | +#### UserAction + +Resolved action values required for presentation and submission validation. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| button_style | [ButtonStyle](#buttonstyle) | | Yes | +| id | string | | Yes | +| title | string | | Yes | + #### UserActionConfig User action configuration. @@ -2149,6 +2536,25 @@ in form definition, or a variable while the workflow is running. | remove_webapp_brand | boolean | | Yes | | replace_webapp_logo | string | | No | +#### WebAppHumanInputV2FormDefinitionResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| expiration_time | integer | | Yes | +| resolved_form | [ResolvedForm](#resolvedform) | | Yes | + +#### WebAppHumanInputV2SubmitPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| action | string | | Yes | +| inputs | object | | Yes | + +#### WebAppHumanInputV2SubmitResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | + #### WebAppSiteResponse | Name | Type | Description | Required | @@ -2238,6 +2644,12 @@ Too many form requests. Please try again later. | title | string | | Yes | | use_icon_as_answer_icon | boolean | | No | +#### WorkflowNotActiveError + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| WorkflowNotActiveError | | | | + #### WorkflowRunPayload | Name | Type | Description | Required | diff --git a/api/pyproject.toml b/api/pyproject.toml index b2e20faeb42..0074d0f8b31 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -26,6 +26,7 @@ dependencies = [ # Stable: production-proven, cap below the next major "aliyun-log-python-sdk==0.9.44", "azure-identity>=1.26.0,<2.0.0", + "email-validator>=2.3.0,<3.0.0", "flask-compress>=1.25,<2.0.0", "flask-login==0.6.3", "flask-migrate>=4.1.0,<5.0.0", @@ -33,6 +34,7 @@ dependencies = [ "flask-restx>=1.3.2,<2.0.0", "google-cloud-aiplatform>=2.3.0,<3.0.0", "httpx[socks]==0.28.1", + "markdown-it-py>=4.0.0,<5.0.0", "opentelemetry-distro==0.65b0", "opentelemetry-instrumentation-celery==0.65b0", "opentelemetry-instrumentation-flask==0.65b0", @@ -45,10 +47,17 @@ dependencies = [ "werkzeug>=3.1.7,<4.0.0", "zstandard==0.25.0", # Emerging: newer and fast-moving, use compatible pins + "alibabacloud-dingtalk>=2.2.54,<3.0.0", + "botbuilder-schema>=4.17.0,<5.0.0", + "botframework-connector>=4.17.0,<5.0.0", "fastopenapi[flask]==0.7.0", "graphon==0.8.0", "httpx-sse==0.4.3", "json-repair==0.60.1", + "lark-oapi==1.7.2", + "pyjwt>=2.10.1,<3.0.0", + "slack-sdk>=3.43.0,<4.0.0", + "wechatpy>=1.8.18,<2.0.0", "yarl>=1.25.1,<2.0.0", ] # Before adding new dependency, consider place it in @@ -135,7 +144,7 @@ dev = [ "pytest-cov>=7.1.0", "pytest-env>=1.7.1", "pytest-mock>=3.15.1", - "testcontainers>=4.14.2", + "testcontainers>=4.15.0,<4.16", "types-aiofiles>=25.1.0.20260518", "types-beautifulsoup4>=4.12.0", "types-cachetools>=7.0.0.20260713", diff --git a/api/repositories/account/repository.py b/api/repositories/account/repository.py index ed497d6a403..85bdce2e3ef 100644 --- a/api/repositories/account/repository.py +++ b/api/repositories/account/repository.py @@ -6,6 +6,7 @@ from typing import override from sqlalchemy import case, delete, func, select, update from sqlalchemy.orm import Session, sessionmaker +from core.human_input_v2.shared import AccountId from models.account import ( Account, AccountIntegrate, @@ -16,6 +17,7 @@ from models.account import ( TenantAccountJoin, TenantStatus, ) +from repositories.human_input_v2.sqlalchemy_contact_repository import SQLAlchemyContactRepository from services.account.contracts import AccountCreation from services.account.login_service import ConsoleAuthAccountRepository from services.account_email import normalize_email @@ -174,6 +176,8 @@ class SQLAlchemyAccountRepository(AccountRepository, ConsoleAuthAccountRepositor account.id = creation.id session.add(account) session.flush() + # Keep the stable Contact identity in the caller's account transaction. + SQLAlchemyContactRepository(session).provision_account_backed_contact(AccountId(account.id)) return account @override diff --git a/api/repositories/api_workflow_run_repository.py b/api/repositories/api_workflow_run_repository.py index 0d6a4cb3922..d2444713d03 100644 --- a/api/repositories/api_workflow_run_repository.py +++ b/api/repositories/api_workflow_run_repository.py @@ -157,6 +157,37 @@ class APIWorkflowRunRepository(Protocol): """ ... + def get_workflow_run_by_id_for_update( + self, + session: Session, + *, + tenant_id: str, + app_id: str, + run_id: str, + ) -> WorkflowRun | None: + """Read and lock a run in the complete tenant/app scope. + + The caller owns the Session and transaction. The lock remains held + until that transaction ends, allowing related writes to share it. + Return None when the run does not exist in the requested scope. + """ + ... + + def stop_workflow_run( + self, + session: Session, + *, + run: WorkflowRun, + error: str, + finished_at: datetime, + ) -> None: + """Persist STOPPED and invalidate the run's active pause together. + + The caller owns the transaction, has locked the owner-scoped run in + this Session, and has decided that execution must stop. + """ + ... + def get_workflow_run_by_id_without_tenant( self, run_id: str, @@ -591,6 +622,29 @@ class APIWorkflowRunRepository(Protocol): """ ... + def has_active_workflow_pause( + self, + session: Session, + *, + tenant_id: str, + app_id: str, + run_id: str, + ) -> bool: + """Check for an unresumed pause in the complete tenant/app scope. + + Use the caller's Session without managing its transaction. Callers + coordinating writes must lock the run first in the same transaction. + """ + ... + + def get_legacy_pause_reasons(self, session: Session, pause_id: str) -> Sequence[DifyPauseReason]: + """Load V1 presentation for an already-authorized pause using the caller's Session. + + Only call when the checkpoint contains V1 references. This method does + not create a Session or manage its transaction. + """ + ... + def resume_workflow_pause( self, workflow_run_id: str, diff --git a/api/repositories/entities/workflow_pause.py b/api/repositories/entities/workflow_pause.py index 85a716aff76..34b5e03204b 100644 --- a/api/repositories/entities/workflow_pause.py +++ b/api/repositories/entities/workflow_pause.py @@ -6,12 +6,9 @@ by the core workflow module. These models are independent of the storage mechani and don't contain implementation details like tenant_id, app_id, etc. """ -from collections.abc import Sequence from datetime import datetime from typing import Protocol -from core.workflow.nodes.human_input.pause_reason import PauseReason as DifyPauseReason - class WorkflowPauseEntity(Protocol): """ @@ -22,6 +19,9 @@ class WorkflowPauseEntity(Protocol): It provides the interface for managing workflow pause/resume operations and state persistence through file storage. + Pause presentation is resolved by the service from checkpoint references; + this entity does not load relational pause reasons. + The `WorkflowPauseEntity` is never reused. If a workflow execution pauses multiple times, it will generate multiple `WorkflowPauseEntity` records. """ @@ -64,12 +64,3 @@ class WorkflowPauseEntity(Protocol): def paused_at(self) -> datetime: """`paused_at` returns the creation time of the pause.""" ... - - def get_pause_reasons(self) -> Sequence[DifyPauseReason]: - """ - Retrieve detailed reasons for this pause. - - Returns a sequence of `PauseReason` objects describing the specific nodes and - reasons for which the workflow execution was paused. - """ - ... diff --git a/api/repositories/execution_extra_content_repository.py b/api/repositories/execution_extra_content_repository.py index 72b5443d2ca..31fabda784b 100644 --- a/api/repositories/execution_extra_content_repository.py +++ b/api/repositories/execution_extra_content_repository.py @@ -1,13 +1,25 @@ from __future__ import annotations from collections.abc import Sequence +from dataclasses import dataclass from typing import Protocol from core.entities.execution_extra_content import ExecutionExtraContentDomainModel +@dataclass(frozen=True, slots=True) +class HumanInputFormReference: + """A stored approval reference whose form is outside the legacy table.""" + + workflow_run_id: str + form_id: str + + +type StoredExecutionExtraContent = ExecutionExtraContentDomainModel | HumanInputFormReference + + class ExecutionExtraContentRepository(Protocol): - def get_by_message_ids(self, message_ids: Sequence[str]) -> list[list[ExecutionExtraContentDomainModel]]: ... + def get_by_message_ids(self, message_ids: Sequence[str]) -> list[list[StoredExecutionExtraContent]]: ... __all__ = ["ExecutionExtraContentRepository"] diff --git a/api/repositories/human_input_v2/__init__.py b/api/repositories/human_input_v2/__init__.py new file mode 100644 index 00000000000..e1a958b69c6 --- /dev/null +++ b/api/repositories/human_input_v2/__init__.py @@ -0,0 +1 @@ +"""Persistence adapters for Human Input v2 bounded contexts.""" diff --git a/api/repositories/human_input_v2/audit_repository.py b/api/repositories/human_input_v2/audit_repository.py new file mode 100644 index 00000000000..a70756b4158 --- /dev/null +++ b/api/repositories/human_input_v2/audit_repository.py @@ -0,0 +1,25 @@ +"""Audit writes within a trusted tenant/form scope and caller-owned transaction.""" + +from typing import Protocol + +from core.human_input_v2.shared.values import RecipientId + + +class AuditRepository(Protocol): + def record_form_view(self, *, viewer_ip_address: str | None) -> None: + """Record a form read after its visibility checks succeed.""" + ... + + def record_otp_send( + self, *, recipient_id: RecipientId, recipient_email: str, sender_ip_address: str | None + ) -> None: + """Record an OTP email accepted by the sending channel.""" + ... + + def record_im_callback_verification(self, *, succeeded: bool, request_ip_address: str | None, reason: str) -> None: + """Record callback authorization independently of form submission.""" + ... + + def record_service_api_submission(self, *, recipient_id: RecipientId, end_user_id: str) -> None: + """Record the recipient grant and actual caller in the submission transaction.""" + ... diff --git a/api/repositories/human_input_v2/channel_credentials.py b/api/repositories/human_input_v2/channel_credentials.py new file mode 100644 index 00000000000..3368f67dd51 --- /dev/null +++ b/api/repositories/human_input_v2/channel_credentials.py @@ -0,0 +1,18 @@ +"""Opaque credential values shared by Channel repositories and persistence models.""" + +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field + + +class ChannelEncryptedCredentials(BaseModel): + """Versioned opaque credential envelope shared by Email and IM Channels.""" + + model_config = ConfigDict(extra="forbid", frozen=True, strict=True, validate_default=True) + + version: Literal[1] = Field(default=1, description="Credential envelope format version.") + ciphertext: str = Field( + min_length=1, + repr=False, + description="Encrypted complete credential payload.", + ) diff --git a/api/repositories/human_input_v2/contact.py b/api/repositories/human_input_v2/contact.py new file mode 100644 index 00000000000..329ca877362 --- /dev/null +++ b/api/repositories/human_input_v2/contact.py @@ -0,0 +1,199 @@ +"""Immutable Contact values and persistence ports.""" + +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from enum import StrEnum +from typing import Protocol + +from pydantic import NaiveDatetime + +from core.human_input_v2.entities import IMBindingScope, IMProvider +from core.human_input_v2.shared import AccountId, ContactId, IMBindingId, IMIdentityId, TenantId + + +class ContactType(StrEnum): + """Workspace-scoped classification of a currently available Contact.""" + + WORKSPACE = "workspace" + PLATFORM = "platform" + EXTERNAL = "external" + + +@dataclass(frozen=True, slots=True) +class Contact: + """Immutable current Contact projection for one tenant.""" + + id: ContactId + type: ContactType + name: str + email: str | None + avatar_file_id: str | None + created_at: NaiveDatetime + + +@dataclass(frozen=True, slots=True) +class ExternalContact: + """Workspace-owned External Contact data accepted by lifecycle writes.""" + + id: ContactId + name: str + email: str + avatar_file_id: str | None + created_at: NaiveDatetime + + +@dataclass(frozen=True, slots=True) +class Page[T]: + """Immutable page returned by Contact repository queries.""" + + items: tuple[T, ...] + page: int + limit: int + + +@dataclass(frozen=True, slots=True) +class ContactQuery: + """Optional keyword and current-type filters shared by list and count.""" + + keyword: str = "" + contact_type: ContactType | None = None + + +_DEFAULT_CONTACT_QUERY = ContactQuery() + + +class ContactRepository(Protocol): + """Tenant-scoped current Contact queries and lifecycle writes.""" + + def count_contact(self, tenant_id: TenantId, query: ContactQuery = _DEFAULT_CONTACT_QUERY) -> int: ... + + def list_contact( + self, + tenant_id: TenantId, + page: int, + limit: int, + query: ContactQuery = _DEFAULT_CONTACT_QUERY, + ) -> Page[Contact]: ... + + def get_contacts_by_id(self, tenant_id: TenantId, contact_id: ContactId) -> Contact | None: ... + + def get_contact_by_account_id(self, tenant_id: TenantId, account_id: AccountId) -> Contact | None: + """Read the current Contact of an authenticated Account without provisioning it. + + Missing, disabled, or unavailable Accounts have no current Contact in + this workspace. Email matching must never substitute for this identity. + """ + ... + + def get_contacts_by_ids( + self, + tenant_id: TenantId, + contact_ids: Sequence[ContactId], + ) -> Sequence[Contact]: ... + + def available( + self, + tenant_id: TenantId, + contact_ids: Sequence[ContactId], + ) -> Mapping[ContactId, bool]: ... + + def save_external_contact(self, tenant_id: TenantId, external_contact: ExternalContact) -> Contact: ... + + def delete_external_contact(self, tenant_id: TenantId, external_contact: ExternalContact) -> None: ... + + def provision_account_backed_contact(self, account_id: AccountId) -> ContactId: ... + + def query_contacts_by_email( + self, + tenant_id: TenantId, + emails: Sequence[str], + ) -> Sequence[Contact]: ... + + +CandidateId = ContactId + + +@dataclass(frozen=True, slots=True) +class OrganizationCandidate: + id: ContactId + name: str + email: str | None + avatar_file_id: str | None + created_at: NaiveDatetime + + +class EnterpriseContactRepository(Protocol): + """EE-only candidate queries and Platform visibility mutations.""" + + def list_organization_candidates( + self, + page: int, + limit: int, + keyword: str = "", + ) -> Sequence[OrganizationCandidate]: ... + + def count_organization_candidates(self, keyword: str = "") -> int: ... + + def create_platform_entry( + self, + tenant_id: TenantId, + candidate_id: ContactId, + added_by_account_id: AccountId, + ) -> None: ... + + def delete_platform_entry(self, tenant_id: TenantId, contact_id: ContactId) -> None: ... + + +@dataclass(frozen=True, slots=True) +class IMBinding: + """Contact-facing read value for one current IM binding.""" + + id: IMBindingId + scope: IMBindingScope + contact_id: ContactId + identity_id: IMIdentityId + provider: IMProvider + + +class ContactIMBindingRepository(Protocol): + """Tenant-scoped read-only port for Contact IM bindings.""" + + def get_im_bindings( + self, + tenant_id: TenantId, + contact_ids: Sequence[ContactId], + ) -> Sequence[IMBinding]: ... + + +class ContactErrorCode(StrEnum): + INVALID_OWNER = "invalid_owner" + CONFLICT = "contact_conflict" + NOT_FOUND = "contact_not_found" + ACCOUNT_NOT_FOUND = "account_not_found" + + +class ContactError(RuntimeError): + """Stable expected failure raised by Contact lifecycle operations.""" + + def __init__(self, code: ContactErrorCode, message: str) -> None: + super().__init__(message) + self.code = code + + +__all__ = [ + "CandidateId", + "Contact", + "ContactError", + "ContactErrorCode", + "ContactIMBindingRepository", + "ContactQuery", + "ContactRepository", + "ContactType", + "EnterpriseContactRepository", + "ExternalContact", + "IMBinding", + "OrganizationCandidate", + "Page", +] diff --git a/api/repositories/human_input_v2/delivery_attempt_repository.py b/api/repositories/human_input_v2/delivery_attempt_repository.py new file mode 100644 index 00000000000..5720b3ddd95 --- /dev/null +++ b/api/repositories/human_input_v2/delivery_attempt_repository.py @@ -0,0 +1,61 @@ +"""Append-only persistence contract for actual send results.""" + +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass +from enum import StrEnum +from typing import Protocol + +from pydantic import JsonValue, NaiveDatetime + +from core.human_input_v2.shared.values import TenantId + + +class DeliveryStatus(StrEnum): + SUCCEEDED = "succeeded" + FAILED = "failed" + + +@dataclass(frozen=True, slots=True) +class DeliveryAttemptCreateParams: + """Completed send result; the caller supplies operator-safe error details.""" + + status: DeliveryStatus + error_message: str | None + response: Mapping[str, JsonValue] + + +@dataclass(frozen=True, slots=True) +class DeliveryAttempt: + id: str + tenant_id: TenantId + form_id: str + delivery_id: str + status: DeliveryStatus + error_message: str | None + response: Mapping[str, JsonValue] + created_at: NaiveDatetime + updated_at: NaiveDatetime + + +class DeliveryAttemptRepository(Protocol): + """Send history bound to a caller-verified tenant and form. + + Every read and write preserves both owner predicates. Callers own the + transaction; implementations never commit or roll it back. Actual sending + happens before recording, outside this repository's transaction. No queued + state, scheduling, provider selection, or delivery mutation is implied. + All timestamps are naive UTC. + """ + + def record_attempt(self, delivery_id: str, params: DeliveryAttemptCreateParams) -> DeliveryAttempt | None: + """Append one result with generated ID and timestamps. + + Return None if the delivery is absent from the bound tenant/form. + Initial sends, automatic retries, and manual resends each append a new + attempt for the original delivery, without overwriting prior results. + Calling this twice records two attempts; callers invoke it once per + actual send. Current channel configuration and binding belong to callers. + """ + ... diff --git a/api/repositories/human_input_v2/delivery_repository.py b/api/repositories/human_input_v2/delivery_repository.py new file mode 100644 index 00000000000..030cdce0fd7 --- /dev/null +++ b/api/repositories/human_input_v2/delivery_repository.py @@ -0,0 +1,175 @@ +"""Persistence contract for frozen form delivery and access information.""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import StrEnum +from typing import Annotated, Literal, Protocol + +from pydantic import BaseModel, ConfigDict, EmailStr, Field, NaiveDatetime + +from core.human_input_v2.entities import IMProvider +from core.human_input_v2.im_integration.adapters.message_locator import MessageLocator +from core.human_input_v2.shared.values import IMBindingId, RecipientId, TenantId + + +class SubmissionAuthType(StrEnum): + # CONSOLE means that this delivery is authenticated with + # console session. It requires an assoiciated Dify `Account`. + CONSOLE = "console" + + # WEB_APP means that this delivery is authenticated with web app + # session. It requires an assoiciated `Enduser`. + # + # Please note that service API also uses `WEB_APP` auth type. + WEB_APP = "web_app" + + # IM means that this delivery should be authenticated by correponding + # IM events. For example, the deliveried card should generate an IM callback / + # streaming event. The Dify instance consumes the event and determines + # whether it is a valid submission. It requires an associated IM user. + IM = "im" + + # IM means that this delivery must be authenticated by send an OTP email + # to the corresponding email address. This auth type has no assoicated Dify or IM + # identity. + EMAIL_OTP = "email_otp" + + +class DeliveryTargetType(StrEnum): + IM_USER = "im_user" + EMAIL = "email" + INITIATOR = "initiator" + + +class IMMessageType(StrEnum): + CARD = "card" + LINK = "link" + + +class IMUserTargetSnapshot(BaseModel): + model_config = ConfigDict(frozen=True, strict=True, validate_default=True) + + type: Literal[DeliveryTargetType.IM_USER] = DeliveryTargetType.IM_USER + + im_provider: IMProvider + im_tenant_id: str + im_provider_user_id: str + im_binding_id: IMBindingId + # Records the assessed presentation; auth_type governs submission. + message_type: IMMessageType = Field(strict=False) + # None until the provider confirms acceptance. Preserve the opaque value. + message_locator: MessageLocator | None = None + + +class EmailTargetSnapshot(BaseModel): + model_config = ConfigDict(frozen=True, strict=True, validate_default=True) + + type: Literal[DeliveryTargetType.EMAIL] = DeliveryTargetType.EMAIL + + email_address: EmailStr + + +class InitiatorSnapshot(BaseModel): + """Delivery through the already-established initiator interaction surface.""" + + model_config = ConfigDict(frozen=True, strict=True, validate_default=True) + + type: Literal[DeliveryTargetType.INITIATOR] = DeliveryTargetType.INITIATOR + # Store tenant-encrypted ciphertext, never plaintext. + protected_form_token: str = Field(repr=False) + + +type TargetSnapshot = Annotated[ + IMUserTargetSnapshot | EmailTargetSnapshot | InitiatorSnapshot, + Field(discriminator="type"), +] + + +@dataclass(frozen=True, slots=True) +class DeliveryCreateParams: + """Resolved, validated access data; raw tokens and credentials stay with callers.""" + + recipient_id: RecipientId + token_hash: str + auth_type: SubmissionAuthType + target_snapshot: TargetSnapshot + + +@dataclass(frozen=True, slots=True) +class Delivery: + """Delivery snapshot with frozen access facts and its accepted message identity.""" + + id: str + tenant_id: TenantId + form_id: str + recipient_id: RecipientId + token_hash: str + auth_type: SubmissionAuthType + target_snapshot: TargetSnapshot + created_at: NaiveDatetime + updated_at: NaiveDatetime + + +class DeliveryRepository(Protocol): + """Delivery creation, frozen access lookup, and accepted IM message identities. + + The caller owns the transaction; implementations never commit or roll it + back. Callers guarantee that the recipient exists and belongs to the supplied + tenant and form; creation does not recheck membership. Token lookup establishes + the candidate delivery's owner context from storage. Targets and authentication + facts are frozen at creation; an accepted message's locator is added to its IM + snapshot after sending. This repository does not resolve recipients, + select endpoints, authenticate actors, generate tokens, or send messages. + All timestamps are naive UTC. + """ + + def list_deliveries(self, *, tenant_id: TenantId, form_id: str) -> tuple[Delivery, ...]: + """Read the form's owner-scoped deliveries, ordered by ID.""" + ... + + def get_delivery(self, *, tenant_id: TenantId, form_id: str, delivery_id: str) -> Delivery | None: + """Read one owner-scoped delivery; missing or foreign-owned returns None.""" + ... + + def set_im_message_locator( + self, *, tenant_id: TenantId, form_id: str, delivery_id: str, message_locator: MessageLocator + ) -> Delivery | None: + """Save the exact accepted IM message identity in the caller's transaction. + + Return None for an absent or foreign-owned delivery. The same locator + is idempotent. A different locator or a non-IM target raises ValueError. + Subsequent failed attempts never clear the accepted message identity. + """ + ... + + def create_delivery(self, *, tenant_id: TenantId, form_id: str, params: DeliveryCreateParams) -> Delivery: + """Create one delivery with generated ID and timestamps. + + The caller guarantees recipient existence and tenant/form membership. + Each call creates a separate delivery. Different recipients may share an + endpoint; that does not suppress creation. Callers reuse persisted + deliveries for subsequent send attempts. + """ + ... + + def get_delivery_by_token_hash(self, token_hash: str) -> Delivery | None: + """Locate a candidate delivery by its exact hash without prior owner context. + + Missing hashes return None. Return the persisted tenant and form with + the candidate so callers can perform subsequent owner-scoped checks. + Ambiguous hashes, including matches across different owners, must fail + instead of selecting an arbitrary recipient. Lookup alone does not + authenticate an actor or check whether the form may still be submitted. + """ + ... + + def get_initiator_delivery(self, *, tenant_id: TenantId, form_id: str) -> Delivery | None: + """Read the original interaction entry when a waiting form is revisited. + + Scope by tenant/form and the persisted Initiator target type. Missing + returns None; multiple entries for the form fail rather than selecting + a token. The caller establishes app ownership before this lookup. + This read neither creates another delivery nor authenticates an actor. + """ + ... diff --git a/api/repositories/human_input_v2/email_channel/__init__.py b/api/repositories/human_input_v2/email_channel/__init__.py new file mode 100644 index 00000000000..95d72677e6b --- /dev/null +++ b/api/repositories/human_input_v2/email_channel/__init__.py @@ -0,0 +1,39 @@ +"""Resend Email channel repository contracts and implementations.""" + +from .entities import ( + EmailChannelConfiguration, + EmailChannelView, + EmailConfigurationSnapshot, + ResendCandidate, +) +from .ports import ( + CreateEmailConfigurationResult, + CreateEmailConfigurationStatus, + DeleteEmailConfigurationResult, + DeleteEmailConfigurationStatus, + EmailChannelPersistenceError, + EmailChannelRepository, + EmailProviderOperationError, + EmailProviderValidationError, + UpdateEmailConfigurationResult, + UpdateEmailConfigurationStatus, +) +from .repository import SQLAlchemyEmailChannelRepository + +__all__ = [ + "CreateEmailConfigurationResult", + "CreateEmailConfigurationStatus", + "DeleteEmailConfigurationResult", + "DeleteEmailConfigurationStatus", + "EmailChannelConfiguration", + "EmailChannelPersistenceError", + "EmailChannelRepository", + "EmailChannelView", + "EmailConfigurationSnapshot", + "EmailProviderOperationError", + "EmailProviderValidationError", + "ResendCandidate", + "SQLAlchemyEmailChannelRepository", + "UpdateEmailConfigurationResult", + "UpdateEmailConfigurationStatus", +] diff --git a/api/repositories/human_input_v2/email_channel/entities.py b/api/repositories/human_input_v2/email_channel/entities.py new file mode 100644 index 00000000000..788c153c95b --- /dev/null +++ b/api/repositories/human_input_v2/email_channel/entities.py @@ -0,0 +1,84 @@ +"""Credential-safe values for the workspace Resend configuration lifecycle.""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +from pydantic import BaseModel, ConfigDict, Field, NaiveDatetime + +from core.human_input_v2.entities import EmailProviderType +from core.human_input_v2.shared import ( + AccountId, + EmailProviderId, + NormalizedEmail, + TenantId, +) +from repositories.human_input_v2.channel_credentials import ChannelEncryptedCredentials + + +class ResendCredentials(BaseModel): + """Complete authentication credentials stored inside the Channel envelope.""" + + model_config = ConfigDict(extra="forbid", frozen=True, strict=True, hide_input_in_errors=True) + + api_key: str = Field(repr=False) + + +@dataclass(frozen=True, slots=True) +class ResendCandidate: + """Complete candidate settings for a Resend save or test operation.""" + + sender_email: NormalizedEmail + sender_name: str + credentials: ResendCredentials = field(repr=False) + + +@dataclass(frozen=True, slots=True) +class EmailConfigurationSnapshot: + """Complete internal token guarding a configuration write.""" + + configuration_id: EmailProviderId + config_version: int + + def __post_init__(self) -> None: + if self.config_version < 1: + raise ValueError("config version must be positive") + + +@dataclass(frozen=True, slots=True) +class EmailChannelConfiguration: + """Workspace-owned Resend configuration independent from ORM lifetime.""" + + id: EmailProviderId + tenant_id: TenantId + sender_email: NormalizedEmail + sender_name: str + encrypted_credentials: ChannelEncryptedCredentials = field(repr=False) + configured_by_account_id: AccountId | None + created_at: NaiveDatetime + updated_at: NaiveDatetime + config_version: int = 1 + provider: EmailProviderType = EmailProviderType.RESEND + + def __post_init__(self) -> None: + if self.provider is not EmailProviderType.RESEND: + raise ValueError("only the Resend email provider is supported") + if self.config_version < 1: + raise ValueError("config version must be positive") + + @property + def snapshot(self) -> EmailConfigurationSnapshot: + return EmailConfigurationSnapshot(self.id, self.config_version) + + +@dataclass(frozen=True, slots=True) +class EmailChannelView: + """Credential-free configuration state exposed by the Email owner.""" + + id: EmailProviderId + provider: EmailProviderType + created_at: NaiveDatetime + updated_at: NaiveDatetime + sender_name: str + sender_email: str + revision: EmailConfigurationSnapshot diff --git a/api/repositories/human_input_v2/email_channel/mappers.py b/api/repositories/human_input_v2/email_channel/mappers.py new file mode 100644 index 00000000000..f8b82575f4e --- /dev/null +++ b/api/repositories/human_input_v2/email_channel/mappers.py @@ -0,0 +1,55 @@ +"""Bidirectional mapping between Email channel values and ORM records.""" + +from datetime import datetime + +from pydantic import NaiveDatetime + +from core.human_input_v2.shared import ( + AccountId, + EmailProviderId, + NormalizedEmail, + TenantId, +) +from libs.datetime_utils import ensure_naive_utc +from models.human_input_v2 import HumanInputEmailProvider + +from .entities import EmailChannelConfiguration + + +def _timestamp(value: datetime) -> NaiveDatetime: + return ensure_naive_utc(value) + + +def email_configuration_to_record(configuration: EmailChannelConfiguration) -> HumanInputEmailProvider: + record = HumanInputEmailProvider( + provider=configuration.provider, + sender_email=str(configuration.sender_email), + sender_name=configuration.sender_name, + encrypted_credentials=configuration.encrypted_credentials, + tenant_id=str(configuration.tenant_id), + config_version=configuration.config_version, + configured_by_account_id=( + str(configuration.configured_by_account_id) if configuration.configured_by_account_id is not None else None + ), + ) + record.id = str(configuration.id) + record.created_at = configuration.created_at + record.updated_at = configuration.updated_at + return record + + +def email_configuration_from_record(record: HumanInputEmailProvider) -> EmailChannelConfiguration: + return EmailChannelConfiguration( + id=EmailProviderId(record.id), + tenant_id=TenantId(record.tenant_id), + provider=record.provider, + sender_email=NormalizedEmail(record.sender_email), + sender_name=record.sender_name, + encrypted_credentials=record.encrypted_credentials, + configured_by_account_id=( + AccountId(record.configured_by_account_id) if record.configured_by_account_id is not None else None + ), + created_at=_timestamp(record.created_at), + updated_at=_timestamp(record.updated_at), + config_version=record.config_version, + ) diff --git a/api/repositories/human_input_v2/email_channel/ports.py b/api/repositories/human_input_v2/email_channel/ports.py new file mode 100644 index 00000000000..9a69c502e79 --- /dev/null +++ b/api/repositories/human_input_v2/email_channel/ports.py @@ -0,0 +1,117 @@ +"""Provider failures and transactional persistence ports for Email.""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import StrEnum +from typing import Protocol + +from pydantic import NaiveDatetime + +from core.human_input_v2.shared import TenantId + +from .entities import ( + EmailChannelConfiguration, + EmailConfigurationSnapshot, +) + + +class EmailProviderValidationError(Exception): + """Classified provider failure with safe internal and upstream codes.""" + + code: str + + def __init__(self, code: str, *, provider_error_code: str | None = None, http_status: int | None = None) -> None: + super().__init__(code) + self.code = code + self.provider_error_code = provider_error_code + self.http_status = http_status + + +class EmailProviderOperationError(Exception): + """Classified provider or transport failure with safe diagnostic codes.""" + + code: str + + def __init__(self, code: str, *, provider_error_code: str | None = None, http_status: int | None = None) -> None: + super().__init__(code) + self.code = code + self.provider_error_code = provider_error_code + self.http_status = http_status + + +class EmailChannelPersistenceError(RuntimeError): + """Credential-free failure raised by an Email persistence adapter.""" + + +class CreateEmailConfigurationStatus(StrEnum): + """Stable outcome of creating the first workspace Email configuration.""" + + # The workspace had no configuration and the new row was committed. + CREATED = "created" + + # Another configuration already owns the workspace, including a concurrent creation winner. + CONFLICT = "conflict" + + +@dataclass(frozen=True, slots=True) +class CreateEmailConfigurationResult: + status: CreateEmailConfigurationStatus + configuration: EmailChannelConfiguration | None + + +class UpdateEmailConfigurationStatus(StrEnum): + """Outcome of an identity-and-revision guarded configuration update.""" + + # The captured configuration snapshot still matched and the replacement was committed. + UPDATED = "updated" + + # The captured identity or revision is no longer current; callers must reload before retrying. + STALE = "stale" + + +@dataclass(frozen=True, slots=True) +class UpdateEmailConfigurationResult: + status: UpdateEmailConfigurationStatus + configuration: EmailChannelConfiguration | None + + +class DeleteEmailConfigurationStatus(StrEnum): + """Outcome of deleting the current workspace Email configuration.""" + + # The current workspace row was removed atomically. + DELETED = "deleted" + + # No configuration exists in the trusted workspace scope. + NOT_CONFIGURED = "not_configured" + + # The current row no longer matches the identity-and-revision snapshot. + STALE = "stale" + + +@dataclass(frozen=True, slots=True) +class DeleteEmailConfigurationResult: + status: DeleteEmailConfigurationStatus + + +class EmailChannelRepository(Protocol): + """Operation-scoped transactional persistence for one workspace row.""" + + def load(self, tenant_id: TenantId) -> EmailChannelConfiguration | None: ... + + def create(self, configuration: EmailChannelConfiguration) -> CreateEmailConfigurationResult: ... + + def update( + self, + configuration: EmailChannelConfiguration, + *, + expected: EmailConfigurationSnapshot, + now: NaiveDatetime, + ) -> UpdateEmailConfigurationResult: ... + + def delete( + self, + tenant_id: TenantId, + *, + expected: EmailConfigurationSnapshot, + ) -> DeleteEmailConfigurationResult: ... diff --git a/api/repositories/human_input_v2/email_channel/repository.py b/api/repositories/human_input_v2/email_channel/repository.py new file mode 100644 index 00000000000..ea9dc7e971a --- /dev/null +++ b/api/repositories/human_input_v2/email_channel/repository.py @@ -0,0 +1,136 @@ +"""Transactional SQLAlchemy repository for one workspace Email configuration.""" + +from __future__ import annotations + +from dataclasses import replace +from datetime import timedelta +from typing import override + +from pydantic import NaiveDatetime +from sqlalchemy import select +from sqlalchemy.exc import SQLAlchemyError +from sqlalchemy.orm import Session + +from core.human_input_v2.shared import TenantId +from models.account import Tenant +from models.human_input_v2 import HumanInputEmailProvider + +from .entities import EmailChannelConfiguration, EmailConfigurationSnapshot +from .mappers import email_configuration_from_record, email_configuration_to_record +from .ports import ( + CreateEmailConfigurationResult, + CreateEmailConfigurationStatus, + DeleteEmailConfigurationResult, + DeleteEmailConfigurationStatus, + EmailChannelPersistenceError, + EmailChannelRepository, + UpdateEmailConfigurationResult, + UpdateEmailConfigurationStatus, +) + + +class SQLAlchemyEmailChannelRepository(EmailChannelRepository): + """Apply Email row locking and CAS using the caller-owned Session.""" + + def __init__(self, session: Session) -> None: + self._session = session + + @override + def load(self, tenant_id: TenantId) -> EmailChannelConfiguration | None: + try: + record = self._session.scalar( + select(HumanInputEmailProvider).where(HumanInputEmailProvider.tenant_id == str(tenant_id)) + ) + return email_configuration_from_record(record) if record is not None else None + except SQLAlchemyError as error: + raise EmailChannelPersistenceError("failed to load Email channel configuration") from error + + @override + def create(self, configuration: EmailChannelConfiguration) -> CreateEmailConfigurationResult: + try: + owner = self._session.get(Tenant, str(configuration.tenant_id), with_for_update=True) + if owner is None: + raise EmailChannelPersistenceError("workspace owner does not exist") + existing = self._session.scalar( + select(HumanInputEmailProvider.id) + .where(HumanInputEmailProvider.tenant_id == str(configuration.tenant_id)) + .limit(1) + ) + if existing is not None: + return CreateEmailConfigurationResult(CreateEmailConfigurationStatus.CONFLICT, None) + record = email_configuration_to_record(configuration) + self._session.add(record) + self._session.flush() + return CreateEmailConfigurationResult( + CreateEmailConfigurationStatus.CREATED, + email_configuration_from_record(record), + ) + except SQLAlchemyError as error: + raise EmailChannelPersistenceError("failed to create Email channel configuration") from error + + @override + def update( + self, + configuration: EmailChannelConfiguration, + *, + expected: EmailConfigurationSnapshot, + now: NaiveDatetime, + ) -> UpdateEmailConfigurationResult: + try: + record = self._session.scalar( + select(HumanInputEmailProvider) + .where( + HumanInputEmailProvider.id == str(expected.configuration_id), + HumanInputEmailProvider.tenant_id == str(configuration.tenant_id), + HumanInputEmailProvider.config_version == expected.config_version, + ) + .with_for_update() + ) + if record is None: + return UpdateEmailConfigurationResult(UpdateEmailConfigurationStatus.STALE, None) + current_configuration = email_configuration_from_record(record) + current_updated_at = current_configuration.updated_at + next_value = max(now, current_updated_at + timedelta(microseconds=1)) + updated = replace( + configuration, + created_at=current_configuration.created_at, + config_version=current_configuration.config_version + 1, + updated_at=next_value, + ) + mapped = email_configuration_to_record(updated) + record.provider = mapped.provider + record.sender_email = mapped.sender_email + record.sender_name = mapped.sender_name + record.encrypted_credentials = mapped.encrypted_credentials + record.configured_by_account_id = mapped.configured_by_account_id + record.config_version = mapped.config_version + record.updated_at = mapped.updated_at + self._session.flush() + return UpdateEmailConfigurationResult( + UpdateEmailConfigurationStatus.UPDATED, + email_configuration_from_record(record), + ) + except SQLAlchemyError as error: + raise EmailChannelPersistenceError("failed to update Email channel configuration") from error + + @override + def delete( + self, + tenant_id: TenantId, + *, + expected: EmailConfigurationSnapshot, + ) -> DeleteEmailConfigurationResult: + try: + record = self._session.scalar( + select(HumanInputEmailProvider) + .where(HumanInputEmailProvider.tenant_id == str(tenant_id)) + .with_for_update() + ) + if record is None: + return DeleteEmailConfigurationResult(DeleteEmailConfigurationStatus.NOT_CONFIGURED) + if record.id != str(expected.configuration_id) or record.config_version != expected.config_version: + return DeleteEmailConfigurationResult(DeleteEmailConfigurationStatus.STALE) + self._session.delete(record) + except SQLAlchemyError as error: + raise EmailChannelPersistenceError("failed to delete Email channel configuration") from error + return DeleteEmailConfigurationResult(DeleteEmailConfigurationStatus.DELETED) diff --git a/api/repositories/human_input_v2/file_upload_repository.py b/api/repositories/human_input_v2/file_upload_repository.py new file mode 100644 index 00000000000..6aa6eca1100 --- /dev/null +++ b/api/repositories/human_input_v2/file_upload_repository.py @@ -0,0 +1,20 @@ +"""Resolve creator identities for files uploaded to a v2 approval.""" + +from dataclasses import dataclass +from typing import Protocol + +from models.enums import CreatorUserRole + + +@dataclass(frozen=True, slots=True) +class FileUploadOwner: + id: str + creator_role: CreatorUserRole + + +class FileUploadRepository(Protocol): + def get_owner( + self, *, tenant_id: str, app_id: str, owner_id: str, owner_role: CreatorUserRole + ) -> FileUploadOwner | None: ... + + def get_delivery_test_owner(self, *, tenant_id: str, app_id: str) -> FileUploadOwner | None: ... diff --git a/api/repositories/human_input_v2/form_repository.py b/api/repositories/human_input_v2/form_repository.py new file mode 100644 index 00000000000..918bf7b2b1e --- /dev/null +++ b/api/repositories/human_input_v2/form_repository.py @@ -0,0 +1,206 @@ +"""Business persistence contract limited to HumanInputForm rows.""" + +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from enum import StrEnum +from typing import Protocol + +from pydantic import JsonValue, NaiveDatetime + +from core.human_input_v2.resolved_form import ResolvedForm +from core.human_input_v2.shared.values import RecipientId, TenantId +from core.workflow.nodes.human_input.enums import HumanInputFormKind, HumanInputFormStatus +from graphon.variables.segments import Segment + + +@dataclass(frozen=True, slots=True) +class FormSubmission: + """Complete accepted submission; absent as a whole before submission.""" + + submitted_at: NaiveDatetime + submitted_by_recipient_id: RecipientId + submitted_by_delivery_id: str + selected_action_id: str + raw_submission_inputs: Mapping[str, JsonValue] + normalized_submission_data: Mapping[str, Segment] + + +@dataclass(frozen=True, slots=True) +class Form: + """Form snapshot independent of ORM lifetime, with decoded submission data.""" + + id: str + tenant_id: TenantId + app_id: str + workflow_run_id: str | None + node_execution_id: str | None + form_kind: HumanInputFormKind + resolved_form: ResolvedForm + created_at: NaiveDatetime + updated_at: NaiveDatetime + expiration_time: NaiveDatetime + global_timeout_deadline: NaiveDatetime + submission: FormSubmission | None + # The source of truth for form status. Deadlines govern transitions out of + # WAITING, but never change an already persisted outcome. + status: HumanInputFormStatus + + +@dataclass(frozen=True, slots=True) +class FormCreateParams: + """Resolved creation values for the repository's bound tenant and app. + + Runtime forms require both execution IDs. Delivery tests have neither. + Values, including naive UTC deadlines, are validated before this boundary. + """ + + workflow_run_id: str | None + node_execution_id: str | None + resolved_form: ResolvedForm + expiration_time: NaiveDatetime + global_timeout_deadline: NaiveDatetime + form_kind: HumanInputFormKind = HumanInputFormKind.RUNTIME + + +@dataclass(frozen=True, slots=True) +class FormExpirationRun: + """Owner-scoped candidate with the earliest waiting form global deadline.""" + + tenant_id: TenantId + app_id: str + workflow_run_id: str + global_timeout_deadline: NaiveDatetime + + +@dataclass(frozen=True, slots=True) +class FormExpirationBatch: + """All waiting runtime forms in a locked run and the persistence clock.""" + + forms: tuple[Form, ...] + now: NaiveDatetime + + +class FormSubmissionFailure(StrEnum): + """Expected rejection reasons that require distinct caller handling.""" + + NOT_FOUND = "not_found" + ALREADY_SUBMITTED = "already_submitted" + FORM_EXPIRED = "form_expired" + GLOBAL_TIMEOUT = "global_timeout" + + +class FormRepository(Protocol): + """Form operations scoped to one constructor-bound tenant and app. + + Every bound read and write includes both owner predicates. Bound operations + access HumanInputForm; owner-resolution factories may check its App. + Recipient authorization, file ownership, workflow + state, delivery, and audit orchestration belong to callers. Callers own the + transaction so form writes can participate in a larger business operation. + + Implementations serialize submission mappings and use Pydantic to decode + stored JSON into concrete values, preserving Segment subtypes. Partial + stored submissions are invalid, not equivalent to an unsubmitted form. + All timestamps use naive UTC. + """ + + def get_form(self, workflow_run_id: str, node_execution_id: str) -> Form | None: + """Read the runtime form for this exact execution, including on resume.""" + ... + + def get_form_by_id(self, form_id: str) -> Form | None: + """Read a runtime or delivery-test form; missing or out of scope returns None.""" + ... + + def get_forms_by_ids(self, *, workflow_run_id: str, form_ids: Sequence[str]) -> tuple[Form, ...]: + """Read requested forms belonging to one run within the bound tenant and app. + + Omit missing or out-of-scope IDs and return each matching form once. + Empty IDs return an empty tuple. Result order is unspecified. + """ + ... + + def create_form(self, params: FormCreateParams) -> Form: + """Create an unsubmitted form with generated ID and timestamps. + + Runtime creation atomically reuses the form for the same execution, + including concurrent retries, without replacing its frozen content, + deadlines, or submission. Delivery-test creation always creates a form. + """ + ... + + def submit_form( + self, + form_id: str, + *, + submitted_by_recipient_id: RecipientId, + submitted_by_delivery_id: str, + selected_action_id: str, + raw_submission_inputs: Mapping[str, JsonValue], + normalized_submission_data: Mapping[str, Segment], + ) -> Form | FormSubmissionFailure: + """Atomically accept the first submission while both deadlines allow it. + + The caller has already authorized this exact delivery and recipient for + the same tenant/form; never infer a delivery from the recipient. It has + validated the action, inputs, and file ownership. Preserve raw inputs + without sanitizing them; persist normalized inputs with their types. + + Reject missing/out-of-scope forms first, then persisted terminal states. + Only WAITING forms may be submitted. Compare persistence time + against both deadlines: equality is expired. Set submitted_at from + that same time and persist SUBMITTED with all submission fields together, + returning the updated form. Global timeout takes precedence when both + deadlines are reached. A rejected write leaves the form unchanged, including + when another recipient wins a concurrent submission. + """ + ... + + def list_expired_forms( + self, + *, + now: NaiveDatetime, + limit: int, + after_form_id: str | None = None, + ) -> tuple[Form, ...]: + """Find WAITING runtime forms due for timeout handling. + + Include forms where either deadline is <= now, within the bound owner. + Return at most the positive limit in ascending ID order, exclusively + after after_form_id when supplied. Keep now fixed across a scan's pages. + This is a read, not a claim or persisted timeout transition: callers + must coordinate workflow handling and tolerate repeated scan results. + """ + ... + + def expire_form(self, form_id: str) -> Form | None: + """Set a WAITING form to EXPIRED when its global deadline is reached. + + Use persistence time after acquiring the form lock. Preserve terminal + states and forms whose global deadline is still in the future. Return + the current form, or None if missing/out of scope. The caller terminates + the workflow in the same transaction. + """ + ... + + def list_waiting_forms_for_run(self, workflow_run_id: str) -> FormExpirationBatch: + """Read all WAITING runtime forms in this run without locking form rows. + + Scope reads to the bound owner and sample persistence time after the + read. The caller must first lock the run to serialize with submission. + Only subsequent state transitions lock individual forms. No pagination + may truncate the run's global-expiration decision. + """ + ... + + def timeout_form(self, form_id: str) -> Form | None: + """Set a WAITING form to TIMEOUT when only its node deadline is reached. + + Use persistence time after acquiring the form lock. Preserve terminal + states. If the global deadline is reached, leave the form WAITING for + global expiration handling. Return the current form, or None if missing + or out of scope. The caller requests resume after committing. + """ + ... diff --git a/api/repositories/human_input_v2/im_binding_repository.py b/api/repositories/human_input_v2/im_binding_repository.py new file mode 100644 index 00000000000..081365701ae --- /dev/null +++ b/api/repositories/human_input_v2/im_binding_repository.py @@ -0,0 +1,152 @@ +"""Owner-free values and persistence port for current IM Bindings.""" + +from __future__ import annotations + +from collections.abc import Sequence +from dataclasses import dataclass +from enum import StrEnum +from typing import Protocol + +from pydantic import NaiveDatetime + +from core.human_input_v2.shared import AccountId, ContactId, IMBindingId, IMIdentityId, TenantId + + +class IMBindingKind(StrEnum): + """Persistence kind that produced one current Binding.""" + + DEFAULT = "default" + WORKSPACE_OVERRIDE = "workspace_override" + + +@dataclass(frozen=True, slots=True) +class IMBinding: + """Current owner-free Contact-to-IM-identity Binding, immutable for its ID.""" + + id: IMBindingId + kind: IMBindingKind + contact_id: ContactId + identity_id: IMIdentityId + created_at: NaiveDatetime + updated_at: NaiveDatetime + + +@dataclass(frozen=True, slots=True) +class IMBindingAssignment: + """Requested Contact-to-Identity assignment.""" + + contact_id: ContactId + identity_id: IMIdentityId + assigned_at: NaiveDatetime + + +class IMBindingRepositoryError(Exception): + """Root error for expected IM Binding persistence failures.""" + + +class IMBindingConflictError(IMBindingRepositoryError): + """The Contact or Identity is already assigned to another endpoint.""" + + +class IMBindingIdentityNotFoundError(IMBindingRepositoryError): + """The requested Identity is not current in the bound Channel.""" + + +class IMBindingRepository(Protocol): + """Read and write current Bindings for one already-bound IM Channel.""" + + def get(self, binding_id: IMBindingId) -> IMBinding | None: + """Return a default Binding current in the bound Channel, if present.""" + ... + + def get_default_for_contact(self, contact_id: ContactId) -> IMBinding | None: + """Return the Contact's default Binding in the bound Channel, ignoring overrides.""" + ... + + def list_all(self) -> tuple[IMBinding, ...]: + """Return all default Bindings in the bound Channel.""" + ... + + def create( + self, + assignment: IMBindingAssignment, + *, + bound_by_account_id: AccountId | None, + ) -> IMBinding: + """Create an idempotent default Binding; reject conflicting assignments.""" + ... + + def replace( + self, + assignment: IMBindingAssignment, + *, + bound_by_account_id: AccountId | None, + ) -> IMBinding: + """Manually reassign both default Binding endpoints in the bound Channel. + + Preserve identical assignments, including ID, timestamps, and actor metadata. + Otherwise delete the Contact's old default and the Identity's old default, + if present, then create the requested assignment with a new Binding ID. + Never modify workspace overrides. Concurrent endpoint changes raise + IMBindingConflictError. The caller owns the transaction and must roll back + all deletions and creation on failure. + """ + ... + + def delete( + self, + binding_id: IMBindingId, + *, + expected_identity_id: IMIdentityId, + ) -> None: + """Idempotently delete one exact default Binding in the bound Channel.""" + ... + + def set_workspace_override( + self, + tenant_id: TenantId, + assignment: IMBindingAssignment, + *, + bound_by_account_id: AccountId | None, + ) -> IMBinding: + """Create or replace one target workspace override with a new Binding ID. + + Preserve identical assignments, including timestamps and actor metadata. + The caller owns the transaction covering deletion and creation. + """ + ... + + def reset_workspace_override( + self, + tenant_id: TenantId, + contact_id: ContactId, + ) -> IMBinding | None: + """Idempotently remove one target workspace override.""" + ... + + def get_effective( + self, + tenant_id: TenantId, + contact_id: ContactId, + ) -> IMBinding | None: + """Return the override-first effective Binding for one Contact.""" + ... + + def get_effective_many( + self, + tenant_id: TenantId, + contact_ids: Sequence[ContactId], + ) -> tuple[IMBinding, ...]: + """Return at most one override-first effective Binding per Contact.""" + ... + + +__all__ = [ + "IMBinding", + "IMBindingAssignment", + "IMBindingConflictError", + "IMBindingIdentityNotFoundError", + "IMBindingKind", + "IMBindingRepository", + "IMBindingRepositoryError", +] diff --git a/api/repositories/human_input_v2/im_channel_repository.py b/api/repositories/human_input_v2/im_channel_repository.py new file mode 100644 index 00000000000..5927e3c20a9 --- /dev/null +++ b/api/repositories/human_input_v2/im_channel_repository.py @@ -0,0 +1,115 @@ +"""Owner-free values and persistence ports for one current IM Channel.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import StrEnum +from typing import NewType, Protocol + +from pydantic import NaiveDatetime + +from core.human_input_v2.entities import IMProvider +from core.human_input_v2.shared.values import DirectoryScope +from repositories.human_input_v2.channel_credentials import ChannelEncryptedCredentials + +WebhookId = NewType("WebhookId", str) +IMChannelId = NewType("IMChannelId", str) + + +class IMChannelStatus(StrEnum): + """Credential-safe status persisted with one Channel configuration.""" + + CONNECTED = "connected" + INVALID_CREDENTIALS = "invalid_credentials" + CONNECTION_FAILURE = "connection_failure" + + +@dataclass(frozen=True, slots=True) +class IMChannel: + """Owner-free value mapped to one current IM Channel row.""" + + id: IMChannelId + created_at: NaiveDatetime + updated_at: NaiveDatetime + provider: IMProvider + provider_tenant_id: str + encrypted_credentials: ChannelEncryptedCredentials = field(repr=False) + app_identifier: str + webhook_id: WebhookId + config_version: int + status: IMChannelStatus + status_reason: str | None = None + + +class IMChannelAlreadyConfiguredError(Exception): + """The constructor-bound owner key already has a current Channel.""" + + +@dataclass(frozen=True, slots=True) +class IMWebhookChannelRoute: + """Current Channel and credential owner resolved from persisted routing data.""" + + channel: IMChannel + scope: DirectoryScope + + +class StaleIMChannelWriteError(Exception): + """The current Channel ID or numeric configuration version changed.""" + + +class IMChannelReader(Protocol): + """Read the current Channel for one owner-bound persistence slot.""" + + def get(self) -> IMChannel | None: + """Return the current Channel for the bound owner, if configured.""" + ... + + +class IMChannelWriter(Protocol): + """Channel writes for one owner and caller-owned SQLAlchemy Session. + + Concrete constructors bind the owner key and configuring actor. Operation + methods persist already constructed Channel values and perform no business + orchestration, Provider I/O, or credential transformation. + """ + + def create(self, channel: IMChannel) -> IMChannel: + """Insert the first Channel for the bound owner.""" + ... + + def update( + self, + channel: IMChannel, + expected_config_version: int, + ) -> IMChannel: + """Persist the same Channel ID under owner, ID, and version CAS.""" + ... + + def replace( + self, + current_channel_id: IMChannelId, + expected_config_version: int, + replacement: IMChannel, + ) -> IMChannel: + """Replace the current row with a new Channel ID in the same owner slot.""" + ... + + def delete( + self, + channel_id: IMChannelId, + expected_config_version: int, + ) -> None: + """Delete the current row under owner, ID, and version CAS.""" + ... + + +__all__ = [ + "IMChannel", + "IMChannelAlreadyConfiguredError", + "IMChannelId", + "IMChannelReader", + "IMChannelStatus", + "IMChannelWriter", + "StaleIMChannelWriteError", + "WebhookId", +] diff --git a/api/repositories/human_input_v2/im_identity_repository.py b/api/repositories/human_input_v2/im_identity_repository.py new file mode 100644 index 00000000000..d240b22ff6d --- /dev/null +++ b/api/repositories/human_input_v2/im_identity_repository.py @@ -0,0 +1,120 @@ +"""Owner-free values and persistence port for current IM Identities.""" + +from __future__ import annotations + +from collections.abc import Sequence +from dataclasses import dataclass +from typing import Protocol + +from pydantic import ConfigDict, JsonValue, NaiveDatetime, RootModel + +from core.human_input_v2.shared import IMIdentityId, IMSyncRunId + + +class OpaqueProviderPayload(RootModel[dict[str, JsonValue]]): + """Opaque Provider JSON retained only for persistence diagnostics.""" + + model_config = ConfigDict(frozen=True, strict=True, validate_default=True) + + +@dataclass(frozen=True, slots=True) +class IMIdentity: + """Current Provider user returned by a Channel-bound repository.""" + + id: IMIdentityId + provider_user_id: str + display_name: str | None + email: str | None + last_seen_sync_run_id: IMSyncRunId + last_seen_at: NaiveDatetime + created_at: NaiveDatetime + updated_at: NaiveDatetime + avatar_file_id: str | None = None + + +@dataclass(frozen=True, slots=True) +class IMIdentityObservation: + """Provider facts accepted by one synchronization write.""" + + provider_user_id: str + display_name: str | None + email: str | None + raw_payload: OpaqueProviderPayload + sync_run_id: IMSyncRunId + observed_at: NaiveDatetime + avatar_file_id: str | None = None + + +@dataclass(frozen=True, slots=True) +class IMIdentityPage: + """Immutable page returned by current Identity search.""" + + items: tuple[IMIdentity, ...] + page: int + limit: int + total: int + + +class IMIdentityRepositoryError(Exception): + """Root error for expected IM Identity persistence failures.""" + + +class IMIdentityAlreadyExistsError(IMIdentityRepositoryError): + """The Provider user already has a current Identity in the bound Channel.""" + + +class IMIdentityNotFoundError(IMIdentityRepositoryError): + """The requested Identity is not current in the bound Channel.""" + + +class IMIdentityInUseError(IMIdentityRepositoryError): + """A current Binding still references the requested Identity.""" + + +class IMIdentityRepository(Protocol): + """Read and write current Identities for one already-bound IM Channel.""" + + def get(self, identity_id: IMIdentityId) -> IMIdentity | None: + """Return an Identity current in the bound Channel, if present.""" + ... + + def get_many(self, identity_ids: Sequence[IMIdentityId]) -> tuple[IMIdentity, ...]: + """Read requested current Identities in one batch, omitting missing or foreign-Channel IDs.""" + ... + + def get_by_provider_user_id(self, provider_user_id: str) -> IMIdentity | None: + """Return the bound-Channel Identity for one Provider user ID.""" + ... + + def list_all(self) -> tuple[IMIdentity, ...]: + """Return all current Identities in the bound Channel.""" + ... + + def search(self, *, keyword: str = "", page: int, limit: int) -> IMIdentityPage: + """Search current bound-Channel Identities by safe profile facts.""" + ... + + def create(self, identity_id: IMIdentityId, observation: IMIdentityObservation) -> IMIdentity: + """Create a current Identity from one Provider observation.""" + ... + + def update(self, identity_id: IMIdentityId, observation: IMIdentityObservation) -> IMIdentity: + """Update a current bound-Channel Identity from one observation.""" + ... + + def delete(self, identity_id: IMIdentityId) -> None: + """Idempotently delete an unbound Identity from the bound Channel.""" + ... + + +__all__ = [ + "IMIdentity", + "IMIdentityAlreadyExistsError", + "IMIdentityInUseError", + "IMIdentityNotFoundError", + "IMIdentityObservation", + "IMIdentityPage", + "IMIdentityRepository", + "IMIdentityRepositoryError", + "OpaqueProviderPayload", +] diff --git a/api/repositories/human_input_v2/im_integration/__init__.py b/api/repositories/human_input_v2/im_integration/__init__.py new file mode 100644 index 00000000000..6cb2420f060 --- /dev/null +++ b/api/repositories/human_input_v2/im_integration/__init__.py @@ -0,0 +1,7 @@ +"""SQLAlchemy persistence adapters for the IM Control Plane domain.""" + +from .repository import SQLAlchemyIMControlPlaneRepository + +__all__ = [ + "SQLAlchemyIMControlPlaneRepository", +] diff --git a/api/repositories/human_input_v2/im_integration/mappers.py b/api/repositories/human_input_v2/im_integration/mappers.py new file mode 100644 index 00000000000..b455d441c3a --- /dev/null +++ b/api/repositories/human_input_v2/im_integration/mappers.py @@ -0,0 +1,300 @@ +"""Explicit bidirectional mappings for IM domain values and ORM records.""" + +from datetime import datetime + +from pydantic import NaiveDatetime + +from core.human_input_v2.entities import IMSyncRemovalReason +from core.human_input_v2.im_integration import ( + IMBindingChangeSnapshot, + IMChannelRevision, + IMIdentityChangeSnapshot, + IMReconciliationChange, + IMReconciliationSubjectKind, + IMSyncRun, + OpaqueProviderPayload, + SyncContactSnapshot, + SyncIdentitySnapshot, + SyncResultFact, +) +from core.human_input_v2.im_integration.adapters.entities import ProviderUserId +from core.human_input_v2.shared import ( + AccountId, + ContactId, + IMBindingId, + IMIdentityId, + IMReconciliationChangeId, + IMSyncResultId, + IMSyncRunId, + NormalizedEmail, +) +from libs.datetime_utils import ensure_naive_utc +from models.human_input_v2 import ( + HumanInputIMReconciliationChange, + HumanInputIMSyncResult, + HumanInputIMSyncRun, + IMBindingReconciliationSnapshot, + IMIdentityReconciliationSnapshot, + IMSyncContactSnapshot, + IMSyncDirectoryEntryPayload, + IMSyncIdentitySnapshot, +) +from repositories.human_input_v2.im_channel_repository import IMChannelId + + +def _timestamp(value: datetime) -> NaiveDatetime: + """Interpret database-naive timestamps as UTC, matching Dify persistence.""" + + return ensure_naive_utc(value) + + +def sync_run_from_record(record: HumanInputIMSyncRun) -> IMSyncRun: + """Map one sync run record into its independent aggregate.""" + + return IMSyncRun( + id=IMSyncRunId(record.id), + channel_revision=IMChannelRevision(record.channel_id, record.channel_config_version), + provider=record.provider, + status=record.status, + added_count=record.added_count, + not_matched_count=record.not_matched_count, + failed_count=record.failed_count, + removed_count=record.removed_count, + skipped_count=record.skipped_count, + started_by_account_id=( + AccountId(record.started_by_account_id) if record.started_by_account_id is not None else None + ), + started_at=_timestamp(record.started_at) if record.started_at is not None else None, + finished_at=_timestamp(record.finished_at) if record.finished_at is not None else None, + error_code=record.error_code, + error_message=record.error_message, + created_at=_timestamp(record.created_at), + updated_at=_timestamp(record.updated_at), + ) + + +def sync_run_to_record(run: IMSyncRun) -> HumanInputIMSyncRun: + """Map one sync aggregate into a detached record.""" + + record = HumanInputIMSyncRun( + channel_id=run.channel_revision.channel_id, + channel_config_version=run.channel_revision.config_version, + provider=run.provider, + status=run.status, + added_count=run.added_count, + not_matched_count=run.not_matched_count, + failed_count=run.failed_count, + removed_count=run.removed_count, + skipped_count=run.skipped_count, + started_by_account_id=str(run.started_by_account_id) if run.started_by_account_id is not None else None, + started_at=run.started_at if run.started_at is not None else None, + finished_at=run.finished_at if run.finished_at is not None else None, + error_code=run.error_code, + error_message=run.error_message, + ) + record.id = str(run.id) + record.created_at = run.created_at + record.updated_at = run.updated_at + return record + + +def sync_result_from_record(record: HumanInputIMSyncResult) -> SyncResultFact: + """Map one append-only result record into an immutable domain fact.""" + + contact_snapshot = record.contact_snapshot + identity_snapshot = record.identity_snapshot + return SyncResultFact( + id=IMSyncResultId(record.id), + channel_id=IMChannelId(record.channel_id), + sync_run_id=IMSyncRunId(record.sync_run_id), + operation_key=record.operation_key, + result_type=record.result_type, + provider_user_id=record.provider_user_id, + display_name=record.display_name, + email=record.email, + normalized_email=NormalizedEmail(record.normalized_email) if record.normalized_email is not None else None, + contact_id=ContactId(record.contact_id) if record.contact_id is not None else None, + identity_id=IMIdentityId(record.im_identity_id) if record.im_identity_id is not None else None, + binding_id=IMBindingId(record.im_binding_id) if record.im_binding_id is not None else None, + removal_reason=IMSyncRemovalReason(record.removal_reason) if record.removal_reason is not None else None, + reason_code=record.reason_code, + reason_message=record.reason_message, + directory_entry_payload=( + OpaqueProviderPayload.from_mapping(record.directory_entry_payload.root) + if record.directory_entry_payload is not None + else None + ), + contact_snapshot=( + SyncContactSnapshot( + contact_id=ContactId(contact_snapshot.contact_id), + name=contact_snapshot.name, + email=contact_snapshot.email, + avatar_file_id=contact_snapshot.avatar_file_id, + created_at=( + _timestamp(contact_snapshot.created_at) if contact_snapshot.created_at is not None else None + ), + ) + if contact_snapshot is not None + else None + ), + identity_snapshot=( + SyncIdentitySnapshot( + identity_id=IMIdentityId(identity_snapshot.identity_id), + provider=identity_snapshot.provider, + provider_user_id=identity_snapshot.provider_user_id, + display_name=identity_snapshot.display_name, + email=identity_snapshot.email, + ) + if identity_snapshot is not None + else None + ), + created_at=_timestamp(record.created_at), + updated_at=_timestamp(record.updated_at), + ) + + +def sync_result_to_record(result: SyncResultFact) -> HumanInputIMSyncResult: + """Map one immutable sync result fact into a detached record.""" + + contact_snapshot = result.contact_snapshot + identity_snapshot = result.identity_snapshot + record = HumanInputIMSyncResult( + channel_id=str(result.channel_id), + sync_run_id=str(result.sync_run_id), + operation_key=result.operation_key, + result_type=result.result_type, + provider_user_id=result.provider_user_id, + display_name=result.display_name, + email=result.email, + normalized_email=str(result.normalized_email) if result.normalized_email is not None else None, + contact_id=str(result.contact_id) if result.contact_id is not None else None, + im_identity_id=str(result.identity_id) if result.identity_id is not None else None, + im_binding_id=str(result.binding_id) if result.binding_id is not None else None, + removal_reason=result.removal_reason, + reason_code=result.reason_code, + reason_message=result.reason_message, + directory_entry_payload=( + IMSyncDirectoryEntryPayload(result.directory_entry_payload.to_mapping()) + if result.directory_entry_payload is not None + else None + ), + contact_snapshot=( + IMSyncContactSnapshot( + contact_id=str(contact_snapshot.contact_id), + name=contact_snapshot.name, + email=contact_snapshot.email, + avatar_file_id=contact_snapshot.avatar_file_id, + created_at=contact_snapshot.created_at, + ) + if contact_snapshot is not None + else None + ), + identity_snapshot=( + IMSyncIdentitySnapshot( + identity_id=str(identity_snapshot.identity_id), + provider=identity_snapshot.provider, + provider_user_id=identity_snapshot.provider_user_id, + display_name=identity_snapshot.display_name, + email=identity_snapshot.email, + ) + if identity_snapshot is not None + else None + ), + ) + record.id = str(result.id) + record.created_at = result.created_at + record.updated_at = result.updated_at + return record + + +def reconciliation_change_from_record(record: HumanInputIMReconciliationChange) -> IMReconciliationChange: + """Map one append-only ORM change record into an immutable application value.""" + + return IMReconciliationChange( + id=IMReconciliationChangeId(record.id), + channel_id=IMChannelId(record.channel_id), + sync_run_id=IMSyncRunId(record.sync_run_id), + operation_key=record.operation_key, + subject_kind=record.subject_kind, + operation=record.operation, + reason_code=record.reason_code, + identity_id=IMIdentityId(record.im_identity_id), + binding_id=IMBindingId(record.im_binding_id) if record.im_binding_id is not None else None, + contact_id=ContactId(record.contact_id) if record.contact_id is not None else None, + before=_change_snapshot_from_record(record.before_snapshot), + after=_change_snapshot_from_record(record.after_snapshot), + committed_at=_timestamp(record.committed_at), + ) + + +def reconciliation_change_to_record(change: IMReconciliationChange) -> HumanInputIMReconciliationChange: + """Map one immutable change value into a detached append-only ORM record.""" + + record = HumanInputIMReconciliationChange( + channel_id=str(change.channel_id), + sync_run_id=str(change.sync_run_id), + operation_key=change.operation_key, + subject_kind=change.subject_kind, + operation=change.operation, + reason_code=change.reason_code, + im_identity_id=str(change.identity_id), + im_binding_id=str(change.binding_id) if change.binding_id is not None else None, + contact_id=str(change.contact_id) if change.contact_id is not None else None, + before_snapshot=_change_snapshot_to_record(change.before), + after_snapshot=_change_snapshot_to_record(change.after), + committed_at=change.committed_at, + ) + record.id = str(change.id) + record.created_at = change.committed_at + record.updated_at = change.committed_at + return record + + +def _change_snapshot_from_record( + snapshot: IMIdentityReconciliationSnapshot | IMBindingReconciliationSnapshot | None, +) -> IMIdentityChangeSnapshot | IMBindingChangeSnapshot | None: + if snapshot is None: + return None + if snapshot.subject_kind is IMReconciliationSubjectKind.IDENTITY: + if not isinstance(snapshot, IMIdentityReconciliationSnapshot): + raise ValueError("identity change contains an invalid snapshot") + return IMIdentityChangeSnapshot( + identity_id=IMIdentityId(snapshot.identity_id), + provider=snapshot.provider, + provider_user_id=ProviderUserId(snapshot.provider_user_id), + display_name=snapshot.display_name, + email=snapshot.email, + normalized_email=NormalizedEmail(snapshot.normalized_email) if snapshot.normalized_email else None, + last_seen_sync_run_id=( + IMSyncRunId(snapshot.last_seen_sync_run_id) if snapshot.last_seen_sync_run_id else None + ), + ) + if not isinstance(snapshot, IMBindingReconciliationSnapshot): + raise ValueError("IM binding change contains an invalid snapshot") + return IMBindingChangeSnapshot( + binding_id=IMBindingId(snapshot.binding_id), + identity_id=IMIdentityId(snapshot.identity_id), + contact_id=ContactId(snapshot.contact_id), + ) + + +def _change_snapshot_to_record( + snapshot: IMIdentityChangeSnapshot | IMBindingChangeSnapshot | None, +) -> IMIdentityReconciliationSnapshot | IMBindingReconciliationSnapshot | None: + if snapshot is None: + return None + if isinstance(snapshot, IMIdentityChangeSnapshot): + return IMIdentityReconciliationSnapshot( + identity_id=str(snapshot.identity_id), + provider=snapshot.provider, + provider_user_id=str(snapshot.provider_user_id), + display_name=snapshot.display_name, + email=snapshot.email, + normalized_email=str(snapshot.normalized_email) if snapshot.normalized_email else None, + last_seen_sync_run_id=(str(snapshot.last_seen_sync_run_id) if snapshot.last_seen_sync_run_id else None), + ) + return IMBindingReconciliationSnapshot( + binding_id=str(snapshot.binding_id), + identity_id=str(snapshot.identity_id), + contact_id=str(snapshot.contact_id), + ) diff --git a/api/repositories/human_input_v2/im_integration/repository.py b/api/repositories/human_input_v2/im_integration/repository.py new file mode 100644 index 00000000000..3858659a4a6 --- /dev/null +++ b/api/repositories/human_input_v2/im_integration/repository.py @@ -0,0 +1,248 @@ +"""Caller-session SQLAlchemy persistence for historical IM synchronization.""" + +from __future__ import annotations + +import sqlalchemy as sa +from pydantic import NaiveDatetime +from sqlalchemy.engine import CursorResult +from sqlalchemy.orm import Session + +from core.human_input_v2.entities import IMIdentityBindingStatus, IMSyncResultType, IMSyncRunStatus +from core.human_input_v2.im_integration import ( + ActiveRunDecision, + ActiveRunDecisionKind, + IMChannelRevision, + IMSyncRun, + StaleRevision, + SynchronizedIMIdentity, + SynchronizedIMIdentityPage, + SyncResultFact, + SyncResultPage, +) +from core.human_input_v2.shared import AccountId, IMSyncResultId, IMSyncRunId +from libs.uuid_utils import uuidv7 +from models.human_input_v2 import HumanInputIMChannel, HumanInputIMSyncResult, HumanInputIMSyncRun +from repositories.human_input_v2.im_binding_repository import IMBindingRepository +from repositories.human_input_v2.im_channel_repository import IMChannel, IMChannelId +from repositories.human_input_v2.im_identity_repository import IMIdentityRepository + +from .mappers import sync_result_from_record, sync_result_to_record, sync_run_from_record, sync_run_to_record + +_MAX_PAGE_LIMIT = 100 + + +class SQLAlchemyIMControlPlaneRepository: + """Historical sync persistence bound to one caller Session and current Channel.""" + + def __init__(self, session: Session, channel: IMChannel) -> None: + self._session = session + self._channel = channel + + def create_or_get_active_run( + self, + *, + sync_run_id: IMSyncRunId, + started_by_account_id: AccountId | None, + now: NaiveDatetime, + ) -> ActiveRunDecision: + revision = IMChannelRevision( + str(self._channel.id), + self._channel.config_version, + ) + if not self._serialize_current_channel(): + return ActiveRunDecision( + ActiveRunDecisionKind.STALE_REVISION, + None, + StaleRevision(revision, None), + ) + active_records = self._session.scalars( + sa.select(HumanInputIMSyncRun) + .where( + HumanInputIMSyncRun.channel_id == str(self._channel.id), + HumanInputIMSyncRun.status.in_((IMSyncRunStatus.QUEUED, IMSyncRunStatus.RUNNING)), + ) + .order_by(HumanInputIMSyncRun.created_at, HumanInputIMSyncRun.id) + # A locking read also observes the previous lock holder's commit on + # databases whose ordinary reads retain a transaction snapshot. + .with_for_update() + ).all() + current_active: HumanInputIMSyncRun | None = None + for active_record in active_records: + if active_record.channel_config_version < self._channel.config_version: + self._finish_stale_run(active_record, now) + else: + current_active = active_record + if current_active is not None: + return ActiveRunDecision(ActiveRunDecisionKind.EXISTING_ACTIVE, sync_run_from_record(current_active)) + run = IMSyncRun.create( + sync_run_id=sync_run_id, + channel_revision=revision, + provider=self._channel.provider, + started_by_account_id=started_by_account_id, + now=now, + ) + record = sync_run_to_record(run) + self._session.add(record) + self._session.flush([record]) + return ActiveRunDecision(ActiveRunDecisionKind.CREATED, sync_run_from_record(record)) + + def finish_stale_run(self, sync_run_id: IMSyncRunId, *, now: NaiveDatetime) -> IMSyncRun | None: + """Finish this Channel's older capture under Channel-then-run locks. + + Return None when the Channel snapshot is no longer current or the run + does not belong to an older revision of it. The caller owns the transaction. + Terminal runs are returned without appending another diagnostic. + """ + if not self._serialize_current_channel(): + return None + record = self._session.scalar( + sa.select(HumanInputIMSyncRun) + .where( + HumanInputIMSyncRun.id == str(sync_run_id), + HumanInputIMSyncRun.channel_id == str(self._channel.id), + HumanInputIMSyncRun.channel_config_version < self._channel.config_version, + ) + .with_for_update() + ) + if record is None: + return None + if record.status in (IMSyncRunStatus.QUEUED, IMSyncRunStatus.RUNNING): + self._finish_stale_run(record, now) + return sync_run_from_record(record) + + def _finish_stale_run(self, record: HumanInputIMSyncRun, now: NaiveDatetime) -> None: + """The caller holds both row locks; never modify current directory state.""" + code = "stale_revision" + message = "IM Channel revision changed before reconciliation apply." + diagnostic = SyncResultFact( + id=IMSyncResultId(str(uuidv7())), + channel_id=IMChannelId(record.channel_id), + sync_run_id=IMSyncRunId(record.id), + operation_key=f"diagnostic:{code}", + result_type=IMSyncResultType.FAILED, + provider_user_id=None, + display_name=None, + email=None, + normalized_email=None, + contact_id=None, + identity_id=None, + binding_id=None, + removal_reason=None, + reason_code=code, + reason_message=message, + directory_entry_payload=None, + contact_snapshot=None, + identity_snapshot=None, + created_at=now, + updated_at=now, + ) + self._session.add(sync_result_to_record(diagnostic)) + record.status = IMSyncRunStatus.FAILED + record.failed_count = 1 + record.started_at = record.started_at or now + record.finished_at = now + record.error_code = code + record.error_message = message + record.updated_at = now + self._session.flush() + + def _serialize_current_channel(self) -> bool: + """Acquire the current Channel row's database write lock portably.""" + + result = self._session.execute( + sa.update(HumanInputIMChannel) + .where( + HumanInputIMChannel.id == str(self._channel.id), + HumanInputIMChannel.config_version == self._channel.config_version, + HumanInputIMChannel.provider == self._channel.provider, + ) + .values({HumanInputIMChannel.updated_at: HumanInputIMChannel.updated_at}) + .execution_options(autoflush=False, synchronize_session=False) + ) + if not isinstance(result, CursorResult): + raise TypeError("conditional IM Channel guard did not return a cursor result") + return result.rowcount == 1 + + def load_sync_run(self, sync_run_id: IMSyncRunId) -> IMSyncRun | None: + record = self._session.get(HumanInputIMSyncRun, str(sync_run_id)) + return sync_run_from_record(record) if record is not None else None + + def load_latest_sync_run(self) -> IMSyncRun | None: + record = self._session.scalar( + sa.select(HumanInputIMSyncRun) + .where(HumanInputIMSyncRun.channel_id == str(self._channel.id)) + .order_by(HumanInputIMSyncRun.created_at.desc(), HumanInputIMSyncRun.id.desc()) + .limit(1) + ) + return sync_run_from_record(record) if record is not None else None + + def page_sync_results( + self, + sync_run_id: IMSyncRunId, + result_type: IMSyncResultType, + *, + page: int, + limit: int, + ) -> SyncResultPage: + if page < 1: + raise ValueError("page must be positive") + if limit < 1 or limit > _MAX_PAGE_LIMIT: + raise ValueError(f"limit must be between 1 and {_MAX_PAGE_LIMIT}") + predicate = ( + HumanInputIMSyncResult.sync_run_id == str(sync_run_id), + HumanInputIMSyncResult.result_type == result_type, + ) + total = self._session.scalar(sa.select(sa.func.count(HumanInputIMSyncResult.id)).where(*predicate)) or 0 + records = self._session.scalars( + sa.select(HumanInputIMSyncResult) + .where(*predicate) + .order_by(HumanInputIMSyncResult.created_at, HumanInputIMSyncResult.id) + .offset((page - 1) * limit) + .limit(limit) + ).all() + return SyncResultPage( + tuple(sync_result_from_record(record) for record in records), + page=page, + limit=limit, + total=total, + ) + + def search_identities( + self, + identities: IMIdentityRepository, + bindings: IMBindingRepository, + *, + keyword: str | None, + page: int, + limit: int, + ) -> SynchronizedIMIdentityPage: + identity_page = identities.search(keyword=keyword or "", page=page, limit=limit) + bound_identity_ids = {binding.identity_id for binding in bindings.list_all()} + return SynchronizedIMIdentityPage( + items=tuple( + SynchronizedIMIdentity( + id=identity.id, + provider=self._channel.provider, + provider_user_id=identity.provider_user_id, + display_name=identity.display_name, + email=identity.email, + binding_status=( + IMIdentityBindingStatus.BOUND + if identity.id in bound_identity_ids + else IMIdentityBindingStatus.UNBOUND + ), + ) + for identity in identity_page.items + ), + page=identity_page.page, + limit=identity_page.limit, + total=identity_page.total, + ) + + def append_sync_results(self, results: tuple[SyncResultFact, ...]) -> None: + for result in results: + self._session.add(sync_result_to_record(result)) + self._session.flush() + + +__all__ = ["SQLAlchemyIMControlPlaneRepository"] diff --git a/api/repositories/human_input_v2/im_message_inbox/__init__.py b/api/repositories/human_input_v2/im_message_inbox/__init__.py new file mode 100644 index 00000000000..f5c326970e3 --- /dev/null +++ b/api/repositories/human_input_v2/im_message_inbox/__init__.py @@ -0,0 +1 @@ +"""Persistence adapters for durable IM callback records.""" diff --git a/api/repositories/human_input_v2/im_message_inbox/mappers.py b/api/repositories/human_input_v2/im_message_inbox/mappers.py new file mode 100644 index 00000000000..45c16138ae8 --- /dev/null +++ b/api/repositories/human_input_v2/im_message_inbox/mappers.py @@ -0,0 +1,69 @@ +"""Mappings between durable callback contracts and ORM records.""" + +from datetime import UTC, datetime + +from core.human_input_v2.entities import IMProvider +from core.human_input_v2.im_integration.adapters.entities import AuthenticatedIMEvent +from core.human_input_v2.im_message_inbox import IMInboxRecord, IMInboxRecordId +from models.human_input_v2 import IMMessageEvent +from repositories.human_input_v2.im_channel_repository import IMChannelId + + +def _naive_utc(timestamp: datetime) -> datetime: + if timestamp.tzinfo is None or timestamp.utcoffset() is None: + raise ValueError("timestamp must be timezone-aware") + return timestamp.astimezone(UTC).replace(tzinfo=None) + + +def event_record( + *, + record_id: IMInboxRecordId, + channel_id: IMChannelId, + event: AuthenticatedIMEvent, + now: datetime, + request_ip_address: str | None = None, +) -> IMMessageEvent: + """Create one detached record containing immutable callback facts.""" + + record = IMMessageEvent( + channel_id=str(channel_id), + provider=event.provider, + tenant_id=event.provider_tenant_id, + event_id=event.event_id, + provider_event_time=event.occurred_at, + received_at=event.received_at, + provider_event_type=event.event_type, + ingress_kind=event.ingress_kind, + payload=event.payload, + request_ip_address=request_ip_address, + ) + record.id = str(record_id) + record.created_at = _naive_utc(now) + return record + + +def event_from_record(record: IMMessageEvent) -> AuthenticatedIMEvent: + """Reconstruct the exact authenticated Provider callback facts.""" + + return AuthenticatedIMEvent( + provider=IMProvider(record.provider), + provider_tenant_id=record.tenant_id, + event_id=record.event_id, + event_type=record.provider_event_type, + occurred_at=record.provider_event_time, + received_at=record.received_at, + ingress_kind=record.ingress_kind, + payload=record.payload, + ) + + +def inbox_record_from_model(record: IMMessageEvent) -> IMInboxRecord: + """Convert one database record at the repository trust boundary.""" + + return IMInboxRecord( + record_id=IMInboxRecordId(record.id), + channel_id=IMChannelId(record.channel_id), + event=event_from_record(record), + processed_at=record.processed_at, + request_ip_address=record.request_ip_address, + ) diff --git a/api/repositories/human_input_v2/im_message_inbox/repository.py b/api/repositories/human_input_v2/im_message_inbox/repository.py new file mode 100644 index 00000000000..94ef49c91a0 --- /dev/null +++ b/api/repositories/human_input_v2/im_message_inbox/repository.py @@ -0,0 +1,80 @@ +"""SQLAlchemy persistence for callbacks within caller-owned transactions.""" + +from datetime import UTC, datetime + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from core.human_input_v2.im_integration.adapters.entities import AuthenticatedIMEvent +from core.human_input_v2.im_message_inbox.contracts import ( + AcceptanceKind, + IMInboxRecord, + IMInboxRecordId, + InboxAcceptance, + InboxPersistenceError, + canonicalize_inbox_event, + validate_inbox_event, +) +from libs.uuid_utils import uuidv7 +from models.human_input_v2 import IMMessageEvent +from repositories.human_input_v2.im_channel_repository import IMChannelId + +from .mappers import event_record, inbox_record_from_model + + +def _naive_utc(timestamp: datetime) -> datetime: + if timestamp.tzinfo is None or timestamp.utcoffset() is None: + raise ValueError("timestamp must be timezone-aware") + return timestamp.astimezone(UTC).replace(tzinfo=None) + + +class SQLAlchemyIMMessageInboxRepository: + """Flush callback changes without owning Session or transaction lifetimes.""" + + def __init__(self, session: Session) -> None: + self._session = session + + def insert_or_resolve( + self, + channel_id: IMChannelId, + event: AuthenticatedIMEvent, + *, + now: datetime, + request_ip_address: str | None = None, + ) -> InboxAcceptance: + event = canonicalize_inbox_event(event) + validate_inbox_event(event) + if event.event_id is not None: + existing_id = self._session.scalar( + select(IMMessageEvent.id).where( + IMMessageEvent.provider == event.provider, + IMMessageEvent.event_id == event.event_id, + ) + ) + if existing_id is not None: + return InboxAcceptance(IMInboxRecordId(existing_id), AcceptanceKind.DUPLICATE) + record_id = IMInboxRecordId(str(uuidv7())) + self._session.add( + event_record( + record_id=record_id, + channel_id=channel_id, + event=event, + now=now, + request_ip_address=request_ip_address, + ) + ) + self._session.flush() + return InboxAcceptance(record_id, AcceptanceKind.NEW) + + def get(self, record_id: IMInboxRecordId) -> IMInboxRecord | None: + record = self._session.get(IMMessageEvent, str(record_id)) + return inbox_record_from_model(record) if record is not None else None + + def mark_processed(self, record_id: IMInboxRecordId, *, processed_at: datetime) -> None: + record = self._session.scalar( + select(IMMessageEvent).where(IMMessageEvent.id == str(record_id)).with_for_update() + ) + if record is None: + raise InboxPersistenceError("IM callback record does not exist") + if record.processed_at is None: + record.processed_at = _naive_utc(processed_at) diff --git a/api/repositories/human_input_v2/recipient_repository.py b/api/repositories/human_input_v2/recipient_repository.py new file mode 100644 index 00000000000..274f83cb320 --- /dev/null +++ b/api/repositories/human_input_v2/recipient_repository.py @@ -0,0 +1,135 @@ +"""Business persistence contract limited to HumanInputRecipient rows.""" + +from __future__ import annotations + +from collections.abc import Sequence +from dataclasses import dataclass +from typing import Protocol + +from pydantic import NaiveDatetime + +from core.human_input_v2.shared.values import ContactId, NormalizedEmail, RecipientId, TenantId +from core.workflow.nodes.human_input_v2.entities import RecipientConfig + + +@dataclass(frozen=True, slots=True) +class ContactRecipientSubject: + """A resolved Contact identity, independent of its delivery endpoints.""" + + contact_id: ContactId + + +@dataclass(frozen=True, slots=True) +class EmailRecipientSubject: + """A direct email recipient without a Contact identity.""" + + email: NormalizedEmail + + +@dataclass(frozen=True, slots=True) +class EndUserRecipientSubject: + """An EndUser whose app ownership has already been verified by the caller.""" + + end_user_id: str + + +type RecipientSubject = ContactRecipientSubject | EmailRecipientSubject | EndUserRecipientSubject + + +@dataclass(frozen=True, slots=True) +class Recipient: + """Persisted recipient independent of ORM lifetime and delivery channel. + + subject identifies a Contact, a direct email recipient, or an EndUser. + name is captured at creation and is None for subjects without a name; + it is never refreshed from a directory. + sources preserves the original configurations for provenance only. Reading + them does not resolve or refresh this recipient's identity. + """ + + id: RecipientId + tenant_id: TenantId + form_id: str + subject: RecipientSubject + sources: tuple[RecipientConfig, ...] + name: str | None + created_at: NaiveDatetime + updated_at: NaiveDatetime + + +@dataclass(frozen=True, slots=True) +class RecipientCreateParams: + """One concrete subject produced by the caller's recipient resolution. + + subject and name are supplied explicitly; the repository must not derive + them from sources. sources is an already-collected history + of configurations that selected this subject, stored without interpretation. + + Selector evaluation, initiator resolution, Contact expansion, and source + merging belong to the caller. Values are already validated and normalized + at their ingress boundaries; this value object performs no normalization. + """ + + subject: RecipientSubject + sources: tuple[RecipientConfig, ...] + name: str | None + + +class RecipientRepository(Protocol): + """Recipient operations scoped to one constructor-bound tenant and form. + + Every read and write includes both owner predicates. The caller establishes + the form's tenant/app ownership before binding the repository. Implementations + access only HumanInputRecipient, without looking up forms, contacts, EndUsers, + deliveries, attempts, or submission records. A successful lookup establishes + recipient membership in this form, not authentication of the current actor. + + Implementations map subject variants to the model's subject_type and + subject_value columns and reconstruct the variants on reads. Callers never + encode or interpret these persistence fields. + + Callers own the transaction so recipient creation can be atomic with form + creation. Implementations must not begin, commit, roll back, or create + savepoints in that transaction. The caller rolls back on write failure. + Persist and restore the supplied source configurations as provenance only. + Pydantic validation at the storage boundary checks their serialized shape; + it must not evaluate selectors, resolve identities, or query directories. + All timestamps use naive UTC. + """ + + def create_recipients(self, params: Sequence[RecipientCreateParams]) -> tuple[Recipient, ...]: + """Persist resolved recipients for form creation and return them in input order. + + Inputs contain one entry per distinct subject, with all its sources + already merged. Different subject variants never merge, and + different Contacts never merge merely because they share an email address. + + Atomically create missing subjects with generated IDs and timestamps. + Reuse existing recipients on retries, preserving their IDs, sources, + names, and timestamps. Concurrent creation may raise a database write + conflict; the caller must roll back and retry the whole transaction. + The repository does not recover or retry a failed transaction. This does not + replace the form's recipient set or remove recipients absent from params. + Empty input returns an empty tuple. On failure, the caller rolls back + the transaction so partial batch writes do not persist. + The caller only invokes this during form initialization; + delivery retries and workflow resume read the persisted recipients. + """ + ... + + def list_recipients(self) -> tuple[Recipient, ...]: + """Read frozen recipients for delivery planning, resume, or historical display. + + Return all recipients for the bound form in ascending ID order, or an + empty tuple when none exist. Do not re-resolve configuration sources or + refresh subject information from current directories. + """ + ... + + def get_recipient(self, recipient_id: RecipientId) -> Recipient | None: + """Read a delivery's candidate recipient or a recorded submitter. + + Return None when the recipient is missing or outside the bound tenant + and form. Actor verification and submission acceptance belong to callers. + """ + ... diff --git a/api/repositories/human_input_v2/sqlalchemy_audit_repository.py b/api/repositories/human_input_v2/sqlalchemy_audit_repository.py new file mode 100644 index 00000000000..0d85ca87780 --- /dev/null +++ b/api/repositories/human_input_v2/sqlalchemy_audit_repository.py @@ -0,0 +1,71 @@ +"""Persist form audits using a caller-owned Session and transaction.""" + +from typing import override + +from sqlalchemy.orm import Session + +from core.human_input_v2.shared.values import RecipientId, TenantId +from models.human_input_v2 import ( + FormViewEvent, + HumanInputAuditEvent, + IMCallbackVerificationEvent, + OtpSendEvent, + ServiceAPIFormSubmissionEvent, +) + +from .audit_repository import AuditRepository + + +class SQLAlchemyAuditRepository(AuditRepository): + def __init__(self, session: Session, tenant_id: TenantId, form_id: str) -> None: + self._session = session + self._tenant_id = tenant_id + self._form_id = form_id + + @override + def record_form_view(self, *, viewer_ip_address: str | None) -> None: + self._session.add( + HumanInputAuditEvent( + tenant_id=str(self._tenant_id), + form_id=self._form_id, + payload=FormViewEvent(viewer_ip_address=viewer_ip_address), + ) + ) + + @override + def record_otp_send( + self, *, recipient_id: RecipientId, recipient_email: str, sender_ip_address: str | None + ) -> None: + self._session.add( + HumanInputAuditEvent( + tenant_id=str(self._tenant_id), + form_id=self._form_id, + payload=OtpSendEvent( + recipient_id=str(recipient_id), + recipient_email=recipient_email, + sender_ip_address=sender_ip_address, + ), + ) + ) + + @override + def record_im_callback_verification(self, *, succeeded: bool, request_ip_address: str | None, reason: str) -> None: + self._session.add( + HumanInputAuditEvent( + tenant_id=str(self._tenant_id), + form_id=self._form_id, + payload=IMCallbackVerificationEvent( + succeeded=succeeded, request_ip_address=request_ip_address, reason=reason + ), + ) + ) + + @override + def record_service_api_submission(self, *, recipient_id: RecipientId, end_user_id: str) -> None: + self._session.add( + HumanInputAuditEvent( + tenant_id=str(self._tenant_id), + form_id=self._form_id, + payload=ServiceAPIFormSubmissionEvent(recipient_id=str(recipient_id), end_user_id=end_user_id), + ) + ) diff --git a/api/repositories/human_input_v2/sqlalchemy_contact_repository.py b/api/repositories/human_input_v2/sqlalchemy_contact_repository.py new file mode 100644 index 00000000000..59abe31540d --- /dev/null +++ b/api/repositories/human_input_v2/sqlalchemy_contact_repository.py @@ -0,0 +1,574 @@ +"""Session-bound SQLAlchemy Contact and Contact-IM read adapters. + +This module is the only production owner allowed to combine Contact identity, +Account, membership, Platform visibility, and External profile tables. The +repositories may flush but never create, commit, or roll back a Session. +""" + +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from datetime import datetime + +import sqlalchemy as sa +from sqlalchemy.dialects.mysql import insert as mysql_insert +from sqlalchemy.dialects.postgresql import insert as postgresql_insert +from sqlalchemy.dialects.sqlite import insert as sqlite_insert +from sqlalchemy.engine import Row, RowMapping +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session, aliased + +from core.human_input_v2.entities import IMBindingScope +from core.human_input_v2.shared import AccountId, ContactId, IMBindingId, NormalizedEmail, TenantId +from libs.datetime_utils import ensure_naive_utc, naive_utc_now +from libs.uuid_utils import uuidv7 +from models.account import Account, AccountStatus, TenantAccountJoin +from models.human_input_v2 import ( + ContactSubjectType, + HumanInputContactIdentity, + HumanInputExternalContactProfile, + HumanInputPlatformContactWorkspaceEntry, +) +from models.model import UploadFile +from repositories.human_input_v2.contact import ( + CandidateId, + Contact, + ContactError, + ContactErrorCode, + ContactQuery, + ContactType, + ExternalContact, + IMBinding, + OrganizationCandidate, + Page, +) +from repositories.human_input_v2.im_binding_repository import IMBindingKind +from repositories.human_input_v2.im_channel_repository import IMChannel +from repositories.human_input_v2.sqlalchemy_im_binding_repository import SQLAlchemyIMBindingRepository + +_CONTACT_ID = "contact_id" +_CONTACT_TYPE = "contact_type" +_NAME = "name" +_EMAIL = "email" +_AVATAR_FILE_ID = "avatar_file_id" +_CREATED_AT = "created_at" +_DEFAULT_CONTACT_QUERY = ContactQuery() + + +def _escape_like(value: str) -> str: + return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + + +class SQLAlchemyContactRepository: + """One concrete implementation of core and enterprise Contact ports.""" + + def __init__(self, session: Session) -> None: + self._session = session + + def count_contact(self, tenant_id: TenantId, query: ContactQuery = _DEFAULT_CONTACT_QUERY) -> int: + current_contacts = self._current_contacts_query(tenant_id, query).subquery() + return self._session.scalar(sa.select(sa.func.count()).select_from(current_contacts)) or 0 + + def list_contact( + self, + tenant_id: TenantId, + page: int, + limit: int, + query: ContactQuery = _DEFAULT_CONTACT_QUERY, + ) -> Page[Contact]: + if page < 1: + raise ValueError("page must be positive") + if limit < 1: + raise ValueError("limit must be positive") + rows = self._session.execute( + self._current_contacts_query(tenant_id, query) + .order_by(sa.column(_CREATED_AT), sa.column(_CONTACT_ID)) + .offset((page - 1) * limit) + .limit(limit) + ).mappings() + return Page(tuple(self._contact_from_row(row) for row in rows), page, limit) + + def get_contacts_by_id(self, tenant_id: TenantId, contact_id: ContactId) -> Contact | None: + contacts = self._get_contacts_by_distinct_ids(tenant_id, (contact_id,)) + return contacts[0] if contacts else None + + def get_contact_by_account_id(self, tenant_id: TenantId, account_id: AccountId) -> Contact | None: + current_contacts = self._current_contacts_subquery(tenant_id) + row = ( + self._session.execute( + sa.select(current_contacts) + .join(HumanInputContactIdentity, HumanInputContactIdentity.id == current_contacts.c.contact_id) + .where( + HumanInputContactIdentity.subject_type == ContactSubjectType.ACCOUNT, + HumanInputContactIdentity.account_id == str(account_id), + ) + ) + .mappings() + .one_or_none() + ) + return self._contact_from_row(row) if row is not None else None + + def get_contacts_by_ids( + self, + tenant_id: TenantId, + contact_ids: Sequence[ContactId], + ) -> Sequence[Contact]: + return self._get_contacts_by_distinct_ids(tenant_id, tuple(dict.fromkeys(contact_ids))) + + def available( + self, + tenant_id: TenantId, + contact_ids: Sequence[ContactId], + ) -> Mapping[ContactId, bool]: + distinct_ids = tuple(dict.fromkeys(contact_ids)) + available_ids = {contact.id for contact in self._get_contacts_by_distinct_ids(tenant_id, distinct_ids)} + return {contact_id: contact_id in available_ids for contact_id in distinct_ids} + + def query_contacts_by_email( + self, + tenant_id: TenantId, + emails: Sequence[str], + ) -> Sequence[Contact]: + normalized_emails = tuple(dict.fromkeys(str(NormalizedEmail(email)) for email in emails)) + if not normalized_emails: + return () + current_contacts = self._current_contacts_subquery(tenant_id) + rows = self._session.execute( + sa.select(current_contacts) + .where(sa.func.lower(current_contacts.c.email).in_(normalized_emails)) + .order_by(current_contacts.c.contact_id) + ).mappings() + return tuple(self._contact_from_row(row) for row in rows) + + def provision_account_backed_contact(self, account_id: AccountId) -> ContactId: + """Idempotently allocate the globally stable Contact ID for one Account.""" + + if self._session.get(Account, str(account_id)) is None: + raise ContactError(ContactErrorCode.ACCOUNT_NOT_FOUND, "Account does not exist") + existing_id = self._session.scalar( + sa.select(HumanInputContactIdentity.id).where( + HumanInputContactIdentity.subject_type == ContactSubjectType.ACCOUNT, + HumanInputContactIdentity.account_id == str(account_id), + ) + ) + if existing_id is not None: + return ContactId(existing_id) + + contact_id = str(uuidv7()) + now = naive_utc_now() + values = { + "id": contact_id, + "subject_type": ContactSubjectType.ACCOUNT, + "account_id": str(account_id), + "created_at": now, + "updated_at": now, + } + dialect_name = self._session.get_bind().dialect.name + try: + if dialect_name == "postgresql": + postgresql_statement = postgresql_insert(HumanInputContactIdentity).values(**values) + self._session.execute( + postgresql_statement.on_conflict_do_nothing( + constraint="human_input_contact_identities_account_id_uq", + ) + ) + elif dialect_name == "mysql": + mysql_statement = mysql_insert(HumanInputContactIdentity).values(**values) + self._session.execute( + mysql_statement.on_duplicate_key_update(account_id=mysql_statement.inserted.account_id) + ) + elif dialect_name == "sqlite": + sqlite_statement = sqlite_insert(HumanInputContactIdentity).values(**values) + self._session.execute(sqlite_statement.on_conflict_do_nothing(index_elements=["account_id"])) + else: + identity = HumanInputContactIdentity( + subject_type=ContactSubjectType.ACCOUNT, + account_id=str(account_id), + ) + identity.id = contact_id + identity.created_at = now + identity.updated_at = now + self._session.add(identity) + self._session.flush() + except IntegrityError as error: + raise ContactError(ContactErrorCode.CONFLICT, "Account Contact provisioning conflicted") from error + + stored_identity_statement = sa.select(HumanInputContactIdentity.id).where( + HumanInputContactIdentity.subject_type == ContactSubjectType.ACCOUNT, + HumanInputContactIdentity.account_id == str(account_id), + ) + if dialect_name == "mysql": + # InnoDB locking reads are current reads, so the transaction that + # waited on the duplicate key can see the committed winning row + # despite an earlier REPEATABLE READ snapshot. + stored_identity_statement = stored_identity_statement.with_for_update() + stored_id = self._session.scalar(stored_identity_statement) + if stored_id is None: + raise RuntimeError("Account Contact provisioning did not produce an identity") + return ContactId(stored_id) + + def delete_account_identity_after_failed_creation(self, account_id: AccountId) -> None: + """Remove an identity only while its authoritative Account creation is being compensated.""" + + self._session.execute( + sa.delete(HumanInputContactIdentity).where( + HumanInputContactIdentity.subject_type == ContactSubjectType.ACCOUNT, + HumanInputContactIdentity.account_id == str(account_id), + ) + ) + self._session.flush() + + def save_external_contact(self, tenant_id: TenantId, external_contact: ExternalContact) -> Contact: + """Create identity/profile together or update only the existing profile.""" + + self._ensure_external_avatar_owned(tenant_id, external_contact.avatar_file_id) + identity = self._session.get(HumanInputContactIdentity, str(external_contact.id)) + profile = self._session.scalar( + sa.select(HumanInputExternalContactProfile).where( + HumanInputExternalContactProfile.contact_id == str(external_contact.id), + HumanInputExternalContactProfile.tenant_id == str(tenant_id), + ) + ) + if identity is None and profile is None: + identity = HumanInputContactIdentity(subject_type=ContactSubjectType.EXTERNAL) + identity.id = str(external_contact.id) + identity.created_at = external_contact.created_at + identity.updated_at = external_contact.created_at + profile = HumanInputExternalContactProfile( + contact_id=str(external_contact.id), + tenant_id=str(tenant_id), + name=external_contact.name, + normalized_name=external_contact.name.casefold(), + email=external_contact.email, + normalized_email=str(NormalizedEmail(external_contact.email)), + avatar_file_id=external_contact.avatar_file_id, + ) + profile.created_at = external_contact.created_at + profile.updated_at = external_contact.created_at + self._session.add_all((identity, profile)) + elif ( + identity is None + or identity.subject_type is not ContactSubjectType.EXTERNAL + or identity.account_id is not None + or profile is None + ): + raise ContactError(ContactErrorCode.INVALID_OWNER, "External Contact is not owned by this tenant") + else: + profile.name = external_contact.name + profile.normalized_name = external_contact.name.casefold() + profile.email = external_contact.email + profile.normalized_email = str(NormalizedEmail(external_contact.email)) + profile.avatar_file_id = external_contact.avatar_file_id + profile.updated_at = naive_utc_now() + + try: + self._session.flush() + except IntegrityError as error: + raise ContactError(ContactErrorCode.CONFLICT, "External Contact email already exists") from error + stored = self.get_contacts_by_id(tenant_id, external_contact.id) + if stored is None or stored.type is not ContactType.EXTERNAL: + raise RuntimeError("External Contact write did not produce a current Contact") + return stored + + def delete_external_contact(self, tenant_id: TenantId, external_contact: ExternalContact) -> None: + profile = self._session.scalar( + sa.select(HumanInputExternalContactProfile).where( + HumanInputExternalContactProfile.contact_id == str(external_contact.id), + HumanInputExternalContactProfile.tenant_id == str(tenant_id), + ) + ) + identity = self._session.scalar( + sa.select(HumanInputContactIdentity).where( + HumanInputContactIdentity.id == str(external_contact.id), + HumanInputContactIdentity.subject_type == ContactSubjectType.EXTERNAL, + HumanInputContactIdentity.account_id.is_(None), + ) + ) + if profile is None or identity is None: + raise ContactError(ContactErrorCode.NOT_FOUND, "External Contact was not found") + self._session.delete(profile) + self._session.delete(identity) + self._session.flush() + + def list_organization_candidates( + self, + page: int, + limit: int, + keyword: str = "", + ) -> Sequence[OrganizationCandidate]: + if page < 1: + raise ValueError("page must be positive") + if limit < 1: + raise ValueError("limit must be positive") + rows = self._session.execute( + self._organization_candidates_query(keyword) + .order_by(Account.created_at, HumanInputContactIdentity.id) + .offset((page - 1) * limit) + .limit(limit) + ).all() + return tuple(self._candidate_from_row(row) for row in rows) + + def count_organization_candidates(self, keyword: str = "") -> int: + candidates = self._organization_candidates_query(keyword).subquery() + return self._session.scalar(sa.select(sa.func.count()).select_from(candidates)) or 0 + + def create_platform_entry( + self, + tenant_id: TenantId, + candidate_id: ContactId, + added_by_account_id: AccountId, + ) -> None: + candidate = self._session.execute( + self._organization_candidates_query("").where( + HumanInputContactIdentity.id == str(candidate_id), + ) + ).one_or_none() + if candidate is None: + raise ContactError(ContactErrorCode.NOT_FOUND, "Organization candidate was not found") + now = naive_utc_now() + values = { + "id": str(uuidv7()), + "tenant_id": str(tenant_id), + "contact_id": str(candidate_id), + "added_by_account_id": str(added_by_account_id), + "created_at": now, + "updated_at": now, + } + dialect_name = self._session.get_bind().dialect.name + try: + if dialect_name == "postgresql": + postgresql_statement = postgresql_insert(HumanInputPlatformContactWorkspaceEntry).values(**values) + self._session.execute( + postgresql_statement.on_conflict_do_nothing(constraint="hipcwe_tenant_contact_uq") + ) + elif dialect_name == "mysql": + mysql_statement = mysql_insert(HumanInputPlatformContactWorkspaceEntry).values(**values) + self._session.execute( + mysql_statement.on_duplicate_key_update(contact_id=mysql_statement.inserted.contact_id) + ) + elif dialect_name == "sqlite": + sqlite_statement = sqlite_insert(HumanInputPlatformContactWorkspaceEntry).values(**values) + self._session.execute( + sqlite_statement.on_conflict_do_nothing(index_elements=["tenant_id", "contact_id"]) + ) + else: + existing_id = self._session.scalar( + sa.select(HumanInputPlatformContactWorkspaceEntry.id).where( + HumanInputPlatformContactWorkspaceEntry.tenant_id == str(tenant_id), + HumanInputPlatformContactWorkspaceEntry.contact_id == str(candidate_id), + ) + ) + if existing_id is None: + self._session.add( + HumanInputPlatformContactWorkspaceEntry( + tenant_id=str(tenant_id), + contact_id=str(candidate_id), + added_by_account_id=str(added_by_account_id), + ) + ) + self._session.flush() + except IntegrityError as error: + raise ContactError(ContactErrorCode.CONFLICT, "Platform Contact entry conflicted") from error + + def delete_platform_entry(self, tenant_id: TenantId, contact_id: ContactId) -> None: + self._session.execute( + sa.delete(HumanInputPlatformContactWorkspaceEntry).where( + HumanInputPlatformContactWorkspaceEntry.tenant_id == str(tenant_id), + HumanInputPlatformContactWorkspaceEntry.contact_id == str(contact_id), + ) + ) + self._session.flush() + + def _ensure_external_avatar_owned(self, tenant_id: TenantId, avatar_file_id: str | None) -> None: + if avatar_file_id is None: + return + owned_avatar_id = self._session.scalar( + sa.select(UploadFile.id).where( + UploadFile.id == avatar_file_id, + UploadFile.tenant_id == str(tenant_id), + ) + ) + if owned_avatar_id is None: + raise ContactError(ContactErrorCode.NOT_FOUND, "External Contact avatar was not found") + + def _get_contacts_by_distinct_ids( + self, + tenant_id: TenantId, + contact_ids: Sequence[ContactId], + ) -> tuple[Contact, ...]: + if not contact_ids: + return () + current_contacts = self._current_contacts_subquery(tenant_id) + rows = self._session.execute( + sa.select(current_contacts) + .where(current_contacts.c.contact_id.in_([str(contact_id) for contact_id in contact_ids])) + .order_by(current_contacts.c.contact_id) + ).mappings() + return tuple(self._contact_from_row(row) for row in rows) + + def _current_contacts_query(self, tenant_id: TenantId, query: ContactQuery): + current_contacts = self._current_contacts_subquery(tenant_id) + statement = sa.select(current_contacts) + if query.contact_type is not None: + statement = statement.where(current_contacts.c.contact_type == query.contact_type.value) + normalized_keyword = query.keyword.strip().casefold() + if normalized_keyword: + pattern = f"%{_escape_like(normalized_keyword)}%" + statement = statement.where( + sa.or_( + sa.func.lower(current_contacts.c.name).like(pattern, escape="\\"), + sa.func.lower(current_contacts.c.email).like(pattern, escape="\\"), + ) + ) + return statement + + @staticmethod + def _current_contacts_subquery(tenant_id: TenantId): + membership = aliased(TenantAccountJoin) + platform_entry = aliased(HumanInputPlatformContactWorkspaceEntry) + account_contacts = ( + sa.select( + HumanInputContactIdentity.id.label(_CONTACT_ID), + sa.case( + (membership.id.is_not(None), ContactType.WORKSPACE.value), + else_=ContactType.PLATFORM.value, + ).label(_CONTACT_TYPE), + Account.name.label(_NAME), + Account.email.label(_EMAIL), + sa.cast(Account.avatar, sa.String(255)).label(_AVATAR_FILE_ID), + HumanInputContactIdentity.created_at.label(_CREATED_AT), + ) + .join(Account, Account.id == HumanInputContactIdentity.account_id) + .outerjoin( + membership, + sa.and_( + membership.tenant_id == str(tenant_id), + membership.account_id == HumanInputContactIdentity.account_id, + ), + ) + .outerjoin( + platform_entry, + sa.and_( + platform_entry.tenant_id == str(tenant_id), + platform_entry.contact_id == HumanInputContactIdentity.id, + ), + ) + .where( + HumanInputContactIdentity.subject_type == ContactSubjectType.ACCOUNT, + Account.status == AccountStatus.ACTIVE, + sa.or_(membership.id.is_not(None), platform_entry.id.is_not(None)), + ) + ) + external_contacts = ( + sa.select( + HumanInputContactIdentity.id.label(_CONTACT_ID), + sa.literal(ContactType.EXTERNAL.value).label(_CONTACT_TYPE), + HumanInputExternalContactProfile.name.label(_NAME), + HumanInputExternalContactProfile.email.label(_EMAIL), + sa.cast(HumanInputExternalContactProfile.avatar_file_id, sa.String(255)).label(_AVATAR_FILE_ID), + HumanInputContactIdentity.created_at.label(_CREATED_AT), + ) + .join( + HumanInputExternalContactProfile, + HumanInputExternalContactProfile.contact_id == HumanInputContactIdentity.id, + ) + .where( + HumanInputContactIdentity.subject_type == ContactSubjectType.EXTERNAL, + HumanInputContactIdentity.account_id.is_(None), + HumanInputExternalContactProfile.tenant_id == str(tenant_id), + ) + ) + return sa.union_all(account_contacts, external_contacts).subquery("current_contacts") + + @staticmethod + def _organization_candidates_query(keyword: str): + statement = ( + sa.select(HumanInputContactIdentity, Account) + .join(Account, Account.id == HumanInputContactIdentity.account_id) + .where( + HumanInputContactIdentity.subject_type == ContactSubjectType.ACCOUNT, + Account.status == AccountStatus.ACTIVE, + ) + ) + normalized_keyword = keyword.strip().casefold() + if normalized_keyword: + pattern = f"%{_escape_like(normalized_keyword)}%" + statement = statement.where( + sa.or_( + sa.func.lower(Account.name).like(pattern, escape="\\"), + sa.func.lower(Account.email).like(pattern, escape="\\"), + ) + ) + return statement + + @staticmethod + def _contact_from_row(row: RowMapping) -> Contact: + created_at = row[_CREATED_AT] + if not isinstance(created_at, datetime): + raise TypeError("Contact creation time is not a datetime") + return Contact( + id=ContactId(row[_CONTACT_ID]), + type=ContactType(row[_CONTACT_TYPE]), + name=row[_NAME], + email=row[_EMAIL], + avatar_file_id=row[_AVATAR_FILE_ID], + created_at=ensure_naive_utc(created_at), + ) + + @staticmethod + def _candidate_from_row(row: Row[tuple[HumanInputContactIdentity, Account]]) -> OrganizationCandidate: + identity, account = row + return OrganizationCandidate( + id=CandidateId(identity.id), + name=account.name, + email=account.email, + avatar_file_id=account.avatar, + created_at=ensure_naive_utc(identity.created_at), + ) + + +class SQLAlchemyContactIMBindingRepository: + """Contact projection over one already-resolved current IM Channel.""" + + # TODO(QuantumGhost): channel should not be None here. + def __init__(self, session: Session, channel: IMChannel | None) -> None: + self._session = session + self._channel = channel + + def get_im_bindings( + self, + tenant_id: TenantId, + contact_ids: Sequence[ContactId], + ) -> Sequence[IMBinding]: + distinct_ids = tuple(dict.fromkeys(contact_ids)) + if not distinct_ids or self._channel is None: + return () + contact_repository = SQLAlchemyContactRepository(self._session) + current_account_contact_ids = { + contact.id + for contact in contact_repository.get_contacts_by_ids(tenant_id, distinct_ids) + if contact.type is not ContactType.EXTERNAL + } + if not current_account_contact_ids: + return () + records = SQLAlchemyIMBindingRepository(self._session, self._channel.id).get_effective_many( + tenant_id, + tuple(current_account_contact_ids), + ) + return tuple( + IMBinding( + id=IMBindingId(record.id), + scope=( + IMBindingScope.WORKSPACE + if record.kind is IMBindingKind.WORKSPACE_OVERRIDE + else IMBindingScope.ORGANIZATION + ), + contact_id=record.contact_id, + identity_id=record.identity_id, + provider=self._channel.provider, + ) + for record in records + ) + + +__all__ = ["SQLAlchemyContactIMBindingRepository", "SQLAlchemyContactRepository"] diff --git a/api/repositories/human_input_v2/sqlalchemy_delivery_attempt_repository.py b/api/repositories/human_input_v2/sqlalchemy_delivery_attempt_repository.py new file mode 100644 index 00000000000..a0e032dacfe --- /dev/null +++ b/api/repositories/human_input_v2/sqlalchemy_delivery_attempt_repository.py @@ -0,0 +1,78 @@ +"""Append-only storage for completed delivery attempts.""" + +from __future__ import annotations + +from typing import override + +import sqlalchemy as sa +from sqlalchemy.orm import Session + +from core.human_input_v2.shared.values import TenantId +from libs.datetime_utils import naive_utc_now +from libs.uuid_utils import uuidv7 +from models.human_input_v2 import DeliveryResponse, HumanInputDelivery, HumanInputDeliveryAttempt + +from .delivery_attempt_repository import ( + DeliveryAttempt, + DeliveryAttemptCreateParams, + DeliveryAttemptRepository, +) + + +class SQLAlchemyDeliveryAttemptRepository(DeliveryAttemptRepository): + def __init__(self, session: Session, tenant_id: TenantId, form_id: str) -> None: + self._session = session + self._tenant_id = tenant_id + self._form_id = form_id + + def _query(self) -> sa.Select[tuple[HumanInputDeliveryAttempt]]: + return ( + sa.select(HumanInputDeliveryAttempt) + .where( + HumanInputDeliveryAttempt.tenant_id == self._tenant_id, + HumanInputDeliveryAttempt.form_id == self._form_id, + ) + .execution_options(autoflush=False, populate_existing=True) + ) + + @override + def record_attempt(self, delivery_id: str, params: DeliveryAttemptCreateParams) -> DeliveryAttempt | None: + delivery = sa.select(HumanInputDelivery.id).where( + HumanInputDelivery.id == delivery_id, + HumanInputDelivery.tenant_id == self._tenant_id, + HumanInputDelivery.form_id == self._form_id, + ) + attempt_id = str(uuidv7()) + now = naive_utc_now() + table = HumanInputDeliveryAttempt.__table__ + values = { + "id": attempt_id, + "tenant_id": self._tenant_id, + "form_id": self._form_id, + "delivery_id": delivery_id, + "status": params.status, + "error_message": params.error_message, + "response": DeliveryResponse(root=dict(params.response)), + "created_at": now, + "updated_at": now, + } + candidate = sa.select( + *(sa.literal(value, type_=table.c[column].type) for column, value in values.items()) + ).where(delivery.exists()) + self._session.execute( + sa.insert(HumanInputDeliveryAttempt).from_select(list(values), candidate).execution_options(autoflush=False) + ) + record = self._session.scalars(self._query().where(HumanInputDeliveryAttempt.id == attempt_id)).one_or_none() + if record is None: + return None + return DeliveryAttempt( + id=record.id, + tenant_id=TenantId(record.tenant_id), + form_id=record.form_id, + delivery_id=record.delivery_id, + status=record.status, + error_message=record.error_message, + response=record.response.root, + created_at=record.created_at, + updated_at=record.updated_at, + ) diff --git a/api/repositories/human_input_v2/sqlalchemy_delivery_repository.py b/api/repositories/human_input_v2/sqlalchemy_delivery_repository.py new file mode 100644 index 00000000000..ebd34a29daf --- /dev/null +++ b/api/repositories/human_input_v2/sqlalchemy_delivery_repository.py @@ -0,0 +1,141 @@ +"""Owner-scoped delivery access and accepted IM message identity storage.""" + +from __future__ import annotations + +from typing import override + +import sqlalchemy as sa +from sqlalchemy.orm import Session + +from core.human_input_v2.im_integration.adapters.message_locator import MessageLocator +from core.human_input_v2.shared.values import RecipientId, TenantId +from libs.datetime_utils import naive_utc_now +from libs.uuid_utils import uuidv7 +from models.human_input_v2 import HumanInputDelivery + +from .delivery_repository import ( + Delivery, + DeliveryCreateParams, + DeliveryRepository, + DeliveryTargetType, + IMUserTargetSnapshot, +) + + +def _to_delivery(record: HumanInputDelivery) -> Delivery: + return Delivery( + id=record.id, + tenant_id=TenantId(record.tenant_id), + form_id=record.form_id, + recipient_id=RecipientId(record.recipient_id), + token_hash=record.token_hash, + auth_type=record.auth_type, + target_snapshot=record.target_snapshot, + created_at=record.created_at, + updated_at=record.updated_at, + ) + + +class SQLAlchemyDeliveryRepository(DeliveryRepository): + def __init__(self, session: Session) -> None: + self._session = session + + def _query(self) -> sa.Select[tuple[HumanInputDelivery]]: + return sa.select(HumanInputDelivery).execution_options(autoflush=False, populate_existing=True) + + @override + def list_deliveries(self, *, tenant_id: TenantId, form_id: str) -> tuple[Delivery, ...]: + records = self._session.scalars( + self._query() + .where(HumanInputDelivery.tenant_id == tenant_id, HumanInputDelivery.form_id == form_id) + .order_by(HumanInputDelivery.id) + ) + return tuple(_to_delivery(record) for record in records) + + @override + def get_delivery(self, *, tenant_id: TenantId, form_id: str, delivery_id: str) -> Delivery | None: + record = self._session.scalars( + self._query().where( + HumanInputDelivery.tenant_id == tenant_id, + HumanInputDelivery.form_id == form_id, + HumanInputDelivery.id == delivery_id, + ) + ).one_or_none() + return _to_delivery(record) if record is not None else None + + @override + def set_im_message_locator( + self, *, tenant_id: TenantId, form_id: str, delivery_id: str, message_locator: MessageLocator + ) -> Delivery | None: + owner_predicates = ( + HumanInputDelivery.tenant_id == tenant_id, + HumanInputDelivery.form_id == form_id, + HumanInputDelivery.id == delivery_id, + ) + # A delivery owns one message. Serialize acknowledgements before assigning + # its identity so retries cannot redirect updates to another message. + record = self._session.scalars(self._query().where(*owner_predicates).with_for_update()).one_or_none() + if record is None: + return None + target = record.target_snapshot + if not isinstance(target, IMUserTargetSnapshot): + raise ValueError("Only an IM delivery can retain a message locator") + if target.message_locator is not None: + if target.message_locator != message_locator: + raise ValueError(f"IM delivery {delivery_id} already has a different message locator") + return _to_delivery(record) + self._session.execute( + sa.update(HumanInputDelivery) + .where(*owner_predicates) + .values( + { + HumanInputDelivery.target_snapshot: target.model_copy(update={"message_locator": message_locator}), + HumanInputDelivery.updated_at: naive_utc_now(), + } + ) + .execution_options(autoflush=False) + ) + record = self._session.scalars(self._query().where(*owner_predicates)).one() + return _to_delivery(record) + + @override + def create_delivery(self, *, tenant_id: TenantId, form_id: str, params: DeliveryCreateParams) -> Delivery: + delivery_id = str(uuidv7()) + now = naive_utc_now() + values = { + "id": delivery_id, + "tenant_id": tenant_id, + "form_id": form_id, + "recipient_id": params.recipient_id, + "token_hash": params.token_hash, + "auth_type": params.auth_type, + "target_type": params.target_snapshot.type, + "target_snapshot": params.target_snapshot, + "created_at": now, + "updated_at": now, + } + self._session.execute(sa.insert(HumanInputDelivery).values(**values).execution_options(autoflush=False)) + record = self._session.scalars( + self._query().where( + HumanInputDelivery.tenant_id == tenant_id, + HumanInputDelivery.form_id == form_id, + HumanInputDelivery.id == delivery_id, + ) + ).one() + return _to_delivery(record) + + @override + def get_delivery_by_token_hash(self, token_hash: str) -> Delivery | None: + record = self._session.scalars(self._query().where(HumanInputDelivery.token_hash == token_hash)).one_or_none() + return _to_delivery(record) if record is not None else None + + @override + def get_initiator_delivery(self, *, tenant_id: TenantId, form_id: str) -> Delivery | None: + record = self._session.scalars( + self._query().where( + HumanInputDelivery.tenant_id == tenant_id, + HumanInputDelivery.form_id == form_id, + HumanInputDelivery.target_type == DeliveryTargetType.INITIATOR, + ) + ).one_or_none() + return _to_delivery(record) if record is not None else None diff --git a/api/repositories/human_input_v2/sqlalchemy_file_upload_repository.py b/api/repositories/human_input_v2/sqlalchemy_file_upload_repository.py new file mode 100644 index 00000000000..16c8a0f150e --- /dev/null +++ b/api/repositories/human_input_v2/sqlalchemy_file_upload_repository.py @@ -0,0 +1,41 @@ +"""Caller-session queries for the original workflow or delivery-test creator.""" + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from models.account import Account, TenantAccountJoin +from models.enums import CreatorUserRole +from models.model import App, EndUser +from repositories.human_input_v2.file_upload_repository import FileUploadOwner, FileUploadRepository + + +class SQLAlchemyFileUploadRepository(FileUploadRepository): + def __init__(self, session: Session) -> None: + self._session = session + + def get_owner( + self, *, tenant_id: str, app_id: str, owner_id: str, owner_role: CreatorUserRole + ) -> FileUploadOwner | None: + if owner_role == CreatorUserRole.END_USER: + identity = self._session.scalar( + select(EndUser.id).where( + EndUser.id == owner_id, EndUser.tenant_id == tenant_id, EndUser.app_id == app_id + ) + ) + elif owner_role == CreatorUserRole.ACCOUNT: + identity = self._session.scalar( + select(Account.id) + .join(TenantAccountJoin, TenantAccountJoin.account_id == Account.id) + .where(Account.id == owner_id, TenantAccountJoin.tenant_id == tenant_id) + ) + else: + return None + return FileUploadOwner(id=identity, creator_role=owner_role) if identity is not None else None + + def get_delivery_test_owner(self, *, tenant_id: str, app_id: str) -> FileUploadOwner | None: + creator_id = self._session.scalar(select(App.created_by).where(App.id == app_id, App.tenant_id == tenant_id)) + if creator_id is None: + return None + return self.get_owner( + tenant_id=tenant_id, app_id=app_id, owner_id=creator_id, owner_role=CreatorUserRole.ACCOUNT + ) diff --git a/api/repositories/human_input_v2/sqlalchemy_form_repository.py b/api/repositories/human_input_v2/sqlalchemy_form_repository.py new file mode 100644 index 00000000000..3ff7816670f --- /dev/null +++ b/api/repositories/human_input_v2/sqlalchemy_form_repository.py @@ -0,0 +1,489 @@ +"""Owner-bound form persistence and cross-owner expiration discovery.""" + +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from datetime import datetime +from typing import Self, override + +import sqlalchemy as sa +from pydantic import BaseModel, ConfigDict, Json, JsonValue, NaiveDatetime, TypeAdapter, model_validator +from pydantic_core import PydanticCustomError +from sqlalchemy.dialects.mysql import insert as mysql_insert +from sqlalchemy.dialects.postgresql import insert as postgresql_insert +from sqlalchemy.dialects.sqlite import insert as sqlite_insert +from sqlalchemy.orm import Session + +from core.human_input_v2.resolved_form import ResolvedForm +from core.human_input_v2.shared.values import RecipientId, TenantId +from core.workflow.nodes.human_input.enums import HumanInputFormKind, HumanInputFormStatus +from graphon.variables.segments import Segment, SerializableSegment +from libs.datetime_utils import naive_utc_now +from libs.uuid_utils import uuidv7 +from models.human_input_v2 import HumanInputForm +from models.model import App + +from .form_repository import ( + Form, + FormCreateParams, + FormExpirationBatch, + FormExpirationRun, + FormRepository, + FormSubmission, + FormSubmissionFailure, +) + +_RAW_INPUTS = TypeAdapter(Mapping[str, JsonValue]) +_NORMALIZED_INPUTS = TypeAdapter(Mapping[str, Segment]) +_EXECUTION_KEY = ("workflow_run_id", "node_execution_id") + + +class _StoredForm(BaseModel): + """Validate database values once before constructing a repository snapshot.""" + + model_config = ConfigDict(from_attributes=True, strict=True) + + id: str + tenant_id: TenantId + app_id: str + workflow_run_id: str | None + node_execution_id: str | None + form_kind: HumanInputFormKind + status: HumanInputFormStatus + resolved_form: ResolvedForm + created_at: NaiveDatetime + updated_at: NaiveDatetime + expiration_time: NaiveDatetime + global_timeout_deadline: NaiveDatetime + submitted_at: NaiveDatetime | None + submitted_by_recipient_id: RecipientId | None + submitted_by_delivery_id: str | None + selected_action_id: str | None + raw_submission_inputs: Json[dict[str, JsonValue]] | None + normalized_submission_data: Json[dict[str, SerializableSegment]] | None + + @model_validator(mode="after") + def validate_facts(self) -> Self: + execution_ids = (self.workflow_run_id, self.node_execution_id) + if self.form_kind == HumanInputFormKind.RUNTIME: + if any(identifier is None for identifier in execution_ids): + raise ValueError("Runtime forms require both execution IDs") + elif any(identifier is not None for identifier in execution_ids): + raise ValueError("Delivery-test forms must not have execution IDs") + submission_fields = ( + self.submitted_at, + self.submitted_by_recipient_id, + self.submitted_by_delivery_id, + self.selected_action_id, + self.raw_submission_inputs, + self.normalized_submission_data, + ) + if self.status == HumanInputFormStatus.SUBMITTED: + if any(value is None for value in submission_fields): + raise PydanticCustomError("submission_data", "Submitted forms require a complete submission") + elif any(value is not None for value in submission_fields): + raise PydanticCustomError("submission_data", "Only submitted forms may contain submission data") + return self + + def to_form(self) -> Form: + submission = None + if self.status == HumanInputFormStatus.SUBMITTED: + # Validation above establishes this all-or-none boundary contract. + assert self.submitted_at is not None + assert self.submitted_by_recipient_id is not None + assert self.submitted_by_delivery_id is not None + assert self.selected_action_id is not None + assert self.raw_submission_inputs is not None + assert self.normalized_submission_data is not None + submission = FormSubmission( + submitted_at=self.submitted_at, + submitted_by_recipient_id=self.submitted_by_recipient_id, + submitted_by_delivery_id=self.submitted_by_delivery_id, + selected_action_id=self.selected_action_id, + raw_submission_inputs=self.raw_submission_inputs, + normalized_submission_data=self.normalized_submission_data, + ) + return Form( + id=self.id, + tenant_id=self.tenant_id, + app_id=self.app_id, + workflow_run_id=self.workflow_run_id, + node_execution_id=self.node_execution_id, + form_kind=self.form_kind, + status=self.status, + resolved_form=self.resolved_form, + created_at=self.created_at, + updated_at=self.updated_at, + expiration_time=self.expiration_time, + global_timeout_deadline=self.global_timeout_deadline, + submission=submission, + ) + + +def _form_from_record(record: HumanInputForm) -> Form: + return _StoredForm.model_validate(record).to_form() + + +def _terminal_submission_failure(status: HumanInputFormStatus) -> FormSubmissionFailure | None: + match status: + case HumanInputFormStatus.SUBMITTED: + return FormSubmissionFailure.ALREADY_SUBMITTED + case HumanInputFormStatus.EXPIRED: + return FormSubmissionFailure.GLOBAL_TIMEOUT + case HumanInputFormStatus.TIMEOUT: + return FormSubmissionFailure.FORM_EXPIRED + case HumanInputFormStatus.WAITING: + return None + + +class SQLAlchemyFormRepository(FormRepository): + """Flush form changes into a caller-owned transaction without committing it.""" + + def __init__(self, session: Session, tenant_id: TenantId, app_id: str) -> None: + self._session = session + self._tenant_id = tenant_id + self._app_id = app_id + + @staticmethod + def find_app_id(session: Session, *, tenant_id: TenantId, form_id: str) -> str | None: + """Recover the form owner for an already located delivery.""" + return session.scalar( + sa.select(HumanInputForm.app_id).where( + HumanInputForm.tenant_id == tenant_id, + HumanInputForm.id == form_id, + ) + ) + + @classmethod + def for_existing_form(cls, session: Session, *, tenant_id: TenantId, form_id: str) -> Self | None: + """Bind the app owner of a tenant/form reference resolved from a delivery. + + Check the owning App when its ID is not yet known to the caller. Bound + operations retain the complete tenant/app scope and caller's Session. + """ + app_id = session.scalar( + sa.select(HumanInputForm.app_id) + .join(App, App.id == HumanInputForm.app_id) + .where( + HumanInputForm.id == form_id, + HumanInputForm.tenant_id == tenant_id, + App.tenant_id == tenant_id, + ) + ) + return cls(session, tenant_id, app_id) if app_id is not None else None + + def _query(self) -> sa.Select[tuple[HumanInputForm]]: + return ( + sa.select(HumanInputForm) + .where( + HumanInputForm.tenant_id == self._tenant_id, + HumanInputForm.app_id == self._app_id, + ) + .execution_options(autoflush=False, populate_existing=True) + ) + + @override + def get_form(self, workflow_run_id: str, node_execution_id: str) -> Form | None: + record = self._session.scalar( + self._query().where( + HumanInputForm.form_kind == HumanInputFormKind.RUNTIME, + HumanInputForm.workflow_run_id == workflow_run_id, + HumanInputForm.node_execution_id == node_execution_id, + ) + ) + return _form_from_record(record) if record is not None else None + + @override + def get_form_by_id(self, form_id: str) -> Form | None: + record = self._session.scalar(self._query().where(HumanInputForm.id == form_id)) + return _form_from_record(record) if record is not None else None + + @override + def get_forms_by_ids(self, *, workflow_run_id: str, form_ids: Sequence[str]) -> tuple[Form, ...]: + if not form_ids: + return () + records = self._session.scalars( + self._query().where( + HumanInputForm.id.in_(form_ids), + HumanInputForm.workflow_run_id == workflow_run_id, + ) + ) + return tuple(_form_from_record(record) for record in records) + + @override + def create_form(self, params: FormCreateParams) -> Form: + execution_ids = (params.workflow_run_id, params.node_execution_id) + if params.form_kind == HumanInputFormKind.RUNTIME: + if any(identifier is None for identifier in execution_ids): + raise ValueError("Runtime forms require both execution IDs") + elif any(identifier is not None for identifier in execution_ids): + raise ValueError("Delivery-test forms must not have execution IDs") + + form_id = str(uuidv7()) + now = naive_utc_now() + values = { + "id": form_id, + "tenant_id": self._tenant_id, + "app_id": self._app_id, + "workflow_run_id": params.workflow_run_id, + "node_execution_id": params.node_execution_id, + "form_kind": params.form_kind, + "status": HumanInputFormStatus.WAITING, + "resolved_form": params.resolved_form, + "expiration_time": params.expiration_time, + "global_timeout_deadline": params.global_timeout_deadline, + "created_at": now, + "updated_at": now, + "submitted_at": None, + "submitted_by_recipient_id": None, + "submitted_by_delivery_id": None, + "selected_action_id": None, + "raw_submission_inputs": None, + "normalized_submission_data": None, + } + statement: sa.Insert + if params.form_kind == HumanInputFormKind.DELIVERY_TEST: + statement = sa.insert(HumanInputForm).values(values) + else: + # Insert first: the unique execution key arbitrates concurrent creation + # without flushing unrelated objects or rolling back the caller's work. + dialect = self._session.get_bind().dialect.name + if dialect == "postgresql": + statement = ( + postgresql_insert(HumanInputForm) + .values(values) + .on_conflict_do_nothing( + constraint="hitlv2_forms_execution_uq", + ) + ) + elif dialect == "sqlite": + statement = ( + sqlite_insert(HumanInputForm) + .values(values) + .on_conflict_do_nothing( + index_elements=_EXECUTION_KEY, + ) + ) + elif dialect == "mysql": + statement = mysql_insert(HumanInputForm).values(values).on_duplicate_key_update(id=HumanInputForm.id) + else: + raise NotImplementedError(f"Unsupported form persistence dialect: {dialect}") + self._session.execute(statement.execution_options(autoflush=False)) + query = self._query() + if params.form_kind == HumanInputFormKind.RUNTIME: + query = query.where( + HumanInputForm.form_kind == HumanInputFormKind.RUNTIME, + HumanInputForm.workflow_run_id == params.workflow_run_id, + HumanInputForm.node_execution_id == params.node_execution_id, + ) + else: + query = query.where(HumanInputForm.id == form_id) + # A locking read also sees the concurrent winner under MySQL REPEATABLE READ. + record = self._session.scalars(query.with_for_update()).one() + return _form_from_record(record) + + def _lock_form(self, form_id: str) -> HumanInputForm | None: + if self._session.get_bind().dialect.name == "sqlite": + # SQLite ignores FOR UPDATE. Acquire its write lock without changing + # any facts before reading the form and the database clock. + self._session.execute( + sa.update(HumanInputForm) + .where( + HumanInputForm.tenant_id == self._tenant_id, + HumanInputForm.app_id == self._app_id, + HumanInputForm.id == form_id, + ) + .values({"id": HumanInputForm.id, "updated_at": HumanInputForm.updated_at}) + .execution_options( + autoflush=False, + synchronize_session=False, + ) + ) + return self._session.scalar(self._query().where(HumanInputForm.id == form_id).with_for_update()) + + def _database_now(self) -> datetime: + dialect = self._session.get_bind().dialect.name + if dialect == "postgresql": + # CURRENT_TIMESTAMP is fixed at transaction start on PostgreSQL. + expression = sa.func.timezone("UTC", sa.func.clock_timestamp(), type_=sa.DateTime()) + elif dialect == "mysql": + expression = sa.func.utc_timestamp(6, type_=sa.DateTime()) + elif dialect == "sqlite": + expression = sa.func.strftime("%Y-%m-%d %H:%M:%f", "now", type_=sa.DateTime()) + else: + raise NotImplementedError(f"Unsupported form persistence dialect: {dialect}") + return self._session.scalars(sa.select(expression).execution_options(autoflush=False)).one() + + @override + def submit_form( + self, + form_id: str, + *, + submitted_by_recipient_id: RecipientId, + submitted_by_delivery_id: str, + selected_action_id: str, + raw_submission_inputs: Mapping[str, JsonValue], + normalized_submission_data: Mapping[str, Segment], + ) -> Form | FormSubmissionFailure: + record = self._lock_form(form_id) + if record is None: + return FormSubmissionFailure.NOT_FOUND + form = _form_from_record(record) + failure = _terminal_submission_failure(form.status) + if failure is not None: + return failure + now = self._database_now() + if now >= form.global_timeout_deadline: + return FormSubmissionFailure.GLOBAL_TIMEOUT + if now >= form.expiration_time: + return FormSubmissionFailure.FORM_EXPIRED + raw_inputs = _RAW_INPUTS.dump_json(dict(raw_submission_inputs), warnings="error").decode() + normalized_inputs = _NORMALIZED_INPUTS.dump_json( + normalized_submission_data, + serialize_as_any=True, + warnings="error", + ).decode() + record.status = HumanInputFormStatus.SUBMITTED + record.submitted_at = now + record.submitted_by_recipient_id = submitted_by_recipient_id + record.submitted_by_delivery_id = submitted_by_delivery_id + record.selected_action_id = selected_action_id + record.raw_submission_inputs = raw_inputs + record.normalized_submission_data = normalized_inputs + record.updated_at = now + self._session.flush([record]) + return _form_from_record(record) + + @override + def list_expired_forms( + self, + *, + now: NaiveDatetime, + limit: int, + after_form_id: str | None = None, + ) -> tuple[Form, ...]: + if limit <= 0: + raise ValueError("Expired form scan limit must be positive") + query = self._query().where( + HumanInputForm.form_kind == HumanInputFormKind.RUNTIME, + HumanInputForm.status == HumanInputFormStatus.WAITING, + sa.or_(HumanInputForm.expiration_time <= now, HumanInputForm.global_timeout_deadline <= now), + ) + if after_form_id is not None: + query = query.where(HumanInputForm.id > after_form_id) + return tuple( + _form_from_record(record) + for record in self._session.scalars( + query.order_by(HumanInputForm.id).limit(limit), + ) + ) + + @override + def list_waiting_forms_for_run(self, workflow_run_id: str) -> FormExpirationBatch: + records = self._session.scalars( + self._query() + .where( + HumanInputForm.workflow_run_id == workflow_run_id, + HumanInputForm.form_kind == HumanInputFormKind.RUNTIME, + HumanInputForm.status == HumanInputFormStatus.WAITING, + ) + .order_by(HumanInputForm.id) + ).all() + return FormExpirationBatch(tuple(_form_from_record(record) for record in records), self._database_now()) + + @override + def expire_form(self, form_id: str) -> Form | None: + record = self._lock_form(form_id) + if record is None: + return None + form = _form_from_record(record) + if form.status != HumanInputFormStatus.WAITING: + return form + now = self._database_now() + if now < form.global_timeout_deadline: + return form + record.status = HumanInputFormStatus.EXPIRED + record.updated_at = now + self._session.flush([record]) + return _form_from_record(record) + + @override + def timeout_form(self, form_id: str) -> Form | None: + record = self._lock_form(form_id) + if record is None: + return None + form = _form_from_record(record) + if form.status != HumanInputFormStatus.WAITING: + return form + now = self._database_now() + if now < form.expiration_time or now >= form.global_timeout_deadline: + return form + record.status = HumanInputFormStatus.TIMEOUT + record.updated_at = now + self._session.flush([record]) + return _form_from_record(record) + + +class SQLAlchemyFormExpirationRepository: + """Discover runs with due forms across owners using the scheduler's Session. + + This read does not claim forms or inspect workflow state. Handling must + recheck each form through its owner-bound repository in a write transaction. + """ + + def __init__(self, session: Session) -> None: + self._session = session + + def list_expired_runs(self, *, now: datetime, limit: int) -> tuple[FormExpirationRun, ...]: + """Find at most limit runs from bounded scans of both deadline indexes. + + A branch may discover multiple forms from the same run, so the result + can contain fewer than limit runs. Aggregate every waiting form of the + selected runs, including forms outside either branch's limit. + """ + if limit <= 0: + raise ValueError("Expiration scan limit must be positive") + candidates = [] + for deadline in (HumanInputForm.expiration_time, HumanInputForm.global_timeout_deadline): + due_forms = ( + sa.select(HumanInputForm.tenant_id, HumanInputForm.app_id, HumanInputForm.workflow_run_id) + .where( + HumanInputForm.form_kind == HumanInputFormKind.RUNTIME, + HumanInputForm.status == HumanInputFormStatus.WAITING, + HumanInputForm.workflow_run_id.is_not(None), + deadline <= now, + ) + .order_by(deadline, HumanInputForm.id) + .limit(limit) + .subquery() + ) + candidates.append(sa.select(due_forms)) + candidate_forms = sa.union(*candidates).subquery() + candidate_runs = ( + sa.select(candidate_forms) + .order_by(candidate_forms.c.workflow_run_id, candidate_forms.c.tenant_id, candidate_forms.c.app_id) + .limit(limit) + .cte("candidate_runs") + ) + global_deadline = ( + sa.select(sa.func.min(HumanInputForm.global_timeout_deadline)) + .where( + HumanInputForm.workflow_run_id == candidate_runs.c.workflow_run_id, + HumanInputForm.tenant_id == candidate_runs.c.tenant_id, + HumanInputForm.app_id == candidate_runs.c.app_id, + HumanInputForm.form_kind == HumanInputFormKind.RUNTIME, + HumanInputForm.status == HumanInputFormStatus.WAITING, + ) + .correlate(candidate_runs) + .scalar_subquery() + ) + query = sa.select( + candidate_runs.c.tenant_id, + candidate_runs.c.app_id, + candidate_runs.c.workflow_run_id, + global_deadline, + ).order_by(candidate_runs.c.workflow_run_id, candidate_runs.c.tenant_id, candidate_runs.c.app_id) + return tuple( + FormExpirationRun(TenantId(tenant_id), app_id, run_id, deadline) + for tenant_id, app_id, run_id, deadline in self._session.execute(query) + ) diff --git a/api/repositories/human_input_v2/sqlalchemy_im_binding_repository.py b/api/repositories/human_input_v2/sqlalchemy_im_binding_repository.py new file mode 100644 index 00000000000..0f87b047460 --- /dev/null +++ b/api/repositories/human_input_v2/sqlalchemy_im_binding_repository.py @@ -0,0 +1,400 @@ +"""Channel-bound SQLAlchemy persistence for current IM Bindings.""" + +from __future__ import annotations + +from collections.abc import Sequence +from typing import override + +import sqlalchemy as sa +from sqlalchemy.engine import CursorResult +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +from core.human_input_v2.shared import AccountId, ContactId, IMBindingId, IMIdentityId, TenantId +from libs.datetime_utils import ensure_naive_utc +from libs.uuid_utils import uuidv7 +from models.human_input_v2 import ( + HumanInputIMBinding, + HumanInputIMBindingWorkspaceOverride, +) + +from .im_binding_repository import ( + IMBinding, + IMBindingAssignment, + IMBindingConflictError, + IMBindingIdentityNotFoundError, + IMBindingKind, + IMBindingRepository, +) +from .im_channel_repository import IMChannelId +from .sqlalchemy_im_identity_repository import _lock_current_identity + +# TODO(QuantumGhost): this does not seems right... +_DEFAULT_ENDPOINT_CONSTRAINTS = frozenset( + { + "human_input_im_bindings_channel_contact_uq", + "human_input_im_bindings_channel_identity_uq", + } +) +_OVERRIDE_ENDPOINT_CONSTRAINTS = frozenset( + { + "hiimwbo_channel_tenant_contact_uq", + "hiimwbo_channel_tenant_identity_uq", + } +) +_SQLITE_DEFAULT_ENDPOINTS = frozenset( + { + "human_input_im_bindings.channel_id, human_input_im_bindings.contact_id", + "human_input_im_bindings.channel_id, human_input_im_bindings.im_identity_id", + } +) +_SQLITE_OVERRIDE_ENDPOINTS = frozenset( + { + ( + "human_input_im_workspace_binding_overrides.channel_id, " + "human_input_im_workspace_binding_overrides.tenant_id, " + "human_input_im_workspace_binding_overrides.contact_id" + ), + ( + "human_input_im_workspace_binding_overrides.channel_id, " + "human_input_im_workspace_binding_overrides.tenant_id, " + "human_input_im_workspace_binding_overrides.im_identity_id" + ), + } +) + + +def _default_binding_from_record(record: HumanInputIMBinding) -> IMBinding: + return IMBinding( + id=IMBindingId(record.id), + kind=IMBindingKind.DEFAULT, + contact_id=ContactId(record.contact_id), + identity_id=IMIdentityId(record.im_identity_id), + created_at=ensure_naive_utc(record.created_at), + updated_at=ensure_naive_utc(record.updated_at), + ) + + +def _override_binding_from_record(record: HumanInputIMBindingWorkspaceOverride) -> IMBinding: + return IMBinding( + id=IMBindingId(record.id), + kind=IMBindingKind.WORKSPACE_OVERRIDE, + contact_id=ContactId(record.contact_id), + identity_id=IMIdentityId(record.im_identity_id), + created_at=ensure_naive_utc(record.created_at), + updated_at=ensure_naive_utc(record.updated_at), + ) + + +def _is_endpoint_conflict(error: IntegrityError, *, workspace_override: bool) -> bool: + message = str(error.orig).lower() + constraint_names = _OVERRIDE_ENDPOINT_CONSTRAINTS if workspace_override else _DEFAULT_ENDPOINT_CONSTRAINTS + sqlite_targets = _SQLITE_OVERRIDE_ENDPOINTS if workspace_override else _SQLITE_DEFAULT_ENDPOINTS + return any(name in message for name in constraint_names) or any(target in message for target in sqlite_targets) + + +class SQLAlchemyIMBindingRepository(IMBindingRepository): + """Persist Bindings through one caller-owned Session and trusted Channel.""" + + def __init__(self, session: Session, channel_id: IMChannelId) -> None: + self._session = session + self._channel_id = channel_id + + @override + def get(self, binding_id: IMBindingId) -> IMBinding | None: + record = self._session.scalar( + sa.select(HumanInputIMBinding) + .where( + HumanInputIMBinding.channel_id == str(self._channel_id), + HumanInputIMBinding.id == str(binding_id), + ) + .execution_options(autoflush=False) + ) + return _default_binding_from_record(record) if record is not None else None + + @override + def get_default_for_contact(self, contact_id: ContactId) -> IMBinding | None: + record = self._get_default_for_contact(contact_id) + return _default_binding_from_record(record) if record is not None else None + + @override + def list_all(self) -> tuple[IMBinding, ...]: + records = self._session.scalars( + sa.select(HumanInputIMBinding) + .where(HumanInputIMBinding.channel_id == str(self._channel_id)) + .order_by(HumanInputIMBinding.id) + .execution_options(autoflush=False) + ).all() + return tuple(_default_binding_from_record(record) for record in records) + + @override + def create( + self, + assignment: IMBindingAssignment, + *, + bound_by_account_id: AccountId | None, + ) -> IMBinding: + self._require_identity(assignment.identity_id) + existing_contact = self._get_default_for_contact(assignment.contact_id) + if existing_contact is not None: + if existing_contact.im_identity_id == str(assignment.identity_id): + return _default_binding_from_record(existing_contact) + raise IMBindingConflictError("Contact already has a different default IM Binding") + if self._get_default_for_identity(assignment.identity_id) is not None: + raise IMBindingConflictError("IM Identity already has a different default Contact") + return self._insert_default(assignment, bound_by_account_id=bound_by_account_id) + + @override + def replace( + self, + assignment: IMBindingAssignment, + *, + bound_by_account_id: AccountId | None, + ) -> IMBinding: + self._require_identity(assignment.identity_id) + contact_binding = self._get_default_for_contact(assignment.contact_id) + if contact_binding is not None and contact_binding.im_identity_id == str(assignment.identity_id): + return _default_binding_from_record(contact_binding) + identity_binding = self._get_default_for_identity(assignment.identity_id) + # Free both endpoints for a manual reassignment. Delete only observed + # Bindings so a concurrent replacement is not lost; the caller owns rollback. + previous_bindings = sorted( + (binding for binding in (contact_binding, identity_binding) if binding is not None), + key=lambda binding: binding.id, + ) + for record in previous_bindings: + result = self._session.execute( + sa.delete(HumanInputIMBinding) + .where( + HumanInputIMBinding.channel_id == str(self._channel_id), + HumanInputIMBinding.id == record.id, + HumanInputIMBinding.im_identity_id == record.im_identity_id, + ) + .execution_options(autoflush=False) + ) + if not isinstance(result, CursorResult): + raise TypeError("Conditional IM Binding deletion did not return a cursor result") + if result.rowcount != 1: + raise IMBindingConflictError("Default IM Binding changed concurrently") + return self._insert_default(assignment, bound_by_account_id=bound_by_account_id) + + @override + def delete( + self, + binding_id: IMBindingId, + *, + expected_identity_id: IMIdentityId, + ) -> None: + record = self._session.scalar( + sa.select(HumanInputIMBinding) + .where( + HumanInputIMBinding.channel_id == str(self._channel_id), + HumanInputIMBinding.id == str(binding_id), + HumanInputIMBinding.im_identity_id == str(expected_identity_id), + ) + .execution_options(autoflush=False) + ) + if record is None: + return + self._session.delete(record) + self._session.flush() + + @override + def set_workspace_override( + self, + tenant_id: TenantId, + assignment: IMBindingAssignment, + *, + bound_by_account_id: AccountId | None, + ) -> IMBinding: + self._require_identity(assignment.identity_id) + record = self._get_workspace_override_for_contact(tenant_id, assignment.contact_id) + if record is not None and record.im_identity_id == str(assignment.identity_id): + return _override_binding_from_record(record) + if self._get_workspace_override_for_identity(tenant_id, assignment.identity_id) is not None: + raise IMBindingConflictError("IM Identity already overrides a different Contact in this workspace") + + if record is not None: + # A new Binding ID invalidates cards frozen before this replacement. + # Delete only the observed override within the caller's transaction. + result = self._session.execute( + sa.delete(HumanInputIMBindingWorkspaceOverride) + .where( + HumanInputIMBindingWorkspaceOverride.channel_id == str(self._channel_id), + HumanInputIMBindingWorkspaceOverride.tenant_id == str(tenant_id), + HumanInputIMBindingWorkspaceOverride.id == record.id, + HumanInputIMBindingWorkspaceOverride.im_identity_id == record.im_identity_id, + ) + .execution_options(autoflush=False) + ) + if not isinstance(result, CursorResult): + raise TypeError("Conditional workspace IM Binding deletion did not return a cursor result") + if result.rowcount != 1: + raise IMBindingConflictError("Workspace IM Binding changed concurrently") + + record = HumanInputIMBindingWorkspaceOverride( + channel_id=str(self._channel_id), + tenant_id=str(tenant_id), + contact_id=str(assignment.contact_id), + im_identity_id=str(assignment.identity_id), + bound_by_account_id=(str(bound_by_account_id) if bound_by_account_id is not None else None), + ) + record.id = str(uuidv7()) + record.created_at = assignment.assigned_at + record.updated_at = assignment.assigned_at + try: + self._session.add(record) + self._session.flush([record]) + except IntegrityError as error: + if _is_endpoint_conflict(error, workspace_override=True): + raise IMBindingConflictError("Workspace IM Binding endpoint is already assigned") from error + raise + return _override_binding_from_record(record) + + @override + def reset_workspace_override( + self, + tenant_id: TenantId, + contact_id: ContactId, + ) -> IMBinding | None: + record = self._get_workspace_override_for_contact(tenant_id, contact_id) + if record is None: + return None + binding = _override_binding_from_record(record) + self._session.delete(record) + self._session.flush() + return binding + + @override + def get_effective( + self, + tenant_id: TenantId, + contact_id: ContactId, + ) -> IMBinding | None: + override = self._get_workspace_override_for_contact(tenant_id, contact_id) + if override is not None: + return _override_binding_from_record(override) + default = self._get_default_for_contact(contact_id) + return _default_binding_from_record(default) if default is not None else None + + @override + def get_effective_many( + self, + tenant_id: TenantId, + contact_ids: Sequence[ContactId], + ) -> tuple[IMBinding, ...]: + distinct_contact_ids = tuple(dict.fromkeys(contact_ids)) + if not distinct_contact_ids: + return () + contact_id_values = [str(contact_id) for contact_id in distinct_contact_ids] + overrides = self._session.scalars( + sa.select(HumanInputIMBindingWorkspaceOverride) + .where( + HumanInputIMBindingWorkspaceOverride.channel_id == str(self._channel_id), + HumanInputIMBindingWorkspaceOverride.tenant_id == str(tenant_id), + HumanInputIMBindingWorkspaceOverride.contact_id.in_(contact_id_values), + ) + .execution_options(autoflush=False) + ).all() + defaults = self._session.scalars( + sa.select(HumanInputIMBinding) + .where( + HumanInputIMBinding.channel_id == str(self._channel_id), + HumanInputIMBinding.contact_id.in_(contact_id_values), + ) + .execution_options(autoflush=False) + ).all() + override_by_contact = {record.contact_id: record for record in overrides} + default_by_contact = {record.contact_id: record for record in defaults} + result: list[IMBinding] = [] + for contact_id in distinct_contact_ids: + contact_key = str(contact_id) + override = override_by_contact.get(contact_key) + if override is not None: + result.append(_override_binding_from_record(override)) + continue + default = default_by_contact.get(contact_key) + if default is not None: + result.append(_default_binding_from_record(default)) + return tuple(result) + + def _insert_default( + self, + assignment: IMBindingAssignment, + *, + bound_by_account_id: AccountId | None, + ) -> IMBinding: + record = HumanInputIMBinding( + channel_id=str(self._channel_id), + contact_id=str(assignment.contact_id), + im_identity_id=str(assignment.identity_id), + bound_by_account_id=(str(bound_by_account_id) if bound_by_account_id is not None else None), + ) + record.id = str(uuidv7()) + record.created_at = assignment.assigned_at + record.updated_at = assignment.assigned_at + try: + self._session.add(record) + self._session.flush([record]) + except IntegrityError as error: + if _is_endpoint_conflict(error, workspace_override=False): + raise IMBindingConflictError("Default IM Binding endpoint is already assigned") from error + raise + return _default_binding_from_record(record) + + def _require_identity(self, identity_id: IMIdentityId) -> None: + if not _lock_current_identity(self._session, self._channel_id, identity_id): + raise IMBindingIdentityNotFoundError("IM Identity is not current in the bound Channel") + + def _get_default_for_contact(self, contact_id: ContactId) -> HumanInputIMBinding | None: + return self._session.scalar( + sa.select(HumanInputIMBinding) + .where( + HumanInputIMBinding.channel_id == str(self._channel_id), + HumanInputIMBinding.contact_id == str(contact_id), + ) + .execution_options(autoflush=False) + ) + + def _get_default_for_identity(self, identity_id: IMIdentityId) -> HumanInputIMBinding | None: + return self._session.scalar( + sa.select(HumanInputIMBinding) + .where( + HumanInputIMBinding.channel_id == str(self._channel_id), + HumanInputIMBinding.im_identity_id == str(identity_id), + ) + .execution_options(autoflush=False) + ) + + def _get_workspace_override_for_contact( + self, + tenant_id: TenantId, + contact_id: ContactId, + ) -> HumanInputIMBindingWorkspaceOverride | None: + return self._session.scalar( + sa.select(HumanInputIMBindingWorkspaceOverride) + .where( + HumanInputIMBindingWorkspaceOverride.channel_id == str(self._channel_id), + HumanInputIMBindingWorkspaceOverride.tenant_id == str(tenant_id), + HumanInputIMBindingWorkspaceOverride.contact_id == str(contact_id), + ) + .execution_options(autoflush=False) + ) + + def _get_workspace_override_for_identity( + self, + tenant_id: TenantId, + identity_id: IMIdentityId, + ) -> HumanInputIMBindingWorkspaceOverride | None: + return self._session.scalar( + sa.select(HumanInputIMBindingWorkspaceOverride) + .where( + HumanInputIMBindingWorkspaceOverride.channel_id == str(self._channel_id), + HumanInputIMBindingWorkspaceOverride.tenant_id == str(tenant_id), + HumanInputIMBindingWorkspaceOverride.im_identity_id == str(identity_id), + ) + .execution_options(autoflush=False) + ) + + +__all__ = ["SQLAlchemyIMBindingRepository"] diff --git a/api/repositories/human_input_v2/sqlalchemy_im_channel_repository.py b/api/repositories/human_input_v2/sqlalchemy_im_channel_repository.py new file mode 100644 index 00000000000..30ed314045e --- /dev/null +++ b/api/repositories/human_input_v2/sqlalchemy_im_channel_repository.py @@ -0,0 +1,319 @@ +"""Owner-bound SQLAlchemy persistence for one current IM Channel.""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Annotated, Literal + +import sqlalchemy as sa +from pydantic import Field, RootModel, TypeAdapter +from sqlalchemy.engine import CursorResult, Result +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +from core.human_input_v2.shared import AccountId, DeploymentScope, TenantId, WorkspaceScope +from models.human_input_v2 import HumanInputIMChannel + +from .im_channel_repository import ( + IMChannel, + IMChannelAlreadyConfiguredError, + IMChannelId, + IMWebhookChannelRoute, + StaleIMChannelWriteError, + WebhookId, +) + +_DEPLOYMENT_OWNER_KEY = "deployment" +_INITIAL_CONFIG_VERSION = 1 +_OWNER_KEY_UNIQUE_CONSTRAINT = "human_input_im_channels_owner_key_uq" +_SQLITE_OWNER_KEY_UNIQUE_TARGET = "human_input_im_channels.owner_key" + + +class _WorkspaceOwner( + RootModel[ + Annotated[ + str, + Field(pattern=r"^workspace:[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"), + ] + ] +): + pass + + +class _DeploymentOwner(RootModel[Literal["deployment"]]): + pass + + +_OWNER_SCOPE = TypeAdapter(_WorkspaceOwner | _DeploymentOwner) + + +class SQLAlchemyIMWebhookChannelRepository: + """Resolve callback routing using a caller-owned Session.""" + + def __init__(self, session: Session) -> None: + self._session = session + + def find_by_webhook_id(self, webhook_id: WebhookId) -> IMWebhookChannelRoute | None: + record = self._session.scalar( + sa.select(HumanInputIMChannel).where(HumanInputIMChannel.webhook_id == webhook_id) + ) + return self._route(record) if record is not None else None + + def find_by_channel_id(self, channel_id: IMChannelId) -> IMWebhookChannelRoute | None: + record = self._session.get(HumanInputIMChannel, str(channel_id)) + return self._route(record) if record is not None else None + + @staticmethod + def _route(record: HumanInputIMChannel) -> IMWebhookChannelRoute: + owner = _OWNER_SCOPE.validate_python(record.owner_key) + scope = ( + WorkspaceScope(TenantId(owner.root.removeprefix("workspace:"))) + if isinstance(owner, _WorkspaceOwner) + else DeploymentScope() + ) + return IMWebhookChannelRoute(channel=_channel_from_record(record), scope=scope) + + +def _workspace_owner_key(tenant_id: TenantId) -> str: + return f"workspace:{tenant_id}" + + +def _channel_from_record(record: HumanInputIMChannel) -> IMChannel: + return IMChannel( + id=IMChannelId(record.id), + created_at=record.created_at, + updated_at=record.updated_at, + provider=record.provider, + provider_tenant_id=record.provider_tenant_id, + encrypted_credentials=record.encrypted_credentials, + app_identifier=record.app_identifier, + webhook_id=WebhookId(record.webhook_id), + config_version=record.config_version, + status=record.status, + status_reason=record.status_reason, + ) + + +def _channel_to_record( + channel: IMChannel, + *, + owner_key: str, + configured_by_account_id: AccountId | None, +) -> HumanInputIMChannel: + record = HumanInputIMChannel( + owner_key=owner_key, + provider=channel.provider, + provider_tenant_id=channel.provider_tenant_id, + encrypted_credentials=channel.encrypted_credentials, + app_identifier=channel.app_identifier, + webhook_id=str(channel.webhook_id), + status=channel.status, + config_version=channel.config_version, + configured_by_account_id=(str(configured_by_account_id) if configured_by_account_id is not None else None), + status_reason=channel.status_reason, + ) + record.id = str(channel.id) + record.created_at = channel.created_at + record.updated_at = channel.updated_at + return record + + +def _channel_update_values( + channel: IMChannel, + *, + configured_by_account_id: AccountId | None, +) -> Mapping[str, object]: + return { + "created_at": channel.created_at, + "updated_at": channel.updated_at, + "provider": channel.provider, + "provider_tenant_id": channel.provider_tenant_id, + "encrypted_credentials": channel.encrypted_credentials, + "app_identifier": channel.app_identifier, + "configured_by_account_id": (str(configured_by_account_id) if configured_by_account_id is not None else None), + "webhook_id": str(channel.webhook_id), + "status": channel.status, + "status_reason": channel.status_reason, + "config_version": channel.config_version, + } + + +def _is_owner_key_unique_violation(error: IntegrityError) -> bool: + message = str(error.orig).lower() + return _OWNER_KEY_UNIQUE_CONSTRAINT in message or _SQLITE_OWNER_KEY_UNIQUE_TARGET in message + + +def _require_initial_version(channel: IMChannel) -> None: + if channel.config_version != _INITIAL_CONFIG_VERSION: + raise ValueError("initial IM Channel configuration version must be 1") + + +def _require_next_version(channel: IMChannel, expected_config_version: int) -> None: + if channel.config_version != expected_config_version + 1: + raise ValueError("updated IM Channel configuration version must increment by one") + + +def _require_replacement(current_channel_id: IMChannelId, replacement: IMChannel) -> None: + if replacement.id == current_channel_id: + raise ValueError("replacement IM Channel must use a different ID") + _require_initial_version(replacement) + + +def _require_current_row(result: Result[tuple[object, ...]], message: str) -> None: + if not isinstance(result, CursorResult): + raise TypeError("conditional IM Channel DML did not return a cursor result") + if result.rowcount != 1: + raise StaleIMChannelWriteError(message) + + +class _OwnerBoundIMChannelReader: + def __init__(self, session: Session, owner_key: str) -> None: + self._session = session + self._owner_key = owner_key + + def get(self) -> IMChannel | None: + record = self._session.scalar( + sa.select(HumanInputIMChannel) + .where(HumanInputIMChannel.owner_key == self._owner_key) + .execution_options(autoflush=False) + ) + return _channel_from_record(record) if record is not None else None + + +class _OwnerBoundIMChannelWriter: + def __init__( + self, + session: Session, + owner_key: str, + configured_by_account_id: AccountId | None, + ) -> None: + self._session = session + self._owner_key = owner_key + self._configured_by_account_id = configured_by_account_id + + def create(self, channel: IMChannel) -> IMChannel: + _require_initial_version(channel) + record = _channel_to_record( + channel, + owner_key=self._owner_key, + configured_by_account_id=self._configured_by_account_id, + ) + try: + self._session.add(record) + self._session.flush([record]) + except IntegrityError as error: + if _is_owner_key_unique_violation(error): + raise IMChannelAlreadyConfiguredError("IM Channel owner is already configured") from error + raise + return _channel_from_record(record) + + def update( + self, + channel: IMChannel, + expected_config_version: int, + ) -> IMChannel: + _require_next_version(channel, expected_config_version) + # Reject an update-shaped value for a replacement before mutation SQL; + # the conditional UPDATE below remains the authoritative concurrency check. + current_channel_id = self._session.scalar( + sa.select(HumanInputIMChannel.id) + .where(HumanInputIMChannel.owner_key == self._owner_key) + .execution_options(autoflush=False) + ) + if current_channel_id != str(channel.id): + raise StaleIMChannelWriteError("IM Channel update did not match the current ID") + result = self._session.execute( + sa.update(HumanInputIMChannel) + .where( + HumanInputIMChannel.owner_key == self._owner_key, + HumanInputIMChannel.id == str(channel.id), + HumanInputIMChannel.config_version == expected_config_version, + ) + .values( + _channel_update_values( + channel, + configured_by_account_id=self._configured_by_account_id, + ) + ) + .execution_options(autoflush=False) + ) + _require_current_row(result, "IM Channel update did not match the current version") + return channel + + def replace( + self, + current_channel_id: IMChannelId, + expected_config_version: int, + replacement: IMChannel, + ) -> IMChannel: + _require_replacement(current_channel_id, replacement) + result = self._session.execute( + sa.delete(HumanInputIMChannel) + .where( + HumanInputIMChannel.owner_key == self._owner_key, + HumanInputIMChannel.id == str(current_channel_id), + HumanInputIMChannel.config_version == expected_config_version, + ) + .execution_options(autoflush=False) + ) + _require_current_row(result, "IM Channel replacement did not match the current version") + record = _channel_to_record( + replacement, + owner_key=self._owner_key, + configured_by_account_id=self._configured_by_account_id, + ) + self._session.add(record) + self._session.flush([record]) + return _channel_from_record(record) + + def delete( + self, + channel_id: IMChannelId, + expected_config_version: int, + ) -> None: + result = self._session.execute( + sa.delete(HumanInputIMChannel) + .where( + HumanInputIMChannel.owner_key == self._owner_key, + HumanInputIMChannel.id == str(channel_id), + HumanInputIMChannel.config_version == expected_config_version, + ) + .execution_options(autoflush=False) + ) + _require_current_row(result, "IM Channel deletion did not match the current version") + + +class WorkspaceIMChannelReader(_OwnerBoundIMChannelReader): + """Read the current IM Channel for one constructor-bound Dify Tenant.""" + + def __init__(self, session: Session, tenant_id: TenantId) -> None: + super().__init__(session, _workspace_owner_key(tenant_id)) + + +class WorkspaceIMChannelWriter(_OwnerBoundIMChannelWriter): + """Write the current IM Channel for one constructor-bound Dify Tenant.""" + + def __init__(self, session: Session, tenant_id: TenantId, configured_by_account_id: AccountId) -> None: + super().__init__(session, _workspace_owner_key(tenant_id), configured_by_account_id) + + +class DeploymentIMChannelReader(_OwnerBoundIMChannelReader): + """Read the single deployment-owned IM Channel.""" + + def __init__(self, session: Session) -> None: + super().__init__(session, _DEPLOYMENT_OWNER_KEY) + + +class DeploymentIMChannelWriter(_OwnerBoundIMChannelWriter): + """Write the single deployment-owned IM Channel without actor metadata.""" + + def __init__(self, session: Session) -> None: + super().__init__(session, _DEPLOYMENT_OWNER_KEY, None) + + +__all__ = [ + "DeploymentIMChannelReader", + "DeploymentIMChannelWriter", + "WorkspaceIMChannelReader", + "WorkspaceIMChannelWriter", +] diff --git a/api/repositories/human_input_v2/sqlalchemy_im_identity_repository.py b/api/repositories/human_input_v2/sqlalchemy_im_identity_repository.py new file mode 100644 index 00000000000..c7589937815 --- /dev/null +++ b/api/repositories/human_input_v2/sqlalchemy_im_identity_repository.py @@ -0,0 +1,273 @@ +"""Channel-bound SQLAlchemy persistence for current IM Identities.""" + +from __future__ import annotations + +from collections.abc import Sequence +from typing import override + +import sqlalchemy as sa +from sqlalchemy.engine import CursorResult +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +from core.human_input_v2.shared import IMIdentityId, IMSyncRunId +from libs.datetime_utils import ensure_naive_utc +from models.human_input_v2 import ( + HumanInputIMBinding, + HumanInputIMBindingWorkspaceOverride, + HumanInputIMIdentity, + IMIdentityRawPayload, +) + +from .im_channel_repository import IMChannelId +from .im_identity_repository import ( + IMIdentity, + IMIdentityAlreadyExistsError, + IMIdentityInUseError, + IMIdentityNotFoundError, + IMIdentityObservation, + IMIdentityPage, + IMIdentityRepository, +) + +_PROVIDER_USER_UNIQUE_CONSTRAINT = "human_input_im_identities_channel_provider_user_uq" +_SQLITE_PROVIDER_USER_UNIQUE_TARGET = "human_input_im_identities.channel_id, human_input_im_identities.provider_user_id" +_MAX_SEARCH_LIMIT = 100 + + +def _identity_from_record(record: HumanInputIMIdentity) -> IMIdentity: + return IMIdentity( + id=IMIdentityId(record.id), + provider_user_id=record.provider_user_id, + display_name=record.display_name, + email=record.email, + last_seen_sync_run_id=IMSyncRunId(record.last_seen_sync_run_id), + last_seen_at=ensure_naive_utc(record.last_seen_at), + created_at=ensure_naive_utc(record.created_at), + updated_at=ensure_naive_utc(record.updated_at), + avatar_file_id=record.avatar_file_id, + ) + + +def _canonical_pair(value: str | None) -> tuple[str | None, str | None]: + if value is None: + return None, None + source = value.strip() + if not source: + return None, None + return source, source.casefold() + + +def _is_provider_user_conflict(error: IntegrityError) -> bool: + message = str(error.orig).lower() + return _PROVIDER_USER_UNIQUE_CONSTRAINT in message or _SQLITE_PROVIDER_USER_UNIQUE_TARGET in message + + +def _apply_observation(record: HumanInputIMIdentity, observation: IMIdentityObservation) -> None: + display_name, normalized_name = _canonical_pair(observation.display_name) + email, normalized_email = _canonical_pair(observation.email) + record.display_name = display_name + record.normalized_name = normalized_name + record.email = email + record.normalized_email = normalized_email + record.raw_payload = IMIdentityRawPayload(observation.raw_payload.root) + record.last_seen_sync_run_id = str(observation.sync_run_id) + record.last_seen_at = observation.observed_at + record.updated_at = observation.observed_at + record.avatar_file_id = observation.avatar_file_id + + +def _lock_current_identity(session: Session, channel_id: IMChannelId, identity_id: IMIdentityId) -> bool: + # TODO(QuantumGhost): this lock is misleading. + """Serialize Identity deletion and Binding writes inside the caller transaction.""" + + result = session.execute( + sa.update(HumanInputIMIdentity) + .where( + HumanInputIMIdentity.channel_id == str(channel_id), + HumanInputIMIdentity.id == str(identity_id), + ) + .values({HumanInputIMIdentity.updated_at: HumanInputIMIdentity.updated_at}) + .execution_options(autoflush=False, synchronize_session=False) + ) + if not isinstance(result, CursorResult): + raise TypeError("conditional IM Identity guard did not return a cursor result") + return result.rowcount == 1 + + +class SQLAlchemyIMIdentityRepository(IMIdentityRepository): + """Persist current Identities through one caller-owned Session and Channel.""" + + def __init__(self, session: Session, channel_id: IMChannelId) -> None: + self._session = session + self._channel_id = channel_id + + @override + def get(self, identity_id: IMIdentityId) -> IMIdentity | None: + record = self._session.scalar( + sa.select(HumanInputIMIdentity) + .where( + HumanInputIMIdentity.channel_id == str(self._channel_id), + HumanInputIMIdentity.id == str(identity_id), + ) + .execution_options(autoflush=False) + ) + return _identity_from_record(record) if record is not None else None + + @override + def get_many(self, identity_ids: Sequence[IMIdentityId]) -> tuple[IMIdentity, ...]: + if not identity_ids: + return () + records = self._session.scalars( + sa.select(HumanInputIMIdentity) + .where( + HumanInputIMIdentity.channel_id == str(self._channel_id), + HumanInputIMIdentity.id.in_(tuple(dict.fromkeys(identity_ids))), + ) + .execution_options(autoflush=False) + ).all() + return tuple(_identity_from_record(record) for record in records) + + @override + def get_by_provider_user_id(self, provider_user_id: str) -> IMIdentity | None: + canonical_provider_user_id = provider_user_id.strip() + record = self._session.scalar( + sa.select(HumanInputIMIdentity) + .where( + HumanInputIMIdentity.channel_id == str(self._channel_id), + HumanInputIMIdentity.provider_user_id == canonical_provider_user_id, + ) + .execution_options(autoflush=False) + ) + return _identity_from_record(record) if record is not None else None + + @override + def list_all(self) -> tuple[IMIdentity, ...]: + records = self._session.scalars( + sa.select(HumanInputIMIdentity) + .where(HumanInputIMIdentity.channel_id == str(self._channel_id)) + .order_by(HumanInputIMIdentity.id) + .execution_options(autoflush=False) + ).all() + return tuple(_identity_from_record(record) for record in records) + + @override + def search(self, *, keyword: str = "", page: int, limit: int) -> IMIdentityPage: + if page < 1: + raise ValueError("page must be positive") + if limit < 1 or limit > _MAX_SEARCH_LIMIT: + raise ValueError(f"limit must be between 1 and {_MAX_SEARCH_LIMIT}") + + predicates: list[sa.ColumnElement[bool]] = [HumanInputIMIdentity.channel_id == str(self._channel_id)] + normalized_keyword = keyword.strip().casefold() + if normalized_keyword: + predicates.append( + sa.or_( + sa.func.lower(HumanInputIMIdentity.provider_user_id).contains( + normalized_keyword, + autoescape=True, + ), + HumanInputIMIdentity.normalized_name.contains(normalized_keyword, autoescape=True), + HumanInputIMIdentity.normalized_email.contains(normalized_keyword, autoescape=True), + ) + ) + total = ( + self._session.scalar( + sa.select(sa.func.count(HumanInputIMIdentity.id)).where(*predicates).execution_options(autoflush=False) + ) + or 0 + ) + records = self._session.scalars( + sa.select(HumanInputIMIdentity) + .where(*predicates) + .order_by(HumanInputIMIdentity.id) + .offset((page - 1) * limit) + .limit(limit) + .execution_options(autoflush=False) + ).all() + return IMIdentityPage( + items=tuple(_identity_from_record(record) for record in records), + page=page, + limit=limit, + total=total, + ) + + @override + def create(self, identity_id: IMIdentityId, observation: IMIdentityObservation) -> IMIdentity: + provider_user_id = observation.provider_user_id.strip() + if not provider_user_id: + raise ValueError("provider user id must not be blank") + record = HumanInputIMIdentity( + channel_id=str(self._channel_id), + provider_user_id=provider_user_id, + raw_payload=IMIdentityRawPayload(observation.raw_payload.root), + last_seen_sync_run_id=str(observation.sync_run_id), + last_seen_at=observation.observed_at, + ) + record.id = str(identity_id) + record.created_at = observation.observed_at + _apply_observation(record, observation) + try: + self._session.add(record) + self._session.flush([record]) + except IntegrityError as error: + if _is_provider_user_conflict(error): + raise IMIdentityAlreadyExistsError("Provider user already exists in the bound IM Channel") from error + raise + return _identity_from_record(record) + + @override + def update(self, identity_id: IMIdentityId, observation: IMIdentityObservation) -> IMIdentity: + record = self._session.scalar( + sa.select(HumanInputIMIdentity) + .where( + HumanInputIMIdentity.channel_id == str(self._channel_id), + HumanInputIMIdentity.id == str(identity_id), + ) + .execution_options(autoflush=False) + ) + if record is None: + raise IMIdentityNotFoundError("IM Identity is not current in the bound Channel") + _apply_observation(record, observation) + self._session.flush([record]) + return _identity_from_record(record) + + @override + def delete(self, identity_id: IMIdentityId) -> None: + if not _lock_current_identity(self._session, self._channel_id, identity_id): + return + record = self._session.scalar( + sa.select(HumanInputIMIdentity) + .where( + HumanInputIMIdentity.channel_id == str(self._channel_id), + HumanInputIMIdentity.id == str(identity_id), + ) + .execution_options(autoflush=False) + ) + if record is None: + raise RuntimeError("guarded IM Identity disappeared inside the caller transaction") + in_use = self._session.scalar( + sa.select( + sa.or_( + sa.exists( + sa.select(HumanInputIMBinding.id).where( + HumanInputIMBinding.channel_id == str(self._channel_id), + HumanInputIMBinding.im_identity_id == str(identity_id), + ) + ), + sa.exists( + sa.select(HumanInputIMBindingWorkspaceOverride.id).where( + HumanInputIMBindingWorkspaceOverride.channel_id == str(self._channel_id), + HumanInputIMBindingWorkspaceOverride.im_identity_id == str(identity_id), + ) + ), + ) + ).execution_options(autoflush=False) + ) + if in_use: + raise IMIdentityInUseError("IM Identity is referenced by a current Binding") + self._session.delete(record) + self._session.flush() + + +__all__ = ["SQLAlchemyIMIdentityRepository"] diff --git a/api/repositories/human_input_v2/sqlalchemy_recipient_repository.py b/api/repositories/human_input_v2/sqlalchemy_recipient_repository.py new file mode 100644 index 00000000000..632d341156f --- /dev/null +++ b/api/repositories/human_input_v2/sqlalchemy_recipient_repository.py @@ -0,0 +1,119 @@ +"""Owner-bound persistence of resolved recipient identities and provenance.""" + +from __future__ import annotations + +from collections.abc import Sequence +from typing import assert_never, override + +import sqlalchemy as sa +from sqlalchemy.orm import Session + +from core.human_input_v2.shared.values import ContactId, NormalizedEmail, RecipientId, TenantId +from models.human_input_v2 import HumanInputRecipient, RecipientSnapshot, RecipientSources, RecipientSubjectType + +from .recipient_repository import ( + ContactRecipientSubject, + EmailRecipientSubject, + EndUserRecipientSubject, + Recipient, + RecipientCreateParams, + RecipientRepository, + RecipientSubject, +) + + +def _subject_columns(subject: RecipientSubject) -> tuple[RecipientSubjectType, str]: + match subject: + case ContactRecipientSubject(contact_id): + return RecipientSubjectType.CONTACT, contact_id + case EmailRecipientSubject(email): + return RecipientSubjectType.EMAIL, email.value + case EndUserRecipientSubject(end_user_id): + return RecipientSubjectType.END_USER, end_user_id + case _: + assert_never(subject) + + +def _to_recipient(record: HumanInputRecipient) -> Recipient: + subject: RecipientSubject + match record.subject_type: + case RecipientSubjectType.CONTACT: + subject = ContactRecipientSubject(ContactId(record.subject_value)) + case RecipientSubjectType.EMAIL: + subject = EmailRecipientSubject(NormalizedEmail(record.subject_value)) + case RecipientSubjectType.END_USER: + subject = EndUserRecipientSubject(record.subject_value) + return Recipient( + id=RecipientId(record.id), + tenant_id=TenantId(record.tenant_id), + form_id=record.form_id, + subject=subject, + sources=tuple(record.sources.root), + name=record.snapshot.name, + created_at=record.created_at, + updated_at=record.updated_at, + ) + + +class SQLAlchemyRecipientRepository(RecipientRepository): + """Use the caller's transaction and the form's unique subject constraint.""" + + def __init__(self, session: Session, tenant_id: TenantId, form_id: str) -> None: + self._session = session + self._tenant_id = tenant_id + self._form_id = form_id + + def _query(self) -> sa.Select[tuple[HumanInputRecipient]]: + return ( + sa.select(HumanInputRecipient) + .where(HumanInputRecipient.tenant_id == self._tenant_id, HumanInputRecipient.form_id == self._form_id) + .execution_options(autoflush=False, populate_existing=True) + ) + + @override + def create_recipients(self, params: Sequence[RecipientCreateParams]) -> tuple[Recipient, ...]: + if not params: + return () + + subjects = [_subject_columns(param.subject) for param in params] + query = self._query().where( + sa.tuple_(HumanInputRecipient.subject_type, HumanInputRecipient.subject_value).in_(subjects) + ) + records = {(record.subject_type, record.subject_value): record for record in self._session.scalars(query)} + new_records: list[HumanInputRecipient] = [] + # Stable lock acquisition order avoids inverse-order batch deadlocks. + for index in sorted(range(len(params)), key=lambda index: subjects[index]): + subject_type, subject_value = subjects[index] + subject_key = (subject_type, subject_value) + if subject_key in records: + continue + param = params[index] + record = HumanInputRecipient( + tenant_id=self._tenant_id, + form_id=self._form_id, + subject_type=subject_type, + subject_value=subject_value, + sources=RecipientSources(root=param.sources), + snapshot=RecipientSnapshot(name=param.name), + ) + record.updated_at = record.created_at + records[subject_key] = record + new_records.append(record) + if new_records: + self._session.add_all(new_records) + self._session.flush(new_records) + recipients = { + (record.subject_type, record.subject_value): _to_recipient(record) + for record in self._session.scalars(query) + } + return tuple(recipients[subject] for subject in subjects) + + @override + def list_recipients(self) -> tuple[Recipient, ...]: + records = self._session.scalars(self._query().order_by(HumanInputRecipient.id)) + return tuple(_to_recipient(record) for record in records) + + @override + def get_recipient(self, recipient_id: RecipientId) -> Recipient | None: + record = self._session.scalars(self._query().where(HumanInputRecipient.id == recipient_id)).one_or_none() + return _to_recipient(record) if record is not None else None diff --git a/api/repositories/installation_state_repository.py b/api/repositories/installation_state_repository.py index 5a833c147d3..a50124044e8 100644 --- a/api/repositories/installation_state_repository.py +++ b/api/repositories/installation_state_repository.py @@ -7,8 +7,10 @@ from sqlalchemy import delete, exists, select from sqlalchemy.orm import Session, sessionmaker from configs import dify_config +from core.human_input_v2.shared import AccountId from models.account import Account, Tenant, TenantAccountJoin from models.model import DifySetup +from repositories.human_input_v2.sqlalchemy_contact_repository import SQLAlchemyContactRepository class InstallationStateRepository: @@ -32,7 +34,13 @@ class InstallationStateRepository: with self._session_factory.begin() as session: session.add(DifySetup(version=dify_config.project.version, instance_id=str(uuid4()))) - def reset_setup(self) -> None: + def reset_setup(self, account_id: str | None) -> None: with self._session_factory.begin() as session: - for model in (DifySetup, TenantAccountJoin, Account, Tenant): + for model in (DifySetup, TenantAccountJoin): + session.execute(delete(model)) + if account_id is not None: + SQLAlchemyContactRepository(session).delete_account_identity_after_failed_creation( + AccountId(account_id) + ) + for model in (Account, Tenant): session.execute(delete(model)) diff --git a/api/repositories/sqlalchemy_api_workflow_run_repository.py b/api/repositories/sqlalchemy_api_workflow_run_repository.py index 7996d362862..e111cf80dbc 100644 --- a/api/repositories/sqlalchemy_api_workflow_run_repository.py +++ b/api/repositories/sqlalchemy_api_workflow_run_repository.py @@ -47,7 +47,6 @@ from extensions.ext_storage import storage from graphon.entities.pause_reason import ( HitlRequired, PauseReasonType, - SchedulingPause, ) from graphon.entities.pause_reason import ( PauseReason as GraphonPauseReason, @@ -94,9 +93,7 @@ class WorkflowRunMessageRef(NamedTuple): class WorkflowRunPauseRecord(NamedTuple): status: WorkflowExecutionStatus - paused_at: datetime | None - reasons: tuple[DifyPauseReason, ...] - form_tokens: Mapping[str, str] + pause: WorkflowPauseEntity | None class _WorkflowRunError(Exception): @@ -320,6 +317,43 @@ class DifyAPISQLAlchemyWorkflowRunRepository(APIWorkflowRunRepository): ) return session.scalar(stmt) + @override + def get_workflow_run_by_id_for_update( + self, + session: Session, + *, + tenant_id: str, + app_id: str, + run_id: str, + ) -> WorkflowRun | None: + """Lock the scoped run in the caller's transaction.""" + stmt = ( + select(WorkflowRun) + .where( + WorkflowRun.tenant_id == tenant_id, + WorkflowRun.app_id == app_id, + WorkflowRun.id == run_id, + ) + .with_for_update() + ) + return session.scalar(stmt) + + @override + def stop_workflow_run( + self, + session: Session, + *, + run: WorkflowRun, + error: str, + finished_at: datetime, + ) -> None: + run.status = WorkflowExecutionStatus.STOPPED + run.error = error + run.finished_at = finished_at + pause = session.scalar(select(WorkflowPause).where(WorkflowPause.workflow_run_id == run.id).with_for_update()) + if pause is not None and pause.resumed_at is None: + pause.resumed_at = finished_at + @override def get_workflow_run_by_id_without_tenant( self, @@ -1135,35 +1169,17 @@ class DifyAPISQLAlchemyWorkflowRunRepository(APIWorkflowRunRepository): workflow_run_id=workflow_run.id, state_object_key=state_obj_key, ) - pause_reason_models = [] - for reason in pause_reasons: - match reason: - case HitlRequired(): - pause_reason_model = WorkflowPauseReason( - pause_id=pause_model.id, - type_=PauseReasonType.HITL_REQUIRED, - form_id=default_session_binding.resolve_form_id_from_session_id( - session_id=reason.session_id - ), - node_id=reason.node_id, - ) - case HumanInputRequired(): - pause_reason_model = WorkflowPauseReason( - pause_id=pause_model.id, - type_=PauseReasonType.HITL_REQUIRED, - form_id=reason.form_id, - node_id=reason.node_id, - ) - case SchedulingPause(): - pause_reason_model = WorkflowPauseReason( - pause_id=pause_model.id, - type_=reason.TYPE, - message=reason.message, - ) - case _: - raise AssertionError(f"unknown reason type: {type(reason)}") - - pause_reason_models.append(pause_reason_model) + pause_reason_models = [ + WorkflowPauseReason.from_entity(pause_id=pause_model.id, pause_reason=reason) + for reason in pause_reasons + if ( + isinstance(reason, HumanInputRequired) + and reason.form_version == "1" + or isinstance(reason, HitlRequired) + and default_session_binding.resolve_form_id_from_session_id(session_id=reason.session_id)[0] + == "1" + ) + ] # Update workflow run status workflow_run.status = WorkflowExecutionStatus.PAUSED @@ -1174,17 +1190,7 @@ class DifyAPISQLAlchemyWorkflowRunRepository(APIWorkflowRunRepository): session.add_all(pause_reason_models) logger.info("Created workflow pause %s for workflow run %s", pause_model.id, workflow_run_id) - - # NOTE(QuantumGhost): repository callers on the Dify side should only - # observe enriched Dify pause reasons. The Graphon-native reason is an - # input-only boundary concern while persisting the pause. - hydrated_pause_reasons = self._hydrate_pause_reasons(session, pause_reason_models) - - return _PrivateWorkflowPauseEntity( - pause_model=pause_model, - reason_models=pause_reason_models, - pause_reasons=hydrated_pause_reasons, - ) + return _PrivateWorkflowPauseEntity(pause_model=pause_model) except Exception: try: storage.delete(state_obj_key) @@ -1194,10 +1200,10 @@ class DifyAPISQLAlchemyWorkflowRunRepository(APIWorkflowRunRepository): ) raise - def _get_reasons_by_pause_id(self, session: Session, pause_id: str): + @override + def get_legacy_pause_reasons(self, session: Session, pause_id: str) -> list[DifyPauseReason]: reason_stmt = select(WorkflowPauseReason).where(WorkflowPauseReason.pause_id == pause_id) - pause_reason_models = session.scalars(reason_stmt).all() - return pause_reason_models + return self._hydrate_pause_reasons(session, session.scalars(reason_stmt).all()) def _hydrate_pause_reasons( self, @@ -1290,8 +1296,7 @@ class DifyAPISQLAlchemyWorkflowRunRepository(APIWorkflowRunRepository): stmt = stmt.where(WorkflowPause.id > after_id) with self._session_maker() as session: return [ - (run, _PrivateWorkflowPauseEntity(pause_model=pause, reason_models=(), pause_reasons=())) - for run, pause in session.execute(stmt).tuples() + (run, _PrivateWorkflowPauseEntity(pause_model=pause)) for run, pause in session.execute(stmt).tuples() ] @override @@ -1348,17 +1353,34 @@ class DifyAPISQLAlchemyWorkflowRunRepository(APIWorkflowRunRepository): pause_model = workflow_run.pause if pause_model is None: return None - pause_reason_models = self._get_reasons_by_pause_id(session, pause_model.id) - pause_reasons = self._hydrate_pause_reasons(session, pause_reason_models) - return _PrivateWorkflowPauseEntity( - pause_model=pause_model, - reason_models=pause_reason_models, - pause_reasons=pause_reasons, + return _PrivateWorkflowPauseEntity(pause_model=pause_model) + + @override + def has_active_workflow_pause( + self, + session: Session, + *, + tenant_id: str, + app_id: str, + run_id: str, + ) -> bool: + active_pause = ( + select(WorkflowPause.id) + .join(WorkflowRun, WorkflowRun.id == WorkflowPause.workflow_run_id) + .where( + WorkflowRun.tenant_id == tenant_id, + WorkflowRun.app_id == app_id, + WorkflowRun.id == run_id, + WorkflowPause.resumed_at.is_(None), + ) + .exists() ) + return session.scalar(select(active_pause)) is True def get_pause_record( self, + session: Session, *, workspace_id: str, workflow_run_id: str, @@ -1371,33 +1393,17 @@ class DifyAPISQLAlchemyWorkflowRunRepository(APIWorkflowRunRepository): WorkflowRun.id == workflow_run_id, ) ) - with self._session_maker() as session: - workflow_run = session.scalar(stmt) - if workflow_run is None: - return None - if workflow_run.status != WorkflowExecutionStatus.PAUSED: - return WorkflowRunPauseRecord( - status=workflow_run.status, - paused_at=None, - reasons=(), - form_tokens={}, - ) + workflow_run = session.scalar(stmt) + if workflow_run is None: + return None + pause_model = workflow_run.pause if workflow_run.status == WorkflowExecutionStatus.PAUSED else None + return WorkflowRunPauseRecord( + status=workflow_run.status, + pause=_PrivateWorkflowPauseEntity(pause_model=pause_model) if pause_model is not None else None, + ) - pause_model = workflow_run.pause - if pause_model is None: - reasons: tuple[DifyPauseReason, ...] = () - else: - reason_models = self._get_reasons_by_pause_id(session, pause_model.id) - reasons = tuple(self._hydrate_pause_reasons(session, reason_models)) - form_ids = [reason.form_id for reason in reasons if isinstance(reason, HumanInputRequired)] - form_tokens = load_form_tokens_by_form_id(form_ids, session=session) - - return WorkflowRunPauseRecord( - status=workflow_run.status, - paused_at=pause_model.created_at if pause_model is not None else None, - reasons=reasons, - form_tokens=form_tokens, - ) + def get_legacy_form_tokens(self, session: Session, *, form_ids: Sequence[str]) -> dict[str, str]: + return load_form_tokens_by_form_id(form_ids, session=session) def stop_paused_workflow_task( self, @@ -1560,9 +1566,6 @@ class DifyAPISQLAlchemyWorkflowRunRepository(APIWorkflowRunRepository): if before_resume is not None: before_resume() - pause_reasons = self._get_reasons_by_pause_id(session, pause_model.id) - hydrated_pause_reasons = self._hydrate_pause_reasons(session, pause_reasons) - # Mark as resumed pause_model.resumed_at = naive_utc_now() workflow_run.status = WorkflowExecutionStatus.RUNNING @@ -1572,11 +1575,7 @@ class DifyAPISQLAlchemyWorkflowRunRepository(APIWorkflowRunRepository): logger.info("Resumed workflow pause %s for workflow run %s", pause_model.id, workflow_run_id) - return _PrivateWorkflowPauseEntity( - pause_model=pause_model, - reason_models=pause_reasons, - pause_reasons=hydrated_pause_reasons, - ) + return _PrivateWorkflowPauseEntity(pause_model=pause_model) @override def delete_workflow_pause( @@ -1937,19 +1936,9 @@ class _PrivateWorkflowPauseEntity(WorkflowPauseEntity): the concrete implementation of the WorkflowPauseEntity interface. """ - def __init__( - self, - *, - pause_model: WorkflowPause, - reason_models: Sequence[WorkflowPauseReason], - pause_reasons: Sequence[DifyPauseReason] | None = None, - human_input_form: Sequence = (), - ) -> None: + def __init__(self, *, pause_model: WorkflowPause) -> None: self._pause_model = pause_model - self._reason_models = reason_models - self._pause_reasons = pause_reasons self._cached_state: bytes | None = None - self._human_input_form = human_input_form @property @override @@ -1987,12 +1976,6 @@ class _PrivateWorkflowPauseEntity(WorkflowPauseEntity): def resumed_at(self) -> datetime | None: return self._pause_model.resumed_at - @override - def get_pause_reasons(self) -> Sequence[DifyPauseReason]: - if self._pause_reasons is not None: - return list(self._pause_reasons) - return [_to_dify_pause_reason(reason) for reason in self._reason_models] - @property @override def paused_at(self) -> datetime: diff --git a/api/repositories/sqlalchemy_execution_extra_content_repository.py b/api/repositories/sqlalchemy_execution_extra_content_repository.py index 84d9d6aa9b4..b213559c541 100644 --- a/api/repositories/sqlalchemy_execution_extra_content_repository.py +++ b/api/repositories/sqlalchemy_execution_extra_content_repository.py @@ -11,12 +11,11 @@ from sqlalchemy import select from sqlalchemy.orm import Session, selectinload, sessionmaker from core.entities.execution_extra_content import ( - ExecutionExtraContentDomainModel, - HumanInputFormDefinition, - HumanInputFormSubmissionData, + HumanInputContent as HumanInputContentDomainModel, ) from core.entities.execution_extra_content import ( - HumanInputContent as HumanInputContentDomainModel, + HumanInputFormDefinition, + HumanInputFormSubmissionData, ) from core.workflow.nodes.human_input.callback import DifyHITLCallback from core.workflow.nodes.human_input.entities import FormDefinition @@ -28,7 +27,11 @@ from models.execution_extra_content import ( HumanInputContent as HumanInputContentModel, ) from models.human_input import HumanInputFormRecipient, RecipientType -from repositories.execution_extra_content_repository import ExecutionExtraContentRepository +from repositories.execution_extra_content_repository import ( + ExecutionExtraContentRepository, + HumanInputFormReference, + StoredExecutionExtraContent, +) logger = logging.getLogger(__name__) @@ -46,13 +49,11 @@ class SQLAlchemyExecutionExtraContentRepository(ExecutionExtraContentRepository) self._session_maker = session_maker @override - def get_by_message_ids(self, message_ids: Sequence[str]) -> list[list[ExecutionExtraContentDomainModel]]: + def get_by_message_ids(self, message_ids: Sequence[str]) -> list[list[StoredExecutionExtraContent]]: if not message_ids: return [] - grouped_contents: dict[str, list[ExecutionExtraContentDomainModel]] = { - message_id: [] for message_id in message_ids - } + grouped_contents: dict[str, list[StoredExecutionExtraContent]] = {message_id: [] for message_id in message_ids} stmt = ( select(ExecutionExtraContentModel) @@ -95,8 +96,12 @@ class SQLAlchemyExecutionExtraContentRepository(ExecutionExtraContentRepository) self, model: ExecutionExtraContentModel, recipients_by_form_id: dict[str, list[HumanInputFormRecipient]], - ) -> ExecutionExtraContentDomainModel | None: + ) -> StoredExecutionExtraContent | None: if isinstance(model, HumanInputContentModel): + # V2 forms live in a separate repository. Preserve their references + # so the service can restore presentation and initiator access. + if model.form is None: + return HumanInputFormReference(workflow_run_id=model.workflow_run_id, form_id=model.form_id) return self._map_human_input_content(model, recipients_by_form_id) logger.debug("Unsupported execution extra content type encountered: %s", model.type) diff --git a/api/repositories/workspace/workspace_repository.py b/api/repositories/workspace/workspace_repository.py index 530641608d3..62809143d15 100644 --- a/api/repositories/workspace/workspace_repository.py +++ b/api/repositories/workspace/workspace_repository.py @@ -7,6 +7,7 @@ from typing import override from sqlalchemy import func, select, update from sqlalchemy.orm import Session, sessionmaker +from core.human_input_v2.shared import AccountId from libs.datetime_utils import naive_utc_now from libs.pagination import paginate_query from models import TenantCreditPool @@ -26,6 +27,7 @@ from models.dataset import Dataset from models.enums import ProviderQuotaType from models.model import App from repositories.account.repository import SQLAlchemyAccountRepository +from repositories.human_input_v2.sqlalchemy_contact_repository import SQLAlchemyContactRepository from services.account.contracts import AccountCreation from services.account.login_service import ConsoleAuthWorkspaceQuery from services.account_ports import AccountWorkspaceMembershipQuery, AccountWorkspaceSnapshotQuery @@ -120,6 +122,8 @@ class WorkspaceRepository( session.add(tenant) if owner_id is not None: session.add(TenantAccountJoin(tenant_id=tenant.id, account_id=owner_id, role=TenantAccountRole.OWNER)) + if account is None: + SQLAlchemyContactRepository(session).provision_account_backed_contact(AccountId(owner_id)) for category, setting in creation.plugin_upgrade_settings.items(): session.add( TenantPluginAutoUpgradeStrategy( @@ -477,6 +481,8 @@ class WorkspaceRepository( session.add(TenantAccountJoin(tenant_id=workspace_id, account_id=account_id, role=role)) else: membership.role = role + # Membership writes repair missing Contact identities for existing accounts. + SQLAlchemyContactRepository(session).provision_account_backed_contact(AccountId(account_id)) if previous_role is not role: from services.knowledge_fs.membership_changes import ( KnowledgeFSWorkspaceMembershipChange, diff --git a/api/services/account/service.py b/api/services/account/service.py index 90025b83edf..c2d6533e877 100644 --- a/api/services/account/service.py +++ b/api/services/account/service.py @@ -252,7 +252,7 @@ class AccountService: class InstallationStore(Protocol): def mark_setup(self) -> None: ... - def reset_setup(self) -> None: ... + def reset_setup(self, account_id: str | None) -> None: ... class InstallationTelemetry(Protocol): @@ -274,6 +274,7 @@ class AccountSetupProvisioner: self._telemetry = telemetry def provision(self, setup: SetupInput) -> None: + account_id: str | None = None try: account = self._accounts.create_account( email=setup.email, @@ -284,9 +285,10 @@ class AccountSetupProvisioner: is_setup=True, initialized=True, ) + account_id = account.id self._workspaces.create_owner_workspace(account.id, is_setup=True, if_missing=True) self._installation.mark_setup() except Exception as exc: - self._installation.reset_setup() + self._installation.reset_setup(account_id) raise ValueError(f"Setup failed: {exc}") from exc self._telemetry.report() diff --git a/api/services/enterprise/human_input_contact_composition.py b/api/services/enterprise/human_input_contact_composition.py new file mode 100644 index 00000000000..14ea72f1551 --- /dev/null +++ b/api/services/enterprise/human_input_contact_composition.py @@ -0,0 +1,31 @@ +"""Enterprise-only composition for Human Input Contact capabilities.""" + +from sqlalchemy.orm import Session + +from repositories.human_input_v2.sqlalchemy_contact_repository import ( + SQLAlchemyContactIMBindingRepository, + SQLAlchemyContactRepository, +) +from repositories.human_input_v2.sqlalchemy_im_channel_repository import DeploymentIMChannelReader +from repositories.human_input_v2.sqlalchemy_im_identity_repository import SQLAlchemyIMIdentityRepository +from services.human_input_v2.contact_service import ContactManagementService + +from .human_input_contact_service import EnterpriseContactManagementService, EnterpriseOrganizationContactReader + + +def build_enterprise_contact_management_service(session: Session) -> EnterpriseContactManagementService: + repository = SQLAlchemyContactRepository(session) + channel = DeploymentIMChannelReader(session).get() + contact_queries = ContactManagementService( + repository, + SQLAlchemyContactIMBindingRepository(session, channel), + SQLAlchemyIMIdentityRepository(session, channel.id) if channel is not None else None, + ) + return EnterpriseContactManagementService(repository, repository, contact_queries) + + +def build_enterprise_organization_contact_reader(session: Session) -> EnterpriseOrganizationContactReader: + return EnterpriseOrganizationContactReader(SQLAlchemyContactRepository(session)) + + +__all__ = ["build_enterprise_contact_management_service", "build_enterprise_organization_contact_reader"] diff --git a/api/services/enterprise/human_input_contact_service.py b/api/services/enterprise/human_input_contact_service.py new file mode 100644 index 00000000000..104d2c2d4c2 --- /dev/null +++ b/api/services/enterprise/human_input_contact_service.py @@ -0,0 +1,126 @@ +"""Enterprise-only Human Input Contact application capabilities.""" + +from __future__ import annotations + +from collections.abc import Sequence + +from core.human_input_v2.shared import AccountId, ContactId, TenantId +from repositories.human_input_v2.contact import ( + Contact, + ContactRepository, + ContactType, + EnterpriseContactRepository, + ExternalContact, + OrganizationCandidate, +) +from services.human_input_v2.contact_service import ContactManagementService, ContactWithIMBindings + +_CANDIDATE_PAGE_LIMIT = 500 + + +class EnterpriseContactManagementService: + """Own EE candidate and Platform mutations outside core Contact flows.""" + + def __init__( + self, + contacts: ContactRepository, + enterprise_contacts: EnterpriseContactRepository, + contact_queries: ContactManagementService, + ) -> None: + self._contacts = contacts + self._enterprise_contacts = enterprise_contacts + self._contact_queries = contact_queries + + def list_organization_candidates( + self, + *, + page: int, + limit: int, + keyword: str, + ) -> Sequence[OrganizationCandidate]: + return self._enterprise_contacts.list_organization_candidates(page, limit, keyword) + + def count_organization_candidates(self, keyword: str) -> int: + return self._enterprise_contacts.count_organization_candidates(keyword) + + def add_platform_contacts( + self, + tenant_id: TenantId, + candidate_ids: Sequence[ContactId], + added_by_account_id: AccountId, + ) -> tuple[ContactWithIMBindings, ...]: + distinct_ids = tuple(dict.fromkeys(candidate_ids)) + for candidate_id in distinct_ids: + self._enterprise_contacts.create_platform_entry(tenant_id, candidate_id, added_by_account_id) + return self._contact_queries.get_contacts(tenant_id, distinct_ids) + + def remove_contacts(self, tenant_id: TenantId, contact_ids: Sequence[ContactId]) -> tuple[ContactId, ...]: + distinct_ids = tuple(dict.fromkeys(contact_ids)) + contacts = self._contacts.get_contacts_by_ids(tenant_id, distinct_ids) + contacts_by_id = {contact.id: contact for contact in contacts} + if len(contacts_by_id) != len(distinct_ids): + raise ValueError("one or more Contacts are unavailable") + for contact_id in distinct_ids: + contact = contacts_by_id[contact_id] + if contact.type is ContactType.WORKSPACE: + raise ValueError("workspace Contacts must be removed through membership management") + if contact.type is ContactType.PLATFORM: + self._enterprise_contacts.delete_platform_entry(tenant_id, contact.id) + continue + self._contacts.delete_external_contact(tenant_id, _external_contact(contact)) + return distinct_ids + + +class EnterpriseOrganizationContactReader: + """Adapt EE Organization candidates for deployment-scoped reconciliation.""" + + def __init__(self, contacts: EnterpriseContactRepository) -> None: + self._contacts = contacts + self._cached_contacts: tuple[Contact, ...] | None = None + + def list_contacts(self, page: int, limit: int) -> Sequence[Contact]: + contacts = self._load_contacts() + offset = (page - 1) * limit + return contacts[offset : offset + limit] + + def get_contact(self, contact_id: ContactId) -> Contact | None: + return next((contact for contact in self._load_contacts() if contact.id == contact_id), None) + + def _load_contacts(self) -> tuple[Contact, ...]: + if self._cached_contacts is not None: + return self._cached_contacts + candidates: list[OrganizationCandidate] = [] + page = 1 + while True: + candidate_page = self._contacts.list_organization_candidates(page, _CANDIDATE_PAGE_LIMIT) + candidates.extend(candidate_page) + if len(candidate_page) < _CANDIDATE_PAGE_LIMIT: + break + page += 1 + self._cached_contacts = tuple( + Contact( + id=candidate.id, + type=ContactType.WORKSPACE, + name=candidate.name, + email=candidate.email, + avatar_file_id=candidate.avatar_file_id, + created_at=candidate.created_at, + ) + for candidate in candidates + ) + return self._cached_contacts + + +def _external_contact(contact: Contact) -> ExternalContact: + if contact.email is None: + raise RuntimeError("External Contact is missing its required Email") + return ExternalContact( + contact.id, + contact.name, + contact.email, + contact.avatar_file_id, + contact.created_at, + ) + + +__all__ = ["EnterpriseContactManagementService", "EnterpriseOrganizationContactReader"] diff --git a/api/services/enterprise/rbac_service.py b/api/services/enterprise/rbac_service.py index 15661f15782..cd6860141b1 100644 --- a/api/services/enterprise/rbac_service.py +++ b/api/services/enterprise/rbac_service.py @@ -500,7 +500,7 @@ class RBACService: """ # ------------------------------------------------------------------ - # Permission catalog (screenshot 3: 新增/编辑角色 弹窗内的权限列表). + # Permission catalog (screenshot 3: permission list in the create/edit role dialog). # ------------------------------------------------------------------ class Catalog: @staticmethod diff --git a/api/services/entities/feature_entities.py b/api/services/entities/feature_entities.py index 843b64c723b..6a54e573b31 100644 --- a/api/services/entities/feature_entities.py +++ b/api/services/entities/feature_entities.py @@ -165,6 +165,9 @@ class FeatureModel(FeatureResponseModel): api_rate_limit: Quota = Quota(usage=0, limit=5000, reset_date=0) # Controls whether email delivery is allowed for HumanInput nodes. human_input_email_delivery_enabled: bool = False + # Centrally managed IM channels and default bindings cannot be edited in Console. + # Workspaces may override bindings without changing the shared channel credentials. + im_centrally_managed: bool = False knowledge_pipeline: KnowledgePipeline = KnowledgePipeline() # Workspace and model-provider credit responses own the public reset-date contract. next_credit_reset_date: int = Field(default=0, exclude=True) diff --git a/api/services/feature_service.py b/api/services/feature_service.py index 6049a851305..c1008e89679 100644 --- a/api/services/feature_service.py +++ b/api/services/feature_service.py @@ -8,6 +8,16 @@ from services.knowledge_fs.feature_service import get_knowledge_fs_features class FeatureService: + @staticmethod + def is_im_centrally_managed() -> bool: + """Enterprise centrally manages IM channels and default bindings.""" + return dify_config.DEPLOYMENT_EDITION == DeploymentEdition.ENTERPRISE + + @staticmethod + def use_tenant_scoped_email_config() -> bool: + """Cloud uses tenant Email Channels instead of the global mail client.""" + return dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD + @classmethod def can_import_premium_site_settings(cls, tenant_id: str) -> bool: """Keep Cloud-only Web app customization out of free workspace imports.""" @@ -26,6 +36,7 @@ class FeatureService: @classmethod def get_features(cls, tenant_id: str, exclude_vector_space: bool = False) -> feature_entities.FeatureModel: features = feature_entities.FeatureModel() + features.im_centrally_managed = cls.is_im_centrally_managed() if exclude_vector_space: features.vector_space = None diff --git a/api/services/human_input_form_delivery_provider.py b/api/services/human_input_form_delivery_provider.py new file mode 100644 index 00000000000..e70dc97e923 --- /dev/null +++ b/api/services/human_input_form_delivery_provider.py @@ -0,0 +1,348 @@ +"""Providers for delivering human input forms through configured channels. + +Dispatch has two explicit phases: load immutable DTOs while a SQLAlchemy session +is active, then perform provider I/O after the session context has closed. +Providers must not depend on live ORM instances because email and future IM +clients can block or retry independently from database transaction lifetimes. +""" + +from __future__ import annotations + +import logging +from collections.abc import Sequence +from dataclasses import dataclass +from typing import Protocol + +from pydantic import TypeAdapter, ValidationError +from sqlalchemy import select +from sqlalchemy.orm import Session + +from configs import dify_config +from core.workflow.human_input_adapter import ( + DeliveryMethodType, + EmailDeliveryConfig, + EmailDeliveryMethod, + InstantMessageDeliveryMethod, +) +from extensions.ext_mail import mail +from graphon.runtime import VariablePool +from models.human_input import ( + EmailExternalRecipientPayload, + EmailMemberRecipientPayload, + HumanInputDelivery, + HumanInputForm, + HumanInputFormRecipient, + InstantMessageRecipientPayload, + RecipientPayload, + RecipientType, +) + +logger = logging.getLogger(__name__) + +_RECIPIENT_PAYLOAD_ADAPTER = TypeAdapter(RecipientPayload) + + +@dataclass(frozen=True) +class HumanInputFormDeliveryRecipientContext: + recipient_id: str + recipient_type: RecipientType + recipient_payload: str + access_token: str | None + + +@dataclass(frozen=True) +class HumanInputFormDeliveryContext: + form_id: str + tenant_id: str + app_id: str + workflow_run_id: str | None + rendered_content: str + delivery_id: str + delivery_method_type: DeliveryMethodType + delivery_config_id: str | None + channel_payload: str + recipients: tuple[HumanInputFormDeliveryRecipientContext, ...] + variable_pool: VariablePool | None = None + + +class HumanInputFormDeliveryProvider(Protocol): + """Sends a persisted human input form through one delivery method.""" + + delivery_method_type: DeliveryMethodType + + def send(self, *, context: HumanInputFormDeliveryContext) -> None: ... + + +class HumanInputFormMailClient(Protocol): + def send(self, *, to: str, subject: str, html: str) -> None: ... + + +class HumanInputFormDeliveryProviderRegistry: + _providers: dict[DeliveryMethodType, HumanInputFormDeliveryProvider] + + def __init__(self, providers: Sequence[HumanInputFormDeliveryProvider] | None = None) -> None: + self._providers: dict[DeliveryMethodType, HumanInputFormDeliveryProvider] = {} + for provider in providers or (): + self.register(provider) + + def register(self, provider: HumanInputFormDeliveryProvider) -> None: + self._providers[provider.delivery_method_type] = provider + + def dispatch(self, *, context: HumanInputFormDeliveryContext, continue_on_error: bool = True) -> bool: + """Dispatch one loaded context through its provider. + + Provider calls are external I/O, so callers default to isolating send failures per context. + Loading and DTO construction stay outside this boundary and should fail through their caller. + """ + provider = self._providers.get(context.delivery_method_type) + if provider is None: + logger.warning( + "No human input form delivery provider registered, form_id=%s, delivery_id=%s, method=%s", + context.form_id, + context.delivery_id, + context.delivery_method_type, + ) + return False + + try: + provider.send(context=context) + except Exception: + logger.exception( + "Failed to dispatch human input form delivery, form_id=%s, delivery_id=%s, method=%s", + context.form_id, + context.delivery_id, + context.delivery_method_type, + ) + if not continue_on_error: + raise + return False + return True + + @classmethod + def default(cls, *, mail_client: HumanInputFormMailClient = mail) -> HumanInputFormDeliveryProviderRegistry: + return cls( + [ + EmailHumanInputFormDeliveryProvider(mail_client=mail_client), + UnsupportedInstantMessageHumanInputFormDeliveryProvider(), + ] + ) + + +class HumanInputFormDeliveryDispatcher: + _registry: HumanInputFormDeliveryProviderRegistry + + def __init__(self, registry: HumanInputFormDeliveryProviderRegistry | None = None) -> None: + self._registry = registry or HumanInputFormDeliveryProviderRegistry.default() + + def load_form_contexts( + self, + *, + session: Session, + form: HumanInputForm, + variable_pool: VariablePool | None = None, + delivery_method_types: Sequence[DeliveryMethodType] | None = None, + ) -> tuple[HumanInputFormDeliveryContext, ...]: + stmt = select(HumanInputDelivery).where(HumanInputDelivery.form_id == form.id) + if delivery_method_types: + stmt = stmt.where(HumanInputDelivery.delivery_method_type.in_(delivery_method_types)) + + deliveries = session.scalars(stmt).all() + contexts: list[HumanInputFormDeliveryContext] = [] + for delivery in deliveries: + recipients = session.scalars( + select(HumanInputFormRecipient).where(HumanInputFormRecipient.delivery_id == delivery.id), + ).all() + contexts.append( + _build_delivery_context( + form=form, + delivery=delivery, + recipients=recipients, + variable_pool=variable_pool, + ) + ) + return tuple(contexts) + + def dispatch_contexts( + self, + contexts: Sequence[HumanInputFormDeliveryContext], + *, + continue_on_error: bool = True, + ) -> None: + for context in contexts: + self._registry.dispatch(context=context, continue_on_error=continue_on_error) + + +def _build_delivery_context( + *, + form: HumanInputForm, + delivery: HumanInputDelivery, + recipients: Sequence[HumanInputFormRecipient], + variable_pool: VariablePool | None, +) -> HumanInputFormDeliveryContext: + return HumanInputFormDeliveryContext( + form_id=form.id, + tenant_id=form.tenant_id, + app_id=form.app_id, + workflow_run_id=form.workflow_run_id, + rendered_content=form.rendered_content, + delivery_id=delivery.id, + delivery_method_type=delivery.delivery_method_type, + delivery_config_id=delivery.delivery_config_id, + channel_payload=delivery.channel_payload, + recipients=tuple( + HumanInputFormDeliveryRecipientContext( + recipient_id=recipient.id, + recipient_type=recipient.recipient_type, + recipient_payload=recipient.recipient_payload, + access_token=recipient.access_token, + ) + for recipient in recipients + ), + variable_pool=variable_pool, + ) + + +@dataclass(frozen=True) +class _EmailRecipient: + email: str + token: str + + +class EmailHumanInputFormDeliveryProvider: + _mail: HumanInputFormMailClient + + delivery_method_type = DeliveryMethodType.EMAIL + + def __init__(self, *, mail_client: HumanInputFormMailClient = mail) -> None: + self._mail = mail_client + + def send(self, *, context: HumanInputFormDeliveryContext) -> None: + try: + delivery_method = EmailDeliveryMethod.model_validate_json(context.channel_payload) + except ValidationError: + # Persisted payloads can be stale or manually corrupted; skip only this delivery. + logger.warning( + "Invalid human input email delivery payload, form_id=%s, delivery_id=%s", + context.form_id, + context.delivery_id, + exc_info=True, + ) + return + + recipients = self._resolve_recipients(context.recipients) + if not recipients: + return + + subject = EmailDeliveryConfig.sanitize_subject(delivery_method.config.subject) + for recipient in recipients: + form_link = _build_form_link(recipient.token) + body = _render_body( + delivery_method.config.body, + form_link, + variable_pool=context.variable_pool, + ) + self._mail.send( + to=recipient.email, + subject=subject, + html=body, + ) + + @staticmethod + def _resolve_recipients(recipients: Sequence[HumanInputFormDeliveryRecipientContext]) -> list[_EmailRecipient]: + resolved: list[_EmailRecipient] = [] + for recipient in recipients: + payload = _parse_email_recipient_payload(recipient) + if payload is None: + continue + token = recipient.access_token + if not token: + continue + resolved.append(_EmailRecipient(email=payload.email, token=token)) + return resolved + + +class UnsupportedInstantMessageHumanInputFormDeliveryProvider: + delivery_method_type = DeliveryMethodType.IM + + def send(self, *, context: HumanInputFormDeliveryContext) -> None: + try: + InstantMessageDeliveryMethod.model_validate_json(context.channel_payload) + except ValidationError: + logger.warning( + "Invalid human input instant message delivery payload, form_id=%s, delivery_id=%s", + context.form_id, + context.delivery_id, + exc_info=True, + ) + return + + valid_recipients = 0 + for recipient in context.recipients: + if _parse_instant_message_recipient_payload(recipient) is not None: + valid_recipients += 1 + logger.warning( + "Human input instant message delivery is not implemented, form_id=%s, delivery_id=%s, recipients=%s", + context.form_id, + context.delivery_id, + valid_recipients, + ) + + +def _build_form_link(token: str) -> str: + base_url = dify_config.APP_WEB_URL + return f"{base_url.rstrip('/')}/form/{token}" + + +def _parse_email_recipient_payload( + recipient: HumanInputFormDeliveryRecipientContext, +) -> EmailMemberRecipientPayload | EmailExternalRecipientPayload | None: + try: + payload = _RECIPIENT_PAYLOAD_ADAPTER.validate_json(recipient.recipient_payload) + except ValidationError: + # Recipient payloads are persisted per delivery; a bad row should not block other recipients. + logger.warning( + "Invalid human input recipient payload, recipient_id=%s", + recipient.recipient_id, + exc_info=True, + ) + return None + + match payload: + case EmailMemberRecipientPayload() | EmailExternalRecipientPayload(): + if payload.email: + return payload + return None + case _: + return None + + +def _parse_instant_message_recipient_payload( + recipient: HumanInputFormDeliveryRecipientContext, +) -> InstantMessageRecipientPayload | None: + try: + payload = _RECIPIENT_PAYLOAD_ADAPTER.validate_json(recipient.recipient_payload) + except ValidationError: + logger.warning( + "Invalid human input instant message recipient payload, recipient_id=%s", + recipient.recipient_id, + exc_info=True, + ) + return None + + if isinstance(payload, InstantMessageRecipientPayload): + return payload + return None + + +def _render_body( + body_template: str, + form_link: str, + *, + variable_pool: VariablePool | None, +) -> str: + body = EmailDeliveryConfig.render_body_template( + body=body_template, + url=form_link, + variable_pool=variable_pool, + ) + return EmailDeliveryConfig.render_markdown_body(body) diff --git a/api/services/human_input_service.py b/api/services/human_input_service.py index c577a872fca..8e4b7ebf291 100644 --- a/api/services/human_input_service.py +++ b/api/services/human_input_service.py @@ -1,7 +1,7 @@ import logging from collections.abc import Mapping, Sequence from datetime import datetime, timedelta -from typing import Any, Protocol, cast +from typing import Any, Protocol, cast, override from pydantic import JsonValue, TypeAdapter, ValidationError from sqlalchemy import Engine, select @@ -519,3 +519,20 @@ class HumanInputService: allowed_file_upload_methods=list(form_input.allowed_file_upload_methods), number_limits=number_limits, ) + + +class ServiceAPIHumanInputService(HumanInputService): + """Preserve legacy JSON paragraph values for app-token form submissions.""" + + @classmethod + @override + def _normalize_input_value( + cls, + *, + tenant_id: str, + form_input: FormInputConfig, + value: Any, + ) -> JsonValue: + if isinstance(form_input, ParagraphInputConfig): + return _JsonValueAdapter.validate_python(value) + return super()._normalize_input_value(tenant_id=tenant_id, form_input=form_input, value=value) diff --git a/api/services/human_input_v2/__init__.py b/api/services/human_input_v2/__init__.py new file mode 100644 index 00000000000..3ebdcecfddf --- /dev/null +++ b/api/services/human_input_v2/__init__.py @@ -0,0 +1 @@ +"""Application services for Human Input v2 use cases.""" diff --git a/api/services/human_input_v2/composition.py b/api/services/human_input_v2/composition.py new file mode 100644 index 00000000000..9137f07f1af --- /dev/null +++ b/api/services/human_input_v2/composition.py @@ -0,0 +1,104 @@ +"""Production composition for shared Human Input v2 services.""" + +from __future__ import annotations + +from collections.abc import Callable +from datetime import timedelta +from typing import override + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from configs import dify_config +from core.app.entities.app_invoke_entities import DifyRunContext, InvokeFrom, UserFrom +from core.db.session_factory import session_factory +from core.human_input_v2.shared.values import AccountId, TenantId +from core.workflow.nodes.human_input_v2.entities import HumanInputNodeData +from core.workflow.nodes.human_input_v2.runtime import HumanInputDeliveryError, HumanInputRuntime, PreparedForm +from core.workflow.system_variables import SystemVariableKey, get_system_text +from graphon.runtime.runtime_state_protocol import ReadOnlyVariablePool +from libs.datetime_utils import ensure_naive_utc +from models.workflow import WorkflowRun + +from .delivery_service import HumanInputDeliveryService +from .email_client import build_email_client +from .form_service import AccountInitiator, EndUserInitiator, FormExecutionContext, HumanInputFormService +from .node_data_migration import HumanInputNodeDataMigrationService +from .runtime import DifyHumanInputRuntime +from .workspace_member_email_lookup import SQLAlchemyWorkspaceMemberEmailLookup + + +def build_human_input_delivery_service( + *, tenant_id: TenantId, session_factory: Callable[[], Session] +) -> HumanInputDeliveryService: + return HumanInputDeliveryService( + session_factory=session_factory, + email_client=build_email_client(tenant_id=tenant_id, session_factory=session_factory), + ) + + +def build_human_input_node_data_migration_service() -> HumanInputNodeDataMigrationService: + """Compose the read-only migration service for one Console request.""" + + return HumanInputNodeDataMigrationService( + member_email_lookup=SQLAlchemyWorkspaceMemberEmailLookup(session_factory.create_session), + ) + + +class WorkflowHumanInputRuntime(HumanInputRuntime): + """Bind services when execution starts, after the run has been persisted. + + Graph construction precedes the run-start persistence layer. In particular, + do not read the run or calculate a new global deadline in NodeFactory. + """ + + def __init__(self, context: DifyRunContext) -> None: + self._context = context + + @override + def prepare_form( + self, + *, + node_execution_id: str, + node_data: HumanInputNodeData, + variable_pool: ReadOnlyVariablePool, + ) -> PreparedForm: + run_id = get_system_text(variable_pool, SystemVariableKey.WORKFLOW_EXECUTION_ID) + if run_id is None: + raise HumanInputDeliveryError("Human Input requires a persisted workflow run") + with session_factory.create_session() as session: + run = session.scalar( + select(WorkflowRun).where( + WorkflowRun.id == run_id, + WorkflowRun.tenant_id == self._context.tenant_id, + WorkflowRun.app_id == self._context.app_id, + ) + ) + if run is None: + raise HumanInputDeliveryError("Human Input workflow run was not found") + deadline = ensure_naive_utc(run.created_at) + timedelta( + seconds=dify_config.HUMAN_INPUT_GLOBAL_TIMEOUT_SECONDS + ) + initiator: AccountInitiator | EndUserInitiator | None = None + if self._context.user_from == UserFrom.ACCOUNT: + initiator = AccountInitiator(AccountId(self._context.user_id)) + elif self._context.invoke_from in (InvokeFrom.WEB_APP, InvokeFrom.SERVICE_API): + initiator = EndUserInitiator(self._context.user_id) + context = FormExecutionContext( + tenant_id=TenantId(self._context.tenant_id), + app_id=self._context.app_id, + workflow_run_id=run_id, + global_timeout_deadline=deadline, + initiator=initiator, + debugging_account_id=( + AccountId(self._context.user_id) + if self._context.invoke_from == InvokeFrom.DEBUGGER and self._context.user_from == UserFrom.ACCOUNT + else None + ), + ) + return DifyHumanInputRuntime( + form_service=HumanInputFormService(session_factory=session_factory.get_session_maker(), context=context), + delivery_service=build_human_input_delivery_service( + tenant_id=context.tenant_id, session_factory=session_factory.create_session + ), + ).prepare_form(node_execution_id=node_execution_id, node_data=node_data, variable_pool=variable_pool) diff --git a/api/services/human_input_v2/contact_service.py b/api/services/human_input_v2/contact_service.py new file mode 100644 index 00000000000..5a2f84b81be --- /dev/null +++ b/api/services/human_input_v2/contact_service.py @@ -0,0 +1,188 @@ +"""Application orchestration for workspace Contact management surfaces.""" + +from __future__ import annotations + +from collections.abc import Sequence +from dataclasses import dataclass + +from pydantic import NaiveDatetime + +from core.human_input_v2.shared import ContactId, TenantId +from repositories.human_input_v2.contact import ( + Contact, + ContactIMBindingRepository, + ContactQuery, + ContactRepository, + ContactType, + ExternalContact, + IMBinding, + Page, +) +from repositories.human_input_v2.im_identity_repository import IMIdentity, IMIdentityRepository + + +@dataclass(frozen=True, slots=True) +class ContactWithIMBindings: + contact: Contact + im_bindings: tuple[IMBinding, ...] + + +@dataclass(frozen=True, slots=True) +class IMBindingDetail: + binding: IMBinding + identity: IMIdentity + + +@dataclass(frozen=True, slots=True) +class ContactDetails: + contact: ContactWithIMBindings + im_binding_details: tuple[IMBindingDetail, ...] + + +class ContactManagementService: + """Compose Contact and optional binding values without persistence details.""" + + def __init__( + self, + contact_repository: ContactRepository, + binding_repository: ContactIMBindingRepository, + identity_repository: IMIdentityRepository | None, + ) -> None: + self._contacts = contact_repository + self._bindings = binding_repository + self._identities = identity_repository + + def list_contacts( + self, + tenant_id: TenantId, + *, + page: int, + limit: int, + query: ContactQuery, + ) -> tuple[Page[Contact], tuple[ContactWithIMBindings, ...]]: + contact_page = self._contacts.list_contact(tenant_id, page, limit, query) + return contact_page, self._with_bindings(tenant_id, contact_page.items) + + def count_contacts(self, tenant_id: TenantId, query: ContactQuery) -> int: + return self._contacts.count_contact(tenant_id, query) + + def get_contact(self, tenant_id: TenantId, contact_id: ContactId) -> ContactWithIMBindings | None: + contact = self._contacts.get_contacts_by_id(tenant_id, contact_id) + if contact is None: + return None + return self._with_bindings(tenant_id, (contact,))[0] + + def get_contacts( + self, + tenant_id: TenantId, + contact_ids: Sequence[ContactId], + ) -> tuple[ContactWithIMBindings, ...]: + return self._with_bindings(tenant_id, self._contacts.get_contacts_by_ids(tenant_id, contact_ids)) + + def get_contact_details(self, tenant_id: TenantId, contact_id: ContactId) -> ContactDetails | None: + contact = self.get_contact(tenant_id, contact_id) + if contact is None: + return None + if not contact.im_bindings: + return ContactDetails(contact, ()) + if self._identities is None: + raise ValueError("An identity repository is required to resolve effective IM bindings") + identities = self._identities.get_many(tuple(binding.identity_id for binding in contact.im_bindings)) + identities_by_id = {identity.id: identity for identity in identities} + details: list[IMBindingDetail] = [] + for binding in contact.im_bindings: + identity = identities_by_id.get(binding.identity_id) + if identity is None: + raise ValueError( + f"Effective IM binding {binding.id} references a missing identity {binding.identity_id}" + ) + details.append(IMBindingDetail(binding, identity)) + return ContactDetails(contact, tuple(details)) + + def list_contact_options( + self, + tenant_id: TenantId, + *, + page: int, + limit: int, + keyword: str, + ) -> Page[Contact]: + return self._contacts.list_contact( + tenant_id, + page, + limit, + ContactQuery(keyword=keyword), + ) + + def get_contact_options( + self, + tenant_id: TenantId, + contact_ids: Sequence[ContactId], + ) -> Sequence[Contact]: + return self._contacts.get_contacts_by_ids(tenant_id, contact_ids) + + def create_external_contact( + self, + tenant_id: TenantId, + *, + contact_id: ContactId, + name: str, + email: str, + avatar_file_id: str | None, + now: NaiveDatetime, + ) -> ContactWithIMBindings: + contact = self._contacts.save_external_contact( + tenant_id, + ExternalContact(contact_id, name, email, avatar_file_id, now), + ) + return ContactWithIMBindings(contact, ()) + + def update_external_contact( + self, + tenant_id: TenantId, + external_contact: ExternalContact, + ) -> ContactWithIMBindings: + contact = self._contacts.save_external_contact(tenant_id, external_contact) + return ContactWithIMBindings(contact, ()) + + def remove_contacts(self, tenant_id: TenantId, contact_ids: Sequence[ContactId]) -> tuple[ContactId, ...]: + distinct_ids = tuple(dict.fromkeys(contact_ids)) + contacts = self._contacts.get_contacts_by_ids(tenant_id, distinct_ids) + contacts_by_id = {contact.id: contact for contact in contacts} + if len(contacts_by_id) != len(distinct_ids): + raise ValueError("one or more Contacts are unavailable") + for contact_id in distinct_ids: + contact = contacts_by_id[contact_id] + if contact.type is ContactType.WORKSPACE: + raise ValueError("workspace Contacts must be removed through membership management") + if contact.type is ContactType.PLATFORM: + raise ValueError("Platform Contacts require the Enterprise Contact capability") + if contact.email is None: + raise RuntimeError("External Contact is missing its required Email") + self._contacts.delete_external_contact( + tenant_id, + ExternalContact( + contact.id, + contact.name, + contact.email, + contact.avatar_file_id, + contact.created_at, + ), + ) + return distinct_ids + + def _with_bindings( + self, + tenant_id: TenantId, + contacts: Sequence[Contact], + ) -> tuple[ContactWithIMBindings, ...]: + bindings = self._bindings.get_im_bindings(tenant_id, [contact.id for contact in contacts]) + bindings_by_contact: dict[ContactId, list[IMBinding]] = {} + for binding in bindings: + bindings_by_contact.setdefault(binding.contact_id, []).append(binding) + return tuple( + ContactWithIMBindings(contact, tuple(bindings_by_contact.get(contact.id, ()))) for contact in contacts + ) + + +__all__ = ["ContactManagementService", "ContactWithIMBindings"] diff --git a/api/services/human_input_v2/credential_update.py b/api/services/human_input_v2/credential_update.py new file mode 100644 index 00000000000..b2c8e9d154b --- /dev/null +++ b/api/services/human_input_v2/credential_update.py @@ -0,0 +1,169 @@ +"""Typed credential edits; persisted credentials always remain complete values.""" + +from dataclasses import dataclass, field +from enum import Enum +from typing import Literal + +from core.human_input_v2.entities import IMProvider +from core.human_input_v2.im_integration.adapters.credentials import ( + DingTalkCredentials, + FeishuCredentials, + IMProviderCredentials, + LarkCredentials, + MSTeamsCredentials, + SlackCredentials, + WeComCredentials, +) +from core.human_input_v2.shared import NormalizedEmail +from services.human_input_v2.errors import ReplacementRequiredError + + +class Unchanged(Enum): + VALUE = "unchanged" + + +def updated_value[T](edit: T | Unchanged, current: T) -> T: + return current if isinstance(edit, Unchanged) else edit + + +@dataclass(frozen=True, slots=True) +class FeishuCredentialsUpdate: + app_id: str | Unchanged + app_secret: str | Unchanged = field(repr=False) + verification_token: str | None | Unchanged = field(repr=False) + encrypt_key: str | None | Unchanged = field(repr=False) + provider: Literal[IMProvider.FEISHU] = field(init=False, default=IMProvider.FEISHU) + + def apply(self, current: FeishuCredentials) -> FeishuCredentials: + return FeishuCredentials( + provider=self.provider, + app_id=updated_value(self.app_id, current.app_id), + app_secret=updated_value(self.app_secret, current.app_secret), + verification_token=updated_value(self.verification_token, current.verification_token), + encrypt_key=updated_value(self.encrypt_key, current.encrypt_key), + ) + + +@dataclass(frozen=True, slots=True) +class LarkCredentialsUpdate: + app_id: str | Unchanged + app_secret: str | Unchanged = field(repr=False) + verification_token: str | None | Unchanged = field(repr=False) + encrypt_key: str | None | Unchanged = field(repr=False) + provider: Literal[IMProvider.LARK] = field(init=False, default=IMProvider.LARK) + + def apply(self, current: LarkCredentials) -> LarkCredentials: + return LarkCredentials( + provider=self.provider, + app_id=updated_value(self.app_id, current.app_id), + app_secret=updated_value(self.app_secret, current.app_secret), + verification_token=updated_value(self.verification_token, current.verification_token), + encrypt_key=updated_value(self.encrypt_key, current.encrypt_key), + ) + + +@dataclass(frozen=True, slots=True) +class SlackCredentialsUpdate: + client_id: str | Unchanged + client_secret: str | Unchanged = field(repr=False) + signing_secret: str | Unchanged = field(repr=False) + bot_token: str | Unchanged = field(repr=False) + app_token: str | None | Unchanged = field(repr=False) + provider: Literal[IMProvider.SLACK] = field(init=False, default=IMProvider.SLACK) + + def apply(self, current: SlackCredentials) -> SlackCredentials: + return SlackCredentials( + provider=self.provider, + client_id=updated_value(self.client_id, current.client_id), + client_secret=updated_value(self.client_secret, current.client_secret), + signing_secret=updated_value(self.signing_secret, current.signing_secret), + bot_token=updated_value(self.bot_token, current.bot_token), + app_token=updated_value(self.app_token, current.app_token), + ) + + +@dataclass(frozen=True, slots=True) +class DingTalkCredentialsUpdate: + corp_id: str | Unchanged + client_id: str | Unchanged + client_secret: str | Unchanged = field(repr=False) + provider: Literal[IMProvider.DING_TALK] = field(init=False, default=IMProvider.DING_TALK) + + def apply(self, current: DingTalkCredentials) -> DingTalkCredentials: + return DingTalkCredentials( + provider=self.provider, + corp_id=updated_value(self.corp_id, current.corp_id), + client_id=updated_value(self.client_id, current.client_id), + client_secret=updated_value(self.client_secret, current.client_secret), + ) + + +@dataclass(frozen=True, slots=True) +class MSTeamsCredentialsUpdate: + tenant_id: str | Unchanged + client_id: str | Unchanged + client_secret: str | Unchanged = field(repr=False) + provider: Literal[IMProvider.MS_TEAMS] = field(init=False, default=IMProvider.MS_TEAMS) + + def apply(self, current: MSTeamsCredentials) -> MSTeamsCredentials: + return MSTeamsCredentials( + provider=self.provider, + tenant_id=updated_value(self.tenant_id, current.tenant_id), + client_id=updated_value(self.client_id, current.client_id), + client_secret=updated_value(self.client_secret, current.client_secret), + ) + + +@dataclass(frozen=True, slots=True) +class WeComCredentialsUpdate: + corp_id: str | Unchanged + agent_id: str | Unchanged + secret: str | Unchanged = field(repr=False) + provider: Literal[IMProvider.WE_COM] = field(init=False, default=IMProvider.WE_COM) + + def apply(self, current: WeComCredentials) -> WeComCredentials: + return WeComCredentials( + provider=self.provider, + corp_id=updated_value(self.corp_id, current.corp_id), + agent_id=updated_value(self.agent_id, current.agent_id), + secret=updated_value(self.secret, current.secret), + ) + + +type IMCredentialsUpdate = ( + FeishuCredentialsUpdate + | LarkCredentialsUpdate + | SlackCredentialsUpdate + | DingTalkCredentialsUpdate + | MSTeamsCredentialsUpdate + | WeComCredentialsUpdate +) + + +def merge_im_credentials(current: IMProviderCredentials, edit: IMCredentialsUpdate) -> IMProviderCredentials: + match current, edit: + case FeishuCredentials(), FeishuCredentialsUpdate(): + return edit.apply(current) + case LarkCredentials(), LarkCredentialsUpdate(): + return edit.apply(current) + case SlackCredentials(), SlackCredentialsUpdate(): + return edit.apply(current) + case DingTalkCredentials(), DingTalkCredentialsUpdate(): + return edit.apply(current) + case MSTeamsCredentials(), MSTeamsCredentialsUpdate(): + return edit.apply(current) + case WeComCredentials(), WeComCredentialsUpdate(): + return edit.apply(current) + raise ReplacementRequiredError("IM provider replacement is required") + + +@dataclass(frozen=True, slots=True) +class ResendCredentialsUpdate: + api_key: str | Unchanged = field(repr=False) + + +@dataclass(frozen=True, slots=True) +class ResendChannelUpdate: + sender_email: NormalizedEmail | Unchanged + sender_name: str | Unchanged + credentials: ResendCredentialsUpdate = field(repr=False) diff --git a/api/services/human_input_v2/delivery_service.py b/api/services/human_input_v2/delivery_service.py new file mode 100644 index 00000000000..162796e22ef --- /dev/null +++ b/api/services/human_input_v2/delivery_service.py @@ -0,0 +1,441 @@ +"""Determine and send initial notifications for one committed recipient. + +The runtime invokes this after the form service commits first initialization, +and waits for the recorded result before deciding whether delivery succeeded. +""" + +from __future__ import annotations + +import hashlib +import logging +import secrets +from collections.abc import Callable +from dataclasses import dataclass, field +from smtplib import SMTPException +from typing import assert_never +from urllib.parse import quote + +import httpx +import sqlalchemy as sa +from pydantic import SecretStr, ValidationError +from sqlalchemy.orm import Session + +from configs import dify_config +from core.helper import encrypter +from core.human_input_v2.entities import DebugChannel +from core.human_input_v2.im_integration.adapters import factory as im_adapter_factory_module +from core.human_input_v2.im_integration.adapters.entities import ( + CorrelationToken, + DynamicCardMessagingError, + MessageAccepted, + MessageSendingError, + ProviderUserId, +) +from core.human_input_v2.im_integration.adapters.message_locator import MessageLocator +from core.human_input_v2.im_integration.adapters.protocols import IMProviderAdapter +from core.human_input_v2.shared.values import IMBindingId +from core.workflow.human_input_adapter import EmailDeliveryConfig +from core.workflow.nodes.human_input_v2.entities import Initiator +from extensions.ext_key_provider import key_provider_manager +from models.model import EndUser +from repositories.human_input_v2.contact import ContactType +from repositories.human_input_v2.delivery_attempt_repository import ( + DeliveryAttempt, + DeliveryAttemptCreateParams, + DeliveryStatus, +) +from repositories.human_input_v2.delivery_repository import ( + Delivery, + DeliveryCreateParams, + EmailTargetSnapshot, + IMMessageType, + IMUserTargetSnapshot, + InitiatorSnapshot, + SubmissionAuthType, + TargetSnapshot, +) +from repositories.human_input_v2.email_channel.ports import EmailProviderOperationError, EmailProviderValidationError +from repositories.human_input_v2.form_repository import Form +from repositories.human_input_v2.im_channel_repository import IMChannel, IMChannelStatus +from repositories.human_input_v2.recipient_repository import ( + ContactRecipientSubject, + EmailRecipientSubject, + EndUserRecipientSubject, + Recipient, +) +from repositories.human_input_v2.sqlalchemy_contact_repository import SQLAlchemyContactRepository +from repositories.human_input_v2.sqlalchemy_delivery_attempt_repository import SQLAlchemyDeliveryAttemptRepository +from repositories.human_input_v2.sqlalchemy_delivery_repository import SQLAlchemyDeliveryRepository +from repositories.human_input_v2.sqlalchemy_im_binding_repository import SQLAlchemyIMBindingRepository +from repositories.human_input_v2.sqlalchemy_im_channel_repository import ( + DeploymentIMChannelReader, + WorkspaceIMChannelReader, +) +from repositories.human_input_v2.sqlalchemy_im_identity_repository import SQLAlchemyIMIdentityRepository +from repositories.human_input_v2.sqlalchemy_recipient_repository import SQLAlchemyRecipientRepository +from services.feature_service import FeatureService +from services.human_input_v2.email_client import EmailClient +from services.human_input_v2.im_credential_codec import IMCredentialCodec, IMCredentialError +from services.human_input_v2.im_tenant_credential_cipher import TenantBoundCredentialCipher + +logger = logging.getLogger(__name__) +_CONSOLE_FORM_PAGE_PATH = "/human-input" +_PUBLIC_V2_FORM_PAGE_PATH = "/form-v2" +_LEGACY_FORM_PAGE_PATH = "/form" + + +@dataclass(frozen=True, slots=True) +class ResolvedMessageTemplate: + """Notification text resolved for the initial delivery round. + + The body retains the single Request URL slot for the delivery-specific URL. + Actual approval content and actions come from Form.resolved_form instead. + """ + + subject: str = field(repr=False) + body: str = field(repr=False) + + +@dataclass(frozen=True, slots=True) +class RecipientDeliveryResult: + """Results for one recipient, including its usable initiator entry if any. + + attempts contains only actual sends; skipped channels do not fabricate + successful attempts. form_token is present only for a usable Current + Initiator interaction surface, never for an Email or IM notification. + """ + + attempts: tuple[DeliveryAttempt, ...] + form_token: SecretStr | None = field(repr=False) + + +@dataclass(frozen=True, slots=True) +class _InitiatorTarget: + pass + + +@dataclass(frozen=True, slots=True) +class _EmailTarget: + email: str + + +@dataclass(frozen=True, slots=True) +class _IMTarget: + channel: IMChannel + binding_id: IMBindingId + user_id: ProviderUserId + + +type _DeliveryTarget = _InitiatorTarget | _EmailTarget | _IMTarget + + +@dataclass(frozen=True, slots=True) +class _RecipientContext: + form: Form + recipient: Recipient + auth_type: SubmissionAuthType + targets: tuple[_DeliveryTarget, ...] + + +class HumanInputDeliveryService: + """Determine and send deliveries for one persisted recipient. + + Accept only detached, committed inputs. Provider I/O runs without an open + database transaction; creating deliveries and recording attempts use this + service's own short transactions. + No caller-owned Session or transaction participates in this operation. + """ + + def __init__( + self, + *, + session_factory: Callable[[], Session], + email_client: EmailClient | None, + im_adapter_factory: Callable[[IMChannel], IMProviderAdapter] | None = None, + ) -> None: + """Use a tenant-bound email client; None means no email channel is available. + + An EE owner may also supply its credential-bound IM adapter factory. + """ + self._session_factory = session_factory + self._email_client = email_client + self._im_adapter_factory = im_adapter_factory + + def deliver( + self, + *, + form: Form, + recipient: Recipient, + message_template: ResolvedMessageTemplate, + debug_channels: tuple[DebugChannel, ...] | None = None, + ) -> RecipientDeliveryResult: + """Commit chosen deliveries before sending, and record each actual result. + + During form initialization, recipient configurations that resolve to the + same subject produce one recipient within that form. Deliver to distinct + recipients separately, even when they share a delivery address. + SubmissionAuthType governs authentication; + message_type records the IM presentation chosen by capability assessment. + A provider error records failure; it never silently changes card to link. + Form and workflow state do not cancel delivery. Submission eligibility + and card state synchronization belong to their respective owners. + """ + context = self._load_context(form, recipient) + if context is None: + return RecipientDeliveryResult((), None) + initiator_form_token = None + if debug_channels is None and any(isinstance(target, _InitiatorTarget) for target in context.targets): + token = SecretStr(secrets.token_urlsafe(32)) + ciphertext = encrypter.encrypt_token(str(form.tenant_id), token.get_secret_value()) + self._prepare_delivery( + context, InitiatorSnapshot(protected_form_token=ciphertext), context.auth_type, token + ) + initiator_form_token = token + + attempts: list[DeliveryAttempt] = [] + for target in context.targets: + match target: + case _InitiatorTarget(): + continue + case _IMTarget(): + if debug_channels is not None and not any( + value.value == target.channel.provider.value for value in debug_channels + ): + continue + attempt = self._deliver_im(context, target, message_template) + case _EmailTarget(): + if debug_channels is not None and DebugChannel.EMAIL not in debug_channels: + continue + attempt = self._deliver_email(context, target, message_template) + case _: + assert_never(target) + if attempt is not None: + attempts.append(attempt) + if not attempts and initiator_form_token is None: + self._warn(context, "recipient_no_available_channel") + return RecipientDeliveryResult(tuple(attempts), initiator_form_token) + + def _load_context(self, form: Form, recipient: Recipient) -> _RecipientContext | None: + with self._session_factory() as session, session.begin(): + recipient_record = SQLAlchemyRecipientRepository(session, form.tenant_id, form.id).get_recipient( + recipient.id + ) + if recipient_record is None: + raise ValueError("Recipient does not belong to this form") + recipient = recipient_record + email = None + targets: list[_DeliveryTarget] = [] + auth_type = SubmissionAuthType.EMAIL_OTP + match recipient.subject: + case ContactRecipientSubject(contact_id): + contact = SQLAlchemyContactRepository(session).get_contacts_by_id(form.tenant_id, contact_id) + if contact is None: + logger.warning( + "Human Input contact unavailable: form_id=%s recipient_id=%s", form.id, recipient.id + ) + return None + email = contact.email + if contact.type != ContactType.EXTERNAL: + auth_type = SubmissionAuthType.CONSOLE + if any(isinstance(source, Initiator) for source in recipient.sources): + targets.append(_InitiatorTarget()) + # TODO(QuantumGhost): maybe the channel creation logic below should be unified? + channel = ( + DeploymentIMChannelReader(session).get() + if FeatureService.is_im_centrally_managed() + else WorkspaceIMChannelReader(session, form.tenant_id).get() + ) + if channel is not None and channel.status == IMChannelStatus.CONNECTED: + binding = SQLAlchemyIMBindingRepository(session, channel.id).get_effective( + form.tenant_id, contact.id + ) + identity = ( + SQLAlchemyIMIdentityRepository(session, channel.id).get(binding.identity_id) + if binding is not None + else None + ) + if binding is not None and identity is not None: + targets.append( + _IMTarget( + channel=channel, + binding_id=binding.id, + user_id=ProviderUserId(identity.provider_user_id), + ) + ) + case EmailRecipientSubject(address): + email = address.value + case EndUserRecipientSubject(end_user_id): + end_user = session.scalar( + sa.select(EndUser).where( + EndUser.id == end_user_id, + EndUser.tenant_id == form.tenant_id, + EndUser.app_id == form.app_id, + ) + ) + if end_user is None: + return None + if any(isinstance(source, Initiator) for source in recipient.sources): + targets.append(_InitiatorTarget()) + auth_type = SubmissionAuthType.WEB_APP + case _: + assert_never(recipient.subject) + if email: + targets.append(_EmailTarget(email)) + return _RecipientContext(form, recipient, auth_type, tuple(targets)) + + def _prepare_delivery( + self, + context: _RecipientContext, + target: TargetSnapshot, + auth_type: SubmissionAuthType, + token: SecretStr, + ) -> Delivery: + with self._session_factory() as session, session.begin(): + form = context.form + delivery = SQLAlchemyDeliveryRepository(session).create_delivery( + tenant_id=form.tenant_id, + form_id=form.id, + params=DeliveryCreateParams( + recipient_id=context.recipient.id, + token_hash=hashlib.sha256(token.get_secret_value().encode()).hexdigest(), + auth_type=auth_type, + target_snapshot=target, + ), + ) + return delivery + + def _record( + self, delivery: Delivery, outcome: DeliveryAttemptCreateParams, *, message_locator: MessageLocator | None + ) -> DeliveryAttempt: + with self._session_factory() as session, session.begin(): + if message_locator is not None: + updated = SQLAlchemyDeliveryRepository(session).set_im_message_locator( + tenant_id=delivery.tenant_id, + form_id=delivery.form_id, + delivery_id=delivery.id, + message_locator=message_locator, + ) + if updated is None: + raise ValueError("The original delivery is missing while recording its message identity") + # The accepted message identity and send outcome must commit together. + attempt = SQLAlchemyDeliveryAttemptRepository(session, delivery.tenant_id, delivery.form_id).record_attempt( + delivery.id, outcome + ) + if attempt is None: + raise ValueError("The original delivery is missing while recording its result") + return attempt + + def _deliver_im( + self, context: _RecipientContext, target: _IMTarget, template: ResolvedMessageTemplate + ) -> DeliveryAttempt | None: + channel = target.channel + user_id = target.user_id + try: + if self._im_adapter_factory is not None: + adapter = self._im_adapter_factory(channel) + else: + if FeatureService.is_im_centrally_managed(): + # TODO(QuantumGhost): Dify EE Adapter creation. + raise IMCredentialError("The deployment's credential-bound adapter factory is required") + cipher = TenantBoundCredentialCipher(key_provider_manager.provider, str(context.form.tenant_id)) + credentials = IMCredentialCodec(cipher).load(channel.provider, channel.encrypted_credentials) + adapter = im_adapter_factory_module.build_im_provider_adapter(credentials) + except IMCredentialError: + self._warn(context, "im_credentials_unavailable") + return None + try: + card = adapter.dynamic_card_messaging + message_type = ( + IMMessageType.CARD + if card is not None and card.assess(context.form.resolved_form).representable + else IMMessageType.LINK + ) + auth_type = SubmissionAuthType.IM if message_type == IMMessageType.CARD else context.auth_type + token = SecretStr(secrets.token_urlsafe(32)) + snapshot = IMUserTargetSnapshot( + im_provider=channel.provider, + im_tenant_id=channel.provider_tenant_id, + im_provider_user_id=user_id, + im_binding_id=target.binding_id, + message_type=message_type, + ) + delivery = self._prepare_delivery(context, snapshot, auth_type, token) + try: + if message_type == IMMessageType.CARD: + assert card is not None + result = card.send_card( + user_id, context.form.resolved_form, CorrelationToken(token.get_secret_value()) + ) + else: + body = template.body.replace( + EmailDeliveryConfig.URL_PLACEHOLDER, build_form_request_url(token, auth_type) + ) + result = adapter.messaging.send_text(user_id, body, subject=template.subject) + except DynamicCardMessagingError: + result = MessageSendingError("The provider could not send the assessed card") + message_locator = None + if isinstance(result, MessageAccepted): + message_locator = result.locator + outcome = DeliveryAttemptCreateParams( + DeliveryStatus.SUCCEEDED, None, {"accepted": True, "message_locator": str(result.locator)} + ) + else: + outcome = DeliveryAttemptCreateParams( + DeliveryStatus.FAILED, "IM provider did not confirm acceptance", {"accepted": False} + ) + return self._record(delivery, outcome, message_locator=message_locator) + finally: + adapter.close() + + def _deliver_email( + self, context: _RecipientContext, target: _EmailTarget, template: ResolvedMessageTemplate + ) -> DeliveryAttempt | None: + client = self._email_client + if client is None: + self._warn(context, "email_channel_unavailable") + return None + try: + snapshot = EmailTargetSnapshot(email_address=target.email) + except ValidationError: + self._warn(context, "invalid_email") + return None + token = SecretStr(secrets.token_urlsafe(32)) + delivery = self._prepare_delivery(context, snapshot, context.auth_type, token) + body = template.body.replace( + EmailDeliveryConfig.URL_PLACEHOLDER, build_form_request_url(token, context.auth_type) + ) + html = EmailDeliveryConfig.render_markdown_body(body) + try: + client.send(to=target.email, subject=template.subject, html=html) + except (EmailProviderOperationError, EmailProviderValidationError, SMTPException, OSError, httpx.RequestError): + outcome = DeliveryAttemptCreateParams( + DeliveryStatus.FAILED, "Email provider did not confirm acceptance", {"accepted": False} + ) + else: + outcome = DeliveryAttemptCreateParams(DeliveryStatus.SUCCEEDED, None, {"accepted": True}) + return self._record(delivery, outcome, message_locator=None) + + @staticmethod + def _warn(context: _RecipientContext, reason: str) -> None: + logger.warning( + "Human Input channel skipped: tenant_id=%s form_id=%s recipient_id=%s reason=%s", + context.form.tenant_id, + context.form.id, + context.recipient.id, + reason, + ) + + +def build_form_request_url(token: SecretStr, auth_type: SubmissionAuthType) -> str: + """Select the form entry point from its authentication contract.""" + if auth_type == SubmissionAuthType.CONSOLE: + base_url = dify_config.CONSOLE_WEB_URL + page_path = _CONSOLE_FORM_PAGE_PATH + required_setting = "CONSOLE_WEB_URL" + else: + base_url = dify_config.APP_WEB_URL + page_path = _PUBLIC_V2_FORM_PAGE_PATH if auth_type == SubmissionAuthType.EMAIL_OTP else _LEGACY_FORM_PAGE_PATH + required_setting = "APP_WEB_URL" + if not base_url: + raise ValueError(f"{required_setting} is required for Human Input approval links") + return f"{base_url.rstrip('/')}{page_path}/{quote(token.get_secret_value(), safe='')}" diff --git a/api/services/human_input_v2/email_channel_management_composition.py b/api/services/human_input_v2/email_channel_management_composition.py new file mode 100644 index 00000000000..37ecd3c40d5 --- /dev/null +++ b/api/services/human_input_v2/email_channel_management_composition.py @@ -0,0 +1,18 @@ +"""Production composition for Human Input Email Channel management.""" + +from sqlalchemy.orm import Session, sessionmaker + +from extensions.ext_database import db +from services.human_input_v2.email_channel_management_service import HumanInputEmailChannelManagementService +from services.human_input_v2.resend_channel import ResendProviderGateway + + +def build_human_input_email_channel_management_service() -> HumanInputEmailChannelManagementService: + operation_sessions = sessionmaker[Session](bind=db.engine, expire_on_commit=False) + return HumanInputEmailChannelManagementService( + ResendProviderGateway(), + session_factory=operation_sessions, + ) + + +__all__ = ["build_human_input_email_channel_management_service"] diff --git a/api/services/human_input_v2/email_channel_management_service.py b/api/services/human_input_v2/email_channel_management_service.py new file mode 100644 index 00000000000..3a32240655b --- /dev/null +++ b/api/services/human_input_v2/email_channel_management_service.py @@ -0,0 +1,244 @@ +"""Application owner for the complete workspace Email configuration lifecycle.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import replace +from typing import Protocol + +from pydantic import NaiveDatetime +from sqlalchemy.orm import Session + +from core.human_input_v2.entities import EmailProviderType +from core.human_input_v2.shared import AccountId, EmailProviderId, TenantId, WorkspaceScope +from libs.datetime_utils import naive_utc_now +from libs.uuid_utils import uuidv7 +from repositories.human_input_v2.email_channel import ( + CreateEmailConfigurationStatus, + DeleteEmailConfigurationStatus, + EmailChannelConfiguration, + EmailChannelRepository, + EmailChannelView, + EmailConfigurationSnapshot, + EmailProviderOperationError, + EmailProviderValidationError, + ResendCandidate, + UpdateEmailConfigurationStatus, +) +from repositories.human_input_v2.email_channel.entities import ResendCredentials +from repositories.human_input_v2.email_channel.repository import SQLAlchemyEmailChannelRepository +from services.human_input_v2.credential_update import ResendChannelUpdate, updated_value +from services.human_input_v2.email_credential_codec import load_email_credentials, seal_email_credentials +from services.human_input_v2.errors import ( + ChannelAlreadyConfiguredError, + ChannelNotFoundError, + ChannelProviderError, + ProviderConfigurationUpdatedError, + ProviderFailureKind, + UnexpectedChannelProviderError, +) + +_INVALID_CREDENTIALS_DESCRIPTION = "The submitted credentials are invalid." +_CONNECTION_FAILURE_DESCRIPTION = "The provider connection could not be established." + + +class _ResendProviderGateway(Protocol): + """Provider I/O required by the Resend management use cases.""" + + def send_test(self, candidate: ResendCandidate) -> None: + """Verify the candidate through exactly one test delivery.""" + ... + + +class HumanInputEmailChannelManagementService: + """Own Resend test delivery, protection, singleton persistence, and CAS.""" + + def __init__( + self, + provider_gateway: _ResendProviderGateway, + *, + session_factory: Callable[[], Session], + repository_factory: Callable[[Session], EmailChannelRepository] = SQLAlchemyEmailChannelRepository, + clock: Callable[[], NaiveDatetime] = naive_utc_now, + id_factory: Callable[[], str] = lambda: str(uuidv7()), + ) -> None: + self._repositories = repository_factory + self._sessions = session_factory + self._provider_gateway = provider_gateway + self._clock = clock + self._id_factory = id_factory + + def available_providers(self) -> tuple[EmailProviderType, ...]: + return (EmailProviderType.RESEND,) + + def get_current(self, scope: WorkspaceScope) -> EmailChannelView | None: + configuration = self._load_current(scope.id) + return self._view(configuration) if configuration is not None else None + + def get(self, scope: WorkspaceScope, channel_id: EmailProviderId) -> EmailChannelView: + configuration = self._load_addressed(scope, channel_id) + return self._view(configuration) + + def test( + self, + scope: WorkspaceScope, + candidate: ResendCandidate, + ) -> None: + del scope + self._send_test(candidate) + + def test_saved( + self, + scope: WorkspaceScope, + channel_id: EmailProviderId, + expected_revision: EmailConfigurationSnapshot, + credentials: ResendChannelUpdate, + ) -> None: + """Test the merged saved snapshot without persisting submitted edits.""" + + current = self._load_addressed(scope, channel_id) + self._ensure_current_revision(current, expected_revision) + self._send_test(self._merge_candidate(current, credentials)) + + def create( + self, + scope: WorkspaceScope, + actor_account_id: AccountId | None, + candidate: ResendCandidate, + ) -> EmailChannelView: + if self._load_current(scope.id) is not None: + raise ChannelAlreadyConfiguredError("email channel is already configured") + + encrypted_credentials = seal_email_credentials(scope.id, candidate.credentials) + now = self._clock() + configuration = EmailChannelConfiguration( + id=EmailProviderId(self._id_factory()), + tenant_id=scope.id, + sender_email=candidate.sender_email, + sender_name=candidate.sender_name, + encrypted_credentials=encrypted_credentials, + configured_by_account_id=actor_account_id, + created_at=now, + updated_at=now, + ) + with self._sessions() as session, session.begin(): + create_result = self._repositories(session).create(configuration) + if create_result.status is CreateEmailConfigurationStatus.CONFLICT or create_result.configuration is None: + raise ChannelAlreadyConfiguredError("email channel is already configured") + return self._view(create_result.configuration) + + def update( + self, + scope: WorkspaceScope, + channel_id: EmailProviderId, + expected_revision: EmailConfigurationSnapshot, + actor_account_id: AccountId | None, + candidate: ResendCandidate | ResendChannelUpdate, + ) -> EmailChannelView: + current = self._load_addressed(scope, channel_id) + self._ensure_current_revision(current, expected_revision) + if isinstance(candidate, ResendChannelUpdate): + candidate = self._merge_candidate(current, candidate) + replacement = replace( + current, + sender_email=candidate.sender_email, + sender_name=candidate.sender_name, + encrypted_credentials=seal_email_credentials(scope.id, candidate.credentials), + configured_by_account_id=actor_account_id, + ) + with self._sessions() as session, session.begin(): + update_result = self._repositories(session).update( + replacement, + expected=expected_revision, + now=self._clock(), + ) + if update_result.status is UpdateEmailConfigurationStatus.STALE or update_result.configuration is None: + raise ProviderConfigurationUpdatedError("email configuration was updated") + return self._view(update_result.configuration) + + @staticmethod + def _merge_candidate( + current: EmailChannelConfiguration, + credentials: ResendChannelUpdate, + ) -> ResendCandidate: + saved = load_email_credentials(current.tenant_id, current.encrypted_credentials) + return ResendCandidate( + sender_email=updated_value(credentials.sender_email, current.sender_email), + sender_name=updated_value(credentials.sender_name, current.sender_name), + credentials=ResendCredentials.model_construct( + api_key=updated_value(credentials.credentials.api_key, saved.api_key) + ), + ) + + def delete( + self, + scope: WorkspaceScope, + channel_id: EmailProviderId, + expected_revision: EmailConfigurationSnapshot, + ) -> EmailProviderId: + current = self._load_addressed(scope, channel_id) + self._ensure_current_revision(current, expected_revision) + with self._sessions() as session, session.begin(): + delete_result = self._repositories(session).delete(scope.id, expected=expected_revision) + if delete_result.status is not DeleteEmailConfigurationStatus.DELETED: + raise ProviderConfigurationUpdatedError("email configuration was updated") + return current.id + + def _send_test(self, candidate: ResendCandidate) -> None: + try: + self._provider_gateway.send_test(candidate) + except EmailProviderValidationError as error: + raise ChannelProviderError( + ProviderFailureKind.INVALID_CREDENTIALS, + _INVALID_CREDENTIALS_DESCRIPTION, + provider_error_code=error.provider_error_code, + http_status=error.http_status, + ) from None + except EmailProviderOperationError as error: + raise ChannelProviderError( + ProviderFailureKind.CONNECTION_FAILURE, + _CONNECTION_FAILURE_DESCRIPTION, + provider_error_code=error.provider_error_code, + http_status=error.http_status, + ) from None + except Exception: + # Provider exceptions can contain request bodies or credentials, so this + # boundary deliberately discards both the message and exception chain. + raise UnexpectedChannelProviderError() from None + + def _load_addressed( + self, + scope: WorkspaceScope, + channel_id: EmailProviderId, + ) -> EmailChannelConfiguration: + current = self._load_current(scope.id) + if current is None or current.id != channel_id: + raise ChannelNotFoundError("email channel was not found") + return current + + def _load_current(self, tenant_id: TenantId) -> EmailChannelConfiguration | None: + with self._sessions() as session: + return self._repositories(session).load(tenant_id) + + @staticmethod + def _ensure_current_revision( + current: EmailChannelConfiguration, + expected_revision: EmailConfigurationSnapshot, + ) -> None: + if expected_revision != current.snapshot: + raise ProviderConfigurationUpdatedError("email configuration was updated") + + @staticmethod + def _view(configuration: EmailChannelConfiguration) -> EmailChannelView: + return EmailChannelView( + id=configuration.id, + provider=configuration.provider, + created_at=configuration.created_at, + updated_at=configuration.updated_at, + sender_name=configuration.sender_name, + sender_email=str(configuration.sender_email), + revision=configuration.snapshot, + ) + + +__all__ = ["HumanInputEmailChannelManagementService"] diff --git a/api/services/human_input_v2/email_client.py b/api/services/human_input_v2/email_client.py new file mode 100644 index 00000000000..15d8bac1f7e --- /dev/null +++ b/api/services/human_input_v2/email_client.py @@ -0,0 +1,50 @@ +"""Compose a mail client shared by a tenant's Human Input delivery service.""" + +from collections.abc import Callable +from functools import cached_property +from typing import Protocol + +from sqlalchemy.orm import Session + +from core.human_input_v2.shared.values import TenantId +from extensions.ext_mail import mail +from repositories.human_input_v2.email_channel.entities import EmailChannelConfiguration, ResendCandidate +from repositories.human_input_v2.email_channel.repository import SQLAlchemyEmailChannelRepository +from services.feature_service import FeatureService +from services.human_input_v2.email_credential_codec import load_email_credentials +from services.human_input_v2.resend_channel import ResendProviderGateway + + +class EmailClient(Protocol): + def send(self, *, to: str, subject: str, html: str) -> None: ... + + +class _ResendEmailClient: + def __init__(self, configuration: EmailChannelConfiguration) -> None: + self._configuration = configuration + self._gateway = ResendProviderGateway() + + @cached_property + def _candidate(self) -> ResendCandidate: + # Resolve credentials once, outside the configuration read transaction, + # and only if this execution actually sends an email. + return ResendCandidate( + sender_email=self._configuration.sender_email, + sender_name=self._configuration.sender_name, + credentials=load_email_credentials( + self._configuration.tenant_id, self._configuration.encrypted_credentials + ), + ) + + def send(self, *, to: str, subject: str, html: str) -> None: + self._gateway.send_message(self._candidate, to=to, subject=subject, html=html) + + +def build_email_client(*, tenant_id: TenantId, session_factory: Callable[[], Session]) -> EmailClient | None: + if not FeatureService.use_tenant_scoped_email_config(): + return mail if mail.is_inited() else None + with session_factory() as session: + configuration = SQLAlchemyEmailChannelRepository(session).load(tenant_id) + if configuration is None: + return None + return _ResendEmailClient(configuration) diff --git a/api/services/human_input_v2/email_credential_codec.py b/api/services/human_input_v2/email_credential_codec.py new file mode 100644 index 00000000000..792bbbd0a3f --- /dev/null +++ b/api/services/human_input_v2/email_credential_codec.py @@ -0,0 +1,16 @@ +"""Protect Resend authentication credentials with the shared Channel envelope.""" + +from core.helper import encrypter +from core.human_input_v2.shared import TenantId +from repositories.human_input_v2.channel_credentials import ChannelEncryptedCredentials +from repositories.human_input_v2.email_channel.entities import ResendCredentials + + +def seal_email_credentials(tenant_id: TenantId, credentials: ResendCredentials) -> ChannelEncryptedCredentials: + ciphertext = encrypter.encrypt_token(str(tenant_id), credentials.model_dump_json()) + return ChannelEncryptedCredentials(ciphertext=ciphertext) + + +def load_email_credentials(tenant_id: TenantId, envelope: ChannelEncryptedCredentials) -> ResendCredentials: + plaintext = encrypter.decrypt_token(str(tenant_id), envelope.ciphertext) + return ResendCredentials.model_validate_json(plaintext) diff --git a/api/services/human_input_v2/email_otp_composition.py b/api/services/human_input_v2/email_otp_composition.py new file mode 100644 index 00000000000..d00b114f0cd --- /dev/null +++ b/api/services/human_input_v2/email_otp_composition.py @@ -0,0 +1,40 @@ +"""Deployment mail and shared Redis composition for public email proof.""" + +import logging +from smtplib import SMTPException + +from python_http_client.exceptions import HTTPError +from requests.exceptions import RequestException +from resend.exceptions import ResendError + +from core.db.session_factory import session_factory +from extensions.ext_mail import mail +from extensions.ext_redis import redis_client +from libs.helper import RateLimiter +from services.human_input_v2.email_otp_service import EmailOtpError, EmailOtpService, OtpFailure, OtpMail + +logger = logging.getLogger(__name__) +_OTP_TEMPLATE = """

Use this verification code to submit your approval form:

+

{code}

This code expires in 10 minutes.

""" + + +def _send(email: str, otp: str) -> None: + # Provider exceptions may contain secrets; expose only the OTP failure code. + try: + mail.send( + to=email, + subject="Verify your approval form submission", + html=_OTP_TEMPLATE.format(code=otp), + ) + except (SMTPException, OSError, HTTPError, ResendError, RequestException, ValueError): + logger.warning("Human Input OTP mail delivery failed") + raise EmailOtpError(OtpFailure.DELIVERY) from None + + +def build_email_otp_service() -> EmailOtpService: + return EmailOtpService( + session_factory=session_factory.create_session, + redis=redis_client, + limiter=RateLimiter(prefix="hitl:otp:sends", max_attempts=5, time_window=3600), + mail=OtpMail(available=mail.is_inited, send=_send), + ) diff --git a/api/services/human_input_v2/email_otp_service.py b/api/services/human_input_v2/email_otp_service.py new file mode 100644 index 00000000000..9a7f29c3b61 --- /dev/null +++ b/api/services/human_input_v2/email_otp_service.py @@ -0,0 +1,365 @@ +"""Issue and verify email proof; Submission owns accepting the form and success audit.""" + +import logging +import secrets +from collections.abc import Callable +from dataclasses import dataclass, field +from enum import StrEnum +from hashlib import sha256 +from typing import Annotated, Literal + +import sqlalchemy as sa +from pydantic import BaseModel, ConfigDict, Field, TypeAdapter, ValidationError +from redis.exceptions import RedisError +from sqlalchemy.orm import Session + +from core.human_input_v2.shared.values import TenantId +from core.workflow.nodes.human_input.enums import HumanInputFormStatus +from extensions.ext_redis import RedisClientWrapper +from extensions.redis_names import serialize_redis_name +from graphon.enums import WorkflowExecutionStatus +from libs.datetime_utils import naive_utc_now +from libs.helper import RateLimiter +from models.human_input_v2 import HumanInputAuditEvent, HumanInputForm, OtpVerificationEvent +from models.model import App +from models.workflow import WorkflowRun +from repositories.human_input_v2.contact import ContactType +from repositories.human_input_v2.delivery_repository import Delivery, EmailTargetSnapshot, SubmissionAuthType +from repositories.human_input_v2.recipient_repository import ContactRecipientSubject, EmailRecipientSubject +from repositories.human_input_v2.sqlalchemy_audit_repository import SQLAlchemyAuditRepository +from repositories.human_input_v2.sqlalchemy_contact_repository import SQLAlchemyContactRepository +from repositories.human_input_v2.sqlalchemy_delivery_repository import SQLAlchemyDeliveryRepository +from repositories.human_input_v2.sqlalchemy_recipient_repository import SQLAlchemyRecipientRepository + +logger = logging.getLogger(__name__) +_COOLDOWN_SECONDS = 60 + + +class EmailSubject(BaseModel): + model_config = ConfigDict(frozen=True) + + type: Literal["email"] = "email" + email: str + + +class ExternalContactSubject(BaseModel): + model_config = ConfigDict(frozen=True) + + type: Literal["external_contact"] = "external_contact" + contact_id: str + email: str + + +type EmailOtpSubject = Annotated[EmailSubject | ExternalContactSubject, Field(discriminator="type")] + + +class EmailChallenge(EmailSubject): + otp: str = Field(repr=False) + + +class ExternalContactChallenge(ExternalContactSubject): + otp: str = Field(repr=False) + + +type EmailOtpChallenge = Annotated[EmailChallenge | ExternalContactChallenge, Field(discriminator="type")] + + +class VerificationStatus(StrEnum): + VERIFIED = "verified" + INVALID = "invalid" + STALE = "stale" + EXPIRED = "expired" + + +_CHALLENGE_TTL_SECONDS = 600 +_MAX_FAILED_ATTEMPTS = 5 + +_CHALLENGE_ADAPTER = TypeAdapter(EmailOtpChallenge) +_COUNTER_ADAPTER = TypeAdapter(Annotated[int, Field(ge=0)]) + + +class OtpFailure(StrEnum): + NOT_FOUND = "human_input_form_not_found" + EXPIRED = "human_input_form_expired" + SUBMITTED = "human_input_form_submitted" + RATE_LIMIT = "human_input_access_rate_limit_exceeded" + DELIVERY = "human_input_access_delivery_failed" + UNAVAILABLE = "human_input_v2_unavailable" + INVALID = "human_input_invalid_otp" + STALE = "human_input_challenge_stale" + CHALLENGE_EXPIRED = "human_input_challenge_expired" + + +class EmailOtpError(Exception): + def __init__(self, reason: OtpFailure, retry_after: int | None = None) -> None: + super().__init__(reason.value) + self.reason = reason + self.retry_after = retry_after + + +class AccessChallenge(BaseModel): + challenge_token: str = Field(repr=False) + expires_in_seconds: int + resend_after_seconds: int + + +@dataclass(frozen=True) +class VerifiedEmail: + delivery: Delivery + subject: EmailOtpSubject + # The app owner resolved while verifying the delivery, never supplied by the client. + app_id: str + challenge_token: str = field(repr=False) + + def add_submission_audit(self, session: Session, *, ip_address: str | None) -> None: + """Call only after accepting FormSubmission, in that same caller-owned transaction.""" + _add_audit(session, self.delivery, self.subject.email, ip_address, succeeded=True, reason="submitted") + + +@dataclass(frozen=True) +class OtpMail: + available: Callable[[], bool] + send: Callable[[str, str], None] = field(repr=False) + + +def _add_audit( + session: Session, delivery: Delivery, email: str, ip_address: str | None, *, succeeded: bool, reason: str +) -> None: + session.add( + HumanInputAuditEvent( + tenant_id=delivery.tenant_id, + form_id=delivery.form_id, + payload=OtpVerificationEvent( + recipient_id=delivery.recipient_id, + recipient_email=email, + submitter_ip_address=ip_address, + succeeded=succeeded, + reason=reason, + ), + ) + ) + + +class EmailOtpService: + def __init__( + self, + *, + session_factory: Callable[[], Session], + redis: RedisClientWrapper, + limiter: RateLimiter, + mail: OtpMail, + ) -> None: + self._sessions = session_factory + self._redis = redis + self._limiter = limiter + self._mail = mail + + def request_access(self, form_token: str, *, ip_address: str | None = None) -> AccessChallenge: + delivery, subject, _ = self._authorize(form_token) + if not self._mail.available(): + raise EmailOtpError(OtpFailure.UNAVAILABLE) + try: + wait = self._limiter.acquire(delivery.id, cooldown_seconds=_COOLDOWN_SECONDS) + if wait: + raise EmailOtpError(OtpFailure.RATE_LIMIT, wait) + token = secrets.token_urlsafe(32) + otp = f"{secrets.randbelow(1_000_000):06d}" + if isinstance(subject, ExternalContactSubject): + challenge = ExternalContactChallenge(contact_id=subject.contact_id, email=subject.email, otp=otp) + else: + challenge = EmailChallenge(email=subject.email, otp=otp) + self._create_challenge(delivery.id, token, challenge) + except RedisError: + raise EmailOtpError(OtpFailure.UNAVAILABLE) from None + # Authorization transaction is already closed before external I/O. + try: + self._mail.send(subject.email, otp) + except EmailOtpError: + self.cleanup(delivery.id, token) + raise EmailOtpError(OtpFailure.DELIVERY, self._retry_after(delivery.id)) from None + # Sending is an external side effect. Commit its audit even when the + # challenge expires or Redis becomes unavailable before the response. + with self._sessions() as session, session.begin(): + SQLAlchemyAuditRepository(session, delivery.tenant_id, delivery.form_id).record_otp_send( + recipient_id=delivery.recipient_id, + recipient_email=subject.email, + sender_ip_address=ip_address, + ) + try: + remaining = self._challenge_remaining_seconds(delivery.id, token) + if remaining <= 0: + raise EmailOtpError(OtpFailure.CHALLENGE_EXPIRED) + return AccessChallenge( + challenge_token=token, + expires_in_seconds=remaining, + resend_after_seconds=self._retry_after(delivery.id), + ) + except RedisError: + raise EmailOtpError(OtpFailure.UNAVAILABLE) from None + + def verify( + self, form_token: str, *, challenge_token: str | None, otp_code: str | None, ip_address: str | None + ) -> VerifiedEmail: + delivery, subject, app_id = self._authorize(form_token, submitting=True, ip_address=ip_address) + reason = None + if not challenge_token or not otp_code: + reason = OtpFailure.INVALID + else: + try: + status = self._verify_challenge(delivery.id, challenge_token, subject, otp_code) + except (RedisError, ValidationError): + reason = OtpFailure.UNAVAILABLE + else: + match status: + case VerificationStatus.VERIFIED: + return VerifiedEmail(delivery, subject, app_id, challenge_token) + case VerificationStatus.INVALID: + reason = OtpFailure.INVALID + case VerificationStatus.STALE: + reason = OtpFailure.STALE + case VerificationStatus.EXPIRED: + reason = OtpFailure.CHALLENGE_EXPIRED + assert reason is not None + with self._sessions() as session, session.begin(): + _add_audit(session, delivery, subject.email, ip_address, succeeded=False, reason=reason.value) + raise EmailOtpError(reason) + + def cleanup(self, delivery_id: str, challenge_token: str) -> None: + """Best effort after commit or a failed send; never affects another challenge.""" + try: + key = self._challenge_key(delivery_id, challenge_token) + self._redis.delete(key, self._counter_key(key)) + except RedisError: + # Redis errors can embed command arguments; do not log the exception. + logger.warning("OTP cleanup failed: delivery_id=%s", delivery_id) + + @staticmethod + def _challenge_key(delivery_id: str, token: str) -> str: + # Keep the payload and counter in the same Redis Cluster slot. + return "hitl:otp:challenge:{" + delivery_id + ":" + token + "}" + + @staticmethod + def _counter_key(challenge_key: str) -> str: + return f"{challenge_key}:failed_attempts" + + def _create_challenge(self, delivery_id: str, token: str, challenge: EmailOtpChallenge) -> None: + # Publish the JSON and zero counter with their TTLs in one transaction. + # Raw pipelines require physical names, unlike RedisClientWrapper commands. + key = self._challenge_key(delivery_id, token) + with self._redis.pipeline(transaction=True) as pipeline: + pipeline.set(serialize_redis_name(key), challenge.model_dump_json(), ex=_CHALLENGE_TTL_SECONDS) + pipeline.set(serialize_redis_name(self._counter_key(key)), 0, ex=_CHALLENGE_TTL_SECONDS) + pipeline.execute() + + def _verify_challenge(self, delivery_id: str, token: str, subject: EmailOtpSubject, otp: str) -> VerificationStatus: + key = self._challenge_key(delivery_id, token) + raw = self._redis.get(key) + if raw is None: + return VerificationStatus.EXPIRED + counter_key = self._counter_key(key) + raw_count = self._redis.get(counter_key) + if raw_count is None or _COUNTER_ADAPTER.validate_json(raw_count) >= _MAX_FAILED_ATTEMPTS: + return VerificationStatus.EXPIRED + challenge = _CHALLENGE_ADAPTER.validate_json(raw) + if challenge.type != subject.type or challenge.email != subject.email: + return VerificationStatus.STALE + if isinstance(challenge, ExternalContactChallenge) and ( + not isinstance(subject, ExternalContactSubject) or challenge.contact_id != subject.contact_id + ): + return VerificationStatus.STALE + # A matching code is valid at these reads, even if another request later + # removes the challenge. Counter exhaustion also blocks pending cleanup. + if challenge.otp == otp: + return VerificationStatus.VERIFIED + failures = self._redis.incr_if_exists(counter_key) + if failures is None: + return VerificationStatus.EXPIRED + if failures >= _MAX_FAILED_ATTEMPTS: + self._redis.delete(key, counter_key) + return VerificationStatus.STALE if failures == _MAX_FAILED_ATTEMPTS else VerificationStatus.EXPIRED + return VerificationStatus.INVALID + + def _challenge_remaining_seconds(self, delivery_id: str, token: str) -> int: + remaining = self._redis.ttl(self._challenge_key(delivery_id, token)) + if not isinstance(remaining, int): + raise ValueError("Invalid Redis TTL response") + return max(0, remaining) + + def _retry_after(self, delivery_id: str) -> int: + try: + return self._limiter.retry_after(delivery_id, cooldown_seconds=_COOLDOWN_SECONDS) + except RedisError: + raise EmailOtpError(OtpFailure.UNAVAILABLE) from None + + def _authorize( + self, form_token: str, *, submitting: bool = False, ip_address: str | None = None + ) -> tuple[Delivery, EmailOtpSubject, str]: + """Return the delivery, email subject, and resolved app ID.""" + with self._sessions() as session: + delivery = SQLAlchemyDeliveryRepository(session).get_delivery_by_token_hash( + sha256(form_token.encode()).hexdigest() + ) + if delivery is None or delivery.auth_type != SubmissionAuthType.EMAIL_OTP: + raise EmailOtpError(OtpFailure.NOT_FOUND) + if not isinstance(delivery.target_snapshot, EmailTargetSnapshot): + raise EmailOtpError(OtpFailure.NOT_FOUND) + form = session.scalar( + sa.select(HumanInputForm) + .join(App, App.id == HumanInputForm.app_id) + .where( + HumanInputForm.id == delivery.form_id, + HumanInputForm.tenant_id == delivery.tenant_id, + App.tenant_id == delivery.tenant_id, + ) + ) + if form is None: + raise EmailOtpError(OtpFailure.NOT_FOUND) + if form.status == HumanInputFormStatus.SUBMITTED: + raise EmailOtpError(OtpFailure.SUBMITTED) + now = naive_utc_now() + if ( + form.status != HumanInputFormStatus.WAITING + or form.expiration_time <= now + or form.global_timeout_deadline <= now + ): + raise EmailOtpError(OtpFailure.EXPIRED) + if form.workflow_run_id is not None: + run = session.scalar( + sa.select(WorkflowRun).where( + WorkflowRun.id == form.workflow_run_id, + WorkflowRun.tenant_id == delivery.tenant_id, + WorkflowRun.app_id == form.app_id, + ) + ) + if run is None or run.status not in (WorkflowExecutionStatus.RUNNING, WorkflowExecutionStatus.PAUSED): + raise EmailOtpError(OtpFailure.EXPIRED) + recipient = SQLAlchemyRecipientRepository(session, TenantId(delivery.tenant_id), form.id).get_recipient( + delivery.recipient_id + ) + if recipient is None: + raise EmailOtpError(OtpFailure.NOT_FOUND) + address = delivery.target_snapshot.email_address + match recipient.subject: + case EmailRecipientSubject(email): + if email.value != address: + if submitting: + _add_audit( + session, delivery, address, ip_address, succeeded=False, reason=OtpFailure.STALE.value + ) + session.commit() + raise EmailOtpError(OtpFailure.STALE if submitting else OtpFailure.NOT_FOUND) + return delivery, EmailSubject(email=address), form.app_id + case ContactRecipientSubject(contact_id): + contact = SQLAlchemyContactRepository(session).get_contacts_by_id(delivery.tenant_id, contact_id) + if contact is None or contact.type != ContactType.EXTERNAL: + raise EmailOtpError(OtpFailure.NOT_FOUND) + if contact.email != address: + if submitting: + _add_audit( + session, delivery, address, ip_address, succeeded=False, reason=OtpFailure.STALE.value + ) + session.commit() + raise EmailOtpError(OtpFailure.STALE if submitting else OtpFailure.NOT_FOUND) + return delivery, ExternalContactSubject(contact_id=contact_id, email=address), form.app_id + case _: + raise EmailOtpError(OtpFailure.NOT_FOUND) diff --git a/api/services/human_input_v2/errors.py b/api/services/human_input_v2/errors.py new file mode 100644 index 00000000000..5ae50e8a630 --- /dev/null +++ b/api/services/human_input_v2/errors.py @@ -0,0 +1,72 @@ +"""Application errors raised by Human Input v2 Channel Management services.""" + +from enum import StrEnum + + +class ProviderFailureKind(StrEnum): + INVALID_CREDENTIALS = "invalid_credentials" + CONNECTION_FAILURE = "connection_failure" + + +class ChannelManagementError(Exception): + """Base class for safe application errors translated by controllers.""" + + +class ChannelAlreadyConfiguredError(ChannelManagementError): + """The owner scope already contains the current singleton resource.""" + + +class ChannelNotFoundError(ChannelManagementError): + """The addressed kind, identity, or trusted owner scope does not match.""" + + +class ProviderConfigurationUpdatedError(ChannelManagementError): + """The submitted opaque version no longer names the current resource revision.""" + + +class ReplacementRequiredError(ChannelManagementError): + """The requested IM update changes provider installation identity.""" + + +class ChannelProviderError(ChannelManagementError): + """Expected provider failure with credential-free diagnostics for the user.""" + + def __init__( + self, + kind: ProviderFailureKind, + status_description: str, + *, + provider_error_code: str | None = None, + missing_permissions: tuple[str, ...] = (), + http_status: int | None = None, + ) -> None: + if http_status is not None: + status_description += f" HTTP status: {http_status}." + if provider_error_code is not None: + status_description += f" API error code: {provider_error_code}." + if missing_permissions: + status_description += f" Missing permissions: {', '.join(missing_permissions)}." + super().__init__(status_description) + self.kind = kind + self.status_description = status_description + self.http_status = http_status + self.provider_error_code = provider_error_code + + +class UnexpectedChannelProviderError(ChannelManagementError): + """Detail-free boundary for unclassified provider or credential failures.""" + + def __init__(self) -> None: + super().__init__("channel provider operation failed") + + +__all__ = [ + "ChannelAlreadyConfiguredError", + "ChannelManagementError", + "ChannelNotFoundError", + "ChannelProviderError", + "ProviderConfigurationUpdatedError", + "ProviderFailureKind", + "ReplacementRequiredError", + "UnexpectedChannelProviderError", +] diff --git a/api/services/human_input_v2/file_upload_service.py b/api/services/human_input_v2/file_upload_service.py new file mode 100644 index 00000000000..0492cc197d3 --- /dev/null +++ b/api/services/human_input_v2/file_upload_service.py @@ -0,0 +1,136 @@ +"""Issue version-specific upload grants and retain the workflow initiator's file ownership. + +Grants only authorize uploads, not approvals. Recheck the delivery's current +recipient authorization and both form deadlines for every upload. Storage and +remote fetching take place after closing all authorization/owner read sessions. +""" + +from dataclasses import dataclass +from typing import Literal + +import jwt +from pydantic import BaseModel, ConfigDict, ValidationError +from sqlalchemy.orm import Session, sessionmaker + +from configs import dify_config +from core.workflow.nodes.human_input.enums import HumanInputFormKind +from graphon.file import helpers as file_helpers +from libs.datetime_utils import to_utc_timestamp +from repositories.factory import DifyAPIRepositoryFactory +from repositories.human_input_v2.form_repository import Form +from repositories.human_input_v2.sqlalchemy_file_upload_repository import SQLAlchemyFileUploadRepository +from services.file_service import FileService, FileUploadActor +from services.human_input_file_upload_service import HumanInputUploadToken, InvalidUploadTokenError +from services.human_input_v2.submission_service import HumanInputSubmissionService +from services.remote_file_service import RemoteFileService, RemoteFileUploadResult + +V2_UPLOAD_TOKEN_PREFIX = "hitl_v2_upload_" +_UPLOAD_AUDIENCE = "human-input-v2-file-upload" + + +class _PublicGrant(BaseModel): + model_config = ConfigDict(strict=True, frozen=True) + surface: Literal["public"] = "public" + form_token: str + + +@dataclass(frozen=True, slots=True) +class HumanInputV2UploadContext: + form: Form + owner: FileUploadActor + + +class HumanInputV2FileUploadService: + def __init__( + self, *, session_factory: sessionmaker[Session], files: FileService, remote_files: RemoteFileService + ) -> None: + self._sessions = session_factory + self._forms = HumanInputSubmissionService(session_factory=session_factory) + self._files = files + self._remote_files = remote_files + + def issue_public_upload_token(self, form_token: str) -> HumanInputUploadToken: + grant = _PublicGrant(form_token=form_token) + form = self._forms.authorize_public_form(form_token=form_token) + self._resolve_owner(form) + expires_at = min(form.expiration_time, form.global_timeout_deadline) + claims = {**grant.model_dump(), "aud": _UPLOAD_AUDIENCE, "exp": to_utc_timestamp(expires_at)} + token = jwt.encode(claims, dify_config.SECRET_KEY, algorithm="HS256") + return HumanInputUploadToken(upload_token=V2_UPLOAD_TOKEN_PREFIX + token, expires_at=expires_at) + + def validate_upload_token(self, upload_token: str) -> HumanInputV2UploadContext: + if not upload_token.startswith(V2_UPLOAD_TOKEN_PREFIX): + raise InvalidUploadTokenError() + try: + claims = jwt.decode( + upload_token.removeprefix(V2_UPLOAD_TOKEN_PREFIX), + dify_config.SECRET_KEY, + algorithms=["HS256"], + audience=_UPLOAD_AUDIENCE, + options={"require": ["exp", "aud"]}, + ) + grant = _PublicGrant.model_validate(claims) + except (jwt.PyJWTError, ValidationError) as exc: + raise InvalidUploadTokenError() from exc + form = self._forms.authorize_public_form(form_token=grant.form_token) + return HumanInputV2UploadContext(form=form, owner=self._resolve_owner(form)) + + def upload_local_file( + self, *, context: HumanInputV2UploadContext, filename: str, content: bytes, mimetype: str + ) -> RemoteFileUploadResult: + uploaded = self._files.upload_file( + filename=filename, + content=content, + mimetype=mimetype, + user=context.owner, + tenant_id=str(context.form.tenant_id), + ) + return RemoteFileUploadResult( + id=uploaded.id, + name=uploaded.name, + size=uploaded.size, + extension=uploaded.extension, + url=file_helpers.get_signed_file_url(upload_file_id=uploaded.id), + mime_type=uploaded.mime_type, + created_by=uploaded.created_by, + created_at=uploaded.created_at, + ) + + def upload_remote_file(self, *, context: HumanInputV2UploadContext, url: str) -> RemoteFileUploadResult: + return self._remote_files.upload_from_url(url=url, user=context.owner, tenant_id=str(context.form.tenant_id)) + + def _resolve_owner(self, form: Form) -> FileUploadActor: + if form.workflow_run_id is not None: + runs = DifyAPIRepositoryFactory.create_api_workflow_run_repository(self._sessions) + run = runs.get_workflow_run_by_id( + tenant_id=str(form.tenant_id), app_id=form.app_id, run_id=form.workflow_run_id + ) + if run is None: + raise InvalidUploadTokenError() + with self._sessions() as session: + owner = SQLAlchemyFileUploadRepository(session).get_owner( + tenant_id=str(form.tenant_id), + app_id=form.app_id, + owner_id=run.created_by, + owner_role=run.created_by_role, + ) + elif form.form_kind == HumanInputFormKind.DELIVERY_TEST: + with self._sessions() as session: + owner = SQLAlchemyFileUploadRepository(session).get_delivery_test_owner( + tenant_id=str(form.tenant_id), app_id=form.app_id + ) + else: + raise InvalidUploadTokenError() + if owner is None: + raise InvalidUploadTokenError() + return FileUploadActor(id=owner.id, creator_role=owner.creator_role) + + +def build_file_upload_service() -> HumanInputV2FileUploadService: + from core.db.session_factory import session_factory + from extensions.ext_application_services import application_services + + services = application_services() + return HumanInputV2FileUploadService( + session_factory=session_factory.get_session_maker(), files=services.files, remote_files=services.remote_files + ) diff --git a/api/services/human_input_v2/form_service.py b/api/services/human_input_v2/form_service.py new file mode 100644 index 00000000000..8d8154dafc9 --- /dev/null +++ b/api/services/human_input_v2/form_service.py @@ -0,0 +1,556 @@ +"""Initialize approvals and handle node timeout and global expiration. + +Form creation and recipient resolution share a transaction. Node timeout +requests resume after committing; global expiration stops execution without +resuming it. Recipient notifications belong to the delivery service. +""" + +from __future__ import annotations + +import hashlib +import hmac +import logging +import secrets +from collections.abc import Callable, Sequence +from dataclasses import dataclass, replace +from datetime import timedelta +from typing import NamedTuple, assert_never + +from pydantic import EmailStr, NaiveDatetime, SecretStr, TypeAdapter, ValidationError +from sqlalchemy.orm import Session, sessionmaker + +from configs import dify_config +from core.db.session_factory import session_factory as default_session_factory +from core.helper import encrypter +from core.human_input_v2.entities import DebugChannel +from core.human_input_v2.shared.values import AccountId, ContactId, NormalizedEmail, TenantId +from core.workflow.human_input_adapter import EmailDeliveryConfig +from core.workflow.nodes.human_input.enums import HumanInputFormKind, HumanInputFormStatus, TimeoutUnit +from core.workflow.nodes.human_input_v2.entities import ( + AllWorkspaceContacts, + Contact, + DynamicEmail, + HumanInputNodeData, + Initiator, + OnetimeEmail, + RecipientConfig, +) +from core.workflow.nodes.human_input_v2.form_compiler import MissingResolvedInputError, compile_resolved_form +from core.workflow.nodes.human_input_v2.node import HumanInputNode +from core.workflow.nodes.human_input_v2.runtime import HumanInputDeliveryError, PreparedForm +from graphon.enums import WorkflowExecutionStatus +from graphon.runtime import VariablePool +from graphon.runtime.runtime_state_protocol import ReadOnlyVariablePool +from graphon.variable_loader import VariableLoader, load_into_variable_pool +from graphon.variables.segments import StringSegment +from graphon.variables.template_resolution import convert_template +from libs.datetime_utils import naive_utc_now +from models.workflow import WorkflowRun +from repositories.api_workflow_run_repository import APIWorkflowRunRepository +from repositories.factory import DifyAPIRepositoryFactory +from repositories.human_input_v2.contact import Contact as ResolvedContact +from repositories.human_input_v2.contact import ContactQuery, ContactType +from repositories.human_input_v2.delivery_repository import ( + DeliveryCreateParams, + InitiatorSnapshot, + SubmissionAuthType, + TargetSnapshot, +) +from repositories.human_input_v2.form_repository import Form, FormCreateParams +from repositories.human_input_v2.recipient_repository import ( + ContactRecipientSubject, + EmailRecipientSubject, + EndUserRecipientSubject, + Recipient, + RecipientCreateParams, + RecipientSubject, +) +from repositories.human_input_v2.sqlalchemy_contact_repository import SQLAlchemyContactRepository +from repositories.human_input_v2.sqlalchemy_delivery_repository import SQLAlchemyDeliveryRepository +from repositories.human_input_v2.sqlalchemy_form_repository import SQLAlchemyFormRepository +from repositories.human_input_v2.sqlalchemy_recipient_repository import SQLAlchemyRecipientRepository + +from .delivery_service import ResolvedMessageTemplate, build_form_request_url + +logger = logging.getLogger(__name__) +_EMAIL = TypeAdapter(EmailStr) +_CONTACT_PAGE_SIZE = 100 + + +class HumanInputTestFormError(Exception): + """The editor's draft cannot produce a usable delivery-test form.""" + + +class HumanInputDeliveryTestFormService: + """Create frozen editor-only test forms independently of workflow execution. + + Own draft variable preparation, deadlines, and the atomic form/recipient/access + transaction. A notification channel supplies its target snapshot; it does not + create the form or choose its authentication contract. + """ + + def __init__( + self, + *, + session_factory: Callable[[], Session], + tenant_id: TenantId, + account_id: AccountId, + variable_loader_factory: Callable[[str], VariableLoader], + ) -> None: + self._sessions = session_factory + self._tenant_id = tenant_id + self._account_id = account_id + self._variable_loader_factory = variable_loader_factory + + def create_form( + self, + *, + app_id: str, + node_id: str, + node_data: HumanInputNodeData, + variable_pool: VariablePool, + target: TargetSnapshot, + ) -> str: + """Commit a test form and return its Console-authenticated request URL. + + Ignore configured recipients: only the authenticated editor can access + this form. The returned link is usable before notification I/O starts. + """ + # The message test has already populated its manual values. Resolve the + # remaining dependencies from the draft, excluding configured recipients. + test_node_data = node_data.model_copy(update={"recipients_spec": []}) + variable_mapping = HumanInputNode.extract_variable_selector_to_variable_mapping( + graph_config={}, config={"id": node_id, "data": test_node_data} + ) + load_into_variable_pool( + variable_loader=self._variable_loader_factory(app_id), + variable_pool=variable_pool, + variable_mapping=variable_mapping, + user_inputs={}, + ) + for selector in variable_mapping.values(): + if variable_pool.get(selector) is None: + raise HumanInputTestFormError(f"The form variable {'.'.join(selector)} is unavailable.") + try: + resolved_form = compile_resolved_form(node_data, variable_pool) + except (MissingResolvedInputError, TypeError) as exc: + raise HumanInputTestFormError("A valid Human Input v2 form is required.") from exc + token = SecretStr(secrets.token_urlsafe(32)) + try: + request_url = build_form_request_url(token, SubmissionAuthType.CONSOLE) + except ValueError as exc: + raise HumanInputTestFormError(str(exc)) from exc + duration = ( + timedelta(hours=node_data.timeout) + if node_data.timeout_unit == TimeoutUnit.HOUR + else timedelta(days=node_data.timeout) + ) + with self._sessions() as session, session.begin(): + contact = SQLAlchemyContactRepository(session).get_contact_by_account_id(self._tenant_id, self._account_id) + if contact is None: + raise HumanInputTestFormError("The current editor has no available Contact in this workspace.") + now = naive_utc_now() + form = SQLAlchemyFormRepository(session, self._tenant_id, app_id).create_form( + FormCreateParams( + workflow_run_id=None, + node_execution_id=None, + form_kind=HumanInputFormKind.DELIVERY_TEST, + resolved_form=resolved_form, + expiration_time=now + duration, + global_timeout_deadline=now + timedelta(seconds=dify_config.HUMAN_INPUT_GLOBAL_TIMEOUT_SECONDS), + ) + ) + (recipient,) = SQLAlchemyRecipientRepository(session, self._tenant_id, form.id).create_recipients( + [ + RecipientCreateParams( + ContactRecipientSubject(contact.id), (Contact(contact_id=contact.id),), contact.name + ) + ] + ) + SQLAlchemyDeliveryRepository(session).create_delivery( + tenant_id=self._tenant_id, + form_id=form.id, + params=DeliveryCreateParams( + recipient_id=recipient.id, + token_hash=hashlib.sha256(token.get_secret_value().encode()).hexdigest(), + auth_type=SubmissionAuthType.CONSOLE, + target_snapshot=target, + ), + ) + return request_url + + +@dataclass(frozen=True, slots=True) +class CreatedForm: + """Committed first initialization whose delivery round belongs to this caller. + + Only the caller that initialized the form receives this result, including + when there are no external notifications. Other callers receive PreparedForm. + The result carries detached values, never an open Session or transaction. + """ + + form: Form + recipients: tuple[Recipient, ...] + message_template: ResolvedMessageTemplate + debug_channels: tuple[DebugChannel, ...] | None = None + + +@dataclass(frozen=True, slots=True) +class AccountInitiator: + """Account authenticated by the invocation boundary, not an email match.""" + + account_id: AccountId + + +@dataclass(frozen=True, slots=True) +class EndUserInitiator: + """Original app session verified by the invocation boundary. + + App ownership and the caller's right to use this session are already checked. + A service API's arbitrary user string or API key alone cannot establish this. + """ + + end_user_id: str + + +@dataclass(frozen=True, slots=True) +class FormExecutionContext: + """Trusted execution facts shared by initialization and node reentry.""" + + tenant_id: TenantId + app_id: str + workflow_run_id: str + global_timeout_deadline: NaiveDatetime + initiator: AccountInitiator | EndUserInitiator | None + # Present only for an actual debugger invocation; node configuration alone + # must not establish a debugging identity or redirect production recipients. + debugging_account_id: AccountId | None + + +class HumanInputFormService: + """Run-bound transaction boundary for form initialization. + + Construct the required repositories inside each method using its Session. + Initialization writes share one transaction. Return only after committing + or rolling back, without carrying an active transaction into delivery. + """ + + def __init__( + self, + *, + session_factory: sessionmaker[Session], + context: FormExecutionContext, + ) -> None: + self._session_factory = session_factory + self._context = context + + def prepare_form( + self, + *, + node_execution_id: str, + node_data: HumanInputNodeData, + variable_pool: ReadOnlyVariablePool, + ) -> PreparedForm | CreatedForm: + """Commit Form and Recipients together; reentry never creates deliveries. + + Execution startup and the Graph Engine establish execution eligibility + and guarantee exclusive execution for each node execution. This operation + trusts the bound execution context and only creates or restores the form. + """ + protected_token = None + expected_hash = None + created = None + with self._session_factory() as session, session.begin(): + repository = SQLAlchemyFormRepository(session, self._context.tenant_id, self._context.app_id) + existing = repository.get_form(self._context.workflow_run_id, node_execution_id) + if existing is not None: + if existing.status == HumanInputFormStatus.WAITING: + delivery = SQLAlchemyDeliveryRepository(session).get_initiator_delivery( + tenant_id=self._context.tenant_id, form_id=existing.id + ) + if delivery is not None: + assert isinstance(delivery.target_snapshot, InitiatorSnapshot) + protected_token = delivery.target_snapshot.protected_form_token + expected_hash = delivery.token_hash + else: + now = naive_utc_now() + duration = ( + timedelta(hours=node_data.timeout) + if node_data.timeout_unit == TimeoutUnit.HOUR + else timedelta(days=node_data.timeout) + ) + template = self._resolve_message_template(node_data, variable_pool) + debug_channels = ( + tuple(node_data.debug_mode.channels) + if node_data.debug_mode.enabled and self._context.debugging_account_id is not None + else None + ) + if debug_channels == (): + raise HumanInputDeliveryError("Debug Mode requires at least one test channel") + form = repository.create_form( + FormCreateParams( + workflow_run_id=self._context.workflow_run_id, + node_execution_id=node_execution_id, + resolved_form=compile_resolved_form(node_data, variable_pool), + expiration_time=now + duration, + global_timeout_deadline=self._context.global_timeout_deadline, + ) + ) + resolved_recipients = self._resolve_recipients( + session=session, node_data=node_data, variable_pool=variable_pool + ) + if not resolved_recipients: + raise HumanInputDeliveryError("No one can approve this step.") + recipients = SQLAlchemyRecipientRepository(session, self._context.tenant_id, form.id).create_recipients( + resolved_recipients + ) + created = CreatedForm(form, recipients, template, debug_channels) + if created is not None: + return created + assert existing is not None + # Tenant-key loading may perform I/O; it must not prolong the DB transaction. + token = None + if protected_token is not None: + plaintext = encrypter.decrypt_token(str(self._context.tenant_id), protected_token) + assert expected_hash is not None + if not hmac.compare_digest(hashlib.sha256(plaintext.encode()).hexdigest(), expected_hash): + raise ValueError("Recovered initiator token does not match its delivery") + token = SecretStr(plaintext) + return PreparedForm(existing, token) + + @staticmethod + def _resolve_message_template( + node_data: HumanInputNodeData, variable_pool: ReadOnlyVariablePool + ) -> ResolvedMessageTemplate: + template = node_data.message_template + if template.body.count(EmailDeliveryConfig.URL_PLACEHOLDER) != 1: + raise ValueError("Message body must contain exactly one Request URL") + subject = EmailDeliveryConfig.sanitize_subject(convert_template(variable_pool, template.subject).text) + body = convert_template(variable_pool, template.body).text + if body.count(EmailDeliveryConfig.URL_PLACEHOLDER) != 1: + raise ValueError("Resolved message body must retain exactly one Request URL") + return ResolvedMessageTemplate(subject, body) + + def _resolve_recipients( + self, + *, + session: Session, + node_data: HumanInputNodeData, + variable_pool: ReadOnlyVariablePool, + ) -> tuple[RecipientCreateParams, ...]: + """Resolve subjects once, merging provenance only for equal subjects.""" + contacts = SQLAlchemyContactRepository(session) + resolved: dict[RecipientSubject, RecipientCreateParams] = {} + + def add(subject: RecipientSubject, source: RecipientConfig, name: str | None = None) -> None: + previous = resolved.get(subject) + if previous is None: + resolved[subject] = RecipientCreateParams(subject, (source,), name) + elif source not in previous.sources: + resolved[subject] = replace(previous, sources=(*previous.sources, source)) + + def add_contact(contact: ResolvedContact | None, source: RecipientConfig) -> None: + if contact is None: + self._recipient_warning("contact_unavailable") + return + add(ContactRecipientSubject(contact.id), source, contact.name) + + if node_data.debug_mode.enabled and self._context.debugging_account_id is not None: + contact = contacts.get_contact_by_account_id(self._context.tenant_id, self._context.debugging_account_id) + if contact is None: + raise HumanInputDeliveryError("The debugging account has no available Contact") + add_contact(contact, Contact(contact_id=str(contact.id))) + return tuple(resolved.values()) + + for source in node_data.recipients_spec: + match source: + case Contact(): + add_contact( + contacts.get_contacts_by_id(self._context.tenant_id, ContactId(source.contact_id)), source + ) + case AllWorkspaceContacts(): + page = 1 + while True: + # This migration marker preserves the legacy all-members audience. + members = contacts.list_contact( + self._context.tenant_id, + page, + _CONTACT_PAGE_SIZE, + ContactQuery(contact_type=ContactType.WORKSPACE), + ) + for contact in members.items: + add_contact(contact, source) + if len(members.items) < _CONTACT_PAGE_SIZE: + break + page += 1 + case Initiator(): + match self._context.initiator: + case AccountInitiator(account_id): + add_contact(contacts.get_contact_by_account_id(self._context.tenant_id, account_id), source) + case EndUserInitiator(end_user_id): + add(EndUserRecipientSubject(end_user_id), source) + case None: + self._recipient_warning("initiator_unavailable") + case OnetimeEmail(): + email = self._parse_email(source.email) + if email is not None: + add(EmailRecipientSubject(email), source) + case DynamicEmail(): + segment = variable_pool.get(source.selector) + if segment is None: + self._recipient_warning("variable_missing") + continue + if not isinstance(segment, StringSegment): + self._recipient_warning("unsupported_type") + continue + if not segment.value.strip(): + self._recipient_warning("variable_empty") + continue + email = self._parse_email(segment.value) + if email is not None: + add(EmailRecipientSubject(email), source) + case _: + assert_never(source) + return tuple(resolved.values()) + + def _parse_email(self, email: str) -> NormalizedEmail | None: + try: + validated = _EMAIL.validate_python(email) + except ValidationError: + self._recipient_warning("invalid_email") + return None + return NormalizedEmail(str(validated).lower()) + + def _recipient_warning(self, reason: str) -> None: + logger.warning( + "Human Input recipient skipped: tenant_id=%s app_id=%s workflow_run_id=%s reason=%s", + self._context.tenant_id, + self._context.app_id, + self._context.workflow_run_id, + reason, + ) + + +def handle_form_timeouts( + *, + session_factory: sessionmaker[Session], + tenant_id: TenantId, + app_id: str, + workflow_run_id: str, +) -> None: + """Choose one expiration path for this run and publish at most one resume. + + Lock the run to serialize with submission, then reload its waiting + forms and evaluate deadlines using the current database time. + Lock individual forms only when applying their state transitions. + """ + resume = False + with session_factory() as session, session.begin(): + workflow_run_repository = DifyAPIRepositoryFactory.create_api_workflow_run_repository(session_factory) + # Keep the run-before-form lock order shared with submission. + run = workflow_run_repository.get_workflow_run_by_id_for_update( + session, tenant_id=tenant_id, app_id=app_id, run_id=workflow_run_id + ) + if run is None: + logger.info( + "Timeout candidate run no longer exists in scope: tenant_id=%s app_id=%s workflow_run_id=%s", + tenant_id, + app_id, + workflow_run_id, + ) + return + repository = SQLAlchemyFormRepository(session, tenant_id, app_id) + batch = repository.list_waiting_forms_for_run(run.id) + globally_expired = tuple(form for form in batch.forms if form.global_timeout_deadline <= batch.now) + if globally_expired: + _handle_global_expiration(session, workflow_run_repository, repository, run, globally_expired) + else: + node_timeouts = tuple(form for form in batch.forms if form.expiration_time <= batch.now) + resume = _handle_node_timeouts(repository, run, node_timeouts) + if resume: + _enqueue_resume(workflow_run_id) + + +def _handle_global_expiration( + session: Session, + workflow_run_repository: APIWorkflowRunRepository, + repository: SQLAlchemyFormRepository, + run: WorkflowRun, + forms: Sequence[Form], +) -> None: + for form in forms: + repository.expire_form(form.id) + if run.status in (WorkflowExecutionStatus.RUNNING, WorkflowExecutionStatus.PAUSED): + workflow_run_repository.stop_workflow_run( + session, run=run, error="Human input global timeout", finished_at=naive_utc_now() + ) + + +def _handle_node_timeouts(repository: SQLAlchemyFormRepository, run: WorkflowRun, forms: Sequence[Form]) -> bool: + timed_out = False + for form in forms: + settled = repository.timeout_form(form.id) + assert settled is not None + if settled.status == HumanInputFormStatus.TIMEOUT: + timed_out = True + return timed_out and run.status in (WorkflowExecutionStatus.RUNNING, WorkflowExecutionStatus.PAUSED) + + +def _enqueue_resume(workflow_run_id: str) -> None: + # Import at the task boundary to keep task bootstrap out of service imports. + + # FIX(QuantumGhost): fix the import loop + from tasks.app_generate.workflow_execute_task import resume_app_execution + + resume_app_execution.apply_async(kwargs={"payload": {"workflow_run_id": workflow_run_id}}) + + +class _InitiatorFormToken(NamedTuple): + ciphertext: str + token_hash: str + + +def load_prepared_forms( + *, + workflow_run_id: str, + form_ids: Sequence[str], + session_factory: Callable[[], Session] = default_session_factory.create_session, +) -> dict[str, PreparedForm]: + """Read run-owned presentation and original initiator grants without delivery. + + The caller has authorized the workflow run. Recover secrets only after the + read transaction closes; never substitute a notification delivery's token. + """ + if not form_ids: + return {} + initiator_tokens_by_form_id: dict[str, _InitiatorFormToken] = {} + with session_factory() as session: + run = session.get(WorkflowRun, workflow_run_id) + if run is None: + return {} + tenant_id = TenantId(run.tenant_id) + repository = SQLAlchemyFormRepository(session, tenant_id, run.app_id) + forms = repository.get_forms_by_ids(workflow_run_id=run.id, form_ids=form_ids) + for form in forms: + if form.status != HumanInputFormStatus.WAITING: + continue + delivery = SQLAlchemyDeliveryRepository(session).get_initiator_delivery( + tenant_id=tenant_id, form_id=form.id + ) + if delivery is not None: + assert isinstance(delivery.target_snapshot, InitiatorSnapshot) + initiator_tokens_by_form_id[form.id] = _InitiatorFormToken( + ciphertext=delivery.target_snapshot.protected_form_token, + token_hash=delivery.token_hash, + ) + prepared: dict[str, PreparedForm] = {} + for form in forms: + token = None + initiator_token = initiator_tokens_by_form_id.get(form.id) + if initiator_token is not None: + plaintext = encrypter.decrypt_token(str(form.tenant_id), initiator_token.ciphertext) + if not hmac.compare_digest(hashlib.sha256(plaintext.encode()).hexdigest(), initiator_token.token_hash): + raise ValueError("Recovered initiator token does not match its delivery") + token = SecretStr(plaintext) + prepared[form.id] = PreparedForm(form, token) + return prepared diff --git a/api/services/human_input_v2/im_card_service.py b/api/services/human_input_v2/im_card_service.py new file mode 100644 index 00000000000..8742834745e --- /dev/null +++ b/api/services/human_input_v2/im_card_service.py @@ -0,0 +1,149 @@ +"""Fan out post-submission updates and replace each exact delivered IM card.""" + +import logging +from collections.abc import Callable +from contextlib import closing + +from kombu.exceptions import OperationalError +from sqlalchemy.orm import Session + +from core.human_input_v2.im_integration.adapters import factory as im_adapter_factory_module +from core.human_input_v2.im_integration.adapters.entities import ReplacementErrorKind, StaticCardIntent +from core.human_input_v2.im_integration.adapters.protocols import IMProviderAdapter +from core.human_input_v2.shared.values import TenantId +from core.workflow.nodes.human_input.enums import HumanInputFormStatus +from extensions.ext_key_provider import key_provider_manager +from factories.human_input_v2_form_presentation import render_filled_content +from repositories.human_input_v2.delivery_repository import IMMessageType, IMUserTargetSnapshot +from repositories.human_input_v2.form_repository import Form +from repositories.human_input_v2.im_channel_repository import IMChannel +from repositories.human_input_v2.sqlalchemy_delivery_repository import SQLAlchemyDeliveryRepository +from repositories.human_input_v2.sqlalchemy_form_repository import SQLAlchemyFormRepository +from repositories.human_input_v2.sqlalchemy_im_channel_repository import ( + DeploymentIMChannelReader, + WorkspaceIMChannelReader, +) +from services.feature_service import FeatureService +from services.human_input_v2.im_credential_codec import IMCredentialCodec, IMCredentialError +from services.human_input_v2.im_tenant_credential_cipher import TenantBoundCredentialCipher + +logger = logging.getLogger(__name__) + + +class IMCardUpdateRetryableError(Exception): + """A card replacement needs another independent follow-up attempt.""" + + +class HumanInputIMCardService: + def __init__(self, *, session_factory: Callable[[], Session]) -> None: + self._session_factory = session_factory + + def enqueue_updates(self, form: Form) -> None: + from tasks.human_input_im_card_tasks import update_human_input_im_card_task + + with self._session_factory() as session: + deliveries = SQLAlchemyDeliveryRepository(session).list_deliveries( + tenant_id=form.tenant_id, form_id=form.id + ) + for delivery in deliveries: + target = delivery.target_snapshot + if ( + not isinstance(target, IMUserTargetSnapshot) + or target.message_type != IMMessageType.CARD + or target.message_locator is None + ): + continue + try: + update_human_input_im_card_task.apply_async( + kwargs={"tenant_id": str(form.tenant_id), "form_id": form.id, "delivery_id": delivery.id} + ) + except OperationalError: + # One unavailable publication must not suppress other deliveries + # or prevent the already accepted workflow from resuming. + logger.exception( + "Failed to enqueue Human Input IM card update tenant_id=%s form_id=%s delivery_id=%s", + form.tenant_id, + form.id, + delivery.id, + ) + + def update_card(self, *, tenant_id: TenantId, form_id: str, delivery_id: str) -> None: + with self._session_factory() as session: + repository = SQLAlchemyFormRepository.for_existing_form(session, tenant_id=tenant_id, form_id=form_id) + if repository is None: + return + form = repository.get_form_by_id(form_id) + if form is None or form.status != HumanInputFormStatus.SUBMITTED: + return + delivery = SQLAlchemyDeliveryRepository(session).get_delivery( + tenant_id=tenant_id, form_id=form_id, delivery_id=delivery_id + ) + if delivery is None: + return + target = delivery.target_snapshot + if ( + not isinstance(target, IMUserTargetSnapshot) + or target.message_type != IMMessageType.CARD + or target.message_locator is None + ): + return + channel = ( + DeploymentIMChannelReader(session).get() + if FeatureService.is_im_centrally_managed() + else WorkspaceIMChannelReader(session, tenant_id).get() + ) + if ( + channel is None + or channel.provider != target.im_provider + or channel.provider_tenant_id != target.im_tenant_id + ): + logger.warning( + "Human Input IM card channel unavailable tenant_id=%s form_id=%s delivery_id=%s", + tenant_id, + form_id, + delivery_id, + ) + return + + intent = _submitted_card_intent(form) + with closing(self._build_adapter(tenant_id, channel)) as adapter: + cards = adapter.dynamic_card_messaging + if cards is None: + return + # Replacing the same exact card with the committed presentation + # is idempotent across task retries and broker redelivery. + failure = cards.replace_with_static(target.message_locator, intent) + if failure is None: + logger.info( + "Human Input IM card updated tenant_id=%s form_id=%s delivery_id=%s", + tenant_id, + form_id, + delivery_id, + ) + return + logger.warning( + "Human Input IM card update failed tenant_id=%s form_id=%s delivery_id=%s kind=%s", + tenant_id, + form_id, + delivery_id, + failure.kind, + ) + if failure.kind == ReplacementErrorKind.UNKNOWN: + raise IMCardUpdateRetryableError(f"Human Input IM card update failed delivery_id={delivery_id}") + + def _build_adapter(self, tenant_id: TenantId, channel: IMChannel) -> IMProviderAdapter: + if FeatureService.is_im_centrally_managed(): + # Deployment credentials must never be decrypted with a workspace key. + raise IMCredentialError("Deployment IM credentials are unavailable") + cipher = TenantBoundCredentialCipher(key_provider_manager.provider, str(tenant_id)) + credentials = IMCredentialCodec(cipher).load(channel.provider, channel.encrypted_credentials) + return im_adapter_factory_module.build_im_provider_adapter(credentials) + + +def _submitted_card_intent(form: Form) -> StaticCardIntent: + submission = form.submission + assert submission is not None, "A submitted form must retain its accepted submission" + content = render_filled_content(form.resolved_form, submission.normalized_submission_data) + action = next(action for action in form.resolved_form.actions if action.id == submission.selected_action_id) + sections = [form.resolved_form.title, content, f"Submitted: {action.title}"] + return StaticCardIntent("\n\n".join(section for section in sections if section)) diff --git a/api/services/human_input_v2/im_channel_service.py b/api/services/human_input_v2/im_channel_service.py new file mode 100644 index 00000000000..7f1c044d920 --- /dev/null +++ b/api/services/human_input_v2/im_channel_service.py @@ -0,0 +1,474 @@ +"""Owner-bound application service for Human Input IM Channel management.""" + +from __future__ import annotations + +import secrets +from abc import ABC, abstractmethod +from collections.abc import Callable +from dataclasses import dataclass, field +from typing import Never, override +from urllib.parse import quote + +from pydantic import NaiveDatetime +from sqlalchemy.orm import Session, sessionmaker +from yarl import URL + +from configs import dify_config +from configs.deploy import IMEventTransportMode +from core.human_input_v2.entities import IMProvider +from core.human_input_v2.im_integration.adapters.credentials import ( + DingTalkCredentials, + FeishuCredentials, + IMProviderCredentials, + LarkCredentials, + MSTeamsCredentials, + SlackCredentials, + WeComCredentials, +) +from core.human_input_v2.im_integration.adapters.entities import ( + CredentialTestFailure, + CredentialTestFailureKind, + CredentialTestSuccess, + IMProviderError, +) +from core.human_input_v2.im_integration.adapters.factory import build_im_provider_adapter +from core.human_input_v2.shared import AccountId, TenantId +from libs.datetime_utils import naive_utc_now +from libs.key_providers.base import BaseKeyProvider +from libs.uuid_utils import uuidv7 +from repositories.human_input_v2.channel_credentials import ChannelEncryptedCredentials +from repositories.human_input_v2.im_channel_repository import ( + IMChannel, + IMChannelAlreadyConfiguredError, + IMChannelId, + IMChannelReader, + IMChannelStatus, + IMChannelWriter, + StaleIMChannelWriteError, + WebhookId, +) +from repositories.human_input_v2.sqlalchemy_im_channel_repository import ( + WorkspaceIMChannelReader, + WorkspaceIMChannelWriter, +) +from services.human_input_v2.credential_update import ( + IMCredentialsUpdate, + merge_im_credentials, +) +from services.human_input_v2.errors import ( + ChannelAlreadyConfiguredError, + ChannelNotFoundError, + ChannelProviderError, + ProviderConfigurationUpdatedError, + ProviderFailureKind, + ReplacementRequiredError, +) +from services.human_input_v2.im_credential_codec import IMCredentialCodec +from services.human_input_v2.im_tenant_credential_cipher import TenantBoundCredentialCipher + +_AVAILABLE_PROVIDERS = ( + IMProvider.SLACK, + IMProvider.FEISHU, + IMProvider.LARK, + IMProvider.DING_TALK, + IMProvider.MS_TEAMS, + IMProvider.WE_COM, +) +_INITIAL_CONFIG_VERSION = 1 +_INVALID_CREDENTIALS_DESCRIPTION = "The submitted credentials are invalid." +_CONNECTION_FAILURE_DESCRIPTION = "The provider connection could not be established." +_IM_CALLBACK_PATH = "/callbacks/human-input/v2/im" + + +def _generate_im_provider_webhook_url(webhook_id: WebhookId) -> str: + """Build a Human Input callback URL from the current public Trigger base URL.""" + + base_url = URL(dify_config.TRIGGER_URL) + escaped_webhook_id = quote(str(webhook_id), safe="") + callback_path = f"{base_url.raw_path.rstrip('/')}{_IM_CALLBACK_PATH}/{escaped_webhook_id}" + return str(base_url.with_path(callback_path, encoded=True).with_query(None).with_fragment(None)) + + +@dataclass(frozen=True, slots=True) +class _PreparedIMChannelConfiguration: + """Private provider result used only while constructing a complete Channel. + + This is deliberately not a Domain contract: it combines provider I/O and + credential-protection results that exist only inside this application use case. + """ + + provider: IMProvider + provider_tenant_id: str + encrypted_credentials: ChannelEncryptedCredentials + app_identifier: str + + def __post_init__(self) -> None: + provider_tenant_id = self.provider_tenant_id.strip() + if not provider_tenant_id: + raise ValueError("provider tenant id must not be blank") + object.__setattr__(self, "provider_tenant_id", provider_tenant_id) + + +@dataclass(frozen=True, slots=True) +class IMChannelView: + """Immutable credential-free projection returned to management callers. + + Persisted ``IMChannel`` values contain protected credentials and routing + metadata, so only ``IMChannelService._to_view`` may expose their safe facts. + """ + + id: IMChannelId + created_at: NaiveDatetime + updated_at: NaiveDatetime + provider: IMProvider + status: IMChannelStatus + status_reason: str | None + app_identifier: str + webhook_url: str | None + config_version: int + + +@dataclass(frozen=True, slots=True) +class IMChannelDetailView: + summary: IMChannelView + credentials: IMProviderCredentials = field(repr=False) + + +class IMChannelService(ABC): + """Shared IM Channel management implementation bound to one owner.""" + + def __init__( + self, + session_factory: sessionmaker[Session], + credential_codec: IMCredentialCodec, + event_transport_mode: IMEventTransportMode, + ) -> None: + self._session_factory = session_factory + self._credential_codec = credential_codec + self._event_transport_mode = event_transport_mode + + @abstractmethod + def _new_reader(self, session: Session) -> IMChannelReader: + """Bind a caller-owned Session to this service's owner.""" + ... + + @abstractmethod + def _new_writer(self, session: Session) -> IMChannelWriter: + """Bind writes and audit metadata to this service's owner context.""" + ... + + def available_providers(self) -> tuple[IMProvider, ...]: + """Return provider discriminators accepted by complete credentials.""" + + return _AVAILABLE_PROVIDERS + + def get_current(self) -> IMChannelView | None: + """Read the current owner-bound Channel without Provider I/O.""" + + current = self._read_current() + return self._to_view(current) if current is not None else None + + def get(self, channel_id: IMChannelId) -> IMChannelView: + """Read the addressed current Channel within the bound owner slot.""" + + return self._to_view(self._load_addressed(channel_id)) + + def get_detail(self, channel_id: IMChannelId) -> IMChannelDetailView: + """Read complete saved credentials for explicit Console response projection.""" + + current = self._load_addressed(channel_id) + credentials = self._credential_codec.load(current.provider, current.encrypted_credentials) + return IMChannelDetailView(self._to_view(current), credentials) + + def test(self, credentials: IMProviderCredentials) -> None: + """Validate only submitted credentials without persistence access.""" + + self._test_credentials(credentials) + + def test_saved( + self, + channel_id: IMChannelId, + expected_config_version: int, + credentials: IMCredentialsUpdate, + ) -> None: + """Test an edit against one saved snapshot without writing configuration.""" + + current = self._load_addressed(channel_id) + self._require_current_version(current, expected_config_version) + self._test_credentials(self._merge_credentials(current, credentials)) + + def create(self, credentials: IMProviderCredentials) -> IMChannelView: + """Validate and persist the first Channel after an owner-slot precheck.""" + + if self._read_current() is not None: + raise ChannelAlreadyConfiguredError("IM channel is already configured") + + prepared = self._prepare_configuration(credentials) + now = self._now() + channel = self._new_channel(prepared, now) + try: + persisted = self._write(lambda writer: writer.create(channel)) + except IMChannelAlreadyConfiguredError: + raise ChannelAlreadyConfiguredError("IM channel is already configured") from None + return self._to_view(persisted) + + def update( + self, + channel_id: IMChannelId, + expected_config_version: int, + credentials: IMProviderCredentials | IMCredentialsUpdate, + ) -> IMChannelView: + """Merge credential edits while preserving Channel and provider identity.""" + + current = self._load_addressed(channel_id) + self._require_current_version(current, expected_config_version) + resolved = self._merge_credentials(current, credentials) + # Encrypt the complete credential document after merging partial updates. + prepared = self._prepare_configuration(resolved) + if prepared.provider_tenant_id != current.provider_tenant_id: + raise ReplacementRequiredError("IM provider tenant replacement is required") + + now = self._now() + updated = IMChannel( + id=current.id, + created_at=current.created_at, + updated_at=now, + provider=prepared.provider, + provider_tenant_id=prepared.provider_tenant_id, + encrypted_credentials=prepared.encrypted_credentials, + app_identifier=prepared.app_identifier, + webhook_id=current.webhook_id, + config_version=expected_config_version + 1, + status=IMChannelStatus.CONNECTED, + status_reason=None, + ) + try: + persisted = self._write(lambda writer: writer.update(updated, expected_config_version)) + except StaleIMChannelWriteError: + raise ProviderConfigurationUpdatedError("IM configuration was updated") from None + return self._to_view(persisted) + + def _merge_credentials( + self, + current: IMChannel, + credentials: IMProviderCredentials | IMCredentialsUpdate, + ) -> IMProviderCredentials: + if credentials.provider is not current.provider: + raise ReplacementRequiredError("IM provider replacement is required") + if isinstance( + credentials, + ( + FeishuCredentials, + LarkCredentials, + SlackCredentials, + DingTalkCredentials, + MSTeamsCredentials, + WeComCredentials, + ), + ): + return credentials + stored = self._credential_codec.load(current.provider, current.encrypted_credentials) + return merge_im_credentials(stored, credentials) + + def replace( + self, + channel_id: IMChannelId, + expected_config_version: int, + credentials: IMProviderCredentials, + ) -> IMChannelView: + """Replace the current provider installation under an explicit CAS guard.""" + + current = self._load_addressed(channel_id) + self._require_current_version(current, expected_config_version) + prepared = self._prepare_configuration(credentials) + now = self._now() + replacement = self._new_channel(prepared, now) + try: + persisted = self._write(lambda writer: writer.replace(current.id, expected_config_version, replacement)) + except StaleIMChannelWriteError: + raise ProviderConfigurationUpdatedError("IM configuration was updated") from None + return self._to_view(persisted) + + def delete( + self, + channel_id: IMChannelId, + expected_config_version: int, + ) -> IMChannelId: + """Delete the addressed Channel without Provider or dependent-domain work.""" + + current = self._load_addressed(channel_id) + self._require_current_version(current, expected_config_version) + try: + self._write(lambda writer: writer.delete(current.id, expected_config_version)) + except StaleIMChannelWriteError: + raise ProviderConfigurationUpdatedError("IM configuration was updated") from None + return current.id + + def _prepare_configuration( + self, + credentials: IMProviderCredentials, + ) -> _PreparedIMChannelConfiguration: + tested = self._test_credentials(credentials) + provider_tenant_id = tested.provider_tenant_id.strip() + if not provider_tenant_id: + raise ValueError("provider tenant id must not be blank") + app_identifier = self._app_identifier(credentials) + encrypted_credentials = self._credential_codec.seal(credentials) + return _PreparedIMChannelConfiguration( + provider=tested.provider, + provider_tenant_id=provider_tenant_id, + encrypted_credentials=encrypted_credentials, + app_identifier=app_identifier, + ) + + def _test_credentials( + self, + credentials: IMProviderCredentials, + ) -> CredentialTestSuccess: + adapter = build_im_provider_adapter(credentials) + try: + tested = adapter.test_credentials() + except IMProviderError as error: + raise ChannelProviderError(ProviderFailureKind.CONNECTION_FAILURE, str(error)) from error + finally: + adapter.close() + + if isinstance(tested, CredentialTestFailure): + self._raise_provider_failure(tested) + if tested.provider is not credentials.provider: + raise AssertionError("provider adapter returned a mismatched provider") + return tested + + @staticmethod + def _raise_provider_failure(failure: CredentialTestFailure) -> Never: + if failure.kind is CredentialTestFailureKind.AUTHENTICATION_REJECTED: + raise ChannelProviderError( + ProviderFailureKind.INVALID_CREDENTIALS, + _INVALID_CREDENTIALS_DESCRIPTION, + provider_error_code=failure.provider_error_code, + missing_permissions=failure.missing_permissions, + http_status=failure.http_status, + ) + raise ChannelProviderError( + ProviderFailureKind.CONNECTION_FAILURE, + _CONNECTION_FAILURE_DESCRIPTION, + provider_error_code=failure.provider_error_code, + missing_permissions=failure.missing_permissions, + http_status=failure.http_status, + ) + + @staticmethod + def _app_identifier(credentials: IMProviderCredentials) -> str: + if isinstance(credentials, (FeishuCredentials, LarkCredentials)): + app_identifier = credentials.app_id + elif isinstance(credentials, (SlackCredentials, DingTalkCredentials, MSTeamsCredentials)): + app_identifier = credentials.client_id + elif isinstance(credentials, WeComCredentials): + app_identifier = credentials.agent_id + else: + raise TypeError("unsupported IM provider credentials") + + app_identifier = app_identifier.strip() + if not app_identifier: + raise ValueError("app identifier must not be blank") + return app_identifier + + def _to_view(self, channel: IMChannel) -> IMChannelView: + webhook_url = ( + _generate_im_provider_webhook_url(channel.webhook_id) + if self._event_transport_mode is IMEventTransportMode.WEBHOOK and channel.provider.supports_webhook() + else None + ) + return IMChannelView( + id=channel.id, + created_at=channel.created_at, + updated_at=channel.updated_at, + provider=channel.provider, + status=channel.status, + status_reason=channel.status_reason, + app_identifier=channel.app_identifier, + webhook_url=webhook_url, + config_version=channel.config_version, + ) + + def _read_current(self) -> IMChannel | None: + with self._session_factory() as session: + return self._new_reader(session).get() + + def _load_addressed(self, channel_id: IMChannelId) -> IMChannel: + current = self._read_current() + if current is None or current.id != channel_id: + raise ChannelNotFoundError("IM channel was not found") + return current + + @staticmethod + def _require_current_version(channel: IMChannel, expected_config_version: int) -> None: + if channel.config_version != expected_config_version: + raise ProviderConfigurationUpdatedError("IM configuration was updated") + + def _write[T](self, mutation: Callable[[IMChannelWriter], T]) -> T: + with self._session_factory() as session: + with session.begin(): + persisted = mutation(self._new_writer(session)) + return persisted + + def _new_channel( + self, + prepared: _PreparedIMChannelConfiguration, + now: NaiveDatetime, + ) -> IMChannel: + return IMChannel( + id=self._new_channel_id(), + created_at=now, + updated_at=now, + provider=prepared.provider, + provider_tenant_id=prepared.provider_tenant_id, + encrypted_credentials=prepared.encrypted_credentials, + app_identifier=prepared.app_identifier, + webhook_id=self._new_webhook_id(), + config_version=_INITIAL_CONFIG_VERSION, + status=IMChannelStatus.CONNECTED, + status_reason=None, + ) + + @staticmethod + def _now() -> NaiveDatetime: + return naive_utc_now() + + @staticmethod + def _new_channel_id() -> IMChannelId: + return IMChannelId(str(uuidv7())) + + @staticmethod + def _new_webhook_id() -> WebhookId: + return WebhookId(secrets.token_urlsafe(24)) + + +class WorkspaceIMChannelService(IMChannelService): + """IM Channel management bound to one trusted Workspace and Account.""" + + def __init__( + self, + session_factory: sessionmaker[Session], + tenant_id: TenantId, + account_id: AccountId, + key_provider: BaseKeyProvider, + ) -> None: + super().__init__( + session_factory, + IMCredentialCodec(TenantBoundCredentialCipher(key_provider, str(tenant_id))), + dify_config.HUMAN_INPUT_IM_EVENT_TRANSPORT_MODE, + ) + self._tenant_id = tenant_id + self._account_id = account_id + + @override + def _new_reader(self, session: Session) -> IMChannelReader: + return WorkspaceIMChannelReader(session, self._tenant_id) + + @override + def _new_writer(self, session: Session) -> IMChannelWriter: + return WorkspaceIMChannelWriter(session, self._tenant_id, self._account_id) + + +__all__ = ["IMChannelService", "IMChannelView", "WorkspaceIMChannelService"] diff --git a/api/services/human_input_v2/im_channel_service_composition.py b/api/services/human_input_v2/im_channel_service_composition.py new file mode 100644 index 00000000000..0a45fbd7218 --- /dev/null +++ b/api/services/human_input_v2/im_channel_service_composition.py @@ -0,0 +1,24 @@ +"""Production composition for Workspace IM Channel management.""" + +from sqlalchemy.orm import Session, sessionmaker + +from core.human_input_v2.shared import AccountId, TenantId +from extensions.ext_database import db +from extensions.ext_key_provider import key_provider_manager +from services.human_input_v2.im_channel_service import WorkspaceIMChannelService + + +def build_workspace_im_channel_service( + tenant_id: TenantId, + account_id: AccountId, +) -> WorkspaceIMChannelService: + operation_sessions = sessionmaker[Session](bind=db.engine, expire_on_commit=False) + return WorkspaceIMChannelService( + operation_sessions, + tenant_id, + account_id, + key_provider_manager.provider, + ) + + +__all__ = ["build_workspace_im_channel_service"] diff --git a/api/services/human_input_v2/im_contact_sync/__init__.py b/api/services/human_input_v2/im_contact_sync/__init__.py new file mode 100644 index 00000000000..660d4ca416f --- /dev/null +++ b/api/services/human_input_v2/im_contact_sync/__init__.py @@ -0,0 +1,27 @@ +"""Transport-neutral IM Contact Sync application boundary.""" + +from .binding_service import ContactIMBindingService +from .composition import IMContactSyncApplication, build_im_contact_sync_application +from .coordinator import IMChannelReconciliationService, IMSyncRetryableError +from .service import ( + IMChannelNotConfiguredError, + IMSyncRevisionChangedError, + IMSyncRunDispatcher, + IMSyncRunNotFoundError, + IMSyncService, +) +from .worker import IMContactSyncWorker + +__all__ = [ + "ContactIMBindingService", + "IMChannelNotConfiguredError", + "IMChannelReconciliationService", + "IMContactSyncApplication", + "IMContactSyncWorker", + "IMSyncRetryableError", + "IMSyncRevisionChangedError", + "IMSyncRunDispatcher", + "IMSyncRunNotFoundError", + "IMSyncService", + "build_im_contact_sync_application", +] diff --git a/api/services/human_input_v2/im_contact_sync/binding_service.py b/api/services/human_input_v2/im_contact_sync/binding_service.py new file mode 100644 index 00000000000..8c99fa71171 --- /dev/null +++ b/api/services/human_input_v2/im_contact_sync/binding_service.py @@ -0,0 +1,194 @@ +"""Transport-neutral manual IM binding commands.""" + +from __future__ import annotations + +from collections.abc import Callable + +from pydantic import NaiveDatetime +from sqlalchemy.orm import Session, sessionmaker + +from core.human_input_v2.entities import HumanInputContactType, IMBindingScope +from core.human_input_v2.im_integration import ContactIMBindingView, IMBindingCommandError, IMBindingCommandErrorCode +from core.human_input_v2.shared import AccountId, ContactId, DirectoryScope, IMIdentityId, TenantId +from libs.datetime_utils import naive_utc_now +from repositories.human_input_v2.contact import IMBinding as ContactIMBinding +from repositories.human_input_v2.im_binding_repository import ( + IMBindingAssignment, + IMBindingConflictError, + IMBindingIdentityNotFoundError, + IMBindingKind, +) +from repositories.human_input_v2.im_channel_repository import IMChannel +from repositories.human_input_v2.sqlalchemy_contact_repository import SQLAlchemyContactRepository +from repositories.human_input_v2.sqlalchemy_im_binding_repository import SQLAlchemyIMBindingRepository + +# TODO(QuantumGhost): this abstraction is problematic. +type ChannelResolver = Callable[[Session, DirectoryScope], IMChannel | None] + + +class ContactIMBindingService: + """Own transactions while repositories remain bound to one trusted Channel.""" + + def __init__( + self, + session_factory: sessionmaker[Session], + channel_resolver: ChannelResolver, + *, + clock: Callable[[], NaiveDatetime] = naive_utc_now, + ) -> None: + self._session_factory = session_factory + self._channel_resolver = channel_resolver + self._clock = clock + + def set_organization_binding( + self, + *, + organization_scope: DirectoryScope, + tenant_id: TenantId, + contact_id: ContactId, + identity_id: IMIdentityId, + bound_by_account_id: AccountId | None, + ) -> ContactIMBindingView: + """Atomically replace either occupied default endpoint for a manual edit. + + Identical assignments preserve the Binding ID and metadata. A changed + assignment creates a new ID, invalidating cards for replaced defaults. + Controller authorization determines whether default bindings are editable. + """ + return self._mutate( + organization_scope, + tenant_id, + contact_id, + lambda repository: repository.replace( + IMBindingAssignment(contact_id, identity_id, self._clock()), + bound_by_account_id=bound_by_account_id, + ), + ) + + def delete_organization_binding( + self, + *, + organization_scope: DirectoryScope, + contact_id: ContactId, + ) -> None: + """Delete the Contact's current default Binding without changing workspace overrides.""" + tenant_id = _tenant_id(organization_scope) + with self._session_factory() as session, session.begin(): + channel = self._require_channel(session, organization_scope) + repository = SQLAlchemyIMBindingRepository(session, channel.id) + binding = repository.get_default_for_contact(contact_id) + if binding is None: + raise IMBindingCommandError( + IMBindingCommandErrorCode.BINDING_NOT_FOUND, + "IM Binding was not found", + ) + repository.delete(binding.id, expected_identity_id=binding.identity_id) + self._require_contact(session, tenant_id, contact_id) + + def set_workspace_override( + self, + *, + organization_scope: DirectoryScope, + tenant_id: TenantId, + contact_id: ContactId, + identity_id: IMIdentityId, + bound_by_account_id: AccountId | None, + ) -> ContactIMBindingView: + return self._mutate( + organization_scope, + tenant_id, + contact_id, + lambda repository: repository.set_workspace_override( + tenant_id, + IMBindingAssignment(contact_id, identity_id, self._clock()), + bound_by_account_id=bound_by_account_id, + ), + ) + + def reset_workspace_override( + self, + *, + organization_scope: DirectoryScope, + tenant_id: TenantId, + contact_id: ContactId, + ) -> ContactIMBindingView: + return self._mutate( + organization_scope, + tenant_id, + contact_id, + lambda repository: repository.reset_workspace_override(tenant_id, contact_id), + ) + + def _mutate[T]( + self, + owner_scope: DirectoryScope, + tenant_id: TenantId, + contact_id: ContactId, + mutation: Callable[[SQLAlchemyIMBindingRepository], T], + ) -> ContactIMBindingView: + try: + with self._session_factory() as session, session.begin(): + channel = self._require_channel(session, owner_scope) + contact = self._require_contact(session, tenant_id, contact_id) + repository = SQLAlchemyIMBindingRepository(session, channel.id) + mutation(repository) + effective = repository.get_effective(tenant_id, contact_id) + bindings = ( + ( + ContactIMBinding( + id=effective.id, + scope=( + IMBindingScope.WORKSPACE + if effective.kind is IMBindingKind.WORKSPACE_OVERRIDE + else IMBindingScope.ORGANIZATION + ), + contact_id=effective.contact_id, + identity_id=effective.identity_id, + provider=channel.provider, + ), + ) + if effective is not None + else () + ) + return ContactIMBindingView( + id=contact.id, + type=HumanInputContactType(contact.type.value), + name=contact.name, + email=contact.email, + avatar_file_id=contact.avatar_file_id, + im_bindings=bindings, + created_at=contact.created_at, + ) + except IMBindingIdentityNotFoundError: + raise IMBindingCommandError( + IMBindingCommandErrorCode.IDENTITY_NOT_FOUND, "IM Identity was not found" + ) from None + except IMBindingConflictError: + raise IMBindingCommandError(IMBindingCommandErrorCode.BINDING_CONFLICT, "IM Binding conflicts") from None + + def _require_channel(self, session: Session, owner_scope: DirectoryScope) -> IMChannel: + channel = self._channel_resolver(session, owner_scope) + if channel is None: + raise IMBindingCommandError( + IMBindingCommandErrorCode.INTEGRATION_NOT_CONFIGURED, + "IM Channel is not configured", + ) + return channel + + @staticmethod + def _require_contact(session: Session, tenant_id: TenantId, contact_id: ContactId): + contact = SQLAlchemyContactRepository(session).get_contacts_by_id(tenant_id, contact_id) + if contact is None: + raise IMBindingCommandError(IMBindingCommandErrorCode.CONTACT_NOT_FOUND, "Contact was not found") + return contact + + +def _tenant_id(scope: DirectoryScope) -> TenantId: + from core.human_input_v2.shared import WorkspaceScope + + if isinstance(scope, WorkspaceScope): + return scope.id + raise IMBindingCommandError(IMBindingCommandErrorCode.INVALID_SCOPE, "Workspace tenant is required") + + +__all__ = ["ContactIMBindingService"] diff --git a/api/services/human_input_v2/im_contact_sync/composition.py b/api/services/human_input_v2/im_contact_sync/composition.py new file mode 100644 index 00000000000..87073b9d423 --- /dev/null +++ b/api/services/human_input_v2/im_contact_sync/composition.py @@ -0,0 +1,213 @@ +"""Production composition for transport-neutral IM Contact synchronization.""" + +from __future__ import annotations + +from collections.abc import Callable, Sequence +from dataclasses import dataclass + +from sqlalchemy.orm import Session, sessionmaker + +from core.human_input_v2.im_integration.adapters import IMProviderAdapter +from core.human_input_v2.im_integration.adapters.credentials import IMProviderCredentials +from core.human_input_v2.im_integration.adapters.factory import build_im_provider_adapter +from core.human_input_v2.shared import DeploymentScope, DirectoryScope, IMSyncRunId, TenantId, WorkspaceScope +from extensions.ext_database import db +from extensions.ext_key_provider import key_provider_manager +from repositories.human_input_v2.contact import Contact, ContactQuery, ContactType +from repositories.human_input_v2.im_channel_repository import IMChannel +from repositories.human_input_v2.sqlalchemy_contact_repository import SQLAlchemyContactRepository +from repositories.human_input_v2.sqlalchemy_im_channel_repository import ( + DeploymentIMChannelReader, + WorkspaceIMChannelReader, +) +from services.human_input_v2.im_credential_codec import BoundCredentialCipher, IMCredentialCodec, IMCredentialError +from services.human_input_v2.im_tenant_credential_cipher import TenantBoundCredentialCipher + +from .binding_service import ContactIMBindingService +from .coordinator import ( + BoundContactReader, + BoundContactReaderFactory, + IMChannelAdapterFactory, + IMChannelReconciliationService, +) +from .service import IMChannelNotConfiguredError, IMSyncService +from .worker import IMContactSyncWorker + +_CONTACT_PAGE_LIMIT = 500 +_CREDENTIAL_UNAVAILABLE_MESSAGE = "IM credential configuration is unavailable" + + +@dataclass(frozen=True, slots=True) +class IMContactSyncApplication: + sync_service: IMSyncService + binding_service: ContactIMBindingService + worker: IMContactSyncWorker + + +class DifyIMChannelAdapterFactory: + """Recover one Channel's credentials using an already-selected owner cipher.""" + + def __init__( + self, + cipher: BoundCredentialCipher, + adapter_builder: Callable[[IMProviderCredentials], IMProviderAdapter] = build_im_provider_adapter, + ) -> None: + self._cipher = cipher + self._adapter_builder = adapter_builder + + def __call__(self, channel: IMChannel) -> IMProviderAdapter: + credentials = IMCredentialCodec(self._cipher).load(channel.provider, channel.encrypted_credentials) + return self._adapter_builder(credentials) + + +class _WorkspaceContactReader: + def __init__(self, session: Session, tenant_id: TenantId) -> None: + self._repository = SQLAlchemyContactRepository(session) + self._tenant_id = tenant_id + self._contacts: tuple[Contact, ...] | None = None + + def list_contacts(self, page: int, limit: int) -> Sequence[Contact]: + contacts = self._load() + offset = (page - 1) * limit + return contacts[offset : offset + limit] + + def get_contact(self, contact_id) -> Contact | None: + return next((contact for contact in self._load() if contact.id == contact_id), None) + + def _load(self) -> tuple[Contact, ...]: + if self._contacts is not None: + return self._contacts + contacts: list[Contact] = [] + for contact_type in (ContactType.WORKSPACE, ContactType.PLATFORM): + page = 1 + while True: + current = self._repository.list_contact( + self._tenant_id, + page, + _CONTACT_PAGE_LIMIT, + ContactQuery(contact_type=contact_type), + ) + contacts.extend(current.items) + if len(current.items) < _CONTACT_PAGE_LIMIT: + break + page += 1 + self._contacts = tuple(contacts) + return self._contacts + + +def build_im_contact_sync_application( + *, + session_maker: sessionmaker[Session] | None = None, + deployment_contact_reader_factory: Callable[[Session], BoundContactReader] | None = None, + workspace_adapter_factory: IMChannelAdapterFactory | None = None, + deployment_adapter_factory: IMChannelAdapterFactory | None = None, +) -> IMContactSyncApplication: + sessions = session_maker or sessionmaker(bind=db.engine, expire_on_commit=False) + sync_service = IMSyncService(sessions, _resolve_channel, _dispatch) + worker = IMContactSyncWorker( + sync_service, + _reconciliation_factory( + sessions, + deployment_contact_reader_factory, + workspace_adapter_factory, + deployment_adapter_factory, + ), + ) + return IMContactSyncApplication( + sync_service=sync_service, + binding_service=ContactIMBindingService(sessions, _resolve_channel), + worker=worker, + ) + + +def build_im_contact_sync_worker( + *, + session_maker: sessionmaker[Session] | None = None, + deployment_contact_reader_factory: Callable[[Session], BoundContactReader] | None = None, + workspace_adapter_factory: IMChannelAdapterFactory | None = None, + deployment_adapter_factory: IMChannelAdapterFactory | None = None, +) -> IMContactSyncWorker: + return build_im_contact_sync_application( + session_maker=session_maker, + deployment_contact_reader_factory=deployment_contact_reader_factory, + workspace_adapter_factory=workspace_adapter_factory, + deployment_adapter_factory=deployment_adapter_factory, + ).worker + + +def build_im_sync_service(*, session_maker: sessionmaker[Session] | None = None) -> IMSyncService: + sessions = session_maker or sessionmaker(bind=db.engine, expire_on_commit=False) + return IMSyncService(sessions, _resolve_channel, _dispatch) + + +def _resolve_channel(session: Session, owner_scope: DirectoryScope) -> IMChannel | None: + if isinstance(owner_scope, WorkspaceScope): + return WorkspaceIMChannelReader(session, owner_scope.id).get() + if isinstance(owner_scope, DeploymentScope): + return DeploymentIMChannelReader(session).get() + raise TypeError("unsupported IM Channel owner scope") + + +def _reconciliation_factory( + sessions: sessionmaker[Session], + deployment_contacts: Callable[[Session], BoundContactReader] | None, + workspace_adapter_factory: IMChannelAdapterFactory | None, + deployment_adapter_factory: IMChannelAdapterFactory | None, +) -> Callable[[DirectoryScope], IMChannelReconciliationService]: + def create(owner_scope: DirectoryScope) -> IMChannelReconciliationService: + with sessions() as session: + channel = _resolve_channel(session, owner_scope) + if channel is None: + raise IMChannelNotConfiguredError("Owner has no IM Channel") + contact_reader_factory: BoundContactReaderFactory + adapter_factory: IMChannelAdapterFactory + if isinstance(owner_scope, WorkspaceScope): + + def contact_reader_factory(session: Session) -> BoundContactReader: + return _WorkspaceContactReader(session, owner_scope.id) + + adapter_factory = workspace_adapter_factory or DifyIMChannelAdapterFactory( + TenantBoundCredentialCipher(key_provider_manager.provider, str(owner_scope.id)) + ) + elif isinstance(owner_scope, DeploymentScope) and deployment_contacts is not None: + contact_reader_factory = deployment_contacts + adapter_factory = deployment_adapter_factory or _unavailable_deployment_adapter + else: + raise IMChannelNotConfiguredError("Deployment Contact reader is unavailable") + return IMChannelReconciliationService( + sessions, + channel, + adapter_factory, + contact_reader_factory, + tenant_id=owner_scope.id if isinstance(owner_scope, WorkspaceScope) else None, + ) + + return create + + +def _dispatch(sync_run_id: IMSyncRunId, scope: DirectoryScope) -> None: + from tasks.im_contact_sync_tasks import reconcile_im_contacts_task + + if isinstance(scope, WorkspaceScope): + payload: tuple[str, str | None] = ("workspace", str(scope.id)) + elif isinstance(scope, DeploymentScope): + payload = ("deployment", None) + else: + raise TypeError("unsupported IM Channel owner scope") + reconcile_im_contacts_task.apply_async( + args=(str(sync_run_id), *payload), + queue="human_input_contact_sync", + ) + + +def _unavailable_deployment_adapter(_channel: IMChannel) -> IMProviderAdapter: + raise IMCredentialError(_CREDENTIAL_UNAVAILABLE_MESSAGE) + + +__all__ = [ + "DifyIMChannelAdapterFactory", + "IMContactSyncApplication", + "build_im_contact_sync_application", + "build_im_contact_sync_worker", + "build_im_sync_service", +] diff --git a/api/services/human_input_v2/im_contact_sync/coordinator.py b/api/services/human_input_v2/im_contact_sync/coordinator.py new file mode 100644 index 00000000000..c0ae40bae0f --- /dev/null +++ b/api/services/human_input_v2/im_contact_sync/coordinator.py @@ -0,0 +1,849 @@ +"""Channel-bound reconciliation Service with an explicit Session transaction.""" + +from __future__ import annotations + +import hashlib +import logging +import mimetypes +from collections.abc import Callable, Sequence +from itertools import batched +from typing import Protocol + +import sqlalchemy as sa +from pydantic import NaiveDatetime +from sqlalchemy.engine import CursorResult +from sqlalchemy.orm import Session, sessionmaker + +from configs import dify_config +from core.human_input_v2.entities import IMProvider, IMSyncRemovalReason, IMSyncResultType, IMSyncRunStatus +from core.human_input_v2.im_integration import ( + BlockedReconciliation, + ContactEmailMatchState, + CreateIMBinding, + CurrentIMIdentityState, + DeleteIMBinding, + ExistingIMIdentityRef, + IMBindingChangeSnapshot, + IMIdentityChangeSnapshot, + IMIdentityUpsertKind, + IMReconciliationChange, + IMReconciliationOperation, + IMReconciliationSubjectKind, + IMSyncRun, + NewIMIdentityRef, + ReconciliationPlan, + ReplaceIMBinding, + SyncContactSnapshot, + SyncIdentitySnapshot, + SyncResultFact, +) +from core.human_input_v2.im_integration.adapters import IMProviderAdapter +from core.human_input_v2.im_integration.adapters.entities import DirectoryEntry, DirectoryReadFailure, ProviderUserId +from core.human_input_v2.shared import ( + AccountId, + ContactId, + IMBindingId, + IMIdentityId, + IMReconciliationChangeId, + IMSyncResultId, + IMSyncRunId, + NormalizedEmail, + TenantId, +) +from extensions.ext_storage import storage +from extensions.storage.storage_type import StorageType +from libs.datetime_utils import naive_utc_now +from libs.uuid_utils import uuidv7 +from models.account import Account, TenantAccountJoin, TenantAccountRole +from models.enums import CreatorUserRole +from models.human_input_v2 import HumanInputIMBinding, HumanInputIMChannel, HumanInputIMSyncRun +from models.model import UploadFile +from repositories.human_input_v2.contact import Contact, ContactType +from repositories.human_input_v2.im_channel_repository import IMChannel, IMChannelId +from repositories.human_input_v2.im_identity_repository import IMIdentity, IMIdentityObservation, OpaqueProviderPayload +from repositories.human_input_v2.im_integration.mappers import ( + reconciliation_change_to_record, + sync_result_to_record, + sync_run_from_record, +) +from repositories.human_input_v2.im_integration.repository import SQLAlchemyIMControlPlaneRepository +from repositories.human_input_v2.sqlalchemy_im_binding_repository import SQLAlchemyIMBindingRepository +from repositories.human_input_v2.sqlalchemy_im_identity_repository import SQLAlchemyIMIdentityRepository +from services.human_input_v2.im_credential_codec import IMCredentialError + +from .reconciliation import generate_reconciliation_plan +from .service import IMSyncRunNotFoundError + +logger = logging.getLogger(__name__) + +_CONTACT_PAGE_LIMIT = 500 +_BINDING_WRITE_BATCH_SIZE = 300 + + +class IMSyncRetryableError(RuntimeError): + """A transient synchronization failure requires worker redelivery.""" + + +class _ReconciliationPreconditionError(Exception): + pass + + +class _StaleChannelRevisionError(Exception): + pass + + +class _ReconciliationAlreadyTerminalError(Exception): + def __init__(self, run: IMSyncRun) -> None: + super().__init__(run.status.value) + self.run = run + + +class BoundContactReader(Protocol): + """Contact reads whose owner was selected before reconciliation composition.""" + + def list_contacts(self, page: int, limit: int) -> Sequence[Contact]: ... + + def get_contact(self, contact_id: ContactId) -> Contact | None: ... + + +type BoundContactReaderFactory = Callable[[Session], BoundContactReader] +type IMChannelAdapterFactory = Callable[[IMChannel], IMProviderAdapter] + + +class IMChannelReconciliationService: + """Load, plan, and apply one sync against a constructor-bound trusted Channel.""" + + def __init__( + self, + session_factory: sessionmaker[Session], + channel: IMChannel, + adapter_factory: IMChannelAdapterFactory, + contact_reader_factory: BoundContactReaderFactory, + *, + tenant_id: TenantId | None = None, + clock: Callable[[], NaiveDatetime] = naive_utc_now, + ) -> None: + self._session_factory = session_factory + self._channel = channel + self._adapter_factory = adapter_factory + self._contact_reader_factory = contact_reader_factory + self._clock = clock + self._tenant_id = tenant_id + + def reconcile(self, sync_run_id: IMSyncRunId) -> IMSyncRun: + adapter: IMProviderAdapter | None = None + try: + run = self._require_run(sync_run_id) + if run.channel_revision.channel_id != str(self._channel.id): + raise IMSyncRunNotFoundError("IM synchronization run was not found") + if run.channel_revision.config_version < self._channel.config_version: + with self._session_factory() as session, session.begin(): + stale_run = SQLAlchemyIMControlPlaneRepository(session, self._channel).finish_stale_run( + sync_run_id, now=self._clock() + ) + if stale_run is None: + raise IMSyncRetryableError("IM Channel revision changed during stale run completion") + return stale_run + if not self._run_matches_channel(run): + raise IMSyncRunNotFoundError("IM synchronization run was not found") + if not run.is_active: + return run + try: + adapter = self._adapter_factory(self._channel) + except IMCredentialError: + logger.warning("IM Channel credentials are unavailable, sync_run_id=%s", sync_run_id) + return self._persist_failure( + sync_run_id, + "directory_read_failed", + "IM credential configuration is unavailable.", + ) + try: + directory = adapter.directory.read_directory() + except Exception: + logger.exception("IM Contact directory read raised unexpectedly, sync_run_id=%s", sync_run_id) + return self._persist_failure( + sync_run_id, + "directory_read_failed", + "Provider directory could not be read.", + ) + if isinstance(directory, DirectoryReadFailure): + logger.warning("IM Contact directory read failed, sync_run_id=%s", sync_run_id) + return self._persist_failure( + sync_run_id, + "directory_read_failed", + "Provider directory could not be read.", + ) + try: + avatar_files = self._prepare_avatar_files(run, directory.entries) + with self._session_factory() as session, session.begin(): + run_record = self._require_active_run_record(session, sync_run_id) + identities = SQLAlchemyIMIdentityRepository(session, self._channel.id) + bindings = SQLAlchemyIMBindingRepository(session, self._channel.id) + contacts = self._load_contacts(self._contact_reader_factory(session)) + plan_or_block = generate_reconciliation_plan( + sync_run_from_record(run_record), + directory.entries, + identities.list_all(), + bindings.list_all(), + self._contact_states(contacts), + ) + if isinstance(plan_or_block, BlockedReconciliation): + blocker_codes = ",".join(blocker.code.value for blocker in plan_or_block.blockers) + self._fail_run_record( + session, + run_record, + "plan_blocked", + f"Reconciliation input was blocked: {blocker_codes}.", + self._clock(), + ) + else: + self._apply_plan(session, run_record, plan_or_block, contacts, identities, avatar_files) + except _ReconciliationAlreadyTerminalError as terminal: + return terminal.run + except _StaleChannelRevisionError: + return self._persist_failure( + sync_run_id, + "stale_revision", + "IM Channel revision changed before reconciliation apply.", + ) + except _ReconciliationPreconditionError: + return self._persist_failure( + sync_run_id, + "precondition_failed", + "IM reconciliation precondition changed.", + ) + except IMSyncRunNotFoundError: + raise + except Exception: + logger.exception("IM Contact reconciliation failed, sync_run_id=%s", sync_run_id) + return self._persist_failure( + sync_run_id, + "unexpected_apply_failure", + "IM reconciliation could not be applied.", + ) + return self._require_run(sync_run_id) + finally: + if adapter is not None: + try: + adapter.close() + except Exception: + logger.exception("IM Provider adapter close failed, sync_run_id=%s", sync_run_id) + + def _prepare_avatar_files( + self, run: IMSyncRun, entries: tuple[DirectoryEntry, ...] + ) -> dict[ProviderUserId, UploadFile]: + # Deployment file ownership is not supported yet. Composition supplies a + # tenant only for Workspace Channels. + if self._tenant_id is None or not any(entry.avatar is not None for entry in entries): + return {} + created_by = run.started_by_account_id + if created_by is None: + with self._session_factory() as session: + owner_id = session.scalar( + sa.select(Account.id) + .join(TenantAccountJoin, TenantAccountJoin.account_id == Account.id) + .where( + TenantAccountJoin.tenant_id == str(self._tenant_id), + TenantAccountJoin.role == TenantAccountRole.OWNER, + ) + ) + if owner_id is None: + raise ValueError("Workspace owner is unavailable for IM avatar storage") + created_by = AccountId(owner_id) + now = self._clock() + avatar_files: dict[ProviderUserId, UploadFile] = {} + for entry in entries: + if entry.avatar is None: + continue + avatar = entry.avatar + suffix = mimetypes.guess_extension(avatar.mime_type) or "" + file_key = f"upload_files/{self._tenant_id}/{uuidv7()}{suffix}" + storage.save(file_key, avatar.data) + # Persist metadata with the Identity update, after storage I/O has + # completed. Orphaned objects from a failed apply need future GC. + avatar_files[entry.provider_user_id] = UploadFile( + tenant_id=str(self._tenant_id), + storage_type=StorageType(dify_config.STORAGE_TYPE), + key=file_key, + name=f"avatar{suffix}", + size=len(avatar.data), + extension=suffix.lstrip("."), + mime_type=avatar.mime_type, + created_by_role=CreatorUserRole.ACCOUNT, + created_by=str(created_by), + created_at=now, + used=True, + used_by=str(created_by), + used_at=now, + hash=hashlib.sha3_256(avatar.data).hexdigest(), + ) + return avatar_files + + def _apply_plan( + self, + session: Session, + run_record: HumanInputIMSyncRun, + plan: ReconciliationPlan, + contacts: tuple[Contact, ...], + identities: SQLAlchemyIMIdentityRepository, + avatar_files: dict[ProviderUserId, UploadFile], + ) -> None: + now = self._clock() + initial_identities = {identity.id: identity for identity in identities.list_all()} + identity_ids: dict[NewIMIdentityRef, IMIdentityId] = {} + binding_ids: dict[tuple[IMIdentityId, ContactId], IMBindingId] = {} + changes: list[IMReconciliationChange] = [] + for upsert in plan.identity_upserts: + avatar_file = avatar_files.get(upsert.entry.provider_user_id) + if avatar_file is not None: + session.add(avatar_file) + observation = IMIdentityObservation( + provider_user_id=str(upsert.entry.provider_user_id), + display_name=upsert.entry.display_name, + email=upsert.entry.email, + raw_payload=OpaqueProviderPayload({}), + sync_run_id=plan.run.sync_run_id, + observed_at=now, + avatar_file_id=avatar_file.id if avatar_file is not None else None, + ) + if isinstance(upsert.identity_ref, NewIMIdentityRef): + identity_id = IMIdentityId(str(uuidv7())) + persisted_identity = identities.create(identity_id, observation) + identity_ids[upsert.identity_ref] = identity_id + else: + identity_id = upsert.identity_ref.identity_id + persisted_identity = identities.update(identity_id, observation) + before_snapshot = ( + self._identity_snapshot_from_state(upsert.before, self._channel.provider) + if upsert.before is not None + else None + ) + changes.append( + IMReconciliationChange( + id=IMReconciliationChangeId(str(uuidv7())), + channel_id=IMChannelId(str(self._channel.id)), + sync_run_id=plan.run.sync_run_id, + operation_key=upsert.operation_key, + subject_kind=IMReconciliationSubjectKind.IDENTITY, + operation={ + IMIdentityUpsertKind.CREATE: IMReconciliationOperation.CREATE, + IMIdentityUpsertKind.UPDATE: IMReconciliationOperation.UPDATE, + IMIdentityUpsertKind.REFRESH: IMReconciliationOperation.REFRESH, + }[upsert.kind], + reason_code="directory_identity_observed", + identity_id=identity_id, + binding_id=None, + contact_id=None, + before=before_snapshot, + after=self._identity_snapshot(persisted_identity, self._channel.provider), + committed_at=now, + ) + ) + + contact_by_id = {contact.id: contact for contact in contacts} + bindings_to_delete: list[IMBindingChangeSnapshot] = [] + bindings_to_create: list[IMBindingChangeSnapshot] = [] + for mutation in plan.binding_mutations: + if isinstance(mutation, CreateIMBinding): + self._require_contact_precondition(contact_by_id, mutation.contact_precondition) + identity_id = self._resolve_identity(mutation.identity_ref, identity_ids) + created_after_snapshot = self._binding_snapshot( + IMBindingId(str(uuidv7())), + identity_id, + mutation.contact_id, + ) + bindings_to_create.append(created_after_snapshot) + binding_ids[(identity_id, mutation.contact_id)] = created_after_snapshot.binding_id + changes.append( + self._binding_change( + plan, + mutation.operation_key, + IMReconciliationOperation.CREATE, + mutation.reason.value, + created_after_snapshot, + before=None, + after=created_after_snapshot, + now=now, + ) + ) + elif isinstance(mutation, ReplaceIMBinding): + self._require_contact_precondition(contact_by_id, mutation.contact_precondition) + identity_id = self._resolve_identity(mutation.next_identity_ref, identity_ids) + replaced_before_snapshot = self._binding_snapshot( + mutation.before.binding_id, + mutation.before.identity_id, + mutation.before.contact_id, + ) + replaced_after_snapshot = self._binding_snapshot( + IMBindingId(str(uuidv7())), + identity_id, + mutation.before.contact_id, + ) + bindings_to_delete.append(replaced_before_snapshot) + bindings_to_create.append(replaced_after_snapshot) + binding_ids[(identity_id, mutation.before.contact_id)] = replaced_after_snapshot.binding_id + changes.append( + self._binding_change( + plan, + mutation.operation_key, + IMReconciliationOperation.REPLACE, + mutation.reason.value, + replaced_after_snapshot, + before=replaced_before_snapshot, + after=replaced_after_snapshot, + now=now, + ) + ) + elif isinstance(mutation, DeleteIMBinding): + deleted_before_snapshot = self._binding_snapshot( + mutation.before.binding_id, + mutation.before.identity_id, + mutation.before.contact_id, + ) + bindings_to_delete.append(deleted_before_snapshot) + changes.append( + self._binding_change( + plan, + mutation.operation_key, + IMReconciliationOperation.DELETE, + mutation.reason.value, + deleted_before_snapshot, + before=deleted_before_snapshot, + after=None, + now=now, + ) + ) + self._persist_binding_changes(session, bindings_to_delete, bindings_to_create, now) + for deletion in plan.identity_deletions: + current_identity = identities.get(deletion.before.identity_id) + if current_identity is None or not self._identity_matches_state(current_identity, deletion.before): + raise _ReconciliationPreconditionError + identities.delete(deletion.before.identity_id) + changes.append( + IMReconciliationChange( + id=IMReconciliationChangeId(str(uuidv7())), + channel_id=IMChannelId(str(self._channel.id)), + sync_run_id=plan.run.sync_run_id, + operation_key=deletion.operation_key, + subject_kind=IMReconciliationSubjectKind.IDENTITY, + operation=IMReconciliationOperation.DELETE, + reason_code=deletion.reason.value, + identity_id=deletion.before.identity_id, + binding_id=None, + contact_id=None, + before=self._identity_snapshot_from_state(deletion.before, self._channel.provider), + after=None, + committed_at=now, + ) + ) + + results = self._materialize_results( + plan, + contacts, + identities, + initial_identities, + identity_ids, + binding_ids, + now, + ) + for result in results: + session.add(sync_result_to_record(result)) + for change in changes: + session.add(reconciliation_change_to_record(change)) + run_record.status = IMSyncRunStatus.SUCCEEDED + run_record.added_count = sum(result.result_type is IMSyncResultType.ADDED for result in results) + run_record.not_matched_count = sum(result.result_type is IMSyncResultType.NOT_MATCHED for result in results) + run_record.failed_count = sum(result.result_type is IMSyncResultType.FAILED for result in results) + run_record.removed_count = sum(result.result_type is IMSyncResultType.REMOVED for result in results) + run_record.skipped_count = sum(result.result_type is IMSyncResultType.SKIPPED for result in results) + run_record.started_at = run_record.started_at or now + run_record.finished_at = now + run_record.updated_at = now + session.flush() + + def _persist_binding_changes( + self, + session: Session, + removed: Sequence[IMBindingChangeSnapshot], + added: Sequence[IMBindingChangeSnapshot], + now: NaiveDatetime, + ) -> None: + # Apply the plan without re-deciding individual assignments. Check the + # captured endpoints and free them before inserting replacement Bindings. + for batch in batched(removed, _BINDING_WRITE_BATCH_SIZE, strict=False): + deleted_ids = set( + session.scalars( + sa.delete(HumanInputIMBinding) + .where( + HumanInputIMBinding.channel_id == str(self._channel.id), + sa.tuple_( + HumanInputIMBinding.id, + HumanInputIMBinding.im_identity_id, + HumanInputIMBinding.contact_id, + ).in_( + [ + (str(binding.binding_id), str(binding.identity_id), str(binding.contact_id)) + for binding in batch + ] + ), + ) + .returning(HumanInputIMBinding.id) + .execution_options(autoflush=False) + ) + ) + if deleted_ids != {str(binding.binding_id) for binding in batch}: + raise _ReconciliationPreconditionError + for batch in batched(added, _BINDING_WRITE_BATCH_SIZE, strict=False): + session.execute( + sa.insert(HumanInputIMBinding), + [ + { + "id": str(binding.binding_id), + "channel_id": str(self._channel.id), + "contact_id": str(binding.contact_id), + "im_identity_id": str(binding.identity_id), + "bound_by_account_id": None, + "created_at": now, + "updated_at": now, + } + for binding in batch + ], + ) + + def _materialize_results( + self, + plan: ReconciliationPlan, + contacts: tuple[Contact, ...], + identities: SQLAlchemyIMIdentityRepository, + initial_identities: dict[IMIdentityId, IMIdentity], + new_identity_ids: dict[NewIMIdentityRef, IMIdentityId], + new_binding_ids: dict[tuple[IMIdentityId, ContactId], IMBindingId], + now: NaiveDatetime, + ) -> tuple[SyncResultFact, ...]: + contacts_by_id = {contact.id: contact for contact in contacts} + upserts_by_provider_user_id = {upsert.entry.provider_user_id: upsert for upsert in plan.identity_upserts} + removal_reason_by_binding_id: dict[IMBindingId, IMSyncRemovalReason] = { + mutation.before.binding_id: mutation.removal_reason + for mutation in plan.binding_mutations + if isinstance(mutation, ReplaceIMBinding | DeleteIMBinding) + } + results: list[SyncResultFact] = [] + for planned in plan.sync_results: + identity_id = ( + self._resolve_identity(planned.identity_ref, new_identity_ids) + if planned.identity_ref is not None + else None + ) + identity = identities.get(identity_id) if identity_id is not None else None + if identity is None and identity_id is not None: + identity = initial_identities.get(identity_id) + contact = contacts_by_id.get(planned.contact_id) if planned.contact_id is not None else None + upsert = ( + upserts_by_provider_user_id.get(planned.provider_user_id) + if planned.provider_user_id is not None + else None + ) + binding_id = planned.binding_id + if binding_id is None and identity_id is not None and planned.contact_id is not None: + binding_id = new_binding_ids.get((identity_id, planned.contact_id)) + results.append( + SyncResultFact( + id=IMSyncResultId(str(uuidv7())), + channel_id=IMChannelId(str(self._channel.id)), + sync_run_id=plan.run.sync_run_id, + operation_key=planned.operation_key, + result_type=planned.result_type, + provider_user_id=str(planned.provider_user_id) if planned.provider_user_id else None, + display_name=upsert.entry.display_name if upsert else None, + email=upsert.entry.email if upsert else None, + normalized_email=_normalized_email(upsert.entry.email) if upsert else None, + contact_id=planned.contact_id, + identity_id=identity_id, + binding_id=binding_id, + removal_reason=( + removal_reason_by_binding_id.get(planned.binding_id) if planned.binding_id is not None else None + ), + reason_code=planned.reason_code, + reason_message=None, + directory_entry_payload=None, + contact_snapshot=( + SyncContactSnapshot( + contact.id, + contact.name, + contact.email, + contact.avatar_file_id, + contact.created_at, + ) + if contact is not None + else None + ), + identity_snapshot=( + SyncIdentitySnapshot( + identity.id, + self._channel.provider, + identity.provider_user_id, + identity.display_name, + identity.email, + ) + if identity is not None + else None + ), + created_at=now, + updated_at=now, + ) + ) + return tuple(results) + + @staticmethod + def _identity_snapshot(identity: IMIdentity, provider: IMProvider) -> IMIdentityChangeSnapshot: + return IMIdentityChangeSnapshot( + identity_id=identity.id, + provider=provider, + provider_user_id=ProviderUserId(identity.provider_user_id), + display_name=identity.display_name, + email=identity.email, + normalized_email=_normalized_email(identity.email), + last_seen_sync_run_id=identity.last_seen_sync_run_id, + ) + + @staticmethod + def _identity_snapshot_from_state( + identity: CurrentIMIdentityState, + provider: IMProvider, + ) -> IMIdentityChangeSnapshot: + return IMIdentityChangeSnapshot( + identity_id=identity.identity_id, + provider=provider, + provider_user_id=identity.provider_user_id, + display_name=identity.display_name, + email=identity.email, + normalized_email=identity.normalized_email, + last_seen_sync_run_id=identity.last_seen_sync_run_id, + ) + + @staticmethod + def _identity_matches_state(identity: IMIdentity, expected: CurrentIMIdentityState) -> bool: + return ( + identity.provider_user_id == expected.provider_user_id + and identity.display_name == expected.display_name + and identity.email == expected.email + and _normalized_email(identity.email) == expected.normalized_email + and identity.last_seen_sync_run_id == expected.last_seen_sync_run_id + ) + + @staticmethod + def _binding_snapshot( + binding_id: IMBindingId, + identity_id: IMIdentityId, + contact_id: ContactId, + ) -> IMBindingChangeSnapshot: + return IMBindingChangeSnapshot(binding_id, identity_id, contact_id) + + @staticmethod + def _binding_change( + plan: ReconciliationPlan, + operation_key: str, + operation: IMReconciliationOperation, + reason_code: str, + subject: IMBindingChangeSnapshot, + *, + before: IMBindingChangeSnapshot | None, + after: IMBindingChangeSnapshot | None, + now: NaiveDatetime, + ) -> IMReconciliationChange: + return IMReconciliationChange( + id=IMReconciliationChangeId(str(uuidv7())), + channel_id=IMChannelId(plan.run.channel_revision.channel_id), + sync_run_id=plan.run.sync_run_id, + operation_key=operation_key, + subject_kind=IMReconciliationSubjectKind.BINDING, + operation=operation, + reason_code=reason_code, + identity_id=subject.identity_id, + binding_id=subject.binding_id, + contact_id=subject.contact_id, + before=before, + after=after, + committed_at=now, + ) + + def _persist_failure(self, sync_run_id: IMSyncRunId, code: str, message: str) -> IMSyncRun: + try: + with self._session_factory() as session, session.begin(): + record = self._lock_active_run(session, sync_run_id) + self._fail_run_record(session, record, code, message, self._clock()) + except _ReconciliationAlreadyTerminalError as terminal: + return terminal.run + return self._require_run(sync_run_id) + + def _fail_run_record( + self, + session: Session, + record: HumanInputIMSyncRun, + code: str, + message: str, + now: NaiveDatetime, + ) -> None: + diagnostic = SyncResultFact( + id=IMSyncResultId(str(uuidv7())), + channel_id=IMChannelId(record.channel_id), + sync_run_id=IMSyncRunId(record.id), + operation_key=f"diagnostic:{code}", + result_type=IMSyncResultType.FAILED, + provider_user_id=None, + display_name=None, + email=None, + normalized_email=None, + contact_id=None, + identity_id=None, + binding_id=None, + removal_reason=None, + reason_code=code, + reason_message=message, + directory_entry_payload=None, + contact_snapshot=None, + identity_snapshot=None, + created_at=now, + updated_at=now, + ) + session.add(sync_result_to_record(diagnostic)) + record.status = IMSyncRunStatus.FAILED + record.failed_count = 1 + record.started_at = record.started_at or now + record.finished_at = now + record.error_code = code + record.error_message = message + record.updated_at = now + session.flush([record]) + + def _require_run(self, sync_run_id: IMSyncRunId) -> IMSyncRun: + with self._session_factory() as session: + record = session.get(HumanInputIMSyncRun, str(sync_run_id)) + if record is None: + raise IMSyncRunNotFoundError("IM synchronization run was not found") + return sync_run_from_record(record) + + def _require_active_run_record(self, session: Session, sync_run_id: IMSyncRunId) -> HumanInputIMSyncRun: + if not self._lock_current_channel(session): + raise _StaleChannelRevisionError + return self._lock_active_run(session, sync_run_id) + + def _lock_current_channel(self, session: Session) -> bool: + result = session.execute( + sa.update(HumanInputIMChannel) + .where( + HumanInputIMChannel.id == str(self._channel.id), + HumanInputIMChannel.config_version == self._channel.config_version, + HumanInputIMChannel.provider == self._channel.provider, + ) + .values({HumanInputIMChannel.updated_at: HumanInputIMChannel.updated_at}) + .execution_options(autoflush=False, synchronize_session=False) + ) + if not isinstance(result, CursorResult): + raise TypeError("conditional IM Channel guard did not return a cursor result") + return result.rowcount == 1 + + def _lock_active_run(self, session: Session, sync_run_id: IMSyncRunId) -> HumanInputIMSyncRun: + predicates = ( + HumanInputIMSyncRun.id == str(sync_run_id), + HumanInputIMSyncRun.channel_id == str(self._channel.id), + HumanInputIMSyncRun.channel_config_version == self._channel.config_version, + HumanInputIMSyncRun.provider == self._channel.provider, + ) + result = session.execute( + sa.update(HumanInputIMSyncRun) + .where( + *predicates, + HumanInputIMSyncRun.status.in_((IMSyncRunStatus.QUEUED, IMSyncRunStatus.RUNNING)), + ) + .values({HumanInputIMSyncRun.updated_at: HumanInputIMSyncRun.updated_at}) + .execution_options(autoflush=False, synchronize_session=False) + ) + if not isinstance(result, CursorResult): + raise TypeError("conditional IM sync-run guard did not return a cursor result") + record = session.scalar(sa.select(HumanInputIMSyncRun).where(*predicates).execution_options(autoflush=False)) + if record is None: + raise IMSyncRunNotFoundError("IM synchronization run was not found") + if result.rowcount == 1: + return record + run = sync_run_from_record(record) + if not run.is_active: + raise _ReconciliationAlreadyTerminalError(run) + raise _ReconciliationPreconditionError("active IM synchronization run could not be guarded") + + def _run_matches_channel(self, run: IMSyncRun) -> bool: + return ( + run.channel_revision.channel_id == str(self._channel.id) + and run.channel_revision.config_version == self._channel.config_version + and run.provider is self._channel.provider + ) + + @staticmethod + def _load_contacts(reader: BoundContactReader) -> tuple[Contact, ...]: + contacts: list[Contact] = [] + page = 1 + while True: + current_page = tuple(reader.list_contacts(page, _CONTACT_PAGE_LIMIT)) + contacts.extend(current_page) + if len(current_page) < _CONTACT_PAGE_LIMIT: + return tuple(contacts) + page += 1 + + @staticmethod + def _contact_states(contacts: tuple[Contact, ...]) -> tuple[ContactEmailMatchState, ...]: + return tuple( + ContactEmailMatchState( + contact.id, + contact.name, + contact.email, + NormalizedEmail(contact.email), + contact.avatar_file_id, + ) + for contact in contacts + if contact.type is not ContactType.EXTERNAL and contact.email is not None + ) + + @staticmethod + def _require_contact_precondition( + contacts_by_id: dict[ContactId, Contact], + expected: ContactEmailMatchState, + ) -> None: + contact = contacts_by_id.get(expected.contact_id) + if ( + contact is None + or contact.type is ContactType.EXTERNAL + or contact.name != expected.display_name + or contact.email != expected.email + or contact.avatar_file_id != expected.avatar_file_id + ): + raise _ReconciliationPreconditionError + + @staticmethod + def _resolve_identity( + identity_ref: ExistingIMIdentityRef | NewIMIdentityRef, + new_identity_ids: dict[NewIMIdentityRef, IMIdentityId], + ) -> IMIdentityId: + if isinstance(identity_ref, ExistingIMIdentityRef): + return identity_ref.identity_id + identity_id = new_identity_ids.get(identity_ref) + if identity_id is None: + raise _ReconciliationPreconditionError + return identity_id + + +def _normalized_email(email: str | None) -> NormalizedEmail | None: + if email is None: + return None + try: + return NormalizedEmail(email) + except ValueError: + return None + + +__all__ = [ + "BoundContactReader", + "BoundContactReaderFactory", + "IMChannelAdapterFactory", + "IMChannelReconciliationService", + "IMSyncRetryableError", +] diff --git a/api/services/human_input_v2/im_contact_sync/errors.py b/api/services/human_input_v2/im_contact_sync/errors.py new file mode 100644 index 00000000000..52adf8dacd6 --- /dev/null +++ b/api/services/human_input_v2/im_contact_sync/errors.py @@ -0,0 +1,8 @@ +"""Transport-neutral application errors for IM Contact synchronization.""" + + +class IMWriteUnavailableError(RuntimeError): + """An Organization-scoped write could not safely acquire or retain serialization.""" + + +__all__ = ["IMWriteUnavailableError"] diff --git a/api/services/human_input_v2/im_contact_sync/reconciliation.py b/api/services/human_input_v2/im_contact_sync/reconciliation.py new file mode 100644 index 00000000000..4af90493a7b --- /dev/null +++ b/api/services/human_input_v2/im_contact_sync/reconciliation.py @@ -0,0 +1,70 @@ +"""IO-free reconciliation input adaptation and plan generation.""" + +from __future__ import annotations + +from core.human_input_v2.im_integration import ( + ContactEmailMatchState, + CurrentIMBindingState, + CurrentIMIdentityState, + IMSyncRun, + PlanGenerationResult, + ReconciliationInput, + ReconciliationRunRef, + SyncReconciler, +) +from core.human_input_v2.im_integration.adapters.entities import DirectoryEntry, ProviderUserId +from core.human_input_v2.shared import NormalizedEmail +from repositories.human_input_v2.im_binding_repository import IMBinding +from repositories.human_input_v2.im_identity_repository import IMIdentity + + +def generate_reconciliation_plan( + run: IMSyncRun, + directory_entries: tuple[DirectoryEntry, ...], + current_identities: tuple[IMIdentity, ...], + current_bindings: tuple[IMBinding, ...], + contacts_for_email_matching: tuple[ContactEmailMatchState, ...], +) -> PlanGenerationResult: + """Compute a plan exclusively from already-loaded immutable values.""" + + identity_states = tuple( + CurrentIMIdentityState( + identity_id=identity.id, + provider_user_id=ProviderUserId(identity.provider_user_id), + display_name=identity.display_name, + email=identity.email, + normalized_email=_normalize_email(identity.email), + last_seen_sync_run_id=identity.last_seen_sync_run_id, + ) + for identity in current_identities + ) + binding_states = tuple( + CurrentIMBindingState( + binding_id=binding.id, + identity_id=binding.identity_id, + contact_id=binding.contact_id, + ) + for binding in current_bindings + ) + return SyncReconciler.generate_plan( + ReconciliationInput( + run=ReconciliationRunRef(run.id, run.channel_revision, run.provider), + directory_entries=directory_entries, + current_identities=identity_states, + current_bindings=binding_states, + reconciled_binding_ids=frozenset(binding.id for binding in current_bindings), + contacts_for_email_matching=contacts_for_email_matching, + ) + ) + + +def _normalize_email(email: str | None) -> NormalizedEmail | None: + if email is None or not email.strip(): + return None + try: + return NormalizedEmail(email) + except ValueError: + return None + + +__all__ = ["generate_reconciliation_plan"] diff --git a/api/services/human_input_v2/im_contact_sync/service.py b/api/services/human_input_v2/im_contact_sync/service.py new file mode 100644 index 00000000000..25207f7423d --- /dev/null +++ b/api/services/human_input_v2/im_contact_sync/service.py @@ -0,0 +1,164 @@ +"""Transport-neutral commands and queries for manual IM directory sync.""" + +from __future__ import annotations + +import logging +from collections.abc import Callable + +from pydantic import NaiveDatetime +from sqlalchemy.orm import Session, sessionmaker + +from core.human_input_v2.entities import IMSyncResultType, IMSyncRunStatus +from core.human_input_v2.im_integration import ( + ActiveRunDecisionKind, + IMSyncRun, + SynchronizedIMIdentityPage, + SyncResultPage, +) +from core.human_input_v2.shared import AccountId, DirectoryScope, IMSyncRunId +from libs.datetime_utils import naive_utc_now +from libs.uuid_utils import uuidv7 +from models.human_input_v2 import HumanInputIMSyncRun +from repositories.human_input_v2.im_channel_repository import IMChannel +from repositories.human_input_v2.im_integration import SQLAlchemyIMControlPlaneRepository +from repositories.human_input_v2.sqlalchemy_im_binding_repository import SQLAlchemyIMBindingRepository +from repositories.human_input_v2.sqlalchemy_im_identity_repository import SQLAlchemyIMIdentityRepository + +logger = logging.getLogger(__name__) + + +class IMChannelNotConfiguredError(Exception): + """No current Channel exists for the requested owner.""" + + +class IMSyncRevisionChangedError(RuntimeError): + """The Channel revision changed while a sync run was being created.""" + + +class IMSyncRunNotFoundError(RuntimeError): + """The current Channel has no persisted synchronization run.""" + + +class IMSyncDispatchUnavailableError(RuntimeError): + """A persisted queued run could not be dispatched for asynchronous execution.""" + + +type IMSyncRunDispatcher = Callable[[IMSyncRunId, DirectoryScope], None] +type IMChannelResolver = Callable[[Session, DirectoryScope], IMChannel | None] + + +class IMSyncService: + """Own Session boundaries around Channel-bound synchronization persistence.""" + + def __init__( + self, + session_factory: sessionmaker[Session], + channel_resolver: IMChannelResolver, + dispatcher: IMSyncRunDispatcher, + *, + clock: Callable[[], NaiveDatetime] = naive_utc_now, + run_id_factory: Callable[[], IMSyncRunId] = lambda: IMSyncRunId(str(uuidv7())), + ) -> None: + self._session_factory = session_factory + self._channel_resolver = channel_resolver + self._dispatcher = dispatcher + self._clock = clock + self._run_id_factory = run_id_factory + + def create_or_get_active_run( + self, + owner_scope: DirectoryScope, + started_by_account_id: AccountId | None, + ) -> IMSyncRun: + with self._session_factory() as session, session.begin(): + channel = self._require_channel(session, owner_scope) + decision = SQLAlchemyIMControlPlaneRepository(session, channel).create_or_get_active_run( + sync_run_id=self._run_id_factory(), + started_by_account_id=started_by_account_id, + now=self._clock(), + ) + if decision.kind is ActiveRunDecisionKind.STALE_REVISION: + raise IMSyncRevisionChangedError("IM Channel revision changed during sync run creation") + if decision.run is None: + raise RuntimeError("active run decision is missing its run") + if decision.run.status is IMSyncRunStatus.QUEUED: + try: + self._dispatcher(decision.run.id, owner_scope) + except Exception as error: + logger.exception("Failed to dispatch IM Contact sync run, sync_run_id=%s", decision.run.id) + raise IMSyncDispatchUnavailableError( + "IM synchronization dispatch is temporarily unavailable" + ) from error + return decision.run + + def get_latest_run(self, owner_scope: DirectoryScope) -> IMSyncRun: + with self._session_factory() as session: + channel = self._require_channel(session, owner_scope) + run = SQLAlchemyIMControlPlaneRepository(session, channel).load_latest_sync_run() + if run is None: + raise IMSyncRunNotFoundError("IM Channel has no synchronization run") + return run + + def list_latest_results( + self, + owner_scope: DirectoryScope, + result_type: IMSyncResultType, + *, + page: int, + limit: int, + ) -> SyncResultPage: + latest_run = self.get_latest_run(owner_scope) + with self._session_factory() as session: + channel = self._require_channel(session, owner_scope) + return SQLAlchemyIMControlPlaneRepository(session, channel).page_sync_results( + latest_run.id, + result_type, + page=page, + limit=limit, + ) + + def search_identities( + self, + owner_scope: DirectoryScope, + *, + keyword: str | None, + page: int, + limit: int, + ) -> SynchronizedIMIdentityPage: + with self._session_factory() as session: + channel = self._require_channel(session, owner_scope) + identities = SQLAlchemyIMIdentityRepository(session, channel.id) + bindings = SQLAlchemyIMBindingRepository(session, channel.id) + return SQLAlchemyIMControlPlaneRepository(session, channel).search_identities( + identities, + bindings, + keyword=keyword, + page=page, + limit=limit, + ) + + def load_run(self, sync_run_id: IMSyncRunId) -> IMSyncRun | None: + with self._session_factory() as session: + record = session.get(HumanInputIMSyncRun, str(sync_run_id)) + if record is None: + return None + from repositories.human_input_v2.im_integration.mappers import sync_run_from_record + + return sync_run_from_record(record) + + def _require_channel(self, session: Session, owner_scope: DirectoryScope) -> IMChannel: + channel = self._channel_resolver(session, owner_scope) + if channel is None: + raise IMChannelNotConfiguredError("Owner has no IM Channel") + return channel + + +__all__ = [ + "IMChannelNotConfiguredError", + "IMChannelResolver", + "IMSyncDispatchUnavailableError", + "IMSyncRevisionChangedError", + "IMSyncRunDispatcher", + "IMSyncRunNotFoundError", + "IMSyncService", +] diff --git a/api/services/human_input_v2/im_contact_sync/worker.py b/api/services/human_input_v2/im_contact_sync/worker.py new file mode 100644 index 00000000000..ce04442cc2b --- /dev/null +++ b/api/services/human_input_v2/im_contact_sync/worker.py @@ -0,0 +1,35 @@ +"""Idempotent durable worker orchestration for IM Contact synchronization.""" + +from __future__ import annotations + +from collections.abc import Callable + +from core.human_input_v2.im_integration import IMSyncRun +from core.human_input_v2.shared import DirectoryScope, IMSyncRunId + +from .coordinator import IMChannelReconciliationService, IMSyncRetryableError +from .service import IMSyncRunNotFoundError, IMSyncService + +type ReconciliationServiceFactory = Callable[[DirectoryScope], IMChannelReconciliationService] + + +class IMContactSyncWorker: + """Resolve owner context before entering Channel-bound reconciliation.""" + + def __init__(self, sync_service: IMSyncService, reconciliation_factory: ReconciliationServiceFactory) -> None: + self._sync_service = sync_service + self._reconciliation_factory = reconciliation_factory + + def execute(self, sync_run_id: IMSyncRunId, owner_scope: DirectoryScope) -> IMSyncRun: + persisted_run = self._sync_service.load_run(sync_run_id) + if persisted_run is None: + raise IMSyncRunNotFoundError("IM synchronization run was not found") + if not persisted_run.is_active: + return persisted_run + terminal_run = self._reconciliation_factory(owner_scope).reconcile(sync_run_id) + if terminal_run.is_active: + raise IMSyncRetryableError("IM synchronization did not reach a persisted terminal state") + return terminal_run + + +__all__ = ["IMContactSyncWorker", "ReconciliationServiceFactory"] diff --git a/api/services/human_input_v2/im_credential_codec.py b/api/services/human_input_v2/im_credential_codec.py new file mode 100644 index 00000000000..622d12f9cc9 --- /dev/null +++ b/api/services/human_input_v2/im_credential_codec.py @@ -0,0 +1,46 @@ +from base64 import b64decode, b64encode +from typing import Protocol + +from core.human_input_v2.entities import IMProvider +from core.human_input_v2.im_integration.adapters.credentials import ( + IMProviderCredentials, + IMProviderCredentialsAdapter, +) +from repositories.human_input_v2.channel_credentials import ChannelEncryptedCredentials + +_SAFE_ERROR = "IM credential configuration is unavailable" + + +class BoundCredentialCipher(Protocol): + """A credential cipher bounded to a tenant or the whole deployment.""" + + def encrypt(self, plaintext: str) -> bytes: ... + def decrypt(self, ciphertext: bytes) -> str: ... + + +class IMCredentialError(ValueError): ... + + +class IMCredentialCodec: + def __init__(self, cipher: BoundCredentialCipher) -> None: + self._cipher = cipher + + def seal(self, credentials: IMProviderCredentials) -> ChannelEncryptedCredentials: + encrypted = self._cipher.encrypt(credentials.model_dump_json()) + return ChannelEncryptedCredentials(ciphertext=b64encode(encrypted).decode()) + + def load( + self, + provider: IMProvider, + envelope: ChannelEncryptedCredentials, + ) -> IMProviderCredentials: + if envelope.version != 1: + raise IMCredentialError(_SAFE_ERROR) + try: + decrypted = self._cipher.decrypt(b64decode(envelope.ciphertext)) + credentials = IMProviderCredentialsAdapter.validate_json(decrypted) + except Exception as exc: + raise IMCredentialError(_SAFE_ERROR) from exc + if credentials.provider is not provider: + raise IMCredentialError(_SAFE_ERROR) + return credentials diff --git a/api/services/human_input_v2/im_message_inbox/__init__.py b/api/services/human_input_v2/im_message_inbox/__init__.py new file mode 100644 index 00000000000..1d935ecd896 --- /dev/null +++ b/api/services/human_input_v2/im_message_inbox/__init__.py @@ -0,0 +1 @@ +"""Application services for durable IM message intake and processing.""" diff --git a/api/services/human_input_v2/im_message_inbox/processor.py b/api/services/human_input_v2/im_message_inbox/processor.py new file mode 100644 index 00000000000..25d9ff53df3 --- /dev/null +++ b/api/services/human_input_v2/im_message_inbox/processor.py @@ -0,0 +1,37 @@ +"""Process one durable IM callback outside repository transactions.""" + +from __future__ import annotations + +import logging +from collections.abc import Callable +from datetime import datetime + +from core.human_input_v2.im_message_inbox import IMInboxConsumer, IMInboxRecordId, IMMessageInboxRepository + +logger = logging.getLogger(__name__) + + +class IMInboxProcessor: + """Load one callback, invoke its consumer, and record successful processing.""" + + def __init__( + self, + *, + repository: IMMessageInboxRepository, + consumer: IMInboxConsumer, + clock: Callable[[], datetime], + ) -> None: + self._repository = repository + self._consumer = consumer + self._clock = clock + + def process(self, record_id: IMInboxRecordId) -> None: + record = self._repository.get(record_id) + if record is None: + logger.warning("IM callback record does not exist record_id=%s", record_id) + return + if record.processed_at is not None: + return + + self._consumer.consume(record) + self._repository.mark_processed(record_id, processed_at=self._clock()) diff --git a/api/services/human_input_v2/im_message_inbox/sink.py b/api/services/human_input_v2/im_message_inbox/sink.py new file mode 100644 index 00000000000..adbfb691997 --- /dev/null +++ b/api/services/human_input_v2/im_message_inbox/sink.py @@ -0,0 +1,127 @@ +"""Persist authenticated callbacks and enqueue their Celery processing task. + +The database commit completes before task publication. Transport acceptance +requires both operations; an identified Provider redelivery resolves the same +record and attempts publication again. Failure logs omit exception details +because they may contain callback payloads or connection credentials. +""" + +from __future__ import annotations + +import logging +from collections.abc import Callable +from datetime import datetime + +from core.human_input_v2.entities import IMProvider +from core.human_input_v2.im_integration.adapters.entities import AuthenticatedIMEvent, EventAcceptance +from core.human_input_v2.im_message_inbox import ( + AcceptanceKind, + IMMessageInboxRepository, + InboxPersistenceError, + canonicalize_inbox_event, + validate_inbox_event, + validate_inbox_provider_tenant_id, +) +from repositories.human_input_v2.im_channel_repository import IMChannelId +from tasks.im_message_inbox_tasks import process_im_message_inbox_record + +from .telemetry import IMInboxMetricKind, IMInboxMetrics + +logger = logging.getLogger(__name__) + + +class IMMessageInboxSink: + """Concrete durable sink bound to one logical local Channel.""" + + _channel_id: IMChannelId + _expected_provider: IMProvider + _expected_provider_tenant_id: str + _repository: IMMessageInboxRepository + _clock: Callable[[], datetime] + _metrics: IMInboxMetrics + + def __init__( + self, + *, + channel_id: IMChannelId, + expected_provider: IMProvider, + expected_provider_tenant_id: str, + repository: IMMessageInboxRepository, + clock: Callable[[], datetime], + metrics: IMInboxMetrics, + ) -> None: + if not expected_provider_tenant_id.strip(): + raise ValueError("expected provider tenant id must not be blank") + validate_inbox_provider_tenant_id(expected_provider_tenant_id) + self._channel_id = channel_id + self._expected_provider = expected_provider + self._expected_provider_tenant_id = expected_provider_tenant_id + self._repository = repository + self._clock = clock + self._metrics = metrics + + def accept(self, event: AuthenticatedIMEvent, *, request_ip_address: str | None = None) -> EventAcceptance: + """Commit or resolve the event before returning transport acceptance.""" + + event = canonicalize_inbox_event(event) + validate_inbox_event(event) + if ( + event.provider is not self._expected_provider + or event.provider_tenant_id != self._expected_provider_tenant_id + ): + self._metrics.record( + IMInboxMetricKind.ACCEPTANCE_FAILURE, + provider=event.provider, + outcome="identity_conflict", + ) + logger.warning( + "Rejected IM event with conflicting bound identity channel_id=%s expected_provider=%s", + self._channel_id, + self._expected_provider.value, + ) + return EventAcceptance.NOT_ACCEPTED + try: + accepted = self._repository.insert_or_resolve( + self._channel_id, + event, + now=self._clock(), + request_ip_address=request_ip_address, + ) + except InboxPersistenceError: + self._metrics.record( + IMInboxMetricKind.ACCEPTANCE_FAILURE, + provider=event.provider, + outcome="persistence_failure", + ) + logger.warning( + "Failed durable IM event acceptance channel_id=%s provider=%s error_code=persistence_failure", + self._channel_id, + event.provider.value, + ) + return EventAcceptance.NOT_ACCEPTED + + self._metrics.record( + IMInboxMetricKind.ACCEPTANCE, + provider=event.provider, + outcome=accepted.kind.value, + ) + if accepted.kind is AcceptanceKind.DUPLICATE: + self._metrics.record(IMInboxMetricKind.DUPLICATE, provider=event.provider) + + try: + process_im_message_inbox_record.apply_async(args=(str(accepted.record_id),), retry=False) + except Exception: + self._metrics.record( + IMInboxMetricKind.DISPATCH_FAILURE, + provider=event.provider, + outcome="broker_unavailable", + ) + logger.warning( + "Failed post-commit IM callback task publication record_id=%s channel_id=%s provider=%s " + "error_code=broker_unavailable", + accepted.record_id, + self._channel_id, + event.provider.value, + ) + return EventAcceptance.NOT_ACCEPTED + return EventAcceptance.ACCEPTED diff --git a/api/services/human_input_v2/im_message_inbox/storage.py b/api/services/human_input_v2/im_message_inbox/storage.py new file mode 100644 index 00000000000..3ec030f975d --- /dev/null +++ b/api/services/human_input_v2/im_message_inbox/storage.py @@ -0,0 +1,72 @@ +"""Own short inbox transactions around the persistence repository.""" + +from datetime import datetime + +from sqlalchemy.exc import IntegrityError, SQLAlchemyError +from sqlalchemy.orm import Session, sessionmaker + +from core.human_input_v2.im_integration.adapters.entities import AuthenticatedIMEvent +from core.human_input_v2.im_message_inbox.contracts import ( + IMInboxRecord, + IMInboxRecordId, + InboxAcceptance, + InboxPersistenceError, +) +from repositories.human_input_v2.im_channel_repository import IMChannelId +from repositories.human_input_v2.im_message_inbox.repository import SQLAlchemyIMMessageInboxRepository + + +class IMInboxStorage: + """Commit intake before transport ACK, with no Provider I/O in transactions.""" + + def __init__(self, session_factory: sessionmaker[Session]) -> None: + self._session_factory = session_factory + + def insert_or_resolve( + self, + channel_id: IMChannelId, + event: AuthenticatedIMEvent, + *, + now: datetime, + request_ip_address: str | None = None, + ) -> InboxAcceptance: + try: + return self._insert(channel_id, event, now=now, request_ip_address=request_ip_address) + except IntegrityError as error: + if event.event_id is None: + raise InboxPersistenceError("failed to persist unidentified IM callback") from error + # A competing intake may insert after the initial lookup. Its commit + # is visible only after rolling back our failed transaction. + try: + return self._insert(channel_id, event, now=now, request_ip_address=request_ip_address) + except SQLAlchemyError as retry_error: + raise InboxPersistenceError("failed to resolve duplicate IM callback") from retry_error + except SQLAlchemyError as error: + raise InboxPersistenceError("failed to persist IM callback") from error + + def _insert( + self, + channel_id: IMChannelId, + event: AuthenticatedIMEvent, + *, + now: datetime, + request_ip_address: str | None, + ) -> InboxAcceptance: + with self._session_factory() as session, session.begin(): + return SQLAlchemyIMMessageInboxRepository(session).insert_or_resolve( + channel_id, event, now=now, request_ip_address=request_ip_address + ) + + def get(self, record_id: IMInboxRecordId) -> IMInboxRecord | None: + try: + with self._session_factory() as session: + return SQLAlchemyIMMessageInboxRepository(session).get(record_id) + except SQLAlchemyError as error: + raise InboxPersistenceError("failed to load IM callback") from error + + def mark_processed(self, record_id: IMInboxRecordId, *, processed_at: datetime) -> None: + try: + with self._session_factory() as session, session.begin(): + SQLAlchemyIMMessageInboxRepository(session).mark_processed(record_id, processed_at=processed_at) + except SQLAlchemyError as error: + raise InboxPersistenceError("failed to mark IM callback as processed") from error diff --git a/api/services/human_input_v2/im_message_inbox/telemetry.py b/api/services/human_input_v2/im_message_inbox/telemetry.py new file mode 100644 index 00000000000..5cc93290962 --- /dev/null +++ b/api/services/human_input_v2/im_message_inbox/telemetry.py @@ -0,0 +1,86 @@ +"""Low-cardinality, payload-free observability for durable IM callback intake.""" + +from __future__ import annotations + +import logging +from enum import StrEnum +from typing import TYPE_CHECKING, Protocol + +from configs import dify_config +from core.human_input_v2.entities import IMProvider + +if TYPE_CHECKING: + from opentelemetry.metrics import Counter + +logger = logging.getLogger(__name__) + + +class IMInboxMetricKind(StrEnum): + """Stable event dimensions emitted during callback intake.""" + + ACCEPTANCE = "acceptance" + DUPLICATE = "duplicate" + ACCEPTANCE_FAILURE = "acceptance_failure" + DISPATCH_FAILURE = "dispatch_failure" + + +class IMInboxMetrics(Protocol): + """Metrics port that cannot receive record payloads or credentials.""" + + def record( + self, + kind: IMInboxMetricKind, + *, + provider: IMProvider | None, + outcome: str | None = None, + ) -> None: + """Increment one low-cardinality intake metric.""" + + +class NoopIMInboxMetrics: + """Explicit metrics dependency for deployments that disable telemetry.""" + + def record( + self, + kind: IMInboxMetricKind, + *, + provider: IMProvider | None, + outcome: str | None = None, + ) -> None: + return None + + +class OpenTelemetryIMInboxMetrics: + """OpenTelemetry adapter with stable, low-cardinality dimensions.""" + + _events: Counter | None + + def __init__(self) -> None: + self._events = None + if not dify_config.ENABLE_OTEL: + return + try: + from opentelemetry.metrics import get_meter + + meter = get_meter("im_message_inbox", version=dify_config.project.version) + self._events = meter.create_counter( + "im_message_inbox_events_total", + description="Durable IM callback intake events.", + unit="{event}", + ) + except Exception: + logger.exception("Failed to initialize IM inbox metrics") + + def record( + self, + kind: IMInboxMetricKind, + *, + provider: IMProvider | None, + outcome: str | None = None, + ) -> None: + if self._events is None: + return + attributes = {"kind": kind.value, "provider": provider.value if provider is not None else "unknown"} + if outcome is not None: + attributes["outcome"] = outcome + self._events.add(1, attributes) diff --git a/api/services/human_input_v2/im_tenant_credential_cipher.py b/api/services/human_input_v2/im_tenant_credential_cipher.py new file mode 100644 index 00000000000..70ae714c3a2 --- /dev/null +++ b/api/services/human_input_v2/im_tenant_credential_cipher.py @@ -0,0 +1,18 @@ +"""Tenant-bound IM credential encryption.""" + +from libs.key_providers.base import BaseKeyProvider + + +class TenantBoundCredentialCipher: + def __init__(self, key_provider: BaseKeyProvider, tenant_id: str) -> None: + self._key_provider = key_provider + self._tenant_id = tenant_id + + def encrypt(self, plaintext: str) -> bytes: + return self._key_provider.encrypt(self._tenant_id, plaintext) + + def decrypt(self, ciphertext: bytes) -> str: + return self._key_provider.decrypt(self._tenant_id, ciphertext) + + +__all__ = ["TenantBoundCredentialCipher"] diff --git a/api/services/human_input_v2/im_webhook_runtime.py b/api/services/human_input_v2/im_webhook_runtime.py new file mode 100644 index 00000000000..2aaf6fdc20b --- /dev/null +++ b/api/services/human_input_v2/im_webhook_runtime.py @@ -0,0 +1,178 @@ +"""Compose authenticated IM intake and durable card approval processing.""" + +import logging +from datetime import UTC, datetime +from traceback import extract_tb + +from pydantic import ValidationError +from sqlalchemy.exc import SQLAlchemyError +from sqlalchemy.orm import Session, sessionmaker + +from core.db.session_factory import session_factory +from core.human_input_v2.entities import IMProvider +from core.human_input_v2.im_integration.adapters.entities import ( + IMCardEventDecodingError, + UnrecognizedIMEvent, + WebhookRequest, + WebhookResponse, +) +from core.human_input_v2.im_integration.adapters.factory import build_im_provider_adapter +from core.human_input_v2.im_integration.adapters.protocols import IMCardEventDecoder +from core.human_input_v2.im_message_inbox.contracts import IMInboxRecord +from core.human_input_v2.shared import WorkspaceScope +from dify_app import DifyApp +from extensions.ext_key_provider import key_provider_manager +from libs.key_providers.base import BaseKeyProvider +from repositories.human_input_v2.im_channel_repository import IMChannelId, WebhookId +from repositories.human_input_v2.sqlalchemy_im_channel_repository import SQLAlchemyIMWebhookChannelRepository +from services.human_input_v2.im_credential_codec import IMCredentialCodec, IMCredentialError +from services.human_input_v2.im_message_inbox.processor import IMInboxProcessor +from services.human_input_v2.im_message_inbox.sink import IMMessageInboxSink +from services.human_input_v2.im_message_inbox.storage import IMInboxStorage +from services.human_input_v2.im_message_inbox.telemetry import OpenTelemetryIMInboxMetrics +from services.human_input_v2.im_tenant_credential_cipher import TenantBoundCredentialCipher +from services.human_input_v2.submission_service import ( + HumanInputFormError, + HumanInputFormErrorCode, + HumanInputSubmissionService, + IMFormActor, +) +from tasks.im_message_inbox_tasks import configure_im_inbox_task_runtime + +logger = logging.getLogger(__name__) + + +class IMWebhookIngressService: + """Resolve current routing before recovering Provider credentials.""" + + def __init__(self, *, sessions: sessionmaker[Session], key_provider: BaseKeyProvider) -> None: + self._sessions = sessions + self._key_provider = key_provider + + def handle(self, webhook_id: WebhookId, request: WebhookRequest) -> WebhookResponse: + try: + with self._sessions() as session: + route = SQLAlchemyIMWebhookChannelRepository(session).find_by_webhook_id(webhook_id) + if route is None: + return WebhookResponse(404, (), b"") + if not isinstance(route.scope, WorkspaceScope): + # Deployment credential provisioning has an independent owner. + # Never decrypt a deployment envelope with a workspace key. + raise IMCredentialError("Deployment IM credentials are unavailable") + cipher = TenantBoundCredentialCipher(self._key_provider, str(route.scope.id)) + credentials = IMCredentialCodec(cipher).load(route.channel.provider, route.channel.encrypted_credentials) + adapter = build_im_provider_adapter(credentials) + try: + sink = IMMessageInboxSink( + channel_id=IMChannelId(str(route.channel.id)), + expected_provider=route.channel.provider, + expected_provider_tenant_id=route.channel.provider_tenant_id, + repository=IMInboxStorage(self._sessions), + clock=lambda: datetime.now(UTC), + metrics=OpenTelemetryIMInboxMetrics(), + ) + handler = adapter.create_webhook_handler(sink) + if handler is None: + return WebhookResponse(404, (), b"") + return handler.handle(request) + finally: + adapter.close() + except (SQLAlchemyError, ValidationError, IMCredentialError, OSError) as error: + # SDK and database exception text can retain credentials or callback + # values. Log the type and source frames without messages or locals. + logger.exception( + "IM webhook unavailable webhook_id=%s error_type=%s\n%s", + webhook_id, + type(error).__name__, + "".join( + f' File "{frame.filename}", line {frame.lineno}, in {frame.name}\n' + for frame in extract_tb(error.__traceback__) + ), + exc_info=False, + ) + return WebhookResponse(503, (), b"") + + +def build_im_webhook_ingress_service() -> IMWebhookIngressService: + return IMWebhookIngressService( + sessions=session_factory.get_session_maker(), + key_provider=key_provider_manager.provider, + ) + + +def _card_decoder(provider: IMProvider) -> IMCardEventDecoder | None: + from core.human_input_v2.im_integration.adapters.dingtalk import DingTalkIMProviderAdapter + from core.human_input_v2.im_integration.adapters.feishu_lark import FeishuIMProviderAdapter, LarkIMProviderAdapter + from core.human_input_v2.im_integration.adapters.ms_teams import MSTeamsIMProviderAdapter + from core.human_input_v2.im_integration.adapters.slack import SlackIMProviderAdapter + from core.human_input_v2.im_integration.adapters.wecom import WeComIMProviderAdapter + + match provider: + case IMProvider.SLACK: + return SlackIMProviderAdapter.card_event_decoder() + case IMProvider.FEISHU: + return FeishuIMProviderAdapter.card_event_decoder() + case IMProvider.LARK: + return LarkIMProviderAdapter.card_event_decoder() + case IMProvider.MS_TEAMS: + return MSTeamsIMProviderAdapter.card_event_decoder() + case IMProvider.DING_TALK: + return DingTalkIMProviderAdapter.card_event_decoder() + case IMProvider.WE_COM: + return WeComIMProviderAdapter.card_event_decoder() + + +class IMCardSubmissionConsumer: + """Authorize the saved delivery against the authenticated Provider actor.""" + + def __init__(self, *, sessions: sessionmaker[Session]) -> None: + self._submission_service = HumanInputSubmissionService(session_factory=sessions) + + def consume(self, record: IMInboxRecord) -> None: + decoder = _card_decoder(record.event.provider) + if decoder is None: + return + try: + card = decoder.decode(record.event) + except IMCardEventDecodingError: + logger.warning("Rejected IM card record_id=%s reason=invalid_card_payload", record.record_id) + return + if isinstance(card, UnrecognizedIMEvent): + return + actor = IMFormActor( + channel_id=IMChannelId(str(record.channel_id)), + provider=record.event.provider, + provider_tenant_id=record.event.provider_tenant_id, + provider_user_id=card.provider_user_id, + ) + try: + self._submission_service.submit_im_form( + actor=actor, + form_token=card.correlation_token, + action=card.action_id, + inputs=card.inputs, + ip_address=record.request_ip_address, + ) + except HumanInputFormError as error: + if error.code is HumanInputFormErrorCode.WORKFLOW_NOT_ACTIVE: + # A card can be clicked before the pause checkpoint commits. + # Let the existing Celery retry policy wait for that checkpoint. + raise + logger.warning( + "Rejected IM approval record_id=%s channel_id=%s reason=%s", + record.record_id, + actor.channel_id, + error.code.value, + ) + + +def configure_im_webhook_runtime(app: DifyApp) -> None: + def processor_factory() -> IMInboxProcessor: + sessions = session_factory.get_session_maker() + return IMInboxProcessor( + repository=IMInboxStorage(sessions), + consumer=IMCardSubmissionConsumer(sessions=sessions), + clock=lambda: datetime.now(UTC), + ) + + configure_im_inbox_task_runtime(app, processor_factory=processor_factory) diff --git a/api/services/human_input_v2/message_template_test_composition.py b/api/services/human_input_v2/message_template_test_composition.py new file mode 100644 index 00000000000..e6d241e0023 --- /dev/null +++ b/api/services/human_input_v2/message_template_test_composition.py @@ -0,0 +1,48 @@ +"""Workspace-scoped dependencies for one editor's message-template test.""" + +from sqlalchemy.orm import Session, sessionmaker + +from core.human_input_v2.im_integration.adapters.factory import build_im_provider_adapter +from core.human_input_v2.im_integration.adapters.protocols import IMProviderAdapter +from core.human_input_v2.shared.values import AccountId, TenantId +from extensions.ext_database import db +from extensions.ext_key_provider import key_provider_manager +from graphon.variable_loader import VariableLoader +from repositories.human_input_v2.email_channel.repository import SQLAlchemyEmailChannelRepository +from repositories.human_input_v2.im_channel_repository import IMChannel +from services.human_input_v2.form_service import HumanInputDeliveryTestFormService +from services.human_input_v2.im_credential_codec import IMCredentialCodec +from services.human_input_v2.im_tenant_credential_cipher import TenantBoundCredentialCipher +from services.human_input_v2.message_template_test_service import MessageTemplateTestService +from services.human_input_v2.resend_channel import ResendProviderGateway +from services.workflow_draft_variable_service import DraftVarLoader + + +def build_message_template_test_service( + *, tenant_id: str, account_id: str, account_email: str +) -> MessageTemplateTestService: + sessions = sessionmaker[Session](bind=db.engine, expire_on_commit=False) + + def build_adapter(channel: IMChannel) -> IMProviderAdapter: + cipher = TenantBoundCredentialCipher(key_provider_manager.provider, tenant_id) + credentials = IMCredentialCodec(cipher).load(channel.provider, channel.encrypted_credentials) + return build_im_provider_adapter(credentials) + + def build_variable_loader(app_id: str) -> VariableLoader: + return DraftVarLoader(engine=db.engine, app_id=app_id, tenant_id=tenant_id, user_id=account_id) + + return MessageTemplateTestService( + session_factory=sessions, + tenant_id=TenantId(tenant_id), + account_id=AccountId(account_id), + account_email=account_email, + email_repository_factory=SQLAlchemyEmailChannelRepository, + email_gateway=ResendProviderGateway(), + im_adapter_factory=build_adapter, + form_service=HumanInputDeliveryTestFormService( + session_factory=sessions, + tenant_id=TenantId(tenant_id), + account_id=AccountId(account_id), + variable_loader_factory=build_variable_loader, + ), + ) diff --git a/api/services/human_input_v2/message_template_test_service.py b/api/services/human_input_v2/message_template_test_service.py new file mode 100644 index 00000000000..775ebc578fe --- /dev/null +++ b/api/services/human_input_v2/message_template_test_service.py @@ -0,0 +1,231 @@ +"""Render a saved draft template and send it only to its authenticated editor.""" + +from __future__ import annotations + +from collections.abc import Callable, Mapping +from dataclasses import dataclass, field + +from pydantic import JsonValue, ValidationError +from sqlalchemy import select +from sqlalchemy.orm import Session + +from core.human_input_v2.entities import DebugChannel +from core.human_input_v2.im_integration.adapters.entities import MessageSendingError, ProviderUserId +from core.human_input_v2.im_integration.adapters.protocols import IMProviderAdapter +from core.human_input_v2.shared.values import AccountId, IMBindingId, TenantId +from core.workflow.human_input_adapter import EmailDeliveryConfig +from core.workflow.nodes.human_input_v2.entities import HumanInputNodeData, MessageTemplateConfig +from graphon.runtime import VariablePool +from graphon.runtime.runtime_state_protocol import ReadOnlyVariablePool +from graphon.variables.template_resolution import VARIABLE_PATTERN, convert_template +from models.workflow import NodeNotFoundError, Workflow, WorkflowDataError +from repositories.human_input_v2.delivery_repository import EmailTargetSnapshot, IMMessageType, IMUserTargetSnapshot +from repositories.human_input_v2.email_channel.entities import ResendCandidate +from repositories.human_input_v2.email_channel.ports import ( + EmailChannelRepository, + EmailProviderOperationError, + EmailProviderValidationError, +) +from repositories.human_input_v2.im_channel_repository import IMChannel +from repositories.human_input_v2.sqlalchemy_contact_repository import SQLAlchemyContactRepository +from repositories.human_input_v2.sqlalchemy_im_binding_repository import SQLAlchemyIMBindingRepository +from repositories.human_input_v2.sqlalchemy_im_channel_repository import WorkspaceIMChannelReader +from repositories.human_input_v2.sqlalchemy_im_identity_repository import SQLAlchemyIMIdentityRepository +from services.human_input_v2.email_credential_codec import load_email_credentials +from services.human_input_v2.form_service import HumanInputDeliveryTestFormService, HumanInputTestFormError +from services.human_input_v2.im_credential_codec import IMCredentialError +from services.human_input_v2.resend_channel import ResendProviderGateway + + +class MessageTemplateTestError(Exception): + """A safe, actionable error returned to the editor without sending.""" + + +class MessageTemplateSendError(MessageTemplateTestError): + """The selected provider did not confirm acceptance of the test message.""" + + +@dataclass(frozen=True, slots=True) +class _RenderedTemplate: + subject: str = field(repr=False) + body: str = field(repr=False) + + +@dataclass(frozen=True, slots=True) +class _EmailDestination: + candidate: ResendCandidate + target: EmailTargetSnapshot + + +@dataclass(frozen=True, slots=True) +class _IMDestination: + adapter: IMProviderAdapter + user_id: ProviderUserId + target: IMUserTargetSnapshot + + +class MessageTemplateTestService: + def __init__( + self, + *, + session_factory: Callable[[], Session], + tenant_id: TenantId, + account_id: AccountId, + account_email: str, + email_repository_factory: Callable[[Session], EmailChannelRepository], + email_gateway: ResendProviderGateway, + im_adapter_factory: Callable[[IMChannel], IMProviderAdapter], + form_service: HumanInputDeliveryTestFormService, + ) -> None: + self._sessions = session_factory + self._tenant_id = tenant_id + self._account_id = account_id + self._account_email = account_email + self._email_repositories = email_repository_factory + self._email_gateway = email_gateway + self._im_adapter_factory = im_adapter_factory + self._forms = form_service + + def send_test( + self, + *, + app_id: str, + node_id: str, + channel: DebugChannel, + inputs: Mapping[str, JsonValue], + ) -> None: + node, pool = self._load_draft(app_id, node_id, inputs) + preview = self._render(node.message_template, pool, EmailDeliveryConfig.URL_PLACEHOLDER) + destination = self._resolve_destination(channel, preview) + try: + request_url = self._forms.create_form( + app_id=app_id, + node_id=node_id, + node_data=node, + variable_pool=pool, + target=destination.target, + ) + template = self._render(node.message_template, pool, request_url) + match destination: + case _EmailDestination(candidate, _): + self._send_email(candidate, template) + case _IMDestination(adapter, user_id, _): + result = adapter.messaging.send_text(user_id, template.body, subject=template.subject) + if isinstance(result, MessageSendingError): + raise MessageTemplateSendError("The IM provider did not accept the test message.") + except HumanInputTestFormError as exc: + raise MessageTemplateTestError(str(exc)) from exc + finally: + if isinstance(destination, _IMDestination): + destination.adapter.close() + + def _load_draft( + self, app_id: str, node_id: str, inputs: Mapping[str, JsonValue] + ) -> tuple[HumanInputNodeData, VariablePool]: + with self._sessions() as session: + workflow = session.scalar( + select(Workflow).where( + Workflow.tenant_id == str(self._tenant_id), + Workflow.app_id == app_id, + Workflow.version == Workflow.VERSION_DRAFT, + ) + ) + if workflow is None: + raise MessageTemplateTestError("Workflow draft was not found.") + try: + node_config = workflow.get_node_config_by_id(node_id) + node = HumanInputNodeData.model_validate(node_config["data"], from_attributes=True) + except (NodeNotFoundError, WorkflowDataError, ValidationError): + raise MessageTemplateTestError( + "A Human Input v2 node with a valid message template is required." + ) from None + pool = VariablePool.from_bootstrap(environment_variables=workflow.environment_variables) + + template = node.message_template + for match in VARIABLE_PATTERN.finditer(f"{template.subject}\n{template.body}"): + reference = match.group(1) + selector = reference.split(".") + # Environment values come from the saved workflow, never request overrides. + if selector[0] != "env": + key = f"#{reference}#" + if key not in inputs: + raise MessageTemplateTestError(f"A test value is required for {reference}.") + pool.add(selector, inputs[key]) + if pool.get(selector) is None: + raise MessageTemplateTestError(f"The template variable {reference} is unavailable.") + + return node, pool + + @staticmethod + def _render(template: MessageTemplateConfig, pool: ReadOnlyVariablePool, request_url: str) -> _RenderedTemplate: + # Substitute the reserved URL before variables so input values remain literal. + subject = convert_template(pool, template.subject.replace("{{#url#}}", request_url)).text + body = convert_template(pool, template.body.replace("{{#url#}}", request_url)).text + subject = EmailDeliveryConfig.sanitize_subject(subject) + if not subject.strip() or not body.strip(): + raise MessageTemplateTestError("Message subject and body must not be blank.") + return _RenderedTemplate(subject, body) + + def _resolve_destination( + self, channel: DebugChannel, preview: _RenderedTemplate + ) -> _EmailDestination | _IMDestination: + if channel is DebugChannel.EMAIL: + if not EmailDeliveryConfig.render_markdown_body(preview.body).strip(): + raise MessageTemplateTestError("The rendered email body must not be blank.") + with self._sessions() as session: + configuration = self._email_repositories(session).load(self._tenant_id) + if configuration is None: + raise MessageTemplateTestError("An Email channel must be configured in this workspace.") + if not self._account_email: + raise MessageTemplateTestError("The current editor has no email address.") + candidate = ResendCandidate( + sender_email=configuration.sender_email, + sender_name=configuration.sender_name, + credentials=load_email_credentials(configuration.tenant_id, configuration.encrypted_credentials), + ) + return _EmailDestination(candidate, EmailTargetSnapshot(email_address=self._account_email)) + + im_channel, binding_id, user_id = self._load_im_destination(channel) + try: + adapter = self._im_adapter_factory(im_channel) + except IMCredentialError: + raise MessageTemplateTestError("The selected IM channel's credentials are unavailable.") from None + return _IMDestination( + adapter, + user_id, + IMUserTargetSnapshot( + im_provider=im_channel.provider, + im_tenant_id=im_channel.provider_tenant_id, + im_provider_user_id=user_id, + im_binding_id=binding_id, + message_type=IMMessageType.LINK, + ), + ) + + def _send_email(self, candidate: ResendCandidate, template: _RenderedTemplate) -> None: + html = EmailDeliveryConfig.render_markdown_body(template.body) + try: + self._email_gateway.send_message( + candidate, + to=self._account_email, + subject=template.subject, + html=html, + ) + except (EmailProviderOperationError, EmailProviderValidationError): + raise MessageTemplateSendError("The Email provider did not accept the test message.") from None + + def _load_im_destination(self, channel: DebugChannel) -> tuple[IMChannel, IMBindingId, ProviderUserId]: + with self._sessions() as session: + im_channel = WorkspaceIMChannelReader(session, self._tenant_id).get() + if im_channel is None or im_channel.provider.value != channel.value: + raise MessageTemplateTestError("The selected IM channel is not configured in this workspace.") + contact = SQLAlchemyContactRepository(session).get_contact_by_account_id(self._tenant_id, self._account_id) + if contact is None: + raise MessageTemplateTestError("The current editor has no available Contact in this workspace.") + binding = SQLAlchemyIMBindingRepository(session, im_channel.id).get_effective(self._tenant_id, contact.id) + if binding is None: + raise MessageTemplateTestError("The current editor has no binding for the selected IM channel.") + identity = SQLAlchemyIMIdentityRepository(session, im_channel.id).get(binding.identity_id) + if identity is None: + raise MessageTemplateTestError("The current editor's IM identity is no longer available.") + return im_channel, binding.id, ProviderUserId(identity.provider_user_id) diff --git a/api/services/human_input_v2/node_data_migration.py b/api/services/human_input_v2/node_data_migration.py new file mode 100644 index 00000000000..0419c02628e --- /dev/null +++ b/api/services/human_input_v2/node_data_migration.py @@ -0,0 +1,198 @@ +"""Application orchestration for side-effect-free Human Input node migration.""" + +from __future__ import annotations + +from collections.abc import Callable, Sequence +from dataclasses import dataclass +from typing import Protocol + +from core.human_input_v2.shared import TenantId +from core.workflow.human_input_adapter import EmailDeliveryMethod, MemberRecipient +from core.workflow.nodes.human_input.entities import HumanInputNodeDataFull +from core.workflow.nodes.human_input_v2.entities import HumanInputNodeData +from core.workflow.nodes.human_input_v2.migration import ( + LegacyDeliveryParseIssue, + LegacyNodeDataPreflight, + MemberEmailSnapshot, + MigrationBlockerCode, + NodeMigrationConversion, + convert_legacy_human_input_node_data, +) + + +class WorkspaceMemberEmailLookup(Protocol): + """Read the currently usable Account Emails for one workspace scope.""" + + def find_member_emails(self, tenant_id: TenantId, account_ids: Sequence[str]) -> MemberEmailSnapshot: ... + + +type NodeDataConverter = Callable[ + [HumanInputNodeDataFull, MemberEmailSnapshot], + NodeMigrationConversion, +] + + +@dataclass(frozen=True, slots=True) +class MigrationNode: + node_id: str + node_data: HumanInputNodeDataFull + method_positions: tuple[int, ...] = () + recipient_positions: tuple[tuple[int, ...], ...] = () + preflight_issues: tuple[LegacyDeliveryParseIssue, ...] = () + + @classmethod + def from_preflight(cls, node_id: str, preflight: LegacyNodeDataPreflight) -> MigrationNode: + return cls( + node_id=node_id, + node_data=preflight.node_data, + method_positions=preflight.method_positions, + recipient_positions=preflight.recipient_positions, + preflight_issues=preflight.issues, + ) + + +@dataclass(frozen=True, slots=True) +class MigratedNode: + node_id: str + node_data: HumanInputNodeData + + +@dataclass(frozen=True, slots=True) +class NodeDataMigrationBlocker: + node_id: str + node_title: str + code: MigrationBlockerCode + method_id: str | None = None + value: str | None = None + + +@dataclass(frozen=True, slots=True) +class NodeDataMigrationSuccess: + data: tuple[MigratedNode, ...] + + +@dataclass(frozen=True, slots=True) +class NodeDataMigrationFailure: + blockers: tuple[NodeDataMigrationBlocker, ...] + + +type NodeDataMigrationOutcome = NodeDataMigrationSuccess | NodeDataMigrationFailure + + +@dataclass(frozen=True, slots=True) +class _PositionedMigrationBlocker: + blocker: NodeDataMigrationBlocker + postflight: bool + method_position: int + recipient_position: int + ordinal: int + + +class HumanInputNodeDataMigrationService: + """Create one member snapshot and deterministically preflight a whole batch.""" + + def __init__( + self, + *, + member_email_lookup: WorkspaceMemberEmailLookup, + converter: NodeDataConverter = convert_legacy_human_input_node_data, + ) -> None: + self._member_email_lookup = member_email_lookup + self._converter = converter + + def migrate(self, *, tenant_id: TenantId, nodes: Sequence[MigrationNode]) -> NodeDataMigrationOutcome: + member_ids = self._collect_member_ids(nodes) + member_email_snapshot = self._member_email_lookup.find_member_emails(tenant_id, member_ids) + + migrated_nodes: list[MigratedNode] = [] + blockers: list[NodeDataMigrationBlocker] = [] + for node in nodes: + conversion = self._converter(node.node_data, member_email_snapshot) + blockers.extend(self._ordered_node_blockers(node, conversion)) + if conversion.node_data is not None: + migrated_nodes.append(MigratedNode(node.node_id, conversion.node_data)) + + if blockers: + return NodeDataMigrationFailure(tuple(blockers)) + return NodeDataMigrationSuccess(tuple(migrated_nodes)) + + @staticmethod + def _ordered_node_blockers( + node: MigrationNode, + conversion: NodeMigrationConversion, + ) -> tuple[NodeDataMigrationBlocker, ...]: + method_positions = node.method_positions or tuple(range(len(node.node_data.delivery_methods))) + recipient_positions = node.recipient_positions or tuple( + tuple(range(len(method.config.recipients.items))) if isinstance(method, EmailDeliveryMethod) else () + for method in node.node_data.delivery_methods + ) + positioned_blockers: list[_PositionedMigrationBlocker] = [] + ordinal = 0 + for issue in node.preflight_issues: + positioned_blockers.append( + _PositionedMigrationBlocker( + blocker=NodeDataMigrationBlocker( + node_id=node.node_id, + node_title=node.node_data.title, + code=issue.code, + method_id=issue.method_id, + value=issue.value, + ), + postflight=False, + method_position=issue.method_position if issue.method_position is not None else -1, + recipient_position=issue.recipient_position if issue.recipient_position is not None else -1, + ordinal=ordinal, + ) + ) + ordinal += 1 + for blocker in conversion.blockers: + postflight = blocker.code in { + MigrationBlockerCode.CONFLICTING_EMAIL_TEMPLATES, + MigrationBlockerCode.MISSING_RECIPIENTS, + } + method_position = len(method_positions) + recipient_position = -1 + if blocker.method_index is not None: + method_position = method_positions[blocker.method_index] + if blocker.recipient_index is not None: + recipient_position = recipient_positions[blocker.method_index][blocker.recipient_index] + positioned_blockers.append( + _PositionedMigrationBlocker( + blocker=NodeDataMigrationBlocker( + node_id=node.node_id, + node_title=node.node_data.title, + code=blocker.code, + method_id=blocker.method_id, + value=blocker.value, + ), + postflight=postflight, + method_position=method_position, + recipient_position=recipient_position, + ordinal=ordinal, + ) + ) + ordinal += 1 + positioned_blockers.sort( + key=lambda positioned: ( + positioned.postflight, + positioned.method_position, + positioned.recipient_position, + positioned.ordinal, + ) + ) + return tuple(positioned.blocker for positioned in positioned_blockers) + + @staticmethod + def _collect_member_ids(nodes: Sequence[MigrationNode]) -> tuple[str, ...]: + member_ids: list[str] = [] + seen_member_ids: set[str] = set() + for node in nodes: + for method in node.node_data.delivery_methods: + if not method.enabled or not isinstance(method, EmailDeliveryMethod): + continue + for source in method.config.recipients.items: + if not isinstance(source, MemberRecipient) or source.reference_id in seen_member_ids: + continue + member_ids.append(source.reference_id) + seen_member_ids.add(source.reference_id) + return tuple(member_ids) diff --git a/api/services/human_input_v2/resend_channel.py b/api/services/human_input_v2/resend_channel.py new file mode 100644 index 00000000000..3cb4912655e --- /dev/null +++ b/api/services/human_input_v2/resend_channel.py @@ -0,0 +1,186 @@ +"""Request-scoped Resend delivery adapter for Human Input v2.""" + +from __future__ import annotations + +from collections.abc import Callable, Mapping +from typing import Any, Never, Protocol + +import httpx +from pydantic import BaseModel, ConfigDict, ValidationError + +from core.helper import ssrf_proxy +from libs.uuid_utils import uuidv7 +from repositories.human_input_v2.email_channel import ( + EmailProviderOperationError, + EmailProviderValidationError, + ResendCandidate, +) + +_RESEND_API_ORIGIN = "https://api.resend.com" +_USER_AGENT = "Dify-Human-Input/1.0" +_TEST_RECIPIENT = "delivered@resend.dev" +_TEST_SUBJECT = "Dify Human Input channel test" +_TEST_HTML = ( + "

Your Resend channel is connected to Dify Human Input.

" + "

You can close this email after confirming delivery.

" +) + + +class _ResendErrorResponse(BaseModel): + model_config = ConfigDict(extra="ignore", strict=True, hide_input_in_errors=True) + + name: str | None = None + + +class ResendHTTPResponse(Protocol): + status_code: int + + def json(self) -> Any: ... + + +class ResendHTTPClient(Protocol): + def post(self, url: str, **kwargs: Any) -> ResendHTTPResponse: ... + + +class ResendProviderGateway: + """Perform Resend delivery I/O without shared SDK credential state.""" + + def __init__( + self, + *, + http_client: ResendHTTPClient = ssrf_proxy, + timeout_seconds: float = 10, + id_factory: Callable[[], str] = lambda: str(uuidv7()), + ) -> None: + if timeout_seconds <= 0: + raise ValueError("Resend timeout must be positive") + self._http_client = http_client + self._timeout_seconds = timeout_seconds + self._id_factory = id_factory + + def send_test(self, candidate: ResendCandidate) -> None: + """Verify the candidate through one idempotent Resend test delivery.""" + + self._send_message( + candidate, + to=_TEST_RECIPIENT, + subject=_TEST_SUBJECT, + html=_TEST_HTML, + idempotency_key=f"human-input-channel-test/{self._id_factory()}", + ) + + def send_message(self, candidate: ResendCandidate, *, to: str, subject: str, html: str) -> None: + """Send one rendered notification using only the supplied channel credentials.""" + + self._send_message( + candidate, + to=to, + subject=subject, + html=html, + idempotency_key=f"human-input-message/{self._id_factory()}", + ) + + def _send_message( + self, candidate: ResendCandidate, *, to: str, subject: str, html: str, idempotency_key: str + ) -> None: + sender = f"{candidate.sender_name} <{candidate.sender_email}>" + body = self._request( + "/emails", + candidate.credentials.api_key, + json={ + "from": sender, + "to": [to], + "subject": subject, + "html": html, + }, + idempotency_key=idempotency_key, + ) + message_id = body.get("id") + if not isinstance(message_id, str) or not message_id.strip(): + raise EmailProviderOperationError("provider_response_malformed") + + def _request( + self, + path: str, + api_key: str, + *, + json: Mapping[str, Any] | None = None, + idempotency_key: str | None = None, + ) -> Mapping[str, Any]: + headers = { + "Authorization": f"Bearer {api_key}", + "User-Agent": _USER_AGENT, + } + if idempotency_key is not None: + headers["Idempotency-Key"] = idempotency_key + try: + response = self._http_client.post( + f"{_RESEND_API_ORIGIN}{path}", + headers=headers, + json=json, + timeout=self._timeout_seconds, + max_retries=0, + ) + except httpx.TimeoutException as error: + raise EmailProviderOperationError("provider_timeout") from error + except httpx.RequestError as error: + raise EmailProviderOperationError("provider_connection_failed") from error + + body = self._safe_body(response) + if 200 <= response.status_code < 300: + return body + self._raise_provider_error(response.status_code, body) + + @staticmethod + def _safe_body(response: ResendHTTPResponse) -> Mapping[str, Any]: + try: + body = response.json() + except ValueError: + return {} + return body if isinstance(body, Mapping) else {} + + @staticmethod + def _raise_provider_error(status_code: int, body: Mapping[str, Any]) -> Never: + try: + safe_name = _ResendErrorResponse.model_validate(body).name + except ValidationError: + safe_name = None + provider_error_code = safe_name + if safe_name in {"missing_api_key", "invalid_api_key", "restricted_api_key"}: + raise EmailProviderValidationError( + "invalid_api_key", provider_error_code=provider_error_code, http_status=status_code + ) + if safe_name == "invalid_from_address": + raise EmailProviderValidationError( + "invalid_sender", provider_error_code=provider_error_code, http_status=status_code + ) + if safe_name == "validation_error": + raise EmailProviderValidationError( + "provider_validation_failed", provider_error_code=provider_error_code, http_status=status_code + ) + if safe_name in {"daily_quota_exceeded", "monthly_quota_exceeded"}: + raise EmailProviderOperationError( + "provider_quota_exhausted", provider_error_code=provider_error_code, http_status=status_code + ) + if status_code == 429 or safe_name == "rate_limit_exceeded": + raise EmailProviderOperationError( + "provider_rate_limited", provider_error_code=provider_error_code, http_status=status_code + ) + if status_code >= 500: + raise EmailProviderOperationError( + "provider_unavailable", provider_error_code=provider_error_code, http_status=status_code + ) + if status_code in {401, 403}: + raise EmailProviderValidationError( + "invalid_api_key", provider_error_code=provider_error_code, http_status=status_code + ) + raise EmailProviderValidationError( + "provider_rejected", provider_error_code=provider_error_code, http_status=status_code + ) + + +__all__ = [ + "ResendHTTPClient", + "ResendHTTPResponse", + "ResendProviderGateway", +] diff --git a/api/services/human_input_v2/runtime.py b/api/services/human_input_v2/runtime.py new file mode 100644 index 00000000000..ee040ad9e70 --- /dev/null +++ b/api/services/human_input_v2/runtime.py @@ -0,0 +1,87 @@ +"""Compose Human Input use cases without owning a database transaction.""" + +from __future__ import annotations + +import logging +from typing import override + +from core.workflow.nodes.human_input_v2.entities import HumanInputNodeData +from core.workflow.nodes.human_input_v2.runtime import HumanInputDeliveryError, HumanInputRuntime, PreparedForm +from graphon.runtime.runtime_state_protocol import ReadOnlyVariablePool +from repositories.human_input_v2.delivery_attempt_repository import DeliveryStatus + +from .delivery_service import HumanInputDeliveryService +from .form_service import HumanInputFormService + +logger = logging.getLogger(__name__) + + +class DifyHumanInputRuntime(HumanInputRuntime): + """Combine committed initialization, delivery, and delivery-result handling. + + Services own their transactions and construct their own repositories. This + runtime neither opens a Session nor commits, rolls back, or shares one + service's transaction with another service. + """ + + def __init__( + self, + *, + form_service: HumanInputFormService, + delivery_service: HumanInputDeliveryService, + ) -> None: + self._form_service = form_service + self._delivery_service = delivery_service + + @override + def prepare_form( + self, + *, + node_execution_id: str, + node_data: HumanInputNodeData, + variable_pool: ReadOnlyVariablePool, + ) -> PreparedForm: + """Send notifications on first initialization, then prepare to pause. + + Existing forms are read without repeating notifications, including when + initialization or delivery was interrupted. Reentry only recovers the + persisted form and its existing Current Initiator entry. + + Only Current Initiator deliveries provide a stream token. Submission + requires a persisted workflow pause, so the initial delivery round does + not refresh the form before requesting that pause. + Partial failure does not discard successful deliveries; total failure + still fails the node without rolling back external sends. + """ + initialization = self._form_service.prepare_form( + node_execution_id=node_execution_id, + node_data=node_data, + variable_pool=variable_pool, + ) + if isinstance(initialization, PreparedForm): + return initialization + has_successful_delivery = False + form_token = None + for recipient in initialization.recipients: + result = self._delivery_service.deliver( + form=initialization.form, + recipient=recipient, + message_template=initialization.message_template, + debug_channels=initialization.debug_channels, + ) + if result.form_token is not None: + form_token = result.form_token + for attempt in result.attempts: + if attempt.status == DeliveryStatus.SUCCEEDED: + has_successful_delivery = True + else: + logger.warning( + "Human Input delivery failed: form_id=%s recipient_id=%s delivery_id=%s attempt_id=%s", + initialization.form.id, + recipient.id, + attempt.delivery_id, + attempt.id, + ) + if not has_successful_delivery and form_token is None: + raise HumanInputDeliveryError("No one can approve this step.") + return PreparedForm(form=initialization.form, form_token=form_token) diff --git a/api/services/human_input_v2/submission_service.py b/api/services/human_input_v2/submission_service.py new file mode 100644 index 00000000000..626065410cf --- /dev/null +++ b/api/services/human_input_v2/submission_service.py @@ -0,0 +1,810 @@ +"""Authorize recipients and atomically commit Human Input v2 approvals.""" + +import hashlib +import logging +from collections.abc import Callable, Mapping +from dataclasses import dataclass, replace +from enum import Enum, StrEnum, auto + +from kombu.exceptions import OperationalError +from pydantic import JsonValue, TypeAdapter +from sqlalchemy.orm import Session, sessionmaker + +from core.app.file_access import DatabaseFileAccessController +from core.human_input_v2.entities import IMProvider +from core.human_input_v2.resolved_form import FileInput, FileListInput, MarkdownFragment, ParagraphInput, SelectInput +from core.human_input_v2.shared.values import AccountId, TenantId, WorkspaceScope +from core.workflow.nodes.human_input.enums import HumanInputFormStatus +from factories.file_factory import build_from_mapping +from factories.human_input_v2_form_presentation import refresh_form_file_urls +from graphon.enums import WorkflowExecutionStatus +from graphon.file import FileUploadConfig +from graphon.variables.factory import build_segment +from graphon.variables.segments import ArrayFileSegment, FileSegment, Segment, StringSegment +from libs.datetime_utils import naive_utc_now +from repositories.factory import DifyAPIRepositoryFactory +from repositories.human_input_v2.contact import ContactType +from repositories.human_input_v2.delivery_repository import ( + Delivery, + EmailTargetSnapshot, + IMUserTargetSnapshot, + InitiatorSnapshot, + SubmissionAuthType, +) +from repositories.human_input_v2.form_repository import Form, FormSubmissionFailure +from repositories.human_input_v2.im_channel_repository import IMChannelId +from repositories.human_input_v2.recipient_repository import ( + ContactRecipientSubject, + EmailRecipientSubject, + EndUserRecipientSubject, + Recipient, +) +from repositories.human_input_v2.sqlalchemy_audit_repository import SQLAlchemyAuditRepository +from repositories.human_input_v2.sqlalchemy_contact_repository import SQLAlchemyContactRepository +from repositories.human_input_v2.sqlalchemy_delivery_repository import SQLAlchemyDeliveryRepository +from repositories.human_input_v2.sqlalchemy_form_repository import SQLAlchemyFormRepository +from repositories.human_input_v2.sqlalchemy_im_binding_repository import SQLAlchemyIMBindingRepository +from repositories.human_input_v2.sqlalchemy_im_channel_repository import SQLAlchemyIMWebhookChannelRepository +from repositories.human_input_v2.sqlalchemy_im_identity_repository import SQLAlchemyIMIdentityRepository +from repositories.human_input_v2.sqlalchemy_recipient_repository import SQLAlchemyRecipientRepository +from services.human_input_v2.email_otp_service import ( + EmailOtpError, + EmailOtpService, + EmailSubject, + ExternalContactSubject, + OtpFailure, + VerifiedEmail, +) + +_STRING = TypeAdapter(str) +_FILE = TypeAdapter(dict[str, JsonValue]) +_FILES = TypeAdapter(list[dict[str, JsonValue]]) +logger = logging.getLogger(__name__) + + +class _InputValidationMode(Enum): + STRICT = auto() + LEGACY_COMPATIBLE = auto() + + +class HumanInputFormErrorCode(StrEnum): + NOT_FOUND = "not_found" + ALREADY_SUBMITTED = "already_submitted" + EXPIRED = "expired" + WORKFLOW_NOT_ACTIVE = "workflow_not_active" + INVALID_INPUT = "invalid_input" + + +class HumanInputFormError(Exception): + """A rejection translated to an HTTP response by the owning transport.""" + + def __init__(self, code: HumanInputFormErrorCode, *, form_id: str | None = None): + self.code = code + # Unknown or unauthorized tokens have no form identity to expose. + self.form_id = form_id + super().__init__(code.value) + + +@dataclass(frozen=True, slots=True) +class WebAppFormActor: + """App-owned EndUser established by the Web App passport boundary.""" + + tenant_id: TenantId + app_id: str + end_user_id: str + + +@dataclass(frozen=True, slots=True) +class ServiceAPIFormActor: + """App ownership established by the Service API bearer token. + + The caller may submit for a different EndUser than the run initiator. + EndUser attribution is recorded separately from the recipient grant. + """ + + tenant_id: TenantId + app_id: str + + +@dataclass(frozen=True, slots=True) +class IMFormActor: + """Provider identity established by an authenticated callback in the inbox.""" + + channel_id: IMChannelId + provider: IMProvider + provider_tenant_id: str + provider_user_id: str + + +@dataclass(frozen=True, slots=True) +class _AuthorizedForm: + """The exact delivery and recipient authenticated for one form.""" + + form: Form + recipient: Recipient + delivery: Delivery + + +@dataclass(frozen=True, slots=True) +class _IMFormContext: + form: Form + delivery: Delivery + target: IMUserTargetSnapshot + + +@dataclass(frozen=True, slots=True) +class _IMCallbackRejection: + """Expected rejection with safe diagnostic text and the transport error code.""" + + code: HumanInputFormErrorCode + reason: str + + +class HumanInputSubmissionService: + def __init__(self, *, session_factory: sessionmaker[Session]) -> None: + self._session_factory = session_factory + + def get_service_api_form( + self, *, actor: ServiceAPIFormActor, form_token: str, ip_address: str | None = None + ) -> Form: + token_hash = hashlib.sha256(form_token.encode()).hexdigest() + with self._session_factory() as session: + form = self._authorize_service_api(session, actor=actor, token_hash=token_hash).form + self._record_form_view(form, ip_address=ip_address) + return form + + def submit_service_api_form( + self, + *, + actor: ServiceAPIFormActor, + end_user_id: str, + form_token: str, + action: str, + inputs: Mapping[str, JsonValue], + ) -> None: + token_hash = hashlib.sha256(form_token.encode()).hexdigest() + + def authorize(session: Session) -> _AuthorizedForm: + return self._authorize_service_api(session, actor=actor, token_hash=token_hash) + + def audit_submission(session: Session, form: Form, recipient: Recipient) -> None: + SQLAlchemyAuditRepository(session, form.tenant_id, form.id).record_service_api_submission( + recipient_id=recipient.id, + end_user_id=end_user_id, + ) + + form = self._submit_form( + authorize=authorize, + action=action, + inputs=inputs, + input_validation_mode=_InputValidationMode.LEGACY_COMPATIBLE, + after_submission=audit_submission, + ) + try: + self._enqueue_resume(form) + except OperationalError: + # The legacy Service API acknowledges the committed submission even + # when the broker is unavailable; a retry cannot undo that acceptance. + logger.exception( + "Failed to enqueue resume task after Service API form submission, form_id=%s, workflow_run_id=%s", + form.id, + form.workflow_run_id, + ) + + def get_console_form(self, *, account_id: AccountId, form_token: str, ip_address: str | None = None) -> Form: + token_hash = hashlib.sha256(form_token.encode()).hexdigest() + with self._session_factory() as session: + form = self._authorize_console(session, account_id=account_id, token_hash=token_hash).form + if naive_utc_now() >= min(form.expiration_time, form.global_timeout_deadline): + raise HumanInputFormError(HumanInputFormErrorCode.EXPIRED) + self._record_form_view(form, ip_address=ip_address) + return replace(form, resolved_form=refresh_form_file_urls(form.resolved_form)) + + def submit_console_form( + self, *, account_id: AccountId, form_token: str, action: str, inputs: Mapping[str, JsonValue] + ) -> None: + token_hash = hashlib.sha256(form_token.encode()).hexdigest() + + def authorize(session: Session) -> _AuthorizedForm: + return self._authorize_console(session, account_id=account_id, token_hash=token_hash) + + form = self._submit_form(authorize=authorize, action=action, inputs=inputs) + self._enqueue_resume(form) + + def get_web_app_form(self, *, actor: WebAppFormActor, form_token: str, ip_address: str | None = None) -> Form: + token_hash = hashlib.sha256(form_token.encode()).hexdigest() + with self._session_factory() as session: + form = self._authorize_web_app(session, actor=actor, token_hash=token_hash).form + if naive_utc_now() >= min(form.expiration_time, form.global_timeout_deadline): + raise HumanInputFormError(HumanInputFormErrorCode.EXPIRED) + self._record_form_view(form, ip_address=ip_address) + return form + + def get_public_form(self, *, form_token: str, ip_address: str | None = None) -> Form: + """Read an active email-proof form before any OTP is requested or verified.""" + form = self.authorize_public_form(form_token=form_token) + self._record_form_view(form, ip_address=ip_address) + return replace(form, resolved_form=refresh_form_file_urls(form.resolved_form)) + + def _record_form_view(self, form: Form, *, ip_address: str | None) -> None: + with self._session_factory() as session, session.begin(): + SQLAlchemyAuditRepository(session, form.tenant_id, form.id).record_form_view(viewer_ip_address=ip_address) + + def authorize_public_form(self, *, form_token: str) -> Form: + """Authorize public access, including file uploads, without recording a page view.""" + token_hash = hashlib.sha256(form_token.encode()).hexdigest() + with self._session_factory() as session: + delivery = SQLAlchemyDeliveryRepository(session).get_delivery_by_token_hash(token_hash) + if ( + delivery is None + or delivery.auth_type != SubmissionAuthType.EMAIL_OTP + or not isinstance(delivery.target_snapshot, EmailTargetSnapshot) + ): + raise HumanInputFormError(HumanInputFormErrorCode.NOT_FOUND) + repository = SQLAlchemyFormRepository.for_existing_form( + session, tenant_id=delivery.tenant_id, form_id=delivery.form_id + ) + if repository is None: + raise HumanInputFormError(HumanInputFormErrorCode.NOT_FOUND) + form = repository.get_form_by_id(delivery.form_id) + if form is None: + raise HumanInputFormError(HumanInputFormErrorCode.NOT_FOUND) + recipient = SQLAlchemyRecipientRepository(session, form.tenant_id, form.id).get_recipient( + delivery.recipient_id + ) + if recipient is None: + raise HumanInputFormError(HumanInputFormErrorCode.NOT_FOUND) + match recipient.subject: + case EmailRecipientSubject(email): + if email.value != delivery.target_snapshot.email_address: + raise HumanInputFormError(HumanInputFormErrorCode.NOT_FOUND) + case ContactRecipientSubject(contact_id): + contact = SQLAlchemyContactRepository(session).get_contacts_by_id(form.tenant_id, contact_id) + if ( + contact is None + or contact.type != ContactType.EXTERNAL + or contact.email != delivery.target_snapshot.email_address + ): + raise HumanInputFormError(HumanInputFormErrorCode.NOT_FOUND) + case EndUserRecipientSubject(): + raise HumanInputFormError(HumanInputFormErrorCode.NOT_FOUND) + _ensure_form_waiting(form) + if naive_utc_now() >= min(form.expiration_time, form.global_timeout_deadline): + raise HumanInputFormError(HumanInputFormErrorCode.EXPIRED) + return form + + def submit_web_app_form( + self, *, actor: WebAppFormActor, form_token: str, action: str, inputs: Mapping[str, JsonValue] + ) -> None: + token_hash = hashlib.sha256(form_token.encode()).hexdigest() + + def authorize(session: Session) -> _AuthorizedForm: + return self._authorize_web_app(session, actor=actor, token_hash=token_hash) + + form = self._submit_form(authorize=authorize, action=action, inputs=inputs) + self._enqueue_resume(form) + + def submit_public_form( + self, + *, + email_otp_service: EmailOtpService, + form_token: str, + action: str, + inputs: Mapping[str, JsonValue], + challenge_token: str | None, + otp_code: str | None, + ip_address: str | None, + ) -> None: + # OTP verification accesses Redis and owns failure audits. Complete it + # outside the submission transaction and retain proof on rejected writes. + verified = email_otp_service.verify( + form_token, challenge_token=challenge_token, otp_code=otp_code, ip_address=ip_address + ) + + def authorize(session: Session) -> _AuthorizedForm: + return self._authorize_public(session, verified=verified) + + def audit_submission(session: Session, _form: Form, _recipient: Recipient) -> None: + verified.add_submission_audit(session, ip_address=ip_address) + + form = self._submit_form(authorize=authorize, action=action, inputs=inputs, after_submission=audit_submission) + email_otp_service.cleanup(verified.delivery.id, verified.challenge_token) + self._enqueue_resume(form) + + def submit_im_form( + self, + *, + actor: IMFormActor, + form_token: str, + action: str, + inputs: Mapping[str, JsonValue], + ip_address: str | None = None, + ) -> None: + token_hash = hashlib.sha256(form_token.encode()).hexdigest() + verification = self._verify_im_callback(actor=actor, token_hash=token_hash, ip_address=ip_address) + if isinstance(verification, _IMCallbackRejection): + raise HumanInputFormError(verification.code) + + def authorize(session: Session) -> _AuthorizedForm: + authorized = self._authorize_im_form(session, actor=actor, token_hash=token_hash) + _ensure_form_waiting(authorized.form) + return authorized + + try: + form = self._submit_form(authorize=authorize, action=action, inputs=inputs) + self._enqueue_resume(form) + except HumanInputFormError as error: + if error.code is not HumanInputFormErrorCode.ALREADY_SUBMITTED: + raise + self._resume_submitted_im_form(actor=actor, token_hash=token_hash, action=action, inputs=inputs) + + def _submit_form( + self, + *, + authorize: Callable[[Session], _AuthorizedForm], + action: str, + inputs: Mapping[str, JsonValue], + after_submission: Callable[[Session, Form, Recipient], None] | None = None, + input_validation_mode: _InputValidationMode = _InputValidationMode.STRICT, + ) -> Form: + with self._session_factory() as session: + form = authorize(session).form + # File reconstruction can access storage or remote URLs. Keep it outside + # the write transaction, then recheck authorization and terminal state. + try: + normalized = _normalize_inputs(form, action, inputs, mode=input_validation_mode) + except ValueError as exc: + raise HumanInputFormError(HumanInputFormErrorCode.INVALID_INPUT, form_id=form.id) from exc + + with self._session_factory() as session, session.begin(): + authorized = authorize(session) + form = authorized.form + if form.workflow_run_id is not None: + # Keep the run-before-form lock order shared with expiration. + workflow_run_repository = DifyAPIRepositoryFactory.create_api_workflow_run_repository( + self._session_factory + ) + run = workflow_run_repository.get_workflow_run_by_id_for_update( + session, + tenant_id=form.tenant_id, + app_id=form.app_id, + run_id=form.workflow_run_id, + ) + if run is None: + raise HumanInputFormError(HumanInputFormErrorCode.NOT_FOUND) + if run.status != WorkflowExecutionStatus.PAUSED: + raise HumanInputFormError(HumanInputFormErrorCode.WORKFLOW_NOT_ACTIVE) + # PAUSED can be persisted before the checkpoint. Accept only + # after its active pause is committed so resume has state to load. + if not workflow_run_repository.has_active_workflow_pause( + session, + tenant_id=form.tenant_id, + app_id=form.app_id, + run_id=run.id, + ): + raise HumanInputFormError(HumanInputFormErrorCode.WORKFLOW_NOT_ACTIVE) + # The run lock may have waited for another transaction. Recheck + # current recipient authorization before accepting the form. + authorized = authorize(session) + form, recipient = authorized.form, authorized.recipient + result = SQLAlchemyFormRepository(session, form.tenant_id, form.app_id).submit_form( + form.id, + submitted_by_recipient_id=recipient.id, + submitted_by_delivery_id=authorized.delivery.id, + selected_action_id=action, + raw_submission_inputs=inputs, + normalized_submission_data=normalized, + ) + match result: + case FormSubmissionFailure.NOT_FOUND: + raise HumanInputFormError(HumanInputFormErrorCode.NOT_FOUND) + case FormSubmissionFailure.ALREADY_SUBMITTED: + raise HumanInputFormError(HumanInputFormErrorCode.ALREADY_SUBMITTED, form_id=form.id) + case FormSubmissionFailure.FORM_EXPIRED | FormSubmissionFailure.GLOBAL_TIMEOUT: + raise HumanInputFormError(HumanInputFormErrorCode.EXPIRED, form_id=form.id) + case Form(): + form = result + if after_submission is not None: + after_submission(session, form, recipient) + self._enqueue_card_updates(form) + return form + + def _enqueue_card_updates(self, form: Form) -> None: + from services.human_input_v2.im_card_service import HumanInputIMCardService + + try: + HumanInputIMCardService(session_factory=self._session_factory).enqueue_updates(form) + except Exception: + # Card synchronization is an independent follow-up. A failure must + # not change the accepted submission or suppress workflow resume. + logger.exception( + "Failed to fan out Human Input IM card updates tenant_id=%s form_id=%s", + form.tenant_id, + form.id, + ) + + @staticmethod + def _enqueue_resume(form: Form) -> None: + if form.workflow_run_id is not None: + from tasks.app_generate.workflow_execute_task import resume_app_execution + + resume_app_execution.apply_async(kwargs={"payload": {"workflow_run_id": form.workflow_run_id}}) + + @staticmethod + def _authorize_service_api( + session: Session, + *, + actor: ServiceAPIFormActor, + token_hash: str, + ) -> _AuthorizedForm: + delivery = SQLAlchemyDeliveryRepository(session).get_delivery_by_token_hash(token_hash) + if ( + delivery is None + or delivery.tenant_id != actor.tenant_id + or delivery.auth_type != SubmissionAuthType.WEB_APP + or not isinstance(delivery.target_snapshot, InitiatorSnapshot) + ): + raise HumanInputFormError(HumanInputFormErrorCode.NOT_FOUND) + repository = SQLAlchemyFormRepository.for_existing_form( + session, + tenant_id=actor.tenant_id, + form_id=delivery.form_id, + ) + if repository is None: + raise HumanInputFormError(HumanInputFormErrorCode.NOT_FOUND) + form = repository.get_form_by_id(delivery.form_id) + if form is None or form.app_id != actor.app_id: + raise HumanInputFormError(HumanInputFormErrorCode.NOT_FOUND) + recipient = SQLAlchemyRecipientRepository(session, actor.tenant_id, form.id).get_recipient( + delivery.recipient_id + ) + if recipient is None or not isinstance(recipient.subject, EndUserRecipientSubject): + raise HumanInputFormError(HumanInputFormErrorCode.NOT_FOUND) + # Service API authorizes the application, not a browser passport. + # Do not require the submitted user to equal the original recipient. + _ensure_form_waiting(form) + if naive_utc_now() >= min(form.expiration_time, form.global_timeout_deadline): + raise HumanInputFormError(HumanInputFormErrorCode.EXPIRED, form_id=form.id) + return _AuthorizedForm(form, recipient, delivery) + + @staticmethod + def _authorize_console(session: Session, *, account_id: AccountId, token_hash: str) -> _AuthorizedForm: + delivery = SQLAlchemyDeliveryRepository(session).get_delivery_by_token_hash(token_hash) + if delivery is None or delivery.auth_type != SubmissionAuthType.CONSOLE: + raise HumanInputFormError(HumanInputFormErrorCode.NOT_FOUND) + app_id = SQLAlchemyFormRepository.find_app_id(session, tenant_id=delivery.tenant_id, form_id=delivery.form_id) + if app_id is None: + raise HumanInputFormError(HumanInputFormErrorCode.NOT_FOUND) + form = SQLAlchemyFormRepository(session, delivery.tenant_id, app_id).get_form_by_id(delivery.form_id) + assert form is not None + recipient = SQLAlchemyRecipientRepository(session, form.tenant_id, form.id).get_recipient(delivery.recipient_id) + if recipient is None or not isinstance(recipient.subject, ContactRecipientSubject): + raise HumanInputFormError(HumanInputFormErrorCode.NOT_FOUND) + # Resolve the current Account in the delivery's workspace, not the + # browser's active workspace, and never authenticate by email equality. + contact = SQLAlchemyContactRepository(session).get_contact_by_account_id(form.tenant_id, account_id) + if contact is None or contact.id != recipient.subject.contact_id: + raise HumanInputFormError(HumanInputFormErrorCode.NOT_FOUND) + _ensure_form_waiting(form) + return _AuthorizedForm(form, recipient, delivery) + + @staticmethod + def _authorize_web_app(session: Session, *, actor: WebAppFormActor, token_hash: str) -> _AuthorizedForm: + delivery = SQLAlchemyDeliveryRepository(session).get_delivery_by_token_hash(token_hash) + if ( + delivery is None + or delivery.auth_type != SubmissionAuthType.WEB_APP + or delivery.tenant_id != actor.tenant_id + ): + raise HumanInputFormError(HumanInputFormErrorCode.NOT_FOUND) + form = SQLAlchemyFormRepository(session, actor.tenant_id, actor.app_id).get_form_by_id(delivery.form_id) + if form is None: + raise HumanInputFormError(HumanInputFormErrorCode.NOT_FOUND) + recipient = SQLAlchemyRecipientRepository(session, actor.tenant_id, form.id).get_recipient( + delivery.recipient_id + ) + if recipient is None or not isinstance(recipient.subject, EndUserRecipientSubject): + raise HumanInputFormError(HumanInputFormErrorCode.NOT_FOUND) + if recipient.subject.end_user_id != actor.end_user_id: + raise HumanInputFormError(HumanInputFormErrorCode.NOT_FOUND) + _ensure_form_waiting(form) + return _AuthorizedForm(form, recipient, delivery) + + @staticmethod + def _authorize_public(session: Session, *, verified: VerifiedEmail) -> _AuthorizedForm: + delivery = SQLAlchemyDeliveryRepository(session).get_delivery_by_token_hash(verified.delivery.token_hash) + if delivery is None or delivery.auth_type != SubmissionAuthType.EMAIL_OTP: + raise HumanInputFormError(HumanInputFormErrorCode.NOT_FOUND) + if delivery.id != verified.delivery.id or not isinstance(delivery.target_snapshot, EmailTargetSnapshot): + raise HumanInputFormError(HumanInputFormErrorCode.NOT_FOUND) + form = SQLAlchemyFormRepository(session, delivery.tenant_id, verified.app_id).get_form_by_id(delivery.form_id) + if form is None: + raise HumanInputFormError(HumanInputFormErrorCode.NOT_FOUND) + recipient = SQLAlchemyRecipientRepository(session, form.tenant_id, form.id).get_recipient(delivery.recipient_id) + if recipient is None: + raise HumanInputFormError(HumanInputFormErrorCode.NOT_FOUND) + match verified.subject: + case EmailSubject(): + if not isinstance(recipient.subject, EmailRecipientSubject): + raise HumanInputFormError(HumanInputFormErrorCode.NOT_FOUND) + if recipient.subject.email.value != verified.subject.email: + raise EmailOtpError(OtpFailure.STALE) + case ExternalContactSubject(): + if not isinstance(recipient.subject, ContactRecipientSubject): + raise HumanInputFormError(HumanInputFormErrorCode.NOT_FOUND) + if recipient.subject.contact_id != verified.subject.contact_id: + raise HumanInputFormError(HumanInputFormErrorCode.NOT_FOUND) + contact = SQLAlchemyContactRepository(session).get_contacts_by_id( + form.tenant_id, recipient.subject.contact_id + ) + if contact is None or contact.type != ContactType.EXTERNAL: + raise HumanInputFormError(HumanInputFormErrorCode.NOT_FOUND) + if contact.email != verified.subject.email: + raise EmailOtpError(OtpFailure.STALE) + _ensure_form_waiting(form) + return _AuthorizedForm(form, recipient, delivery) + + def _verify_im_callback( + self, *, actor: IMFormActor, token_hash: str, ip_address: str | None + ) -> Recipient | _IMCallbackRejection: + with self._session_factory() as session, session.begin(): + # No audit attribution until the authenticated route and stored + # delivery resolve a form in the same Provider tenant/workspace. + context = self._resolve_im_form_context(session, actor=actor, token_hash=token_hash) + if isinstance(context, _IMCallbackRejection): + self._log_im_callback_rejection(actor=actor, rejection=context, context=None) + return context + verification = self._check_im_recipient(session, actor=actor, context=context) + if isinstance(verification, _IMCallbackRejection): + succeeded = False + reason = verification.reason + else: + succeeded = True + reason = "verified" + SQLAlchemyAuditRepository(session, context.form.tenant_id, context.form.id).record_im_callback_verification( + succeeded=succeeded, + request_ip_address=ip_address, + reason=reason, + ) + # Verification audits commit before the caller translates a rejection + # or attempts form submission. + if isinstance(verification, _IMCallbackRejection): + self._log_im_callback_rejection(actor=actor, rejection=verification, context=context) + return verification + + @staticmethod + def _log_im_callback_rejection( + *, actor: IMFormActor, rejection: _IMCallbackRejection, context: _IMFormContext | None + ) -> None: + tenant_id = context.form.tenant_id if context is not None else None + form_id = context.form.id if context is not None else None + logger.warning( + "Rejected IM callback channel_id=%s tenant_id=%s form_id=%s code=%s reason=%s", + actor.channel_id, + tenant_id, + form_id, + rejection.code.value, + rejection.reason, + extra={ + "channel_id": actor.channel_id, + "tenant_id": tenant_id, + "form_id": form_id, + "delivery_id": context.delivery.id if context is not None else None, + "recipient_id": context.delivery.recipient_id if context is not None else None, + "im_provider": actor.provider.value, + "provider_tenant_id": actor.provider_tenant_id, + "callback_provider_user_id": actor.provider_user_id, + "target_provider_user_id": context.target.im_provider_user_id if context is not None else None, + "error_code": rejection.code.value, + "rejection_reason": rejection.reason, + }, + ) + + @classmethod + def _authorize_im_form(cls, session: Session, *, actor: IMFormActor, token_hash: str) -> _AuthorizedForm: + """Require current IM recipient authorization for submission and resume repair. + + Rejections raise here; callback verification retains them to commit its audit. + """ + context = cls._resolve_im_form_context(session, actor=actor, token_hash=token_hash) + if isinstance(context, _IMCallbackRejection): + cls._log_im_callback_rejection(actor=actor, rejection=context, context=None) + raise HumanInputFormError(context.code) + recipient = cls._check_im_recipient(session, actor=actor, context=context) + if isinstance(recipient, _IMCallbackRejection): + cls._log_im_callback_rejection(actor=actor, rejection=recipient, context=context) + raise HumanInputFormError(recipient.code) + return _AuthorizedForm(context.form, recipient, context.delivery) + + @staticmethod + def _resolve_im_form_context( + session: Session, *, actor: IMFormActor, token_hash: str + ) -> _IMFormContext | _IMCallbackRejection: + """Resolve trusted form ownership without granting the callback user access. + + This context lets recipient verification failures be audited against the form. + """ + route = SQLAlchemyIMWebhookChannelRepository(session).find_by_channel_id(actor.channel_id) + if route is None: + return _IMCallbackRejection(HumanInputFormErrorCode.NOT_FOUND, "Callback channel was not found.") + if route.channel.provider is not actor.provider: + return _IMCallbackRejection( + HumanInputFormErrorCode.NOT_FOUND, "Callback provider does not match the channel provider." + ) + if route.channel.provider_tenant_id != actor.provider_tenant_id: + return _IMCallbackRejection( + HumanInputFormErrorCode.NOT_FOUND, "Callback provider tenant does not match the channel tenant." + ) + delivery = SQLAlchemyDeliveryRepository(session).get_delivery_by_token_hash(token_hash) + if delivery is None: + return _IMCallbackRejection(HumanInputFormErrorCode.NOT_FOUND, "Callback delivery was not found.") + if delivery.auth_type is not SubmissionAuthType.IM: + return _IMCallbackRejection( + HumanInputFormErrorCode.NOT_FOUND, "Callback delivery does not use IM authentication." + ) + if isinstance(route.scope, WorkspaceScope) and route.scope.id != delivery.tenant_id: + return _IMCallbackRejection( + HumanInputFormErrorCode.NOT_FOUND, "Channel and delivery belong to different workspaces." + ) + target = delivery.target_snapshot + if not isinstance(target, IMUserTargetSnapshot): + return _IMCallbackRejection(HumanInputFormErrorCode.NOT_FOUND, "Delivery target is not an IM user.") + if target.im_provider is not actor.provider: + return _IMCallbackRejection( + HumanInputFormErrorCode.NOT_FOUND, "Callback provider does not match the delivery target provider." + ) + if target.im_tenant_id != actor.provider_tenant_id: + return _IMCallbackRejection( + HumanInputFormErrorCode.NOT_FOUND, "Callback provider tenant does not match the delivery target tenant." + ) + app_id = SQLAlchemyFormRepository.find_app_id(session, tenant_id=delivery.tenant_id, form_id=delivery.form_id) + if app_id is None: + return _IMCallbackRejection( + HumanInputFormErrorCode.NOT_FOUND, "Form ownership could not be resolved for the delivery." + ) + form = SQLAlchemyFormRepository(session, delivery.tenant_id, app_id).get_form_by_id(delivery.form_id) + if form is None: + return _IMCallbackRejection(HumanInputFormErrorCode.NOT_FOUND, "Callback form was not found.") + return _IMFormContext(form=form, delivery=delivery, target=target) + + @staticmethod + def _check_im_recipient( + session: Session, *, actor: IMFormActor, context: _IMFormContext + ) -> Recipient | _IMCallbackRejection: + form, delivery, target = context.form, context.delivery, context.target + if target.im_provider_user_id != actor.provider_user_id: + return _IMCallbackRejection( + HumanInputFormErrorCode.NOT_FOUND, "Callback user does not match the delivery target user." + ) + recipient = SQLAlchemyRecipientRepository(session, form.tenant_id, form.id).get_recipient(delivery.recipient_id) + if recipient is None: + return _IMCallbackRejection(HumanInputFormErrorCode.NOT_FOUND, "Callback recipient was not found.") + if not isinstance(recipient.subject, ContactRecipientSubject): + return _IMCallbackRejection(HumanInputFormErrorCode.NOT_FOUND, "Callback recipient is not a contact.") + # Matching a Provider user must not revive a card after rebinding. + # The delivered Binding must still be effective for this Contact. + contact = SQLAlchemyContactRepository(session).get_contacts_by_id(form.tenant_id, recipient.subject.contact_id) + if contact is None: + return _IMCallbackRejection( + HumanInputFormErrorCode.NOT_FOUND, "Callback contact is no longer available in the workspace." + ) + if contact.type is ContactType.EXTERNAL: + return _IMCallbackRejection( + HumanInputFormErrorCode.NOT_FOUND, "External contacts cannot authenticate IM callbacks." + ) + binding = SQLAlchemyIMBindingRepository(session, actor.channel_id).get_effective(form.tenant_id, contact.id) + if binding is None: + return _IMCallbackRejection( + HumanInputFormErrorCode.NOT_FOUND, "Callback contact has no effective IM binding for the channel." + ) + if binding.id != target.im_binding_id: + return _IMCallbackRejection( + HumanInputFormErrorCode.NOT_FOUND, "Callback contact binding has changed since delivery." + ) + identity = SQLAlchemyIMIdentityRepository(session, actor.channel_id).get(binding.identity_id) + if identity is None: + return _IMCallbackRejection( + HumanInputFormErrorCode.NOT_FOUND, "Callback binding identity was not found in the channel." + ) + if identity.provider_user_id != actor.provider_user_id: + return _IMCallbackRejection( + HumanInputFormErrorCode.NOT_FOUND, "Callback user does not match the bound identity user." + ) + return recipient + + def _resume_submitted_im_form( + self, *, actor: IMFormActor, token_hash: str, action: str, inputs: Mapping[str, JsonValue] + ) -> None: + # Approval commit and broker publication are separate. A redelivery can + # repair a failed resume publication without changing the saved approval. + with self._session_factory() as session, session.begin(): + authorized = self._authorize_im_form(session, actor=actor, token_hash=token_hash) + form, recipient = authorized.form, authorized.recipient + submission = form.submission + if ( + submission is None + or submission.submitted_by_recipient_id != recipient.id + or submission.selected_action_id != action + or submission.raw_submission_inputs != inputs + ): + raise HumanInputFormError(HumanInputFormErrorCode.ALREADY_SUBMITTED) + should_resume = False + if form.workflow_run_id is not None: + repository = DifyAPIRepositoryFactory.create_api_workflow_run_repository(self._session_factory) + run = repository.get_workflow_run_by_id_for_update( + session, tenant_id=form.tenant_id, app_id=form.app_id, run_id=form.workflow_run_id + ) + should_resume = run is not None and run.status == WorkflowExecutionStatus.PAUSED + # A callback redelivery can also repair card publication after resume. + self._enqueue_card_updates(form) + if should_resume: + self._enqueue_resume(form) + + +def _ensure_form_waiting(form: Form) -> None: + if form.status == HumanInputFormStatus.SUBMITTED: + raise HumanInputFormError(HumanInputFormErrorCode.ALREADY_SUBMITTED, form_id=form.id) + if form.status in (HumanInputFormStatus.TIMEOUT, HumanInputFormStatus.EXPIRED): + raise HumanInputFormError(HumanInputFormErrorCode.EXPIRED, form_id=form.id) + + +def _normalize_inputs( + form: Form, + action: str, + inputs: Mapping[str, JsonValue], + *, + mode: _InputValidationMode, +) -> dict[str, Segment]: + if action not in {item.id for item in form.resolved_form.actions}: + raise ValueError("Unknown form action") + fields = { + part.output_variable_name: part for part in form.resolved_form.parts if not isinstance(part, MarkdownFragment) + } + # Legacy submissions retain extra raw fields but only declared fields become + # node outputs. Paragraphs preserve their JSON values rather than coercing them. + missing_inputs = fields.keys() - inputs.keys() + extra_inputs = inputs.keys() - fields.keys() + if missing_inputs or (mode is _InputValidationMode.STRICT and extra_inputs): + raise ValueError("Submitted fields must match the resolved form") + normalized: dict[str, Segment] = {} + for name, part in fields.items(): + value = inputs[name] + match part: + case ParagraphInput(): + if mode is _InputValidationMode.LEGACY_COMPATIBLE: + normalized[name] = build_segment(value) + else: + normalized[name] = StringSegment(value=_STRING.validate_python(value, strict=True)) + case SelectInput(): + text = _STRING.validate_python(value, strict=True) + if text not in part.options: + raise ValueError("Unknown select option") + normalized[name] = StringSegment(value=text) + case FileInput() | FileListInput(): + config = FileUploadConfig( + allowed_file_types=list(part.allowed_file_types), + allowed_file_extensions=list(part.allowed_file_extensions), + allowed_file_upload_methods=list(part.allowed_file_upload_methods), + number_limits=part.number_limits if isinstance(part, FileListInput) else 1, + ) + mappings = ( + _FILES.validate_python(value, strict=True) + if isinstance(part, FileListInput) + else [_FILE.validate_python(value, strict=True)] + ) + if isinstance(part, FileListInput) and len(mappings) > part.number_limits: + raise ValueError("Too many files") + files = [ + build_from_mapping( + mapping=mapping, + tenant_id=str(form.tenant_id), + config=config, + strict_type_validation=True, + access_controller=DatabaseFileAccessController(), + ) + for mapping in mappings + ] + normalized[name] = ( + ArrayFileSegment(value=files) if isinstance(part, FileListInput) else FileSegment(value=files[0]) + ) + return normalized diff --git a/api/services/human_input_v2/workspace_member_email_lookup.py b/api/services/human_input_v2/workspace_member_email_lookup.py new file mode 100644 index 00000000000..7d31c6e883a --- /dev/null +++ b/api/services/human_input_v2/workspace_member_email_lookup.py @@ -0,0 +1,49 @@ +"""Read-only SQL adapter for migration-time workspace member Emails.""" + +from __future__ import annotations + +from collections.abc import Callable, Sequence + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from core.human_input_v2.shared.values import NormalizedEmail, TenantId +from core.workflow.nodes.human_input_v2.migration import MemberEmailSnapshot, ResolvedMemberEmail +from models.account import Account, AccountStatus, TenantAccountJoin + + +class SQLAlchemyWorkspaceMemberEmailLookup: + """Load one request-local Account Email snapshot within a workspace.""" + + def __init__(self, session_factory: Callable[[], Session]) -> None: + self._session_factory = session_factory + + def find_member_emails(self, tenant_id: TenantId, account_ids: Sequence[str]) -> MemberEmailSnapshot: + if not account_ids: + return MemberEmailSnapshot() + + statement = ( + select(Account.id, Account.email) + .join(TenantAccountJoin, TenantAccountJoin.account_id == Account.id) + .where( + TenantAccountJoin.tenant_id == tenant_id, + Account.id.in_(account_ids), + Account.status == AccountStatus.ACTIVE, + ) + ) + with self._session_factory() as session: + with session.no_autoflush: + rows = session.execute(statement).all() + + member_emails: list[ResolvedMemberEmail] = [] + for account_id, email in rows: + try: + member_emails.append( + ResolvedMemberEmail( + member_id=account_id, + email=NormalizedEmail(email), + ) + ) + except ValueError: + continue + return MemberEmailSnapshot(tuple(member_emails)) diff --git a/api/services/message_extra_content_service.py b/api/services/message_extra_content_service.py new file mode 100644 index 00000000000..56d501b2db1 --- /dev/null +++ b/api/services/message_extra_content_service.py @@ -0,0 +1,121 @@ +"""Restore message approvals through their version-specific storage owners.""" + +import logging +from collections import defaultdict +from collections.abc import Sequence + +from sqlalchemy.orm import Session, sessionmaker + +from core.entities.execution_extra_content import ( + ExecutionExtraContentDomainModel, + HumanInputContent, + HumanInputFormDefinition, + HumanInputFormSubmissionData, +) +from core.workflow.nodes.human_input_v2.runtime import PreparedForm +from factories.human_input_v2_form_presentation import build_form_presentation, render_filled_content +from libs.datetime_utils import to_utc_timestamp +from repositories.execution_extra_content_repository import ExecutionExtraContentRepository, HumanInputFormReference +from repositories.sqlalchemy_api_workflow_node_execution_repository import ( + DifyAPISQLAlchemyWorkflowNodeExecutionRepository, +) +from services.human_input_v2.form_service import load_prepared_forms + +logger = logging.getLogger(__name__) + + +def load_message_extra_contents( + *, + repository: ExecutionExtraContentRepository, + message_ids: Sequence[str], + session_factory: sessionmaker[Session], +) -> list[list[ExecutionExtraContentDomainModel]]: + """The caller admits the messages; preserve their content and form ordering. + + V1 content is already projected by the legacy repository. V2 references + recover only their original initiator grants, without creating deliveries. + """ + contents = repository.get_by_message_ids(message_ids) + references_by_run: dict[str, list[HumanInputFormReference]] = defaultdict(list) + for message_contents in contents: + for content in message_contents: + if isinstance(content, HumanInputFormReference): + references_by_run[content.workflow_run_id].append(content) + + restored: dict[HumanInputFormReference, HumanInputContent] = {} + nodes = DifyAPISQLAlchemyWorkflowNodeExecutionRepository(session_maker=session_factory) + for run_id, references in references_by_run.items(): + prepared_forms = load_prepared_forms( + workflow_run_id=run_id, + form_ids=[reference.form_id for reference in references], + session_factory=session_factory, + ) + if not prepared_forms: + logger.warning("No approval forms found for message history: workflow_run_id=%s", run_id) + continue + owner = next(iter(prepared_forms.values())).form + executions = nodes.get_executions_by_workflow_run(str(owner.tenant_id), owner.app_id, run_id) + node_ids = {execution.id: execution.node_id for execution in executions} + for reference in references: + prepared = prepared_forms.get(reference.form_id) + execution_id = prepared.form.node_execution_id if prepared is not None else None + node_id = node_ids.get(execution_id) if execution_id is not None else None + if prepared is None or node_id is None: + logger.warning( + "Approval form or execution missing from message history: workflow_run_id=%s form_id=%s", + run_id, + reference.form_id, + ) + continue + restored[reference] = _restore_v2_content(prepared, workflow_run_id=run_id, node_id=node_id) + + resolved: list[list[ExecutionExtraContentDomainModel]] = [] + for message_contents in contents: + message_resolved: list[ExecutionExtraContentDomainModel] = [] + for content in message_contents: + if isinstance(content, HumanInputFormReference): + if (form_content := restored.get(content)) is not None: + message_resolved.append(form_content) + else: + message_resolved.append(content) + resolved.append(message_resolved) + return resolved + + +def _restore_v2_content(prepared: PreparedForm, *, workflow_run_id: str, node_id: str) -> HumanInputContent: + form = prepared.form + presentation = build_form_presentation(form.resolved_form) + definition = HumanInputFormDefinition( + form_id=form.id, + form_version="2", + node_id=node_id, + node_title=form.resolved_form.title, + form_content=presentation.form_content, + inputs=presentation.inputs, + actions=presentation.user_actions, + resolved_default_values=presentation.resolved_default_values, + expiration_time=to_utc_timestamp(form.expiration_time), + display_in_ui=prepared.form_token is not None, + form_token=prepared.form_token.get_secret_value() if prepared.form_token is not None else None, + ) + submission = form.submission + submitted = None + if submission is not None: + action_text = next( + (action.title for action in form.resolved_form.actions if action.id == submission.selected_action_id), + submission.selected_action_id, + ) + submitted = HumanInputFormSubmissionData( + node_id=node_id, + node_title=form.resolved_form.title, + rendered_content=render_filled_content(form.resolved_form, submission.normalized_submission_data), + action_id=submission.selected_action_id, + action_text=action_text, + submitted_data=submission.raw_submission_inputs, + ) + return HumanInputContent( + workflow_run_id=workflow_run_id, + submitted=submission is not None, + form_definition=definition, + form_submission_data=submitted, + ) diff --git a/api/services/message_query_adapters.py b/api/services/message_query_adapters.py index 0d4a44ec365..301348a4f21 100644 --- a/api/services/message_query_adapters.py +++ b/api/services/message_query_adapters.py @@ -4,6 +4,7 @@ from collections.abc import Sequence from typing import cast from pydantic import JsonValue +from sqlalchemy.orm import Session, sessionmaker from core.app.file_access import DatabaseFileAccessController from factories import file_factory @@ -17,6 +18,7 @@ from services.entities.message_entities import ( MessageFileReference, MessageInputValue, ) +from services.message_extra_content_service import load_message_extra_contents class MessageFileResolver: @@ -101,11 +103,17 @@ class MessageFileResolver: class ExecutionExtraContentReader: - def __init__(self, *, repository: ExecutionExtraContentRepository) -> None: + def __init__(self, *, repository: ExecutionExtraContentRepository, session_factory: sessionmaker[Session]) -> None: self._repository: ExecutionExtraContentRepository = repository + self._session_factory: sessionmaker[Session] = session_factory def get_by_message_ids(self, message_ids: Sequence[str]) -> list[list[dict[str, JsonValue]]]: + contents = load_message_extra_contents( + repository=self._repository, + message_ids=message_ids, + session_factory=self._session_factory, + ) return [ [cast(dict[str, JsonValue], item.model_dump(mode="json", exclude_none=True)) for item in items] - for items in self._repository.get_by_message_ids(message_ids) + for items in contents ] diff --git a/api/services/rbac/builtin.py b/api/services/rbac/builtin.py index ab9e7ec723a..7aa3405ca6e 100644 --- a/api/services/rbac/builtin.py +++ b/api/services/rbac/builtin.py @@ -345,7 +345,7 @@ def builtin_permissions(role: TenantAccountRole | str | None) -> MyPermissionsRe _BUILTIN_ROLE_PERMISSION_KEYS: dict[str, list[str]] = { # This is a compatibility projection from the pre-RBAC workspace roles into - # the 2.0 permission matrix documented in "权限整理2.0". It intentionally + # the matrix documented in "Permissions 2.0". It intentionally # models the product-facing role surface for the new RBAC UI instead of the # legacy backend's exact hard-authorization checks. "owner": [ diff --git a/api/services/workflow_event_snapshot_service.py b/api/services/workflow_event_snapshot_service.py index 4f3dfd1c7b5..75d73408669 100644 --- a/api/services/workflow_event_snapshot_service.py +++ b/api/services/workflow_event_snapshot_service.py @@ -7,12 +7,13 @@ import queue import threading import time from collections.abc import Generator, Mapping, Sequence -from dataclasses import dataclass +from dataclasses import dataclass, replace from typing import Any from sqlalchemy import desc, select from sqlalchemy.orm import Session, sessionmaker +from core.app.apps.common.workflow_response_converter import WorkflowResponseConverter from core.app.apps.message_generator import MessageGenerator from core.app.entities.app_invoke_entities import AdvancedChatAppGenerateEntity from core.app.entities.task_entities import ( @@ -37,7 +38,6 @@ from core.workflow.human_input_policy import ( ) from core.workflow.nodes.human_input.enums import HumanInputFormStatus from core.workflow.nodes.human_input.pause_reason import ( - DifyHITLEventType, HumanInputRequired, PauseReason, ) @@ -55,6 +55,7 @@ from repositories.api_workflow_node_execution_repository import WorkflowNodeExec from repositories.entities.workflow_pause import WorkflowPauseEntity from repositories.factory import DifyAPIRepositoryFactory from services.app_task_service import AppTaskService +from services.workflow_pause_service import WorkflowPauseSnapshot, load_workflow_pause_snapshot logger = logging.getLogger(__name__) @@ -67,6 +68,13 @@ class MessageContext: answer: str | None = None +@dataclass(frozen=True) +class _LegacyFormSnapshot: + dispositions_by_form_id: Mapping[str, FormDisposition] + expiration_times_by_form_id: Mapping[str, int] + display_in_ui_by_form_id: Mapping[str, bool] + + @dataclass class BufferState: queue: queue.Queue[Mapping[str, Any]] @@ -188,17 +196,26 @@ def build_workflow_event_stream( try: task_id = _resolve_task_id(resumption_context, buffer_state, workflow_run.id) - snapshot_events = _build_snapshot_events( - workflow_run=workflow_run, - node_snapshots=node_snapshots, - task_id=task_id, - message_context=message_context, - pause_entity=pause_entity, - resumption_context=resumption_context, - session_maker=session_maker, - human_input_surface=human_input_surface, - node_executions=node_executions, - ) + if workflow_run.status == WorkflowExecutionStatus.PAUSED and pause_entity is not None: + snapshot_events = _build_paused_snapshot_events( + workflow_run=workflow_run, + node_snapshots=node_snapshots, + task_id=task_id, + message_context=message_context, + node_executions=node_executions, + pause_entity=pause_entity, + resumption_context=resumption_context, + session_maker=session_maker, + human_input_surface=human_input_surface, + ) + else: + snapshot_events = _build_snapshot_events( + workflow_run=workflow_run, + node_snapshots=node_snapshots, + task_id=task_id, + message_context=message_context, + node_executions=node_executions, + ) for event in snapshot_events: last_msg_time = time.time() @@ -337,14 +354,9 @@ def _build_snapshot_events( node_snapshots: Sequence[WorkflowNodeExecutionSnapshot], task_id: str, message_context: MessageContext | None, - pause_entity: WorkflowPauseEntity | None, - resumption_context: WorkflowResumptionContext | None, - session_maker: sessionmaker[Session] | None = None, - human_input_surface: HumanInputSurface | None = None, node_executions: Mapping[str, WorkflowNodeExecutionModel], ) -> list[Mapping[str, Any]]: events: list[Mapping[str, Any]] = [] - variable_pool = _load_variable_pool_from_resumption_context(resumption_context) workflow_started = _build_workflow_started_event( workflow_run=workflow_run, @@ -379,35 +391,79 @@ def _build_snapshot_events( _apply_message_context(node_finished, message_context) events.append(node_finished) - pause_reasons: list[PauseReason] = [] - if workflow_run.status == WorkflowExecutionStatus.PAUSED and pause_entity is not None: - pause_reasons = _filter_open_pause_reasons(pause_entity.get_pause_reasons(), session_maker=session_maker) + return events - if pause_reasons: - for human_input_event in _build_human_input_required_events( - workflow_run_id=workflow_run.id, - task_id=task_id, - pause_reasons=pause_reasons, + +def _build_paused_snapshot_events( + *, + workflow_run: WorkflowRun, + node_snapshots: Sequence[WorkflowNodeExecutionSnapshot], + task_id: str, + message_context: MessageContext | None, + pause_entity: WorkflowPauseEntity, + node_executions: Mapping[str, WorkflowNodeExecutionModel], + resumption_context: WorkflowResumptionContext | None, + session_maker: sessionmaker[Session], + human_input_surface: HumanInputSurface | None = None, +) -> list[Mapping[str, Any]]: + events = _build_snapshot_events( + workflow_run=workflow_run, + node_snapshots=node_snapshots, + task_id=task_id, + message_context=message_context, + node_executions=node_executions, + ) + pause_snapshot = load_workflow_pause_snapshot( + pause_entity, session_factory=session_maker, context=resumption_context + ) + open_legacy_ids = { + reason.form_id + for reason in _filter_open_pause_reasons( + [ + reason + for reason in pause_snapshot.reasons + if isinstance(reason, HumanInputRequired) and reason.form_version == "1" + ], session_maker=session_maker, - human_input_surface=human_input_surface, - variable_pool=variable_pool, - ): - _apply_message_context(human_input_event, message_context) - events.append(human_input_event) - - pause_event = _build_pause_event( + ) + if isinstance(reason, HumanInputRequired) + } + pending_reasons = tuple( + reason + for reason in pause_snapshot.reasons + if not isinstance(reason, HumanInputRequired) + or ( + reason.form_id in open_legacy_ids + if reason.form_version == "1" + else pause_snapshot.v2_forms[reason.form_id].form.status == HumanInputFormStatus.WAITING + ) + ) + if not pending_reasons: + return events + pause_snapshot = replace(pause_snapshot, reasons=pending_reasons) + legacy_forms = _load_legacy_form_snapshot( + pause_snapshot, + session_maker=session_maker, + human_input_surface=human_input_surface, + ) + pause_events = _build_human_input_required_events( + workflow_run_id=workflow_run.id, + task_id=task_id, + pause_snapshot=pause_snapshot, + legacy_forms=legacy_forms, + ) + pause_events.append( + _build_pause_event( workflow_run=workflow_run, workflow_run_id=workflow_run.id, task_id=task_id, - pause_reasons=pause_reasons, - resumption_context=resumption_context, - session_maker=session_maker, - human_input_surface=human_input_surface, + pause_snapshot=pause_snapshot, + legacy_forms=legacy_forms, ) - if pause_event is not None: - _apply_message_context(pause_event, message_context) - events.append(pause_event) - + ) + for event in pause_events: + _apply_message_context(event, message_context) + events.append(event) return events @@ -498,21 +554,22 @@ def _filter_open_pause_reasons( ] -def _build_human_input_required_events( +def _load_legacy_form_snapshot( + pause_snapshot: WorkflowPauseSnapshot, *, - workflow_run_id: str, - task_id: str, - pause_reasons: Sequence[PauseReason], - session_maker: sessionmaker[Session] | None, + session_maker: sessionmaker[Session], human_input_surface: HumanInputSurface | None, - variable_pool: ReadOnlyVariablePool | None, -) -> list[dict[str, Any]]: - human_input_form_ids = [reason.form_id for reason in pause_reasons if isinstance(reason, HumanInputRequired)] +) -> _LegacyFormSnapshot: + human_input_form_ids = [ + reason.form_id + for reason in pause_snapshot.reasons + if isinstance(reason, HumanInputRequired) and reason.form_version == "1" + ] expiration_times_by_form_id: dict[str, int] = {} display_in_ui_by_form_id: dict[str, bool] = {} dispositions_by_form_id: dict[str, FormDisposition] = {} - if human_input_form_ids and session_maker is not None: + if human_input_form_ids: stmt = select(HumanInputForm.id, HumanInputForm.expiration_time, HumanInputForm.form_definition).where( HumanInputForm.id.in_(human_input_form_ids) ) @@ -530,14 +587,38 @@ def _build_human_input_required_events( surface=human_input_surface, ) + return _LegacyFormSnapshot(dispositions_by_form_id, expiration_times_by_form_id, display_in_ui_by_form_id) + + +def _build_human_input_required_events( + *, + workflow_run_id: str, + task_id: str, + pause_snapshot: WorkflowPauseSnapshot, + legacy_forms: _LegacyFormSnapshot, +) -> list[dict[str, Any]]: + reasons = pause_snapshot.reasons + v2_forms = pause_snapshot.v2_forms + variable_pool = pause_snapshot.runtime_state.variable_pool events: list[dict[str, Any]] = [] - for reason in pause_reasons: + for reason in reasons: if not isinstance(reason, HumanInputRequired): continue + if reason.form_version == "2": + prepared = v2_forms.get(reason.form_id) + if prepared is not None: + response = HumanInputRequiredResponse( + task_id=task_id, + workflow_run_id=workflow_run_id, + data=WorkflowResponseConverter.human_input_v2_required_data(prepared, node_id=reason.node_id), + ) + events.append(response.model_dump(mode="json")) + continue + form_id = reason.form_id - expiration_time = expiration_times_by_form_id.get(form_id) + expiration_time = legacy_forms.expiration_times_by_form_id.get(form_id) if expiration_time is None: continue @@ -545,19 +626,20 @@ def _build_human_input_required_events( reason.inputs, variable_pool=variable_pool, ) - disposition = dispositions_by_form_id.get(form_id) + disposition = legacy_forms.dispositions_by_form_id.get(form_id) response = HumanInputRequiredResponse( task_id=task_id, workflow_run_id=workflow_run_id, data=HumanInputRequiredResponse.Data( + form_version=reason.form_version, form_id=form_id, node_id=reason.node_id, node_title=reason.node_title, form_content=reason.form_content, inputs=resolved_inputs, actions=reason.actions, - display_in_ui=display_in_ui_by_form_id.get(form_id, False), + display_in_ui=legacy_forms.display_in_ui_by_form_id.get(form_id, False), form_token=disposition.form_token if disposition else None, approval_channels=list(disposition.approval_channels) if disposition else [], resolved_default_values=reason.resolved_default_values, @@ -632,21 +714,15 @@ def _build_pause_event( workflow_run: WorkflowRun, workflow_run_id: str, task_id: str, - pause_reasons: Sequence[PauseReason], - resumption_context: WorkflowResumptionContext | None, - session_maker: sessionmaker[Session] | None, - human_input_surface: HumanInputSurface | None = None, -) -> dict[str, Any] | None: - paused_nodes: list[str] = [] - outputs: dict[str, Any] = {} - variable_pool: ReadOnlyVariablePool | None = None - if resumption_context is not None: - state = RuntimeState.from_snapshot(resumption_context.serialized_graph_runtime_state) - outputs = dict(WorkflowRuntimeTypeConverter().to_json_encodable(state.outputs or {})) - variable_pool = state.variable_pool + pause_snapshot: WorkflowPauseSnapshot, + legacy_forms: _LegacyFormSnapshot, +) -> dict[str, Any]: + state = pause_snapshot.runtime_state + outputs = dict(WorkflowRuntimeTypeConverter().to_json_encodable(state.outputs or {})) + variable_pool = state.variable_pool resolved_pause_reasons = resolve_human_input_pause_reason_inputs( - pause_reasons, + pause_snapshot.reasons, variable_pool=variable_pool, ) paused_nodes = list( @@ -654,35 +730,31 @@ def _build_pause_event( ) reasons = [reason.model_dump(mode="json") for reason in resolved_pause_reasons] human_input_form_ids = [ - form_id - for reason in reasons - if reason.get("TYPE") == DifyHITLEventType.HUMAN_INPUT_REQUIRED - for form_id in [reason.get("form_id")] - if isinstance(form_id, str) + reason.form_id + for reason in resolved_pause_reasons + if isinstance(reason, HumanInputRequired) and reason.form_version == "1" ] - dispositions_by_form_id: dict[str, FormDisposition] = {} - expiration_times_by_form_id: dict[str, int] = {} - if human_input_form_ids and session_maker is not None: - with session_maker() as session: - dispositions_by_form_id = load_form_dispositions_by_form_id( - human_input_form_ids, - session=session, - surface=human_input_surface, - ) - stmt = select(HumanInputForm.id, HumanInputForm.expiration_time).where( - HumanInputForm.id.in_(human_input_form_ids) - ) - for row in session.execute(stmt): - form_id, expiration_time, *_rest = row - expiration_times_by_form_id[str(form_id)] = to_utc_timestamp(expiration_time) + if human_input_form_ids: # Reconnect paths must preserve the same pause-reason contract as live streams; # otherwise clients see schema drift after resume. reasons = enrich_human_input_pause_reasons( reasons, - dispositions_by_form_id=dispositions_by_form_id, - expiration_times_by_form_id=expiration_times_by_form_id, + dispositions_by_form_id=legacy_forms.dispositions_by_form_id, + expiration_times_by_form_id=legacy_forms.expiration_times_by_form_id, ) + v2_forms = pause_snapshot.v2_forms + for reason in resolved_pause_reasons: + if not isinstance(reason, HumanInputRequired) or reason.form_version != "2": + continue + prepared = v2_forms.get(reason.form_id) + if prepared is None: + continue + presentation = WorkflowResponseConverter.human_input_v2_required_data(prepared, node_id=reason.node_id) + for payload in reasons: + if payload.get("form_id") == reason.form_id: + payload.update(presentation.model_dump(mode="json")) + response = WorkflowPauseStreamResponse( task_id=task_id, workflow_run_id=workflow_run_id, diff --git a/api/services/workflow_pause_service.py b/api/services/workflow_pause_service.py new file mode 100644 index 00000000000..056bd23b377 --- /dev/null +++ b/api/services/workflow_pause_service.py @@ -0,0 +1,82 @@ +"""Resolve paused-run presentation from the persisted graph checkpoint.""" + +from collections.abc import Mapping, Sequence +from dataclasses import dataclass + +from sqlalchemy.orm import Session, sessionmaker + +from core.app.layers.pause_state_persist_layer import WorkflowResumptionContext +from core.workflow.nodes.human_input.pause_reason import HumanInputRequired, PauseReason +from core.workflow.nodes.human_input.session_binding import default_session_binding +from core.workflow.nodes.human_input_v2.presentation import build_human_input_v2_pause_reason +from core.workflow.nodes.human_input_v2.runtime import PreparedForm +from graphon.entities.pause_reason import HitlRequired +from graphon.runtime import RuntimeState +from repositories.entities.workflow_pause import WorkflowPauseEntity +from repositories.factory import DifyAPIRepositoryFactory +from services.human_input_v2.form_service import load_prepared_forms + + +@dataclass(frozen=True) +class WorkflowPauseSnapshot: + runtime_state: RuntimeState + reasons: Sequence[PauseReason] + v2_forms: Mapping[str, PreparedForm] + + +def load_workflow_pause_snapshot( + pause: WorkflowPauseEntity, + *, + session_factory: sessionmaker[Session], + context: WorkflowResumptionContext | None = None, +) -> WorkflowPauseSnapshot: + """The authorized pause's graph determines which approvals are still pending. + + Only a v1 reference may consult the legacy reason table. A missing or invalid + v2 checkpoint must not fall back to that table or to all forms in the run. + """ + if context is None: + context = WorkflowResumptionContext.loads(pause.get_state().decode()) + state = RuntimeState.from_snapshot(context.serialized_graph_runtime_state) + graph_reasons = state.graph_execution.pause_reasons + v2_ids: list[str] = [] + has_v1 = False + for reason in graph_reasons: + if not isinstance(reason, HitlRequired): + continue + version, form_id = default_session_binding.resolve_form_id_from_session_id(session_id=reason.session_id) + if version == "2": + v2_ids.append(form_id) + else: + has_v1 = True + legacy: dict[str, HumanInputRequired] = {} + if has_v1: + repository = DifyAPIRepositoryFactory.create_api_workflow_run_repository(session_factory) + with session_factory() as session: + legacy = { + reason.form_id: reason + for reason in repository.get_legacy_pause_reasons(session, pause.id) + if isinstance(reason, HumanInputRequired) + } + forms: Mapping[str, PreparedForm] = {} + if v2_ids: + forms = load_prepared_forms( + workflow_run_id=pause.workflow_execution_id, form_ids=v2_ids, session_factory=session_factory + ) + reasons: list[PauseReason] = [] + for reason in graph_reasons: + if not isinstance(reason, HitlRequired): + reasons.append(reason) + continue + version, form_id = default_session_binding.resolve_form_id_from_session_id(session_id=reason.session_id) + if version == "1": + if (legacy_reason := legacy.get(form_id)) is not None: + reasons.append(legacy_reason) + continue + prepared = forms.get(form_id) + if prepared is None: + raise ValueError(f"Human Input v2 form is missing from its paused run: {form_id}") + reasons.append( + build_human_input_v2_pause_reason(prepared.form.resolved_form, form_id=form_id, node_id=reason.node_id) + ) + return WorkflowPauseSnapshot(state, tuple(reasons), forms) diff --git a/api/services/workflow_run_agg.py b/api/services/workflow_run_agg.py index 06e4ad491f2..d0730d7b106 100644 --- a/api/services/workflow_run_agg.py +++ b/api/services/workflow_run_agg.py @@ -32,6 +32,9 @@ from core.workflow.nodes.human_input.boundary import build_human_input_pause_rea from core.workflow.nodes.human_input.enums import HumanInputFormStatus from core.workflow.nodes.human_input.pause_reason import PauseReason from core.workflow.nodes.human_input.session_binding import default_session_binding +from core.workflow.nodes.human_input_v2.events import NodeRunHumanInputV2FormRequiredEvent +from core.workflow.nodes.human_input_v2.presentation import resolve_human_input_v2_pause_reason +from core.workflow.nodes.human_input_v2.runtime import PreparedForm from core.workflow.system_variables import SystemVariableKey, get_system_text from core.workflow.workflow_tool_container_types import WorkflowToolContainerPayload from extensions.otel import WorkflowAppRunnerHandler, trace_span @@ -124,10 +127,12 @@ class WorkflowRunAgg: node_factory = self._entry.graph_engine.graph.node_factory if isinstance(node_factory, DifyNodeFactory): node_factory.workflow_tools.update(materialized_tools.tools) + self._human_input_v2_forms: dict[str, PreparedForm] = {} self._pending_forms = { - default_session_binding.resolve_form_id_from_session_id(session_id=reason.session_id): reason + default_session_binding.resolve_form_id_from_session_id(session_id=reason.session_id)[1]: reason for reason in self._runtime_state.graph_execution.pause_reasons if isinstance(reason, HitlRequired) + and default_session_binding.resolve_form_id_from_session_id(session_id=reason.session_id)[0] == "1" } self._published_form_ids: set[str] = set() self._form_repository = HumanInputFormSubmissionRepository() @@ -227,6 +232,7 @@ class WorkflowRunAgg: node_run_index=aggregate.index.index_for(event.id) if isinstance(event, NodeRunStartedEvent) else 1, node_execution_snapshots=aggregate.index.root_snapshots, pause_reasons=aggregate.pause_reasons, + human_input_v2_forms=aggregate._human_input_v2_forms, ) def iter_events(self) -> Generator[EngineEvent]: @@ -235,6 +241,8 @@ class WorkflowRunAgg: try: with closing(self._entry.run()) as events: for event in events: + if isinstance(event, NodeRunHumanInputV2FormRequiredEvent): + self._human_input_v2_forms[event.prepared_form.form.id] = event.prepared_form if isinstance(event, GraphRunPausedEvent): paused_event = event continue @@ -323,16 +331,26 @@ class WorkflowRunAgg: def _persist_pause(self, event: GraphRunPausedEvent) -> None: form_ids = tuple( - default_session_binding.resolve_form_id_from_session_id(session_id=reason.session_id) + form_id for reason in event.reasons if isinstance(reason, HitlRequired) + for version, form_id in [ + default_session_binding.resolve_form_id_from_session_id(session_id=reason.session_id) + ] + if version == "1" ) - forms = self._load_forms(form_ids) + forms = self._load_forms(form_ids) if form_ids else {} self.pause_reasons = [ - build_human_input_pause_reason( - reason=reason, - record=forms[default_session_binding.resolve_form_id_from_session_id(session_id=reason.session_id)], - variable_pool=self._runtime_state.variable_pool, + ( + build_human_input_pause_reason( + reason=reason, + record=forms[ + default_session_binding.resolve_form_id_from_session_id(session_id=reason.session_id)[1] + ], + variable_pool=self._runtime_state.variable_pool, + ) + if default_session_binding.resolve_form_id_from_session_id(session_id=reason.session_id)[0] == "1" + else resolve_human_input_v2_pause_reason(reason=reason, forms=self._human_input_v2_forms) ) if isinstance(reason, HitlRequired) else reason diff --git a/api/services/workflow_run_service.py b/api/services/workflow_run_service.py index 11b58e0e3d0..399fd6647cd 100644 --- a/api/services/workflow_run_service.py +++ b/api/services/workflow_run_service.py @@ -1,7 +1,9 @@ import threading from dataclasses import dataclass from datetime import datetime -from typing import TypedDict +from typing import Literal, TypedDict + +from sqlalchemy.orm import Session, sessionmaker import contexts from core.workflow.node_execution_process_data import ( @@ -23,6 +25,7 @@ from services.workflow_node_execution_trace_service import ( WorkflowNodeExecutionTrace, assemble_workflow_node_execution_traces, ) +from services.workflow_pause_service import load_workflow_pause_snapshot class WorkflowRunListArgs(TypedDict, total=False): @@ -38,6 +41,7 @@ class WorkflowRunPausedNode: node_id: str node_title: str form_id: str + form_version: Literal["1", "2"] form_token: str | None @@ -53,9 +57,11 @@ class WorkflowRunService: *, workflow_runs: DifyAPISQLAlchemyWorkflowRunRepository, node_executions: DifyAPIWorkflowNodeExecutionRepository, + session_factory: sessionmaker[Session], ) -> None: self._workflow_runs = workflow_runs self._node_executions = node_executions + self._session_factory = session_factory def get_paginate_advanced_chat_workflow_runs( self, @@ -247,29 +253,46 @@ class WorkflowRunService: *, workflow_run_id: str, ) -> WorkflowRunPauseDetails | None: - pause_record = self._workflow_runs.get_pause_record( - workspace_id=context.active_workspace_id, - workflow_run_id=workflow_run_id, - ) + with self._session_factory() as session: + pause_record = self._workflow_runs.get_pause_record( + session, + workspace_id=context.active_workspace_id, + workflow_run_id=workflow_run_id, + ) if pause_record is None: return None - if pause_record.status != WorkflowExecutionStatus.PAUSED: + if pause_record.status != WorkflowExecutionStatus.PAUSED or pause_record.pause is None: return WorkflowRunPauseDetails(paused_at=None, paused_nodes=()) + snapshot = load_workflow_pause_snapshot(pause_record.pause, session_factory=self._session_factory) human_input_reasons: list[HumanInputRequired] = [] - for reason in pause_record.reasons: + for reason in snapshot.reasons: if not isinstance(reason, HumanInputRequired): raise NotImplementedError(f"Pause details do not support {type(reason).__name__}") human_input_reasons.append(reason) + legacy_form_ids = [reason.form_id for reason in human_input_reasons if reason.form_version == "1"] + form_tokens: dict[str, str] = {} + if legacy_form_ids: + with self._session_factory() as session: + form_tokens = self._workflow_runs.get_legacy_form_tokens(session, form_ids=legacy_form_ids) + form_tokens.update( + { + form_id: prepared.form_token.get_secret_value() + for form_id, prepared in snapshot.v2_forms.items() + if prepared.form_token is not None + } + ) + return WorkflowRunPauseDetails( - paused_at=pause_record.paused_at, + paused_at=pause_record.pause.paused_at, paused_nodes=tuple( WorkflowRunPausedNode( node_id=reason.node_id, node_title=reason.node_title, form_id=reason.form_id, - form_token=pause_record.form_tokens.get(reason.form_id), + form_version=reason.form_version, + form_token=form_tokens.get(reason.form_id), ) for reason in human_input_reasons ), diff --git a/api/services/workflow_tool_source_service.py b/api/services/workflow_tool_source_service.py index 7229d98cc0a..1aef10dace3 100644 --- a/api/services/workflow_tool_source_service.py +++ b/api/services/workflow_tool_source_service.py @@ -11,6 +11,7 @@ from core.tools.entities.tool_entities import ToolEntity from core.tools.workflow_as_tool.repository import WorkflowToolSource from core.tools.workflow_as_tool.tool import WorkflowTool from core.workflow.workflow_tool_container_types import WorkflowToolContainerPayload +from graphon.nodes.tool.exc import ToolRuntimeResolutionError from repositories.workflow_tool_source_repository import SQLAlchemyWorkflowToolSourceRepository @@ -129,4 +130,10 @@ class WorkflowToolSourceService: if source.workflow_id not in sources: pending.append(source.graph_config) sources[source.workflow_id] = source + for source in sources.values(): + if any( + node.get("data", {}).get("type") == "human-input" and node.get("data", {}).get("version") == "2" + for node in source.graph_config.get("nodes", []) + ): + raise ToolRuntimeResolutionError("Human Input V2 nodes are not supported in nested Workflow Tools.") return MaterializedWorkflowTools(sources=sources, tools=tools) diff --git a/api/tasks/human_input_im_card_tasks.py b/api/tasks/human_input_im_card_tasks.py new file mode 100644 index 00000000000..a0ba0666c19 --- /dev/null +++ b/api/tasks/human_input_im_card_tasks.py @@ -0,0 +1,31 @@ +"""Independent Celery follow-up for one committed Human Input card delivery.""" + +import logging + +from celery import shared_task +from sqlalchemy.exc import SQLAlchemyError + +from core.db.session_factory import session_factory +from core.human_input_v2.shared.values import TenantId +from services.human_input_v2.im_card_service import HumanInputIMCardService, IMCardUpdateRetryableError +from services.human_input_v2.im_credential_codec import IMCredentialError + +logger = logging.getLogger(__name__) +_MAX_RETRIES = 5 + + +@shared_task( + queue="human_input_delivery", + autoretry_for=(IMCardUpdateRetryableError, IMCredentialError, SQLAlchemyError, OSError), + retry_backoff=True, + retry_kwargs={"max_retries": _MAX_RETRIES}, + acks_late=True, + reject_on_worker_lost=True, +) +def update_human_input_im_card_task(tenant_id: str, form_id: str, delivery_id: str) -> None: + logger.info( + "Human Input IM card update received tenant_id=%s form_id=%s delivery_id=%s", tenant_id, form_id, delivery_id + ) + HumanInputIMCardService(session_factory=session_factory.get_session_maker()).update_card( + tenant_id=TenantId(tenant_id), form_id=form_id, delivery_id=delivery_id + ) diff --git a/api/tasks/human_input_timeout_tasks.py b/api/tasks/human_input_timeout_tasks.py index acaccf2474c..228eb523b81 100644 --- a/api/tasks/human_input_timeout_tasks.py +++ b/api/tasks/human_input_timeout_tasks.py @@ -2,10 +2,13 @@ import logging from datetime import timedelta from celery import shared_task +from kombu.exceptions import OperationalError as BrokerOperationalError from sqlalchemy import or_, select from sqlalchemy.orm import sessionmaker from configs import dify_config +from core.db.session_factory import session_factory as v2_session_factory +from core.human_input_v2.shared.values import TenantId from core.repositories.human_input_repository import HumanInputFormSubmissionRepository from core.workflow.nodes.human_input.enums import HumanInputFormKind, HumanInputFormStatus from extensions.ext_database import db @@ -14,11 +17,53 @@ from graphon.enums import WorkflowExecutionStatus from libs.datetime_utils import ensure_naive_utc, naive_utc_now from models.human_input import HumanInputForm from models.workflow import WorkflowPause, WorkflowRun +from repositories.human_input_v2.sqlalchemy_form_repository import SQLAlchemyFormExpirationRepository from services.human_input_service import HumanInputService +from services.human_input_v2.form_service import handle_form_timeouts +from tasks.app_generate.workflow_execute_task import WORKFLOW_BASED_APP_EXECUTION_QUEUE logger = logging.getLogger(__name__) +@shared_task(name="human_input_form_timeout.check_and_resume", queue="schedule_executor") +def check_and_handle_human_input_timeouts(limit: int = 100) -> None: + """Keep legacy expiration handling and fan out independent v2 timeout tasks.""" + _handle_legacy_human_input_timeouts(limit) + _enqueue_human_input_v2_timeouts(limit) + + +def _enqueue_human_input_v2_timeouts(limit: int) -> None: + """Discover candidate runs without settling forms in the periodic task.""" + now = naive_utc_now() + with v2_session_factory.create_session() as session: + runs = SQLAlchemyFormExpirationRepository(session).list_expired_runs(now=now, limit=limit) + + for run in runs: + try: + handle_human_input_v2_timeout.apply_async( + kwargs={"tenant_id": run.tenant_id, "app_id": run.app_id, "workflow_run_id": run.workflow_run_id} + ) + except BrokerOperationalError: + logger.exception( + "Failed to enqueue v2 timeout: tenant_id=%s app_id=%s workflow_run_id=%s", + run.tenant_id, + run.app_id, + run.workflow_run_id, + ) + + +@shared_task(name="human_input_v2_form_timeout.handle", queue=WORKFLOW_BASED_APP_EXECUTION_QUEUE) +def handle_human_input_v2_timeout(tenant_id: str, app_id: str, workflow_run_id: str) -> None: + """Settle one run using current persisted state, then enqueue resume if due.""" + logger.info("Handling v2 timeout: tenant_id=%s app_id=%s workflow_run_id=%s", tenant_id, app_id, workflow_run_id) + handle_form_timeouts( + session_factory=v2_session_factory.get_session_maker(), + tenant_id=TenantId(tenant_id), + app_id=app_id, + workflow_run_id=workflow_run_id, + ) + + def _is_global_timeout(form_model: HumanInputForm, global_timeout_seconds: int, *, now) -> bool: if global_timeout_seconds <= 0: return False @@ -53,8 +98,7 @@ def _handle_global_timeout(*, form_id: str, workflow_run_id: str, node_id: str, session.add(pause_model) -@shared_task(name="human_input_form_timeout.check_and_resume", queue="schedule_executor") -def check_and_handle_human_input_timeouts(limit: int = 100) -> None: +def _handle_legacy_human_input_timeouts(limit: int) -> None: """Scan for expired human input forms and resume or end workflows.""" session_factory = sessionmaker(bind=db.engine, expire_on_commit=False) diff --git a/api/tasks/im_contact_sync_tasks.py b/api/tasks/im_contact_sync_tasks.py new file mode 100644 index 00000000000..2937995969d --- /dev/null +++ b/api/tasks/im_contact_sync_tasks.py @@ -0,0 +1,56 @@ +"""Celery entrypoint for durable IM Contact synchronization runs.""" + +from __future__ import annotations + +import logging + +from celery import shared_task + +from core.human_input_v2.shared import DeploymentScope, DirectoryScope, IMSyncRunId, TenantId, WorkspaceScope +from services.human_input_v2.im_contact_sync.coordinator import IMSyncRetryableError +from services.human_input_v2.im_contact_sync.worker import IMContactSyncWorker + +logger = logging.getLogger(__name__) + +_WORKSPACE_SCOPE_KIND = "workspace" +_DEPLOYMENT_SCOPE_KIND = "deployment" + + +def build_im_contact_sync_worker() -> IMContactSyncWorker: + """Defer production composition until a worker delivery is actually claimed.""" + + from services.human_input_v2.im_contact_sync.composition import build_im_contact_sync_worker as build_worker + + return build_worker() + + +@shared_task( + queue="human_input_contact_sync", + autoretry_for=(IMSyncRetryableError,), + retry_backoff=True, + retry_kwargs={"max_retries": 5}, +) +def reconcile_im_contacts_task(sync_run_id: str, scope_kind: str, tenant_id: str | None) -> str: + """Accept durable identifiers and return the persisted terminal run status.""" + + scope = _scope_from_payload(scope_kind, tenant_id) + logger.info("IM Contact sync task received, sync_run_id=%s", sync_run_id) + terminal_run = build_im_contact_sync_worker().execute(IMSyncRunId(sync_run_id), scope) + logger.info( + "IM Contact sync task completed, sync_run_id=%s, channel_id=%s, status=%s", + terminal_run.id, + terminal_run.channel_revision.channel_id, + terminal_run.status.value, + ) + return terminal_run.status.value + + +def _scope_from_payload(scope_kind: str, tenant_id: str | None) -> DirectoryScope: + if scope_kind == _WORKSPACE_SCOPE_KIND and tenant_id is not None: + return WorkspaceScope(id=TenantId(tenant_id)) + if scope_kind == _DEPLOYMENT_SCOPE_KIND and tenant_id is None: + return DeploymentScope() + raise ValueError("invalid Organization scope payload") + + +__all__ = ["reconcile_im_contacts_task"] diff --git a/api/tasks/im_message_inbox_tasks.py b/api/tasks/im_message_inbox_tasks.py new file mode 100644 index 00000000000..5e1554c5e24 --- /dev/null +++ b/api/tasks/im_message_inbox_tasks.py @@ -0,0 +1,91 @@ +"""Celery execution boundary for durable IM callbacks.""" + +from __future__ import annotations + +import logging +from collections.abc import Callable +from dataclasses import dataclass +from traceback import extract_tb +from typing import Protocol + +from celery import shared_task +from flask import current_app + +from core.human_input_v2.im_message_inbox import IMInboxRecordId +from dify_app import DifyApp + +logger = logging.getLogger(__name__) + +_RUNTIME_EXTENSION_KEY = "im_message_inbox_task_runtime" +_MAX_RETRIES = 5 + + +class IMInboxRecordProcessor(Protocol): + """Process one callback record without owning task retry policy.""" + + def process(self, record_id: IMInboxRecordId) -> None: + """Process one durable callback record.""" + + +@dataclass(frozen=True, slots=True) +class IMInboxTaskRuntime: + """Application-composed factory for callback processing.""" + + processor_factory: Callable[[], IMInboxRecordProcessor] + + +class IMInboxRuntimeNotConfiguredError(Exception): + """Application composition has not installed callback processing.""" + + +class IMInboxTaskRetryError(Exception): + """Sanitized callback processing failure retried by Celery.""" + + +def configure_im_inbox_task_runtime( + app: DifyApp, + *, + processor_factory: Callable[[], IMInboxRecordProcessor], +) -> None: + """Install the application-composed callback processor factory.""" + + app.extensions[_RUNTIME_EXTENSION_KEY] = IMInboxTaskRuntime(processor_factory=processor_factory) + + +def _task_runtime() -> IMInboxTaskRuntime: + runtime = current_app.extensions.get(_RUNTIME_EXTENSION_KEY) + if not isinstance(runtime, IMInboxTaskRuntime): + raise IMInboxRuntimeNotConfiguredError("IM callback processor is not configured") + return runtime + + +@shared_task( + name="im_message_inbox.process_record", + queue="human_input_delivery", + autoretry_for=(IMInboxTaskRetryError,), + retry_backoff=True, + retry_kwargs={"max_retries": _MAX_RETRIES}, + acks_late=True, + reject_on_worker_lost=True, +) +def process_im_message_inbox_record(record_id: str) -> None: + """Process one committed callback and let Celery own retry lifecycle.""" + + runtime = _task_runtime() + try: + processor = runtime.processor_factory() + processor.process(IMInboxRecordId(record_id)) + except Exception as error: + # Exception messages and chained SQL parameters may contain callback + # tokens. Source frames still identify the failure without those values. + logger.exception( + "IM callback processing will be retried record_id=%s error_type=%s\n%s", + record_id, + type(error).__name__, + "".join( + f' File "{frame.filename}", line {frame.lineno}, in {frame.name}\n' + for frame in extract_tb(error.__traceback__) + ), + exc_info=False, + ) + raise IMInboxTaskRetryError(f"failed to process IM callback record {record_id}") from None diff --git a/api/tasks/mail_human_input_delivery_task.py b/api/tasks/mail_human_input_delivery_task.py index e2b7e7e0c46..257e2f07e65 100644 --- a/api/tasks/mail_human_input_delivery_task.py +++ b/api/tasks/mail_human_input_delivery_task.py @@ -1,125 +1,41 @@ +"""Celery entrypoints for delivering persisted human input forms. + +The generic form-delivery task must stay independent from email availability so +non-email providers can run even when mail is not configured. The legacy email +task keeps its historical mail and feature gates for callers that still need +email-only semantics. The generic task only checks email availability after +loading persisted delivery contexts and only when an email context is present. +""" + import logging import time -from dataclasses import dataclass +from collections.abc import Sequence import click from celery import shared_task -from pydantic import BaseModel, ConfigDict -from sqlalchemy import select from sqlalchemy.orm import Session, sessionmaker -from configs import dify_config from core.app.layers.pause_state_persist_layer import WorkflowResumptionContext -from core.workflow.human_input_adapter import EmailDeliveryConfig, EmailDeliveryMethod +from core.workflow.human_input_adapter import DeliveryMethodType from extensions.ext_database import db from extensions.ext_mail import mail from graphon.runtime import RuntimeState, VariablePool -from models.human_input import ( - DeliveryMethodType, - HumanInputDelivery, - HumanInputForm, - HumanInputFormRecipient, - RecipientType, -) +from models.human_input import HumanInputForm from repositories.factory import DifyAPIRepositoryFactory from services.feature_service import FeatureService +from services.human_input_form_delivery_provider import ( + HumanInputFormDeliveryContext, + HumanInputFormDeliveryDispatcher, + HumanInputFormDeliveryProviderRegistry, +) logger = logging.getLogger(__name__) - -@dataclass(frozen=True) -class _EmailRecipient: - email: str - token: str +FORM_DELIVERY_METHOD_TYPES = (DeliveryMethodType.EMAIL, DeliveryMethodType.IM) -@dataclass(frozen=True) -class _EmailDeliveryJob: - form_id: str - subject: str - body: str - form_content: str - recipients: list[_EmailRecipient] - - -def _build_form_link(token: str) -> str: - base_url = dify_config.APP_WEB_URL - return f"{base_url.rstrip('/')}/form/{token}" - - -class _RecipientPayload(BaseModel): - """Shape of HumanInputFormRecipient.recipient_payload written by the recipients layer.""" - - model_config = ConfigDict(extra="ignore") - - email: str - TYPE: RecipientType - - -def _parse_recipient_payload(payload: str) -> tuple[str | None, RecipientType | None]: - try: - recipient = _RecipientPayload.model_validate_json(payload) - except Exception: - logger.exception("Failed to parse recipient payload") - return None, None - - return recipient.email, recipient.TYPE - - -def _load_email_jobs(session: Session, form: HumanInputForm) -> list[_EmailDeliveryJob]: - deliveries = session.scalars( - select(HumanInputDelivery).where( - HumanInputDelivery.form_id == form.id, - HumanInputDelivery.delivery_method_type == DeliveryMethodType.EMAIL, - ) - ).all() - jobs: list[_EmailDeliveryJob] = [] - for delivery in deliveries: - delivery_config = EmailDeliveryMethod.model_validate_json(delivery.channel_payload) - - recipients = session.scalars( - select(HumanInputFormRecipient).where(HumanInputFormRecipient.delivery_id == delivery.id) - ).all() - - recipient_entities: list[_EmailRecipient] = [] - for recipient in recipients: - email, recipient_type = _parse_recipient_payload(recipient.recipient_payload) - if recipient_type not in {RecipientType.EMAIL_MEMBER, RecipientType.EMAIL_EXTERNAL}: - continue - if not email: - continue - token = recipient.access_token - if not token: - continue - recipient_entities.append(_EmailRecipient(email=email, token=token)) - - if not recipient_entities: - continue - - jobs.append( - _EmailDeliveryJob( - form_id=form.id, - subject=delivery_config.config.subject, - body=delivery_config.config.body, - form_content=form.rendered_content, - recipients=recipient_entities, - ) - ) - return jobs - - -def _render_body( - body_template: str, - form_link: str, - *, - variable_pool: VariablePool | None, -) -> str: - body = EmailDeliveryConfig.render_body_template( - body=body_template, - url=form_link, - variable_pool=variable_pool, - ) - return EmailDeliveryConfig.render_markdown_body(body) +def _has_email_context(contexts: Sequence[HumanInputFormDeliveryContext]) -> bool: + return any(context.delivery_method_type == DeliveryMethodType.EMAIL for context in contexts) def _load_variable_pool(workflow_run_id: str | None) -> VariablePool | None: @@ -151,12 +67,62 @@ def _open_session(session_factory: sessionmaker | Session | None): return session_factory() -@shared_task(queue="mail") -def dispatch_human_input_email_task(form_id: str, node_title: str | None = None, session_factory=None): - if not mail.is_inited(): +def _filter_unavailable_email_contexts( + *, + contexts: Sequence[HumanInputFormDeliveryContext], + tenant_id: str, + form_id: str, +) -> tuple[HumanInputFormDeliveryContext, ...]: + contexts_tuple = tuple(contexts) + if not _has_email_context(contexts_tuple): + return contexts_tuple + + email_delivery_enabled = False + if mail.is_inited(): + try: + features = FeatureService.get_features(tenant_id, exclude_vector_space=True) + email_delivery_enabled = features.human_input_email_delivery_enabled + except Exception: + logger.exception( + "Failed to check human input email delivery availability; skipping email contexts, " + "tenant=%s, form_id=%s", + tenant_id, + form_id, + ) + + if email_delivery_enabled: + return contexts_tuple + + return tuple(context for context in contexts_tuple if context.delivery_method_type != DeliveryMethodType.EMAIL) + + +def _is_email_delivery_enabled(*, tenant_id: str, form_id: str) -> bool: + features = FeatureService.get_features(tenant_id, exclude_vector_space=True) + email_delivery_enabled = features.human_input_email_delivery_enabled + if not email_delivery_enabled: + logger.info( + "Human input email delivery is not available for tenant=%s, form_id=%s", + tenant_id, + form_id, + ) + return email_delivery_enabled + + +def _load_and_dispatch_form_delivery( + *, + form_id: str, + node_title: str | None, + session_factory, + delivery_method_types: Sequence[DeliveryMethodType] | None, + require_mail_inited: bool, + require_email_feature: bool, + filter_unavailable_email: bool, + log_label: str, +) -> None: + if require_mail_inited and not mail.is_inited(): return - logger.info(click.style(f"Start human input email delivery for form {form_id}", fg="green")) + logger.info(click.style(f"Start human input {log_label} delivery for form {form_id}", fg="green")) start_at = time.perf_counter() try: @@ -165,35 +131,67 @@ def dispatch_human_input_email_task(form_id: str, node_title: str | None = None, if form is None: logger.warning("Human input form not found, form_id=%s", form_id) return - features = FeatureService.get_features(form.tenant_id, exclude_vector_space=True) - if not features.human_input_email_delivery_enabled: - logger.info( - "Human input email delivery is not available for tenant=%s, form_id=%s", - form.tenant_id, - form_id, - ) + + if require_email_feature and not _is_email_delivery_enabled(tenant_id=form.tenant_id, form_id=form_id): return - jobs = _load_email_jobs(session, form) - variable_pool = _load_variable_pool(form.workflow_run_id) + variable_pool = _load_variable_pool(form.workflow_run_id) + registry = HumanInputFormDeliveryProviderRegistry.default(mail_client=mail) + dispatcher = HumanInputFormDeliveryDispatcher(registry=registry) + contexts = dispatcher.load_form_contexts( + session=session, + form=form, + variable_pool=variable_pool, + delivery_method_types=delivery_method_types, + ) - for job in jobs: - for recipient in job.recipients: - form_link = _build_form_link(recipient.token) - body = _render_body(job.body, form_link, variable_pool=variable_pool) - subject = EmailDeliveryConfig.sanitize_subject(job.subject) - - mail.send( - to=recipient.email, - subject=subject, - html=body, - ) + if filter_unavailable_email: + contexts = _filter_unavailable_email_contexts( + contexts=contexts, + tenant_id=form.tenant_id, + form_id=form_id, + ) + dispatcher.dispatch_contexts(contexts) end_at = time.perf_counter() logger.info( click.style( - f"Human input email delivery succeeded for form {form_id}: latency: {end_at - start_at}", fg="green" + f"Human input {log_label} delivery succeeded for form {form_id}: latency: {end_at - start_at}", + fg="green", ) ) except Exception: - logger.exception("Send human input email failed, form_id=%s", form_id) + logger.exception( + "Dispatch human input %s delivery failed, form_id=%s, node_title=%s", + log_label, + form_id, + node_title, + ) + + +@shared_task(queue="mail") +def dispatch_human_input_form_delivery_task(form_id: str, node_title: str | None = None, session_factory=None): + _load_and_dispatch_form_delivery( + form_id=form_id, + node_title=node_title, + session_factory=session_factory, + delivery_method_types=FORM_DELIVERY_METHOD_TYPES, + require_mail_inited=False, + require_email_feature=False, + filter_unavailable_email=True, + log_label="form", + ) + + +@shared_task(queue="mail") +def dispatch_human_input_email_task(form_id: str, node_title: str | None = None, session_factory=None): + _load_and_dispatch_form_delivery( + form_id=form_id, + node_title=node_title, + session_factory=session_factory, + delivery_method_types=(DeliveryMethodType.EMAIL,), + require_mail_inited=True, + require_email_feature=True, + filter_unavailable_email=False, + log_label="email", + ) diff --git a/api/tests/conftest.py b/api/tests/conftest.py index 046023a5840..194211f1af0 100644 --- a/api/tests/conftest.py +++ b/api/tests/conftest.py @@ -1,3 +1,4 @@ +import os from collections.abc import Iterator import pytest @@ -5,6 +6,9 @@ import pytest from core.app.workflow.file_runtime import create_dify_workflow_file_runtime from graphon.file.runtime import use_workflow_file_runtime +# Tests select an explicit deployment event transport. +os.environ.setdefault("HUMAN_INPUT_IM_EVENT_TRANSPORT_MODE", "WEBHOOK") + @pytest.fixture(autouse=True) def _bind_workflow_file_runtime() -> Iterator[None]: diff --git a/api/tests/e2e_tests/containers/commands/test_legacy_model_type_migration.py b/api/tests/e2e_tests/containers/commands/test_legacy_model_type_migration.py index 830cdae3e58..782f4a14142 100644 --- a/api/tests/e2e_tests/containers/commands/test_legacy_model_type_migration.py +++ b/api/tests/e2e_tests/containers/commands/test_legacy_model_type_migration.py @@ -1,17 +1,13 @@ from __future__ import annotations import importlib -import importlib.util import io import json from collections.abc import Generator from datetime import datetime, timedelta -from pathlib import Path import pytest import sqlalchemy as sa -from alembic.migration import MigrationContext -from alembic.operations import Operations from tests.helpers.legacy_model_type_migration import ( assert_tenant_rows_use_only_canonical_model_types, @@ -20,28 +16,6 @@ from tests.helpers.legacy_model_type_migration import ( seed_legacy_model_type_dirty_data, ) -_ALEMBIC_MIGRATION_PATH = ( - Path(__file__).resolve().parents[4] - / "migrations/versions/2026_08_27_1200-5578e028b2f2_migrate_legacy_model_types.py" -) - - -def _run_legacy_model_type_alembic_upgrade(engine: sa.Engine) -> None: - spec = importlib.util.spec_from_file_location("migrate_legacy_model_types", _ALEMBIC_MIGRATION_PATH) - if spec is None or spec.loader is None: - raise RuntimeError("failed to load legacy model type migration") - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - - with engine.begin() as connection: - operations = Operations(MigrationContext.configure(connection)) - original_op = module.__dict__["op"] - module.__dict__["op"] = operations - try: - module.__dict__["upgrade"]() - finally: - module.__dict__["op"] = original_op - def _parse_json_lines(output: io.StringIO) -> list[dict[str, object]]: return [json.loads(line) for line in output.getvalue().splitlines() if line.strip()] @@ -137,6 +111,14 @@ def _insert_load_balancing_model_config( ) +@pytest.fixture(autouse=True) +def reset_legacy_schema(container_engine: tuple[str, sa.Engine]) -> None: + from tests.helpers.legacy_model_type_migration import drop_minimal_legacy_model_type_schema + + _, engine = container_engine + drop_minimal_legacy_model_type_schema(engine) + + @pytest.fixture(scope="session") def migration_module(): try: @@ -176,8 +158,6 @@ def test_legacy_model_type_migration_end_to_end_across_supported_backends( monkeypatch: pytest.MonkeyPatch, ) -> None: backend_name, engine = container_engine - helper_module = importlib.import_module("tests.helpers.legacy_model_type_migration") - helper_module.drop_minimal_legacy_model_type_schema(engine) fixture = seed_legacy_model_type_dirty_data(engine) deleted_cache_keys: list[str] = [] @@ -249,180 +229,12 @@ def test_legacy_model_type_migration_end_to_end_across_supported_backends( assert second_apply_state == first_apply_state -def test_legacy_model_type_alembic_upgrade_across_supported_backends( - container_engine: tuple[str, sa.Engine], -) -> None: - _, engine = container_engine - helper_module = importlib.import_module("tests.helpers.legacy_model_type_migration") - helper_module.drop_minimal_legacy_model_type_schema(engine) - fixture = seed_legacy_model_type_dirty_data(engine) - - canonical_provider_model_id = "00000000-0000-0000-0000-00000000ca01" - canonical_default_model_id = "00000000-0000-0000-0000-00000000ca02" - canonical_credential_ids = { - "00000000-0000-0000-0000-00000000ca03", - "00000000-0000-0000-0000-00000000ca04", - } - older_inherit_id = "00000000-0000-0000-0000-00000000ca05" - newer_inherit_id = "00000000-0000-0000-0000-00000000ca06" - now = datetime(2025, 1, 1, 12, 0, 0) - with engine.begin() as connection: - connection.execute( - sa.text( - """ - INSERT INTO provider_models - ( - id, tenant_id, provider_name, model_name, model_type, - credential_id, is_valid, created_at, updated_at - ) - VALUES - ( - :id, :tenant_id, 'openai', 'gpt-4o-mini', 'llm', - :credential_id, :is_valid, :created_at, :updated_at - ) - """ - ), - { - "id": canonical_provider_model_id, - "tenant_id": fixture.primary.tenant_id, - "credential_id": fixture.primary.winner_credential_id, - "is_valid": True, - "created_at": now - timedelta(days=2), - "updated_at": now - timedelta(hours=7), - }, - ) - connection.execute( - sa.text( - """ - INSERT INTO tenant_default_models - (id, tenant_id, provider_name, model_name, model_type, created_at, updated_at) - VALUES - (:id, :tenant_id, 'openai', 'gpt-4o-mini', 'llm', :created_at, :updated_at) - """ - ), - { - "id": canonical_default_model_id, - "tenant_id": fixture.primary.tenant_id, - "created_at": now - timedelta(days=2), - "updated_at": now, - }, - ) - connection.execute( - sa.text( - """ - INSERT INTO provider_model_credentials - ( - id, tenant_id, provider_name, model_name, model_type, - credential_name, encrypted_config, created_at, updated_at - ) - VALUES - (:older_id, :tenant_id, 'openai', 'gpt-4o-mini', 'llm', - 'canonical-only', '{"api_key":"older"}', :created_at, :older_updated_at), - (:newer_id, :tenant_id, 'openai', 'gpt-4o-mini', 'llm', - 'canonical-only', '{"api_key":"newer"}', :created_at, :newer_updated_at) - """ - ), - { - "older_id": min(canonical_credential_ids), - "newer_id": max(canonical_credential_ids), - "tenant_id": fixture.primary.tenant_id, - "created_at": now - timedelta(days=2), - "older_updated_at": now - timedelta(hours=2), - "newer_updated_at": now - timedelta(hours=1), - }, - ) - - _insert_load_balancing_model_config( - engine, - row_id=older_inherit_id, - tenant_id=fixture.primary.tenant_id, - provider_name="openai", - model_name="gpt-4o-mini", - model_type="llm", - name="__inherit__", - encrypted_config='{"api_key":"older-inherit"}', - credential_id=fixture.primary.winner_credential_id, - enabled=True, - created_at=now - timedelta(days=2), - updated_at=now - timedelta(hours=2), - ) - _insert_load_balancing_model_config( - engine, - row_id=newer_inherit_id, - tenant_id=fixture.primary.tenant_id, - provider_name="openai", - model_name="gpt-4o-mini", - model_type="text-generation", - name="__inherit__", - encrypted_config='{"api_key":"newer-inherit"}', - credential_id=fixture.primary.distinct_credential_id, - enabled=True, - created_at=now - timedelta(days=2), - updated_at=now - timedelta(hours=1), - ) - - _run_legacy_model_type_alembic_upgrade(engine) - - for tenant_id in (fixture.primary.tenant_id, fixture.secondary.tenant_id): - assert_tenant_rows_use_only_canonical_model_types(engine, tenant_id) - - table_names = ( - "provider_models", - "tenant_default_models", - "provider_model_settings", - "load_balancing_model_configs", - "provider_model_credentials", - ) - first_apply_state = {table_name: fetch_table_rows(engine, table_name) for table_name in table_names} - primary_provider_models = [ - row - for row in first_apply_state["provider_models"] - if row["tenant_id"] == fixture.primary.tenant_id and row["model_name"] == "gpt-4o-mini" - ] - assert [row["id"] for row in primary_provider_models] == [fixture.primary.provider_model_id] - assert primary_provider_models[0]["credential_id"] == fixture.primary.winner_credential_id - - primary_defaults = [ - row - for row in first_apply_state["tenant_default_models"] - if row["tenant_id"] == fixture.primary.tenant_id and row["model_type"] == "llm" - ] - assert [row["id"] for row in primary_defaults] == [canonical_default_model_id] - primary_credential_ids = { - row["id"] - for row in first_apply_state["provider_model_credentials"] - if row["tenant_id"] == fixture.primary.tenant_id - } - assert canonical_credential_ids <= primary_credential_ids - assert count_rows(engine, "provider_model_credentials", tenant_id=fixture.primary.tenant_id) == 4 - - primary_load_balancing_config = next( - row - for row in first_apply_state["load_balancing_model_configs"] - if row["id"] == fixture.primary.load_balancing_config_id - ) - assert primary_load_balancing_config["credential_id"] == fixture.primary.winner_credential_id - assert primary_load_balancing_config["encrypted_config"] == fixture.primary.winner_encrypted_config - primary_inherit_ids = { - row["id"] - for row in first_apply_state["load_balancing_model_configs"] - if row["tenant_id"] == fixture.primary.tenant_id and row["name"] == "__inherit__" - } - assert primary_inherit_ids == {newer_inherit_id} - - _run_legacy_model_type_alembic_upgrade(engine) - second_apply_state = {table_name: fetch_table_rows(engine, table_name) for table_name in table_names} - assert second_apply_state == first_apply_state - - def test_load_balancing_inherit_deduplication_is_applied_consistently_across_supported_backends( migration_module, container_engine: tuple[str, sa.Engine], monkeypatch: pytest.MonkeyPatch, ) -> None: _, engine = container_engine - helper_module = importlib.import_module("tests.helpers.legacy_model_type_migration") - helper_module.drop_minimal_legacy_model_type_schema(engine) fixture = seed_legacy_model_type_dirty_data(engine) tenant_id = fixture.primary.tenant_id diff --git a/api/tests/e2e_tests/containers/conftest.py b/api/tests/e2e_tests/containers/conftest.py index c62805f5fdb..66a09fca1a5 100644 --- a/api/tests/e2e_tests/containers/conftest.py +++ b/api/tests/e2e_tests/containers/conftest.py @@ -10,9 +10,9 @@ more reliable and realistic test scenarios. import logging import os from collections.abc import Generator -from contextlib import contextmanager +from contextlib import ExitStack, closing +from dataclasses import dataclass from pathlib import Path -from typing import Protocol import psycopg2 import pytest @@ -22,7 +22,7 @@ from sqlalchemy import Engine, text from sqlalchemy.orm import Session from testcontainers.core.container import DockerContainer from testcontainers.core.network import Network -from testcontainers.core.wait_strategies import LogMessageWaitStrategy +from testcontainers.core.wait_strategies import HttpWaitStrategy, LogMessageWaitStrategy from testcontainers.postgres import PostgresContainer from testcontainers.redis import RedisContainer @@ -32,265 +32,215 @@ from extensions.ext_database import db # Configure logging for test containers logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s") logger = logging.getLogger(__name__) -_TEST_SANDBOX_IMAGE = os.getenv("TEST_SANDBOX_IMAGE", "langgenius/dify-sandbox:0.2.12") DEFAULT_SANDBOX_TEST_IMAGE = "langgenius/dify-sandbox:0.2.14" SANDBOX_TEST_IMAGE_ENV = "DIFY_SANDBOX_TEST_IMAGE" -class _CloserProtocol(Protocol): - """_Closer is any type which implement the close() method.""" - - def close(self) -> None: - """close the current object, release any external resouece (file, transaction, connection etc.) - associated with it. - """ - pass - - -@contextmanager -def _auto_close[T: _CloserProtocol](closer: T) -> Generator[T]: - yield closer - closer.close() - - def _wait_for_log_message(message: str, timeout: int) -> LogMessageWaitStrategy: return LogMessageWaitStrategy(message).with_startup_timeout(timeout) -class DifyTestContainers: - """ - Manages all test containers required for Dify integration tests. +@dataclass(frozen=True, slots=True) +class DifyTestContainerEnvironment: + postgres: PostgresContainer + redis: RedisContainer + dify_sandbox: DockerContainer + dify_plugin_daemon: DockerContainer | None - This class provides a centralized way to manage multiple containers - needed for comprehensive integration testing, including databases, - caches, and search engines. - """ - def __init__(self) -> None: - """Initialize container management with default configurations.""" - self.network: Network | None = None - self.postgres: PostgresContainer | None = None - self.redis: RedisContainer | None = None - self.dify_sandbox: DockerContainer | None = None - self.dify_plugin_daemon: DockerContainer | None = None - self._containers_started = False - logger.info("DifyTestContainers initialized - ready to manage test containers") +@pytest.fixture(scope="session") +def testcontainers_network() -> Generator[Network]: + """Provide the shared network after all dependent containers have stopped.""" + logger.info("Creating Docker network for test container communication") + with Network() as network: + logger.info("Docker network created successfully with name: %s", network.name) + yield network - def start_containers_with_env(self) -> None: - """ - Start all required containers for integration testing. - This method initializes and starts PostgreSQL, Redis - containers with appropriate configurations for Dify testing. Containers - are started in dependency order to ensure proper initialization. - """ - if self._containers_started: - logger.info("Containers already started - skipping container startup") - return +@pytest.fixture(scope="session") +def postgres_container(testcontainers_network: Network) -> Generator[PostgresContainer]: + """Provide PostgreSQL and clean it up if any later setup step fails.""" + # This per-worker database is discarded after the session. Keep its data + # in bounded tmpfs to reduce I/O from full-schema TRUNCATE cleanup. + postgres = ( + PostgresContainer(image="postgres:14-alpine") + .with_tmpfs_mount("/var/lib/postgresql/data", size="rw,size=2g") + .with_network(testcontainers_network) + ) + postgres.waiting_for(_wait_for_log_message("is ready to accept connections", 30)) - logger.info("Starting test containers for Dify integration tests...") + with ExitStack() as stack: + postgres = stack.enter_context(postgres) + environment = stack.enter_context(pytest.MonkeyPatch.context()) + db_host = postgres.get_container_host_ip() + db_port = postgres.get_exposed_port(5432) + environment.setenv("DB_HOST", db_host) + environment.setenv("DB_PORT", str(db_port)) + environment.setenv("DB_USERNAME", postgres.username) + environment.setenv("DB_PASSWORD", postgres.password) + environment.setenv("DB_DATABASE", postgres.dbname) - # Create Docker network for container communication - logger.info("Creating Docker network for container communication...") - self.network = Network() - self.network.create() - logger.info("Docker network created successfully with name: %s", self.network.name) - - # Start PostgreSQL container for main application database - # PostgreSQL is used for storing user data, workflows, and application state - logger.info("Initializing PostgreSQL container...") - self.postgres = PostgresContainer( - image="postgres:14-alpine", - # This per-worker database is discarded after the session. Keep its - # data in bounded tmpfs to reduce I/O from full-schema TRUNCATE cleanup. - tmpfs={"/var/lib/postgresql/data": "rw,size=2g"}, - ).with_network(self.network) - self.postgres.waiting_for(_wait_for_log_message("is ready to accept connections", 30)) - self.postgres.start() - db_host = self.postgres.get_container_host_ip() - db_port = self.postgres.get_exposed_port(5432) - os.environ["DB_HOST"] = db_host - os.environ["DB_PORT"] = str(db_port) - os.environ["DB_USERNAME"] = self.postgres.username - os.environ["DB_PASSWORD"] = self.postgres.password - os.environ["DB_DATABASE"] = self.postgres.dbname logger.info( "PostgreSQL container started successfully - Host: %s, Port: %s User: %s, Database: %s", db_host, db_port, - self.postgres.username, - self.postgres.dbname, + postgres.username, + postgres.dbname, ) - logger.info("PostgreSQL container is ready and accepting connections") - - conn = psycopg2.connect( + connection = psycopg2.connect( host=db_host, port=db_port, - user=self.postgres.username, - password=self.postgres.password, - database=self.postgres.dbname, + user=postgres.username, + password=postgres.password, + database=postgres.dbname, ) - conn.autocommit = True - with _auto_close(conn): - with conn.cursor() as cursor: - # Install uuid-ossp extension for UUID generation - logger.info("Installing uuid-ossp extension...") + connection.autocommit = True + with closing(connection): + with connection.cursor() as cursor: cursor.execute('CREATE EXTENSION IF NOT EXISTS "uuid-ossp";') - logger.info("uuid-ossp extension installed successfully") - # NOTE: We cannot use `with conn.cursor() as cursor:` as it will wrap the statement - # inside a transaction. However, the `CREATE DATABASE` statement cannot run inside a transaction block. - with _auto_close(conn.cursor()) as cursor: - # Create plugin database for dify-plugin-daemon - logger.info("Creating plugin database...") + # CREATE DATABASE cannot run inside a transaction block. + with closing(connection.cursor()) as cursor: cursor.execute("CREATE DATABASE dify_plugin;") - logger.info("Plugin database created successfully") - # Set up storage environment variables - os.environ.setdefault("STORAGE_TYPE", "opendal") - os.environ.setdefault("OPENDAL_SCHEME", "fs") - os.environ.setdefault("OPENDAL_FS_ROOT", "/tmp/dify-storage") + yield postgres - # Start Redis container for caching and session management - # Redis is used for storing session data, cache entries, and temporary data - logger.info("Initializing Redis container...") - self.redis = RedisContainer(image="redis:6-alpine", port=6379).with_network(self.network) - self.redis.waiting_for(_wait_for_log_message("Ready to accept connections", 30)) - self.redis.start() - redis_host = self.redis.get_container_host_ip() - redis_port = self.redis.get_exposed_port(6379) - os.environ["REDIS_HOST"] = redis_host - os.environ["REDIS_PORT"] = str(redis_port) + +@pytest.fixture(scope="session") +def redis_container(testcontainers_network: Network) -> Generator[RedisContainer]: + """Provide Redis with its process environment scoped to the fixture.""" + redis = RedisContainer(image="redis:6-alpine", port=6379).with_network(testcontainers_network) + redis.waiting_for(_wait_for_log_message("Ready to accept connections", 30)) + + with ExitStack() as stack: + redis = stack.enter_context(redis) + environment = stack.enter_context(pytest.MonkeyPatch.context()) + redis_host = redis.get_container_host_ip() + redis_port = redis.get_exposed_port(6379) + environment.setenv("REDIS_HOST", redis_host) + environment.setenv("REDIS_PORT", str(redis_port)) + environment.setenv("REDIS_USERNAME", "") + environment.setenv("REDIS_PASSWORD", "") logger.info("Redis container started successfully - Host: %s, Port: %s", redis_host, redis_port) + yield redis - logger.info("Redis container is ready and accepting connections") - # Start Dify Sandbox container for code execution environment. - # Default to the production-pinned image while allowing local overrides for debugging. - logger.info("Initializing Dify Sandbox container...") - sandbox_image = os.getenv(SANDBOX_TEST_IMAGE_ENV, DEFAULT_SANDBOX_TEST_IMAGE) - self.dify_sandbox = DockerContainer(image=sandbox_image).with_network(self.network) - self.dify_sandbox.with_exposed_ports(8194) - self.dify_sandbox.waiting_for(_wait_for_log_message("config init success", 60)) - self.dify_sandbox.env = { - "API_KEY": "test_api_key", - # Match Docker Compose: the image's 5-second limit can expire during - # Python/Jinja2 startup under parallel CI load. - "WORKER_TIMEOUT": "15", - } - self.dify_sandbox.start() - sandbox_host = self.dify_sandbox.get_container_host_ip() - sandbox_port = self.dify_sandbox.get_exposed_port(8194) - os.environ["CODE_EXECUTION_ENDPOINT"] = f"http://{sandbox_host}:{sandbox_port}" - os.environ["CODE_EXECUTION_API_KEY"] = "test_api_key" +@pytest.fixture(scope="session") +def dify_sandbox_container(testcontainers_network: Network) -> Generator[DockerContainer]: + """Provide Dify Sandbox with its endpoint environment.""" + sandbox_image = os.getenv(SANDBOX_TEST_IMAGE_ENV, DEFAULT_SANDBOX_TEST_IMAGE) + sandbox = DockerContainer(image=sandbox_image).with_network(testcontainers_network) + sandbox.with_exposed_ports(8194) + sandbox.waiting_for(HttpWaitStrategy(8194, "/health").with_startup_timeout(180)) + sandbox.env = { + "API_KEY": "test_api_key", + # Match Docker Compose: the image's 5-second limit can expire during + # Python/Jinja2 startup under parallel CI load. + "WORKER_TIMEOUT": "15", + } + + with ExitStack() as stack: + sandbox = stack.enter_context(sandbox) + environment = stack.enter_context(pytest.MonkeyPatch.context()) + sandbox_host = sandbox.get_container_host_ip() + sandbox_port = sandbox.get_exposed_port(8194) + environment.setenv("CODE_EXECUTION_ENDPOINT", f"http://{sandbox_host}:{sandbox_port}") + environment.setenv("CODE_EXECUTION_API_KEY", "test_api_key") logger.info( "Dify Sandbox container started successfully - Image: %s Host: %s, Port: %s", sandbox_image, sandbox_host, sandbox_port, ) + yield sandbox - logger.info("Dify Sandbox container is ready and accepting connections") - # Start Dify Plugin Daemon container for plugin management - # Dify Plugin Daemon provides plugin lifecycle management and execution - logger.info("Initializing Dify Plugin Daemon container...") - self.dify_plugin_daemon = DockerContainer(image="langgenius/dify-plugin-daemon:0.6.11-local").with_network( - self.network +@pytest.fixture(scope="session") +def dify_plugin_daemon_container( + testcontainers_network: Network, + postgres_container: PostgresContainer, + redis_container: RedisContainer, +) -> Generator[DockerContainer | None]: + """Provide the optional plugin daemon without swallowing consumer failures.""" + postgres_container_name = postgres_container.get_wrapped_container().name + redis_container_name = redis_container.get_wrapped_container().name + assert postgres_container_name is not None + assert redis_container_name is not None + + plugin_daemon = DockerContainer(image="langgenius/dify-plugin-daemon:0.6.11-local").with_network( + testcontainers_network + ) + plugin_daemon.with_exposed_ports(5002) + plugin_daemon.waiting_for(_wait_for_log_message("start plugin manager daemon", 60)) + plugin_daemon.env = { + "DB_HOST": postgres_container_name, + "DB_PORT": "5432", + "DB_USERNAME": postgres_container.username, + "DB_PASSWORD": postgres_container.password, + "DB_DATABASE": "dify_plugin", + "REDIS_HOST": redis_container_name, + "REDIS_PORT": "6379", + "REDIS_PASSWORD": "", + "SERVER_PORT": "5002", + "SERVER_KEY": "test_plugin_daemon_key", + "MAX_PLUGIN_PACKAGE_SIZE": "52428800", + "PPROF_ENABLED": "false", + "DIFY_INNER_API_URL": f"http://{postgres_container_name}:5001", + "DIFY_INNER_API_KEY": "test_inner_api_key", + "PLUGIN_REMOTE_INSTALLING_HOST": "0.0.0.0", + "PLUGIN_REMOTE_INSTALLING_PORT": "5003", + "PLUGIN_WORKING_PATH": "/app/storage/cwd", + "FORCE_VERIFYING_SIGNATURE": "false", + "PYTHON_ENV_INIT_TIMEOUT": "120", + "PLUGIN_MAX_EXECUTION_TIMEOUT": "600", + "PLUGIN_STDIO_BUFFER_SIZE": "1024", + "PLUGIN_STDIO_MAX_BUFFER_SIZE": "5242880", + "PLUGIN_STORAGE_TYPE": "local", + "PLUGIN_STORAGE_LOCAL_ROOT": "/app/storage", + "PLUGIN_INSTALLED_PATH": "plugin", + "PLUGIN_PACKAGE_CACHE_PATH": "plugin_packages", + "PLUGIN_MEDIA_CACHE_PATH": "assets", + } + + stack = ExitStack() + try: + plugin_daemon = stack.enter_context(plugin_daemon) + plugin_daemon_host = plugin_daemon.get_container_host_ip() + plugin_daemon_port = plugin_daemon.get_exposed_port(5002) + except Exception: + logger.exception("Failed to start Dify Plugin Daemon container") + stack.close() + logger.info("Continuing without plugin daemon - some tests may be limited") + yield None + return + + with stack: + environment = stack.enter_context(pytest.MonkeyPatch.context()) + environment.setenv("PLUGIN_DAEMON_URL", f"http://{plugin_daemon_host}:{plugin_daemon_port}") + environment.setenv("PLUGIN_DAEMON_KEY", "test_plugin_daemon_key") + logger.info( + "Dify Plugin Daemon container started successfully - Host: %s, Port: %s", + plugin_daemon_host, + plugin_daemon_port, ) - self.dify_plugin_daemon.with_exposed_ports(5002) - self.dify_plugin_daemon.waiting_for(_wait_for_log_message("start plugin manager daemon", 60)) - # Get container internal network addresses - postgres_container_name = self.postgres.get_wrapped_container().name - redis_container_name = self.redis.get_wrapped_container().name - assert postgres_container_name is not None - assert redis_container_name is not None - - self.dify_plugin_daemon.env = { - "DB_HOST": postgres_container_name, # Use container name for internal network communication - "DB_PORT": "5432", # Use internal port - "DB_USERNAME": self.postgres.username, - "DB_PASSWORD": self.postgres.password, - "DB_DATABASE": "dify_plugin", - "REDIS_HOST": redis_container_name, # Use container name for internal network communication - "REDIS_PORT": "6379", # Use internal port - "REDIS_PASSWORD": "", - "SERVER_PORT": "5002", - "SERVER_KEY": "test_plugin_daemon_key", - "MAX_PLUGIN_PACKAGE_SIZE": "52428800", - "PPROF_ENABLED": "false", - "DIFY_INNER_API_URL": f"http://{postgres_container_name}:5001", - "DIFY_INNER_API_KEY": "test_inner_api_key", - "PLUGIN_REMOTE_INSTALLING_HOST": "0.0.0.0", - "PLUGIN_REMOTE_INSTALLING_PORT": "5003", - "PLUGIN_WORKING_PATH": "/app/storage/cwd", - "FORCE_VERIFYING_SIGNATURE": "false", - "PYTHON_ENV_INIT_TIMEOUT": "120", - "PLUGIN_MAX_EXECUTION_TIMEOUT": "600", - "PLUGIN_STDIO_BUFFER_SIZE": "1024", - "PLUGIN_STDIO_MAX_BUFFER_SIZE": "5242880", - "PLUGIN_STORAGE_TYPE": "local", - "PLUGIN_STORAGE_LOCAL_ROOT": "/app/storage", - "PLUGIN_INSTALLED_PATH": "plugin", - "PLUGIN_PACKAGE_CACHE_PATH": "plugin_packages", - "PLUGIN_MEDIA_CACHE_PATH": "assets", - } - - try: - self.dify_plugin_daemon.start() - plugin_daemon_host = self.dify_plugin_daemon.get_container_host_ip() - plugin_daemon_port = self.dify_plugin_daemon.get_exposed_port(5002) - os.environ["PLUGIN_DAEMON_URL"] = f"http://{plugin_daemon_host}:{plugin_daemon_port}" - os.environ["PLUGIN_DAEMON_KEY"] = "test_plugin_daemon_key" - logger.info( - "Dify Plugin Daemon container started successfully - Host: %s, Port: %s", - plugin_daemon_host, - plugin_daemon_port, - ) - - logger.info("Dify Plugin Daemon container is ready and accepting connections") - except Exception as e: - logger.warning("Failed to start Dify Plugin Daemon container: %s", e) - logger.info("Continuing without plugin daemon - some tests may be limited") - self.dify_plugin_daemon = None - - self._containers_started = True - logger.info("All test containers started successfully") - - def stop_containers(self) -> None: - """ - Stop and clean up all test containers. - - This method ensures proper cleanup of all containers to prevent - resource leaks and conflicts between test runs. - """ - if not self._containers_started: - logger.info("No containers to stop - containers were not started") - return - - logger.info("Stopping and cleaning up test containers...") - containers = [self.redis, self.postgres, self.dify_sandbox, self.dify_plugin_daemon] - for container in containers: - if container: - container_name = container.image - logger.info("Stopping container: %s", container_name) - container.stop() - logger.info("Successfully stopped container: %s", container_name) - - # Stop and remove the network - if self.network: - logger.info("Removing Docker network...") - self.network.remove() - logger.info("Successfully removed Docker network") - - self._containers_started = False - logger.info("All test containers stopped and cleaned up successfully") + yield plugin_daemon -# Global container manager instance -_container_manager = DifyTestContainers() +@pytest.fixture(scope="session") +def storage_environment() -> Generator[None]: + """Provide default storage settings without overwriting caller overrides.""" + defaults = { + "STORAGE_TYPE": "opendal", + "OPENDAL_SCHEME": "fs", + "OPENDAL_FS_ROOT": "/tmp/dify-storage", + } + with pytest.MonkeyPatch.context() as environment: + for name, value in defaults.items(): + if name not in os.environ: + environment.setenv(name, value) + yield def _get_migration_dir() -> Path: @@ -361,9 +311,6 @@ def _create_app_with_containers() -> Flask: from extensions import ext_redis ext_redis.redis_client._client = None - os.environ["REDIS_USERNAME"] = "" - os.environ["REDIS_PASSWORD"] = "" - # Re-create the config after environment variables have been set from configs import dify_config @@ -396,28 +343,34 @@ def _create_app_with_containers() -> Flask: @pytest.fixture(scope="session") -def set_up_containers_and_env() -> Generator[DifyTestContainers]: +def set_up_containers_and_env( + postgres_container: PostgresContainer, + redis_container: RedisContainer, + dify_sandbox_container: DockerContainer, + dify_plugin_daemon_container: DockerContainer | None, + storage_environment: None, +) -> DifyTestContainerEnvironment: """ - Session-scoped fixture to manage test containers. + Compose the independently managed test container fixtures. - This fixture ensures containers are started once per test session - and properly cleaned up when all tests are complete. This approach - improves test performance by reusing containers across multiple tests. + Pytest owns the dependency graph and finalizes every resource fixture in + reverse order, including when a later fixture fails during setup. - Yields: - DifyTestContainers: Container manager instance + Returns: + DifyTestContainerEnvironment: Handles for the started containers """ - logger.info("=== Starting test session container management ===") - _container_manager.start_containers_with_env() + assert storage_environment is None logger.info("Test containers ready for session") - yield _container_manager - logger.info("=== Cleaning up test session containers ===") - _container_manager.stop_containers() - logger.info("Test session container cleanup completed") + return DifyTestContainerEnvironment( + postgres=postgres_container, + redis=redis_container, + dify_sandbox=dify_sandbox_container, + dify_plugin_daemon=dify_plugin_daemon_container, + ) @pytest.fixture(scope="session") -def flask_app_with_containers(set_up_containers_and_env: DifyTestContainers) -> Flask: +def flask_app_with_containers(set_up_containers_and_env: DifyTestContainerEnvironment) -> Flask: """ Session-scoped Flask application fixture using test containers. @@ -425,12 +378,12 @@ def flask_app_with_containers(set_up_containers_and_env: DifyTestContainers) -> to use the test containers for all database and service connections. Args: - containers: Container manager fixture + set_up_containers_and_env: Composed test container environment Returns: Flask: Configured Flask application """ - assert set_up_containers_and_env is _container_manager + assert set_up_containers_and_env.postgres is not None logger.info("=== Creating session-scoped Flask application ===") app = _create_app_with_containers() logger.info("Session-scoped Flask application created successfully") diff --git a/api/tests/e2e_tests/containers/controllers/console/test_human_input_channel_api.py b/api/tests/e2e_tests/containers/controllers/console/test_human_input_channel_api.py new file mode 100644 index 00000000000..4b4fa2636b3 --- /dev/null +++ b/api/tests/e2e_tests/containers/controllers/console/test_human_input_channel_api.py @@ -0,0 +1,222 @@ +"""Console Channel API through the real service and PostgreSQL transaction boundary.""" + +from __future__ import annotations + +import json +from base64 import b64decode +from typing import Never + +import pytest +import sqlalchemy as sa +from cryptography.fernet import Fernet +from flask.testing import FlaskClient +from sqlalchemy.orm import Session + +from configs import dify_config +from configs.deploy import IMEventTransportMode +from core.human_input_v2.entities import IMProvider +from core.human_input_v2.im_integration.adapters.credentials import IMProviderCredentials +from core.human_input_v2.im_integration.adapters.entities import CredentialTestSuccess +from core.human_input_v2.shared import AccountId, TenantId +from models.human_input_v2 import HumanInputIMChannel +from repositories.human_input_v2.im_channel_repository import IMChannel, IMChannelId +from repositories.human_input_v2.sqlalchemy_im_channel_repository import WorkspaceIMChannelWriter +from services.human_input_v2 import im_channel_service as service_module +from services.human_input_v2.im_channel_service_composition import build_workspace_im_channel_service +from services.human_input_v2.im_tenant_credential_cipher import TenantBoundCredentialCipher +from tests.e2e_tests.containers.controllers.console.helpers import ( + authenticate_console_client, + create_console_account_and_tenant, +) + +_BASE_PATH = "/console/api/workspaces/current/human-input/v2/channels/im" +_SLACK_CREDENTIALS = { + "provider": "slack", + "client_id": "slack-client-1", + "client_secret": "client-secret", + "signing_secret": "signing-secret", + "bot_token": "xoxb-bot-token", + "app_token": None, +} + + +class _StaticAdapter: + def __init__(self, credentials: IMProviderCredentials) -> None: + assert credentials.provider is IMProvider.SLACK + + def test_credentials(self) -> CredentialTestSuccess: + return CredentialTestSuccess(IMProvider.SLACK, "slack-tenant-1") + + def close(self) -> None: + return None + + +@pytest.fixture(autouse=True) +def static_provider_and_cipher(monkeypatch: pytest.MonkeyPatch) -> Fernet: + cipher = Fernet(Fernet.generate_key()) + monkeypatch.setattr(service_module, "build_im_provider_adapter", _StaticAdapter) + monkeypatch.setattr( + TenantBoundCredentialCipher, + "encrypt", + lambda _self, plaintext: cipher.encrypt(plaintext.encode()), + ) + monkeypatch.setattr( + TenantBoundCredentialCipher, + "decrypt", + lambda _self, ciphertext: cipher.decrypt(ciphertext).decode(), + ) + monkeypatch.setattr( + dify_config, + "HUMAN_INPUT_IM_EVENT_TRANSPORT_MODE", + IMEventTransportMode.STREAM, + ) + return cipher + + +def test_im_update_wires_opaque_version_through_real_service_and_postgresql_cas( + db_session_with_containers: Session, + test_client_with_containers: FlaskClient, +) -> None: + account, tenant = create_console_account_and_tenant(db_session_with_containers) + headers = authenticate_console_client(test_client_with_containers, account) + + created_response = test_client_with_containers.post( + _BASE_PATH, + json={"credentials": _SLACK_CREDENTIALS}, + headers=headers, + ) + assert created_response.status_code == 200 + created_summary = created_response.get_json()["summary"] + channel_id = IMChannelId(created_summary["id"]) + original_config_version = created_summary["config_version"] + original_record = db_session_with_containers.scalar( + sa.select(HumanInputIMChannel).where(HumanInputIMChannel.id == str(channel_id)) + ) + assert original_record is not None + original_webhook_id = original_record.webhook_id + + rotated_response = test_client_with_containers.put( + f"{_BASE_PATH}/{channel_id}", + json={ + "credentials": _SLACK_CREDENTIALS, + "expected_config_version": original_config_version, + }, + headers=headers, + ) + assert rotated_response.status_code == 200 + rotated_summary = rotated_response.get_json()["summary"] + assert rotated_summary["id"] == str(channel_id) + assert rotated_summary["config_version"] != original_config_version + assert rotated_summary["webhook_url"] is None + + stale_response = test_client_with_containers.put( + f"{_BASE_PATH}/{channel_id}", + json={ + "credentials": _SLACK_CREDENTIALS, + "expected_config_version": original_config_version, + }, + headers=headers, + ) + assert stale_response.status_code == 409 + assert stale_response.get_json()["code"] == "provider_configuration_updated" + + service = build_workspace_im_channel_service(TenantId(tenant.id), AccountId(account.id)) + persisted = service.get(channel_id) + assert persisted.id == channel_id + assert persisted.config_version == 2 + db_session_with_containers.expire_all() + rotated_record = db_session_with_containers.scalar( + sa.select(HumanInputIMChannel).where(HumanInputIMChannel.id == str(channel_id)) + ) + assert rotated_record is not None + assert rotated_record.webhook_id == original_webhook_id + + +def test_service_transaction_rolls_back_a_flushed_create_on_unclassified_failure( + db_session_with_containers: Session, + test_client_with_containers: FlaskClient, + monkeypatch: pytest.MonkeyPatch, +) -> None: + account, tenant = create_console_account_and_tenant(db_session_with_containers) + headers = authenticate_console_client(test_client_with_containers, account) + original_create = WorkspaceIMChannelWriter.create + + def create_then_fail(self: WorkspaceIMChannelWriter, channel: IMChannel) -> Never: + original_create(self, channel) + raise RuntimeError("sql-owner-key-secret-detail") + + monkeypatch.setattr(WorkspaceIMChannelWriter, "create", create_then_fail) + + response = test_client_with_containers.post( + _BASE_PATH, + json={"credentials": _SLACK_CREDENTIALS}, + headers=headers, + ) + + assert response.status_code == 500 + assert "sql-owner-key-secret-detail" not in response.get_data(as_text=True) + db_session_with_containers.expire_all() + persisted_count = db_session_with_containers.scalar( + sa.select(sa.func.count(HumanInputIMChannel.id)).where( + HumanInputIMChannel.owner_key == f"workspace:{tenant.id}" + ) + ) + assert persisted_count == 0 + + +def test_partial_edit_and_saved_test_preserve_one_encrypted_document( + db_session_with_containers: Session, + test_client_with_containers: FlaskClient, + static_provider_and_cipher: Fernet, +) -> None: + account, _tenant = create_console_account_and_tenant(db_session_with_containers) + headers = authenticate_console_client(test_client_with_containers, account) + client = test_client_with_containers + created = client.post(_BASE_PATH, json={"credentials": _SLACK_CREDENTIALS}, headers=headers) + assert created.status_code == 200 + summary = created.get_json()["summary"] + channel_id = summary["id"] + record = db_session_with_containers.get(HumanInputIMChannel, channel_id) + assert record is not None + original_envelope = record.encrypted_credentials + edit = { + "provider": "slack", + "client_id": {"type": "unchanged"}, + "client_secret": "rotated-client-secret", + "signing_secret": {"type": "unchanged"}, + "bot_token": {"type": "unchanged"}, + "app_token": None, + } + payload = {"credentials": edit, "expected_config_version": summary["config_version"]} + + tested = client.post(f"{_BASE_PATH}/{channel_id}/test", json=payload, headers=headers) + assert tested.status_code == 200 + db_session_with_containers.refresh(record) + assert record.config_version == 1 + assert record.encrypted_credentials == original_envelope + + updated = client.put(f"{_BASE_PATH}/{channel_id}", json=payload, headers=headers) + assert updated.status_code == 200 + db_session_with_containers.refresh(record) + assert record.config_version == 2 + assert record.encrypted_credentials != original_envelope + decrypted = json.loads(static_provider_and_cipher.decrypt(b64decode(record.encrypted_credentials.ciphertext))) + assert decrypted == { + "provider": "slack", + "client_id": "slack-client-1", + "client_secret": "rotated-client-secret", + "signing_secret": "signing-secret", + "bot_token": "xoxb-bot-token", + "app_token": None, + } + assert "rotated-client-secret" not in record.encrypted_credentials.ciphertext + detail = client.get(f"{_BASE_PATH}/{channel_id}", headers=headers) + assert detail.status_code == 200 + assert detail.get_json()["credentials"] == { + "provider": "slack", + "client_id": "slack-client-1", + "client_secret": {"type": "redacted"}, + "signing_secret": {"type": "redacted"}, + "bot_token": {"type": "redacted"}, + "app_token": None, + } diff --git a/api/tests/e2e_tests/containers/controllers/console/workspace/test_im_contact_sync_api.py b/api/tests/e2e_tests/containers/controllers/console/workspace/test_im_contact_sync_api.py new file mode 100644 index 00000000000..5fde53b991a --- /dev/null +++ b/api/tests/e2e_tests/containers/controllers/console/workspace/test_im_contact_sync_api.py @@ -0,0 +1,430 @@ +"""PostgreSQL and Redis HTTP contracts for the IM Contact Sync control plane.""" + +from __future__ import annotations + +from dataclasses import replace +from datetime import datetime + +import pytest +from flask.testing import FlaskClient +from sqlalchemy import select +from sqlalchemy.orm import Session + +from core.human_input_v2.entities import ( + IMProvider, + IMSyncRemovalReason, + IMSyncResultType, + IMSyncRunStatus, +) +from core.human_input_v2.im_integration import ( + IMChannelRevision, + IMSyncRun, + SyncContactSnapshot, + SyncIdentitySnapshot, + SyncResultFact, +) +from core.human_input_v2.shared import ( + AccountId, + ContactId, + IMIdentityId, + IMSyncResultId, + IMSyncRunId, + NormalizedEmail, + TenantId, +) +from models.account import Account, Tenant +from models.human_input_v2 import ( + ContactSubjectType, + HumanInputContactIdentity, + HumanInputIMBinding, + HumanInputIMSyncRun, +) +from repositories.human_input_v2.channel_credentials import ChannelEncryptedCredentials +from repositories.human_input_v2.contact import Contact, ContactType +from repositories.human_input_v2.im_channel_repository import ( + IMChannel, + IMChannelId, + IMChannelStatus, + WebhookId, +) +from repositories.human_input_v2.im_identity_repository import IMIdentity, IMIdentityObservation, OpaqueProviderPayload +from repositories.human_input_v2.im_integration.mappers import ( + sync_result_to_record, + sync_run_to_record, +) +from repositories.human_input_v2.sqlalchemy_im_channel_repository import WorkspaceIMChannelWriter +from repositories.human_input_v2.sqlalchemy_im_identity_repository import SQLAlchemyIMIdentityRepository +from tasks.im_contact_sync_tasks import reconcile_im_contacts_task +from tests.e2e_tests.containers.controllers.console.helpers import ( + authenticate_console_client, + create_console_account_and_tenant, +) + +_NOW = datetime(2026, 8, 11, 8) +_LATER = datetime(2026, 8, 11, 9) +_CONTACT_ID = ContactId("00000000-0000-0000-0000-000000000101") +_CHANNEL_ID = IMChannelId("00000000-0000-0000-0000-000000000201") +_PRIMARY_IDENTITY_ID = IMIdentityId("00000000-0000-0000-0000-000000000301") +_SECONDARY_IDENTITY_ID = IMIdentityId("00000000-0000-0000-0000-000000000302") +_SYNC_RUNS_PATH = "/console/api/workspaces/current/human-input/v2/im-sync-runs" + + +def _seed_control_plane( + session: Session, +) -> tuple[Account, Tenant, Contact, IMChannel, IMIdentity, IMIdentity]: + account, tenant = create_console_account_and_tenant(session) + tenant_id = TenantId(tenant.id) + account.name = "Reviewer" + account.email = "reviewer@example.com" + contact_identity = HumanInputContactIdentity( + subject_type=ContactSubjectType.ACCOUNT, + account_id=account.id, + ) + contact_identity.id = str(_CONTACT_ID) + contact_identity.created_at = _NOW + contact_identity.updated_at = _NOW + contact = Contact( + id=_CONTACT_ID, + type=ContactType.WORKSPACE, + name="Reviewer", + email="reviewer@example.com", + avatar_file_id=None, + created_at=_NOW, + ) + channel = IMChannel( + id=_CHANNEL_ID, + created_at=_NOW, + updated_at=_NOW, + provider=IMProvider.FEISHU, + provider_tenant_id="provider-tenant-1", + encrypted_credentials=ChannelEncryptedCredentials(ciphertext="opaque-feishu-ciphertext"), + app_identifier="app-1", + webhook_id=WebhookId("00000000000000000000000000000001"), + config_version=1, + status=IMChannelStatus.CONNECTED, + ) + session.add(contact_identity) + WorkspaceIMChannelWriter(session, tenant_id, AccountId(account.id)).create(channel) + identity_repository = SQLAlchemyIMIdentityRepository(session, channel.id) + primary_identity = _create_identity( + identity_repository, + _PRIMARY_IDENTITY_ID, + "provider-user-primary", + "Primary Reviewer", + "reviewer@example.com", + ) + secondary_identity = _create_identity( + identity_repository, + _SECONDARY_IDENTITY_ID, + "provider-user-secondary", + "Secondary Reviewer", + "secondary@example.com", + ) + session.commit() + return account, tenant, contact, channel, primary_identity, secondary_identity + + +def _create_identity( + repository: SQLAlchemyIMIdentityRepository, + identity_id: IMIdentityId, + provider_user_id: str, + display_name: str, + email: str, +) -> IMIdentity: + return repository.create( + identity_id, + IMIdentityObservation( + provider_user_id=provider_user_id, + display_name=display_name, + email=email, + raw_payload=OpaqueProviderPayload({}), + sync_run_id=IMSyncRunId("00000000-0000-0000-0000-000000000901"), + observed_at=_NOW, + ), + ) + + +def test_manual_binding_http_commands_use_real_guarded_postgresql_writes( + db_session_with_containers: Session, + test_client_with_containers: FlaskClient, +) -> None: + account, _tenant, contact, _channel, _primary, _secondary = _seed_control_plane(db_session_with_containers) + headers = authenticate_console_client(test_client_with_containers, account) + binding_path = f"/console/api/workspaces/current/human-input/v2/contacts/{contact.id}/im-bindings" + override_path = f"/console/api/workspaces/current/human-input/v2/contacts/{contact.id}/im-override" + + created = test_client_with_containers.put( + binding_path, + json={"identity_id": str(_PRIMARY_IDENTITY_ID)}, + headers=headers, + ) + assert created.status_code == 200 + created_payload = created.get_json() + assert created_payload is not None + organization_binding = created_payload["contact"]["im_bindings"][0] + assert organization_binding["scope"] == "organization" + + idempotent = test_client_with_containers.put( + binding_path, + json={"identity_id": str(_PRIMARY_IDENTITY_ID)}, + headers=headers, + ) + assert idempotent.status_code == 200 + assert idempotent.get_json()["contact"]["im_bindings"] == [organization_binding] + + replacement = test_client_with_containers.put( + binding_path, + json={"identity_id": str(_SECONDARY_IDENTITY_ID)}, + headers=headers, + ) + assert replacement.status_code == 200 + replacement_bindings = replacement.get_json()["contact"]["im_bindings"] + assert len(replacement_bindings) == 1 + replacement_binding = replacement_bindings[0] + assert replacement_binding["scope"] == "organization" + assert replacement_binding["id"] != organization_binding["id"] + + missing_identity = test_client_with_containers.put( + binding_path, + json={"identity_id": "00000000-0000-0000-0000-000000000399"}, + headers=headers, + ) + assert missing_identity.status_code == 404 + assert missing_identity.get_json()["code"] == "im_identity_not_found" + + override = test_client_with_containers.put( + override_path, + json={"identity_id": str(_SECONDARY_IDENTITY_ID)}, + headers=headers, + ) + assert override.status_code == 403 + assert override.get_json() == { + "code": "workspace_im_override_not_allowed", + "message": "Workspace IM overrides require centrally managed bindings.", + "status": 403, + } + + reset = test_client_with_containers.delete(override_path, headers=headers) + assert reset.status_code == 403 + assert reset.get_json() == override.get_json() + db_session_with_containers.expire_all() + stored_binding = db_session_with_containers.scalar(select(HumanInputIMBinding)) + assert stored_binding is not None + assert stored_binding.id == replacement_binding["id"] + assert stored_binding.im_identity_id == str(_SECONDARY_IDENTITY_ID) + assert db_session_with_containers.get(HumanInputIMBinding, organization_binding["id"]) is None + + deleted = test_client_with_containers.delete(binding_path, headers=headers) + assert deleted.status_code == 200 + assert deleted.get_json() == {} + db_session_with_containers.expire_all() + assert db_session_with_containers.scalar(select(HumanInputIMBinding)) is None + + missing_binding = test_client_with_containers.delete(binding_path, headers=headers) + assert missing_binding.status_code == 404 + assert missing_binding.get_json()["code"] == "im_binding_not_found" + + +def test_sync_command_and_identity_queries_use_real_postgresql( + db_session_with_containers: Session, + test_client_with_containers: FlaskClient, + monkeypatch: pytest.MonkeyPatch, +) -> None: + account, tenant, _contact, channel, _primary, _secondary = _seed_control_plane(db_session_with_containers) + headers = authenticate_console_client(test_client_with_containers, account) + dispatched: list[tuple[tuple[str, str, str | None], str]] = [] + + def capture_dispatch(*, args: tuple[str, str, str | None], queue: str) -> None: + dispatched.append((args, queue)) + + monkeypatch.setattr(reconcile_im_contacts_task, "apply_async", capture_dispatch) + + created = test_client_with_containers.post(_SYNC_RUNS_PATH, headers=headers) + replayed = test_client_with_containers.post(_SYNC_RUNS_PATH, headers=headers) + latest = test_client_with_containers.get(f"{_SYNC_RUNS_PATH}/latest", headers=headers) + identities = test_client_with_containers.get( + "/console/api/workspaces/current/human-input/v2/im-identities", + query_string={"keyword": "provider-user", "page": 1, "limit": 1}, + headers=headers, + ) + + assert created.status_code == replayed.status_code == latest.status_code == identities.status_code == 200 + created_run = created.get_json()["run"] + assert replayed.get_json()["run"]["id"] == created_run["id"] + assert latest.get_json()["run"]["id"] == created_run["id"] + expected_dispatch = ( + (created_run["id"], "workspace", tenant.id), + "human_input_contact_sync", + ) + assert dispatched == [expected_dispatch, expected_dispatch] + identity_page = identities.get_json() + assert identity_page["total"] == 2 + assert len(identity_page["data"]) == 1 + assert identity_page["data"][0]["provider"] == IMProvider.FEISHU.value + assert identity_page["data"][0]["binding_status"] == "unbound" + assert created_run["channel_id"] == str(channel.id) + + +def test_latest_result_http_projection_reads_every_persisted_bucket( + db_session_with_containers: Session, + test_client_with_containers: FlaskClient, +) -> None: + account, _tenant, contact, channel, primary_identity, _secondary = _seed_control_plane(db_session_with_containers) + run = replace( + IMSyncRun.create( + sync_run_id=IMSyncRunId("00000000-0000-0000-0000-000000000501"), + channel_revision=IMChannelRevision(str(channel.id), channel.config_version), + provider=channel.provider, + started_by_account_id=AccountId(account.id), + now=_NOW, + ), + status=IMSyncRunStatus.SUCCEEDED, + added_count=1, + not_matched_count=1, + failed_count=1, + removed_count=1, + skipped_count=1, + started_at=_NOW, + finished_at=_LATER, + ) + results = _result_facts(run, contact, primary_identity, channel.provider) + db_session_with_containers.add(sync_run_to_record(run)) + db_session_with_containers.add_all(sync_result_to_record(result) for result in results) + db_session_with_containers.commit() + headers = authenticate_console_client(test_client_with_containers, account) + + for result in results: + response = test_client_with_containers.get( + f"{_SYNC_RUNS_PATH}/latest/results", + query_string={"result": result.result_type.value, "page": 1, "limit": 20}, + headers=headers, + ) + assert response.status_code == 200 + payload = response.get_json() + assert payload is not None + assert (payload["page"], payload["limit"], payload["total"]) == (1, 20, 1) + assert payload["data"][0]["result"]["type"] == result.result_type.value + + added = test_client_with_containers.get( + f"{_SYNC_RUNS_PATH}/latest/results", + query_string={"result": IMSyncResultType.ADDED.value, "page": 1, "limit": 20}, + headers=headers, + ).get_json()["data"][0]["result"] + failed = test_client_with_containers.get( + f"{_SYNC_RUNS_PATH}/latest/results", + query_string={"result": IMSyncResultType.FAILED.value, "page": 1, "limit": 20}, + headers=headers, + ).get_json()["data"][0]["result"] + removed = test_client_with_containers.get( + f"{_SYNC_RUNS_PATH}/latest/results", + query_string={"result": IMSyncResultType.REMOVED.value, "page": 1, "limit": 20}, + headers=headers, + ).get_json()["data"][0]["result"] + assert added["contact"]["id"] == str(contact.id) + assert failed["reason"] == "Directory read failed" + assert removed["last_known_identity"]["identity_id"] == str(primary_identity.id) + assert removed["reason"] == IMSyncRemovalReason.NOT_PRESENT_IN_DIRECTORY.value + + +def _result_facts( + run: IMSyncRun, + contact: Contact, + identity: IMIdentity, + provider: IMProvider, +) -> tuple[SyncResultFact, ...]: + contact_snapshot = SyncContactSnapshot(contact.id, contact.name, contact.email, contact.avatar_file_id) + identity_snapshot = SyncIdentitySnapshot( + identity.id, + provider, + identity.provider_user_id, + identity.display_name, + identity.email, + ) + + def result( + result_id: int, + result_type: IMSyncResultType, + *, + include_entry: bool = True, + include_contact: bool = False, + removal_reason: IMSyncRemovalReason | None = None, + reason_message: str | None = None, + ) -> SyncResultFact: + suffix = str(result_id).zfill(12) + return SyncResultFact( + id=IMSyncResultId(f"00000000-0000-0000-0000-{suffix}"), + channel_id=IMChannelId(run.channel_revision.channel_id), + sync_run_id=run.id, + operation_key=f"result:{result_type.value}:{result_id}", + result_type=result_type, + provider_user_id=identity.provider_user_id if include_entry else None, + display_name=identity.display_name if include_entry else None, + email=identity.email if include_entry else None, + normalized_email=NormalizedEmail(identity.email) if include_entry and identity.email is not None else None, + contact_id=contact.id if include_contact else None, + identity_id=identity.id, + binding_id=None, + removal_reason=removal_reason, + reason_code=None, + reason_message=reason_message, + directory_entry_payload=None, + contact_snapshot=contact_snapshot if include_contact else None, + identity_snapshot=identity_snapshot if removal_reason is not None else None, + created_at=_NOW, + updated_at=_NOW, + ) + + return ( + result(1, IMSyncResultType.ADDED, include_contact=True), + result(2, IMSyncResultType.NOT_MATCHED), + result(3, IMSyncResultType.FAILED, reason_message="Directory read failed"), + result( + 4, + IMSyncResultType.REMOVED, + include_entry=False, + include_contact=True, + removal_reason=IMSyncRemovalReason.NOT_PRESENT_IN_DIRECTORY, + ), + result(5, IMSyncResultType.SKIPPED, include_contact=True), + ) + + +def test_missing_control_plane_state_returns_stable_http_errors( + db_session_with_containers: Session, + test_client_with_containers: FlaskClient, +) -> None: + account, tenant = create_console_account_and_tenant(db_session_with_containers) + headers = authenticate_console_client(test_client_with_containers, account) + + missing_integration = test_client_with_containers.get( + "/console/api/workspaces/current/human-input/v2/im-identities", + headers=headers, + ) + missing_sync_integration = test_client_with_containers.post(_SYNC_RUNS_PATH, headers=headers) + assert missing_integration.status_code == missing_sync_integration.status_code == 404 + assert missing_integration.get_json()["code"] == "im_integration_not_configured" + + channel = IMChannel( + id=_CHANNEL_ID, + created_at=_NOW, + updated_at=_NOW, + provider=IMProvider.FEISHU, + provider_tenant_id="provider-tenant-1", + encrypted_credentials=ChannelEncryptedCredentials(ciphertext="opaque-feishu-ciphertext"), + app_identifier="app-1", + webhook_id=WebhookId("00000000000000000000000000000001"), + config_version=1, + status=IMChannelStatus.CONNECTED, + ) + WorkspaceIMChannelWriter(db_session_with_containers, TenantId(tenant.id), AccountId(account.id)).create(channel) + db_session_with_containers.commit() + + missing_run = test_client_with_containers.get(f"{_SYNC_RUNS_PATH}/latest", headers=headers) + missing_results = test_client_with_containers.get( + f"{_SYNC_RUNS_PATH}/latest/results", + query_string={"result": IMSyncResultType.ADDED.value}, + headers=headers, + ) + assert missing_run.status_code == missing_results.status_code == 404 + assert missing_run.get_json()["code"] == "im_sync_run_not_found" + assert db_session_with_containers.scalar(select(HumanInputIMSyncRun)) is None diff --git a/api/tests/e2e_tests/containers/controllers/console/workspace/test_im_contact_sync_live.py b/api/tests/e2e_tests/containers/controllers/console/workspace/test_im_contact_sync_live.py new file mode 100644 index 00000000000..5c1d10d9eef --- /dev/null +++ b/api/tests/e2e_tests/containers/controllers/console/workspace/test_im_contact_sync_live.py @@ -0,0 +1,176 @@ +"""Authenticated HTTP-to-worker live Slack reconciliation verification.""" + +from __future__ import annotations + +import os +from datetime import datetime + +import pytest +from flask.testing import FlaskClient +from sqlalchemy import func, select +from sqlalchemy.orm import Session + +from core.human_input_v2.entities import IMProvider, IMSyncRunStatus +from core.human_input_v2.im_integration.adapters import SlackCredentials +from core.human_input_v2.shared import AccountId, IMSyncRunId, TenantId, WorkspaceScope +from extensions.ext_key_provider import key_provider_manager +from libs.datetime_utils import naive_utc_now +from libs.rsa import generate_key_pair +from libs.uuid_utils import uuidv7 +from models.human_input_v2 import HumanInputIMIdentity, HumanInputIMSyncResult +from repositories.human_input_v2.im_channel_repository import ( + IMChannel, + IMChannelId, + IMChannelStatus, + WebhookId, +) +from repositories.human_input_v2.sqlalchemy_im_channel_repository import WorkspaceIMChannelWriter +from services.human_input_v2.im_contact_sync.composition import build_im_contact_sync_worker +from services.human_input_v2.im_credential_codec import IMCredentialCodec +from services.human_input_v2.im_tenant_credential_cipher import TenantBoundCredentialCipher +from tasks.im_contact_sync_tasks import reconcile_im_contacts_task +from tests.e2e_tests.containers.controllers.console.helpers import ( + authenticate_console_client, + create_console_account_and_tenant, +) + +_SYNC_RUNS_PATH = "/console/api/workspaces/current/human-input/v2/im-sync-runs" +_REQUIRED_SLACK_ENVIRONMENT = ( + "SLACK_CLIENT_ID", + "SLACK_CLIENT_SECRET", + "SLACK_SIGNING_SECRET", + "SLACK_BOT_TOKEN", + "SLACK_APP_SOCKET_TOKEN", +) + + +def test_authenticated_http_sync_reaches_live_provider_worker_and_persisted_queries( + db_session_with_containers: Session, + test_client_with_containers: FlaskClient, + monkeypatch: pytest.MonkeyPatch, +) -> None: + credentials = _live_slack_credentials() + 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() + channel = _persist_live_slack_channel( + db_session_with_containers, + tenant_id=TenantId(tenant.id), + actor_id=AccountId(account.id), + credentials=credentials, + now=naive_utc_now(), + ) + headers = authenticate_console_client(test_client_with_containers, account) + dispatched: list[tuple[tuple[str, str, str | None], str]] = [] + + def capture_dispatch(*, args: tuple[str, str, str | None], queue: str) -> None: + dispatched.append((args, queue)) + + monkeypatch.setattr(reconcile_im_contacts_task, "apply_async", capture_dispatch) + + create_response = test_client_with_containers.post(_SYNC_RUNS_PATH, headers=headers) + + assert create_response.status_code == 200 + create_payload = create_response.get_json() + assert create_payload is not None + run_payload = create_payload["run"] + assert run_payload["status"] == IMSyncRunStatus.QUEUED.value + sync_run_id = IMSyncRunId(run_payload["id"]) + assert dispatched == [ + ( + (str(sync_run_id), "workspace", tenant.id), + "human_input_contact_sync", + ) + ] + + terminal_run = build_im_contact_sync_worker().execute( + sync_run_id, + WorkspaceScope(id=TenantId(tenant.id)), + ) + + assert terminal_run.status is IMSyncRunStatus.SUCCEEDED + latest_response = test_client_with_containers.get(f"{_SYNC_RUNS_PATH}/latest", headers=headers) + assert latest_response.status_code == 200 + latest_payload = latest_response.get_json() + assert latest_payload is not None + assert latest_payload["run"]["id"] == str(sync_run_id) + assert latest_payload["run"]["status"] == IMSyncRunStatus.SUCCEEDED.value + result_counts: dict[str, int] = latest_payload["run"]["result_counts"] + assert sum(result_counts.values()) > 0 + + result_bucket, expected_total = next((bucket, count) for bucket, count in result_counts.items() if count > 0) + first_page_response = test_client_with_containers.get( + f"{_SYNC_RUNS_PATH}/latest/results", + query_string={"result": result_bucket, "page": 1, "limit": 1}, + headers=headers, + ) + assert first_page_response.status_code == 200 + first_page = first_page_response.get_json() + assert first_page is not None + assert (first_page["page"], first_page["limit"], first_page["total"]) == (1, 1, expected_total) + assert len(first_page["data"]) == 1 + + if expected_total > 1: + second_page_response = test_client_with_containers.get( + f"{_SYNC_RUNS_PATH}/latest/results", + query_string={"result": result_bucket, "page": 2, "limit": 1}, + headers=headers, + ) + assert second_page_response.status_code == 200 + second_page = second_page_response.get_json() + assert second_page is not None + assert (second_page["page"], second_page["limit"], second_page["total"]) == (2, 1, expected_total) + assert len(second_page["data"]) == 1 + + db_session_with_containers.expire_all() + persisted_result_count = db_session_with_containers.scalar( + select(func.count(HumanInputIMSyncResult.id)).where(HumanInputIMSyncResult.sync_run_id == str(sync_run_id)) + ) + persisted_identity_count = db_session_with_containers.scalar( + select(func.count(HumanInputIMIdentity.id)).where(HumanInputIMIdentity.channel_id == str(channel.id)) + ) + assert persisted_result_count == sum(result_counts.values()) + assert persisted_identity_count == persisted_result_count + + +def _live_slack_credentials() -> dict[str, str]: + missing_names = tuple(name for name in _REQUIRED_SLACK_ENVIRONMENT if not os.getenv(name)) + if missing_names: + pytest.skip("live Slack credentials are not configured") + return {name: os.environ[name] for name in _REQUIRED_SLACK_ENVIRONMENT} + + +def _persist_live_slack_channel( + session: Session, + *, + tenant_id: TenantId, + actor_id: AccountId, + credentials: dict[str, str], + now: datetime, +) -> IMChannel: + plaintext_credentials = SlackCredentials( + provider=IMProvider.SLACK, + client_id=credentials["SLACK_CLIENT_ID"], + client_secret=credentials["SLACK_CLIENT_SECRET"], + signing_secret=credentials["SLACK_SIGNING_SECRET"], + bot_token=credentials["SLACK_BOT_TOKEN"], + app_token=credentials["SLACK_APP_SOCKET_TOKEN"], + ) + protected = IMCredentialCodec(TenantBoundCredentialCipher(key_provider_manager.provider, str(tenant_id))).seal( + plaintext_credentials + ) + channel = IMChannel( + id=IMChannelId(str(uuidv7())), + created_at=now, + updated_at=now, + provider=IMProvider.SLACK, + provider_tenant_id="live-slack-workspace", + encrypted_credentials=protected, + app_identifier=plaintext_credentials.client_id, + webhook_id=WebhookId(uuidv7().hex), + config_version=1, + status=IMChannelStatus.CONNECTED, + ) + WorkspaceIMChannelWriter(session, tenant_id, actor_id).create(channel) + session.commit() + return channel diff --git a/api/tests/e2e_tests/containers/core/app/layers/test_pause_state_persist_layer.py b/api/tests/e2e_tests/containers/core/app/layers/test_pause_state_persist_layer.py index 21941ec3ab4..d924462f521 100644 --- a/api/tests/e2e_tests/containers/core/app/layers/test_pause_state_persist_layer.py +++ b/api/tests/e2e_tests/containers/core/app/layers/test_pause_state_persist_layer.py @@ -211,9 +211,9 @@ def test_state_persistence_and_retrieval(workflow_case: WorkflowCase) -> None: pause = repository.get_workflow_pause(workflow_case.run_id) assert pause is not None assert pause.workflow_execution_id == workflow_case.run_id - assert pause.get_pause_reasons() == events[-1].reasons snapshot = WorkflowResumptionContext.loads(pause.get_state().decode()) restored = RuntimeState.from_snapshot(snapshot.serialized_graph_runtime_state) + assert restored.graph_execution.pause_reasons == events[-1].reasons assert restored.outputs == outputs assert restored.total_tokens == 250 assert restored.node_run_steps == prepared.entry.graph_engine.runtime_state.node_run_steps diff --git a/api/tests/e2e_tests/containers/core/human_input_v2/im_provider/test_dingtalk_live.py b/api/tests/e2e_tests/containers/core/human_input_v2/im_provider/test_dingtalk_live.py new file mode 100644 index 00000000000..e613fbdabf9 --- /dev/null +++ b/api/tests/e2e_tests/containers/core/human_input_v2/im_provider/test_dingtalk_live.py @@ -0,0 +1,317 @@ +"""Live DingTalk integration tests. + +These tests intentionally use real DingTalk credentials and external APIs. They +skip when the required environment variables are unavailable and never emit +credential material, Provider identifiers, or raw Provider responses. +""" + +from __future__ import annotations + +import os +from collections import deque +from dataclasses import dataclass +from time import monotonic, sleep +from typing import Never +from uuid import uuid4 + +import pytest +from alibabacloud_dingtalk.robot_1_0.models import BatchOTOQueryHeaders, BatchOTOQueryRequest +from alibabacloud_tea_util.models import RuntimeOptions +from pydantic import BaseModel, ConfigDict, Field, ValidationError +from Tea.exceptions import RetryError, TeaException, UnretryableException, ValidateException + +from core.human_input_v2.entities import IMProvider +from core.human_input_v2.im_integration.adapters import ( + CredentialTestSuccess, + DingTalkCredentials, + Directory, + MessageAccepted, + ProviderUserId, +) +from core.human_input_v2.im_integration.adapters import dingtalk as dingtalk_module +from core.human_input_v2.im_integration.adapters.dingtalk import DingTalkIMProviderAdapter + +_DEPARTMENT_LIST_URL = "https://oapi.dingtalk.com/topapi/v2/department/listsub" +_USER_LIST_URL = "https://oapi.dingtalk.com/topapi/v2/user/list" +_ROOT_DEPARTMENT_ID = 1 +_FORCED_PAGE_SIZE = 1 +_SDK_TIMEOUT_MILLISECONDS = 5000 +_STATUS_QUERY_TIMEOUT_SECONDS = 10.0 +_STATUS_QUERY_INTERVAL_SECONDS = 0.5 +_SUCCESS_SEND_STATUS = "SUCCESS" + + +def _fail(reason: str) -> Never: + raise AssertionError(reason) + + +class _WireProviderResponse(BaseModel): + model_config = ConfigDict(extra="ignore", strict=True) + + errcode: int + + +class _WireDepartment(BaseModel): + model_config = ConfigDict(extra="ignore", strict=True) + + dept_id: int = Field(ge=1) + + +class _WireDepartmentListResponse(_WireProviderResponse): + result: tuple[_WireDepartment, ...] | None = None + + +class _WireDirectoryUser(BaseModel): + model_config = ConfigDict(extra="ignore", strict=True) + + userid: str + name: str | None = None + email: str | None = None + + +class _WireUserPage(BaseModel): + model_config = ConfigDict(extra="ignore", strict=True) + + has_more: bool + next_cursor: int | None = None + users: tuple[_WireDirectoryUser, ...] = Field(alias="list") + + +class _WireUserListResponse(_WireProviderResponse): + result: _WireUserPage | None = None + + +@dataclass(frozen=True, slots=True) +class _LiveDirectoryEntry: + provider_user_id: str + display_name: str | None + email: str | None + + +@dataclass(frozen=True, slots=True) +class _LiveDirectorySnapshot: + entries: tuple[_LiveDirectoryEntry, ...] + exercised_pagination: bool + + +@pytest.fixture(scope="module") +def dingtalk_credentials() -> DingTalkCredentials: + corp_id = os.getenv("DINGTALK_CORP_ID") + client_id = os.getenv("DINGTALK_CLIENT_ID") + client_secret = os.getenv("DINGTALK_CLIENT_SECRET") + if not corp_id or not client_id or not client_secret: + pytest.skip("DingTalk live credentials are not configured") + return DingTalkCredentials( + provider=IMProvider.DING_TALK, + corp_id=corp_id, + client_id=client_id, + client_secret=client_secret, + ) + + +@pytest.fixture +def dingtalk_test_recipient_id() -> ProviderUserId: + recipient_id = os.getenv("DINGTALK_TEST_RECIPIENT_ID") + if not recipient_id: + pytest.skip("DINGTALK_TEST_RECIPIENT_ID is not configured") + return ProviderUserId(recipient_id) + + +def test_live_credentials_verify_complete_member_authorization( + dingtalk_credentials: DingTalkCredentials, +) -> None: + adapter = DingTalkIMProviderAdapter(dingtalk_credentials) + try: + result = adapter.test_credentials() + finally: + adapter.close() + adapter.close() + + if not isinstance(result, CredentialTestSuccess): + _fail("DingTalk live credential testing did not verify complete member authorization") + if result.provider_tenant_id != dingtalk_credentials.corp_id: + _fail("DingTalk live credential testing returned an unexpected tenant boundary") + + +def test_live_directory_reads_complete_non_empty_entries_across_real_pages( + dingtalk_credentials: DingTalkCredentials, +) -> None: + expected_snapshot = _read_live_directory_with_forced_pagination(dingtalk_credentials) + adapter = DingTalkIMProviderAdapter(dingtalk_credentials) + try: + actual_snapshot = adapter.directory.read_directory() + finally: + adapter.close() + adapter.close() + + if not isinstance(actual_snapshot, Directory): + _fail("DingTalk live Directory did not return a complete snapshot") + if not actual_snapshot.entries: + _fail("DingTalk live Directory returned an empty snapshot") + if not expected_snapshot.exercised_pagination: + _fail("DingTalk live Directory fixture did not exercise a real pagination boundary") + if not all( + str(entry.provider_user_id).strip() + and entry.display_name is not None + and entry.display_name.strip() + and entry.email is not None + and entry.email.strip() + for entry in actual_snapshot.entries + ): + _fail("DingTalk live Directory returned an entry with an empty shared field") + + expected_by_user_id = { + entry.provider_user_id: (entry.display_name, entry.email) for entry in expected_snapshot.entries + } + actual_by_user_id = { + str(entry.provider_user_id): (entry.display_name, entry.email) for entry in actual_snapshot.entries + } + if actual_by_user_id != expected_by_user_id: + _fail("DingTalk live Directory snapshot did not match the independently paginated snapshot") + + +def test_live_messaging_reports_provider_send_success( + dingtalk_credentials: DingTalkCredentials, + dingtalk_test_recipient_id: ProviderUserId, +) -> None: + message_body = f"Dify DingTalk live integration {uuid4()}" + adapter = DingTalkIMProviderAdapter(dingtalk_credentials) + try: + send_result = adapter.messaging.send_text(dingtalk_test_recipient_id, message_body) + finally: + adapter.close() + adapter.close() + + if not isinstance(send_result, MessageAccepted): + _fail("DingTalk did not confirm live message acceptance") + try: + locator_payload = dingtalk_module._DingTalkLocatorPayload.decode(str(send_result.locator)) + except Exception as exc: + raise AssertionError("DingTalk live message did not return a valid opaque locator") from exc + + # DingTalk exposes send/read status for OTO robot messages, but not the + # persisted message body. Exact msgParam content is therefore asserted in + # the SDK-boundary unit test while this test verifies the real mutation. + _wait_for_message_send_success( + dingtalk_credentials, + dingtalk_test_recipient_id, + locator_payload.process_query_key, + ) + + +def _read_live_directory_with_forced_pagination( + credentials: DingTalkCredentials, +) -> _LiveDirectorySnapshot: + access_token = dingtalk_module._get_access_token(dingtalk_module._new_oauth_client(), credentials) + http_client = dingtalk_module._UrllibDirectoryHTTPClient() + entries: list[_LiveDirectoryEntry] = [] + seen_user_ids: set[str] = set() + pending_departments: deque[int] = deque((_ROOT_DEPARTMENT_ID,)) + discovered_departments = {_ROOT_DEPARTMENT_ID} + exercised_pagination = False + try: + while pending_departments: + department_id = pending_departments.popleft() + department_response = _parse_wire_response( + http_client.post( + _DEPARTMENT_LIST_URL, + access_token=access_token, + body={"dept_id": department_id}, + ).body, + _WireDepartmentListResponse, + ) + if department_response.result is None: + _fail("DingTalk live department response omitted its result") + for child in department_response.result: + if child.dept_id in discovered_departments: + continue + discovered_departments.add(child.dept_id) + pending_departments.append(child.dept_id) + + cursor = 0 + seen_cursors = {cursor} + while True: + user_response = _parse_wire_response( + http_client.post( + _USER_LIST_URL, + access_token=access_token, + body={ + "dept_id": department_id, + "cursor": cursor, + "size": _FORCED_PAGE_SIZE, + }, + ).body, + _WireUserListResponse, + ) + if user_response.result is None: + _fail("DingTalk live user response omitted its result") + page = user_response.result + for user in page.users: + if user.userid in seen_user_ids: + continue + seen_user_ids.add(user.userid) + entries.append(_LiveDirectoryEntry(user.userid, user.name, user.email)) + if not page.has_more: + break + exercised_pagination = True + next_cursor = page.next_cursor + if next_cursor is None or next_cursor in seen_cursors: + _fail("DingTalk live Directory returned an invalid pagination cursor") + seen_cursors.add(next_cursor) + cursor = next_cursor + finally: + http_client.close() + return _LiveDirectorySnapshot(tuple(entries), exercised_pagination) + + +def _parse_wire_response[ResponseT: _WireProviderResponse]( + response_body: bytes, + response_type: type[ResponseT], +) -> ResponseT: + try: + response = response_type.model_validate_json(response_body) + except (ValidationError, ValueError): + _fail("DingTalk live Directory returned an invalid response") + if response.errcode != 0: + _fail("DingTalk live Directory rejected the request") + return response + + +def _wait_for_message_send_success( + credentials: DingTalkCredentials, + recipient_id: ProviderUserId, + process_query_key: str, +) -> None: + access_token = dingtalk_module._get_access_token(dingtalk_module._new_oauth_client(), credentials) + robot_client = dingtalk_module._new_robot_client() + deadline = monotonic() + _STATUS_QUERY_TIMEOUT_SECONDS + while True: + try: + response = robot_client.batch_otoquery_with_options( + BatchOTOQueryRequest( + process_query_key=process_query_key, + robot_code=credentials.client_id, + ), + BatchOTOQueryHeaders(x_acs_dingtalk_access_token=access_token), + RuntimeOptions( + autoretry=False, + max_attempts=1, + connect_timeout=_SDK_TIMEOUT_MILLISECONDS, + read_timeout=_SDK_TIMEOUT_MILLISECONDS, + ), + ) + except (RetryError, TeaException, UnretryableException, ValidateException): + _fail("DingTalk live message status query failed") + if not isinstance(response.status_code, int) or not 200 <= response.status_code < 300: + _fail("DingTalk live message status query returned a non-success status") + response_body = response.body + if response_body is None: + _fail("DingTalk live message status query returned no body") + recipient_observed = any( + read_info.user_id == str(recipient_id) for read_info in response_body.message_read_info_list or () + ) + if response_body.send_status == _SUCCESS_SEND_STATUS and recipient_observed: + return + if monotonic() >= deadline: + _fail("DingTalk did not report successful live message delivery before timeout") + sleep(_STATUS_QUERY_INTERVAL_SECONDS) diff --git a/api/tests/e2e_tests/containers/core/human_input_v2/im_provider/test_dingtalk_redis_token_provider.py b/api/tests/e2e_tests/containers/core/human_input_v2/im_provider/test_dingtalk_redis_token_provider.py new file mode 100644 index 00000000000..ed2876e0ef1 --- /dev/null +++ b/api/tests/e2e_tests/containers/core/human_input_v2/im_provider/test_dingtalk_redis_token_provider.py @@ -0,0 +1,112 @@ +from __future__ import annotations + +import os +from uuid import uuid4 + +import pytest +from alibabacloud_dingtalk.oauth2_1_0.models import GetTokenRequest, GetTokenResponse, GetTokenResponseBody +from redis import Redis + +from core.human_input_v2.entities import IMProvider +from core.human_input_v2.im_integration.adapters import DingTalkCredentials, dingtalk_redis + + +class _FakeOAuthClient: + def __init__(self, response: GetTokenResponse | None = None) -> None: + self.response = response + self.calls: list[tuple[str, GetTokenRequest]] = [] + + def get_token(self, corp_id: str, request: GetTokenRequest) -> GetTokenResponse: + self.calls.append((corp_id, request)) + if self.response is None: + pytest.fail("cached provider unexpectedly requested a DingTalk token") + return self.response + + +def _token_response(token: str, *, expires_in: int = 180) -> GetTokenResponse: + return GetTokenResponse( + status_code=200, + body=GetTokenResponseBody(access_token=token, expires_in=expires_in), + ) + + +def test_real_redis_reuses_tokens_for_the_same_client_id( + monkeypatch: pytest.MonkeyPatch, +) -> None: + redis_url = os.getenv("REDIS_URL") + if redis_url is None or not redis_url.strip(): + pytest.skip("REDIS_URL is unavailable") + + identity = uuid4().hex + credentials = DingTalkCredentials( + provider=IMProvider.DING_TALK, + corp_id=f"fake-corp-{identity}", + client_id=f"fake-client-{identity}", + client_secret=f"fake-secret-{identity}", + ) + changed_credentials = credentials.model_copy(update={"client_secret": f"fake-changed-secret-{identity}"}) + short_lived_credentials = credentials.model_copy(update={"client_id": f"fake-short-client-{identity}"}) + first_client = _FakeOAuthClient(_token_response("fake-fresh-token-primary")) + cached_client = _FakeOAuthClient() + changed_client = _FakeOAuthClient() + text_cached_client = _FakeOAuthClient() + malformed_client = _FakeOAuthClient(_token_response("fake-recovered-token-malformed")) + persistent_client = _FakeOAuthClient(_token_response("fake-recovered-token-persistent")) + short_lived_client = _FakeOAuthClient(_token_response("fake-short-lived-token", expires_in=60)) + clients = iter( + ( + first_client, + cached_client, + changed_client, + text_cached_client, + malformed_client, + persistent_client, + short_lived_client, + ) + ) + monkeypatch.setattr(dingtalk_redis, "_new_oauth_client", lambda: next(clients)) + cache = Redis.from_url(redis_url, decode_responses=False) + text_cache = Redis.from_url(redis_url, decode_responses=True) + primary_key = dingtalk_redis._cache_key(credentials.client_id) + changed_key = dingtalk_redis._cache_key(changed_credentials.client_id) + short_lived_key = dingtalk_redis._cache_key(short_lived_credentials.client_id) + + try: + first_provider = dingtalk_redis.RedisCacheAccessTokenProvider(credentials, cache) + cached_provider = dingtalk_redis.RedisCacheAccessTokenProvider(credentials, cache) + changed_provider = dingtalk_redis.RedisCacheAccessTokenProvider(changed_credentials, cache) + + assert first_provider.get() == "fake-fresh-token-primary" + primary_ttl = cache.ttl(primary_key) + assert 0 < primary_ttl <= 120 + assert cached_provider.get() == "fake-fresh-token-primary" + assert cached_client.calls == [] + + assert changed_provider.get() == "fake-fresh-token-primary" + assert changed_client.calls == [] + assert changed_key == primary_key + assert cache.get(primary_key) == b"fake-fresh-token-primary" + + text_cached_provider = dingtalk_redis.RedisCacheAccessTokenProvider(credentials, text_cache) + assert text_cached_provider.get() == "fake-fresh-token-primary" + assert text_cached_client.calls == [] + + cache.set(changed_key, b"\xff", ex=120) + malformed_provider = dingtalk_redis.RedisCacheAccessTokenProvider(changed_credentials, cache) + assert malformed_provider.get() == "fake-recovered-token-malformed" + assert len(malformed_client.calls) == 1 + + cache.set(changed_key, "fake-persistent-token") + persistent_provider = dingtalk_redis.RedisCacheAccessTokenProvider(changed_credentials, cache) + assert persistent_provider.get() == "fake-recovered-token-persistent" + assert len(persistent_client.calls) == 1 + + short_lived_provider = dingtalk_redis.RedisCacheAccessTokenProvider(short_lived_credentials, cache) + assert short_lived_provider.get() == "fake-short-lived-token" + assert len(short_lived_client.calls) == 1 + assert cache.exists(short_lived_key) == 0 + finally: + cache.delete(primary_key, short_lived_key) + assert cache.exists(primary_key, short_lived_key) == 0 + text_cache.close() + cache.close() diff --git a/api/tests/e2e_tests/containers/core/human_input_v2/im_provider/test_feishu_lark_card_event_flow.py b/api/tests/e2e_tests/containers/core/human_input_v2/im_provider/test_feishu_lark_card_event_flow.py new file mode 100644 index 00000000000..52e522b58e1 --- /dev/null +++ b/api/tests/e2e_tests/containers/core/human_input_v2/im_provider/test_feishu_lark_card_event_flow.py @@ -0,0 +1,679 @@ +from __future__ import annotations + +import json +import os +from collections.abc import Callable, Mapping +from datetime import datetime +from pathlib import Path +from uuid import uuid4 + +import pytest +from lark_oapi.event.callback.model.p2_card_action_trigger import ( + P2CardActionTrigger, + P2CardActionTriggerResponse, +) + +from core.human_input import ButtonStyle +from core.human_input_v2.entities import IMProvider +from core.human_input_v2.im_integration.adapters import ( + AuthenticatedIMEvent, + CorrelationToken, + DynamicCardMessagingError, + EventAcceptance, + IMCardEvent, + IMCardEventDecodingError, + IMEventIngressKind, + MessageAccepted, + ProviderUserId, + StaticCardIntent, + UnrecognizedIMEvent, + WebhookRequest, + feishu_lark, +) +from core.human_input_v2.im_integration.adapters.credentials import FeishuCredentials +from core.human_input_v2.im_integration.adapters.feishu_lark import ( + FeishuIMProviderAdapter, +) +from core.human_input_v2.resolved_form import MarkdownFragment, ParagraphInput, ResolvedForm, SelectInput, UserAction + +_FIXTURE_DIRECTORY = Path(__file__).resolve().parents[5] / "unit_tests/core/human_input_v2/im_provider/fixtures" +_WEBHOOK_FIXTURE = _FIXTURE_DIRECTORY / "feishu_lark_card_action_webhook.json" +_STREAM_FIXTURE = _FIXTURE_DIRECTORY / "feishu_lark_card_action_stream.json" +_RECEIVED_AT = datetime(2026, 8, 12, 10, 0, 0) +_DIFY_ACTION_MARKER = "__dify.human_input.action" +_LEGACY_WEBHOOK_PAYLOAD_KEY = "__dify_feishu_lark.webhook" +_LEGACY_STREAM_PAYLOAD_KEY = "__dify_feishu_lark.stream" + + +class _RecordingGateway: + def __init__(self) -> None: + self.card_content: dict[str, object] | None = None + + def query_tenant(self) -> Mapping[str, object]: + return {"code": 0, "data": {"tenant": {"tenant_key": "tenant_test_only"}}} + + def list_scope(self, _page_token: str | None) -> Mapping[str, object]: + raise AssertionError("unexpected directory call") + + def list_departments( + self, + _department: feishu_lark._DepartmentIdentity, + _page_token: str | None, + ) -> Mapping[str, object]: + raise AssertionError("unexpected directory call") + + def list_users( + self, + _department: feishu_lark._DepartmentIdentity, + _page_token: str | None, + ) -> Mapping[str, object]: + raise AssertionError("unexpected directory call") + + def create_message(self, _receive_id: str, msg_type: str, content: str) -> Mapping[str, object]: + assert msg_type == "interactive" + decoded_content = json.loads(content) + assert isinstance(decoded_content, dict) + self.card_content = decoded_content + return {"code": 0, "data": {"message_id": "message_test_only"}} + + def patch_message(self, _message_id: str, _content: str) -> Mapping[str, object]: + raise AssertionError("unexpected replacement call") + + +class _LiveRecordingGateway: + def __init__(self, delegate: feishu_lark._SDKGateway) -> None: + self._delegate = delegate + self.card_content: dict[str, object] | None = None + + def query_tenant(self) -> Mapping[str, object]: + return self._delegate.query_tenant() + + def list_scope(self, page_token: str | None) -> Mapping[str, object]: + return self._delegate.list_scope(page_token) + + def list_departments( + self, + department: feishu_lark._DepartmentIdentity, + page_token: str | None, + ) -> Mapping[str, object]: + return self._delegate.list_departments(department, page_token) + + def list_users( + self, + department: feishu_lark._DepartmentIdentity, + page_token: str | None, + ) -> Mapping[str, object]: + return self._delegate.list_users(department, page_token) + + def create_message(self, receive_id: str, msg_type: str, content: str) -> Mapping[str, object]: + decoded_content = json.loads(content) + assert isinstance(decoded_content, dict) + self.card_content = decoded_content + return self._delegate.create_message(receive_id, msg_type, content) + + def patch_message(self, message_id: str, content: str) -> Mapping[str, object]: + return self._delegate.patch_message(message_id, content) + + +class _EventConsumer: + def __init__(self) -> None: + self.event: AuthenticatedIMEvent | None = None + + def accept(self, event: AuthenticatedIMEvent, *, request_ip_address: str | None = None) -> EventAcceptance: + del request_ip_address + self.event = event + return EventAcceptance.ACCEPTED + + +class _FixtureSDKObjectStreamClient: + def __init__( + self, + credentials: FeishuCredentials, + domain: str, + callback: Callable[[feishu_lark._SDKEventEnvelope, Callable[[], None]], None], + sdk_event: P2CardActionTrigger, + ) -> None: + self._channel = feishu_lark._SynchronousEventChannel( + credentials=credentials, + domain=domain, + callback=callback, + ) + self._sdk_event = sdk_event + self.response: P2CardActionTriggerResponse | None = None + + def start(self) -> None: + self.response = self._channel._on_card_action(self._sdk_event) + + def stop(self) -> None: + return None + + +def _intent(marker: str = "test-only") -> ResolvedForm: + return ResolvedForm( + title=f"Feishu/Lark card agreement [{marker}]", + parts=( + MarkdownFragment(text="Review the provider wire card."), + ParagraphInput(output_variable_name="comment", default_value="Initial review"), + SelectInput(output_variable_name="decision", options=("ship", "hold"), default_value="hold"), + ), + actions=(UserAction(id="approve", title="Approve", button_style=ButtonStyle.PRIMARY),), + legacy_form_content="unused", + ) + + +def _credentials() -> FeishuCredentials: + return FeishuCredentials( + provider=IMProvider.FEISHU, + app_id="cli_test_only", + app_secret="secret_test_only", + verification_token="verification_test_only", + encrypt_key="encrypt_key_test_only", + ) + + +def _load_fixture(path: Path) -> dict[str, object]: + fixture = json.loads(path.read_text(encoding="utf-8")) + assert isinstance(fixture, dict) + return fixture + + +def _fixture_payload(fixture: Mapping[str, object]) -> dict[str, object]: + authenticated_event = fixture["authenticated_event"] + assert isinstance(authenticated_event, dict) + callback = authenticated_event["payload"] + assert isinstance(callback, dict) + return callback + + +def _decrypted_webhook_payload(fixture: Mapping[str, object]) -> str: + http = fixture["http"] + assert isinstance(http, dict) + request = http["request"] + assert isinstance(request, dict) + body = request["body"] + assert isinstance(body, str) + encrypted_envelope = json.loads(body) + assert isinstance(encrypted_envelope, dict) + encrypted_payload = encrypted_envelope["encrypt"] + assert isinstance(encrypted_payload, str) + return feishu_lark._decrypt_webhook_payload(encrypted_payload, "encrypt_key_test_only").decode() + + +def _replayed_sdk_native_payload(fixture: Mapping[str, object]) -> str: + stream_evidence = fixture["stream"] + assert isinstance(stream_evidence, dict) + serialization = stream_evidence["sdk_serialization"] + assert isinstance(serialization, dict) + captured_payload = serialization["value"] + assert isinstance(captured_payload, str) + sdk_event_mapping = json.loads(captured_payload) + assert isinstance(sdk_event_mapping, dict) + native_payload = feishu_lark.lark.JSON.marshal(P2CardActionTrigger(sdk_event_mapping)) + assert isinstance(native_payload, str) + return native_payload + + +def _fixture_action(callback: Mapping[str, object]) -> dict[str, object]: + event = callback["event"] + assert isinstance(event, dict) + action = event["action"] + assert isinstance(action, dict) + return action + + +def _replay_webhook( + adapter: FeishuIMProviderAdapter, + fixture: Mapping[str, object], +) -> AuthenticatedIMEvent: + http = fixture["http"] + assert isinstance(http, dict) + request = http["request"] + expected_response = http["response"] + assert isinstance(request, dict) + assert isinstance(expected_response, dict) + raw_headers = request["headers"] + assert isinstance(raw_headers, list) + headers: list[tuple[str, str]] = [] + for raw_header in raw_headers: + assert isinstance(raw_header, dict) + name = raw_header["name"] + value = raw_header["value"] + assert isinstance(name, str) + assert isinstance(value, str) + headers.append((name, value)) + + method = request["method"] + body = request["body"] + received_at = request["received_at"] + assert isinstance(method, str) + assert isinstance(body, str) + assert isinstance(received_at, str) + consumer = _EventConsumer() + response = adapter.create_webhook_handler(consumer).handle( + WebhookRequest( + method=method, + headers=tuple(headers), + body=body.encode(), + received_at=datetime.fromisoformat(received_at), + ) + ) + + assert response.status_code == expected_response["status"] + assert response.body.decode() == expected_response["body"] + assert consumer.event is not None + return consumer.event + + +def _replay_stream( + monkeypatch: pytest.MonkeyPatch, + adapter: FeishuIMProviderAdapter, + fixture: Mapping[str, object], +) -> AuthenticatedIMEvent: + stream_evidence = fixture["stream"] + assert isinstance(stream_evidence, dict) + serialization = stream_evidence["sdk_serialization"] + callback_response = stream_evidence["callback_response"] + assert isinstance(serialization, dict) + assert isinstance(callback_response, dict) + serialized_event = serialization["value"] + assert isinstance(serialized_event, str) + sdk_event_mapping = json.loads(serialized_event) + assert isinstance(sdk_event_mapping, dict) + sdk_event = P2CardActionTrigger(sdk_event_mapping) + clients: list[_FixtureSDKObjectStreamClient] = [] + + def create_stream_client( + credentials: FeishuCredentials, + domain: str, + callback: Callable[[feishu_lark._SDKEventEnvelope, Callable[[], None]], None], + ) -> _FixtureSDKObjectStreamClient: + client = _FixtureSDKObjectStreamClient(credentials, domain, callback, sdk_event) + clients.append(client) + return client + + monkeypatch.setattr(feishu_lark, "_create_sdk_stream_client", create_stream_client) + consumer = _EventConsumer() + stream = adapter.create_stream_handler(consumer) + stream.start() + stream.stop() + + assert len(clients) == 1 + response = clients[0].response + assert isinstance(response, P2CardActionTriggerResponse) + response_serialization = callback_response["sdk_serialization"] + assert isinstance(response_serialization, dict) + assert feishu_lark.lark.JSON.marshal(response) == response_serialization["value"] + assert consumer.event is not None + return consumer.event + + +def _event( + callback: Mapping[str, object], + provider: IMProvider = IMProvider.FEISHU, + *, + ingress_kind: IMEventIngressKind = IMEventIngressKind.WEBHOOK, + payload: str | None = None, +) -> AuthenticatedIMEvent: + serialized_payload = payload + if serialized_payload is None: + serialized_payload = json.dumps(callback, ensure_ascii=False) + return AuthenticatedIMEvent( + provider=provider, + provider_tenant_id="tenant_test_only", + event_id="event_test_only", + event_type="card.action.trigger", + occurred_at=None, + received_at=_RECEIVED_AT, + ingress_kind=ingress_kind, + payload=serialized_payload, + ) + + +def _button_metadata(card: Mapping[str, object]) -> dict[str, object]: + body = card["body"] + assert isinstance(body, dict) + body_elements = body["elements"] + assert isinstance(body_elements, list) + form = body_elements[0] + assert isinstance(form, dict) + form_elements = form["elements"] + assert isinstance(form_elements, list) + action_row = form_elements[-1] + assert isinstance(action_row, dict) + columns = action_row["columns"] + assert isinstance(columns, list) + column = columns[0] + assert isinstance(column, dict) + buttons = column["elements"] + assert isinstance(buttons, list) + button = buttons[0] + assert isinstance(button, dict) + behaviors = button["behaviors"] + assert isinstance(behaviors, list) + behavior = behaviors[0] + assert isinstance(behavior, dict) + metadata = behavior["value"] + assert isinstance(metadata, dict) + return metadata + + +def _callback(metadata: Mapping[str, object], actor: str = "union_test_only") -> dict[str, object]: + marked_metadata = metadata.get(_DIFY_ACTION_MARKER) + raw_action_id = marked_metadata.get("action_id") if isinstance(marked_metadata, Mapping) else None + action_id = raw_action_id if isinstance(raw_action_id, str) else "foreign" + return { + "schema": "2.0", + "header": {"event_type": "card.action.trigger", "tenant_key": "tenant_test_only"}, + "event": { + "operator": {"union_id": actor, "open_id": "open_id_must_not_be_used"}, + "action": { + "tag": "button", + "name": action_id, + "value": dict(metadata), + "form_value": {"comment": "Reviewed ✅", "decision": "ship"}, + }, + }, + } + + +def test_webhook_transport_preserves_exact_decrypted_payload_until_decode( + monkeypatch: pytest.MonkeyPatch, +) -> None: + fixture = _load_fixture(_WEBHOOK_FIXTURE) + native_payload = _decrypted_webhook_payload(fixture) + gateway = _RecordingGateway() + monkeypatch.setattr(feishu_lark, "_create_sdk_gateway", lambda _credentials, _domain: gateway) + adapter = FeishuIMProviderAdapter(_credentials()) + + try: + authenticated_event = _replay_webhook(adapter, fixture) + decoded = FeishuIMProviderAdapter.card_event_decoder().decode(authenticated_event) + finally: + adapter.close() + + assert authenticated_event.ingress_kind is IMEventIngressKind.WEBHOOK + assert authenticated_event.payload == native_payload + assert json.loads(native_payload) == _fixture_payload(fixture) + assert _LEGACY_WEBHOOK_PAYLOAD_KEY not in authenticated_event.payload + assert isinstance(decoded, IMCardEvent) + + +def test_stream_transport_preserves_exact_sdk_payload_until_decode( + monkeypatch: pytest.MonkeyPatch, +) -> None: + fixture = _load_fixture(_STREAM_FIXTURE) + stream_evidence = fixture["stream"] + assert isinstance(stream_evidence, dict) + serialization = stream_evidence["sdk_serialization"] + assert isinstance(serialization, dict) + native_payload = _replayed_sdk_native_payload(fixture) + object_type = stream_evidence["object_type"] + assert isinstance(object_type, str) + gateway = _RecordingGateway() + monkeypatch.setattr(feishu_lark, "_create_sdk_gateway", lambda _credentials, _domain: gateway) + adapter = FeishuIMProviderAdapter(_credentials()) + + try: + authenticated_event = _replay_stream(monkeypatch, adapter, fixture) + decoded = FeishuIMProviderAdapter.card_event_decoder().decode(authenticated_event) + finally: + adapter.close() + + assert authenticated_event.ingress_kind is IMEventIngressKind.STREAM + assert authenticated_event.payload == native_payload + assert _LEGACY_STREAM_PAYLOAD_KEY not in authenticated_event.payload + assert object_type not in authenticated_event.payload + assert isinstance(decoded, IMCardEvent) + + +def test_sender_wire_agrees_with_sanitized_webhook_and_stream_boundaries( + monkeypatch: pytest.MonkeyPatch, +) -> None: + gateway = _RecordingGateway() + monkeypatch.setattr(feishu_lark, "_create_sdk_gateway", lambda _credentials, _domain: gateway) + adapter = FeishuIMProviderAdapter(_credentials()) + token = CorrelationToken("feishu-lark-card-evidence-token") + webhook_fixture = _load_fixture(_WEBHOOK_FIXTURE) + stream_fixture = _load_fixture(_STREAM_FIXTURE) + + try: + assert adapter.dynamic_card_messaging.assess(_intent()).representable is True + empty_intent = ResolvedForm(title="", parts=(), actions=(), legacy_form_content="unused") + assert adapter.dynamic_card_messaging.assess(empty_intent).representable is False + result = adapter.dynamic_card_messaging.send_card( + ProviderUserId("union_test_only"), + _intent(), + token, + ) + assert isinstance(result, MessageAccepted) + assert gateway.card_content is not None + sender_metadata = _button_metadata(gateway.card_content) + sender_marker = sender_metadata[_DIFY_ACTION_MARKER] + assert isinstance(sender_marker, dict) + action_id = sender_marker["action_id"] + correlation_token = sender_marker["correlation_token"] + assert isinstance(action_id, str) + assert correlation_token == token + + webhook_callback = _fixture_payload(webhook_fixture) + stream_callback = _fixture_payload(stream_fixture) + webhook_action = _fixture_action(webhook_callback) + stream_action = _fixture_action(stream_callback) + assert webhook_action["value"] == sender_metadata + assert stream_action["value"] == sender_metadata + submitted_inputs = webhook_action["form_value"] + assert isinstance(submitted_inputs, dict) + assert stream_action["form_value"] == submitted_inputs + + webhook_event = _replay_webhook(adapter, webhook_fixture) + stream_event = _replay_stream(monkeypatch, adapter, stream_fixture) + webhook_decoded = FeishuIMProviderAdapter.card_event_decoder().decode(webhook_event) + stream_decoded = FeishuIMProviderAdapter.card_event_decoder().decode(stream_event) + finally: + adapter.close() + + expected = IMCardEvent( + provider_user_id=ProviderUserId("union_test_only"), + action_id=action_id, + inputs=submitted_inputs, + correlation_token=token, + ) + assert webhook_decoded == expected + assert stream_decoded == expected + + +def test_stream_evidence_decodes_through_both_provider_variants() -> None: + fixture = json.loads(_STREAM_FIXTURE.read_text(encoding="utf-8")) + assert isinstance(fixture, dict) + authenticated_event = fixture["authenticated_event"] + assert isinstance(authenticated_event, dict) + callback = authenticated_event["payload"] + assert isinstance(callback, dict) + + feishu_result = FeishuIMProviderAdapter.card_event_decoder().decode(_event(callback)) + lark_result = feishu_lark.LarkIMProviderAdapter.card_event_decoder().decode(_event(callback, IMProvider.LARK)) + + assert feishu_result == lark_result + assert isinstance(feishu_result, IMCardEvent) + assert feishu_result.provider_user_id == ProviderUserId("union_test_only") + + +def test_stream_transport_rejects_whitespace_union_id_with_safe_diagnostic( + monkeypatch: pytest.MonkeyPatch, +) -> None: + fixture = _load_fixture(_STREAM_FIXTURE) + callback = _fixture_payload(fixture) + callback_event = callback["event"] + assert isinstance(callback_event, dict) + operator = callback_event["operator"] + assert isinstance(operator, dict) + operator["union_id"] = " \t " + stream_evidence = fixture["stream"] + assert isinstance(stream_evidence, dict) + serialization = stream_evidence["sdk_serialization"] + assert isinstance(serialization, dict) + serialization["value"] = json.dumps(callback, ensure_ascii=False) + + gateway = _RecordingGateway() + monkeypatch.setattr(feishu_lark, "_create_sdk_gateway", lambda _credentials, _domain: gateway) + adapter = FeishuIMProviderAdapter(_credentials()) + try: + authenticated_event = _replay_stream(monkeypatch, adapter, fixture) + with pytest.raises( + IMCardEventDecodingError, + match=r"^Feishu/Lark card event schema is invalid\.$", + ) as raised: + FeishuIMProviderAdapter.card_event_decoder().decode(authenticated_event) + finally: + adapter.close() + + assert raised.value.args == ("Feishu/Lark card event schema is invalid.",) + assert raised.value.__cause__ is None + assert raised.value.__context__ is None + + +def test_protocol_failure_and_routing_boundaries_remain_distinct() -> None: + codec = feishu_lark._MSFeishuLarkCardCodec() + foreign = _callback({"foreign": True}) + assert isinstance(codec.decode(_event(foreign)), UnrecognizedIMEvent) + assert isinstance(codec.decode(_event(foreign, IMProvider.SLACK)), UnrecognizedIMEvent) + + with pytest.raises(IMCardEventDecodingError, match="schema is invalid"): + codec.decode(_event({"schema": "1.0"})) + + with pytest.raises(IMCardEventDecodingError, match="payload is invalid"): + codec.decode(_event({}, payload="[]")) + + recognized = _callback( + { + _DIFY_ACTION_MARKER: { + "version": 1, + "action_id": "approve", + "correlation_token": "token", + } + }, + actor="", + ) + with pytest.raises(IMCardEventDecodingError, match="schema is invalid"): + codec.decode(_event(recognized)) + + with pytest.raises(IMCardEventDecodingError, match="payload is invalid"): + codec.decode(_event({}, payload="not-json")) + + oversized = ResolvedForm( + title="", + parts=(MarkdownFragment(text="x" * (31 * 1024)),), + actions=(), + legacy_form_content="unused", + ) + with pytest.raises(DynamicCardMessagingError, match="Provider payload limit"): + codec.encode(oversized, CorrelationToken("token")) + + +@pytest.mark.parametrize( + ("payload", "expected_message"), + [ + pytest.param( + json.dumps( + { + "schema": "2.0", + "header": {"event_type": "card.action.trigger"}, + "event": {}, + } + ), + "Feishu/Lark card event schema is invalid.", + id="malformed-direct-callback", + ), + pytest.param( + json.dumps({_LEGACY_WEBHOOK_PAYLOAD_KEY: {"encrypted": True}}), + "Feishu/Lark card event schema is invalid.", + id="malformed-webhook-wrapper", + ), + pytest.param( + json.dumps( + { + _LEGACY_STREAM_PAYLOAD_KEY: { + "native_payload": "{}", + "object_type": "unexpected.sdk.Event", + } + } + ), + "Feishu/Lark card event schema is invalid.", + id="wrong-stream-object-type", + ), + pytest.param( + json.dumps( + { + _LEGACY_WEBHOOK_PAYLOAD_KEY: { + "encrypted": True, + "native_payload": "not-json", + } + } + ), + "Feishu/Lark card event schema is invalid.", + id="invalid-native-payload", + ), + ], +) +def test_direct_payload_protocol_rejects_obsolete_wrappers_and_malformed_callbacks( + payload: str, + expected_message: str, +) -> None: + codec = feishu_lark._MSFeishuLarkCardCodec() + + with pytest.raises(IMCardEventDecodingError) as raised: + codec.decode(_event({}, payload=payload)) + + assert raised.value.args == (expected_message,) + + +def test_live_feishu_sender_is_accepted_and_replaced_without_callback_synthesis( + monkeypatch: pytest.MonkeyPatch, +) -> None: + app_id = os.getenv("FEISHU_APP_ID") + app_secret = os.getenv("FEISHU_APP_SECRET") + verification_token = os.getenv("FEISHU_VERIFICATION_TOKEN") + encrypt_key = os.getenv("FEISHU_ENCRYPT_KEY") + recipient_id = os.getenv("FEISHU_TEST_RECIPIENT_ID") + if not all((app_id, app_secret, verification_token, encrypt_key, recipient_id)): + pytest.skip("Feishu live card sender credentials or recipient are unavailable") + assert app_id is not None + assert app_secret is not None + assert verification_token is not None + assert encrypt_key is not None + assert recipient_id is not None + + credentials = FeishuCredentials( + provider=IMProvider.FEISHU, + app_id=app_id, + app_secret=app_secret, + verification_token=verification_token, + encrypt_key=encrypt_key, + ) + live_gateway = _LiveRecordingGateway(feishu_lark._OfficialSDKGateway(credentials, feishu_lark._FEISHU_DOMAIN)) + monkeypatch.setattr(feishu_lark, "_create_sdk_gateway", lambda _credentials, _domain: live_gateway) + adapter = FeishuIMProviderAdapter(credentials) + marker = f"codex-feishu-lark-card-event-{uuid4()}" + token = CorrelationToken(f"{marker}-correlation") + + try: + result = adapter.dynamic_card_messaging.send_card( + ProviderUserId(recipient_id), + _intent(marker), + token, + ) + assert isinstance(result, MessageAccepted) + assert live_gateway.card_content is not None + marker_metadata = _button_metadata(live_gateway.card_content)[_DIFY_ACTION_MARKER] + assert isinstance(marker_metadata, dict) + assert marker_metadata["action_id"] == "approve" + assert marker_metadata["correlation_token"] == token + assert ( + adapter.dynamic_card_messaging.replace_with_static( + result.locator, + StaticCardIntent(f"Feishu/Lark card integration completed [{marker}]"), + ) + is None + ) + finally: + adapter.close() diff --git a/api/tests/e2e_tests/containers/core/human_input_v2/im_provider/test_ms_teams_card_event_flow.py b/api/tests/e2e_tests/containers/core/human_input_v2/im_provider/test_ms_teams_card_event_flow.py new file mode 100644 index 00000000000..c09ecf75abd --- /dev/null +++ b/api/tests/e2e_tests/containers/core/human_input_v2/im_provider/test_ms_teams_card_event_flow.py @@ -0,0 +1,185 @@ +from __future__ import annotations + +import json +from collections.abc import Mapping +from datetime import datetime + +import pytest + +from core.human_input import ButtonStyle +from core.human_input_v2.entities import IMProvider +from core.human_input_v2.im_integration.adapters import ( + AuthenticatedIMEvent, + CorrelationToken, + DynamicCardMessagingError, + IMCardEvent, + IMCardEventDecodingError, + IMEventIngressKind, + ProviderUserId, + UnrecognizedIMEvent, +) +from core.human_input_v2.im_integration.adapters.ms_teams import MSTeamsIMProviderAdapter, _MSTeamsCardCodec +from core.human_input_v2.resolved_form import MarkdownFragment, ParagraphInput, ResolvedForm, SelectInput, UserAction + +_RECEIVED_AT = datetime(2026, 8, 12, 2, 26, 40) + + +def _form(*, comment_input_name: str = "review_comment") -> ResolvedForm: + return ResolvedForm( + title="Microsoft Teams card integration", + parts=( + MarkdownFragment(text="Review the encoded form."), + ParagraphInput(output_variable_name=comment_input_name, default_value="Initial review"), + SelectInput(output_variable_name="risk_level", options=("low", "high"), default_value="low"), + ), + actions=( + UserAction(id="approve-✅", title="Approve", button_style=ButtonStyle.PRIMARY), + UserAction(id="reject", title="Reject", button_style=ButtonStyle.ACCENT), + ), + legacy_form_content="unused", + ) + + +def _event(callback: Mapping[str, object], *, event_type: str = "message") -> AuthenticatedIMEvent: + return AuthenticatedIMEvent( + provider=IMProvider.MS_TEAMS, + provider_tenant_id="integration-tenant", + event_id="integration-card-event", + event_type=event_type, + occurred_at=None, + received_at=_RECEIVED_AT, + ingress_kind=IMEventIngressKind.WEBHOOK, + payload=json.dumps(callback, ensure_ascii=False, separators=(",", ":")), + ) + + +def test_ms_teams_sender_metadata_and_callback_decoder_round_trip() -> None: + codec = MSTeamsIMProviderAdapter.card_event_decoder() + assert isinstance(codec, _MSTeamsCardCodec) + correlation_token = CorrelationToken("integration-correlation-🔐") + + card = codec.encode(_form(), correlation_token) + actions = card["actions"] + assert isinstance(actions, list) + invoked_action = actions[0] + assert isinstance(invoked_action, dict) + action_data = invoked_action["data"] + assert isinstance(action_data, dict) + callback_value = { + **action_data, + "review_comment": "Reviewed exactly ✅", + "risk_level": "high", + } + callback = { + "type": "message", + "from": { + "id": "29:integration-teams-user", + "aadObjectId": "11111111-2222-4333-8444-abcdefabcdef", + }, + "value": callback_value, + } + + decoded = MSTeamsIMProviderAdapter.card_event_decoder().decode(_event(callback)) + + assert decoded == IMCardEvent( + provider_user_id=ProviderUserId("11111111-2222-4333-8444-abcdefabcdef"), + action_id="approve-✅", + inputs={"review_comment": "Reviewed exactly ✅", "risk_level": "high"}, + correlation_token=correlation_token, + ) + + +def test_ms_teams_codec_rejects_collision_and_malformed_recognized_callback() -> None: + codec = MSTeamsIMProviderAdapter.card_event_decoder() + assert isinstance(codec, _MSTeamsCardCodec) + colliding_form = _form(comment_input_name="__dify.human_input") + + assert codec.assess(_form()).representable is True + assert codec.assess(colliding_form).representable is False + with pytest.raises(DynamicCardMessagingError): + codec.encode(colliding_form, CorrelationToken("integration-correlation")) + + malformed_callback = { + "type": "invoke", + "from": { + "id": "29:integration-teams-user", + "aadObjectId": "11111111-2222-4333-8444-abcdefabcdef", + }, + "value": { + "__dify.human_input": { + "version": 1, + "action_id": True, + "correlation_token": "integration-correlation", + } + }, + } + with pytest.raises(IMCardEventDecodingError): + codec.decode(_event(malformed_callback, event_type="invoke")) + + +def test_ms_teams_codec_routes_foreign_and_invalid_transport_events() -> None: + decoder = MSTeamsIMProviderAdapter.card_event_decoder() + foreign_callback = { + "type": "message", + "from": {"id": "foreign-user"}, + "value": {"foreign": "interaction"}, + } + + assert isinstance(decoder.decode(_event(foreign_callback)), UnrecognizedIMEvent) + non_card_event = AuthenticatedIMEvent( + provider=IMProvider.MS_TEAMS, + provider_tenant_id="integration-tenant", + event_id="integration-card-event", + event_type="conversationUpdate", + occurred_at=None, + received_at=_RECEIVED_AT, + ingress_kind=IMEventIngressKind.WEBHOOK, + payload="not-json", + ) + assert isinstance(decoder.decode(non_card_event), UnrecognizedIMEvent) + + foreign_value_callback: dict[str, object] = {"type": "message", "value": []} + assert isinstance(decoder.decode(_event(foreign_value_callback)), UnrecognizedIMEvent) + + mismatched_type_callback = { + "type": "invoke", + "from": { + "id": "29:integration-teams-user", + "aadObjectId": "11111111-2222-4333-8444-abcdefabcdef", + }, + "value": { + "__dify.human_input": { + "version": 1, + "action_id": "approve", + "correlation_token": "integration-correlation", + } + }, + } + with pytest.raises(IMCardEventDecodingError): + decoder.decode(_event(mismatched_type_callback)) + + invalid_transport = AuthenticatedIMEvent( + provider=IMProvider.MS_TEAMS, + provider_tenant_id="integration-tenant", + event_id="integration-card-event", + event_type="message", + occurred_at=None, + received_at=_RECEIVED_AT, + ingress_kind=IMEventIngressKind.WEBHOOK, + payload="not-json", + ) + with pytest.raises(IMCardEventDecodingError): + decoder.decode(invalid_transport) + with pytest.raises(IMCardEventDecodingError): + decoder.decode( + AuthenticatedIMEvent( + provider=IMProvider.MS_TEAMS, + provider_tenant_id="integration-tenant", + event_id="integration-card-event", + event_type="message", + occurred_at=None, + received_at=_RECEIVED_AT, + ingress_kind=IMEventIngressKind.WEBHOOK, + payload="[]", + ) + ) diff --git a/api/tests/e2e_tests/containers/core/human_input_v2/im_provider/test_ms_teams_external_integration.py b/api/tests/e2e_tests/containers/core/human_input_v2/im_provider/test_ms_teams_external_integration.py new file mode 100644 index 00000000000..6aac7b4d852 --- /dev/null +++ b/api/tests/e2e_tests/containers/core/human_input_v2/im_provider/test_ms_teams_external_integration.py @@ -0,0 +1,401 @@ +from __future__ import annotations + +import logging +import os +import time +from collections.abc import Iterator, Mapping +from dataclasses import dataclass +from datetime import UTC, datetime, timedelta +from html import escape +from typing import Literal, NoReturn +from urllib.parse import quote, urlsplit +from uuid import uuid4 + +import httpx +import pytest +from azure.core.exceptions import AzureError +from azure.identity import ClientSecretCredential +from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator + +from core.human_input_v2.entities import IMProvider +from core.human_input_v2.im_integration.adapters import ( + Directory, + MessageAccepted, + MSTeamsCredentials, + ProviderUserId, +) +from core.human_input_v2.im_integration.adapters.ms_teams import MSTeamsIMProviderAdapter + +_GRAPH_SCOPE = "https://graph.microsoft.com/.default" +_GRAPH_USERS_URL = "https://graph.microsoft.com/v1.0/users" +_GRAPH_HTTP_TIMEOUT_SECONDS = 10.0 +_GRAPH_CONTROL_PAGE_SIZE = 1 +_MESSAGE_LOOKBACK = timedelta(minutes=1) +_MESSAGE_LOOKAHEAD = timedelta(minutes=10) +_MESSAGE_RETRIEVAL_TIMEOUT_SECONDS = 60.0 +_MESSAGE_RETRIEVAL_POLL_SECONDS = 2.0 +_MESSAGE_RETRIEVAL_PAGE_SIZE = 50 +_MESSAGE_RETRIEVAL_MAX_PAGES_PER_POLL = 8 +_MESSAGE_RETRIEVAL_MAX_REQUESTS = 64 +_EXTERNAL_CLIENT_LOGGER_NAMES = ( + "azure", + "botbuilder", + "botframework", + "httpcore", + "httpx", + "msrest", + "requests", + "urllib3", +) +_SUPPRESSED_CLIENT_LOG_LEVEL = logging.CRITICAL + 1 + + +@dataclass(frozen=True, slots=True, repr=False) +class _MSTeamsTestEnvironment: + tenant_id: str + client_id: str + client_secret: str + recipient_id: str | None + + def credentials(self) -> MSTeamsCredentials: + try: + return MSTeamsCredentials( + provider=IMProvider.MS_TEAMS, + tenant_id=self.tenant_id, + client_id=self.client_id, + client_secret=self.client_secret, + ) + except ValidationError: + pass + pytest.fail("Microsoft Teams integration credentials are invalid.", pytrace=False) + + +@dataclass(slots=True) +class _MessageRetrievalBudget: + deadline: float + remaining_requests: int = _MESSAGE_RETRIEVAL_MAX_REQUESTS + + def next_request_timeout(self) -> float: + if self.remaining_requests <= 0: + pytest.fail("Microsoft Graph message retrieval exceeded its request budget.", pytrace=False) + remaining_seconds = self.deadline - time.monotonic() + if remaining_seconds <= 0: + pytest.fail("Microsoft Graph message retrieval exceeded its time budget.", pytrace=False) + self.remaining_requests -= 1 + return min(_GRAPH_HTTP_TIMEOUT_SECONDS, remaining_seconds) + + +class _GraphModel(BaseModel): + model_config = ConfigDict(extra="ignore", frozen=True, populate_by_name=True) + + +class _GraphDirectoryUser(_GraphModel): + id: str = Field(min_length=1) + display_name: str | None = Field(default=None, alias="displayName") + email: str | None = Field(default=None, alias="mail") + + @field_validator("display_name", "email", mode="before") + @classmethod + def normalize_optional_text(cls, value: object) -> object: + if isinstance(value, str) and not value.strip(): + return None + return value + + +class _GraphDirectoryPage(_GraphModel): + users: tuple[_GraphDirectoryUser, ...] = Field(alias="value") + next_link: str | None = Field(default=None, alias="@odata.nextLink") + + +class _GraphMessageBody(_GraphModel): + content: str + content_type: Literal["html", "text"] = Field(alias="contentType") + + +class _GraphMessage(_GraphModel): + body: _GraphMessageBody + + +class _GraphMessagePage(_GraphModel): + messages: tuple[_GraphMessage, ...] = Field(alias="value") + next_link: str | None = Field(default=None, alias="@odata.nextLink") + + +@pytest.fixture(autouse=True) +def _suppress_external_client_logs() -> Iterator[None]: + client_loggers = tuple(logging.getLogger(name) for name in _EXTERNAL_CLIENT_LOGGER_NAMES) + original_levels = tuple(logger.level for logger in client_loggers) + for logger in client_loggers: + logger.setLevel(_SUPPRESSED_CLIENT_LOG_LEVEL) + try: + yield + finally: + for logger, original_level in zip(client_loggers, original_levels, strict=True): + logger.setLevel(original_level) + + +def _test_environment(*, require_recipient: bool) -> _MSTeamsTestEnvironment: + tenant_id = os.environ.get("MS_TEAMS_TENANT_ID") + client_id = os.environ.get("MS_TEAMS_CLIENT_ID") + client_secret = os.environ.get("MS_TEAMS_CLIENT_SECRET") + if not tenant_id or not client_id or not client_secret: + pytest.skip("Real Microsoft Teams integration credentials are not configured.") + + recipient_id = os.environ.get("MS_TEAMS_TEST_RECIPIENT_ID") + if require_recipient and not recipient_id: + pytest.skip("MS_TEAMS_TEST_RECIPIENT_ID is not configured for the Messaging integration test.") + return _MSTeamsTestEnvironment(tenant_id, client_id, client_secret, recipient_id) + + +def _graph_access_token(environment: _MSTeamsTestEnvironment) -> str: + credential = ClientSecretCredential( + environment.tenant_id, + environment.client_id, + environment.client_secret, + ) + try: + try: + access_token = credential.get_token(_GRAPH_SCOPE).token + except AzureError: + access_token = None + finally: + credential.close() + if access_token is None: + pytest.fail("Microsoft Graph authentication failed.", pytrace=False) + return access_token + + +def _fail_invalid_graph_response(operation: str) -> NoReturn: + pytest.fail(f"Microsoft Graph {operation} returned an invalid response.", pytrace=False) + + +def _graph_response_json( + client: httpx.Client, + url: str, + access_token: str, + *, + operation: str, + request_timeout: float, + params: Mapping[str, str] | None = None, +) -> object: + try: + response = client.get( + url, + headers={"Authorization": f"Bearer {access_token}"}, + params=params, + timeout=request_timeout, + ) + except httpx.HTTPError: + response = None + + if response is None: + pytest.fail(f"Microsoft Graph {operation} could not be completed.", pytrace=False) + + if response.is_error: + pytest.fail( + f"Microsoft Graph {operation} failed with HTTP {response.status_code}.", + pytrace=False, + ) + try: + return response.json() + except ValueError: + pass + pytest.fail(f"Microsoft Graph {operation} could not be completed.", pytrace=False) + + +def _trusted_graph_users_url(url: str) -> bool: + parsed = urlsplit(url) + return parsed.scheme == "https" and parsed.netloc == "graph.microsoft.com" and parsed.path == "/v1.0/users" + + +def _trusted_graph_messages_url(url: str) -> bool: + parsed = urlsplit(url) + return ( + parsed.scheme == "https" + and parsed.netloc == "graph.microsoft.com" + and parsed.path.casefold().endswith("/chats/getallmessages") + ) + + +def _real_graph_directory(environment: _MSTeamsTestEnvironment) -> tuple[tuple[_GraphDirectoryUser, ...], int]: + access_token = _graph_access_token(environment) + users: list[_GraphDirectoryUser] = [] + page_count = 0 + next_url: str | None = _GRAPH_USERS_URL + seen_urls: set[str] = set() + params: Mapping[str, str] | None = { + "$select": "id,displayName,mail", + "$top": str(_GRAPH_CONTROL_PAGE_SIZE), + } + + with httpx.Client(timeout=_GRAPH_HTTP_TIMEOUT_SECONDS) as client: + while next_url is not None: + if not _trusted_graph_users_url(next_url) or next_url in seen_urls: + pytest.fail("Microsoft Graph returned an untrusted directory continuation.", pytrace=False) + seen_urls.add(next_url) + response_body = _graph_response_json( + client, + next_url, + access_token, + operation="directory control enumeration", + request_timeout=_GRAPH_HTTP_TIMEOUT_SECONDS, + params=params, + ) + try: + page = _GraphDirectoryPage.model_validate(response_body) + except ValidationError: + page = None + if page is None: + _fail_invalid_graph_response("directory control enumeration") + users.extend(page.users) + page_count += 1 + next_url = page.next_link + params = None + return tuple(users), page_count + + +def _graph_datetime(value: datetime) -> str: + return value.astimezone(UTC).isoformat(timespec="milliseconds").replace("+00:00", "Z") + + +def _recent_graph_messages( + environment: _MSTeamsTestEnvironment, + access_token: str, + modified_after: datetime, + modified_before: datetime, + retrieval_budget: _MessageRetrievalBudget, +) -> tuple[_GraphMessage, ...]: + if environment.recipient_id is None: + pytest.fail("Microsoft Teams Messaging test configuration is invalid.", pytrace=False) + recipient = quote(environment.recipient_id, safe="") + next_url: str | None = f"https://graph.microsoft.com/v1.0/users/{recipient}/chats/getAllMessages" + params: Mapping[str, str] | None = { + "$top": str(_MESSAGE_RETRIEVAL_PAGE_SIZE), + "$filter": ( + f"lastModifiedDateTime gt {_graph_datetime(modified_after)} " + f"and lastModifiedDateTime lt {_graph_datetime(modified_before)}" + ), + } + messages: list[_GraphMessage] = [] + seen_urls: set[str] = set() + page_count = 0 + + with httpx.Client(timeout=_GRAPH_HTTP_TIMEOUT_SECONDS) as client: + while next_url is not None: + if not _trusted_graph_messages_url(next_url) or next_url in seen_urls: + pytest.fail("Microsoft Graph returned an untrusted message continuation.", pytrace=False) + if page_count >= _MESSAGE_RETRIEVAL_MAX_PAGES_PER_POLL: + pytest.fail("Microsoft Graph message retrieval exceeded its pagination limit.", pytrace=False) + seen_urls.add(next_url) + response_body = _graph_response_json( + client, + next_url, + access_token, + operation="message retrieval", + request_timeout=retrieval_budget.next_request_timeout(), + params=params, + ) + try: + page = _GraphMessagePage.model_validate(response_body) + except ValidationError: + page = None + if page is None: + _fail_invalid_graph_response("message retrieval") + messages.extend(page.messages) + page_count += 1 + next_url = page.next_link + params = None + return tuple(messages) + + +def _validate_directory_snapshot( + directory_result: object, + control_users: tuple[_GraphDirectoryUser, ...], + control_page_count: int, +) -> None: + if not isinstance(directory_result, Directory): + pytest.fail("Microsoft Teams production directory could not be read.", pytrace=False) + if control_page_count < 2: + pytest.fail("Microsoft Graph directory control did not traverse multiple pages.", pytrace=False) + if not directory_result.entries: + pytest.fail("Microsoft Teams production directory was empty.", pytrace=False) + if any(not str(entry.provider_user_id).strip() for entry in directory_result.entries): + pytest.fail("Microsoft Teams production directory contained an incomplete entry.", pytrace=False) + if any(entry.display_name is None or not entry.display_name.strip() for entry in directory_result.entries): + pytest.fail("Microsoft Teams production directory contained an incomplete entry.", pytrace=False) + if any(entry.email is None or not entry.email.strip() for entry in directory_result.entries): + pytest.fail("Microsoft Teams production directory contained an incomplete entry.", pytrace=False) + + adapter_facts = { + (str(entry.provider_user_id), entry.display_name, entry.email) for entry in directory_result.entries + } + control_facts = {(user.id, user.display_name, user.email) for user in control_users} + if len(adapter_facts) != len(directory_result.entries) or len(control_facts) != len(control_users): + pytest.fail("Microsoft Teams directory comparison contained duplicate entries.", pytrace=False) + if adapter_facts != control_facts: + pytest.fail("Microsoft Teams production and control directories did not match.", pytrace=False) + + +def _exact_graph_message_found( + messages: tuple[_GraphMessage, ...], + exact_graph_bodies: frozenset[tuple[str, str]], +) -> bool: + match_count = sum((message.body.content_type, message.body.content) in exact_graph_bodies for message in messages) + if match_count > 1: + pytest.fail("Microsoft Graph returned multiple exact accepted Teams messages.", pytrace=False) + return match_count == 1 + + +def test_directory_matches_complete_real_graph_multi_page_control() -> None: + environment = _test_environment(require_recipient=False) + adapter = MSTeamsIMProviderAdapter(environment.credentials()) + try: + directory_result = adapter.directory.read_directory() + finally: + adapter.close() + + control_users, control_page_count = _real_graph_directory(environment) + _validate_directory_snapshot(directory_result, control_users, control_page_count) + + +def test_send_text_is_retrievable_with_exact_content_from_real_graph() -> None: + environment = _test_environment(require_recipient=True) + if environment.recipient_id is None: + pytest.fail("Microsoft Teams Messaging test configuration is invalid.", pytrace=False) + exact_body = f"Dify Microsoft Teams integration verification {uuid4()}" + modified_after = datetime.now(UTC) - _MESSAGE_LOOKBACK + adapter = MSTeamsIMProviderAdapter(environment.credentials()) + try: + send_result = adapter.messaging.send_text( + ProviderUserId(environment.recipient_id), + exact_body, + ) + finally: + adapter.close() + + if not isinstance(send_result, MessageAccepted): + pytest.fail("Microsoft Teams did not confirm message acceptance.", pytrace=False) + modified_before = datetime.now(UTC) + _MESSAGE_LOOKAHEAD + access_token = _graph_access_token(environment) + retrieval_deadline = time.monotonic() + _MESSAGE_RETRIEVAL_TIMEOUT_SECONDS + retrieval_budget = _MessageRetrievalBudget(retrieval_deadline) + exact_graph_bodies = frozenset( + ( + ("html", f"

{escape(exact_body)}

"), + ("text", exact_body), + ) + ) + while True: + messages = _recent_graph_messages( + environment, + access_token, + modified_after, + modified_before, + retrieval_budget, + ) + if _exact_graph_message_found(messages, exact_graph_bodies): + return + remaining_seconds = retrieval_deadline - time.monotonic() + if remaining_seconds <= 0: + pytest.fail("Microsoft Graph did not return the exact accepted Teams message.", pytrace=False) + time.sleep(min(_MESSAGE_RETRIEVAL_POLL_SECONDS, remaining_seconds)) diff --git a/api/tests/e2e_tests/containers/core/human_input_v2/im_provider/test_slack.py b/api/tests/e2e_tests/containers/core/human_input_v2/im_provider/test_slack.py new file mode 100644 index 00000000000..742c0c1e304 --- /dev/null +++ b/api/tests/e2e_tests/containers/core/human_input_v2/im_provider/test_slack.py @@ -0,0 +1,1016 @@ +from __future__ import annotations + +import json +import os +import sys +from collections.abc import Generator, Mapping, Sequence +from copy import deepcopy +from datetime import UTC, datetime +from pathlib import Path +from urllib.parse import urlencode +from uuid import uuid4 + +import pytest +from pydantic import JsonValue, TypeAdapter, ValidationError +from slack_sdk.signature import SignatureVerifier +from slack_sdk.socket_mode.request import SocketModeRequest +from slack_sdk.web import WebClient + +from core.human_input import ButtonStyle +from core.human_input_v2.entities import IMProvider +from core.human_input_v2.im_integration.adapters import ( + AuthenticatedIMEvent, + CorrelationToken, + CredentialTestFailure, + CredentialTestFailureKind, + CredentialTestSuccess, + Directory, + DynamicCardMessagingError, + EventAcceptance, + IMCardEvent, + IMCardEventDecodingError, + IMEventIngressKind, + IMStreamStartError, + MessageAccepted, + MessageSendingError, + ProviderUserId, + SlackCredentials, + StaticCardIntent, + UnrecognizedIMEvent, + WebhookRequest, +) +from core.human_input_v2.im_integration.adapters import slack as slack_module +from core.human_input_v2.im_integration.adapters.slack import SlackIMProviderAdapter +from core.human_input_v2.resolved_form import MarkdownFragment, ParagraphInput, ResolvedForm, SelectInput, UserAction + +_SLACK_DIRECTORY_REFERENCE_PAGE_SIZE = 1 +_MINIMUM_EXPECTED_SLACK_DIRECTORY_USERS = 2 +_RAW_JSON_EXTRA_PLACEHOLDER = "__raw_json_extra_placeholder__" +_RECEIVED_AT = datetime(2026, 8, 11, 12, 0, 0) +_FIXTURE_DIRECTORY = Path(__file__).resolve().parents[5] / "unit_tests/core/human_input_v2/im_provider/fixtures" + + +class _RecordingConsumer: + def __init__(self, acceptance: EventAcceptance = EventAcceptance.ACCEPTED) -> None: + self._acceptance = acceptance + self.events: list[AuthenticatedIMEvent] = [] + + def accept(self, event: AuthenticatedIMEvent, *, request_ip_address: str | None = None) -> EventAcceptance: + del request_ip_address + self.events.append(event) + return self._acceptance + + +class _FailingConsumer: + def accept(self, event: AuthenticatedIMEvent, *, request_ip_address: str | None = None) -> EventAcceptance: + del request_ip_address + del event + raise RuntimeError("sensitive consumer failure") + + +def _fixture(fixture_name: str) -> dict[str, object]: + decoded = json.loads((_FIXTURE_DIRECTORY / fixture_name).read_text(encoding="utf-8")) + assert isinstance(decoded, dict) + return decoded + + +def _signed_webhook_request( + signing_secret: str, + body: bytes, + *, + content_type: str = "application/json", + method: str = "POST", + valid_signature: bool = True, +) -> WebhookRequest: + timestamp = str(int(_RECEIVED_AT.replace(tzinfo=UTC).timestamp())) + signature = SignatureVerifier(signing_secret).generate_signature(timestamp=timestamp, body=body) + assert signature is not None + if not valid_signature: + signature = "v0=invalid" + return WebhookRequest( + method=method, + headers=( + ("X-Slack-Request-Timestamp", timestamp), + ("X-Slack-Signature", signature), + ("Content-Type", content_type), + ), + body=body, + received_at=_RECEIVED_AT, + ) + + +def _serialize_callback_with_raw_extra(callback: Mapping[str, object], raw_json_value: str) -> str: + callback_with_extra = dict(callback) + callback_with_extra["ignored_extra"] = _RAW_JSON_EXTRA_PLACEHOLDER + serialized_callback = json.dumps(callback_with_extra, ensure_ascii=False) + serialized_placeholder = json.dumps(_RAW_JSON_EXTRA_PLACEHOLDER) + assert serialized_callback.count(serialized_placeholder) == 1 + return serialized_callback.replace(serialized_placeholder, raw_json_value) + + +def _authenticated_slack_card_event(serialized_callback: str, received_second: int) -> AuthenticatedIMEvent: + return AuthenticatedIMEvent( + provider=IMProvider.SLACK, + provider_tenant_id="integration-workspace", + event_id=None, + event_type="block_actions", + occurred_at=None, + received_at=datetime(2026, 8, 11, 12, 0, received_second), + ingress_kind=IMEventIngressKind.WEBHOOK, + payload=serialized_callback, + ) + + +def _non_empty_environment_value(name: str) -> str | None: + value = os.getenv(name) + if value is None or not value.strip(): + return None + return value + + +@pytest.fixture +def slack_credentials() -> SlackCredentials: + client_id = _non_empty_environment_value("SLACK_CLIENT_ID") + client_secret = _non_empty_environment_value("SLACK_CLIENT_SECRET") + signing_secret = _non_empty_environment_value("SLACK_SIGNING_SECRET") + bot_token = _non_empty_environment_value("SLACK_BOT_TOKEN") + app_token = _non_empty_environment_value("SLACK_APP_SOCKET_TOKEN") + + missing_names = [ + name + for name, value in ( + ("SLACK_CLIENT_ID", client_id), + ("SLACK_CLIENT_SECRET", client_secret), + ("SLACK_SIGNING_SECRET", signing_secret), + ("SLACK_BOT_TOKEN", bot_token), + ("SLACK_APP_SOCKET_TOKEN", app_token), + ) + if value is None + ] + if missing_names: + pytest.skip(f"Slack integration credentials are unavailable: {', '.join(missing_names)}") + + assert client_id is not None + assert client_secret is not None + assert signing_secret is not None + assert bot_token is not None + assert app_token is not None + try: + return SlackCredentials( + provider=IMProvider.SLACK, + client_id=client_id, + client_secret=client_secret, + signing_secret=signing_secret, + bot_token=bot_token, + app_token=app_token, + ) + except ValidationError: + pytest.fail("Slack integration credentials are invalid.", pytrace=False) + + +@pytest.fixture +def slack_adapter(slack_credentials: SlackCredentials) -> Generator[SlackIMProviderAdapter]: + adapter = SlackIMProviderAdapter(slack_credentials) + try: + yield adapter + finally: + adapter.close() + + +@pytest.fixture +def slack_web_client(slack_credentials: SlackCredentials) -> WebClient: + return WebClient(token=slack_credentials.bot_token, retry_handlers=[]) + + +@pytest.fixture +def slack_test_recipient_id() -> ProviderUserId: + recipient_id = _non_empty_environment_value("SLACK_TEST_RECIPIENT_ID") + if recipient_id is None: + pytest.skip("SLACK_TEST_RECIPIENT_ID is unavailable.") + return ProviderUserId(recipient_id) + + +def _profile_field(profile: Mapping[object, object], name: str) -> str | None: + value = profile.get(name) + if not isinstance(value, str) or not value.strip(): + return None + return value + + +def _paginated_slack_directory_entries(client: WebClient) -> tuple[dict[str, tuple[str, str]], int]: + directory_entries: dict[str, tuple[str, str]] = {} + seen_cursors: set[str] = set() + cursor: str | None = None + page_count = 0 + + while True: + if cursor is None: + response = client.users_list(limit=_SLACK_DIRECTORY_REFERENCE_PAGE_SIZE) + else: + response = client.users_list(limit=_SLACK_DIRECTORY_REFERENCE_PAGE_SIZE, cursor=cursor) + page_count += 1 + + members = response.get("members") + assert isinstance(members, Sequence) + assert not isinstance(members, (str, bytes, bytearray)) + for member in members: + assert isinstance(member, Mapping) + provider_user_id = member.get("id") + if member.get("deleted") is True or member.get("is_bot") is True or member.get("is_app_user") is True: + continue + assert isinstance(provider_user_id, str) + assert provider_user_id.strip() + profile = member.get("profile") + assert isinstance(profile, Mapping) + display_name = _profile_field(profile, "display_name_normalized") or _profile_field( + profile, "real_name_normalized" + ) + email = _profile_field(profile, "email") + if display_name is not None and email is not None: + directory_entries[provider_user_id] = (display_name, email) + + response_metadata = response.get("response_metadata") + assert isinstance(response_metadata, Mapping) + next_cursor = response_metadata.get("next_cursor") + assert isinstance(next_cursor, str) + if not next_cursor: + return directory_entries, page_count + assert next_cursor not in seen_cursors + seen_cursors.add(next_cursor) + cursor = next_cursor + + +def _read_exact_message(client: WebClient, locator: str) -> Mapping[object, object]: + locator_payload = slack_module._SlackLocatorPayload.decode(locator) + response = client.conversations_replies( + channel=locator_payload.channel_id, + ts=locator_payload.message_ts, + inclusive=True, + limit=1, + ) + messages = response.get("messages") + assert isinstance(messages, Sequence) + assert not isinstance(messages, str | bytes | bytearray) + matching_messages = [ + message + for message in messages + if isinstance(message, Mapping) and message.get("ts") == locator_payload.message_ts + ] + assert len(matching_messages) == 1 + return matching_messages[0] + + +def test_slack_directory_matches_real_paginated_directory( + slack_adapter: SlackIMProviderAdapter, + slack_web_client: WebClient, +) -> None: + expected_entries, page_count = _paginated_slack_directory_entries(slack_web_client) + + directory_result = slack_adapter.directory.read_directory() + + assert page_count >= 2 + assert isinstance(directory_result, Directory) + assert len(directory_result.entries) >= _MINIMUM_EXPECTED_SLACK_DIRECTORY_USERS + actual_entries: dict[str, tuple[str, str]] = {} + for entry in directory_result.entries: + provider_user_id = str(entry.provider_user_id) + assert provider_user_id.strip() + assert entry.display_name is not None + assert entry.display_name.strip() + assert entry.email is not None + assert entry.email.strip() + actual_entries[provider_user_id] = (entry.display_name, entry.email) + assert actual_entries == expected_entries + + +def test_slack_adapter_composes_capabilities_and_decoder_survives_lifecycle( + slack_adapter: SlackIMProviderAdapter, +) -> None: + consumer = _RecordingConsumer() + decoder = SlackIMProviderAdapter.card_event_decoder() + event_stream = slack_adapter.create_stream_handler(consumer) + assert event_stream is not None + + assert slack_adapter.provider is IMProvider.SLACK + assert slack_adapter.directory is not None + assert slack_adapter.messaging is not None + assert slack_adapter.dynamic_card_messaging is not None + assert slack_adapter.create_webhook_handler(consumer) is not None + + slack_adapter.close() + slack_adapter.close() + assert isinstance( + decoder.decode( + AuthenticatedIMEvent( + provider=IMProvider.SLACK, + provider_tenant_id="integration-workspace", + event_id=None, + event_type="message", + occurred_at=None, + received_at=_RECEIVED_AT, + ingress_kind=IMEventIngressKind.WEBHOOK, + payload="{}", + ) + ), + UnrecognizedIMEvent, + ) + + event_stream.stop() + event_stream.stop() + with pytest.raises(IMStreamStartError): + event_stream.start() + + +def test_slack_credentials_validate_real_web_and_socket_tokens( + slack_adapter: SlackIMProviderAdapter, +) -> None: + result = slack_adapter.test_credentials() + + assert isinstance(result, CredentialTestSuccess) + assert result.provider is IMProvider.SLACK + assert result.provider_tenant_id.strip() + + +def test_slack_credential_test_translates_documented_provider_rejections( + slack_credentials: SlackCredentials, +) -> None: + invalid_bot_credentials = SlackCredentials( + provider=IMProvider.SLACK, + client_id=slack_credentials.client_id, + client_secret=slack_credentials.client_secret, + signing_secret=slack_credentials.signing_secret, + bot_token="xoxb-invalid", + app_token=slack_credentials.app_token, + ) + invalid_bot_adapter = SlackIMProviderAdapter(invalid_bot_credentials) + try: + invalid_bot_result = invalid_bot_adapter.test_credentials() + finally: + invalid_bot_adapter.close() + + assert isinstance(invalid_bot_result, CredentialTestFailure) + assert invalid_bot_result.kind is CredentialTestFailureKind.AUTHENTICATION_REJECTED + + invalid_app_credentials = SlackCredentials( + provider=IMProvider.SLACK, + client_id=slack_credentials.client_id, + client_secret=slack_credentials.client_secret, + signing_secret=slack_credentials.signing_secret, + bot_token=slack_credentials.bot_token, + app_token="xapp-invalid", + ) + invalid_app_adapter = SlackIMProviderAdapter(invalid_app_credentials) + try: + invalid_app_result = invalid_app_adapter.test_credentials() + invalid_app_stream = invalid_app_adapter.create_stream_handler(_RecordingConsumer()) + assert invalid_app_stream is not None + with pytest.raises(IMStreamStartError): + invalid_app_stream.start() + invalid_app_stream.stop() + finally: + invalid_app_adapter.close() + + assert isinstance(invalid_app_result, CredentialTestFailure) + assert invalid_app_result.kind is CredentialTestFailureKind.AUTHENTICATION_REJECTED + + +def test_slack_webhook_hmac_reconstructs_json_and_form_callbacks( + slack_adapter: SlackIMProviderAdapter, + slack_credentials: SlackCredentials, +) -> None: + consumer = _RecordingConsumer() + handler = slack_adapter.create_webhook_handler(consumer) + event_callback = { + "type": "event_callback", + "team_id": "T012SANITIZED", + "event_id": "Ev012SANITIZED", + "event_time": 1786400000, + "event": {"type": "app_mention", "text": "sanitized"}, + } + event_body = json.dumps(event_callback, separators=(",", ":")).encode() + + event_response = handler.handle(_signed_webhook_request(slack_credentials.signing_secret, event_body)) + + callback_fixture = _fixture("slack_block_actions_webhook.json") + form_body = urlencode({"payload": json.dumps(callback_fixture, ensure_ascii=False)}).encode() + form_response = handler.handle( + _signed_webhook_request( + slack_credentials.signing_secret, + form_body, + content_type="application/x-www-form-urlencoded", + ) + ) + + assert event_response.status_code == 200 + assert form_response.status_code == 200 + assert len(consumer.events) == 2 + event, callback = consumer.events + assert event.provider is IMProvider.SLACK + assert event.provider_tenant_id == "T012SANITIZED" + assert event.event_id == "Ev012SANITIZED" + assert event.event_type == "app_mention" + assert event.occurred_at == datetime.fromtimestamp(1786400000, tz=UTC).replace(tzinfo=None) + assert callback.provider_tenant_id == "T012SANITIZED" + assert callback.event_type == "block_actions" + assert SlackIMProviderAdapter.card_event_decoder().decode(callback) == IMCardEvent( + provider_user_id=ProviderUserId("U012SANITIZED"), + action_id="\u6279\u51c6\u2705", + inputs={ + "\u8bf4\u660e\U0001f4dd": "\u4f60\u597d\uff0c\u4e16\u754c \U0001f30d", + "\u9009\u62e9\U0001f310": "\u9009\u9879 \u03b2", + }, + correlation_token=CorrelationToken("\u5173\u8054\u4ee4\u724c-\U0001f30d"), + ) + + +def test_slack_webhook_rejects_unauthenticated_and_authenticated_malformed_requests( + slack_adapter: SlackIMProviderAdapter, + slack_credentials: SlackCredentials, +) -> None: + consumer = _RecordingConsumer(EventAcceptance.NOT_ACCEPTED) + handler = slack_adapter.create_webhook_handler(consumer) + valid_event = json.dumps( + {"type": "event_callback", "team_id": "T012SANITIZED", "event": {"type": "message"}}, + separators=(",", ":"), + ).encode() + + unauthenticated = handler.handle( + _signed_webhook_request(slack_credentials.signing_secret, valid_event, valid_signature=False) + ) + wrong_method = handler.handle(_signed_webhook_request(slack_credentials.signing_secret, valid_event, method="GET")) + invalid_json = handler.handle(_signed_webhook_request(slack_credentials.signing_secret, b"{")) + missing_tenant = handler.handle( + _signed_webhook_request(slack_credentials.signing_secret, b'{"type":"event_callback"}') + ) + not_accepted = handler.handle(_signed_webhook_request(slack_credentials.signing_secret, valid_event)) + consumer_failure = slack_adapter.create_webhook_handler(_FailingConsumer()).handle( + _signed_webhook_request(slack_credentials.signing_secret, valid_event) + ) + invalid_challenge = handler.handle( + _signed_webhook_request(slack_credentials.signing_secret, b'{"type":"url_verification"}') + ) + missing_form_payload = handler.handle( + _signed_webhook_request( + slack_credentials.signing_secret, + b"field=value", + content_type="application/x-www-form-urlencoded", + ) + ) + invalid_form_payload = handler.handle( + _signed_webhook_request( + slack_credentials.signing_secret, + b"payload=%7B", + content_type="application/x-www-form-urlencoded", + ) + ) + non_object_form_payload = handler.handle( + _signed_webhook_request( + slack_credentials.signing_secret, + b"payload=%5B%5D", + content_type="application/x-www-form-urlencoded", + ) + ) + unsupported_content_type = handler.handle( + _signed_webhook_request(slack_credentials.signing_secret, valid_event, content_type="text/plain") + ) + overflow_event = json.dumps( + { + "type": "event_callback", + "team_id": "T012SANITIZED", + "event_time": 10**100, + "event": {"type": "message"}, + }, + separators=(",", ":"), + ).encode() + overflow_event_response = handler.handle(_signed_webhook_request(slack_credentials.signing_secret, overflow_event)) + one_content_type_request = _signed_webhook_request(slack_credentials.signing_secret, valid_event) + duplicate_content_type_response = handler.handle( + WebhookRequest( + method=one_content_type_request.method, + headers=(*one_content_type_request.headers, ("Content-Type", "application/json")), + body=one_content_type_request.body, + received_at=one_content_type_request.received_at, + ) + ) + + assert unauthenticated.status_code == 401 + assert wrong_method.status_code == 405 + assert invalid_json.status_code == 400 + assert missing_tenant.status_code == 400 + assert not_accepted.status_code == 503 + assert consumer_failure.status_code == 503 + assert invalid_challenge.status_code == 400 + assert missing_form_payload.status_code == 400 + assert invalid_form_payload.status_code == 400 + assert non_object_form_payload.status_code == 400 + assert unsupported_content_type.status_code == 400 + assert overflow_event_response.status_code == 503 + assert duplicate_content_type_response.status_code == 400 + assert len(consumer.events) == 2 + + +def test_slack_webhook_completes_signed_url_verification( + slack_adapter: SlackIMProviderAdapter, + slack_credentials: SlackCredentials, +) -> None: + handler = slack_adapter.create_webhook_handler(_RecordingConsumer()) + body = b'{"type":"url_verification","challenge":"sanitized-challenge"}' + + response = handler.handle(_signed_webhook_request(slack_credentials.signing_secret, body)) + + assert response.status_code == 200 + assert json.loads(response.body) == {"challenge": "sanitized-challenge"} + + +def test_slack_messaging_sends_and_reads_exact_text( + slack_test_recipient_id: ProviderUserId, + slack_adapter: SlackIMProviderAdapter, + slack_web_client: WebClient, +) -> None: + message_body = f"Dify Slack integration test {uuid4()}" + + message_result = slack_adapter.messaging.send_text(slack_test_recipient_id, message_body) + + assert isinstance(message_result, MessageAccepted) + locator_payload = slack_module._SlackLocatorPayload.decode(str(message_result.locator)) + response = slack_web_client.conversations_replies( + channel=locator_payload.channel_id, + ts=locator_payload.message_ts, + inclusive=True, + limit=1, + ) + messages = response.get("messages") + assert isinstance(messages, Sequence) + assert not isinstance(messages, (str, bytes, bytearray)) + matching_messages = [ + message + for message in messages + if isinstance(message, Mapping) and message.get("ts") == locator_payload.message_ts + ] + assert len(matching_messages) == 1 + assert matching_messages[0].get("text") == message_body + + invalid_recipient_result = slack_adapter.messaging.send_text( + ProviderUserId("D000000000000000000"), + f"{message_body} invalid-recipient", + ) + assert isinstance(invalid_recipient_result, MessageSendingError) + + replacement_result = slack_adapter.dynamic_card_messaging.replace_with_static( + message_result.locator, + StaticCardIntent("This must not replace a text message."), + ) + assert replacement_result is None + + +def test_slack_card_assessment_matches_static_select_provider_boundary( + slack_adapter: SlackIMProviderAdapter, + slack_test_recipient_id: ProviderUserId, +) -> None: + supported_options = tuple(f"option-{ordinal}" for ordinal in range(100)) + supported = ResolvedForm( + title="Supported Slack selector boundary", + parts=( + SelectInput( + output_variable_name="risk_level", options=supported_options, default_value=supported_options[0] + ), + ), + actions=(UserAction(id="approve", title="Approve", button_style=ButtonStyle.PRIMARY),), + legacy_form_content="unused", + ) + unsupported_options = (*supported_options, "option-100") + unsupported = ResolvedForm( + title="Unsupported Slack selector boundary", + parts=( + SelectInput( + output_variable_name="risk_level", options=unsupported_options, default_value=unsupported_options[0] + ), + ), + actions=(UserAction(id="approve", title="Approve", button_style=ButtonStyle.PRIMARY),), + legacy_form_content="unused", + ) + + assert slack_adapter.dynamic_card_messaging.assess(supported).representable is True + assert slack_adapter.dynamic_card_messaging.assess(unsupported).representable is False + with pytest.raises(DynamicCardMessagingError): + slack_adapter.dynamic_card_messaging.send_card( + slack_test_recipient_id, + unsupported, + CorrelationToken("unsupported-selector"), + ) + + +def test_slack_card_sender_and_decoder_cross_real_web_api_boundary( + slack_test_recipient_id: ProviderUserId, + slack_adapter: SlackIMProviderAdapter, + slack_web_client: WebClient, +) -> None: + marker = f"codex-implementer-slack-card-event-5ab7-20260811-{uuid4()}" + correlation_token = CorrelationToken(f"{marker}-correlation") + intent = ResolvedForm( + title=f"Slack card integration [{marker}]", + parts=( + MarkdownFragment(text="Review the provider-persisted form."), + ParagraphInput(output_variable_name="review_comment", default_value="Initial review"), + SelectInput(output_variable_name="risk_level", options=("low", "high"), default_value="low"), + ), + actions=( + UserAction(id="approve", title="Approve", button_style=ButtonStyle.PRIMARY), + UserAction(id="reject", title="Reject", button_style=ButtonStyle.ACCENT), + ), + legacy_form_content="unused", + ) + + message_result = slack_adapter.dynamic_card_messaging.send_card( + slack_test_recipient_id, + intent, + correlation_token, + ) + + assert isinstance(message_result, MessageAccepted) + message_reference = str(message_result.locator) + locator_payload = slack_module._SlackLocatorPayload.decode(message_reference) + persisted_message = _read_exact_message(slack_web_client, message_reference) + blocks = persisted_message.get("blocks") + assert isinstance(blocks, Sequence) + assert not isinstance(blocks, str | bytes | bytearray) + input_blocks = [block for block in blocks if isinstance(block, Mapping) and block.get("type") == "input"] + assert len(input_blocks) == 2 + assert [block.get("block_id") for block in input_blocks] == ["__dify.input.0", "__dify.input.1"] + select_input_block = next( + block + for block in input_blocks + if isinstance(block.get("element"), Mapping) and block["element"].get("action_id") == "risk_level" + ) + select_element = select_input_block.get("element") + assert isinstance(select_element, Mapping) + assert select_element.get("type") == "static_select" + assert select_element.get("placeholder") == { + "type": "plain_text", + "text": "Select an option", + "emoji": True, + } + assert select_element.get("options") == [ + {"text": {"type": "plain_text", "text": "low", "emoji": True}, "value": "low"}, + {"text": {"type": "plain_text", "text": "high", "emoji": True}, "value": "high"}, + ] + assert select_element.get("initial_option") == { + "text": {"type": "plain_text", "text": "low", "emoji": True}, + "value": "low", + } + action_blocks = [block for block in blocks if isinstance(block, Mapping) and block.get("type") == "actions"] + assert len(action_blocks) == 1 + action_block = action_blocks[0] + action_block_id = action_block.get("block_id") + assert action_block_id == "__dify.actions" + action_elements = action_block.get("elements") + assert isinstance(action_elements, Sequence) + assert not isinstance(action_elements, str | bytes | bytearray) + assert len(action_elements) == 2 + invoked_action = next( + action for action in action_elements if isinstance(action, Mapping) and action.get("action_id") == "approve" + ) + assert isinstance(invoked_action, Mapping) + callback_action = {**invoked_action, "block_id": action_block_id} + + input_block_ids: dict[str, str] = {} + for input_block in input_blocks: + block_id = input_block.get("block_id") + element = input_block.get("element") + assert isinstance(block_id, str) + assert isinstance(element, Mapping) + action_id = element.get("action_id") + assert isinstance(action_id, str) + input_block_ids[action_id] = block_id + + callback_payload = { + "type": "block_actions", + "team": {"id": "integration-workspace"}, + "user": {"id": str(slack_test_recipient_id)}, + "container": { + "type": "message", + "channel_id": locator_payload.channel_id, + "message_ts": locator_payload.message_ts, + }, + "message": persisted_message, + "state": { + "values": { + input_block_ids["review_comment"]: { + "review_comment": {"type": "plain_text_input", "value": "Reviewed exactly ✅"} + }, + input_block_ids["risk_level"]: { + "risk_level": { + "type": "static_select", + "selected_option": {"value": "high"}, + } + }, + } + }, + "actions": [callback_action], + } + socket_request = SocketModeRequest( + type="interactive", + envelope_id=f"{marker}-envelope", + payload=callback_payload, + accepts_response_payload=False, + ) + authenticated_event = AuthenticatedIMEvent( + provider=IMProvider.SLACK, + provider_tenant_id="integration-workspace", + event_id=None, + event_type="block_actions", + occurred_at=None, + received_at=datetime(2026, 8, 11, 12, 0, 0), + ingress_kind=IMEventIngressKind.STREAM, + payload=json.dumps(socket_request.to_dict(), ensure_ascii=False), + ) + + decoded = SlackIMProviderAdapter.card_event_decoder().decode(authenticated_event) + + assert decoded == IMCardEvent( + provider_user_id=slack_test_recipient_id, + action_id="approve", + inputs={"review_comment": "Reviewed exactly ✅", "risk_level": "high"}, + correlation_token=correlation_token, + ) + + finite_number_decoded = SlackIMProviderAdapter.card_event_decoder().decode( + _authenticated_slack_card_event( + _serialize_callback_with_raw_extra(callback_payload, '{"integer":42,"float":1e300}'), + received_second=6, + ) + ) + assert finite_number_decoded == decoded + + parser_boundary_values = ( + "1" * (sys.get_int_max_str_digits() + 1), + "[" * (sys.getrecursionlimit() * 20) + "null" + "]" * (sys.getrecursionlimit() * 20), + "1e400", + ) + for received_second, raw_json_value in enumerate(parser_boundary_values, start=7): + with pytest.raises(IMCardEventDecodingError) as captured: + SlackIMProviderAdapter.card_event_decoder().decode( + _authenticated_slack_card_event( + _serialize_callback_with_raw_extra(callback_payload, raw_json_value), + received_second=received_second, + ) + ) + assert captured.value.__cause__ is None + assert captured.value.__context__ is None + + missing_input_payload = deepcopy(callback_payload) + missing_state = missing_input_payload["state"] + assert isinstance(missing_state, dict) + missing_values = missing_state["values"] + assert isinstance(missing_values, dict) + missing_values.pop(input_block_ids["risk_level"]) + with pytest.raises(IMCardEventDecodingError): + SlackIMProviderAdapter.card_event_decoder().decode( + AuthenticatedIMEvent( + provider=IMProvider.SLACK, + provider_tenant_id="integration-workspace", + event_id=None, + event_type="block_actions", + occurred_at=None, + received_at=datetime(2026, 8, 11, 12, 0, 1), + ingress_kind=IMEventIngressKind.WEBHOOK, + payload=json.dumps(missing_input_payload, ensure_ascii=False), + ) + ) + + reserved_input_block_payload = deepcopy(callback_payload) + reserved_message = reserved_input_block_payload["message"] + assert isinstance(reserved_message, dict) + reserved_message_blocks = reserved_message["blocks"] + assert isinstance(reserved_message_blocks, list) + reserved_input_block = next( + block + for block in reserved_message_blocks + if isinstance(block, dict) and block.get("block_id") == input_block_ids["risk_level"] + ) + reserved_input_block["type"] = "section" + reserved_state = reserved_input_block_payload["state"] + assert isinstance(reserved_state, dict) + reserved_values = reserved_state["values"] + assert isinstance(reserved_values, dict) + reserved_values.pop(input_block_ids["risk_level"]) + with pytest.raises(IMCardEventDecodingError): + SlackIMProviderAdapter.card_event_decoder().decode( + _authenticated_slack_card_event( + json.dumps(reserved_input_block_payload, ensure_ascii=False), + received_second=10, + ) + ) + + mismatched_action_payload = deepcopy(callback_payload) + mismatched_actions = mismatched_action_payload["actions"] + assert isinstance(mismatched_actions, list) + mismatched_action = mismatched_actions[0] + assert isinstance(mismatched_action, dict) + mismatched_action["action_id"] = "reject" + with pytest.raises(IMCardEventDecodingError): + SlackIMProviderAdapter.card_event_decoder().decode( + AuthenticatedIMEvent( + provider=IMProvider.SLACK, + provider_tenant_id="integration-workspace", + event_id=None, + event_type="block_actions", + occurred_at=None, + received_at=datetime(2026, 8, 11, 12, 0, 2), + ingress_kind=IMEventIngressKind.WEBHOOK, + payload=json.dumps(mismatched_action_payload, ensure_ascii=False), + ) + ) + + extra_input_payload = deepcopy(callback_payload) + extra_state = extra_input_payload["state"] + assert isinstance(extra_state, dict) + extra_values = extra_state["values"] + assert isinstance(extra_values, dict) + extra_values["external.unexpected"] = {"external": {"type": "plain_text_input", "value": "unexpected"}} + with pytest.raises(IMCardEventDecodingError): + SlackIMProviderAdapter.card_event_decoder().decode( + AuthenticatedIMEvent( + provider=IMProvider.SLACK, + provider_tenant_id="integration-workspace", + event_id=None, + event_type="block_actions", + occurred_at=None, + received_at=datetime(2026, 8, 11, 12, 0, 3), + ingress_kind=IMEventIngressKind.WEBHOOK, + payload=json.dumps(extra_input_payload, ensure_ascii=False), + ) + ) + + null_input_payload = TypeAdapter(dict[str, JsonValue]).validate_json(json.dumps(callback_payload)) + null_state = null_input_payload["state"] + assert isinstance(null_state, dict) + null_values = null_state["values"] + assert isinstance(null_values, dict) + null_text_state = null_values[input_block_ids["review_comment"]] + null_select_state = null_values[input_block_ids["risk_level"]] + assert isinstance(null_text_state, dict) + assert isinstance(null_select_state, dict) + null_text_state["review_comment"] = {"type": "plain_text_input", "value": None} + null_select_state["risk_level"] = {"type": "static_select", "selected_option": None} + null_decoded = SlackIMProviderAdapter.card_event_decoder().decode( + AuthenticatedIMEvent( + provider=IMProvider.SLACK, + provider_tenant_id="integration-workspace", + event_id=None, + event_type="block_actions", + occurred_at=None, + received_at=datetime(2026, 8, 11, 12, 0, 4), + ingress_kind=IMEventIngressKind.WEBHOOK, + payload=json.dumps(null_input_payload, ensure_ascii=False), + ) + ) + assert isinstance(null_decoded, IMCardEvent) + assert null_decoded.inputs == {"review_comment": None, "risk_level": None} + + selection_payload = deepcopy(callback_payload) + selection_payload["actions"] = [ + { + "type": "static_select", + "block_id": input_block_ids["risk_level"], + "action_id": "risk_level", + "selected_option": {"value": "high"}, + } + ] + selection_request = SocketModeRequest( + type="interactive", + envelope_id=f"{marker}-selection-envelope", + payload=selection_payload, + ) + selection_event = AuthenticatedIMEvent( + provider=IMProvider.SLACK, + provider_tenant_id="integration-workspace", + event_id=None, + event_type="block_actions", + occurred_at=None, + received_at=_RECEIVED_AT, + ingress_kind=IMEventIngressKind.STREAM, + payload=json.dumps(selection_request.to_dict(), ensure_ascii=False), + ) + assert isinstance(SlackIMProviderAdapter.card_event_decoder().decode(selection_event), UnrecognizedIMEvent) + + malformed_socket_request = SocketModeRequest( + type="interactive", + envelope_id=f"{marker}-malformed-envelope", + payload="invalid", + ) + malformed_socket_event = AuthenticatedIMEvent( + provider=IMProvider.SLACK, + provider_tenant_id="integration-workspace", + event_id=None, + event_type="block_actions", + occurred_at=None, + received_at=_RECEIVED_AT, + ingress_kind=IMEventIngressKind.STREAM, + payload=json.dumps(malformed_socket_request.to_dict(), ensure_ascii=False), + ) + with pytest.raises(IMCardEventDecodingError): + SlackIMProviderAdapter.card_event_decoder().decode(malformed_socket_event) + + legacy_action_payload = deepcopy(callback_payload) + legacy_actions = legacy_action_payload["actions"] + assert isinstance(legacy_actions, list) + legacy_action = legacy_actions[0] + assert isinstance(legacy_action, dict) + legacy_action["block_id"] = "__dify.actions.legacy" + legacy_event = AuthenticatedIMEvent( + provider=IMProvider.SLACK, + provider_tenant_id="integration-workspace", + event_id=None, + event_type="block_actions", + occurred_at=None, + received_at=_RECEIVED_AT, + ingress_kind=IMEventIngressKind.WEBHOOK, + payload=json.dumps(legacy_action_payload, ensure_ascii=False), + ) + assert isinstance(SlackIMProviderAdapter.card_event_decoder().decode(legacy_event), UnrecognizedIMEvent) + + invalid_recipient_result = slack_adapter.dynamic_card_messaging.send_card( + ProviderUserId("D000000000000000000"), + intent, + CorrelationToken(f"{marker}-invalid-recipient"), + ) + assert isinstance(invalid_recipient_result, MessageSendingError) + + replacement_body = f"Slack card integration completed [{marker}]" + replacement_error = slack_adapter.dynamic_card_messaging.replace_with_static( + message_result.locator, + StaticCardIntent(replacement_body), + ) + assert replacement_error is None + replaced_message = _read_exact_message(slack_web_client, message_reference) + assert replaced_message.get("text") == replacement_body + replaced_blocks = replaced_message.get("blocks") + assert isinstance(replaced_blocks, Sequence) + assert not isinstance(replaced_blocks, str | bytes | bytearray) + assert not any( + isinstance(block, Mapping) and block.get("type") in {"input", "actions"} for block in replaced_blocks + ) + + +def test_slack_zero_input_card_round_trips_without_callback_state( + slack_test_recipient_id: ProviderUserId, + slack_adapter: SlackIMProviderAdapter, + slack_web_client: WebClient, +) -> None: + marker = f"codex-implementer-slack-card-event-zero-input-5ab7-20260811-{uuid4()}" + correlation_token = CorrelationToken(f"{marker}-correlation") + intent = ResolvedForm( + title="", + parts=(MarkdownFragment(text=f"Choose one action [{marker}]."),), + actions=(UserAction(id="approve", title="Approve", button_style=ButtonStyle.PRIMARY),), + legacy_form_content="unused", + ) + + message_result = slack_adapter.dynamic_card_messaging.send_card( + slack_test_recipient_id, + intent, + correlation_token, + ) + + assert isinstance(message_result, MessageAccepted) + persisted_message = _read_exact_message(slack_web_client, str(message_result.locator)) + blocks = persisted_message.get("blocks") + assert isinstance(blocks, Sequence) + assert not isinstance(blocks, str | bytes | bytearray) + assert not any(isinstance(block, Mapping) and block.get("type") == "input" for block in blocks) + action_block = next(block for block in blocks if isinstance(block, Mapping) and block.get("type") == "actions") + action_block_id = action_block.get("block_id") + action_elements = action_block.get("elements") + assert isinstance(action_block_id, str) + assert isinstance(action_elements, Sequence) + assert not isinstance(action_elements, str | bytes | bytearray) + assert len(action_elements) == 1 + invoked_action = action_elements[0] + assert isinstance(invoked_action, Mapping) + callback_payload = { + "type": "block_actions", + "team": {"id": "integration-workspace"}, + "user": {"id": str(slack_test_recipient_id)}, + "message": persisted_message, + "actions": [{**invoked_action, "block_id": action_block_id}], + } + authenticated_event = AuthenticatedIMEvent( + provider=IMProvider.SLACK, + provider_tenant_id="integration-workspace", + event_id=None, + event_type="block_actions", + occurred_at=None, + received_at=datetime(2026, 8, 11, 12, 0, 5), + ingress_kind=IMEventIngressKind.WEBHOOK, + payload=json.dumps(callback_payload, ensure_ascii=False), + ) + + decoded = SlackIMProviderAdapter.card_event_decoder().decode(authenticated_event) + + assert decoded == IMCardEvent( + provider_user_id=slack_test_recipient_id, + action_id="approve", + inputs={}, + correlation_token=correlation_token, + ) diff --git a/api/tests/e2e_tests/containers/core/human_input_v2/im_provider/test_wecom_live.py b/api/tests/e2e_tests/containers/core/human_input_v2/im_provider/test_wecom_live.py new file mode 100644 index 00000000000..723a4fc56f4 --- /dev/null +++ b/api/tests/e2e_tests/containers/core/human_input_v2/im_provider/test_wecom_live.py @@ -0,0 +1,292 @@ +"""Live WeCom integration tests using only explicitly authorized credentials.""" + +from __future__ import annotations + +import os +from dataclasses import dataclass, field +from typing import Never +from uuid import uuid4 + +import pytest +from pydantic import BaseModel, ConfigDict, Field, StrictInt, StrictStr, ValidationError +from wechatpy.enterprise import WeChatClient + +from core.human_input_v2.entities import IMProvider +from core.human_input_v2.im_integration.adapters import ( + CredentialTestSuccess, + Directory, + MessageAccepted, + ProviderUserId, + WeComCredentials, +) +from core.human_input_v2.im_integration.adapters.wecom import WeComIMProviderAdapter + +_SDK_TIMEOUT_SECONDS = 5.0 + + +def _fail(reason: str) -> Never: + pytest.fail(reason, pytrace=False) + + +class _ScopedUser(BaseModel): + model_config = ConfigDict(extra="ignore", frozen=True) + + userid: StrictStr + + +class _AllowedDepartments(BaseModel): + model_config = ConfigDict(extra="ignore", frozen=True) + + partyid: tuple[StrictInt, ...] = () + + +class _AllowedUsers(BaseModel): + model_config = ConfigDict(extra="ignore", frozen=True) + + user: tuple[_ScopedUser, ...] = () + + +class _AllowedTags(BaseModel): + model_config = ConfigDict(extra="ignore", frozen=True) + + tagid: tuple[StrictInt, ...] = () + + +class _AgentScope(BaseModel): + model_config = ConfigDict(extra="ignore", frozen=True) + + agentid: StrictInt = Field(gt=0) + allow_partys: _AllowedDepartments = Field(default_factory=_AllowedDepartments) + allow_userinfos: _AllowedUsers = Field(default_factory=_AllowedUsers) + allow_tags: _AllowedTags = Field(default_factory=_AllowedTags) + + +class _Department(BaseModel): + model_config = ConfigDict(extra="ignore", frozen=True) + + id: StrictInt = Field(gt=0) + + +class _DirectoryUser(BaseModel): + model_config = ConfigDict(extra="ignore", frozen=True) + + userid: StrictStr + name: StrictStr | None = None + email: StrictStr | None = None + + +class _TagScope(BaseModel): + model_config = ConfigDict(extra="ignore", frozen=True) + + userlist: tuple[_ScopedUser, ...] = () + partylist: tuple[StrictInt, ...] = () + + +@dataclass(frozen=True, slots=True) +class _ExpectedDirectoryEntry: + provider_user_id: str + display_name: str | None + email: str | None + + +@dataclass(frozen=True, slots=True) +class _LiveCredentials: + value: WeComCredentials = field(repr=False) + + +@dataclass(frozen=True, slots=True) +class _LiveRecipient: + provider_user_id: ProviderUserId = field(repr=False) + + +@pytest.fixture(scope="module") +def wecom_credentials() -> _LiveCredentials: + corp_id = os.getenv("WECOM_CORP_ID") + agent_id = os.getenv("WECOM_AGENT_ID") + secret = os.getenv("WECOM_SECRET") + if not corp_id or not agent_id or not secret: + pytest.skip("WeCom live credentials are not configured") + try: + return _LiveCredentials( + WeComCredentials( + provider=IMProvider.WE_COM, + corp_id=corp_id, + agent_id=agent_id, + secret=secret, + ) + ) + except ValidationError: + _fail("WeCom live credentials failed local validation") + + +@pytest.fixture +def wecom_test_recipient_id() -> _LiveRecipient: + recipient_id = os.getenv("WECOM_TEST_RECIPIENT_ID") + if not recipient_id: + pytest.skip("WECOM_TEST_RECIPIENT_ID is not configured") + return _LiveRecipient(ProviderUserId(recipient_id)) + + +def test_live_credentials_confirm_the_configured_corporation( + wecom_credentials: _LiveCredentials, +) -> None: + credential_repr = repr(wecom_credentials) + if any( + sensitive_value in credential_repr + for sensitive_value in ( + wecom_credentials.value.corp_id, + wecom_credentials.value.agent_id, + wecom_credentials.value.secret, + ) + ): + _fail("WeCom live credential fixture repr is not sanitized") + adapter = WeComIMProviderAdapter(wecom_credentials.value) + try: + result = adapter.test_credentials() + finally: + adapter.close() + + if not isinstance(result, CredentialTestSuccess): + _fail("WeCom live credential testing did not succeed") + if result.provider is not IMProvider.WE_COM: + _fail("WeCom live credential testing returned the wrong Provider") + if result.provider_tenant_id != wecom_credentials.value.corp_id: + _fail("WeCom live credential testing returned the wrong corporation boundary") + + +def test_live_directory_matches_an_independent_complete_sdk_traversal( + wecom_credentials: _LiveCredentials, +) -> None: + expected_entries = _read_expected_directory(wecom_credentials) + adapter = WeComIMProviderAdapter(wecom_credentials.value) + try: + actual = adapter.directory.read_directory() + finally: + adapter.close() + + if not isinstance(actual, Directory): + _fail("WeCom live Directory did not return a complete snapshot") + if not expected_entries: + _fail("WeCom live Directory scope did not expose any members") + expected = tuple((entry.provider_user_id, entry.display_name, entry.email) for entry in expected_entries) + observed = tuple((str(entry.provider_user_id), entry.display_name, entry.email) for entry in actual.entries) + if observed != expected: + _fail("WeCom live Directory did not match the independent SDK traversal") + if any(not entry.provider_user_id.strip() for entry in expected_entries): + _fail("WeCom live Directory returned an invalid identity") + + +def test_live_messaging_confirms_provider_acceptance( + wecom_credentials: _LiveCredentials, + wecom_test_recipient_id: _LiveRecipient, +) -> None: + message_body = f"Dify WeCom live integration {uuid4()}" + if str(wecom_test_recipient_id.provider_user_id) in repr(wecom_test_recipient_id): + _fail("WeCom live recipient fixture repr is not sanitized") + adapter = WeComIMProviderAdapter(wecom_credentials.value) + try: + result = adapter.messaging.send_text(wecom_test_recipient_id.provider_user_id, message_body) + finally: + adapter.close() + + if not isinstance(result, MessageAccepted): + _fail("WeCom did not confirm live message acceptance") + + +def _read_expected_directory( + live_credentials: _LiveCredentials, +) -> tuple[_ExpectedDirectoryEntry, ...]: + try: + credentials = live_credentials.value + client = WeChatClient( + credentials.corp_id, + credentials.secret, + timeout=_SDK_TIMEOUT_SECONDS, + auto_retry=False, + ) + client.fetch_access_token() + scope = _AgentScope.model_validate(client.agent.get(int(credentials.agent_id))) + if scope.agentid != int(credentials.agent_id): + _fail("WeCom live agent scope did not match the configured application") + + entries: list[_ExpectedDirectoryEntry] = [] + seen_users: set[str] = set() + seen_departments: set[int] = set() + for department_id in scope.allow_partys.partyid: + _read_expected_department(client, department_id, entries, seen_users, seen_departments) + for scoped_user in scope.allow_userinfos.user: + _read_expected_user(client, scoped_user.userid, entries, seen_users) + for tag_id in scope.allow_tags.tagid: + tag = _TagScope.model_validate(client.tag.get_users(tag_id)) + for scoped_user in tag.userlist: + _read_expected_user(client, scoped_user.userid, entries, seen_users) + for department_id in tag.partylist: + _read_expected_department(client, department_id, entries, seen_users, seen_departments) + return tuple(entries) + except AssertionError: + raise + except Exception: + _fail("Independent WeCom live Directory traversal failed") + + +def _read_expected_department( + client: WeChatClient, + root_department_id: int, + entries: list[_ExpectedDirectoryEntry], + seen_users: set[str], + seen_departments: set[int], +) -> None: + if root_department_id in seen_departments: + return + departments = tuple(_Department.model_validate(value) for value in client.department.get(root_department_id)) + if not any(department.id == root_department_id for department in departments): + _fail("WeCom live department traversal omitted its requested root") + for department in departments: + if department.id in seen_departments: + continue + seen_departments.add(department.id) + users = tuple( + _DirectoryUser.model_validate(value) + for value in client.user.list(department.id, fetch_child=False, status=0, simple=False) + ) + for user in users: + _append_expected_user(user, entries, seen_users) + + +def _read_expected_user( + client: WeChatClient, + user_id: str, + entries: list[_ExpectedDirectoryEntry], + seen_users: set[str], +) -> None: + if user_id in seen_users: + return + user = _DirectoryUser.model_validate(client.user.get(user_id)) + if user.userid != user_id: + _fail("WeCom live user lookup returned a different identity") + _append_expected_user(user, entries, seen_users) + + +def _append_expected_user( + user: _DirectoryUser, + entries: list[_ExpectedDirectoryEntry], + seen_users: set[str], +) -> None: + if not user.userid.strip(): + _fail("WeCom live Directory returned a blank identity") + if user.userid in seen_users: + return + seen_users.add(user.userid) + entries.append( + _ExpectedDirectoryEntry( + provider_user_id=user.userid, + display_name=_optional_non_blank(user.name), + email=_optional_non_blank(user.email), + ) + ) + + +def _optional_non_blank(value: str | None) -> str | None: + if value is None or not value.strip(): + return None + return value diff --git a/api/tests/e2e_tests/containers/core/repositories/test_human_input_v2_form_repository.py b/api/tests/e2e_tests/containers/core/repositories/test_human_input_v2_form_repository.py new file mode 100644 index 00000000000..666264a18a8 --- /dev/null +++ b/api/tests/e2e_tests/containers/core/repositories/test_human_input_v2_form_repository.py @@ -0,0 +1,107 @@ +"""PostgreSQL concurrency contracts for Human Input v2 form persistence.""" + +from concurrent.futures import ThreadPoolExecutor +from datetime import timedelta +from threading import Barrier, Event +from uuid import uuid4 + +import sqlalchemy as sa +from sqlalchemy.orm import Session + +from core.human_input_v2.resolved_form import ResolvedForm +from core.human_input_v2.shared.values import RecipientId, TenantId +from libs.datetime_utils import naive_utc_now +from models.human_input_v2 import HumanInputForm +from repositories.human_input_v2.form_repository import Form, FormCreateParams, FormSubmissionFailure +from repositories.human_input_v2.sqlalchemy_form_repository import SQLAlchemyFormRepository + + +def _params() -> FormCreateParams: + now = naive_utc_now() + return FormCreateParams( + workflow_run_id=str(uuid4()), + node_execution_id=str(uuid4()), + resolved_form=ResolvedForm(title="Approval", parts=(), actions=(), legacy_form_content="Review this."), + expiration_time=now + timedelta(hours=1), + global_timeout_deadline=now + timedelta(hours=2), + ) + + +def test_concurrent_creation_reuses_the_committed_form(db_session_with_containers: Session) -> None: + engine = db_session_with_containers.get_bind() + tenant_id = TenantId(str(uuid4())) + app_id = str(uuid4()) + params = _params() + barrier = Barrier(2) + + def create() -> Form: + with Session(engine) as session, session.begin(): + barrier.wait(timeout=10) + return SQLAlchemyFormRepository(session, tenant_id, app_id).create_form(params) + + with ThreadPoolExecutor(max_workers=2) as executor: + futures = [executor.submit(create) for _ in range(2)] + forms = [future.result(timeout=15) for future in futures] + assert forms[0] == forms[1] + + +def test_waiting_form_discovery_does_not_wait_for_form_locks(db_session_with_containers: Session) -> None: + engine = db_session_with_containers.get_bind() + tenant_id = TenantId(str(uuid4())) + app_id = str(uuid4()) + params = _params() + assert params.workflow_run_id is not None + with Session(engine) as session, session.begin(): + form = SQLAlchemyFormRepository(session, tenant_id, app_id).create_form(params) + + with Session(engine) as blocker, blocker.begin(): + blocker.scalars(sa.select(HumanInputForm).where(HumanInputForm.id == form.id).with_for_update()).one() + with Session(engine) as reader, reader.begin(): + # Discovery must read committed facts even while a form is locked; + # only its eventual state transition needs to wait for that lock. + reader.execute(sa.text("SET LOCAL lock_timeout = '1s'")) + batch = SQLAlchemyFormRepository(reader, tenant_id, app_id).list_waiting_forms_for_run( + params.workflow_run_id + ) + assert batch.forms == (form,) + + +def test_submission_rechecks_facts_and_clock_after_the_lock(db_session_with_containers: Session) -> None: + engine = db_session_with_containers.get_bind() + tenant_id = TenantId(str(uuid4())) + app_id = str(uuid4()) + with Session(engine) as session, session.begin(): + form = SQLAlchemyFormRepository(session, tenant_id, app_id).create_form(_params()) + + read_before_deadline_change = Event() + + def submit() -> Form | FormSubmissionFailure: + with Session(engine) as session, session.begin(): + repo = SQLAlchemyFormRepository(session, tenant_id, app_id) + assert repo.get_form_by_id(form.id) == form + read_before_deadline_change.set() + return repo.submit_form( + form.id, + submitted_by_recipient_id=RecipientId(str(uuid4())), + submitted_by_delivery_id=str(uuid4()), + selected_action_id="approve", + raw_submission_inputs={}, + normalized_submission_data={}, + ) + + with ThreadPoolExecutor(max_workers=1) as executor: + with Session(engine) as blocker, blocker.begin(): + blocker.scalars(sa.select(HumanInputForm).where(HumanInputForm.id == form.id).with_for_update()).one() + future = executor.submit(submit) + assert read_before_deadline_change.wait(timeout=10) + # The submitter's transaction already started with the original future + # deadline. Commit a reached deadline while still holding the lock: + # neither its earlier snapshot nor transaction-start time may be used. + blocker.execute( + sa.update(HumanInputForm) + .where(HumanInputForm.id == form.id) + .values( + expiration_time=sa.func.timezone("UTC", sa.func.clock_timestamp()), + ) + ) + assert future.result(timeout=15) == FormSubmissionFailure.FORM_EXPIRED diff --git a/api/tests/e2e_tests/containers/human_input_v2/test_email_otp_redis.py b/api/tests/e2e_tests/containers/human_input_v2/test_email_otp_redis.py new file mode 100644 index 00000000000..318c6619ec9 --- /dev/null +++ b/api/tests/e2e_tests/containers/human_input_v2/test_email_otp_redis.py @@ -0,0 +1,426 @@ +"""CI-only OTP behavior through the service with real Redis and PostgreSQL.""" + +from __future__ import annotations + +from collections.abc import Callable, Iterator +from concurrent.futures import ThreadPoolExecutor +from dataclasses import dataclass +from datetime import timedelta +from hashlib import sha256 +from threading import Barrier +from uuid import uuid4 + +import pytest +from sqlalchemy import Engine, delete, select +from sqlalchemy.orm import Session, sessionmaker + +from core.human_input_v2.resolved_form import ResolvedForm +from core.human_input_v2.shared.values import ContactId, NormalizedEmail, TenantId +from core.workflow.nodes.human_input.enums import HumanInputFormKind +from core.workflow.nodes.human_input_v2.entities import OnetimeEmail +from extensions.ext_redis import redis_client +from extensions.redis_names import serialize_redis_name +from libs.datetime_utils import naive_utc_now +from libs.helper import RateLimiter +from models.account import Tenant +from models.human_input_v2 import ( + HumanInputAuditEvent, + HumanInputDelivery, + HumanInputExternalContactProfile, + HumanInputForm, + HumanInputRecipient, + OtpSendEvent, + OtpVerificationEvent, +) +from models.model import App, AppMode +from repositories.human_input_v2.contact import ExternalContact +from repositories.human_input_v2.delivery_repository import ( + Delivery, + DeliveryCreateParams, + EmailTargetSnapshot, + SubmissionAuthType, +) +from repositories.human_input_v2.form_repository import Form, FormCreateParams, FormSubmissionFailure +from repositories.human_input_v2.recipient_repository import ( + ContactRecipientSubject, + EmailRecipientSubject, + RecipientCreateParams, +) +from repositories.human_input_v2.sqlalchemy_contact_repository import SQLAlchemyContactRepository +from repositories.human_input_v2.sqlalchemy_delivery_repository import SQLAlchemyDeliveryRepository +from repositories.human_input_v2.sqlalchemy_form_repository import SQLAlchemyFormRepository +from repositories.human_input_v2.sqlalchemy_recipient_repository import SQLAlchemyRecipientRepository +from services.human_input_v2.email_otp_service import EmailOtpError, EmailOtpService, OtpFailure, OtpMail, VerifiedEmail + +pytestmark = pytest.mark.usefixtures("flask_app_with_containers") + + +def test_challenges_are_independent_and_fifth_mismatch_deletes_only_one( + otp_case: OtpCase, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr("services.human_input_v2.email_otp_service.secrets.randbelow", lambda _bound: 1234) + first_node, second_node = otp_case.service(), otp_case.service() + first = first_node.request_access(otp_case.token) + otp_case.release_cooldown() + second = second_node.request_access(otp_case.token) + assert otp_case.sent[0][1] == "001234" + other_token = str(uuid4()) + with otp_case.sessions.begin() as session: + SQLAlchemyDeliveryRepository(session).create_delivery( + tenant_id=otp_case.form.tenant_id, + form_id=otp_case.form.id, + params=DeliveryCreateParams( + recipient_id=otp_case.delivery.recipient_id, + token_hash=sha256(other_token.encode()).hexdigest(), + auth_type=SubmissionAuthType.EMAIL_OTP, + target_snapshot=EmailTargetSnapshot(email_address="ci@example.com"), + ), + ) + with pytest.raises(EmailOtpError) as error: + second_node.verify(other_token, challenge_token=first.challenge_token, otp_code="001234", ip_address=None) + assert error.value.reason == OtpFailure.CHALLENGE_EXPIRED + second_node.verify(otp_case.token, challenge_token=first.challenge_token, otp_code="001234", ip_address=None) + for _ in range(4): + with pytest.raises(EmailOtpError) as error: + second_node.verify(otp_case.token, challenge_token=first.challenge_token, otp_code="1234", ip_address=None) + assert error.value.reason == OtpFailure.INVALID + first_node.verify(otp_case.token, challenge_token=first.challenge_token, otp_code="001234", ip_address=None) + with pytest.raises(EmailOtpError) as error: + first_node.verify(otp_case.token, challenge_token=first.challenge_token, otp_code="000000", ip_address=None) + assert error.value.reason == OtpFailure.STALE + assert not redis_client.exists(f"hitl:otp:challenge:{{{otp_case.delivery.id}:{first.challenge_token}}}") + with pytest.raises(EmailOtpError) as error: + second_node.verify(otp_case.token, challenge_token=first.challenge_token, otp_code="001234", ip_address=None) + assert error.value.reason == OtpFailure.CHALLENGE_EXPIRED + second_node.verify(otp_case.token, challenge_token=second.challenge_token, otp_code="001234", ip_address=None) + + +@pytest.mark.parametrize("otp_case", ["external_contact"], indirect=True) +def test_contact_email_change_does_not_consume_guesses(otp_case: OtpCase) -> None: + service = otp_case.service() + challenge = service.request_access(otp_case.token) + with otp_case.sessions.begin() as session: + profile = session.scalars(select(HumanInputExternalContactProfile)).one() + profile.email = "changed@example.com" + for _ in range(6): + with pytest.raises(EmailOtpError) as error: + service.verify( + otp_case.token, challenge_token=challenge.challenge_token, otp_code=otp_case.sent[0][1], ip_address=None + ) + assert error.value.reason == OtpFailure.STALE + with otp_case.sessions.begin() as session: + profile = session.scalars(select(HumanInputExternalContactProfile)).one() + profile.email = "ci@example.com" + service.verify( + otp_case.token, challenge_token=challenge.challenge_token, otp_code=otp_case.sent[0][1], ip_address=None + ) + + +def test_concurrent_guesses_cannot_exceed_budget(otp_case: OtpCase) -> None: + service = otp_case.service() + challenge = service.request_access(otp_case.token) + barrier = Barrier(12) + + def guess(_: int) -> OtpFailure: + barrier.wait() + try: + service.verify( + otp_case.token, challenge_token=challenge.challenge_token, otp_code="wrong-code", ip_address=None + ) + except EmailOtpError as error: + return error.reason + pytest.fail("An incorrect code was accepted") + + with ThreadPoolExecutor(max_workers=12) as executor: + results = list(executor.map(guess, range(12))) + assert results.count(OtpFailure.INVALID) == 4 + assert results.count(OtpFailure.STALE) == 1 + assert results.count(OtpFailure.CHALLENGE_EXPIRED) == 7 + + +def test_concurrent_sends_consume_one_slot_and_rejections_do_not_count() -> None: + limiter = RateLimiter(prefix=f"otp-test:{uuid4()}", max_attempts=5, time_window=3600) + barrier = Barrier(12) + + def acquire(_: int) -> int: + barrier.wait() + return limiter.acquire("delivery", cooldown_seconds=60) + + with ThreadPoolExecutor(max_workers=12) as executor: + results = list(executor.map(acquire, range(12))) + assert results.count(0) == 1 + assert all(0 < wait <= 60 for wait in results if wait) + assert redis_client.zcard(f"{limiter.prefix}:delivery") == 1 + assert limiter.acquire("other-delivery", cooldown_seconds=60) == 0 + redis_client.delete(f"{limiter.prefix}:delivery", f"{limiter.prefix}:other-delivery") + + +def test_hourly_window_and_cooldown_return_combined_delay() -> None: + limiter = RateLimiter(prefix=f"otp-test:{uuid4()}", max_attempts=5, time_window=3600) + key = serialize_redis_name(f"{limiter.prefix}:delivery") + # Redis controls time; seed recent history without waiting an hour in CI. + redis_client.eval( + """ + local now = tonumber(redis.call('TIME')[1]) + for i = 0, 4 do redis.call('ZADD', KEYS[1], now - 300 + i * 60, tostring(i)) end + redis.call('EXPIRE', KEYS[1], 3600) + """, + 1, + key, + ) + assert 3298 <= limiter.acquire("delivery", cooldown_seconds=60) <= 3300 + assert redis_client.zcard(f"{limiter.prefix}:delivery") == 5 + redis_client.delete(f"{limiter.prefix}:delivery") + + +def test_verification_does_not_refresh_ttl(otp_case: OtpCase) -> None: + service = otp_case.service() + challenge = service.request_access(otp_case.token) + key = f"hitl:otp:challenge:{{{otp_case.delivery.id}:{challenge.challenge_token}}}" + redis_client.expire(key, 20) + with pytest.raises(EmailOtpError) as error: + service.verify( + otp_case.token, challenge_token=challenge.challenge_token, otp_code="wrong-code", ip_address=None + ) + assert error.value.reason == OtpFailure.INVALID + assert 0 < redis_client.ttl(key) <= 20 + redis_client.expire(key, 0) + with pytest.raises(EmailOtpError) as error: + service.verify( + otp_case.token, challenge_token=challenge.challenge_token, otp_code=otp_case.sent[0][1], ip_address=None + ) + assert error.value.reason == OtpFailure.CHALLENGE_EXPIRED + + +@dataclass +class OtpCase: + sessions: sessionmaker[Session] + form: Form + delivery: Delivery + token: str + limiter: RateLimiter + sent: list[tuple[str, str]] + + def service(self, send: Callable[[str, str], None] | None = None) -> EmailOtpService: + return EmailOtpService( + session_factory=self.sessions, + redis=redis_client, + limiter=self.limiter, + mail=OtpMail(available=lambda: True, send=send or (lambda email, code: self.sent.append((email, code)))), + ) + + def release_cooldown(self) -> None: + # Move the successful-send history beyond its minute cooldown without + # changing the challenge TTL or discarding the hourly send budget. + key = serialize_redis_name(f"{self.limiter.prefix}:{self.delivery.id}") + redis_client.eval( + """ + local now = tonumber(redis.call('TIME')[1]) + local members = redis.call('ZRANGE', KEYS[1], 0, -1) + for _, member in ipairs(members) do redis.call('ZADD', KEYS[1], now - 61, member) end + """, + 1, + key, + ) + + +@pytest.fixture(params=["email", "external_contact"]) +def otp_case(db_session_with_containers: Session, request: pytest.FixtureRequest) -> Iterator[OtpCase]: + engine = db_session_with_containers.get_bind() + assert isinstance(engine, Engine) + sessions = sessionmaker(engine, expire_on_commit=False) + tenant_id, app_id, token = TenantId(str(uuid4())), str(uuid4()), str(uuid4()) + now = naive_utc_now() + with sessions.begin() as session: + tenant = Tenant(name="OTP CI") + tenant.id = tenant_id + session.add(tenant) + session.add( + App(id=app_id, tenant_id=tenant_id, name="OTP CI", mode=AppMode.WORKFLOW, enable_site=True, enable_api=True) + ) + session.flush() + form = SQLAlchemyFormRepository(session, tenant_id, app_id).create_form( + FormCreateParams( + workflow_run_id=None, + node_execution_id=None, + form_kind=HumanInputFormKind.DELIVERY_TEST, + resolved_form=ResolvedForm(title="Approval", parts=(), actions=(), legacy_form_content="Review"), + expiration_time=now + timedelta(hours=1), + global_timeout_deadline=now + timedelta(hours=2), + ) + ) + if request.param == "external_contact": + contact = SQLAlchemyContactRepository(session).save_external_contact( + tenant_id, + ExternalContact( + id=ContactId(str(uuid4())), + name="CI approver", + email="ci@example.com", + avatar_file_id=None, + created_at=now, + ), + ) + subject = ContactRecipientSubject(contact.id) + else: + subject = EmailRecipientSubject(NormalizedEmail("ci@example.com")) + recipient = SQLAlchemyRecipientRepository(session, tenant_id, form.id).create_recipients( + [ + RecipientCreateParams( + subject=subject, + sources=(OnetimeEmail(email="ci@example.com"),), + name=None, + ) + ] + )[0] + delivery = SQLAlchemyDeliveryRepository(session).create_delivery( + tenant_id=tenant_id, + form_id=form.id, + params=DeliveryCreateParams( + recipient_id=recipient.id, + token_hash=sha256(token.encode()).hexdigest(), + auth_type=SubmissionAuthType.EMAIL_OTP, + target_snapshot=EmailTargetSnapshot(email_address="ci@example.com"), + ), + ) + limiter = RateLimiter(prefix=f"otp-ci:{uuid4()}", max_attempts=5, time_window=3600) + try: + yield OtpCase(sessions, form, delivery, token, limiter, []) + finally: + # Remove all test-issued challenges without adding a production list API. + pattern = serialize_redis_name(f"hitl:otp:challenge:{{{delivery.id}:*") + for key in redis_client.scan_iter(match=pattern): + redis_client.delete(key) + redis_client.delete(f"{limiter.prefix}:{delivery.id}") + with sessions.begin() as session: + for model in (HumanInputAuditEvent, HumanInputDelivery, HumanInputRecipient): + session.execute(delete(model).where(model.form_id == form.id)) + session.execute(delete(HumanInputForm).where(HumanInputForm.id == form.id)) + session.execute(delete(App).where(App.id == app_id)) + session.execute(delete(Tenant).where(Tenant.id == tenant_id)) + + +def test_verified_challenges_accept_one_form_on_postgres(otp_case: OtpCase) -> None: + delivery, form, token = otp_case.delivery, otp_case.form, otp_case.token + sessions = otp_case.sessions + tenant_id, app_id, recipient_id = form.tenant_id, form.app_id, delivery.recipient_id + service = otp_case.service() + challenges = [service.request_access(token)] + otp_case.release_cooldown() + challenges.append(service.request_access(token)) + proofs = [ + service.verify(token, challenge_token=challenge.challenge_token, otp_code=sent[1], ip_address=None) + for challenge, sent in zip(challenges, otp_case.sent, strict=True) + ] + barrier = Barrier(2) + + def submit(proof: VerifiedEmail) -> Form | FormSubmissionFailure: + barrier.wait() + with sessions.begin() as session: + result = SQLAlchemyFormRepository(session, tenant_id, app_id).submit_form( + form.id, + submitted_by_recipient_id=recipient_id, + submitted_by_delivery_id=proof.delivery.id, + selected_action_id="approve", + raw_submission_inputs={}, + normalized_submission_data={}, + ) + if isinstance(result, Form): + proof.add_submission_audit(session, ip_address=None) + return result + + with ThreadPoolExecutor(max_workers=2) as executor: + results = list(executor.map(submit, proofs)) + assert sum(isinstance(result, Form) for result in results) == 1 + assert results.count(FormSubmissionFailure.ALREADY_SUBMITTED) == 1 + with sessions() as session: + audits = session.scalars(select(HumanInputAuditEvent).where(HumanInputAuditEvent.form_id == form.id)).all() + sends = [audit.payload for audit in audits if isinstance(audit.payload, OtpSendEvent)] + submissions = [audit.payload for audit in audits if isinstance(audit.payload, OtpVerificationEvent)] + + assert len(audits) == 3 + assert sends == [ + OtpSendEvent(recipient_id=recipient_id, recipient_email="ci@example.com", sender_ip_address=None), + OtpSendEvent(recipient_id=recipient_id, recipient_email="ci@example.com", sender_ip_address=None), + ] + assert submissions == [ + OtpVerificationEvent( + recipient_id=recipient_id, + recipient_email="ci@example.com", + succeeded=True, + submitter_ip_address=None, + reason="submitted", + ) + ] + + +def test_lost_response_and_failed_resend_preserve_earlier_proof_and_budget(otp_case: OtpCase) -> None: + service = otp_case.service() + first = service.request_access(otp_case.token) + first_code = otp_case.sent[0][1] + otp_case.release_cooldown() + service.request_access(otp_case.token) # The browser loses this successful response. + assert ( + service.verify( + otp_case.token, challenge_token=first.challenge_token, otp_code=first_code, ip_address=None + ).delivery.id + == otp_case.delivery.id + ) + otp_case.release_cooldown() + + def fail(_email: str, _code: str) -> None: + raise EmailOtpError(OtpFailure.DELIVERY) + + with pytest.raises(EmailOtpError) as error: + otp_case.service(fail).request_access(otp_case.token) + assert error.value.reason == OtpFailure.DELIVERY + assert error.value.retry_after is not None + assert error.value.retry_after > 0 + service.verify(otp_case.token, challenge_token=first.challenge_token, otp_code=first_code, ip_address=None) + assert redis_client.zcard(f"{otp_case.limiter.prefix}:{otp_case.delivery.id}") == 3 + pattern = serialize_redis_name(f"hitl:otp:challenge:{{{otp_case.delivery.id}:*") + assert len(list(redis_client.scan_iter(match=pattern))) == 4 + + +def test_older_mail_finishing_last_does_not_replace_newer_challenge(otp_case: OtpCase) -> None: + from threading import Event + + started, release = Event(), Event() + first_code = [] + + def slow_send(_email: str, code: str) -> None: + first_code.append(code) + started.set() + assert release.wait(10) + + with ThreadPoolExecutor(max_workers=1) as executor: + older = executor.submit(otp_case.service(slow_send).request_access, otp_case.token) + assert started.wait(10) + try: + otp_case.release_cooldown() + newer = otp_case.service().request_access(otp_case.token) + finally: + release.set() + first = older.result(timeout=10) + service = otp_case.service() + service.verify(otp_case.token, challenge_token=first.challenge_token, otp_code=first_code[0], ip_address=None) + service.verify(otp_case.token, challenge_token=newer.challenge_token, otp_code=otp_case.sent[0][1], ip_address=None) + + +def test_hourly_slot_recovers_without_discarding_recent_sends() -> None: + limiter = RateLimiter(prefix=f"otp-test:{uuid4()}", max_attempts=5, time_window=3600) + key = serialize_redis_name(f"{limiter.prefix}:delivery") + redis_client.eval( + """ + local now = tonumber(redis.call('TIME')[1]) + redis.call('ZADD', KEYS[1], now - 3600, 'boundary') + for i = 1, 4 do redis.call('ZADD', KEYS[1], now - 300 + i * 30, tostring(i)) end + redis.call('EXPIRE', KEYS[1], 3600) + """, + 1, + key, + ) + assert limiter.acquire("delivery", cooldown_seconds=60) == 0 + assert redis_client.zcard(f"{limiter.prefix}:delivery") == 5 + assert limiter.retry_after("delivery", cooldown_seconds=60) > 60 + redis_client.delete(f"{limiter.prefix}:delivery") diff --git a/api/tests/e2e_tests/containers/libs/test_redis_counter.py b/api/tests/e2e_tests/containers/libs/test_redis_counter.py new file mode 100644 index 00000000000..2956a0b4f40 --- /dev/null +++ b/api/tests/e2e_tests/containers/libs/test_redis_counter.py @@ -0,0 +1,52 @@ +"""Behavior of TTL-preserving scalar counters on real Redis (CI only).""" + +from concurrent.futures import ThreadPoolExecutor +from threading import Barrier +from uuid import uuid4 + +import pytest + +from extensions.ext_redis import redis_client + +pytestmark = pytest.mark.usefixtures("flask_app_with_containers") + + +def test_increment_preserves_counter_ttl_without_resetting_it() -> None: + key = f"counter-test:{uuid4()}" + redis_client.set(key, 0, ex=60) + try: + for expected in range(1, 7): + assert redis_client.incr_if_exists(key) == expected + assert redis_client.get(key) == str(expected).encode() + assert 0 < redis_client.ttl(key) <= 60 + finally: + redis_client.delete(key) + assert redis_client.incr_if_exists(key) is None + assert not redis_client.exists(key) + + +def test_expired_counter_is_not_recreated() -> None: + key = f"counter-test:{uuid4()}" + redis_client.set(key, 0, ex=60) + redis_client.expire(key, 0) + assert redis_client.incr_if_exists(key) is None + assert not redis_client.exists(key) + + +def test_concurrent_increments_do_not_lose_updates() -> None: + key = f"counter-test:{uuid4()}" + redis_client.set(key, 0, ex=60) + barrier = Barrier(12) + + def increment(_: int) -> int | None: + barrier.wait() + return redis_client.incr_if_exists(key) + + try: + with ThreadPoolExecutor(max_workers=12) as executor: + counts = list(executor.map(increment, range(12))) + assert sorted(count for count in counts if count is not None) == list(range(1, 13)) + assert redis_client.get(key) == b"12" + assert 0 < redis_client.ttl(key) <= 60 + finally: + redis_client.delete(key) diff --git a/api/tests/e2e_tests/containers/models/test_pydantic_model_json.py b/api/tests/e2e_tests/containers/models/test_pydantic_model_json.py new file mode 100644 index 00000000000..2af61242c84 --- /dev/null +++ b/api/tests/e2e_tests/containers/models/test_pydantic_model_json.py @@ -0,0 +1,116 @@ +from collections.abc import Iterator +from typing import Annotated, Literal + +import pytest +import sqlalchemy as sa +from pydantic import BaseModel, ConfigDict, Field, TypeAdapter +from sqlalchemy import Engine, select +from sqlalchemy.orm import DeclarativeBase, Mapped, Session, mapped_column + +from models.types import FrozenPydanticModelColumn + + +class _ConcretePayload(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True, strict=True, validate_default=True) + + name: str + count: int + + +class _EmailDestination(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True, strict=True, validate_default=True) + + channel: Literal["email"] = "email" + address: str + + +class _SlackDestination(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True, strict=True, validate_default=True) + + channel: Literal["slack"] = "slack" + channel_id: str + + +type _Destination = Annotated[_EmailDestination | _SlackDestination, Field(discriminator="channel")] + + +class _Base(DeclarativeBase): + pass + + +class _PydanticJSONRecord(_Base): + __tablename__ = "integration_pydantic_json_records" + + id: Mapped[int] = mapped_column(sa.Integer, primary_key=True, autoincrement=True) + concrete: Mapped[_ConcretePayload] = mapped_column( + FrozenPydanticModelColumn( + _ConcretePayload, + ), + nullable=False, + ) + destination: Mapped[_Destination] = mapped_column( + FrozenPydanticModelColumn( + TypeAdapter(_Destination), + model_types=(_EmailDestination, _SlackDestination), + ), + nullable=False, + ) + + +@pytest.fixture +def pydantic_json_engine(db_session_with_containers: Session) -> Engine: + bind = db_session_with_containers.get_bind() + assert isinstance(bind, Engine) + return bind + + +@pytest.fixture(autouse=True) +def _pydantic_json_schema(pydantic_json_engine: Engine) -> Iterator[None]: + _Base.metadata.create_all(pydantic_json_engine) + try: + yield + finally: + _Base.metadata.drop_all(pydantic_json_engine) + + +def test_real_database_stores_single_encoded_json_and_round_trips_a_concrete_model( + pydantic_json_engine: Engine, +) -> None: + with Session(pydantic_json_engine) as session: + record = _PydanticJSONRecord( + concrete=_ConcretePayload(name="container", count=3), + destination=_EmailDestination(address="reviewer@example.com"), + ) + session.add(record) + session.commit() + record_id = record.id + + with Session(pydantic_json_engine) as session: + stored_json = session.execute( + sa.text("SELECT concrete FROM integration_pydantic_json_records WHERE id = :record_id"), + {"record_id": record_id}, + ).scalar_one() + restored = session.scalar(select(_PydanticJSONRecord).where(_PydanticJSONRecord.id == record_id)) + + assert stored_json == '{"name":"container","count":3}' + assert restored is not None + assert restored.concrete == _ConcretePayload(name="container", count=3) + assert isinstance(restored.concrete, _ConcretePayload) + + +def test_real_database_restores_the_discriminated_union_subtype(pydantic_json_engine: Engine) -> None: + with Session(pydantic_json_engine) as session: + record = _PydanticJSONRecord( + concrete=_ConcretePayload(name="container", count=4), + destination=_SlackDestination(channel_id="C123"), + ) + session.add(record) + session.commit() + record_id = record.id + + with Session(pydantic_json_engine) as session: + restored = session.scalar(select(_PydanticJSONRecord).where(_PydanticJSONRecord.id == record_id)) + + assert restored is not None + assert restored.destination == _SlackDestination(channel_id="C123") + assert isinstance(restored.destination, _SlackDestination) diff --git a/api/tests/e2e_tests/containers/repositories/human_input_v2/test_email_channel_repository.py b/api/tests/e2e_tests/containers/repositories/human_input_v2/test_email_channel_repository.py new file mode 100644 index 00000000000..a6612f3ffb9 --- /dev/null +++ b/api/tests/e2e_tests/containers/repositories/human_input_v2/test_email_channel_repository.py @@ -0,0 +1,160 @@ +"""PostgreSQL concurrency contracts for Human Input Email configuration.""" + +from concurrent.futures import ThreadPoolExecutor +from dataclasses import replace +from threading import Barrier +from uuid import uuid4 + +from sqlalchemy.orm import Session, sessionmaker + +from core.human_input_v2.shared import ( + AccountId, + EmailProviderId, + NormalizedEmail, + TenantId, +) +from libs.datetime_utils import naive_utc_now +from models.account import Tenant +from repositories.human_input_v2.channel_credentials import ChannelEncryptedCredentials +from repositories.human_input_v2.email_channel import ( + CreateEmailConfigurationResult, + CreateEmailConfigurationStatus, + DeleteEmailConfigurationResult, + DeleteEmailConfigurationStatus, + EmailChannelConfiguration, + UpdateEmailConfigurationResult, + UpdateEmailConfigurationStatus, +) +from repositories.human_input_v2.email_channel.repository import SQLAlchemyEmailChannelRepository + + +def _configuration(tenant_id: TenantId, *, configuration_id: str | None = None) -> EmailChannelConfiguration: + now = naive_utc_now() + return EmailChannelConfiguration( + EmailProviderId(configuration_id or str(uuid4())), + tenant_id, + NormalizedEmail("sender@example.com"), + "Sender", + ChannelEncryptedCredentials(ciphertext="ciphertext"), + AccountId(str(uuid4())), + now, + now, + ) + + +def _sessions(db_session: Session) -> tuple[sessionmaker[Session], TenantId]: + tenant = Tenant(name="Email channel concurrency") + db_session.add(tenant) + db_session.commit() + tenant_id = TenantId(tenant.id) + maker = sessionmaker(bind=db_session.get_bind(), expire_on_commit=False) + return maker, tenant_id + + +def test_concurrent_first_creation_has_one_winner(db_session_with_containers: Session) -> None: + sessions, tenant_id = _sessions(db_session_with_containers) + barrier = Barrier(2) + + def create(configuration: EmailChannelConfiguration) -> CreateEmailConfigurationResult: + barrier.wait() + with sessions.begin() as session: + return SQLAlchemyEmailChannelRepository(session).create(configuration) + + with ThreadPoolExecutor(max_workers=2) as executor: + results = tuple( + executor.map( + create, + (_configuration(tenant_id), _configuration(tenant_id)), + ) + ) + + assert sorted(result.status for result in results) == [ + CreateEmailConfigurationStatus.CONFLICT, + CreateEmailConfigurationStatus.CREATED, + ] + + +def test_concurrent_conditional_updates_have_one_winner(db_session_with_containers: Session) -> None: + sessions, tenant_id = _sessions(db_session_with_containers) + with sessions.begin() as session: + current = SQLAlchemyEmailChannelRepository(session).create(_configuration(tenant_id)).configuration + assert current is not None + barrier = Barrier(2) + + def update(sender_name: str) -> UpdateEmailConfigurationResult: + barrier.wait() + with sessions.begin() as session: + return SQLAlchemyEmailChannelRepository(session).update( + replace(current, encrypted_credentials=ChannelEncryptedCredentials(ciphertext=sender_name)), + expected=current.snapshot, + now=naive_utc_now(), + ) + + with ThreadPoolExecutor(max_workers=2) as executor: + results = tuple(executor.map(update, ("First", "Second"))) + + assert sorted(result.status for result in results) == [ + UpdateEmailConfigurationStatus.STALE, + UpdateEmailConfigurationStatus.UPDATED, + ] + + +def test_update_delete_race_cannot_restore_deleted_row(db_session_with_containers: Session) -> None: + sessions, tenant_id = _sessions(db_session_with_containers) + with sessions.begin() as session: + current = SQLAlchemyEmailChannelRepository(session).create(_configuration(tenant_id)).configuration + assert current is not None + barrier = Barrier(2) + + def update() -> UpdateEmailConfigurationResult: + barrier.wait() + with sessions.begin() as session: + return SQLAlchemyEmailChannelRepository(session).update( + replace(current, encrypted_credentials=ChannelEncryptedCredentials(ciphertext="replacement")), + expected=current.snapshot, + now=naive_utc_now(), + ) + + def delete() -> DeleteEmailConfigurationResult: + barrier.wait() + with sessions.begin() as session: + return SQLAlchemyEmailChannelRepository(session).delete(tenant_id, expected=current.snapshot) + + with ThreadPoolExecutor(max_workers=2) as executor: + update_future = executor.submit(update) + delete_future = executor.submit(delete) + update_result = update_future.result() + delete_result = delete_future.result() + + with sessions() as session: + persisted = SQLAlchemyEmailChannelRepository(session).load(tenant_id) + if update_result.status is UpdateEmailConfigurationStatus.UPDATED: + assert delete_result.status is DeleteEmailConfigurationStatus.STALE + assert update_result.configuration is not None + assert persisted == update_result.configuration + else: + assert update_result.status is UpdateEmailConfigurationStatus.STALE + assert delete_result.status is DeleteEmailConfigurationStatus.DELETED + assert persisted is None + + +def test_delete_recreate_rejects_previous_identity(db_session_with_containers: Session) -> None: + sessions, tenant_id = _sessions(db_session_with_containers) + with sessions.begin() as session: + deleted = SQLAlchemyEmailChannelRepository(session).create(_configuration(tenant_id)).configuration + assert deleted is not None + with sessions.begin() as session: + SQLAlchemyEmailChannelRepository(session).delete(tenant_id, expected=deleted.snapshot) + with sessions.begin() as session: + recreated = SQLAlchemyEmailChannelRepository(session).create(_configuration(tenant_id)).configuration + assert recreated is not None + + with sessions.begin() as session: + repository = SQLAlchemyEmailChannelRepository(session) + stale = repository.update( + replace(deleted, encrypted_credentials=ChannelEncryptedCredentials(ciphertext="stale")), + expected=deleted.snapshot, + now=naive_utc_now(), + ) + assert stale.status is UpdateEmailConfigurationStatus.STALE + assert repository.load(tenant_id) == recreated diff --git a/api/tests/e2e_tests/containers/repositories/human_input_v2/test_im_channel_repository.py b/api/tests/e2e_tests/containers/repositories/human_input_v2/test_im_channel_repository.py new file mode 100644 index 00000000000..0ba2fd6f067 --- /dev/null +++ b/api/tests/e2e_tests/containers/repositories/human_input_v2/test_im_channel_repository.py @@ -0,0 +1,150 @@ +"""Supported-database concurrency contracts for IM Channel creation.""" + +from __future__ import annotations + +from collections.abc import Callable, Generator +from concurrent.futures import ThreadPoolExecutor +from threading import Barrier +from uuid import uuid4 + +import pytest +import sqlalchemy as sa +from sqlalchemy.engine import Engine +from sqlalchemy.orm import Session, sessionmaker + +from core.human_input_v2.entities import IMProvider +from core.human_input_v2.shared import AccountId, TenantId +from libs.datetime_utils import naive_utc_now +from models.human_input_v2 import HumanInputIMChannel +from repositories.human_input_v2.channel_credentials import ChannelEncryptedCredentials +from repositories.human_input_v2.im_channel_repository import ( + IMChannel, + IMChannelAlreadyConfiguredError, + IMChannelId, + IMChannelStatus, + IMChannelWriter, + WebhookId, +) +from repositories.human_input_v2.sqlalchemy_im_channel_repository import ( + DeploymentIMChannelWriter, + WorkspaceIMChannelWriter, +) + +type _WriterFactory = Callable[[Session], IMChannelWriter] + + +@pytest.fixture(params=("postgresql", "mysql"), scope="session") +def im_channel_engine(request: pytest.FixtureRequest) -> Generator[Engine]: + if request.param == "postgresql": + testcontainers_postgres = pytest.importorskip("testcontainers.postgres") + container = testcontainers_postgres.PostgresContainer("postgres:15-alpine") + else: + testcontainers_mysql = pytest.importorskip("testcontainers.mysql") + container = testcontainers_mysql.MySqlContainer("mysql:8.0") + + container.start() + raw_url = container.get_connection_url() + engine = sa.create_engine(raw_url.replace("mysql://", "mysql+pymysql://", 1)) + channel_table = HumanInputIMChannel.metadata.tables[HumanInputIMChannel.__tablename__] + try: + HumanInputIMChannel.metadata.create_all(engine, tables=[channel_table]) + yield engine + finally: + HumanInputIMChannel.metadata.drop_all(engine, tables=[channel_table]) + engine.dispose() + container.stop() + + +@pytest.fixture(autouse=True) +def clean_im_channel_table(im_channel_engine: Engine) -> Generator[None]: + with im_channel_engine.begin() as connection: + connection.execute(sa.delete(HumanInputIMChannel)) + yield + with im_channel_engine.begin() as connection: + connection.execute(sa.delete(HumanInputIMChannel)) + + +def _channel() -> IMChannel: + now = naive_utc_now() + return IMChannel( + id=IMChannelId(str(uuid4())), + created_at=now, + updated_at=now, + provider=IMProvider.FEISHU, + provider_tenant_id=f"provider-tenant-{uuid4()}", + encrypted_credentials=ChannelEncryptedCredentials(ciphertext=f"opaque-{uuid4()}"), + app_identifier=f"app-{uuid4()}", + webhook_id=WebhookId(uuid4().hex), + config_version=1, + status=IMChannelStatus.CONNECTED, + ) + + +def _concurrent_create_outcomes( + engine: Engine, + writer_factories: tuple[_WriterFactory, _WriterFactory], +) -> tuple[type[IMChannelAlreadyConfiguredError] | None, ...]: + sessions = sessionmaker(bind=engine, expire_on_commit=False) + barrier = Barrier(2) + + def create(index: int) -> type[IMChannelAlreadyConfiguredError] | None: + try: + with sessions.begin() as session: + barrier.wait() + writer_factories[index](session).create(_channel()) + except IMChannelAlreadyConfiguredError as error: + return type(error) + return None + + with ThreadPoolExecutor(max_workers=2) as executor: + return tuple(executor.map(create, range(2))) + + +def test_same_workspace_concurrent_create_has_one_committed_winner( + im_channel_engine: Engine, +) -> None: + tenant_id = TenantId(str(uuid4())) + writer_factories: tuple[_WriterFactory, _WriterFactory] = ( + lambda session: WorkspaceIMChannelWriter(session, tenant_id, AccountId(str(uuid4()))), + lambda session: WorkspaceIMChannelWriter(session, tenant_id, AccountId(str(uuid4()))), + ) + + outcomes = _concurrent_create_outcomes(im_channel_engine, writer_factories) + + assert outcomes.count(None) == 1 + assert outcomes.count(IMChannelAlreadyConfiguredError) == 1 + with Session(im_channel_engine) as session: + assert session.scalar(sa.select(sa.func.count(HumanInputIMChannel.id))) == 1 + + +def test_different_workspaces_concurrent_create_both_commit( + im_channel_engine: Engine, +) -> None: + tenant_ids = (TenantId(str(uuid4())), TenantId(str(uuid4()))) + writer_factories: tuple[_WriterFactory, _WriterFactory] = ( + lambda session: WorkspaceIMChannelWriter(session, tenant_ids[0], AccountId(str(uuid4()))), + lambda session: WorkspaceIMChannelWriter(session, tenant_ids[1], AccountId(str(uuid4()))), + ) + + outcomes = _concurrent_create_outcomes(im_channel_engine, writer_factories) + + assert outcomes == (None, None) + with Session(im_channel_engine) as session: + owner_keys = set(session.scalars(sa.select(HumanInputIMChannel.owner_key))) + assert owner_keys == {f"workspace:{tenant_id}" for tenant_id in tenant_ids} + + +def test_deployment_concurrent_create_has_one_committed_winner( + im_channel_engine: Engine, +) -> None: + writer_factories: tuple[_WriterFactory, _WriterFactory] = ( + DeploymentIMChannelWriter, + DeploymentIMChannelWriter, + ) + + outcomes = _concurrent_create_outcomes(im_channel_engine, writer_factories) + + assert outcomes.count(None) == 1 + assert outcomes.count(IMChannelAlreadyConfiguredError) == 1 + with Session(im_channel_engine) as session: + assert session.scalar(sa.select(HumanInputIMChannel.owner_key)) == "deployment" diff --git a/api/tests/e2e_tests/containers/repositories/human_input_v2/test_im_message_inbox_repository.py b/api/tests/e2e_tests/containers/repositories/human_input_v2/test_im_message_inbox_repository.py new file mode 100644 index 00000000000..f0547a85844 --- /dev/null +++ b/api/tests/e2e_tests/containers/repositories/human_input_v2/test_im_message_inbox_repository.py @@ -0,0 +1,168 @@ +"""PostgreSQL tests for durable IM callback persistence.""" + +from __future__ import annotations + +from concurrent.futures import ThreadPoolExecutor +from datetime import UTC, datetime +from threading import Barrier + +import pytest +import sqlalchemy as sa +from sqlalchemy.engine import Engine +from sqlalchemy.exc import DataError +from sqlalchemy.orm import Session, sessionmaker + +from core.human_input_v2.entities import IMProvider +from core.human_input_v2.im_integration.adapters import AuthenticatedIMEvent, IMEventIngressKind +from core.human_input_v2.im_message_inbox import AcceptanceKind, InboxEventValidationError +from models.human_input_v2 import IMMessageEvent +from repositories.human_input_v2.im_channel_repository import IMChannelId +from services.human_input_v2.im_message_inbox.storage import IMInboxStorage + +_NOW = datetime(2026, 8, 2, 8, tzinfo=UTC) +_RECEIVED_AT = datetime(2026, 8, 2, 8) +_CHANNEL_ID = IMChannelId("00000000-0000-0000-0000-000000000001") + + +def _event( + event_id: str | None, + *, + tenant_id: str = "tenant-1", + event_type: str | None = None, +) -> AuthenticatedIMEvent: + return AuthenticatedIMEvent( + provider=IMProvider.FEISHU, + provider_tenant_id=tenant_id, + event_id=event_id, + event_type=event_type, + occurred_at=None, + received_at=_RECEIVED_AT, + ingress_kind=IMEventIngressKind.WEBHOOK, + payload='{"secret":"must-not-log"}', + ) + + +@pytest.fixture +def postgres_context( + db_session_with_containers: Session, +) -> tuple[Engine, IMInboxStorage, sessionmaker[Session]]: + engine = db_session_with_containers.get_bind() + assert isinstance(engine, Engine) + assert engine.dialect.name == "postgresql" + session_maker = sessionmaker(bind=engine, expire_on_commit=False) + return engine, IMInboxStorage(session_maker), session_maker + + +@pytest.mark.parametrize("absent_event_id", [None, "", " \t\n"]) +def test_postgres_unidentified_callbacks_remain_independent( + postgres_context: tuple[Engine, IMInboxStorage, sessionmaker[Session]], + absent_event_id: str | None, +) -> None: + _, repository, session_maker = postgres_context + + first = repository.insert_or_resolve(_CHANNEL_ID, _event(absent_event_id), now=_NOW) + second = repository.insert_or_resolve(_CHANNEL_ID, _event(absent_event_id), now=_NOW) + + assert first.kind is AcceptanceKind.NEW + assert second.kind is AcceptanceKind.NEW + assert first.record_id != second.record_id + with session_maker() as session: + assert session.scalar(sa.select(sa.func.count(IMMessageEvent.id))) == 2 + + +def test_postgres_preserves_max_length_provider_metadata( + postgres_context: tuple[Engine, IMInboxStorage, sessionmaker[Session]], +) -> None: + _, repository, session_maker = postgres_context + tenant_id = "t" * 128 + event_id = "e" * 128 + event_type = "y" * 128 + + accepted = repository.insert_or_resolve( + _CHANNEL_ID, + _event(event_id, tenant_id=tenant_id, event_type=event_type), + now=_NOW, + ) + + with session_maker() as session: + stored = session.get_one(IMMessageEvent, str(accepted.record_id)) + assert stored.tenant_id == tenant_id + assert stored.event_id == event_id + assert stored.provider_event_type == event_type + + +def test_postgres_repository_rejects_oversized_provider_metadata_before_insert( + postgres_context: tuple[Engine, IMInboxStorage, sessionmaker[Session]], +) -> None: + _, repository, session_maker = postgres_context + + with pytest.raises(InboxEventValidationError): + repository.insert_or_resolve( + _CHANNEL_ID, + _event("event-1", tenant_id="t" * 129), + now=_NOW, + ) + + with session_maker() as session: + assert session.scalar(sa.select(sa.func.count(IMMessageEvent.id))) == 0 + + +def test_postgres_schema_rejects_oversized_provider_metadata( + postgres_context: tuple[Engine, IMInboxStorage, sessionmaker[Session]], +) -> None: + _, _, session_maker = postgres_context + + def insert_oversized_callback() -> None: + with session_maker.begin() as session: + session.add( + IMMessageEvent( + channel_id=str(_CHANNEL_ID), + provider=IMProvider.FEISHU, + tenant_id="t" * 129, + event_id="event-1", + received_at=_RECEIVED_AT, + ingress_kind=IMEventIngressKind.WEBHOOK, + payload="{}", + ) + ) + session.flush() + + with pytest.raises(DataError): + insert_oversized_callback() + + +@pytest.mark.parametrize("tenant_ids", [("tenant-1", "tenant-1"), ("tenant-1", "tenant-2")]) +def test_postgres_concurrent_identified_insert_resolves_one_record( + postgres_context: tuple[Engine, IMInboxStorage, sessionmaker[Session]], + tenant_ids: tuple[str, str], +) -> None: + _, repository, session_maker = postgres_context + barrier = Barrier(2) + + def insert_callback(tenant_id: str) -> tuple[AcceptanceKind, str]: + barrier.wait(timeout=5) + accepted = repository.insert_or_resolve(_CHANNEL_ID, _event("event-1", tenant_id=tenant_id), now=_NOW) + return accepted.kind, str(accepted.record_id) + + with ThreadPoolExecutor(max_workers=2) as executor: + futures = [executor.submit(insert_callback, tenant_id) for tenant_id in tenant_ids] + results = [future.result(timeout=5) for future in futures] + + assert {kind for kind, _ in results} == {AcceptanceKind.NEW, AcceptanceKind.DUPLICATE} + assert len({record_id for _, record_id in results}) == 1 + with session_maker() as session: + assert session.scalar(sa.select(sa.func.count(IMMessageEvent.id))) == 1 + + +def test_postgres_mark_processed_is_idempotent( + postgres_context: tuple[Engine, IMInboxStorage, sessionmaker[Session]], +) -> None: + _, repository, _ = postgres_context + accepted = repository.insert_or_resolve(_CHANNEL_ID, _event("event-1"), now=_NOW) + + repository.mark_processed(accepted.record_id, processed_at=_NOW) + repository.mark_processed(accepted.record_id, processed_at=_NOW.replace(minute=1)) + + stored = repository.get(accepted.record_id) + assert stored is not None + assert stored.processed_at == datetime(2026, 8, 2, 8) diff --git a/api/tests/e2e_tests/containers/repositories/test_sqlalchemy_api_workflow_run_repository.py b/api/tests/e2e_tests/containers/repositories/test_sqlalchemy_api_workflow_run_repository.py index 0b9ab1023dd..618d4af848c 100644 --- a/api/tests/e2e_tests/containers/repositories/test_sqlalchemy_api_workflow_run_repository.py +++ b/api/tests/e2e_tests/containers/repositories/test_sqlalchemy_api_workflow_run_repository.py @@ -94,7 +94,6 @@ def _unpersisted_pause(*, workflow_id: str, workflow_run_id: str) -> WorkflowPau workflow_run_id=workflow_run_id, state_object_key="unpersisted-state", ), - reason_models=[], ) @@ -360,7 +359,7 @@ class TestCreateWorkflowPause: assert workflow_run.status == WorkflowExecutionStatus.PAUSED assert pause_entity.id == pause_model.id assert pause_entity.workflow_execution_id == workflow_run.id - assert pause_entity.get_pause_reasons() == [] + assert repository.get_legacy_pause_reasons(db_session_with_containers, pause_entity.id) == [] assert pause_entity.get_state() == state.encode() def test_replaces_pause_when_previous_state_object_delete_fails( @@ -535,7 +534,7 @@ class TestCreateWorkflowPause: assert reason_models[0].form_id == form_model.id assert reason_models[0].node_id == "node-1" - pause_reasons = pause_entity.get_pause_reasons() + pause_reasons = repository.get_legacy_pause_reasons(db_session_with_containers, pause_entity.id) assert len(pause_reasons) == 1 reason = pause_reasons[0] @@ -549,7 +548,7 @@ class TestCreateWorkflowPause: reloaded_pause = repository.get_workflow_pause(workflow_run.id) assert reloaded_pause is not None - reloaded_reasons = reloaded_pause.get_pause_reasons() + reloaded_reasons = repository.get_legacy_pause_reasons(db_session_with_containers, reloaded_pause.id) assert len(reloaded_reasons) == 1 reloaded_reason = reloaded_reasons[0] assert isinstance(reloaded_reason, HumanInputRequired) @@ -623,7 +622,7 @@ class TestCreateWorkflowPause: assert raw_reason.session_id == form_model.id assert raw_reason.node_id == "node-1" - pause_reasons = pause_entity.get_pause_reasons() + pause_reasons = repository.get_legacy_pause_reasons(db_session_with_containers, pause_entity.id) assert len(pause_reasons) == 1 reason = pause_reasons[0] assert isinstance(reason, HumanInputRequired) @@ -685,7 +684,7 @@ class TestCreateWorkflowPause: pause_entity = repository.get_workflow_pause(workflow_run.id) assert pause_entity is not None - pause_reasons = pause_entity.get_pause_reasons() + pause_reasons = repository.get_legacy_pause_reasons(db_session_with_containers, pause_entity.id) assert len(pause_reasons) == 1 reason = pause_reasons[0] assert isinstance(reason, HumanInputRequired) @@ -862,7 +861,7 @@ class TestPrivateWorkflowPauseEntity: db_session_with_containers.refresh(pause) test_scope.state_keys.add(pause.state_object_key) - entity = _PrivateWorkflowPauseEntity(pause_model=pause, reason_models=[], human_input_form=[]) + entity = _PrivateWorkflowPauseEntity(pause_model=pause) assert entity.id == pause.id assert entity.workflow_execution_id == workflow_run.id @@ -894,7 +893,7 @@ class TestPrivateWorkflowPauseEntity: expected_state = b'{"test": "state"}' storage.save(state_key, expected_state) - entity = _PrivateWorkflowPauseEntity(pause_model=pause, reason_models=[], human_input_form=[]) + entity = _PrivateWorkflowPauseEntity(pause_model=pause) result = entity.get_state() assert result == expected_state @@ -925,7 +924,7 @@ class TestPrivateWorkflowPauseEntity: expected_state = b'{"test": "state"}' storage.save(state_key, expected_state) - entity = _PrivateWorkflowPauseEntity(pause_model=pause, reason_models=[], human_input_form=[]) + entity = _PrivateWorkflowPauseEntity(pause_model=pause) result1 = entity.get_state() # Delete from storage to prove second call uses cache storage.delete(state_key) diff --git a/api/tests/e2e_tests/containers/repositories/test_workflow_run_repository.py b/api/tests/e2e_tests/containers/repositories/test_workflow_run_repository.py index e8383f656b4..9b5013782b1 100644 --- a/api/tests/e2e_tests/containers/repositories/test_workflow_run_repository.py +++ b/api/tests/e2e_tests/containers/repositories/test_workflow_run_repository.py @@ -7,7 +7,7 @@ from datetime import timedelta from uuid import uuid4 import pytest -from sqlalchemy import Engine, delete +from sqlalchemy import Engine, delete, select from sqlalchemy import exc as sa_exc from sqlalchemy.orm import Session, sessionmaker @@ -93,6 +93,34 @@ def test_scope(db_session_with_containers: Session) -> _TestScope: _cleanup_scope_data(db_session_with_containers, scope) +def test_run_lock_lasts_until_the_callers_transaction_ends( + repository: DifyAPISQLAlchemyWorkflowRunRepository, + db_session_with_containers: Session, + test_scope: _TestScope, +) -> None: + run_id = _create_workflow_run(db_session_with_containers, test_scope, status=WorkflowExecutionStatus.PAUSED).id + sessions = sessionmaker(bind=db_session_with_containers.get_bind()) + lock_statement = select(WorkflowRun).where(WorkflowRun.id == run_id).with_for_update(nowait=True) + + with sessions() as session, session.begin(): + run = repository.get_workflow_run_by_id_for_update( + session, + tenant_id=test_scope.tenant_id, + app_id=test_scope.app_id, + run_id=run_id, + ) + assert run is not None + assert run.id == run_id + with sessions() as competing_session: + with pytest.raises(sa_exc.OperationalError, match="could not obtain lock"), competing_session.begin(): + competing_session.scalar(lock_statement) + + with sessions() as session, session.begin(): + run = session.scalar(lock_statement) + assert run is not None + assert run.id == run_id + + class TestGetPaginatedWorkflowRuns: """Integration tests for get_paginated_workflow_runs.""" diff --git a/api/tests/e2e_tests/containers/services/test_message_service_execution_extra_content.py b/api/tests/e2e_tests/containers/services/test_message_service_execution_extra_content.py index 316d9d2d768..126a956f541 100644 --- a/api/tests/e2e_tests/containers/services/test_message_service_execution_extra_content.py +++ b/api/tests/e2e_tests/containers/services/test_message_service_execution_extra_content.py @@ -23,7 +23,8 @@ def test_pagination_returns_extra_contents(db_session_with_containers: Session) messages=MessageRepository(session_factory=sessions), files=MessageFileResolver(), extra_contents=ExecutionExtraContentReader( - repository=SQLAlchemyExecutionExtraContentRepository(session_maker=sessions) + repository=SQLAlchemyExecutionExtraContentRepository(session_maker=sessions), + session_factory=sessions, ), ) pagination = service.get_page( @@ -71,7 +72,8 @@ def test_pagination_returns_waiting_human_input_extra_contents(db_session_with_c messages=MessageRepository(session_factory=sessions), files=MessageFileResolver(), extra_contents=ExecutionExtraContentReader( - repository=SQLAlchemyExecutionExtraContentRepository(session_maker=sessions) + repository=SQLAlchemyExecutionExtraContentRepository(session_maker=sessions), + session_factory=sessions, ), ) pagination = service.get_page( diff --git a/api/tests/e2e_tests/containers/services/test_message_service_extra_contents.py b/api/tests/e2e_tests/containers/services/test_message_service_extra_contents.py index c547b944ea5..baec028ead7 100644 --- a/api/tests/e2e_tests/containers/services/test_message_service_extra_contents.py +++ b/api/tests/e2e_tests/containers/services/test_message_service_extra_contents.py @@ -53,7 +53,8 @@ def test_message_details_include_serialized_extra_contents(db_session_with_conta messages=MessageRepository(session_factory=sessions), files=MessageFileResolver(), extra_contents=ExecutionExtraContentReader( - repository=SQLAlchemyExecutionExtraContentRepository(session_maker=sessions) + repository=SQLAlchemyExecutionExtraContentRepository(session_maker=sessions), + session_factory=sessions, ), ) messages = [ @@ -75,6 +76,7 @@ def test_message_details_include_serialized_extra_contents(db_session_with_conta "submitted": True, "form_definition": { "form_id": form.id, + "form_version": "1", "node_id": form.node_id, "node_title": "Approval", "form_content": "Rendered block", diff --git a/api/tests/e2e_tests/containers/services/test_workflow_run_service.py b/api/tests/e2e_tests/containers/services/test_workflow_run_service.py index 0c1642b7ac7..b522e713116 100644 --- a/api/tests/e2e_tests/containers/services/test_workflow_run_service.py +++ b/api/tests/e2e_tests/containers/services/test_workflow_run_service.py @@ -37,6 +37,7 @@ class TestWorkflowRunService: workflow_runs = DifyAPISQLAlchemyWorkflowRunRepository(session_maker=session_factory) return WorkflowRunService( workflow_runs=workflow_runs, + session_factory=session_factory, node_executions=DifyAPIRepositoryFactory.create_api_workflow_node_execution_repository( session_maker=session_factory ), diff --git a/api/tests/e2e_tests/containers/services/workflow/test_workflow_event_snapshot_service.py b/api/tests/e2e_tests/containers/services/workflow/test_workflow_event_snapshot_service.py index ca8d6e294c4..a055c2f618f 100644 --- a/api/tests/e2e_tests/containers/services/workflow/test_workflow_event_snapshot_service.py +++ b/api/tests/e2e_tests/containers/services/workflow/test_workflow_event_snapshot_service.py @@ -1,6 +1,5 @@ from __future__ import annotations -from collections.abc import Sequence from dataclasses import dataclass from datetime import UTC, datetime, timedelta from typing import override @@ -12,17 +11,23 @@ from sqlalchemy.orm import Session, sessionmaker from core.app.app_config.entities import WorkflowUIBasedAppConfig from core.app.entities.app_invoke_entities import InvokeFrom, UserFrom, WorkflowAppGenerateEntity from core.app.layers.pause_state_persist_layer import WorkflowResumptionContext, _WorkflowGenerateEntityWrapper -from core.workflow.nodes.human_input.entities import SelectInputConfig, StringListSource, UserActionConfig +from core.workflow.nodes.human_input.entities import ( + FormDefinition, + SelectInputConfig, + StringListSource, + UserActionConfig, +) from core.workflow.nodes.human_input.enums import HumanInputFormStatus, ValueSourceType from core.workflow.nodes.human_input.pause_reason import HumanInputRequired +from graphon.entities.pause_reason import HitlRequired from graphon.enums import WorkflowExecutionStatus from graphon.runtime import RuntimeState, VariablePool from models.enums import CreatorUserRole, WorkflowRunTriggeredFrom from models.human_input import HumanInputForm from models.model import AppMode -from models.workflow import WorkflowRun, WorkflowType +from models.workflow import WorkflowPauseReason, WorkflowRun, WorkflowType from repositories.entities.workflow_pause import WorkflowPauseEntity -from services.workflow_event_snapshot_service import _build_snapshot_events +from services.workflow_event_snapshot_service import _build_paused_snapshot_events @dataclass(frozen=True) @@ -30,7 +35,6 @@ class _FakePauseEntity(WorkflowPauseEntity): pause_id: str workflow_run_id: str paused_at_value: datetime - pause_reasons: Sequence[HumanInputRequired] @property @override @@ -56,12 +60,8 @@ class _FakePauseEntity(WorkflowPauseEntity): def paused_at(self) -> datetime: return self.paused_at_value - @override - def get_pause_reasons(self) -> Sequence[HumanInputRequired]: - return self.pause_reasons - -def _build_resumption_context(workflow_run_id: str) -> WorkflowResumptionContext: +def _build_resumption_context(workflow_run_id: str, form_id: str) -> WorkflowResumptionContext: app_config = WorkflowUIBasedAppConfig( tenant_id=str(uuid4()), app_id=str(uuid4()), @@ -82,6 +82,7 @@ def _build_resumption_context(workflow_run_id: str) -> WorkflowResumptionContext ) runtime_state = RuntimeState(workflow_id="test-workflow", variable_pool=VariablePool(), start_at=0.0) runtime_state.variable_pool.add(("start", "options"), ["approve", "reject"]) + runtime_state.graph_execution.pause(HitlRequired(session_id=form_id, node_id="node-id", node_title="Human Input")) wrapper = _WorkflowGenerateEntityWrapper(entity=generate_entity) return WorkflowResumptionContext( generate_entity=wrapper, @@ -154,17 +155,27 @@ def test_build_snapshot_events_resolves_variable_select_options(db_session_with_ pause_id=str(uuid4()), workflow_run_id=workflow_run_id, paused_at_value=datetime.now(UTC), - pause_reasons=[reason], ) + form.form_definition = FormDefinition( + form_content=reason.form_content, + rendered_content=reason.form_content, + inputs=reason.inputs, + user_actions=reason.actions, + expiration_time=form.expiration_time, + display_in_ui=True, + ).model_dump_json() + db_session_with_containers.add(WorkflowPauseReason.from_entity(pause_id=pause_entity.id, pause_reason=reason)) + db_session_with_containers.commit() + session_maker = sessionmaker(bind=engine, expire_on_commit=False) - events = _build_snapshot_events( + events = _build_paused_snapshot_events( workflow_run=_build_workflow_run(workflow_run_id), node_snapshots=[], task_id="task-1", message_context=None, pause_entity=pause_entity, - resumption_context=_build_resumption_context(workflow_run_id), + resumption_context=_build_resumption_context(workflow_run_id, form.id), session_maker=session_maker, node_executions={}, ) @@ -173,5 +184,8 @@ def test_build_snapshot_events_resolves_variable_select_options(db_session_with_ assert len(human_input_events) == 1 assert human_input_events[0]["data"]["inputs"][0]["option_source"]["value"] == ["approve", "reject"] + db_session_with_containers.execute( + delete(WorkflowPauseReason).where(WorkflowPauseReason.pause_id == pause_entity.id) + ) db_session_with_containers.execute(delete(HumanInputForm).where(HumanInputForm.id == form.id)) db_session_with_containers.commit() diff --git a/api/tests/e2e_tests/containers/tasks/test_delete_account_task.py b/api/tests/e2e_tests/containers/tasks/test_delete_account_task.py index bbb95b0d233..933fdcc36d5 100644 --- a/api/tests/e2e_tests/containers/tasks/test_delete_account_task.py +++ b/api/tests/e2e_tests/containers/tasks/test_delete_account_task.py @@ -66,8 +66,11 @@ def test_community_account_exists_sends_email_only( mail_task.delay.assert_called_once_with(account.email) +@pytest.mark.usefixtures("db_session_with_containers") def test_cloud_account_not_found_calls_billing_no_email( - mock_external_dependencies: tuple[MagicMock, MagicMock], mocker: MockerFixture, caplog: LogCaptureFixture + mock_external_dependencies: tuple[MagicMock, MagicMock], + mocker: MockerFixture, + caplog: LogCaptureFixture, ) -> None: billing_service, mail_task = mock_external_dependencies account_id = str(uuid4()) diff --git a/api/tests/e2e_tests/containers/test_container_fixtures.py b/api/tests/e2e_tests/containers/test_container_fixtures.py new file mode 100644 index 00000000000..c89555dc550 --- /dev/null +++ b/api/tests/e2e_tests/containers/test_container_fixtures.py @@ -0,0 +1,101 @@ +from __future__ import annotations + +import inspect +import os +from types import TracebackType +from typing import Self + +import pytest + +from tests.e2e_tests.containers import conftest as container_conftest + + +class _FakePostgresContainer: + username = "postgres" + password = "postgres" + dbname = "test" + + def __init__(self) -> None: + self.events: list[str] = [] + self.tmpfs: dict[str, str | None] = {} + + def with_network(self, _network: object) -> Self: + return self + + def with_tmpfs_mount(self, _container_path: str, size: str | None = None) -> Self: + self.tmpfs[_container_path] = size + return self + + def waiting_for(self, _strategy: object) -> Self: + return self + + def __enter__(self) -> Self: + self.events.append("enter") + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: TracebackType | None, + ) -> None: + self.events.append("exit") + + def get_container_host_ip(self) -> str: + return "127.0.0.1" + + def get_exposed_port(self, port: int) -> int: + return port + + +def test_postgres_fixture_stops_container_when_database_initialization_fails( + monkeypatch: pytest.MonkeyPatch, +) -> None: + postgres = _FakePostgresContainer() + monkeypatch.setattr(container_conftest, "PostgresContainer", lambda **_: postgres) + + def raise_initialization_error(**_: object) -> None: + raise RuntimeError("database initialization failed") + + monkeypatch.setattr(container_conftest.psycopg2, "connect", raise_initialization_error) + + fixture_function = inspect.unwrap(container_conftest.postgres_container) + fixture_iterator = fixture_function(object()) + + with pytest.raises(RuntimeError, match="database initialization failed"): + next(fixture_iterator) + + assert postgres.events == ["enter", "exit"] + + +def test_redis_fixture_restores_connection_environment(monkeypatch: pytest.MonkeyPatch) -> None: + redis = _FakePostgresContainer() + monkeypatch.setattr(container_conftest, "RedisContainer", lambda **_: redis) + monkeypatch.setenv("REDIS_USERNAME", "caller-user") + monkeypatch.setenv("REDIS_PASSWORD", "caller-password") + + fixture_function = inspect.unwrap(container_conftest.redis_container) + fixture_iterator = fixture_function(object()) + assert next(fixture_iterator) is redis + + assert os.environ["REDIS_USERNAME"] == "" + assert os.environ["REDIS_PASSWORD"] == "" + + with pytest.raises(StopIteration): + next(fixture_iterator) + + assert os.environ["REDIS_USERNAME"] == "caller-user" + assert os.environ["REDIS_PASSWORD"] == "caller-password" + + +def test_container_environment_fixture_composes_resource_fixtures() -> None: + fixture_function = inspect.unwrap(container_conftest.set_up_containers_and_env) + fixture_parameters = inspect.signature(fixture_function).parameters + + assert set(fixture_parameters) == { + "postgres_container", + "redis_container", + "dify_sandbox_container", + "dify_plugin_daemon_container", + "storage_environment", + } diff --git a/api/tests/e2e_tests/containers/test_workflow_pause_integration.py b/api/tests/e2e_tests/containers/test_workflow_pause_integration.py index dc7c228675f..d6dee5b6041 100644 --- a/api/tests/e2e_tests/containers/test_workflow_pause_integration.py +++ b/api/tests/e2e_tests/containers/test_workflow_pause_integration.py @@ -337,7 +337,7 @@ class TestWorkflowPauseIntegration: assert pause_entity is not None assert pause_entity.id is not None assert pause_entity.workflow_execution_id == workflow_run.id - assert list(pause_entity.get_pause_reasons()) == [] + assert list(repository.get_legacy_pause_reasons(self.session, pause_entity.id)) == [] # Convert both to strings for comparison retrieved_state = pause_entity.get_state() if isinstance(retrieved_state, bytes): @@ -364,7 +364,7 @@ class TestWorkflowPauseIntegration: if isinstance(retrieved_state, bytes): retrieved_state = retrieved_state.decode() assert retrieved_state == test_state - assert list(retrieved_entity.get_pause_reasons()) == [] + assert list(repository.get_legacy_pause_reasons(self.session, retrieved_entity.id)) == [] # Act - Resume workflow resumed_entity = repository.resume_workflow_pause( diff --git a/api/tests/e2e_tests/containers/trigger/test_trigger_e2e.py b/api/tests/e2e_tests/containers/trigger/test_trigger_e2e.py index 29b784c02e5..9519c114da7 100644 --- a/api/tests/e2e_tests/containers/trigger/test_trigger_e2e.py +++ b/api/tests/e2e_tests/containers/trigger/test_trigger_e2e.py @@ -278,7 +278,9 @@ def test_schedule_poll_dispatches_due_plan( assert plan.id in {schedule_id for schedule_id, _ in schedule_task.calls} -def test_schedule_visual_debug_poll_generates_event(monkeypatch: pytest.MonkeyPatch) -> None: +def test_schedule_visual_debug_poll_generates_event( + flask_app_with_containers: Flask, monkeypatch: pytest.MonkeyPatch +) -> None: """Visual mode schedule node should generate event in single-step debug.""" base_now = naive_utc_now() monkeypatch.setattr(event_selectors, "naive_utc_now", lambda: base_now) diff --git a/api/tests/e2e_tests/contracts/core/workflow/entities/test_private_workflow_pause.py b/api/tests/e2e_tests/contracts/core/workflow/entities/test_private_workflow_pause.py index 5dde9985d78..aedfc68ea52 100644 --- a/api/tests/e2e_tests/contracts/core/workflow/entities/test_private_workflow_pause.py +++ b/api/tests/e2e_tests/contracts/core/workflow/entities/test_private_workflow_pause.py @@ -32,7 +32,7 @@ class TestPrivateWorkflowPauseEntity: pause_model = _make_workflow_pause() # Create entity - entity = _PrivateWorkflowPauseEntity(pause_model=pause_model, reason_models=[], human_input_form=[]) + entity = _PrivateWorkflowPauseEntity(pause_model=pause_model) # Verify initialization assert entity._pause_model is pause_model @@ -42,7 +42,7 @@ class TestPrivateWorkflowPauseEntity: """Test id property returns pause model ID.""" pause_model = _make_workflow_pause() - entity = _PrivateWorkflowPauseEntity(pause_model=pause_model, reason_models=[], human_input_form=[]) + entity = _PrivateWorkflowPauseEntity(pause_model=pause_model) assert entity.id == "pause-123" @@ -50,7 +50,7 @@ class TestPrivateWorkflowPauseEntity: """Test workflow_execution_id property returns workflow run ID.""" pause_model = _make_workflow_pause() - entity = _PrivateWorkflowPauseEntity(pause_model=pause_model, reason_models=[], human_input_form=[]) + entity = _PrivateWorkflowPauseEntity(pause_model=pause_model) assert entity.workflow_execution_id == "execution-456" @@ -60,7 +60,7 @@ class TestPrivateWorkflowPauseEntity: pause_model = _make_workflow_pause(resumed_at=resumed_at) - entity = _PrivateWorkflowPauseEntity(pause_model=pause_model, reason_models=[], human_input_form=[]) + entity = _PrivateWorkflowPauseEntity(pause_model=pause_model) assert entity.resumed_at == resumed_at @@ -68,7 +68,7 @@ class TestPrivateWorkflowPauseEntity: """Test resumed_at property returns None when not set.""" pause_model = _make_workflow_pause() - entity = _PrivateWorkflowPauseEntity(pause_model=pause_model, reason_models=[], human_input_form=[]) + entity = _PrivateWorkflowPauseEntity(pause_model=pause_model) assert entity.resumed_at is None @@ -80,7 +80,7 @@ class TestPrivateWorkflowPauseEntity: pause_model = _make_workflow_pause() - entity = _PrivateWorkflowPauseEntity(pause_model=pause_model, reason_models=[], human_input_form=[]) + entity = _PrivateWorkflowPauseEntity(pause_model=pause_model) # First call should load from storage result = entity.get_state() @@ -97,7 +97,7 @@ class TestPrivateWorkflowPauseEntity: pause_model = _make_workflow_pause() - entity = _PrivateWorkflowPauseEntity(pause_model=pause_model, reason_models=[], human_input_form=[]) + entity = _PrivateWorkflowPauseEntity(pause_model=pause_model) # First call result1 = entity.get_state() @@ -116,7 +116,7 @@ class TestPrivateWorkflowPauseEntity: pause_model = _make_workflow_pause() - entity = _PrivateWorkflowPauseEntity(pause_model=pause_model, reason_models=[], human_input_form=[]) + entity = _PrivateWorkflowPauseEntity(pause_model=pause_model) # Pre-cache data entity._cached_state = state_data @@ -137,7 +137,7 @@ class TestPrivateWorkflowPauseEntity: pause_model = _make_workflow_pause() - entity = _PrivateWorkflowPauseEntity(pause_model=pause_model, reason_models=[], human_input_form=[]) + entity = _PrivateWorkflowPauseEntity(pause_model=pause_model) result = entity.get_state() diff --git a/api/tests/e2e_tests/contracts/dev/test_knowledge_fs_ci_workflow.py b/api/tests/e2e_tests/contracts/dev/test_knowledge_fs_ci_workflow.py index af95abe25c1..72d25a03dac 100644 --- a/api/tests/e2e_tests/contracts/dev/test_knowledge_fs_ci_workflow.py +++ b/api/tests/e2e_tests/contracts/dev/test_knowledge_fs_ci_workflow.py @@ -92,7 +92,6 @@ def test_dify_gate_target_collection_includes_all_owned_integration_files(tmp_pa assert { "api/tests/e2e_tests/contracts/dev/test_knowledge_fs_ci_workflow.py", "api/tests/unit_tests/services/knowledge_fs/test_service_query_image_upload.py", - "api/tests/e2e_tests/contracts/migrations/test_knowledge_fs_migration_chain.py", } <= tests assert all((ROOT / path).is_file() for path in production | tests) for name in ( diff --git a/api/tests/e2e_tests/contracts/migrations/test_knowledge_fs_migration_chain.py b/api/tests/e2e_tests/contracts/migrations/test_knowledge_fs_migration_chain.py deleted file mode 100644 index 4f8fd56ac27..00000000000 --- a/api/tests/e2e_tests/contracts/migrations/test_knowledge_fs_migration_chain.py +++ /dev/null @@ -1,62 +0,0 @@ -from __future__ import annotations - -from pathlib import Path - -from alembic.script import ScriptDirectory - -_VERSIONS = Path(__file__).resolve().parents[4] / "migrations/versions" -_MAIN_HEAD = "d6b9e2f1a407" -# This fixed KnowledgeFS interval runs after main's historical head, without merge revisions. -# Later migrations may extend the chain beyond this interval. -_KNOWLEDGE_FS_CHAIN = [ - "a4e7c2f91b30", - "b7f2a9d41c60", - "c8e31b7d52a4", - "d4f6e8a1c305", - "e5a7c9b2d416", - "7c1e9a4b2d60", - "9d4e6f8a1b2c", - "4f8b2c7d9e10", - "f3a8c1d7e920", - "e6b4a2c9d731", - "9a4e7d1c2b60", - "b6e2c4d8f1a0", - "3edd6cd27f54", - "a8c2e6f4b901", - "b9d3f7a5c102", -] - - -def _scripts() -> ScriptDirectory: - return ScriptDirectory(str(_VERSIONS.parent)) - - -def test_migration_graph_has_a_single_head() -> None: - assert len(_scripts().get_heads()) == 1 - - -def test_latest_head_includes_knowledge_fs_chain() -> None: - scripts = _scripts() - head = scripts.get_current_head() - assert head is not None - ancestors = {revision.revision for revision in scripts.iterate_revisions(head, "base")} - assert _KNOWLEDGE_FS_CHAIN[-1] in ancestors - - -def test_upgrade_from_main_head_applies_knowledge_fs_chain_linearly() -> None: - scripts = _scripts() - pending = [ - revision.revision for revision in reversed(list(scripts.iterate_revisions(_KNOWLEDGE_FS_CHAIN[-1], _MAIN_HEAD))) - ] - - assert pending == _KNOWLEDGE_FS_CHAIN - for parent, revision in zip([_MAIN_HEAD, *_KNOWLEDGE_FS_CHAIN[:-1]], _KNOWLEDGE_FS_CHAIN, strict=True): - script = scripts.get_revision(revision) - assert script is not None - assert script.down_revision == parent - - -def test_clean_legacy_agent_soul_files_keeps_main_parent() -> None: - current = _scripts().get_revision("fbdfcf5f5a6e") - assert current is not None - assert current.down_revision == "89919253ca7a" diff --git a/api/tests/e2e_tests/contracts/test_sqlite_fixtures.py b/api/tests/e2e_tests/contracts/test_sqlite_fixtures.py index 11a60b407c7..dd5f0f7c5d9 100644 --- a/api/tests/e2e_tests/contracts/test_sqlite_fixtures.py +++ b/api/tests/e2e_tests/contracts/test_sqlite_fixtures.py @@ -3,7 +3,7 @@ from pathlib import Path from threading import Barrier import pytest -from sqlalchemy import create_engine, inspect, text +from sqlalchemy import create_engine, inspect, select, text from sqlalchemy.engine import URL, Engine from sqlalchemy.exc import UnboundExecutionError from sqlalchemy.orm import Session, sessionmaker @@ -36,12 +36,13 @@ def test_sqlite_engine_is_a_pristine_file_copy( assert isinstance(sqlite_engine.pool, QueuePool) assert sqlite_engine.url.database != str(sqlite_database_template) - with sqlite_engine.begin() as connection: - connection.execute(text("CREATE TABLE per_test_mutation (value INTEGER NOT NULL)")) + with Session(sqlite_engine) as session, session.begin(): + session.add(Account(name="Copy probe", email="copy-probe@example.com")) template_engine = create_engine(URL.create("sqlite", database=str(sqlite_database_template))) try: - assert not inspect(template_engine).has_table("per_test_mutation") + with Session(template_engine) as session: + assert session.scalar(select(Account).where(Account.email == "copy-probe@example.com")) is None finally: template_engine.dispose() @@ -52,11 +53,12 @@ def test_core_session_factory_uses_the_shared_sqlite_session_factory( assert session_factory_module.session_factory.get_session_maker() is sqlite_session_factory with sqlite_session_factory.begin() as session: - session.execute(text("CREATE TABLE global_factory_probe (value INTEGER NOT NULL)")) - session.execute(text("INSERT INTO global_factory_probe (value) VALUES (42)")) + session.add(Account(name="Global factory probe", email="global-probe@example.com")) with session_factory_module.session_factory.create_session() as session: - assert session.scalar(text("SELECT value FROM global_factory_probe")) == 42 + assert session.scalar(select(Account.name).where(Account.email == "global-probe@example.com")) == ( + "Global factory probe" + ) def test_unbound_session_factory_disables_explicit_and_global_database_access( @@ -82,16 +84,15 @@ def test_sqlite_session_factory_shares_one_database_across_worker_sessions( sqlite_session_factory: sessionmaker[Session], ) -> None: with sqlite_session_factory.begin() as session: - session.execute(text("CREATE TABLE thread_probe (value INTEGER NOT NULL)")) - session.execute(text("INSERT INTO thread_probe (value) VALUES (42)")) + session.add(Account(name="Thread probe", email="thread-probe@example.com")) worker_barrier = Barrier(2) - def read_value() -> tuple[int, int]: + def read_value() -> tuple[int, str | None]: with sqlite_session_factory() as session: connection = session.connection() worker_barrier.wait(timeout=1) - value = session.scalar(text("SELECT value FROM thread_probe")) + value = session.scalar(select(Account.name).where(Account.email == "thread-probe@example.com")) connection_id = id(connection.connection.dbapi_connection) return connection_id, value @@ -99,5 +100,5 @@ def test_sqlite_session_factory_shares_one_database_across_worker_sessions( futures = [executor.submit(read_value) for _ in range(2)] results = [future.result() for future in futures] - assert {value for _, value in results} == {42} + assert {value for _, value in results} == {"Thread probe"} assert len({connection_id for connection_id, _ in results}) == 2 diff --git a/api/tests/fixtures/im_provider/README.md b/api/tests/fixtures/im_provider/README.md new file mode 100644 index 00000000000..3350f3fcdc4 --- /dev/null +++ b/api/tests/fixtures/im_provider/README.md @@ -0,0 +1,51 @@ +# IM provider response fixtures + +`feishu_lark/sanitized_directory_user.json` and +`slack/sanitized_directory_user.json` are redacted user objects extracted from +real directory HTTP responses. Object keys, nesting, scalar types, booleans, +empty strings, and field presence are preserved; nonempty strings are replaced. +These are not unmodified captures. + +Local source captures (not committed): + +- Feishu: `temp/fixtures/feishu-025-directory-read.json`, captured 2026-09-20. + The response includes `avatar_72`, `avatar_240`, `avatar_640`, and `avatar_origin`. +- Slack: `temp/fixtures/slack-005-directory-read.json`. + The response includes `profile.image_24` through `profile.image_512`. + +Teams photo requests were also captured on 2026-09-20 under `temp/fixtures/`. +Initially all three users returned HTTP 404 `ImageNotFound` for both `/photo` +and `/photo/$value`. After the user set a photo, a follow-up capture succeeded: + +- `ms_teams-028-avatar-metadata.json`: HTTP 200, 394 × 394 photo metadata. +- `ms_teams-029-avatar-read.json`: HTTP 200, `image/jpeg`, 33,448 bytes. + The original response bytes are stored losslessly as base64 with + `body_encoding=base64` and verified against the HTTP response body. + +The Graph photo API requires bearer authentication and returns image bytes, +not a public image URL. The earlier 404 captures remain available as negative cases. + +`ms_teams/sanitized_avatar_not_found.json` is the redacted JSON response body +from `temp/fixtures/ms_teams-022-avatar-read.json`. Error codes and structure +are retained; other nonempty strings are replaced. Batch directory tests use +this case to verify that one missing photo does not discard other users' avatars. + +`ms_teams/sanitized_avatar_success.json` is derived from +`temp/fixtures/ms_teams-029-avatar-read.json`. It retains the HTTP 200 status, +`Content-Type: image/jpeg`, and the original 33,448 JPEG bytes encoded as base64. +The request and all other response headers are omitted to exclude credentials, +user identifiers, and request identifiers. The image has no EXIF metadata; its +bytes are unchanged (SHA-256: +`101888393d81d5b2dc6dacd4ff10cc1e6f4dd42011849ff30584dd00c55ed636`). +The Teams directory test replays this response through the avatar downloader +and checks that the directory entry contains the JPEG MIME type and exact bytes. + +Unmodified HTTP envelopes contain credentials and personal data and remain local. + + +## Lark + +Lark HTTP replay fixtures are owned by the corresponding unit tests. See +[the Lark fixture README](../../unit_tests/core/human_input_v2/im_provider/fixtures/lark/README.md) +for capture provenance and redaction details. Tests load the committed +`sanitized_protocol.json` in that directory. diff --git a/api/tests/fixtures/im_provider/dingtalk/sanitized_protocol.json b/api/tests/fixtures/im_provider/dingtalk/sanitized_protocol.json new file mode 100644 index 00000000000..09b2af81b44 --- /dev/null +++ b/api/tests/fixtures/im_provider/dingtalk/sanitized_protocol.json @@ -0,0 +1,192 @@ +{ + "credential_test": { + "headers": { + "content-type": "application/json", + "x-acs-request-id": "fake-request-credential-001" + }, + "statusCode": 200, + "body": { + "access_token": "fake-access-token-credential-001", + "expires_in": 7200 + } + }, + "authorization_scope": { + "request": { + "method": "GET", + "path": "/auth/scopes" + }, + "response": { + "errcode": 0, + "errmsg": "ok", + "auth_org_scopes": { + "authed_dept": [1], + "authed_user": [] + } + } + }, + "directory": [ + { + "request": { + "path": "/topapi/v2/department/listsub", + "body": { + "dept_id": 1 + } + }, + "response": { + "errcode": 0, + "errmsg": "ok", + "request_id": "fake-request-department-root-001", + "result": [ + { + "dept_id": 2, + "name": "Fake Engineering", + "parent_id": 1, + "create_dept_group": false, + "auto_approve_apply": false + } + ] + } + }, + { + "request": { + "path": "/topapi/v2/user/list", + "body": { + "dept_id": 1, + "cursor": 0, + "size": 100 + } + }, + "response": { + "errcode": 0, + "errmsg": "ok", + "request_id": "fake-request-user-root-001", + "result": { + "has_more": true, + "next_cursor": 9, + "list": [ + { + "userid": "fake-user-001", + "unionid": "fake-union-001", + "name": "Fake User One", + "avatar": "https://example.invalid/fake-user-001.png", + "state_code": "00", + "manager_userid": "fake-user-manager-001", + "mobile": "00000000000", + "hide_mobile": true, + "telephone": "000-0000", + "job_number": "fake-job-001", + "title": "Fake Engineer", + "email": "fake.user.one@example.invalid", + "work_place": "Fake Office", + "remark": "Fake fixture member", + "dept_id_list": [1, 2], + "extension": "{}", + "hired_date": 0, + "active": true, + "admin": false, + "boss": false, + "leader_in_dept": [{"dept_id": 1, "leader": false}], + "role_list": [{"id": 1, "name": "Fake Role", "group_name": "Fake Group"}], + "exclusive_account": false, + "login_id": "fake-login-001" + } + ] + } + } + }, + { + "request": { + "path": "/topapi/v2/user/list", + "body": { + "dept_id": 1, + "cursor": 9, + "size": 100 + } + }, + "response": { + "errcode": 0, + "errmsg": "ok", + "request_id": "fake-request-user-root-002", + "result": { + "has_more": false, + "next_cursor": 0, + "list": [ + { + "userid": "fake-user-002", + "active": false, + "admin": false, + "boss": false, + "dept_id_list": [1] + } + ] + } + } + }, + { + "request": { + "path": "/topapi/v2/department/listsub", + "body": { + "dept_id": 2 + } + }, + "response": { + "errcode": 0, + "errmsg": "ok", + "request_id": "fake-request-department-child-001", + "result": [] + } + }, + { + "request": { + "path": "/topapi/v2/user/list", + "body": { + "dept_id": 2, + "cursor": 0, + "size": 100 + } + }, + "response": { + "errcode": 0, + "errmsg": "ok", + "request_id": "fake-request-user-child-001", + "result": { + "has_more": false, + "next_cursor": 0, + "list": [ + { + "userid": "fake-user-002", + "name": "Fake Duplicate Must Not Replace First Seen", + "email": "fake.duplicate@example.invalid", + "active": false, + "admin": false, + "boss": false, + "dept_id_list": [2] + }, + { + "userid": "fake-user-003", + "name": " ", + "email": "", + "active": true, + "admin": false, + "boss": false, + "dept_id_list": [2] + } + ] + } + } + } + ], + "message": { + "headers": { + "content-type": "application/json", + "x-acs-request-id": "fake-request-message-001" + }, + "statusCode": 200, + "body": { + "filteredStaffIdList": [], + "flowControlledStaffIdList": [], + "invalidStaffIdList": [], + "processQueryKey": "fake-process-query-key-001" + } + } +} diff --git a/api/tests/fixtures/im_provider/feishu_lark/sanitized_directory_user.json b/api/tests/fixtures/im_provider/feishu_lark/sanitized_directory_user.json new file mode 100644 index 00000000000..26354829eb2 --- /dev/null +++ b/api/tests/fixtures/im_provider/feishu_lark/sanitized_directory_user.json @@ -0,0 +1,32 @@ +{ + "avatar": { + "avatar_240": "https://example.invalid/avatar_240.png", + "avatar_640": "https://example.invalid/avatar_640.png", + "avatar_72": "https://example.invalid/avatar_72.png", + "avatar_origin": "https://example.invalid/avatar_origin.png" + }, + "city": "", + "country": "", + "description": "", + "email": "user@example.invalid", + "employee_no": "", + "employee_type": 1, + "en_name": "sanitized-en_name", + "enterprise_email": "user@example.invalid", + "is_tenant_manager": true, + "job_title": "", + "join_time": 0, + "mobile_visible": true, + "name": "sanitized-name", + "open_id": "sanitized-open_id", + "status": { + "is_activated": true, + "is_exited": false, + "is_frozen": false, + "is_resigned": false, + "is_unjoin": false + }, + "union_id": "sanitized-union_id", + "user_id": "sanitized-user_id", + "work_station": "" +} diff --git a/api/tests/fixtures/im_provider/ms_teams/sanitized_avatar_not_found.json b/api/tests/fixtures/im_provider/ms_teams/sanitized_avatar_not_found.json new file mode 100644 index 00000000000..0952ef18833 --- /dev/null +++ b/api/tests/fixtures/im_provider/ms_teams/sanitized_avatar_not_found.json @@ -0,0 +1,11 @@ +{ + "error": { + "code": "ImageNotFound", + "message": "sanitized-message", + "innerError": { + "date": "sanitized-date", + "request-id": "sanitized-request-id", + "client-request-id": "sanitized-client-request-id" + } + } +} diff --git a/api/tests/fixtures/im_provider/ms_teams/sanitized_avatar_success.json b/api/tests/fixtures/im_provider/ms_teams/sanitized_avatar_success.json new file mode 100644 index 00000000000..21b5ddd03d1 --- /dev/null +++ b/api/tests/fixtures/im_provider/ms_teams/sanitized_avatar_success.json @@ -0,0 +1,8 @@ +{ + "status": 200, + "headers": { + "content-type": "image/jpeg" + }, + "body_encoding": "base64", + "body": "/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAMCAgMCAgMDAwMEAwMEBQgFBQQEBQoHBwYIDAoMDAsKCwsNDhIQDQ4RDgsLEBYQERMUFRUVDA8XGBYUGBIUFRT/2wBDAQMEBAUEBQkFBQkUDQsNFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBT/wAARCAGKAYoDASIAAhEBAxEB/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcICQoL/8QAtRAAAgEDAwIEAwUFBAQAAAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0KxwRVS0fAkM2JyggkKFhcYGRolJicoKSo0NTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uHi4+Tl5ufo6erx8vP09fb3+Pn6/8QAHwEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoL/8QAtREAAgECBAQDBAcFBAQAAQJ3AAECAxEEBSExBhJBUQdhcRMiMoEIFEKRobHBCSMzUvAVYnLRChYkNOEl8RcYGRomJygpKjU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6goOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna4uPk5ebn6Onq8vP09fb3+Pn6/9oADAMBAAIRAxEAPwD4kACgADAHAFHTk0Vk65qYt4mgjOZWGGI/hFAGLql19rvZHB+UHav0FdTp/wDx5Q/7orjK7PT/APjyh/3RQBPXL+If+Qi30H8q6iuX8Q/8hFvoP5UAZtKrFSCCQR0INJRQB0eg6k9yGhlO5lGVY9SK165nw7/x/n/dNdNQBS1pA+nS5HTBH51meGf9fN/uiuusfCWo+KbW5jsY02qo3yzSLGi/VmIArobX9m3x3oEDXdxpkUltKgaOW3uY5Fb6bWNJtLcaTexyFUNc/wCQbL+H8639V0DUdDlMd/ZTWrD/AJ6KQD9DWBrn/INl/D+dPcRylFFFAFvTL97K4Ugny2OGXtj1rr+vIrha7fT45LiCAIhkdlXhRknigB5GRg9643UIfs97KgGAGyPpX0F4G/Zy8a+ODHLHpr6ZYtg/a78eUmPUA8t+Ar2zwl+wx4VFwt14i1O71qfALW9ofJiyOxbBY/pXm4jMcNhtJy17LVnfQwOIr/BHTu9EfHOio0lhbqilmK8ADJrtfD/wp8YeKiP7K8N6jeKejrbsE/76OB+tfeOl/D3wJ8M7OFNM8OaZavAuFdovOl/F2yc1Q134sRWsWIf3Y6Bf4QK8Opn6btRhf1PcpZHLerK3ofGl1+yJ8S9avYmOkW9gpQLm8u41wck9FLH9K0bf9hjxurob3WPD9tGcbtt07sB9NgH617xqXxCvL+YmKd0GeSrYpltrl3eu2ZmVR3LEkfXtXL/a+LntFL5HfHJcNHeTZT8Z/BnVL7wvpOiaLd2LQafarbqGkKZIHLcLjkkn8a8j1L9m7x1aaFZ2VlFa3heZprwR3So5I+4uGxuHU8d698hu7kyIRPKE24wG61oJq7wIfMl2bf77EmvNp4utQqe0dm/1PSq4SnWpeyu0vI+Pdd+Fni3w0gfUdAvoIj0lERdP++lyP1rk7hdkUgcEYU5BHtX3FeeNZLVjHFJIwIwTtwK5nVZfDXjCOWHWdCtLxmXy2mEQSXHT7y4P6171HPU1+9hb0Pn6uRNa0p39UfFXhmUGOePuCGFbde46t+zl4TuLp7jw5rculSNnFtfkSRn2DqAQPwP41wev/B/xNoNykZsjfRSMFWezPmIxPTkdPxr3qOOoYjSEte2zPBrYGvh9Zx079Di10W48QOlhawvcXMzBY4413MT24rsvCfguLwpr1lpOmaZF4v8AHM7AbGXfY6cT3OOJWHfPyj3rovD/AIfuo7tPC/hRBe+JLobL/UE5W3U/ejRui4/ib8BXumnyeDP2avCxae5ik1mRP9Juidzu3dV9q6XI5Ejr/AHgy48BWH2/xDq8+veJpl+aWZ/3VuOyRoPlCj2FYvj+zXxBbyCUZkyWV/4lb1HpXl+m/tQ6d4n1XYbC6WAtgTMR09cZzXqjTxanpkV3buJreVdyuvP4Vi7PRFptM+X/AIn+BF0OODV7OFYrWVzDdRxDCxTgZyB2Vh8w98ivPa+qtZ0KLxDDrWiSg41KzcwAD7tzEC8ZHucMv/Aq+U53FsjtJ8oXOc9qujO6ae6CpFRaktmc/wCJZA1xGgP3Vya1NF/5BsX0Nc1e3Ju7l5T/ABHj6V0ui/8AINi+hroMS9XOa/bzS3+UidxtAyqkjvXR0UAcX9jn/wCeEn/fBqSHSruZgBAwz3YYH612FFAGfpekJYDexDzHqew+laFFFAEV1OLa3klY8BSfxrmtFYvqqMTy24n8qn13UhcMIIjlFOWI7mq+h/8AISi+h/lQB1dQXylrOYAEkqcAVPRQBxf2Of8A54Sf98Gj7HP/AM8JP++DXaUUAY+oa+kYKW/zv03dhXPO7SOWYlmY5JPekooAK7KxYCzhyR90d642nCRxwHP50Adv5i+o/OuY8QEHUWIOflX+VZ/mv/fb86aWLHJJJ96ACiiigDU8PEC+JJx8prs9E0x9c1a0sICPMnkVAc8KCeSfYDJrzgMVOQSD7Gu4+GDG0uNa1cuxfTtPkeFcnmVsKv8AMmgD2T4ZfCWD4o+NbzTbB5I9DtJFD7mOJCp5Y/XBr7cs/DlhoGgW2kW6KbeBdqg814z+yR4PbwZ8M21O7AF1fHzGc9cHmsr4i/tP22g69JpmmQLfTxMVkcthVPpXNOVtzeFOUtUdz40+GdrqkLlYUIPOx1DIfYqa+YfiZ8AJ/sk/9jxGG5zkWzt8jc9FY9D7H86938FftJ2utOkGt2YtAxx50Z3KPqOteoX/AIesvEOnpd2jxXdvIMhkwQRWCbXvRZra+k0flVqmlXei3stpfW0lrcxNtaOVSrA03T9NutWu47Wyt5bq4kYKsUSlmYn0Ar798ZfAzRvG92lvq8AaBeBcqds0Q/2W7/Q1seD/AAH4Q+GEPk+GtLVLno1/cAPMx9ien4Vy180p0Fa132O2hllSu+a9l3Pnv4bfsY6hqVtFqPjW/OhWjYYWcQD3Lj3HRfxr6d8GeD/Cnw9ijg8NaBbxzbQv226UTTntkFhhfwFaFlpl7rM4IMmCcs7HpWzLPpfhUBlIur3HBJ+VDXyWJzCviW1zNLsj6zDZdh8Olpd92btra3FzEJtSlZI1yeW+Zh/QVz/in4gQaXCbayKqegAP8zXK6/40vtTdlL7I8cBTivPdeuZWckEEn15riVFyd5HdzWVibxP47numcGdgMknDdK4G71+W5lGC7qemT0q1PHJcSyEqNoHb1qSDSNiqUTc3XOK7oUoxJcnLYdpaTXJ3urZPI4wAa6rT7WUgBmULnqvv1z61X0jT5ymSMEngY4FdNa2QjdcgccmlJoa1LNnHthIKjGOCRVG/YWiFnG7vtJ4rQmvYbZGZnBxwB3Nch4h1xZAcE8djWPLzMbkooytWvpJGYgrgg5APQVxWo+IJ7aYBHxHyMH0+tXNS1otuyWOM9BXJarOZzuBIzxgito00nqLnvHQ6Ky8RHIy+SeAM5A+ldDoviSdJSrzM8bAqQGKkAjBwR0615tpkZVxuJJBzgiumsjjDKp2k8muhRUXeO5hL31yyPQE8W+H/AIQeB72fw7pVw97KC09w53yuT23Door4x8ZeP9U8fa9Le6jM7BnOyInhBnpivqBriVYNoC7H42tyD9a808Z/B6DVHe+0xBDcj5miQfKx9q9mhjvs1fvPnsRltlzUfuOe8BwIBGcYY4r6x+Dt6LvTr3SJGJBj8+D2YfeH4j+VfKvh+1n0WcRXMbRup6MMV7V8O/FP9l6zYXAbCq4DD1UnBH5GvZj7y5o7HzkoyjLlkrM7bxNI+l39veoSsltMsqkexr5T+ONm2jfELV7KIgWkkv2mHZwCsg3D8t2Pwr66+IdiIZriMHIOcH6182ftH6aph8H6uFAe5sWt5WB5ZomwCfwI/Kog+Wt6o1kr0r9meLV1ejOq6dECwBweprlKUOw4yR+NegcZ2/mJ/fX86PMT++v51xG9v7zfnRvb+8350Adv5if31/OgyoOrL+YriN7f3m/OgsT1JP40AdfPqltADulUn0HJrE1DXnuQUhBjjPBPc1lUUAFXtEIGpR8gDB/lVGgEg5BxQB3HmJ/fX86PMT++v51xG9v7zfnRvb+8350Adv5if31/OjzE/vr+dcRvb+8350b2/vN+dAGpe6IUBeA7gOdh6/hWUQQcEc12FZGsaeCpnjHI+8B396AMauls7aJrWImNSSoycVzVdTY/8ekX+6KAHfZYf+eS/lR9lh/55L+VS0UARfZYf+eS/lR9lh/55L+VS0UAVpNPt5BgxAe4GK7PwRoAh8I608ETzyXV5aWwQdlDFm+tcrXtvwP06O+0mwhKl3uNciDc9FVfT61MnZXHHVn07481dfA3wZWW2Ah+z2IZVPBBK/4mvz90m7kur+WeVmeSVizMxySSa+2/2uLw2fw4vrcEouEjAHoCK+G9GfFz+NedJ6np0laNz1bw7IGVMdelfQ/wmm1zw9ZvMZzFYzLxbyc5/wBoA9K4f4PfCt47OHV9XjwXw0Fu3p2Zv6V6zcKThV2gDrz/ACrycTjOS8ae56+FwPtHz1FoWWafVbglZCATyxOBWxa6fp1jiS5lErryFWueW7MChA2O2elQXdyxX7wHqcV83K85XZ9IkoJKJv6p4rldTb2ifZ4c9V4JFctdXDTnLMS55qrNeneQCHI98VE2pKhWNmCl8qGAzzVqCWxXNdDJd28lsnAOPcVk3tv53IXBAzgmrMt+EkZGbCg9OwX61nTatGQ65GOzZrVJmfMmUJbMxyFVAdsZwR1NOt5JrdVLAKoPIAzUM2opJM5DhmK4AHQe9VzqBfbG2AuPmYnGfStEk0JuxsHW5Fb5coVGCP60p12UW/mF2QA1zcmpxRpOcln4UnsBVI63HNHNHv3YICgHAPHGaappsXM0a2pa5KrbnY5bnr29awbnVBOx3uSewx1NZV3qBlDgbS6EKp3fnWTPqexC7SLlRgYPeto010MnUfUsatOJJwqjHHBz0NZhvFkkBCZA6j0xTLq7Mvyqeo3E+9Zs90I4WVAfOJwSeiitPZ30M/a2Ow057O5KYAUkZYnjFa0VooOA4PGVUtXnk2rxwxeWrgAcZBxkVJbeICttkO2AdoyegpeyZXtkz0RYgSMOCBz7CnSX/wBjYEsoB6nNedSeMHgZcOpwO9Z8/it5ARJJ8rd6h0pPcPbxT0PULqw0TxXEVciO5XIV04596yYPCupaG28AXEKngp1+uK4Wz8Xi1uQwYIRgAiu80D4gjESTvvZ2K4DfdIGamNWvhG3F3XYidChjVqrPueoeMPEkWoW9nKSC0lujN7NtAOfxry/4k2Sav8Jba9KBzp+qtDuI6CRcgfmK6i4+ya3AArrDKfusOx9/UVVu/CWp33wn8W6fFELiVLiC7iVCCXVSQ5UdScduterQzClWlG7s7ni4nLatCEuXVWPlvXUVHi2gLkHoKu6XFG1jGSgJOeSPeqviFSksasCrAEEEYIOau6T/AMg+P8f519OfMFjyI/8Anmv5UeRH/wA81/KpKKAI/Ij/AOea/lSNbRNwY1P4VLRQBSl0i2lBwmw+q8VlXulS2oLg+ZGOpHUfWuipCARgjINAHIVb0pQ19GCARzwfpUuraeLVxJGP3bHp/dNR6R/x/wAf4/yoA6DyI/8Anmv5UeRH/wA81/KpKKAI/Ij/AOea/lR5Ef8AzzX8qkooAKQgMCCMg8Gqek3BuLRcnLL8pq7QByt7B9muXj7A8fSujsf+PSL/AHRWXr8YEsbjuCD+Falj/wAekX+6KAJ6w9VvJ4bxkSVlUAcA+1blYeq2c814zJEzKQOQPagCn/aN1/z3alGpXIOfOak/s65/54v+VH9nXP8Azxf8qANnSr5ruNg/317+or6Q/Z3CR6Zprn5nbW0wMfdATrmvm/SbFrSNi+N7dvQV9Lfs0Qm50iQtwINVhZT6ZXms6mxcFeR6n+2HB5/gK8fJJDK36186fs2fDlPF/iB9Xv0Emk6ew3Kejy9VU+w6mvqT9qewN58O9VCjkxAj8CK4H4D6WuifCDTDFtL3DPNIVGCxLEc/gBXjYyo4U3Y9vBU1OWvQ9Jv9Vji+RCqquBwO3asq7vArqXcoG4ArHu71lkcNjnnJPSsO51rKkOSWOSpzxmvmNW7s+qi7KyOou9YS1ISVgGP3cntVC71n5M+YVVyABnpXE6v4iWeKMuQJV4yT2rnb/wAcWtmgE14uVxj5hT5G+gOSWrO6vPEQijkGP3iHByffrmse58Xm2cfui7BTtI6DPevOrz4h2TGQRO0245OFJzWHd+Lr28kP2fT7p1HyghcAVtGm7EOoujPRT4olkiJfJcnlSegrOvPFrDGzJX+IsM4/KuEebXrgZi0xgTyNzYqJrDxJKSfs0cb+7dK1VPu0Zup2R19x4sMEisGG0LjGOD71n3XjOWXpJuXGCV4P4Vx9xoPiJnJcxjPpmqx8LeICeJQFHUBea0jTj3MXWkuh1DeLyUkDytuboCx4Hoazj4rCtguc561z914R1hUDicFj/Dtql/wi2sFiDIcgdcVsqcV1M3Vn2Onk8RhYjiQs7NyMdqz5dadyAXPXOB0FYcfhvV3J2yHPfK1HJ4f1uMtiYFR3xzVqnG+5m6krao6I+ICsZ+ctIRg9sCq11rxeMIh6DnnjNcncWurwKTgSYOCcc1VE+ory8II+laqlHdMxdWS6HWSamW2ZXIPUimza0QAAx2L/AA1yn9q3UIIe2yo9DyKqvr3JDxMnpVqjJkuskjqbnVzPgZCr6dzVObUHfaCSVHHoBWAuuQucMxX6ipRqUEi5Ey/TNV7KS6Eqon1NQamQ2Ce2Ku6b4hltrgNydoJ6+2M/rXPKyvyrBs+hpyg7sg80SppqzNIzcXdHuXhTxakaBJJM5AxmvQtC8WxsyhJycHgqcV8vWGrTWi5TlwRjNdr4Z8TGK4RgxIXBYdq+cxWC5U5RPo8NiVJJSPWvip8LbX4g6a+r6Wiwa3EpZtihUucdmHZv9rv3rwaxt5bS3EMyNHLGxVkYYKkHkGvo7wd4qjlijIk+VzyCelcz8ePDVsv2PxBaII/tDGG4C9GcDKt9SOv0r0MozOUqn1Svv0foeJnOVwpw+tUdF1XqeP1iaxdTQ3e1JWRdoOAfrW3XP65/x+j/AHR/WvtD4orf2hc/893/ADqWLV7mM5Lhx6MKp0UAdJYail6pGNsg6rVyuUtZzbzo4PQ8/SuqByAR3oAiuoBcW7xn+IcfWsLSQV1BARyMg10dYkKeXrjADHzE/mKANuobxilrKwJBC5BFTVBf/wDHpN/umgDnft1z/wA93/76o+3XP/Pd/wDvqoKKANnw/nZMO2RWvWfo0BitNxGC53fhWhQBk6//AKuL6mr9j/x6Rf7orM19wXiT0BJrTsf+PSL/AHRQBPRRUb3EUbYeRVPoTQBJRUX2uD/nqn/fQpySpJwrhvoc0APr3n9mG+LjWrMZPlNDdKB14bGa8Gr2f9li7C+PLuyzhru0ZF9yp3VE1dMuDs0fV3xysv7R8CX4AzvtWIHvtzXz98BPGUd14FudIlcC6sJDsQ9TGTnP4HNfU3iHTl1nwoiMN4eDacc84xX58H7b4M8XX4tS0UsMzxMM8MAehryq9L20bLc9bDVlRld7HsXiPxZFZ+Y7yBep5PWuGXU/FHih2GmWjRWxbaJ5RgfUVr/DzSk8f69Nc6lEzw2gVhABnzHJwqgd6+ltE+Deo6nBGZjHotqB8saqHkI+nQV4f1eUZcqWp77xNLl5mz5gtfhLf3b79W1OWTPJVG2rW1ZfCjSI34WOQDvJ83NfVSfBjw1pce/UZ7m9J6+dJgH8BgVBe6d4G0NQv9kWx2jILLmupYOpLd2OV5hSj0ufN7eG9F02IosMCMD1KgcU5TpUW4qV2HHRf8K9e1X4h+FLS4kEVlaIvZSikVjf8Lq0C0JWO0sQAOP3Kn+laxwDe8jB5ok9InnButK5CkOegwDxVW71HTvtAHlE7Rztjb/CvUR8ctCbEqWFgGX7w8lcN+lWrT46aBcuA2m2bHvthUCr/s+PdkPNJfynitzrVo8qhLR8joBC2cflVWfWIlVh/Z9wFbjJhbj9K+pNM+JfhW+t0d9Otl5x8qLxW9B4n8H38iRhrVCRkDCnFWsBCPUzeZTfRHxTd3rY+TTLmQEHafJbj68Vky3c4kDnTroKRggQt/hX3ysPh28GIHtWz6J2/KoZ/DWlgbgLVQ397aP51awcBf2jU6I+BIbxkEm7T7oE5AzCw/pVG6vEUsVtJSxHRomH9K+7dQ0XRI9yPNp6n1Mi1zN9omgs5/0rTc9M+YlP6pC9yf7RqbWR8O3V9EQP9Fk3HlgY2GP0qpLPaCJt8ZXJ4Gw5Ffa194d8PsOZ9ObjpvWsS+8FaHcJgJZOG6bSpqlhooHjpNao+Lrx7LIA4z6jFZktlZyYL7QSPWvsi/8AhdpUq5/s6B16ghQa5TWPhJokqHOmxox9Fwa09ilszJ4pveJ8m3mjwNkIVP0NZU+jYPB59q+idY+CmlEPsjaI/wCwxFcfqHwa8lMWt1Kp64bkVcYyWzM/bp7o8c+wTRsTG5BHTBqRLm9t/nYb16ciu9ufhnrNvITBtlA7Hgmsu68Na1p7g3WmzMgOcxrvB/Ks5VLaSsdNNXSaOfg1pWIDqUJ61t6bqohB2NlW6kGtbQtC0zWpjDIpikbsy42n3FS678Np9Lga6st0sK/eA7VwVK1GT5Hoz2qdCrGPOtUdv4B1/KhN2ABgsT0rvviRqC3vw5CORlLqMqfU7Xz+hrxf4fWF3c3e1EcxgZdsYVR3LHoB710/jLxPFq0Vrp1lKJbK1JJkHSSU8Mw9sAAfSvOw2Bcscqsdlq2dOPx0Y4F0pbvRI5euf1z/AI/R/uj+tdBXP65/x+j/AHR/Wvtj8/M+iiigArrLfPkR+uBXLQRGeZEHVjiusUbVAHYYoAWsf/mO/wCfStisSJw+uEj1I/SgDbqC/wD+PSb/AHTU9QX/APx6Tf7poA5aiiigDrwAAABgCjoKWs3V78QxmFD+8Yc4/hFAGTqFwLm7dwflzhfoK6Gx/wCPSL/dFctXU2P/AB6Rf7ooAnrndZ/4/wB/oP5V0Vc7rP8AyEH/AN0fyoAo0qsVOQSCPQ0lFAG7o9+9wGikO5lGQe5FemfBDWjoXxS8P3AbajXAic5/hPFeTaJ/x+/ga6jTrxtP1C2uYzteGQOp9CDmk1dWGnZ3P1F0CAyaLPbOu0xSsu32JyK+PPjT4FGj/ELVHKFYrlhOhx13DnH45r6j8D+Nra78PaZqryBoL62RiQOpAw1c34x1XQ/HHj7T7az0o6jc6TH9plmYkIrE/u0ZR97kFsdsD1rjOlt30K/wB+Ftv8P9GXWtZCxanqCho4WPMUfVcj+8ep9Oleo6h4zFuf3UQKjoxbg1zradcsxuNTkYzqoZgT8i+wrzn4ofEGHQNMuVGAgBUSK2MVNkDbWjIvip8dbbSRLBPNGHUZwG6V8seMv2hLq/uHWzZ35IDFuK888beJbvxj4il8gyzh22rGmWLHPYDrUjeBLfQLdbnxVq0OiIwytmg826b22A4X/gRB9q3jFJXkYuTexXl8f6reX3myzHa5wy57V1Gl+GfEetDzbOyu3j7zMNkY/4E2F/WneDdM1vxVdrB8OPA0s5B2jV9UTzGH+1yAq/hXs+kfsfePfFQWfxt4zmiRhlrSyYnaPTPQU+aK2QrPqeRS6Pb6Eu/V/Fmn6e/wDFFHN57j2+XjP41l3HiPwVa5Cav4l1lwdxS1RYUJ9iQxxX1Ton7HXgLQGDz2txqtwDkyXcuc/gAK6dvhT4b0lBHZ6RaQKBj5IVz+eM1m5lJHx1afF86aoj0fwJLcIB8r6lcO5PuQCBVg/HT4iRDGm6VpejL28q2AIH1r6Y1v4f2PzYhVB6KoFeZeJPBNtAzFVwtF7jtY8k1H4v/FfUkKz+JpIFP8MXGK5q78TeN7wE3fiq/fPUeYRXe6tpcduzKoGB7VzF5bpzx0p3EcfcNrczbpdd1CQ98zN/jVOSK93YbVL0/WZv8a6O6iAJwKzp1GelFx2Moi+HI1K849Jmpw1TWbcDytZvkPY+c3FWZOpFVn6UhPQ0Lf4ieMdO/wBT4guv+BNu/nXQ6X+0d450xlE9xFqEa8bZl+8K4aQY5PSq8pG0cVTtYm77nvmhftRaZfFItf0ZrMtw00Byo969F07U9C8XWf2jRL6K8XHMYYb1/CvjWVsjGKZpuqXuhXqXenXUllcKch4mxn6joRRyqS0K5mj7Ht9OQ3ASRMHPIIr1PwF4Cg1raDErYx1FfPXwj+L9t45lh0nW2isdb27Ybg/Kk5HQex9q+pvhLq4s9QNvMPLniYKyN2Pr9K+VzenOFNyifUZXUjJ8r3LPjD9krSfFWnM72YhusZW7tVCSIexyOv0NfNs/gHVfhr4wl8NeIZFlsZwGhvWYIssZOCOejDoRX6h+Eljv9KUEg+tfDX/BUHwzBpvh/wAO6nEmJEuWjLDurDOPzr5jAVKmIrKhUd0/vTPeq1lRi5R3SPlHx1c6yzSadYadPp+kK52wW8LKr88FmxliR7msPToJYbNUkjeN1yGVlII57g1xDeJL17Sz23Eq+SDGo3twM5HGa9N8AeKNW1fzY5dQncKisu9t5BHBxuzjoK/TYz9lGyWh8LKn7WV3K7fco1ha1DJJeBlRmG0chSfWvaj4ZHi3T5IViQ6qrAwzhVRnJB+VtoAbJAAJ5yRzXmkkbQyMjqVdSVZSOQR1FbUqsaq03RyVaUqTszkfs03/ADyf/vk1JFYXErYETD3IwK6iitzEo6dpi2Y3sd0pHXsPpV6iigCO4mEELuegGawNKYvqKMerEk1NrF+Jm8mM5RT8xHc1BpH/AB/x/j/KgDpKgvQWtZQASSp4FT0UAcn9mm/55P8A98mj7NN/zyf/AL5NdZRQBl3uspGCkPzv03dhWI7mRizEsx5JNJRQAV1FkwFpFyPujvXL0okYcBm/OgDrty/3hXO6yQb98HsP5VU8x/77fnTSSxySSfegAooooAv6IQLzk/wmug3L/eFc7o2l3Gs6lBZ2wYySsE3AEhQe5x2FfTen/spaBofhkar4h8TtLcMEKW1uQAwYgA569wa5a2Jp0Lcz3NqdGdT4Ueo/szeIV8UfCibShiW+0tmKKOu3rj8sV7h8JPBSaLpEmo30KnVdRczysRyoP3V/AYFeJfDqPwt8PPFPh/SPBlvIlxc3Yhv7iZy/nqFbPB4HIzX1TGf3nAwoHbtXPGoqt2lodEqbp6Sep5n8W/E/9i2E0ayYmdSeRxxXxD8UNevvHt82kafIxijbdNM7bUUZ5Zj2FfRX7TWvLYCWR3LgKQqA8uTwAPcnFfIOow317q1t4I0UmTVtQkDahcKc7Mn7mfRf51tHTU5372hP4btdR1HWl8MfDbT2v9Xf5bnWmXLJnhtp6Iv6mvrb4L/sOeHvDgi1nxq7eJtefDss5JhjbrgD+L8a9H/Z9+DujfCXwla2tlbqbyRQ1zcsMvIx6kn0r1Wa7EY4qua+5ST2RTttIsNGs1tbC0hs4EGFjhQIoH0FUbllUkZFSXd+ME5rEu7/ACSc1nsHK0QXrqC3Nc/qcypzmrd/eckiub1e+AQkmmFmZGvX6KjMRXkXi7WFJYA11XivXgI3VW6V4/ruqGVny3f1oQrGDrd2Hdjjk1yV5PuyOlamo3BbJB71z13IeTVDKdw45yc1RlIbOKsSvnmqrZJIoJZWl+8KqyfdNXzGScEZz0rr/Dfwvl1WOO71W7j0XTn586ZSXZe5VeprOdSNNXZpGlKo7I86kBPHaqsgPSvabu9+HPhOMw2ljPrdyoINxdngn2VeAPqawLz4m6Mg2r4ZtGXp/qlU/wBa5vrDfwxb/A63hEtJNJnlUhy3FVZfvV6ovi3wPqmI9Q0F7Rif9ZAOV/I81Be/DbStfga48NakLjjd9nlOHH9a1jXS+NNESwrteLTPOLS8e2kQqzIVYMrqcMrDoQexr7Q+AHxTfxtpKm5kz4i0ZQJ8dbmDs2PUD+Rr4w1TSrvR7poLuBoXUkfMOv0rtPg147l8CeONL1ZSfKikEdwmeHiY4YH1x1rStShiKbi9UzGjUnQqJ7NH7J/B3xQl/Zx/P8jqCPpivln/AIKkaok3hLR7NHyRcBwPwxXo3wr8UR6Pqc1nG+YAFuLdgchoZBuXH05H4V81f8FD/ETa1e6fGGLIilq+AwdB0MwUGtmfW4mfPhnUXY+OIdMeSeGIAgMC2PoK9L+C1kZfFzWTAnzLaRgP93B/lmuctLHy9R0diMCZlX65XFd38Jcad8V9GV8DzZJbU/8AbRGQfqRX6G0nofFRm76HsGi239larb3KA4ikSUr67GV8fkprG/aj8AxeDvH8l9ZJt03Ux5yEDChjgnH1BB/OvQX0pWuo0IwDIFb2BOD+la/x/wBCj8cfsy6LrbpuvrWxhmV8fMCmVYfkGFeROq8NiodpaM9aMfb4eS6rU+Qd6/3h+dG9f7w/OuS3H1P50m4nqTX0R4p1E1/BAPmkGfQcmsm+1hpwUiBRD1Pc1m0UAFXNJIF9GSfX+VU6ASKAOu3r/eH50b1/vD865LcfU/nRuPqfzoA63ev94fnRvX+8Pzrktx9T+dG4+p/OgC7caaVy0RyP7veqPSuh21n6jaDBlUdPvD+tAGdWzbxIYEJUE7R2rGrcth/o8f8AuigBfKT+4v5UeUn9xfyp+PcUY9xQAzyk/uL+Vewfs2fD3S/GfjOGHV7UT2U7G2VCMfMVJZh7hR+ZFeRY9xXvXwtu5/A+jw6ssZSWyspb4K3BbcpKn8QFx7YrGpdKy6mlO3Ndn034k+E3g74d/D3XH0PSbGynSyl2SsQ8jEKejHnNfLuvXOof2FBNLIzKPIOGPGMrisC3+MXiHxfrkFne3bLaTsUMSdDnNe0eNvCscng2AogVT9mJIH8IZa5XSineS1Oj2rtZGX8KSY/ij4alm4BvCRnuSjAfzr7YuH+z2kj55C4GK+NYbE6J8QPC0h+VBqcWT7Ekf1r7B1KQtbugcDKg4rRJW0Mrtyuz5C/aR8UxWbzTyAH+z4mvSWHVgdkaj/gTBvoprx39kHw62r+KpNcvCZLiVjJubk4zxV/9rvUZEuNZtBMW+0XlvZoAMbVVS7D8WkX8q6z9mXT00WB2yscUSBSzHG0Ac5NNtRVxJXeh9lWN+sFuAWCqq9ScACuI8W/HXRNDc21ksusXjfKEtuUB/wB7v+ANeXav4z1P4pay+h6C7W+kxNi4u2BCsPU4655wp69Txiu+8N+FNG8H2u63hEtzj57qYBnY/XsPYVySk5O0TrjBRV5GVJ448aa3C0troQs4mAK+fKEP65P6Cqlxq3izywZLeKFwMnZeEg/mKl8T+P3S6FhpkH2u8f5Tj7q+5qhB4L1bxAwN5dTTTNz5EPAHtXNOmormlJnVCak7RijNuPHmu2LkTxLOuMlJNpwfZl7fUVlal8R7e6QRXEcljctxslOVY/7LDg/zrpNb+Fo0u0Ek9pLCGyBIWOcjrXj3iOOXQ5JEmA1HTmO14JuWUeqn1rGnUle8JXSNp04SVpRsyLxNrwkLkHI+tefapdFyxz1qTxHHPpEaXltK15os7YSVuXgP91v6Gubu78yrkHINepTmpq55VSDpuzEubjg56VjXLjB5qWWdjyTVCaXNbGLdiOQjOaaFLngUmdx5rsvhxoEWq6yJ7pc2dqpmlGOCB0H4moqS5ItmkI88tDW8L+GrDwzpi65rKrJcH5rW1cZA9GYd/YVzPibxdd+Jb93kLFScKg4AH0pfiD4ol1nVXyQsanCRqeFHYfgK2fgl4QtvGHjnR9PvSwt7q4WJtn3sMcccH+VeFiKvs4SrT6Hv4ejzSVOJiab8MPEXiC2FxZaRdXEJyRIkR2kex6GuU1nw9cWJZZYyjKSCGHcV+sWjeBrWxgt7W0txDawqUSONQFwDnoyDvXzR+2N8OtP0zS9P1e1tVhuJnMMrKoAbCggnDEZ69hXy+D4hdbEKk1ZM9qtltNQbT1PhGaMhiG7UtpeXOmzrPbTPBKhyro2DV3VbcwTsCOhrOYZGK/QqbU43PjaqcJHqGha7p3xOshpGsIsWrqD5MygDzz6D0b+dcH4g8L3fgvXTZ3ILRyLuimAwssZPDD8sEdiCKxEnktJ0lhcxyIwZWU4KkdCK9v1h0+Knwst9QVV/tWyLk4HKzKoZ1/3ZEG8D+8jetZ/wJK3wv8DeKWJg0/iS+899+CXjd9U+GnhjVnYm5svM0m5Ynn5TuQn8K8n/AGpNZ/t+6XLb2VCOvQUfs26s114F8a6RuLNb+VqUSg9McMRXHfE3UDeySO5OMEZJrilhovFqqjaNdvCuDMKSQJpmgXgAAjaJyfowrb0+9Gk/EnRrsnCw6rbzHHoJVJ/SuBm1Iy+EbaBXy0akDB9DWv4j1H98t3EdzCJJV5/iCg/zFe21c8NKzufZ+pShDcOOCGZh+eRXS6VeRa58B7zTJdrpY3NxaurdNu8tg/g1eeatqYVgA4ZXjRhzx8yg/wBat/CjVhN4X8baczl2a7Eyqe26NVP6qa+czaNowmujR7uX+85R7pnytrWlDStZv7JkXdbTyQnA7qxH9KomCNuqL+VdZ8TIgnjrWHH3Z5zOP+B/N/WuY219PB80Uzwprlk0VZNPhfoNp9qo3Fi8AJHzL6jtWxtpCoIwask5+p7FQ1yoPI5qS/tPIcMo+Rv0NM0//j6T8aANbyY/7o/75o8mP+6P++adj6/lRj6/lQA3yY/7o/75o8mP+6P++adj6/lRj6/lQAvBpCARjsar2Epmt1ycleDVn71AGFcxGGZk7A8fSti1z9nT/dFUdVQCRG9Rir1t/wAe6fQUAS8jvWbfXUsVwVR2VcCtGsy/gkkuSVRmGByBQAWM1zd3tvAjszSyKiqCBkkgYr628caT/ZVz4us4kQx2uiqqhRwFWFAMfgK+UfDlhNN4g0yPy5RuuolBj4bJYdD2NfVt7O+qahrsGH33GiGNN/LELDtH1+7WM3qhrZnzT4H1Bz4gsHGfMSZcYHWvuPxmi/8ACuhsHIt4MH6Fc/yNfDHguE2euWMsg/5bLke2a+0vEerJN8Lmfq32Et17jJH8qJlRM34gSpBqOn3KAZhuLeRcdv3iZP5Zr6ouJAJwCM5RTk/SvjTxVrS3NnC7EYFsJSSf7qh/6V9laEo1Sy069JDRy2sbcj1Uf41lsWtz87P2t5o5PiDZ27YcT6tNMy9MBdiKP/HD+db/AIOuru08JCK2ISXUJhDHz97PX8PX2BrnP2s5PtHxi0rCYBluX2+h86RR+iiun0h10rX9H0uSUFtOsklZR0WWRcn8gf1rlrS0UU9zqoxTvJ9D3rwhpdp4S0SGytjvYDdLMR80rn7zH/PSsj4geNm02yW3tmH2mc7VGenvVAeKVW3yJFGBXnH9s/254raV33RW5woJqlBJWQnK7uepeCtOTS7VZJT5l1KA0kjdSa+hPhfZwNpzXO3dIzEZ4yMAjA4Pr/8Arr5mtdeRWADY/GvpL4G3Y1LwzKQA7RzBeVDcEKehB9DXz2dT5KHLc9XARcpNnZa9pcep6fPbzRhldSNrr1545BHpXwH8Vz9j1C7hxwkrLgdsE1+iM9u4iLIFXcvQBkIOCfQetfnx+0BZNYeLtXgf7wnZvzOf614GS1mqrj0Z6uJpKdO/Y8m0XWoobqbTr0btNvh5UyE/dJ+6w9wa8+8QWN14Z1y4sJiSsTkIx6MvY/lWvqJKysQTwa763j8Na3oem6/4jZgkEbwsVHDyx4wrf7ykflX20n7GSmtmeCoOrFwe6OH8O+Cta8VbWtLfbC2D5sp2IBnrn09+ldFqHwMubaAEeK9AmnPW3glkd1PoSUA/ImuM8cfFe+12V7bT3bT9KX5Y7WE7QQOhPqfrXAPNNcuWkdmPX5mJrRRrVPeTsjBKjT0krs7zXPB+q+HJcXKRyxdRLA25SPcdR+Irt9GkGg/DKa+A2fa5jEHI+8AOQD9cV5XoXibU9N2xLKbmA8NbT/OjD0APIPuOa9i+Kcd9afCDQEubBtNeKWRmt2AyN20gnHXgjnrWNeU4uMJdTpoxpyTnDS2tjxa6nae6JJySetey/s93P9neNtBuRgmO9ibk99wrw+2l3zAk55r2L4PyCLXLBycbZkbj2YV5ubJ/VpLyPTy2V6qZ+qdvpsmB+5bjcuREH7eoQGvAP2x9Dkl+F0UhAIt7wMTgjGQR0J9/SvpiG3W5gilRFHmhWVimOoz1CntXkf7UuntefBfXExkxMjY3ehHbj+Vfk2EfLiYvzPfUuZuJ+TviK3Edy/APNcxLwxrtPFcRS4kGO9cbN96v27CS5qaPj8XG02QvCsrA7gox3r1r9n29t4tT1jSZ5XkguLX7Wix4BE0J3LjORypdTx0Y15LdoVtkcEjJxXb/ALP9j/aPxJtYHOIvsl27knGAtvI38wK6aq5qctTnoSSqo9i0JLD4dm/v9EtHhmvIzDMskhdGjLZKhT0FVf2gdR8HmzgsvClhAZJLeOSa6BZgjlcsoLEng1zHw01yW6/aAsI5HZ7W4laP7OzEou6Lsp4FQ+ONMFrdXaAfKkrqB/wI15saFSM4zlJvqd861OUZU4RtY8ZtpXhilhb5sEitdr03GnQbj0iC/pisu4ULqF0no2altjus1HoSv619Bc+etc+p9G1g6l4Y0e5JLGWyhYn1+QD+lWvh74hOkeI/EVuxws0ETj3ILZrmfh5N9p8CaMf+edusf/fJI/pVK9ujp3jPcmR5tmN313MK8LHWrQcP63PewcHTtPucf8T9QaXxSWilbAtoVbn+IIAf5VyyajMh5IYehFbPjs51tckl/JQtn1OT/Iiudr3KP8OPojwa38SXqzatbtLleOGHVasVgQSGKVWHY8/St0HIB9a2Mhk8QmiZT3HFZViCLtR3Ga2fwrMjUJqZA6ZJoA0qZMSsTkHBAOKfUdx/qX/3TQBkfa5v+ejfnR9rm/56N+dRUUAaWk52yfUVfWqmmxGODJHLHNXKAM/VsbI/rVq2/wCPdPoKpas3zIuemTV22P8Ao6D/AGRQBLtpaKY0iKcMyg+5oAu6Tcmz1SzuAcGKZHB9MMDX1HrcSaX450VEPyXmjKpJ7s/mZ/8AQhXyesyAj51496+j/GviFbn/AIRDU4nDGOxjj3A9doXn9aynsXFN3see6H8E/E0ms2yiylAWVW3bTjbu6/lXpuoeIQvhe509n5jM9sVPXCswr6u0/wAQ2suk2MsUcSmS2jbcFGeVBPP418J+P9RfSfGniGyJwqX0jD6Md39aybb3GlYh1nxGbnQ4nDEhrXaQD1G3BH5V9EfDz4weKNM8I6RJc2l3FpclvGYZmj3pswAPmHI4r5GF952mRQg/dVl/U194fs6mLUPgd4VSUCULYpG4YZyRlTn8qwqwc42TsdFGag7tXPMfiLp3grW/E1vqGuwR63qkCl1uVbyEwWLbdoPPXk18z+LvHt7beNdRvLeFC1xKflC8KFwFA9sYr1X9vrQYvDl74Pv9NjFnHKjRSCL5QxDZ5x1615F8SAs2u6VqcaqIb6wimG0YBIUI36oa82nQnTqJzd73+R6br050nGEbNFuP4p6vJDseIDPBOOlafhPxG5eSR2wxOTXDrcApgAUumal9mnKFiMnIr01Gx5qlc9ssPELGVSTkE+tfYP7K+qJe6LqsLOBtkjfB9NrD/CvgHStUJkXLV9l/sZawJtS1e2Z+GtVdRnqQ4H8ia+Uz27pXPfy9Xi2fVcqIVIAZu3AB9B6V8A/tX25tfiLqfACuQwGMdVFffzTjOT168r0696+Gv2z7ZIvG4cEfvbZWOPXkf0r4/KqnLiVY9Vq8ZJ9j5I1FsyP0613elabbyfB2GaVVuEGuL5iEcLuXbj9Aa871Fttyw5r0y0nstJ+CFiksojk1DVjPKWbhUiXjA9zX6XV96EfU+fpPlqM+a9RukfW7tI18uISsFUdhu6VdgAIBNZEhEupzyYIDOxH4mtaA9vSvXiklZHkzd5NmjZTeTKrqcMpBH1Br6C8Z+Jk8f/CC0uJdzz20RSYkc5C8EflXzmGweDXonw18VhVk8OXpU2OoEqrN/BIRx+BPFcOLpc1pLpqdWEklJxfVWPLtLmLyrXr3w3u/JvISDgqwI/OvM/EWgS+FvEM9pKpVQxKHsVzXT+DdQ8q8jIJAzmvPzCPtKL5eqPWwL9lVSZ+xvg3VDd+FNGnBU+ZZQOcddxjUnqPU1y/x4h/tH4XeJ0Y/dtTIAAO30+lUfg54gN78K/Dk5kAzZqoJYEkq2zof92oviZdtf+EtciPSSzcE7cDG0nsa/Fk3Cul2Z9Nye/zI/KzxnHi5cj1rg7lTvrufGEqm5fB7n+dclbWxvLxU7Zyx9BX7bgb+xi2fK41J1JJFfVovKsrZMfMV3EV3XwGhNlqWv6y52xWWlzoD/eeVdigf99NXC6vN9qvWVBlV+Vcd8V6NqDD4ffC5dPcbNT1dhO4/iVcYVfpjn613zd48q6nmwjaak+hkfCLUmPxk0O7Dbi1+Qp9sYH6V6J8QLfdcXzYzmaQ5/wCBGvMvgTbG4+Kfh1D0W634+ik17H4otTdx3jkctK7Y+rGssTJU1E1w0faTmfOl/Bt1ecY6gGm2cZEUq46Ma0Neh+z67IMdV/rVaycItwpGSW/pXXzXjdHIo8s2me9/Cch/h9YAHLK0gP8A32x/rWd4rz/wk1qF+8YcD/vrFSfCS4KeB4Bn/ltLn6bqf4heIas93MdqWlo0xx1YhsKv4sQK8ed5VnFdT301DDqb6Hmviq8F9r946nKK3lqfUKNufxxmsqldmdixOSxJJ96SvpIrlSSPkpNybbCt+HPlrnrgVhRIZJFUdSa3gMKB6CqEOrN/5ih/z2rR/irNRg2pkj1x+lAGjUdx/qX/AN01JUdx/qX/AN00AYdFFFAHQDAAA4Apfwo+lU9QuxEhRT856+woAoXkvnXDEHgcCta1/wBRH9BWHW5bf8eyf7ooAl+9WRqf/H0foK12rI1L/j7P0FAFWvcNOvzq/wAOdAnLBmtle3fH8JVjjP8AwEr+deH13nw61rdp+paK/WUCeA5/iXG5QO+VH/jtZVFeN0a0pJS1PtL4f+IDqngPQp8kk2oU891JX+lfLvxxjNn8UdZOcC48uYfiuD/KvYvg1rjf8IPFbPndaTSRD2BO4fzNeS/tJDb4v027QYFxasrEdyrf4GvPjVvKx6MqVo8x5lb3XleYCekxx9DzX3f+yPqZvfgxp6k8wzXEOPZZGx+lfn157fapkJ7Bh/L+lfaP7FGvCfwLqthuGbS+Y4/31Vv8a6WcdiH/AIKB6I2ofC3RtTjXcbG9KtgfdVlz/Svm3Ro18Z/DuKBSG1DR8sg7vC3PH+62fzr7j/aC8Njxp8HfEdgE3zRw/aY17krycfhmvzr8F+IbjwzfW9zD8z25MM0TdHUHBU/UVlUi5QvHdamtFqE7S2Yu948hiQQcEVC0+yQP3rt/F3hyC+tk13RiJbKflol5eFu6sPUfrXBSg5wRU06imvMqdJwfkdHpl8VKtnivqv8AY88ROnjyK1D4FxbyKBjJJVd44/4DXx1Z3RjbBJr0T4b+PrzwTrlnqlpIEngfKkjI5BBBB4IwTXj5ph5YijKMdz1MvqqnK0tmfq3c3flwB1IDHAKkFf8A2Wvjj9tiUf27pU24M0kDKSGB4Dex9617T9snTH0rfPpU4vFUgrHJhCexBGMV87/FT4kaj8Udba7m3yEkrDCpLbFJ+6K+Hy7AV4YhSmrJH0VScFBuLPN4bCXV9RS3gUvLKwVQPetL40XqadFpnh62IEGnQ4YDvI3LGuhtI7f4Z6S2r37pJqc6EWtuCCV/2jXiuv6vPrN7LcTuWkkYsxPfNfokE5Ndj5eVopyvqzDjgy+8jnOavRsAOaiRRTwCOK9WLPKknclDA1LHIY2VkO1lIKkHBB9arDPYU8E4wetErNWYRTi7o9P/ANG+KmiCKV1h1+0T0/1oA+8PX3H41xEK3Xh3UTBdxNDKh7jgj1B7j3rOsb2fTrqO5tpWhnjIZXQ4INeg2/jbSPFtqLTxHAtvcdFvI1OwnuWA5U+65HqK8udNx93dHtUqymk3oz6B+B/7UsXhrw/BoutrJLYW6nyZIcb4x8zbSON2Wb14rT+MX7VtlrPhuTTdAE6PKpWS5kJRtuAMABiPWvnRPhZdXVuZ9D1KC8hxlfnDZ9ty9D7ECsa5+GviyVyi2HnY4+W4jH82FfMf2PhJ1vap632PaeLqRha12c3rN6b2YheTnrVGS7Wxt3ihO6aThnHb2rvdJ+Bur38g/tPVbHRYDyxJa4kA9kiVsn6kCujlg+HXwliE1s83iPXEA2zX6qAreqwKSF+rsT7V9TCpThFQhq/I8OcZ1JOUtPU5rwT4Cg0CzHiXxMht7aMb7e2kGGkbsWB5A9q4Hxr4pm8W67NeyZWLO2KPsoHSr3jTx5qfji9aW6kKwBsrCp+Vf8a5nyj3rognfmluc1RqKaTPUP2brISfEdLsruSxtJpz7HaQP1NexTWgntdrclhk/U1xXwA0U6V4J13XXQpJqEy2Fu5H3kX5nI/QfjXokMReIDHAFeZmNRLlij0MuppxlJ9T5s+INi1t4mKqOAv9axtPsnmeXaOc4/SvSfijHplp4nZGhlnuFiHmfvNqAkZwABn9aytN1CCGHzINMsoZWwRJsZ2Ujv8AMTXqUqdSUFfsjzKtSlGpJavU7D4dadd2PhOPdEUj8yRt0mEVRuxkk4GKxviJq8RCWFq6zSuoNzLEdygAsVQN0PJyccZx6Vl3OpXd6SZ55Zc9mYkflVetKeFUKntG7szq411KfskrI5/yn/uN+VPS1lcgBG59Rit2k7dK7zzSrZ2It/mblz+lWmpaT7tADJZBFGznoBnrWVZMWvAT1OTUuo3YkPlIcqPvEdzUOn/8fSfjQBsVHOCYXA/umpPz/Oj8/wA6AMHyn/uN+VHlP/cb8q3vz/Oj8/zoAp3WopGCseGb17CsxmLsSSST1JpKKACty2P7iPnsKw6cJHAwGOPrQBvAj1rJ1I7ro/QVX3v/AHn/ADNISWOSST70AJVnTL+TS7+C6iJDxMGGD19qrYNGDQB9L/CzXYzbagYnHkXJS4jGfunBDL9QcVg/HgC/0rT7sctBMUJHow/xFeaeA/Hb+E53inRpbKUfMFPzIfVf8K6/XfiB4e1/RZrR7m4jd8FfMhJwQcjpXh1KFWFVSgro+go4ijOlyzdmeVT5W4U8/MpU/hzX0x+xJrS2niDxJpjv/wAfMMNwoz1Kllb9GWvB7iz027bfFqMChWBHmEqffjHNdr8GPEdl4J+Imjak2pW0Vs8htpwGJLI/y56cANtPNdKc27OLOSSp2dpI+/ZpUnikglG+KVWjYeoIwf51+afxR8KSeAvijrWkOmyF5DJCccEf/qxX6PfaVYA+3avn79rH4SyeMNCi8S6ZFu1PTsbwo5ZK1hJXsYTg0uY+XvDfia98NXBeDbLBIMTW8gyjj3Hr711L6V4e8a5lsrkaXfsMtBLwpP8AUe4rgIH86IHlXHysp4KsOoNCk569K5KlJN80XZndTq+6lJXR1t18IvE8eXtrA38OcCS1YP8AmByKS3+GXjaMKI/DmpFGPB+zNt/PFYtt4r1jTFAtdRuIwOAA5OB+NWm+J3i0x+WdduxF02iQ4/LOKxkq700ZpaktVdHc6X8MPEaop1EWulRkZLXLjIH+7mm6p4l0LwPG0VnMNY1QDb5ijbGn1/w/WvL77xNql8c3eo3E3P8AE/8AhWU1wCeKmNCV7top11ayuXvEuuXniC+a7vZzLK3QE8KPQViPHuYcVO7bjTScV2qKWiOST5mM8of3aQx4pxemlia0uZ2TALQUBNRtNg4zTHn54oBJIkIxQGIGKh84mnRsWODQPQuQXlzatut5pIX9Y2Kn8xW5Za34jcKo1e6RB03TFiPz5qPw9ocmowXMqIXkiXKqByagt7iSzmzMjKwPRhiuOTj2Oim5PS7sdCx1jUrfyr3V9Su4T/yza4bZ+WcVmT6Lp1nIongbn+InJrXt/F0AjAaM5xjC1k6pdDUZfNJEMI/vHms7tbGun2mZviDSbTTUieA7ll5Gewqj4Y8NXvjPxHZ6Lpy77q6kCLgfdHdj7Ac06Zr3xNqsGn6bDLdysdkUUa5Yn1r6D+GvgBPhTYzXE8kc3iS7j2TSL8wgU9UU+vqa6FJUqfPUZycrrT5YK51F/pdnoNtpmgac26x0uLylfs7nlm/E/pinSXMOlWE97cELDbxmViemAOn4mq1tmabLkszNyfWvPfjb4vUCPw9ZvnaQ92ynv/Cn4dTXjUovG4i/Tqe7WlHBYa3V6I8s1rU5dZ1a7vpSTJcSM5z7ngflV2wI+yp06VjUoZgOCfzr7BKysj41u7uzoN1Jn3FYG5vVvzo3N6t+dMRv59xRkdyKwNzerfnQWY9zQBtyXMUX3nX6dTVC51IyApGCqnqe5qlRQAVYsP8Aj5T8ar0AkHigDoOP8mjj/JrA3N6t+dG5vVvzoA3+P8ml49vzrn9zerfnRub1b86AL1xp2MmP/vmqBBBwRgit/Huao6haBlMqDkfeHrQBnVs26KYEJQH5R2rGrbtyPIj/AN0UAO8tP7g/Kjy0/uD8qdkUZFADfLT/AJ5j8qPLT+4Pyp2RRkUARvbxOMGMflVK407ALR54/hNaORRkUAYHSrmmKGlbIB+Wm6hCI5dwGFb+dP0v/XP/ALtAH27+z78Tl8aeEksryUHVtNVYpQT80kYGFf8AIYPuM969QnukmjeJwHjdSrKehBr8/wDwf4tv/BOvW+q6dJsmiOGQ/ddT1Vh3BFfXvg34j2HjXSIr20cK+AJoC3zxN3B9vQ15daDhLnjsetQnGrH2b3PEfjp+z7faRfz+I/DEBurCQlrm1QfMh9QPWvDvIcZJBBBwwIwVPoa/QS31wW7ZBDqeGRuVYe4rzvx78EPC/jy5kvbCQaBqTAktGuY2PuO9KNWM0EqcqT02PjputMZMjrXqfib4D+I9AuZE+zLqMYG4SWLZYj12HBP4Zrz7VdAvNJYpcwyW5JwonjaPP/fQrRpoFUi0Yk0WeBzVZ4igyatm3vGYhIg2P7rA1We2u5ZRG8Tpk4JK9KdjGVRXIc56UMpxnBrptL8LROFZ7iIkdQW6fhV660S1hhJM8bEdl5osJTucOVPNMKkDJrTv1ihbEUTNjvismV5mUgIq/VgKdgc0hrgcnvTUiMrhR1NM2yOQC6gnsDn+Va2meGtYu5A1rp17dk9PItnfP5CnysXtIlyy8N+YqvIcA9q0U0O3h5xnHrXRaD8MviHqwAtPCuo7f71xD5Q/8eIrq7f9nXx/cANqDaTo8R6tcXYZgPoopNNBzp7HAWF+ulyExSeUccYOKwPEup3F0+8OpOck4Fe22n7N+jW7Z13xoJCOWj0+En/x5q1rH4ZfDHw++TbXesSL/HfSkrn/AHV4rNqN7spc7+FHy9YQ6tq1yILGCe7lJwFt4y5/SvTPC37P3inW3jm1uZdFsTywmYNLj2Xt+Ne4v400rQ7YwaNp8NjGB0t41T9RzXJ3/jC71CRgZGCsemawniKcPhWp1U8NObXM9Do/Dug+Gfhjp7waNAJL512yXj4aVvx/hH0rPe+a7myQSScADmsWS9htYftF5cpbQ9S8rYz9B1P4VxfiP4pMVe20RGt0I2teSD94w77R0UfrXnKlXxr10Xc9R1cPgY+7q+x3HiTxpD4agaG3dZdVIICggrB7t/te1eSzyNczPLKfMldizO3JYnqTVWwdpYWd2LOWJZickn1NWa+hoYeGHjywR85iMTPEy5p/JdjN1RVUpgAcHoKs2Sr9mQkA8elV9W6x/Q1ZsP8Aj1T6V1HIS7U/uj8qNqf3R+VPooAZtT+6v5UFEPVR+VPooAryWcMnVcH1FUbiweLJQ71H5itaigDn6nsQDcqCAetSX9qIW3oPlJ6ehplh/wAfa/jQBrbF/ur+VGxf7q/lRn3/AEoz9fyoANi/3V/KjYv91fyoz9fyoz/tH8qAFzkZFB5GCOtVtPlMsABOSvFWqAMO5i8mZk7dR9K1rb/j3j/3RVPVUw6N6jFXLb/j3j/3RQBNWXfyulyQHYDA4BrUrNvbWWW4LIhIwOaAKnnSf32/Ol+0SjkOfzqT7DP/AHD+Yo+wz/3D+YoAu2FyZlYOcsO/rVuqtlam3Qlsbj+lWcCgCnqgzCp9GqHS/wDXP/u1Jqjjaidyc1Hpf+uf/doA060NE8W6l4Ou1v8ATZzFIpG5DyjjPKsO4rPqK4iE8RTOM96TSasxptO6Pobwd8etM8SxpDdsml3/AEMczfI/urHp9D+Zrvf7fQgfPyRmvjD+yuOJf/Ha6Pw/4o1nw3sFrqcphXpBIA8ZHptbI/KvMqYJSd6bsepSxzUeWoro+rz4nMUe0yBhnO1hkZ+hqJvGSkGOeC0uIiMGOSMbT9R0NeCW/wAXJXI+26dHKTwWt5WT8cHcPwrS03x3YateR2yJdwyysQv7tXA+pDA+vOOKyVKtTWupq6tCptoeqam/hLU0UXHhrTGIxnFuh/Ilcj86x5vDHgS9BDeGIOf7i4I/FSK87l+Kek6VfSwT219c+WfvRIu1h6jJ6VHcftDaVafLb6LeHHd1Gf51MZza0B0oLVnocPgHwQfv+HPOTupdlJ+jBsitCLwR8OkXH/CGRKP+mt1I/wDM146/7RMlwSItNuFHoqDj9apXPx5nlj3fYLo84Gdo5q/3r0IVKmu57qvhr4bWIGzwTp5I5PnOzZ/WpBceBrJt9r4I0CBx/F9m3n9TXzZefGjULlmVNNmyB/E/+FZ7fFTW3DkaeMDIJL+n4UctV9RpUl0Pqv8A4WRZ2KBLTRtGtVHQxWMYI/HFVp/jRqcYKxXa26kYxCioP0FfKU3xB8SXABjt4k3DIzk8Vn3HijxPMu4yiIEE5VPz60/Z1HvIf7tbRPqC++J17dkma9lkz1y5rn73xqHBLzk59Wr5uk1TXp1Bl1KUAttwuBzVKe2vZpMTXM0mR1MhNJUO8h8/aJ9B3vjiwhXMl7EnsXArmr/4m6TG5CXBnPTEKlq8dh0lRG7ytnGSMnnitmwngaVYbWEAhQGkIyFP9aboxSvdsPa230PVfCWonxpqz2qwz20CLlpcDOew9vc9qxfEviLUvDepz2CWQsJojjfIxkcjsyk8YI7gV3fwfgg0i3LOd7SkDnknPb3NS/Gu20jxJqdrpltcQ2Gp2MfzGUHYwbnbuGSCPcY56ivPw9eDxDpyjp3N6sKkqSnTbv2R4fe6jc6lMZLmd5nJzl2zVetHWfD9/oM3l3luUDfdkHzI49VYcEVY0PwbrPiMM1hp800Sglpiu2NcerNhR+dfR88IxvdJHhcs5S5bakemf8ev/AjVuuh0XwC/l+TPeA3O4/uLOFrhvzU7f1r0Xw78ChqciGSx1hkYDiZI4Mn6kmuOpmGGp7zR1QwOIqbRZ4Rq3WP6GrNh/wAeqfSvr7Sv2VfCWr6XOL2zubG/RSYmnvsox9CVzj8qyB+zE9vCqRaFol2oG3cmuTJJ16kFMVzRzfCSdub79DSeX4iO8fu1PmCszUJXS4wGYDaOhr7Bj/Y9tb/QmnMwtNT34Wzs75bjI92ZFA+n615F8Qf2W/FvhuVpYrSaRdp2pOmwuB12MCVY+2QfTNa08zwlSXJGauRLAYiEeZxdjxHzpP77fnT0vZkPDk+x5qOSN4ZGjkVkdSVZWGCpHUEU2vUOA17S8FyMEbXHb1qzWDFIYpFYHoa3QcgH1oAZPGJYmQ9xWXYgi7Udxmtis2Ndupke5NAGlUVyStu5BwQKlqG7/wCPaX/doAyPOk/vt+dHnSf32/OmUUAaGlZ2yemRV/J9KrafEY4ATkFuas/nQBR1X7kf1NWrcZgT/dFUtUYF0XPQE1ctv+PeP/dFAEuBRgUtRvcRxttZwD6GgB+BRgVF9qi/56LT1lR+jqfxoAdgUyVxEjMQTjnAqSkwD1oAw5pTNIXPft6VZ0v/AFr/AEpdRthGQ6jAbgj3o0v/AFz/AO7QBpZHofyoyPQ/lS1HPKIIi5GcdqAH7vY/lRkeh/KqH9qD+4fzp6aoh+8pWgC5keh/KvSPhZoTtZapfyw7BIq28EjjBbdncF9eAM+xrP8Ahx4IHiBl1S+UjSo2woPHnsP4R7DvXps0vn2ZiaMQrbyMyqvyhFHCgY+leXjMQlF047s9fA4aUpKpPZficXq/w5W9TfECsgBIYDnNcVe+G7vT7tra9tvLLAbZQvytj39favp/wvYweKPDqahpxWaa3IjvrfHzxt/C4HdWH5HNWdR8B2mu2DwzRg7hwccg+3oa8KlVnTdpbH0dWlTqK8dz5Xk0f7PN80MmGTI2pkCs6XTE8mFtrbI2G7HOOx/nXr/jLwVrvgy7t5hbnUtJQnMq/fQdRuHce9caNdsrjTbpHieIhmbCoeQTn+lempX1TPOlHl0ZzTGGxvFQQnLJtXC9w27kf7pNNEcNy90pjYxlicBezBSP1rp5dR0m51a1lYnZufOUORlePrjFMg1yw0/UbqcsrCRYyEdfu7QQfx6flV819SOVHJI6wQQyJbMzfIPu8cEqR+dVLiN5NNkXyXjk3SLuPfjNbh8T2UEEKRBnVN5xtJO7fuFUpdZW7EwitJnZ5mlUbexUgj9aLvcnlXc5Nra5ubSMGPazSx7TjHUCoZIZFlYytsAjLc9a32N7Lp6oloVdWUZfjDDsK0dL8C3l1Kb/AFcqFwAlqO6/7X+Fa88VG8jz8RWjh1ds4zTdAu9YjViWjtiT8x4LDPb2rrdL0JLJFSNBnp0roLiFIATgIi8AAYA9ABXqHw3+D93qvk6jqcTW8BIMcLjDMOzEdhXDVxDastjyaHtcbV0WiM/wTo40HSrnX9SJjtbCEzBG7kDj8Sa8In8bXOu+Ip9RuOXnmMuMfdyemRz0r6G/ad1SPwv4Vi8OWQAe5TzZmXsq9F/E18uaLbCaNSc9aWDpJwlUluz6erN0eWnF9D6o/Zx8HW3xS1a+h1u/ig0m2iadreVVkM23HChh1+nNe1a/rul6m1t4e0Xw9BFYWv7tdy7VY9CzY6/ToK+bvgNpUd5qVglzqYsFnllSMq+x0IVcMCeuSele0eEvGltBr9zo97DP9qExjW7yCGAPUjrn3FfL5nSxLqWUvd3se5gK2GinUmry2PUbb4U32gafDPc3drp6zcR29uFjz+XJxkVJ4ps4tG8PLHAYJ9RhYtIVKPKR6Deyg/gRWlNBqt/pS2t3DY6zZKCUju3IZPp3H51lzz6boWnsr6aI1I+aG3ld1B9tzcfhXJQpUqUozqSu+26DEYqrWbhTVkcM/i6907UJ5/7WkdPLC/Zb2NY4YieyxLuaRuOxxXWeG/il4c1u9jtpbRrdtgUF1CvM4wG2xKSwHU84rzPxR4sia/NxZeHLdZkUoks9ww4PYheT+dcwni/xc0u3T/7P0vPbS7FQ/wD31gnNetNYavHWOvQ5KdPFQldSsup9ZeMbDQbPQLe9tJG06/bDeS0m0bfVgT8p71B4Y1H/AISPwnqhe/tbq0t0LMXcSEYGcKB396+Q774f+KfFshm1GXUbpW+897OyJ+Ve2/BDSZ/CGkTaUxjeOcndtHAXvXiVsJOFqlJa37WVj2FVjKn7OTuz5t8VfAzWPF/xS1qS2gXS9HlkN013cfKioeSR685rB174YeGNLS5trfWb25uo1Ijuhbf6MzehbPA9+a9W+KvxGvY/F2oabPLFJaSXAhtLfOFcDHLY/hBJ46E1ja14M1u9120gsrg/avMVp7dpB9mFuf4gD0xzX3GGxGIrQV2kkvvsfJV8LQovVNt/gfOmpabcaTdvbXKFJF59QQehB7itOLPlJnOdor074m+EE1HwtZ6naRFpre4aBgi5zHk8/gQcfWvNAMDHpXt4asq9NTR4+IouhUcBPzqiP+Qn/n0rQrNjbfqZI9SK6jmNKobv/j2l/wB2pqhu/wDj2l/3aAMWiiigDfGAMCjIAyTRu+tUtQuwimJD8x+8fQUAUrmXzp2btnA+latt/wAe8f8AuisWtq2/494/90UATVkaj/x9H8K16yNR/wCPo/hQBWoBIoooA0tOuTJlHOSOQTV3IrL03/j5P+7WpgUAQXwDWz98c1U0v/XP/u1cvB/o0n0qnpf+uf8A3aANOq2of8ep/CrNVtQ/49T+FAGRXR+APBlz458R2+nQAiHO6eXtHGOpz+grnOtfU3wd8JQeDfB6S3MLDUdSQzTSYwUj/hX24/nXNiKyowb69Dpw9J1ppdOpvXdlaaRp0NhZKttZwQ4iG3IULjn6k/mc1xeoXErztEsiqYxuKMuBCCpOSe7NyR6V1mp6mslqxmjJaKdUWPoCzH5VH94AYJx61x3ia5gtzOkqyLBaSLNfKQPnkZSVQkdM4BPooA6mvmk5Tldn1PuxjZdDovhZ4pi8O6jNcwO/kSqqujnDPHyM/nyPpXs0eoWiy+YJo4t5Gzc3yyA9CPSvmfwBcm58R3VyVaR5YQy2zEApET8pOehPX6Yr3n4a6IPE97ELlknis5MpFjkgDOD7A1bik7My5mrSidnLaQanbNFIgORhlYcivJPHXwHcSS6l4eYI8gIkscfI+epX0PtXs/idYrYm7ikEPkQu07MAqEjARAem49AB+NZt5q8sNkLYn7PdXKn96TgwxADc3pu5Cj0JJ7Vgpyi7o6XyzVnuePwfA7U724skilsppBuDLCN2flK9f7wIH5V01h+yjqWqQtPP5Nsm35VlQbi3HP6Vh+IPHM+mXcI8Pxs9w5+x6ZbQtgyyBlZpmPZUAJJ7kgetfRvhD4gQeNdNistVxpetbVU7mwjN7H69q7I1L76HLUpySvE8g0P9jqJsC7u1g5JMiKuTkegrbb9lXwL4bsTeaxqF6La3BZppSsajHOAe/wBK9K8R6m/w/sxcXupQlj8q265LyewH9elfO3xT8f6x43ut15OyWER/dWit8iD1PqfelUqKKPnsTj44e8d2ch4vt/BzagyeHNKeC2hZitzcSF3lY98HgDjiuI1MLDl3OASQB1JPoB3qbUL6WXUI7Cyje71CThLeMZI929BXtfwv/Z7nLx6t4mkMtyRuW2A+RPbFcqcpPU8mjh62YT9pU0icB8Ivg7f+J9eg1nV7TbptmrXUVq44mZFLIGHcbscV2vh/4k61D4iaDVprW6gd8NGIwjINoJK49M4/Cvd302LTrRYrXdblV2gocEDFeX+KPApvzcXCCBJ5AVEwiCvg9eRx+lZ1VJrQ+2wcaeHTgo6HyX8bbzUNT8Za/LfAhZSPJU9PLx8hH4V5V4YfACnBw20juK+if2gPDc8WiadqZTLw5s53HTA5Un+VfNWnymx1F49+CW3CvZwcuek0cmL+NM968DfDlviNomo6fY3bRanpyi6tsD77HI2exJAwa5SDXfGvhO8hlkuL6yZ/mV7hSQwHHBYc4xXXfADWlsfENzGb9rFr232rLEQHVlYPxkHHCntX1RB4V0b4j6BZnUIVn+cLcxXEYV0OfllG35cjOGCn606mIVHSceZHM6PtNYuzPmzSP2mvHNmEgk1GKeLofMhVifxrr7L4reJPEIMbPCCMMT5IIIrgvjPZwxfEe50q20u10yOxYwqLVCpkUHgt2Jx3r174d/C3U9Nayv5JLS4tZVR0COCWBGcbWwc+1FSnheWM3FK/kYxliOZxjJux23wv0CTXry2luoI7lGO5h5Y2n1GK9g1jU9D0rXV0Cy04C8WESSG2thsjBGV3MOhIrzOPxF4i8Kamj6VFFPZxsSI0td29e6ttG5W7Vp+JPENh4wvImg0jVtM1+7jWKZvP8hHAHVsfMQB6gGuFYijJtQjZryPRVCtZOpK6fmYPxA8U2em3JhnnAlJOIVOWH1HaneAtTEVpLdEsygE4fg4Ncd/wi9sVfU41EsTOViZudwU4Lc9ckHHtVXXdek0HwrqNyrGPYo2lepJOAAK83Gc9VRs7a7Hp4Tlhe6PBPi/4dvfEvxak0nRJzDcC5EayTPgDgHPsAc16TqnwnvfCfg2fXNV8Z3V5qdrGM2ZQIHUEZHBJK4z3ryyW71HxN49uL3S4vtWpSXOIYd2NzehPYcda7nxZo3xGuvCWo3raBbRwQptmZ7liQDxlQwAP4E19HQjamqdtDwq0uabk2eqeHde8KeN/h1bWMEqaJcSMJDOIQ6sq5yvPTnNfPHxW0Wz0TxXNFYuJbdhkOF2hyCQWx2zjNd38Efhp4k8QeDftUOraPbGJcQ2ZmEcjZJ3MztkLg+1c78bdKttGu9Fs0ZHv7e2eG8kjl3rJIJG+cN33A5rPCxdKu4J6dhYm1Smp9TzGeQRRM57CsuxJN2p7nNPv7kStsU/KDz7mmWH/AB9r+Ne4eQbFRXILQOAMkipaKAMLyZP7jflR5Mn9xvyrdooAzrnUFGVi5P8AeI/lVAkkk5yTRRQAVtW7qIEBIHyjvWLRQBvb1/vL+dZWokG5JBB4FVqKACiiigC1ppAnJJxxWpvX+8v51g0UAbF26m2kAYdPWqemECViSBx3qnRQBvb1/vL+dV79wbZgCD071k06ONppFRAWZiFUDqSaAPSPgX8PR418VpPdoTpdiwlmJ/jbPyr+fX2r6E8T6pKlxLDbhYtqk7Sc7gOAoqn4J8MJ4A8CWOnEiG7uFE9w2DuMjDOD7AcU28uPOUu7lip8yXK4CgYIUY9Tj64r5vFVva1eVbI+owdBUqfM92cjq2sDTSLqWaWWXT4zwi4L3DDgKO+ATz23A1xnibWEWUWlzNvdSJr5tpPmY5OD3LP8uT/Cgrc1a+8s2rzkhZna+mRjzgDhfbHy5/3a8p1XVRPHJIEeObUCZ5A7cJGMhFHtt5+popwuXUn0PQvgxdS6rrev6s0RdnVULDooJJ/pX0R4R1qTw1oQe3IE8rbic45Pb8h+teAfs2Si60rXSyEo06qVUZ2KFJJNexGdp1ji+zi2UYYp028fKv1x19yaUotT1OeVVKmkjvdT8UJfLHctGZ7kLlBcHekJI5KL0B/2jk+9ZtnpS+L72LSZUe5S4YhTvKujEfMQw5Uev8jWLbWaypkknHvW94bsJYtZsJIHMfzjdIDjYmPmJPYYpqKsYKpytSZl2/wu13wD4hvNU1jT45YWQW1pJZOZI7S3XogJAOScszEck5OKu3F4k6b4yG44PcV3LeJLmw0u4tYrx5/NJDTNxlf7qg9P96vGvFM0fh0PfwzC3hDYaE5KueyoBzuPoOK4qkW3dMqlm9H2iotb9RniC/1FrxruaWS+4C/vXLOFHGAT0HtWd4b8O678UPEL6ZpdpJp1vAR9qvrlcBAeyL/Ef9rpWo808lrbT3NpcWZnQMi3EZQnIz0qraa3f+HL5LzTLuW2mU5+Q8N7EdxXI6vK7SR21clw+In7aO/4H0P4O+C/hr4c2qNpWiQ3GoOMzX1zJvkdu7EtnqfStTVbrWILaQQGytMBirFWkbPbsP51wXgz4/wagEtdciW1mwALiMfIx9x2P6V2Gra5b3lr5lvOkisuVZWBzmuqMlJXRMqDovltZHJaTq12PGWtadeXZu5PLguw+3avzoM7V5wMg8ZrQ1J1OewHrXIee1v4+sp2Pz3elhCfUxyMv9a0df1y2sImee5ig/33A/nWji5Id0tTkvilZ2mp+GLyyniEkM6kPtHzKezD6GvgTxr4cvfC2utHMp27j5co+647Gvs7xT8TtChikjfUY5ZDkYiBf+VfOnjaZfEM05tw2xm3K0g4XBz0rswSlTk09jjxklKKa3Of8E67Jper6fqEGSbaZZGTaSSoI3AH3XNfVPxI8WXHhbRvD/iLw1rBs9Q0/NvPahQ8VxG53IWB4PynkHswPevn8y6fdxxvJbiyvY1VEnhKqoII5YY+YEfiD37V6bZeEn8badDZTu39qWUAyYWDLcQnlWBHVlztI9NvpXdKCcrnHz+55nvfwrm0r4l2N7qt3YRQaxqEaC4WSEMjkLgNGTyFPcdiK0NM8PeIPDIaxvNHmvNOVj5F1ZMr7RnoVzx+lef+FfHOr6HLGDayRzRIIYxE+1CFG0KVIOBgDpivU/B+s6pqdvDFJfQpe3TFnEkpYxKW6KvJGP614uIpz97mjdPZHbQnG6tKz7nTHTdRsrJbu31B9PkljLPM0SpKqKMsc5JJA4z64rzbwZrCar4w1IusiCWzYWxcljjcdzMT1Y5GT7V7ZfaBPBrsnmkT6PJpotVcthkOcthfc85rzCDQx4Qe/RpYrlGJ8iXbh1B6qT6VzKnVjKN1ZdT0qdSm4S1u+h5pDZazpMws76eGSys1aK2EROWUn+Ie1VvENukuhzmRDIqKXVQM8gccVu6u5urh2HOTXnfxV+JkXw4tLKA2wu7u6yTGWxsQfxf4CqnDnkox1ZtTkkry0R4uzD4a67ps0trPPq003mfZ7Ul5MH+9jo3PCjn1rv8AX/jnHc+HNQsr3TtciZomjMdxbMqqx/vEnisrSddsPEevWGradcA3No7TNuOx1yOTk9DXf+OPHl94s8GXtnHfPeyGMCNCyyEtnjBr2qSXIue9zysQ7ybhaxyfw8t9U0/wLDe2miXs1vGrMZJSIlfLFjsB+ZgM9QK4H4r6w91q1pBvUyJao0xT7u9/mIB64AYDnng16xpPxC1L4e/DyDTdU1SSOMW+xrVZgXkJXBUKOg569BXzrqeoSarfzXUuA8rFsKMADsB7AVvhYP2kpyXkjgxVRcihFlWrFgQLlSTjrVeivUPMN3zF9R+Yo8xfUfmKwqKAN3zF9R+Yo80eo/MVhUUAWZbXHKflVYjHBrSqvdQ5G8de9AFWr0SgxrkDp6VRq/D/AKpfpQA7y19B+VHlr6D8qWigBPLX0H5UeWvoPypaKAGmJG6qKry2uBlOfarVFAGbU9oAWbIB4pLmMI+QOGp1l95vpQBZ8tfQflXpfwJ8Fp4i8Wx31zGv2DTyJmJXIZ/4V/Pn8K82ALEAdScV9YeA9Is/CfgnTLKMbLiVTNcS7eWZl3H8ApArjxVT2dN92duEo+2qpPZamr4jnLmAh2RWk3OzDOFB9feuB17UJpNPvAkr+Xe3KqhP8KZAyB67QT+NdRqt8k1kDK5Ed2QqDOQi9M+3GTXEeN7oWtw88RDW1jamRUDdzgKfc4H6185TV7X3PqpNLY5jxlqMV+k9vZsFE8sdosrfxKDhh9Msxz7VzEnw+8T+M5nv9N0t5LDHlQuSEVlXjIz1GAPyrS0/RJdc8c+HdDlkLIxLzNtxkfebH/fR59q+rIBFBBFFEixRxqEVFAAUAcACumrW9ha27Pj8zx7w7UY7s5H4Y/D7TvA/h2CCCVba6mjDXSOD88u0D5j0IBzj3o8QTWHho7r3UYYpZDuVZZlBf3GTXf6XAk12hlA8sZZvoK+TfiDZWPxF8Wya1fRzS+bfNFbIWIQQo4UKF9Dgn8aVCo6zbqaI8rD4+rUdp2sfRGjSC+t4nM6wW7AASk8P6AetddLqsdvZrDGBHEi7fqfc968g8LahZ6Lp17pcoENmzFYG5IhKn5VX2zXR6PDr3jq9stI0pWtnnIWa9xuaNe5UdAcdzzU1Hf4XoeliaVeq1GGwzxL8R0tbldNslN9qsrBUt0PTPQsR90e3WvWPg98E1ubyLxJ4vniu79Ruht2x5cA9FU9K6vR/h/4I8G6RZ6ZHHDHfRLiW7mTMzuRkszdScnNX9L0bTYLsyrem6RlwEO5gD7iojC52YbBww6u9ZHWeJ/DWj+I9KNnLBDcR4+XaASvuCOlfOPjr4Hanozz3OkJLfWoOfJK/Oo9vWvpuxCrbILS0UKowGACn8qo6k97MhQoiH3Y8/lUVKSktT2aVeVN3TPha9zBdPBLG0Mq8NG4wQak03xLqei3USWU7OsjhfIY5ViTjFfR3jj4SW3ihZp7lIo7gglZIwdw/HvXhOoeEj4K160uL+VptOhkLGRVyQQOMge9cipSpu6PXjiKdaNpnK/GXxt4ms/GGkaXpUMWoMsbqsFuzJOgdgWy3TBPSvNPF9tPZ6gDLNcN5gJaO4cs8bA4ZT7ggitTX9dvLr4q6pq7xSpZQxokc5UhNpb72fyrI8UZupbtXctKLiSVWJ5O7DH88k/hX0lCF6abWtj5PEzSqtRehiBgTkdqjuJ32YAA+lQRTkLg9Rwa19CTS7o3y6jO0MqwhrZQcB2zyCe2BzVP3VczinJ2ucrezsYmUk1ueHvHOsaDbafrWl3j2uoafKbZ2ByHRgXQMp4YcODnsBWLdQq19PEHDIh4YHIIq7BYpaeH9Sd0DK/lhSeMPuO38cbvwzWlOSckZ1IySfkfbf7P/AI48LfG+xEd6kGmeJIcvcWoX5ZQP44/bHUdq9+0vwlofhyc3NuivMBwSBxX5i/CLxZP4O15b+zuRaXwwsUx6Y3Dcue24ZH419dXH7Q1/onhLTtQttNk8R3MzkXFvAu/yAPUjJGe3aortxmoxT1HSUXByk9j3XxH4gAjYFxn0rx7xRqrXLOA2QKt2vxnsvE/g251l/CV7plxCwTyLkk+YSOq4GSK8V8Q/GbWr+dotP0O0skyQJbp9uP8AvoivJq1JSlyLdHsYdQUVN7HdxuY0muDFJLHCpdgi5PsPbJwPxr5U8c+IbLxVr1xf3dvdy3TMQ0czBVQDgKAOwr2vwz+0VP8ADzSdeg1Ce01y61KERi1t1ysbA5BZsYAHoOtfON/ePqN7cXUuPMmcyNjpljmurL8PLmlKtG3ZnPjsV8MactOthZ70zQiBIooIBg+XEuAx9SepNVgAAQOAeuO9FFe8kkrI8Nybd2Rtbo3bH0qtLbNHyPmFXaKYjNqS3AMqginXMIQ7gPlP6Ult/rloAueWvoPyo8tfQflS0UAJ5a+g/Kjy19B+VLRQAUdaitn3xDPUcVLQBnyp5chHbtV2H/VL9KgvF+ZT6jFTw/6pfpQA+qlzI6ykBiBirdVbiJ3kJAyMUARec/8Aeb86POf+8350v2eT+6aPs8n900AWLaUyAg9R3qaoreExA56mpaAILsZjB9DTLL7zfSnXjcKPxptl95vpQBbVirBh1ByK+obPU9X1fwxp93HagPLbozKxC5yu3AHvtz+NfMEWPNTPTcM/TNfWl7q2m2unQQpOkIaKNI49wBWPaoBH4V5mOV4RXmetl2k2/I4PVPEVxZXdg93YzW8aK0YU/MoOOvHtmuSu9Zj1C5jRJVmSSRp3ZjxsXhV+p6Ae9eoXs+nTajdzmSNljj2wq55OeM4/SvIfFVhHFpxureILdTyAhwQCpLYAGPQAfnXmQirnszn2N74XlNb+MUEm5V8q1mjhGeGYLyB+bflX0Fc3UdnblTFJJNLhYig4Vsgkt7YzXxl4bgvY/HmjWIuWGLxI1ljbDLjk4I/GvqY6Lcucwa/qlvnqomVl/AMpxXJjIaxuz4HNpxjiE2r6G1438Z2/grwdqeouVM7wtDbRk8vIw2qB69a8E1OCPQLPw9Hcl90UO+Ty1ywYsTnHfmvZ9H+EGl6xrVtqWtXt/rEtoxkiS7nLRoRyDtGBXjnxJvhN4mlRB8iKFUegySP51pTSjTSi7u551Hl92xh6Vd3/AIk8b21hDdySQOwIUrs+Ynk4r9DPhn4Rt/A3go3AiJnMYG4DLZI65r4I+FMfn/FLSlAB43frX6TaYnm+FXjPIKf0q27s+7oSbpps858WW5u/ErvGoAMcb5znO5RXT+GLcWYcOrO0hGNo6Uw2cV3rFi2ADLaoTx6ZFdxZaPEiqVK8DFbLbQp9y9GDHajIwdvasG6Yh2Jz610EzAREMe3GK5nUmKLIc8dqmQ4mTrFyggIzgmvNPFOmW9zHK8saugU8EV2OoyBmwXYkdBXNaoDNIVYHZjqO9ZHQtj44+LvhmW18VS25nlj06ZVlWFThW55H0yBxXL+K4m/syG6iH74R71PqUOD/AOOtXuf7Qug+XDp+pIBhZGhcj0Iyv6g14tfyq2kgOA3lyH/vllKn+Yr2sPJSijxa8WpO55fL4kiiJM0ckbZ52DINUZ/E0E3CQTyHtlgBRe2xS7kjYcqSCDUYgjU5OM+1byjFPUzjJ7I09Bee9uAHVbaHuByxH1r065hsrrwhc2EUKozrvD9TvX5lOT7bh/wI15laXkcGACPrmuj0DXwdWsLYjfFJOgYf7IYFv0zXBJPni10O6DXs5KXUw7T/AFX41oWuqXti4a3u5oGHeOQr/KqUJDByBtUsSB6DPSn17J5Jb1LxTrE7IZNUu2IBxmZv8aqpdz3EQMs0khOSSzE1VvOq1Jbf6lalJLYq7JaqXTES4BI4q3VO8/1v4CqJIt7epp63Dr/ET9ajooAvQziUY6MO1SVnIxRwR2NaI55oAR1DoQe4qnb8TAfWrtVVXbd49zQBapshxGxHoadTZf8AVt9DQBQ3t6mje3qaSigCzZ9GqzUVqhWPJHXmpaAK150Wpof9Uv0qC8YblHpzU8P+qX6UAPooppkVTgkA0AOopvmp/eFKrg9CDQAtIzBFJPalooAz5HMjkmprL7zfSkuogh3DoetLZfeb6UAWq+of2bvD+nXngq91jxBZQ6nDbiUwC6jDlEjXcVGf4SxHHTivl6vsD9nu1GpfCuK0VwPPhuoQScAEjv8AmKzqJNamkG09Dxz4a6pL438Z3A1O3sYdAhWW7vdkCwiKFQfusmCrdAOetZnjTwtr+oagZdDsl/sa3xPDBcXKvcLGR8rMByMjpmvR9J8C2fgX4I+JEv5Vj17VEZJCrqfJjVvlXj1Iz+VReHbuw1Lw14X1OwiYeJIY/sE1kq83sYGCG+nB3GsXGDdrHRGVRLmueF+ERd2fjPSr+exeKG3nLSSSjYi9QTuPHGa+jIfGtqApYEAgEEFeR6jnmvHviLpeqi3nkv7eK3NpOVEcEpdEVs8HtnPp61x2kazd6fcQrFO/lB1JiJyh55+U8Vx1sFGvFO9mjzMXh44qalLRo+oB8U7aygkSN1BZSpZ3UAcemc14L4n1GTVNdurlVdlkb5WIIyAMD+VfX3iP4BaTqtvZahp+mQWMskK+YsS4BbAycdBXIXf7P8vm/Nlh67a8WMHTdrnZQymMEnzHi3wTsJ3+JOnzhWI6Yx0r9JvCahtMWKQDBUZB+lfL/wAPfg3LofiCK8JP7s4AxjivqbRIDb2qDP8ADWjfvHvKnGnBJFKa1srS7hNwWWSJQqhcAYBPGfxFaC69aQLkKACcHDD/ABqSSwtbskz28c2RzvUHI/GnJ4e0xYxixt0HoIwM1d2Rp1KF54kiZeEAQfxeYv8AjXH654ztYlf99b7gOjTKD/OuzvfDmlJHvSzgAI6qi81yGuaJZNG4itIy/qEAqW2ax5Weeal49tLeTAnjZvSJWkOf+AjFU08SvcxF4rK+vJGBwPKWJQfqSTXUWXhdJbzJgVV9Ntbr6VDbQKqKiIvrjrUWZq3FHz58SNG13xB4V1OI6fDbxKFnG+ZnlJTnC8ADvXyxq2qrDDJESMOpUg1+huo2RnDBEDZ7bcgivCfiR+zXoWstLfwQS2V0xLMsMuEYnvtI4/CuulW9no9jkrUvaao+JtUummumfGWbrjv71DHHLKQFRmY+gr2vWvhBYaK/zKXx1LyEf4VQt9P0TSQB5sIcdFT5mreWMXRE0sE5PVnDaB8Otd8RSbbO2zgZJkbao/E1evox4NvL3TkCT6lEWt5rkr8qNyrCMdfUbj26Ada7zWPGsngzT7STT7cfaryNmWSbkIAxXIXueO9eU3V1LfXMtxO5lmlYu7seWJOSa6cJz1F7SaVnsc2M9nTfs6d7rcns/wDVfiamqK14hH1qWvTPLK151WpLb/UrUd51WpLb/UrQBLVO8/1v4CrlU7z/AFv4CgCGiiigArRT7gz6VnopdwB3NaIGBQAVW/5fB/ntVmqqndd596ALVNl/1bfQ06my/wCrb6GgDPooooA0ulFFQXM20bAeT19qAK8z+ZISOnarkP8Aql+lUKvw/wCqX6UAPqld/wCuP0FXapXf+uP0FAEVAOOlFFAFu1lL5UnOO9T1TtP9b+FXKAI7gAwtntzUNl95vpU8/wDqn+lQWX3m+lAFqvV/hFfXfiLT28Lf2rNp9us32oLC2GlBADJnsDgV5RV/Q9en8M6tbajbHEkDhsf3hnkflUyV1YadmfRnxg+EGgaNY2up6U9xaWs9usj7J2KsRywYE4JryLwT41ufD/jBdYgmD6aSIhJGf9UB03DqOnJr02x8VWfj2BTfzfaNFdSYbHccRSnqXx1HoK4PxP4MsPD+qQT26oiO2dgHyFDwwbHYAmuRStLU7LOUdNkbPxa8caTqWnam1q6M2oLEQikHDhgSR7YB/OvG4JjBPHKoBKMGGfY5rNudouJAhLIGIU+2aup91foK64qyscjd3c+8Phl+2N4R1rSrDTtehuNN1IKqMwj3xu3TII5GfSvV7D4vfDbVEJXxPaQODtZJ2KMp9CGFfmb4YVG8RaaJCVQ3CbiOw3CvoXUvCVtfyzObaKYOxYjA4JPSvBx0YUZRa6nu5fF4hNN7H2Rp/iTwddgvaeJdMnUHqlwhOffmurs9a0x7YGLVbKRV4ysyn+tfnFefDJIS5Sykhc5OUOP5Vy2oeFbywLiKW7iYDLYdh/WuGM4yeh6MsM46XP1Jl1i0jjd0vLWTaM5Eyn9M1Uh8RW15Hl57dcEghp1x/OvyM1GXUbOZiLu/B9pWx/OsmfUtTjG4Xd5n1adv8a6lTT6nG4ST1P2GuvEWkwoouNZsYEA+75ygD9awNV8X+Go4iTrdsM8h1lXBH1zX5CXeoarfERtdXDknaq+YzEn0HNdXqV9dDyIGnf8AcwpGVDnAIHOfeuqnhefqclWr7LZH6J6x8ePh94VuSt7r8BuFG7Yrbj+QrzzxB+2p4EtHZLK0utSccApGAp/E18Talpkmt2dtPbAzXMSmKVARvIzlSB1PHHHpWZBo8ynEkE8ZHXKMMfXionh1B2Z0U6saiTPqHxB+29fyGQaPoEcOfutcyZwPoK8v8R/tNeOPEAZTewWMbEnbbxgH8zXnSaKki4DOc+lOj8Ml/uxySH0AJrPkijotIr6r4hv9YlZ76/muGbk7nJB/Crnh633TRs5CqCDnrVyz8GXtx8sVhLjpuddoH1JqT7NBoVw8VzKZpozgxw8Ln0LHt9KHSlVTjBDVaNBqU3Y2/iJLb3nhrS5Ij80EzxjjqCqk/qP1rz2OIyHAHHrW9q2rPq3lIyLFBECI4kHC56n3J9aoAAV6+GpOjSUG9UeBi60a9Z1IqyYiqEUAdBS0UjMFBJPArqOQq3jZcD0FTW3+pWqkjl3JPerdt/qVoAlqpcoWlyATxVuigDP2P/dP5UqwOx4B/Gr9FAEUMAi5PLVLRRQAjsEQk9hVO3OZwT7065mDnaDwOvvTbb/XLQBdpsgzGwHoadRQBn7H/un8qNj/AN0/lWhRQBXlugPlTk+tVSSTknmiigAq/D/ql+lUKKANKqV3/rj9BUVFABRRRQBNaf638KuVm0UAX5/9U/0qCy+830qvRQBpVFc/6lqpUUAXdK1q90ScTWVy9u4/ung/UdDV/XvGWp+I440u5F2J2jUKCfU4rDopcqbuO7tYK0Y/uL9BWdRTEasUrwypIhKupDKR2Ir0nTfjk1nGqXdrI0oA3vGwKufXB6H8a8horGpRp1laaub0q9Si703Y+i9N+KcfiGJmtHVZQQqx3BEXJ9CWINWNSstWkti8sdpCGXJZ75Ofwri/g58J7T4g2GoWeoRxCfCTQO0zIVU5BJ2g9ewpvxQ+AGmeAp7ESCXUIZlZ2W1uy7KqnBO1lX19a8adGhGo6aTNXnc4z9nJXZX1y0RI2M+oaZAD1P2pWI/AVyUU3h251m2sptTF40zbAtqrbcn7u5uwzjOOcVQ1uw8AadaA2bXs9zzmGdWQrz37VkaP4j03TdYs5rSyFtHHKrNI3JAzyfyreFOKezt5lPM51fdUbXNhb2K2YmztoLMkEFkyzn/gTEn9arGReSXH50viDSpNJ1F0YrJFJ+9hlQ5V0blWFZtetFJLQ45SlJ+8XWvBHyhJb1HFa/g6+vrjXo44bto5TBOYt/zIHELlQQeCCyqMVzddf8MtDXWdelcyMjWlvJOgU4LsFJA/IGiSumKN76EXh/XvGtp81rZ2Ewbne8AatLWdd+Istu8091Z6dAq7maONVwPyry+O+1BLwQW91c7zIY1jidsk5wAAK7u28F+IdHeW58R6dfQRyQlrY3yttZhg9D3AOcGuFQn5fcZSniW/j0Me1m13xHl7vWL27tt4VzEwVc+nP+Fdd4l8NCKytr2zniuIxCvnIG/eIRxlh3+ormdA1hNJW9ilikmMrq67AMAjrmtnSb8XurLJLJFaRGMxMJXGMHPJH41nz1IVEug6Tq1JuM9V3MGirOu6HqOhFHnCPbykmK4hZZI3Hsw4z7daxzPI3Vz+HFemmnqi2raMuvKsYyT+FU5pzKcDhfSoySeTRTAKu27ARKCQPxqlRQBo+YvqPzo8xfUfnWdRQBo+YvqPzo8xfUfnWdRQBeadF6nP0qvLcl+BwP51DRQAVJbkCVSTUdFAGj5i+o/OjzF9R+dZ1FAGj5i+o/OjzF9R+dZ1FADmT0ptS0x17igBtSKBtHFR1Iv3RQAuKMUUUAGKMUUUAJtB7U1kxyKfRQBFTk70jjB+tLH1NAD8UjAbTxS0jfdNAEdFFFABUuKi61LQAYqSx0641S8W3tYmllY8KP51GAScAcmvRfBuk22kwZuzMLifG8IwQBccLuPr1OKmTsrjitT0D4d31/4S1ea8S0W782FYmiV9oBUDpS/FXxlda9LBLc6ZLaeTGUwDuyCc+lVYfD5lgM9rpl35S/8ALSO8Vx+IzXLa1d3xvI9Mt5761vppBFDb3mSjMxwMFiVxXB7KPNztamM8PTdT2ltTi08O+HdStdR1jW7u7jihcQw2lkqmSVyMliWICqBj6k1jW3wxufEd6qeFbuPVbd2A8uZliniB671J7eoJFd5r507w1pV/DEZftN5A1rfabdRg7LpcYkVsfKoyTx64rykb7aYPE7RuOjKcGtnJpHVCKTuz074h6QmlWdtZCWKeTTTHaSPG2csULEA9wCK4QqD2rW8LWU+qaTqolYsoUSxljyWHU/lmsqroyvG3YKlnJtDGTHIrrfh54qXwRdf268IuY7W6t1kgwMyRsWDqD/u/0rla7vwb4Mm8R+CtWmMbC3F3EgmC8K4V2wT6cj86upJQi2zJNLVmvrfiXwLoOvXWp+A9OvY9SvZDL9r1HaRZ7jllhUDhuThj07V3vh3w9N448GSol495cXEyrJHLLv2v1DNnlTjPzfhXhMWg3i6y+nRwNJdoTlB6Dv8ATkV2PgLxBe+FNeEoLRMhKTROSoZf4lYelYqaa0Ic4t8qepz/AIt0J9Fu7qKV4rK0icxqYm3GXBxkNjn8Kd4Q+GGueLSJdK0TzrcsAbm+ZUTnuS3avQ9T1WDxfr1nLFpsDWVodsNq7ZCr3+bGck8110+jatpulyXccSWsZUkl5Sw6egxWiXMrlxk0c9L8B/FOn6NLDc6doFzbuBugsdQXzAezAD5dw9a8d8W+CtU8IXYS9tJoYJMmKSRcBhnpnpkHIPuK7N/Fvi251HyNLuL65lLbRHZx5J9uBn9a9Bkt/Ed/4OlXxX4X1NbeJCTPOpKhT1JB5H17VyyrOg1pdHdCnGunZ2aPm9OlOxXXJ8Pbq41WaC3DPBw8bEcsjAMp/IituD4XSwL++Q575rvU01dHE01ozzNxyMCnxxO6gqhP4V6ZP4HtLFA8wUjOOKqvYafb7gBwOgpcwjgGt3TGUIz7U5LV3IGMZrodYmtHUBcKVP8ACaxDqBUEKBgdD3ou3sBBNbtAQHA56VHinyzPMQXOcUyqV7agIUB7UxlIqSimBFSp96hlxyOlCfeoAkxRiiigAxRiiigAopqnI+lOoAiYYOKkX7oprjvTl+6KAFpjEg9afTGUk5xQAm4+tG4+tGxvSjY3pQA5WzwadTVUjrTqAGv0pI+poc9qE70APpG+6aWkIyMUAR0U7y/elCDvzQA1Rk5qSiigDqPBukwNKuo3iLNDG4VIWOA7defYV7DY/DC08XQ/bLuKJWcl/wDXtkZ9s4ry+wF3aeHLIWUSyXpDMq7fuKT99ieM/KAB+NTadqXjKJti6zdIO8eS4H4dKiWrK6aHT6teTfD2ae0tIbeS3Jz1bP55riPE/wAQbnxHFHbzxRxRxsWUoPnycfxHnjFetzfCXWfiZoUV5pEsR1KFQtzDOSquccMuAcE9xiuatP2PfiRqc2WgsbKLPMtzPtH16Z/Ss3sNXbseV6nr9zqkAW9nkuXjTYjyHLEDsT3+taFh8Nr3WrDZAm27IDGVzhEP90/hXu3hH9lW08O3hl8U6vb6tOv+rs7EMsakc7mY4JA64AFVPEN9pXhq4lsoJkhhjy4LMASO5Oa8+pOSklE61RcrNuyRP8Ofh74a8K6C82sajHPdLCysj4VFyPmbnk9680j+DGq6lqE509GuLFmLQTKuA6HlTz7VzMtr4r+LXiyWHw7pd3qURPlRmJTsVfVm6Ad6/Qz4U+GL7wf4D0TStRgtUu7S3VJXbaTu78n0rbDU5U5Sbe+plWlCyUFsfG1t+zR4imVc20oJ6nbXqXww8e6V+zlbv4S8UaNJqEN+ovZZNqnaWYqF2t1wqg/jX1TNrUNvHh9RsYP96VRj8M14h8cfhJoHxivLS7PjG00nVLaMxJKoV1Zc5AYcZwffvXZNOSscykluej6f8LPhn8TdOj8T6Tp0SG+gCreWp2OoH8JHYgjBBrwb42/s7NocMuowXsYKD5Sww8qf7QHQj1rW+DmieJ/gZc3VjD4t0DXdFuG3GF52iKuP4lBDYz3Heu11nVLzxvpFxJrd3psGowSkW6WVx5kbRk8BiQMH6jFcrpap9TJ0qcpKa3R8d2A1LRpPNhSKSNT/AKxZl2j+orf1jxzqd9YJbyajaokmF8tJDI59gF4z9a9J8Q+AtGvdROm3N3a2cu0S45Mbk9cMucn1HWsiDTPA3gm6Sd459fuImDLb2lq0cRI5+Z5NuR9K6E0tDblPef2e/AOneDfAsWv3dso1K+XevnAEwr2C+56k0vjLxnFdzyQtKsqMCrJnKlTwQfwrwPxh+0XretR+XBBaWdmg2xxNqEKqi9htQs1edz/Eqc3Kvea8saH70enWpkYf8CdlH44NclSNSomkj0MPKnS1kz6h0v4d6ZdWCXuiWpOyEQun39pHT9OPoBXmHxJ1RPDCsLgCCUcFCMGuV0P9qe78DaK+n+GLCRXdmZ73UpvNkZj0O0AKPp0ryXxj471vx5qcl/rV613cOSckAAfQCt6NOUYJS3OSvKNSbcdix4i8cXOovshYqgPU1z76jc3Ay8rEmqsnUUqfdrqSSMbDic8mmOeafTH60wG5NLuPrSUUASK26lqIHBqWgBCMjFMT71SUwcOaAH0HpRSHpQBHk0ZNFFADk6Gn0xOhp9ADZOgpV+6Ka9KvQUAOopP4qWgAooooAKQnA5paT+GgCMnJpyd6RvvGlTvQA+iiigAopF6UtABT4Ld7qZY0ZELfxSMFUfUnimUUAfVfg278F6V4O0Q3ulLr2oCARXH2a7HyuucDYpBYEYOelbsXxK8EaewC+Bb2FgeA1jKM/Ri3NfGzE460iXEoTYJXCf3dxxWEoXd7nRCdlsfc2k/tD6D4YmLW3hDU7RmG7Js5FUj13Hik1j9qvT9SUg6bd2pPG52RQPzYV8PRuyBtrFcjsaa55qVSXcj2zvsfQ2pfHpWuNVlku4Fhn2x28UCmWRFHLM2MDLHtuwMVybfELwJb3Iv5/Dl14o1XAxLrDhIY++FhQkdf7zNXktFVGjFO45VZSVmz1i8/aV8WAJFpLW+h2cZOy2sYgiAduFxXMaj8YPGGqOzT67dkk5O1yK46itrJGJtTeNddnB8zVLlye7SE1Sk8Saq5y19Pn1DEVSpj9qYrI0Itf1JQcX9x1/56Go5dTu5xiS5mcHszk1TTp+NPoGOnup5QA8sjY6bmJpjXUzLtMrlR2LHFNfoKbQAUUUUAPj6GnUxOhp38VADX6ilT7tI/WlTpQA6on6/hUtRt1/CgBKKKKACpO3NR1LQAU3+OnUz+I0APpD0oXpSHp+FADKKKKAP/2Q==" +} diff --git a/api/tests/fixtures/im_provider/ms_teams_webhook_activity.json b/api/tests/fixtures/im_provider/ms_teams_webhook_activity.json new file mode 100644 index 00000000000..0f6882ce206 --- /dev/null +++ b/api/tests/fixtures/im_provider/ms_teams_webhook_activity.json @@ -0,0 +1,35 @@ +{ + "type": "invoke", + "id": "test-only-event", + "timestamp": "2026-08-06T07:59:58Z", + "serviceUrl": "https://smba.trafficmanager.net/teams/", + "channelId": "msteams", + "from": { + "id": "test-only-user", + "name": "Test User" + }, + "conversation": { + "id": "test-only-conversation", + "tenantId": "test-only-tenant" + }, + "recipient": { + "id": "test-only-bot" + }, + "channelData": { + "tenant": { + "id": "test-only-tenant" + }, + "source": { + "name": "compose" + }, + "preservedNull": null + }, + "value": { + "action_id": "approve", + "correlation_token": "test-only-correlation", + "comment": "Looks good", + "choice": "One" + }, + "locale": "en-US", + "text": null +} diff --git a/api/tests/fixtures/im_provider/slack/sanitized_directory_user.json b/api/tests/fixtures/im_provider/slack/sanitized_directory_user.json new file mode 100644 index 00000000000..e33ed9d1ab0 --- /dev/null +++ b/api/tests/fixtures/im_provider/slack/sanitized_directory_user.json @@ -0,0 +1,46 @@ +{ + "id": "sanitized-id", + "name": "sanitized-name", + "is_bot": false, + "updated": 1780558873, + "is_app_user": false, + "team_id": "sanitized-team_id", + "deleted": false, + "color": "sanitized-color", + "is_email_confirmed": true, + "real_name": "sanitized-real_name", + "tz": "sanitized-tz", + "tz_label": "sanitized-tz_label", + "tz_offset": 28800, + "is_admin": true, + "is_owner": true, + "is_primary_owner": true, + "is_restricted": false, + "is_ultra_restricted": false, + "who_can_share_contact_card": "sanitized-who_can_share_contact_card", + "profile": { + "real_name": "sanitized-real_name", + "display_name": "", + "avatar_hash": "sanitized-avatar_hash", + "real_name_normalized": "sanitized-real_name_normalized", + "display_name_normalized": "", + "image_24": "https://example.invalid/image_24.png", + "image_32": "https://example.invalid/image_32.png", + "image_48": "https://example.invalid/image_48.png", + "image_72": "https://example.invalid/image_72.png", + "image_192": "https://example.invalid/image_192.png", + "image_512": "https://example.invalid/image_512.png", + "first_name": "sanitized-first_name", + "last_name": "", + "team": "sanitized-team", + "email": "user@example.invalid", + "title": "", + "phone": "", + "skype": "", + "status_text": "", + "status_text_canonical": "", + "status_emoji": "", + "status_emoji_display_info": [], + "status_expiration": 0 + } +} diff --git a/api/tests/fixtures/im_provider/wecom/sanitized_protocol.json b/api/tests/fixtures/im_provider/wecom/sanitized_protocol.json new file mode 100644 index 00000000000..368f54c707a --- /dev/null +++ b/api/tests/fixtures/im_provider/wecom/sanitized_protocol.json @@ -0,0 +1,168 @@ +{ + "credential_test": { + "token": { + "request": { + "method": "GET", + "path": "/cgi-bin/gettoken", + "query": { + "corpid": "fake-corp-001", + "corpsecret": "fake-secret-001" + }, + "headers": [], + "body": "" + }, + "response": { + "status": 200, + "headers": [["content-type", "application/json"]], + "body": { + "errcode": 0, + "errmsg": "ok", + "access_token": "fake-access-token-credential-001", + "expires_in": 7200 + } + } + }, + "agent": { + "request": { + "method": "GET", + "path": "/cgi-bin/agent/get", + "query": { + "access_token": "fake-access-token-credential-001", + "agentid": "1000001" + }, + "headers": [], + "body": "" + }, + "response": { + "status": 200, + "headers": [["content-type", "application/json"]], + "body": { + "errcode": 0, + "errmsg": "ok", + "agentid": 1000001, + "name": "Fake Application", + "square_logo_url": "https://example.invalid/fake-application.png", + "description": "Fake fixture application", + "allow_userinfos": {"user": [{"userid": "fake-direct-user-001"}]}, + "allow_partys": {"partyid": [1]}, + "allow_tags": {"tagid": [7]}, + "close": 0, + "redirect_domain": "example.invalid", + "report_location_flag": 0, + "isreportenter": 0, + "home_url": "https://example.invalid/fake-application" + } + } + } + }, + "directory": { + "departments": { + "request": { + "method": "GET", + "path": "/cgi-bin/department/list", + "query": { + "access_token": "fake-access-token-directory-001", + "id": "1" + }, + "headers": [], + "body": "" + }, + "response": { + "status": 200, + "headers": [["content-type", "application/json"]], + "body": { + "errcode": 0, + "errmsg": "ok", + "department": [ + {"id": 1, "name": "Fake Root", "parentid": 0, "order": 1}, + {"id": 2, "name": "Fake Child", "parentid": 1, "order": 2} + ] + } + } + }, + "users": { + "request": { + "method": "GET", + "path": "/cgi-bin/user/list", + "query": { + "access_token": "fake-access-token-directory-001", + "department_id": "1" + }, + "headers": [], + "body": "" + }, + "response": { + "status": 200, + "headers": [["content-type", "application/json"]], + "body": { + "errcode": 0, + "errmsg": "ok", + "userlist": [ + { + "userid": "fake-user-001", + "name": "Fake User One", + "department": [1], + "order": [1], + "position": "Fake Engineer", + "email": "fake.user.one@example.invalid", + "is_leader_in_dept": [0], + "isleader": 0, + "enable": 1, + "status": 1, + "hide_mobile": 1, + "telephone": "", + "alias": "", + "main_department": 1, + "external_profile": {"external_corp_name": "Fake Corporation"} + }, + { + "userid": "fake-user-002", + "name": "Fake User Two", + "department": [1], + "order": [2], + "position": "", + "is_leader_in_dept": [0], + "isleader": 0, + "enable": 1, + "status": 1, + "hide_mobile": 1, + "telephone": "", + "alias": "", + "main_department": 1, + "external_profile": {"external_corp_name": "Fake Corporation"} + } + ] + } + } + } + }, + "message": { + "request": { + "method": "POST", + "path": "/cgi-bin/message/send", + "query": {"access_token": "fake-access-token-message-001"}, + "headers": [["content-type", "application/json"]], + "body": { + "touser": "fake-user-001", + "toparty": "", + "totag": "", + "agentid": 1000001, + "msgtype": "text", + "text": {"content": "Fake exact text body"}, + "safe": 0 + } + }, + "response": { + "status": 200, + "headers": [["content-type", "application/json"]], + "body": { + "errcode": 0, + "errmsg": "ok", + "invaliduser": "", + "invalidparty": "", + "invalidtag": "", + "msgid": "fake-message-id-001" + } + } + } +} diff --git a/api/tests/integration_tests/repositories/human_input_v2/test_im_control_plane_concurrency.py b/api/tests/integration_tests/repositories/human_input_v2/test_im_control_plane_concurrency.py new file mode 100644 index 00000000000..a4a1ad7f5ba --- /dev/null +++ b/api/tests/integration_tests/repositories/human_input_v2/test_im_control_plane_concurrency.py @@ -0,0 +1,178 @@ +"""PostgreSQL-only concurrency coverage for Channel-bound IM persistence.""" + +from concurrent.futures import ThreadPoolExecutor +from datetime import datetime +from threading import Barrier + +import pytest +import sqlalchemy as sa +from sqlalchemy.orm import Session, sessionmaker + +from core.human_input_v2.entities import IMProvider +from core.human_input_v2.im_integration import ActiveRunDecisionKind +from core.human_input_v2.shared import ContactId, IMIdentityId, IMSyncRunId +from extensions.ext_database import db +from libs.datetime_utils import naive_utc_now +from libs.uuid_utils import uuidv7 +from models.human_input_v2 import ( + HumanInputIMBinding, + HumanInputIMBindingWorkspaceOverride, + HumanInputIMChannel, + HumanInputIMIdentity, + HumanInputIMReconciliationChange, + HumanInputIMSyncResult, + HumanInputIMSyncRun, +) +from repositories.human_input_v2.channel_credentials import ChannelEncryptedCredentials +from repositories.human_input_v2.im_binding_repository import IMBindingAssignment, IMBindingConflictError +from repositories.human_input_v2.im_channel_repository import ( + IMChannel, + IMChannelId, + IMChannelStatus, + WebhookId, +) +from repositories.human_input_v2.im_identity_repository import IMIdentityObservation, OpaqueProviderPayload +from repositories.human_input_v2.im_integration.repository import SQLAlchemyIMControlPlaneRepository +from repositories.human_input_v2.sqlalchemy_im_binding_repository import SQLAlchemyIMBindingRepository +from repositories.human_input_v2.sqlalchemy_im_channel_repository import DeploymentIMChannelWriter +from repositories.human_input_v2.sqlalchemy_im_identity_repository import SQLAlchemyIMIdentityRepository + + +def _require_postgresql() -> None: + if db.engine.dialect.name != "postgresql": + pytest.skip("requires the CI PostgreSQL integration database") + + +def _channel() -> IMChannel: + now = naive_utc_now() + return IMChannel( + id=IMChannelId(str(uuidv7())), + created_at=now, + updated_at=now, + provider=IMProvider.FEISHU, + provider_tenant_id=f"provider-{uuidv7()}", + encrypted_credentials=ChannelEncryptedCredentials(ciphertext="opaque-ciphertext"), + app_identifier="app-1", + webhook_id=WebhookId(uuidv7().hex), + config_version=1, + status=IMChannelStatus.CONNECTED, + ) + + +def _cleanup(session_maker: sessionmaker[Session], channel_id: IMChannelId) -> None: + with session_maker.begin() as session: + run_ids = session.scalars( + sa.select(HumanInputIMSyncRun.id).where(HumanInputIMSyncRun.channel_id == str(channel_id)) + ).all() + if run_ids: + session.execute( + sa.delete(HumanInputIMReconciliationChange).where( + HumanInputIMReconciliationChange.sync_run_id.in_(run_ids) + ) + ) + session.execute(sa.delete(HumanInputIMSyncResult).where(HumanInputIMSyncResult.sync_run_id.in_(run_ids))) + session.execute( + sa.delete(HumanInputIMBindingWorkspaceOverride).where( + HumanInputIMBindingWorkspaceOverride.channel_id == str(channel_id) + ) + ) + session.execute(sa.delete(HumanInputIMBinding).where(HumanInputIMBinding.channel_id == str(channel_id))) + session.execute(sa.delete(HumanInputIMIdentity).where(HumanInputIMIdentity.channel_id == str(channel_id))) + session.execute(sa.delete(HumanInputIMSyncRun).where(HumanInputIMSyncRun.channel_id == str(channel_id))) + session.execute(sa.delete(HumanInputIMChannel).where(HumanInputIMChannel.id == str(channel_id))) + + +def _persist_channel(session_maker: sessionmaker[Session], channel: IMChannel) -> None: + with session_maker.begin() as session: + DeploymentIMChannelWriter(session).create(channel) + + +def test_concurrent_sync_triggers_create_at_most_one_active_run(flask_req_ctx: object) -> None: + _require_postgresql() + session_maker = sessionmaker(bind=db.engine, expire_on_commit=False) + channel = _channel() + _persist_channel(session_maker, channel) + barrier = Barrier(2) + + def trigger(_index: int) -> ActiveRunDecisionKind: + barrier.wait() + with session_maker.begin() as session: + decision = SQLAlchemyIMControlPlaneRepository(session, channel).create_or_get_active_run( + sync_run_id=IMSyncRunId(str(uuidv7())), + started_by_account_id=None, + now=naive_utc_now(), + ) + return decision.kind + + try: + with ThreadPoolExecutor(max_workers=2) as executor: + outcomes = list(executor.map(trigger, range(2))) + + assert outcomes.count(ActiveRunDecisionKind.CREATED) == 1 + assert outcomes.count(ActiveRunDecisionKind.EXISTING_ACTIVE) == 1 + with session_maker() as session: + count = session.scalar( + sa.select(sa.func.count(HumanInputIMSyncRun.id)).where( + HumanInputIMSyncRun.channel_id == str(channel.id) + ) + ) + assert count == 1 + finally: + _cleanup(session_maker, channel.id) + + +def test_concurrent_default_binding_conflict_commits_at_most_one_endpoint(flask_req_ctx: object) -> None: + _require_postgresql() + session_maker = sessionmaker(bind=db.engine, expire_on_commit=False) + channel = _channel() + _persist_channel(session_maker, channel) + identity_ids = (IMIdentityId(str(uuidv7())), IMIdentityId(str(uuidv7()))) + contact_id = ContactId(str(uuidv7())) + run_id = IMSyncRunId(str(uuidv7())) + now = naive_utc_now() + with session_maker.begin() as session: + identities = SQLAlchemyIMIdentityRepository(session, channel.id) + for index, identity_id in enumerate(identity_ids): + identities.create( + identity_id, + IMIdentityObservation( + provider_user_id=f"provider-user-{index}", + display_name=None, + email=None, + raw_payload=OpaqueProviderPayload({}), + sync_run_id=run_id, + observed_at=now, + ), + ) + barrier = Barrier(2) + + def bind(identity_id: IMIdentityId) -> str: + barrier.wait() + try: + with session_maker.begin() as session: + SQLAlchemyIMBindingRepository(session, channel.id).create( + IMBindingAssignment(contact_id, identity_id, datetime.fromtimestamp(now.timestamp())), + bound_by_account_id=None, + ) + return "created" + except IMBindingConflictError: + return "conflict" + + try: + with ThreadPoolExecutor(max_workers=2) as executor: + outcomes = list(executor.map(bind, identity_ids)) + + assert outcomes.count("created") == 1 + assert outcomes.count("conflict") == 1 + with session_maker() as session: + assert ( + session.scalar( + sa.select(sa.func.count(HumanInputIMBinding.id)).where( + HumanInputIMBinding.channel_id == str(channel.id), + HumanInputIMBinding.contact_id == str(contact_id), + ) + ) + == 1 + ) + finally: + _cleanup(session_maker, channel.id) diff --git a/api/tests/integration_tests/repositories/human_input_v2/test_portable_delivery_binding_writes.py b/api/tests/integration_tests/repositories/human_input_v2/test_portable_delivery_binding_writes.py new file mode 100644 index 00000000000..20d393e8968 --- /dev/null +++ b/api/tests/integration_tests/repositories/human_input_v2/test_portable_delivery_binding_writes.py @@ -0,0 +1,243 @@ +"""Delivery and Binding write contracts on MySQL and PostgreSQL.""" + +import os +from collections.abc import Iterator +from concurrent.futures import ThreadPoolExecutor +from contextlib import ExitStack +from threading import Barrier, Event +from uuid import uuid4 + +import pytest +import sqlalchemy as sa +from sqlalchemy import event +from sqlalchemy.engine import Engine +from sqlalchemy.orm import ORMExecuteState, Session +from testcontainers.community.mysql import MySqlContainer +from testcontainers.community.postgres import PostgresContainer + +from core.human_input_v2.entities import IMProvider +from core.human_input_v2.im_integration.adapters.message_locator import MessageLocator +from core.human_input_v2.shared.values import ContactId, IMBindingId, IMIdentityId, RecipientId, TenantId +from libs.datetime_utils import naive_utc_now +from models.human_input_v2 import ( + HumanInputDelivery, + HumanInputIMBinding, + HumanInputIMBindingWorkspaceOverride, + HumanInputIMIdentity, + IMIdentityRawPayload, +) +from repositories.human_input_v2.delivery_repository import ( + DeliveryCreateParams, + IMMessageType, + IMUserTargetSnapshot, + InitiatorSnapshot, + SubmissionAuthType, +) +from repositories.human_input_v2.im_binding_repository import IMBinding, IMBindingAssignment, IMBindingConflictError +from repositories.human_input_v2.im_channel_repository import IMChannelId +from repositories.human_input_v2.sqlalchemy_delivery_repository import SQLAlchemyDeliveryRepository +from repositories.human_input_v2.sqlalchemy_im_binding_repository import SQLAlchemyIMBindingRepository + + +@pytest.fixture(scope="module", params=["mysql", "postgresql"]) +def write_engine(request: pytest.FixtureRequest) -> Iterator[Engine]: + """Allow an isolated external database, otherwise start a disposable container.""" + dialect = request.param + external_url = os.environ.get(f"HITL_WRITE_TEST_{dialect.upper()}_URL") + with ExitStack() as stack: + if external_url is None: + container = stack.enter_context( + MySqlContainer("mysql:8.0", dialect="pymysql") + if dialect == "mysql" + else PostgresContainer("postgres:15-alpine") + ) + external_url = container.get_connection_url() + engine = sa.create_engine(external_url) + tables = [ + HumanInputDelivery.metadata.tables[model.__tablename__] + for model in ( + HumanInputDelivery, + HumanInputIMIdentity, + HumanInputIMBinding, + HumanInputIMBindingWorkspaceOverride, + ) + ] + try: + HumanInputDelivery.metadata.create_all(engine, tables=tables) + yield engine + finally: + engine.dispose() + + +def _delivery_params(*, im: bool) -> DeliveryCreateParams: + return DeliveryCreateParams( + recipient_id=RecipientId(str(uuid4())), + token_hash=uuid4().hex * 2, + auth_type=SubmissionAuthType.IM if im else SubmissionAuthType.CONSOLE, + target_snapshot=( + IMUserTargetSnapshot( + im_provider=IMProvider.SLACK, + im_tenant_id="team", + im_provider_user_id="user", + im_binding_id=IMBindingId(str(uuid4())), + message_type=IMMessageType.CARD, + ) + if im + else InitiatorSnapshot(protected_form_token="encrypted-token") + ), + ) + + +def test_initiator_delivery_round_trip_and_caller_rollback(write_engine: Engine) -> None: + tenant_id, form_id = TenantId(str(uuid4())), str(uuid4()) + params = _delivery_params(im=False) + with Session(write_engine) as session, session.begin(): + delivery = SQLAlchemyDeliveryRepository(session).create_delivery( + tenant_id=tenant_id, form_id=form_id, params=params + ) + with Session(write_engine) as session: + repository = SQLAlchemyDeliveryRepository(session) + assert repository.get_initiator_delivery(tenant_id=tenant_id, form_id=form_id) == delivery + assert delivery.target_snapshot == params.target_snapshot + assert delivery.recipient_id == params.recipient_id + rolled_back = repository.create_delivery( + tenant_id=tenant_id, form_id=form_id, params=_delivery_params(im=False) + ) + session.rollback() + assert repository.get_delivery(tenant_id=tenant_id, form_id=form_id, delivery_id=rolled_back.id) is None + + +def test_locator_assignment_is_serialized_idempotent_and_owner_scoped(write_engine: Engine) -> None: + tenant_id, form_id = TenantId(str(uuid4())), str(uuid4()) + with Session(write_engine) as session, session.begin(): + delivery = SQLAlchemyDeliveryRepository(session).create_delivery( + tenant_id=tenant_id, form_id=form_id, params=_delivery_params(im=True) + ) + barrier = Barrier(2) + + def assign(locator: MessageLocator) -> bool: + barrier.wait(timeout=10) + try: + with Session(write_engine) as session, session.begin(): + assigned = SQLAlchemyDeliveryRepository(session).set_im_message_locator( + tenant_id=tenant_id, form_id=form_id, delivery_id=delivery.id, message_locator=locator + ) + assert assigned is not None + return True + except ValueError as error: + if "already has a different message locator" not in str(error): + raise + return False + + with ThreadPoolExecutor(max_workers=2) as executor: + assert sorted(executor.map(assign, (MessageLocator("card-one"), MessageLocator("card-two")))) == [False, True] + with Session(write_engine) as session, session.begin(): + repository = SQLAlchemyDeliveryRepository(session) + saved = repository.get_delivery(tenant_id=tenant_id, form_id=form_id, delivery_id=delivery.id) + assert saved is not None + assert isinstance(saved.target_snapshot, IMUserTargetSnapshot) + locator = saved.target_snapshot.message_locator + assert locator is not None + assert locator in (MessageLocator("card-one"), MessageLocator("card-two")) + assert ( + repository.set_im_message_locator( + tenant_id=tenant_id, form_id=form_id, delivery_id=delivery.id, message_locator=locator + ) + == saved + ) + assert ( + repository.set_im_message_locator( + tenant_id=TenantId(str(uuid4())), form_id=form_id, delivery_id=delivery.id, message_locator=locator + ) + is None + ) + + +@pytest.fixture +def binding_scope(write_engine: Engine) -> tuple[IMChannelId, ContactId, tuple[IMIdentityId, ...]]: + channel_id, contact_id = IMChannelId(str(uuid4())), ContactId(str(uuid4())) + identity_ids = tuple(IMIdentityId(str(uuid4())) for _ in range(3)) + with Session(write_engine) as session, session.begin(): + for identity_id in identity_ids: + record = HumanInputIMIdentity( + channel_id=str(channel_id), + provider_user_id=str(identity_id), + raw_payload=IMIdentityRawPayload({}), + last_seen_sync_run_id=str(uuid4()), + last_seen_at=naive_utc_now(), + ) + record.id = str(identity_id) + session.add(record) + return channel_id, contact_id, identity_ids + + +@pytest.mark.parametrize("workspace", [False, True]) +def test_binding_replacement_preserves_ids_only_for_identical_assignments_and_rolls_back( + write_engine: Engine, binding_scope: tuple[IMChannelId, ContactId, tuple[IMIdentityId, ...]], workspace: bool +) -> None: + channel_id, contact_id, identities = binding_scope + tenant_id = TenantId(str(uuid4())) + + def replace(session: Session, identity_id: IMIdentityId) -> IMBinding: + repository = SQLAlchemyIMBindingRepository(session, channel_id) + assignment = IMBindingAssignment(contact_id, identity_id, naive_utc_now().replace(microsecond=0)) + if workspace: + return repository.set_workspace_override(tenant_id, assignment, bound_by_account_id=None) + return repository.replace(assignment, bound_by_account_id=None) + + with Session(write_engine) as session, session.begin(): + original = replace(session, identities[0]) + with Session(write_engine) as session, session.begin(): + updated = replace(session, identities[1]) + assert updated.id != original.id + assert updated.identity_id == identities[1] + assert replace(session, identities[1]) == updated + with Session(write_engine) as session: + assert replace(session, identities[2]).id != updated.id + session.rollback() + with Session(write_engine) as session: + assert SQLAlchemyIMBindingRepository(session, channel_id).get_effective(tenant_id, contact_id) == updated + + +@pytest.mark.parametrize("workspace", [False, True]) +def test_stale_binding_replacement_cannot_delete_a_concurrent_replacement( + write_engine: Engine, binding_scope: tuple[IMChannelId, ContactId, tuple[IMIdentityId, ...]], workspace: bool +) -> None: + channel_id, contact_id, identities = binding_scope + tenant_id = TenantId(str(uuid4())) + observed, replaced = Event(), Event() + + def replace(session: Session, identity_id: IMIdentityId) -> IMBinding: + repository = SQLAlchemyIMBindingRepository(session, channel_id) + assignment = IMBindingAssignment(contact_id, identity_id, naive_utc_now().replace(microsecond=0)) + if workspace: + return repository.set_workspace_override(tenant_id, assignment, bound_by_account_id=None) + return repository.replace(assignment, bound_by_account_id=None) + + with Session(write_engine) as session, session.begin(): + replace(session, identities[0]) + + def pause_before_delete(state: ORMExecuteState) -> None: + if state.is_delete: + observed.set() + assert replaced.wait(timeout=10) + + def stale_replace() -> None: + with Session(write_engine) as session: + # Pause at the database boundary after the old Binding was observed. + event.listen(session, "do_orm_execute", pause_before_delete) + with pytest.raises(IMBindingConflictError, match="changed concurrently"): + replace(session, identities[2]) + session.rollback() + + with ThreadPoolExecutor(max_workers=1) as executor: + stale = executor.submit(stale_replace) + try: + assert observed.wait(timeout=10) + with Session(write_engine) as session, session.begin(): + winner = replace(session, identities[1]) + finally: + replaced.set() + stale.result(timeout=10) + with Session(write_engine) as session: + assert SQLAlchemyIMBindingRepository(session, channel_id).get_effective(tenant_id, contact_id) == winner diff --git a/api/tests/integration_tests/repositories/human_input_v2/test_stale_im_sync_runs.py b/api/tests/integration_tests/repositories/human_input_v2/test_stale_im_sync_runs.py new file mode 100644 index 00000000000..7e5cc2cfd06 --- /dev/null +++ b/api/tests/integration_tests/repositories/human_input_v2/test_stale_im_sync_runs.py @@ -0,0 +1,339 @@ +"""Stale sync lifecycle and serialization on real MySQL and PostgreSQL.""" + +import os +from collections.abc import Iterator +from concurrent.futures import ThreadPoolExecutor +from contextlib import ExitStack +from dataclasses import dataclass, replace +from threading import Barrier, Event +from uuid import uuid4 + +import pytest +import sqlalchemy as sa +from sqlalchemy.engine import Engine +from sqlalchemy.orm import Session, sessionmaker +from testcontainers.community.mysql import MySqlContainer +from testcontainers.community.postgres import PostgresContainer + +from core.human_input_v2.entities import IMProvider, IMSyncRunStatus +from core.human_input_v2.im_integration import IMSyncRun +from core.human_input_v2.im_integration.adapters import ( + CredentialTestFailure, + CredentialTestFailureKind, + Directory, + DirectoryEntry, + IMCardEventDecoder, + IMDynamicCardMessaging, + IMEventConsumer, + IMEventStream, + IMMessaging, + IMWebhookHandler, + ProviderUserId, +) +from core.human_input_v2.shared import AccountId, ContactId, DirectoryScope, IMSyncRunId, TenantId, WorkspaceScope +from libs.datetime_utils import naive_utc_now +from models.human_input_v2 import ( + HumanInputIMBinding, + HumanInputIMBindingWorkspaceOverride, + HumanInputIMChannel, + HumanInputIMIdentity, + HumanInputIMReconciliationChange, + HumanInputIMSyncResult, + HumanInputIMSyncRun, +) +from repositories.human_input_v2.channel_credentials import ChannelEncryptedCredentials +from repositories.human_input_v2.contact import Contact +from repositories.human_input_v2.im_channel_repository import IMChannel, IMChannelId, IMChannelStatus, WebhookId +from repositories.human_input_v2.im_integration.repository import SQLAlchemyIMControlPlaneRepository +from repositories.human_input_v2.sqlalchemy_im_channel_repository import ( + WorkspaceIMChannelReader, + WorkspaceIMChannelWriter, +) +from services.human_input_v2.im_contact_sync.coordinator import IMChannelReconciliationService +from services.human_input_v2.im_contact_sync.service import IMSyncRunNotFoundError, IMSyncService +from services.human_input_v2.im_contact_sync.worker import IMContactSyncWorker + + +@pytest.fixture(scope="module", params=["mysql", "postgresql"]) +def sync_engine(request: pytest.FixtureRequest) -> Iterator[Engine]: + dialect = request.param + external_url = os.environ.get(f"HITL_SYNC_TEST_{dialect.upper()}_URL") + with ExitStack() as stack: + if external_url is None: + container = stack.enter_context( + MySqlContainer("mysql:8.0", dialect="pymysql") + if dialect == "mysql" + else PostgresContainer("postgres:15-alpine") + ) + external_url = container.get_connection_url() + engine = sa.create_engine(external_url) + models = ( + HumanInputIMChannel, + HumanInputIMSyncRun, + HumanInputIMSyncResult, + HumanInputIMIdentity, + HumanInputIMBinding, + HumanInputIMBindingWorkspaceOverride, + HumanInputIMReconciliationChange, + ) + try: + HumanInputIMChannel.metadata.create_all( + engine, tables=[HumanInputIMChannel.metadata.tables[model.__tablename__] for model in models] + ) + yield engine + finally: + engine.dispose() + + +_ACCOUNT_ID = AccountId("00000000-0000-0000-0000-000000000201") + + +class _EmptyContacts: + def list_contacts(self, page: int, limit: int) -> tuple[Contact, ...]: + return () + + def get_contact(self, contact_id: ContactId) -> Contact | None: + return None + + +class _Directory: + def __init__(self, entered: Event | None = None, resume: Event | None = None) -> None: + self._entered = entered + self._resume = resume + + def read_directory(self) -> Directory: + if self._entered is not None: + self._entered.set() + if self._resume is not None: + assert self._resume.wait(timeout=20) + return Directory((DirectoryEntry(ProviderUserId("user-1"), "Sync reviewer", "reviewer@example.com"),)) + + +class _Adapter: + provider = IMProvider.FEISHU + + def __init__(self, directory: _Directory) -> None: + self.directory = directory + + @classmethod + def card_event_decoder(cls) -> IMCardEventDecoder | None: + return None + + def test_credentials(self) -> CredentialTestFailure: + return CredentialTestFailure(CredentialTestFailureKind.UNKNOWN, "not exercised") + + @property + def messaging(self) -> IMMessaging: + raise AssertionError("sync must not send messages") + + @property + def dynamic_card_messaging(self) -> IMDynamicCardMessaging | None: + return None + + def create_webhook_handler(self, consumer: IMEventConsumer) -> IMWebhookHandler | None: + return None + + def create_stream_handler(self, consumer: IMEventConsumer) -> IMEventStream | None: + return None + + def close(self) -> None: + pass + + +@dataclass(frozen=True) +class _SyncContext: + sessions: sessionmaker[Session] + scope: WorkspaceScope + channel: IMChannel + + def resolve_channel(self, session: Session, scope: DirectoryScope) -> IMChannel | None: + assert scope == self.scope + return WorkspaceIMChannelReader(session, self.scope.id).get() + + def service(self) -> IMSyncService: + return IMSyncService(self.sessions, self.resolve_channel, lambda _run, _scope: None) + + def worker(self, directory: _Directory | None = None) -> IMContactSyncWorker: + def reconciliation(scope: DirectoryScope) -> IMChannelReconciliationService: + with self.sessions() as session: + channel = self.resolve_channel(session, scope) + assert channel is not None + return IMChannelReconciliationService( + self.sessions, + channel, + lambda _channel: _Adapter(directory or _Directory()), + lambda _session: _EmptyContacts(), + ) + + return IMContactSyncWorker(self.service(), reconciliation) + + def rotate(self) -> IMChannel: + updated = replace(self.channel, config_version=2, updated_at=naive_utc_now()) + with self.sessions.begin() as session: + return WorkspaceIMChannelWriter(session, self.scope.id, _ACCOUNT_ID).update(updated, 1) + + +@pytest.fixture +def sync_context(sync_engine: Engine) -> _SyncContext: + now = naive_utc_now() + channel = IMChannel( + id=IMChannelId(str(uuid4())), + provider=IMProvider.FEISHU, + provider_tenant_id="test-tenant", + encrypted_credentials=ChannelEncryptedCredentials(ciphertext="test-envelope"), + app_identifier="test-app", + webhook_id=WebhookId(uuid4().hex), + config_version=1, + status=IMChannelStatus.CONNECTED, + created_at=now, + updated_at=now, + ) + context = _SyncContext( + sessionmaker(sync_engine, expire_on_commit=False), WorkspaceScope(TenantId(str(uuid4()))), channel + ) + with context.sessions.begin() as session: + WorkspaceIMChannelWriter(session, context.scope.id, _ACCOUNT_ID).create(channel) + return context + + +@pytest.mark.parametrize("status", [IMSyncRunStatus.QUEUED, IMSyncRunStatus.RUNNING]) +@pytest.mark.parametrize("recovery", ["worker", "trigger"]) +def test_revision_rotation_retires_old_run_and_new_worker_succeeds( + sync_context: _SyncContext, status: IMSyncRunStatus, recovery: str +) -> None: + old = sync_context.service().create_or_get_active_run(sync_context.scope, None) + with sync_context.sessions.begin() as session: + session.get_one(HumanInputIMSyncRun, str(old.id)).status = status + sync_context.rotate() + if recovery == "worker": + terminal = sync_context.worker().execute(old.id, sync_context.scope) + assert terminal.status is IMSyncRunStatus.FAILED + assert terminal.error_code == "stale_revision" + current = sync_context.service().create_or_get_active_run(sync_context.scope, None) + assert current.id != old.id + assert current.channel_revision.config_version == 2 + assert current.status is IMSyncRunStatus.QUEUED + assert sync_context.worker().execute(current.id, sync_context.scope).status is IMSyncRunStatus.SUCCEEDED + assert sync_context.worker().execute(old.id, sync_context.scope).status is IMSyncRunStatus.FAILED + with sync_context.sessions() as session: + saved = session.get_one(HumanInputIMSyncRun, str(old.id)) + assert saved.status is IMSyncRunStatus.FAILED + assert saved.finished_at is not None + assert saved.failed_count == 1 + facts = session.scalars( + sa.select(HumanInputIMSyncResult).where(HumanInputIMSyncResult.sync_run_id == str(old.id)) + ).all() + assert len(facts) == 1 + assert facts[0].reason_code == "stale_revision" + identities = session.scalars( + sa.select(HumanInputIMIdentity).where(HumanInputIMIdentity.channel_id == str(sync_context.channel.id)) + ).all() + assert len(identities) == 1 + assert identities[0].last_seen_sync_run_id == str(current.id) + + +def test_concurrent_old_workers_and_triggers_leave_one_new_active_run(sync_context: _SyncContext) -> None: + old = sync_context.service().create_or_get_active_run(sync_context.scope, None) + sync_context.rotate() + barrier = Barrier(4) + + def execute(trigger: bool) -> IMSyncRun: + barrier.wait(timeout=10) + if trigger: + return sync_context.service().create_or_get_active_run(sync_context.scope, None) + return sync_context.worker().execute(old.id, sync_context.scope) + + with ThreadPoolExecutor(max_workers=4) as executor: + runs = tuple(executor.map(execute, (True, True, False, False))) + assert runs[0].id == runs[1].id != old.id + assert runs[0].channel_revision.config_version == 2 + assert runs[2].status is runs[3].status is IMSyncRunStatus.FAILED + with sync_context.sessions() as session: + active = session.scalars( + sa.select(HumanInputIMSyncRun).where( + HumanInputIMSyncRun.channel_id == str(sync_context.channel.id), + HumanInputIMSyncRun.status.in_((IMSyncRunStatus.QUEUED, IMSyncRunStatus.RUNNING)), + ) + ).all() + assert [run.id for run in active] == [str(runs[0].id)] + assert ( + session.scalar( + sa.select(sa.func.count(HumanInputIMSyncResult.id)).where( + HumanInputIMSyncResult.sync_run_id == str(old.id) + ) + ) + == 1 + ) + + +def test_late_old_apply_cannot_overwrite_new_revision_state(sync_context: _SyncContext) -> None: + old = sync_context.service().create_or_get_active_run(sync_context.scope, None) + entered, resume = Event(), Event() + with ThreadPoolExecutor(max_workers=1) as executor: + pending = executor.submit(sync_context.worker(_Directory(entered, resume)).execute, old.id, sync_context.scope) + try: + assert entered.wait(timeout=10) + sync_context.rotate() + current = sync_context.service().create_or_get_active_run(sync_context.scope, None) + assert sync_context.worker().execute(current.id, sync_context.scope).status is IMSyncRunStatus.SUCCEEDED + finally: + resume.set() + assert pending.result(timeout=20).status is IMSyncRunStatus.FAILED + with sync_context.sessions() as session: + identity = session.scalar( + sa.select(HumanInputIMIdentity).where(HumanInputIMIdentity.channel_id == str(sync_context.channel.id)) + ) + assert identity is not None + assert identity.last_seen_sync_run_id == str(current.id) + assert ( + session.scalar( + sa.select(sa.func.count(HumanInputIMSyncResult.id)).where( + HumanInputIMSyncResult.sync_run_id == str(old.id) + ) + ) + == 1 + ) + + +def test_stale_completion_is_scoped_current_and_caller_rollback_owned(sync_context: _SyncContext) -> None: + old = sync_context.service().create_or_get_active_run(sync_context.scope, None) + current = sync_context.rotate() + with sync_context.sessions() as session: + assert ( + SQLAlchemyIMControlPlaneRepository(session, sync_context.channel).finish_stale_run( + old.id, now=naive_utc_now() + ) + is None + ) + with sync_context.sessions() as session: + replacement = SQLAlchemyIMControlPlaneRepository(session, current).create_or_get_active_run( + sync_run_id=IMSyncRunId(str(uuid4())), started_by_account_id=None, now=naive_utc_now() + ) + assert replacement.run is not None + assert replacement.run.id != old.id + session.rollback() + with sync_context.sessions() as session: + assert session.get_one(HumanInputIMSyncRun, str(old.id)).status is IMSyncRunStatus.QUEUED + assert session.get(HumanInputIMSyncRun, str(replacement.run.id)) is None + assert ( + session.scalar( + sa.select(sa.func.count(HumanInputIMSyncResult.id)).where( + HumanInputIMSyncResult.sync_run_id == str(old.id) + ) + ) + == 0 + ) + foreign = replace(current, id=IMChannelId(str(uuid4())), webhook_id=WebhookId(uuid4().hex), config_version=1) + foreign_scope = WorkspaceScope(TenantId(str(uuid4()))) + with sync_context.sessions.begin() as session: + WorkspaceIMChannelWriter(session, foreign_scope.id, _ACCOUNT_ID).create(foreign) + service = IMChannelReconciliationService( + sync_context.sessions, foreign, lambda _channel: _Adapter(_Directory()), lambda _session: _EmptyContacts() + ) + with pytest.raises(IMSyncRunNotFoundError): + service.reconcile(old.id) + with sync_context.sessions.begin() as session: + assert ( + SQLAlchemyIMControlPlaneRepository(session, foreign).finish_stale_run(old.id, now=naive_utc_now()) is None + ) + assert session.get_one(HumanInputIMSyncRun, str(old.id)).status is IMSyncRunStatus.QUEUED diff --git a/api/tests/unit_tests/configs/test_app_config.py b/api/tests/unit_tests/configs/test_app_config.py index 9a73057d179..ed5a9adf4d8 100644 --- a/api/tests/unit_tests/configs/test_app_config.py +++ b/api/tests/unit_tests/configs/test_app_config.py @@ -10,6 +10,7 @@ from pydantic_settings import BaseSettings, PydanticBaseSettingsSource from yarl import URL from configs.app_config import DifyConfig +from configs.deploy import IMEventTransportMode from configs.feature import OpsTraceConfig from enums import DeploymentEdition @@ -42,6 +43,32 @@ def test_ops_trace_config_accepts_parent_context_ttl_covering_retry_window() -> ) +@pytest.mark.parametrize( + ("value", "expected"), + [ + ("WEBHOOK", IMEventTransportMode.WEBHOOK), + ("STREAM", IMEventTransportMode.STREAM), + ], +) +def test_im_event_transport_mode_loads_both_supported_values( + value: str, + expected: IMEventTransportMode, +) -> None: + config = _make_config(HUMAN_INPUT_IM_EVENT_TRANSPORT_MODE=value) + + assert config.HUMAN_INPUT_IM_EVENT_TRANSPORT_MODE is expected + assert {mode.value for mode in IMEventTransportMode} == {"WEBHOOK", "STREAM"} + + +def test_im_event_transport_mode_defaults_to_webhook() -> None: + assert _IsolatedDifyConfig().HUMAN_INPUT_IM_EVENT_TRANSPORT_MODE == IMEventTransportMode.WEBHOOK + + +def test_im_event_transport_mode_rejects_invalid_values() -> None: + with pytest.raises(ValidationError, match="HUMAN_INPUT_IM_EVENT_TRANSPORT_MODE"): + _make_config(HUMAN_INPUT_IM_EVENT_TRANSPORT_MODE="SOCKET") + + class _IsolatedDifyConfig(DifyConfig): """Load explicit test values and packaging metadata without consulting process state.""" @@ -66,6 +93,7 @@ class _IsolatedDifyConfig(DifyConfig): def _make_config(**values: object) -> DifyConfig: + values.setdefault("HUMAN_INPUT_IM_EVENT_TRANSPORT_MODE", "WEBHOOK") # Pydantic exposes a generated field-wise signature, while this helper intentionally forwards heterogeneous input. factory = cast(Callable[..., _IsolatedDifyConfig], _IsolatedDifyConfig) return factory(**values) diff --git a/api/tests/unit_tests/controllers/common/test_access_response.py b/api/tests/unit_tests/controllers/common/test_access_response.py index 1fc87489d21..e831c915adc 100644 --- a/api/tests/unit_tests/controllers/common/test_access_response.py +++ b/api/tests/unit_tests/controllers/common/test_access_response.py @@ -5,7 +5,7 @@ import json import logging from collections.abc import Buffer, Iterator from dataclasses import dataclass -from typing import Literal, Never, cast, override +from typing import Literal, Never, override from unittest.mock import MagicMock import pytest @@ -13,7 +13,7 @@ from flask import Blueprint, Flask, Response, jsonify, request from flask.testing import FlaskClient from flask_restx import Namespace, Resource from pytest_mock import MockerFixture -from sqlalchemy import Table +from sqlalchemy.engine import Engine from sqlalchemy.orm import Session, sessionmaker from werkzeug.exceptions import NotFound, Unauthorized @@ -75,9 +75,9 @@ type MCPClient = tuple[FlaskClient, SessionCalls, MagicMock, MagicMock] @pytest.fixture -def http_app() -> Flask: +def http_app(sqlite_engine: Engine) -> Flask: app = Flask(__name__) - app.config.update(TESTING=True, RESTX_ERROR_404_HELP=False, SQLALCHEMY_DATABASE_URI="sqlite:///:memory:") + app.config.update(TESTING=True, RESTX_ERROR_404_HELP=False, SQLALCHEMY_DATABASE_URI=sqlite_engine.url) db.init_app(app) return app @@ -163,7 +163,6 @@ def test_invalid_token_database_path_does_not_record_usage(http_app: Flask, mock usage = mocker.patch.object(token_service, "record_token_usage") mocker.patch.object(token_service.ApiTokenCache, "set") with http_app.app_context(): - cast(Table, ApiToken.__table__).create(db.engine) with pytest.raises(token_service.ApiTokenInvalidError, match="Access token is invalid"): token_service.query_token_from_db("missing-token", "app") usage.assert_not_called() @@ -200,8 +199,6 @@ def uncached_token_client( api.add_resource(DatasetTokenResource, "/dataset") http_app.register_blueprint(bp) try: - with http_app.app_context(): - cast(Table, ApiToken.__table__).create(db.engine) yield http_app.test_client(), commands finally: token_service.redis_client = previous_redis diff --git a/api/tests/unit_tests/controllers/common/test_human_input_v2_contracts.py b/api/tests/unit_tests/controllers/common/test_human_input_v2_contracts.py new file mode 100644 index 00000000000..6168adf7d5e --- /dev/null +++ b/api/tests/unit_tests/controllers/common/test_human_input_v2_contracts.py @@ -0,0 +1,400 @@ +from __future__ import annotations + +import pytest +from pydantic import TypeAdapter, ValidationError + +from controllers.common import human_input_v2_contracts as contracts +from controllers.common.human_input import HumanInputFormSubmitPayload +from controllers.common.human_input_v2_contracts import ( + BatchGetContactsQuery, + ExternalContactCreateRequest, + FormAccessRequestResponse, + IMSyncResultItem, + IMSyncRun, + ListIMIdentitiesQuery, + MessageTemplateTestRequest, + NodeDataMigrationFailureResponse, + NodeDataMigrationPayload, + NodeDataMigrationResponse, +) +from controllers.common.human_input_v2_migration import preflight_legacy_human_input_node_data +from core.workflow.nodes.human_input_v2.entities import RecipientConfig + + +def test_request_dto_coerces_enum_values_and_forbids_extra_fields() -> None: + request_body = MessageTemplateTestRequest.model_validate({"channel": "email", "inputs": {}}) + + assert request_body.channel.value == "email" + + with pytest.raises(ValidationError): + MessageTemplateTestRequest.model_validate({"channel": "email", "inputs": {}, "unexpected": True}) + + +def test_sync_run_exposes_captured_revision() -> None: + sync_run = IMSyncRun.model_validate( + { + "id": "run-id", + "status": "queued", + "result_counts": {"added": 0, "not_matched": 0, "failed": 0, "removed": 0, "skipped": 0}, + "provider": "feishu", + "channel_id": "channel-id", + "channel_config_version": 4, + } + ) + + assert sync_run.channel_id == "channel-id" + assert sync_run.model_dump(mode="json")["channel_config_version"] == 4 + + +def test_external_contact_avatar_is_optional() -> None: + request_body = ExternalContactCreateRequest.model_validate( + { + "name": "External Approver", + "email": "approver@example.com", + } + ) + + assert request_body.avatar is None + + +def test_node_data_migration_contract_matches_frontend_adapter_boundary() -> None: + request_body = NodeDataMigrationPayload.model_validate( + { + "nodes": [ + { + "node_id": "node-1", + "node_data": { + "title": "Approval", + "version": "1", + "future_legacy_field": "ignored", + }, + "future_envelope_field": "ignored", + } + ], + "future_request_field": "ignored", + } + ) + + node_data = preflight_legacy_human_input_node_data(request_body.nodes[0].node_data).node_data + assert node_data.version == "1" + assert node_data.model_extra == {"future_legacy_field": "ignored"} + assert set(NodeDataMigrationResponse.model_json_schema()["properties"]) == {"data"} + assert contracts.LegacyHITLv1NodeData.model_json_schema()["properties"]["version"]["default"] == "1" + + failure = NodeDataMigrationFailureResponse.model_validate( + { + "message": "Migration failed", + "status": 400, + "blockers": [ + { + "node_id": "node-1", + "node_title": "Approval", + "code": "unresolved-member", + "method_id": "email-1", + "value": "member-1", + } + ], + } + ) + + assert failure.blockers[0].code == "unresolved-member" + + with pytest.raises(ValidationError): + NodeDataMigrationPayload.model_validate( + { + "nodes": [ + { + "node_id": "node-1", + "node_data": {"title": "Approval", "version": "2"}, + } + ] + } + ) + + with pytest.raises(ValidationError): + NodeDataMigrationPayload.model_validate( + { + "nodes": [ + {"node_id": "node-1", "node_data": {"title": "Approval", "version": "1"}}, + {"node_id": "node-1", "node_data": {"title": "Approval", "version": "1"}}, + ] + } + ) + + +def test_node_data_migration_transport_refines_invalid_delivery_methods_into_preflight_issues() -> None: + request_body = NodeDataMigrationPayload.model_validate( + { + "nodes": [ + { + "node_id": "node-1", + "node_data": { + "title": "Approval", + "delivery_methods": [ + {"id": "33333333-3333-4333-8333-333333333333", "type": "sms", "config": {}}, + { + "id": "11111111-1111-4111-8111-111111111111", + "type": "email", + "config": { + "subject": "Subject", + "body": 7, + "recipients": {"items": [{"type": "external", "email": "invalid-email"}]}, + }, + }, + ], + }, + } + ] + } + ) + + preflight = preflight_legacy_human_input_node_data(request_body.nodes[0].node_data) + assert [(issue.code, issue.value) for issue in preflight.issues] == [ + ("unsupported-delivery-method", "sms"), + ("invalid-email-configuration", "body"), + ] + assert preflight.node_data.delivery_methods == [] + + +def test_node_data_migration_transport_normalizes_real_and_compatibility_member_ids() -> None: + request_body = NodeDataMigrationPayload.model_validate( + { + "nodes": [ + { + "node_id": "node-1", + "node_data": { + "title": "Approval", + "delivery_methods": [ + { + "id": "11111111-1111-4111-8111-111111111111", + "type": "email", + "config": { + "subject": "Review", + "body": "Please review", + "recipients": { + "items": [ + {"type": "member", "user_id": "member-real"}, + {"type": "member", "reference_id": "member-compat"}, + ] + }, + }, + } + ], + }, + } + ] + } + ) + + preflight = preflight_legacy_human_input_node_data(request_body.nodes[0].node_data) + assert preflight.issues == () + method = preflight.node_data.delivery_methods[0] + assert method.type == "email" + assert [source.reference_id for source in method.config.recipients.items] == ["member-real", "member-compat"] + + +def test_node_data_migration_transport_reports_configured_unsupported_delivery_methods() -> None: + request_body = NodeDataMigrationPayload.model_validate( + { + "nodes": [ + { + "node_id": "node-1", + "node_data": { + "title": "Approval", + "delivery_methods": [ + { + "id": "im-1", + "type": "im", + "enabled": True, + "config": { + "provider": "slack", + "message": "Please review", + "recipients": { + "items": [ + {"type": "channel", "channel_id": "channel-1"}, + {"type": "user", "user_id": "user-1"}, + ] + }, + }, + }, + { + "id": "future-1", + "type": "future-channel", + "enabled": False, + "config": {"message": "Configured"}, + }, + ], + }, + } + ] + } + ) + + preflight = preflight_legacy_human_input_node_data(request_body.nodes[0].node_data) + assert [(issue.code, issue.value) for issue in preflight.issues] == [ + ("unsupported-delivery-method", "im"), + ("unsupported-delivery-method", "future-channel"), + ] + + +@pytest.mark.parametrize("version", ["2", "v1", 1, None]) +def test_node_data_migration_rejects_every_explicit_non_v1_version(version: object) -> None: + with pytest.raises(ValidationError): + NodeDataMigrationPayload.model_validate( + {"nodes": [{"node_id": "node-1", "node_data": {"title": "Approval", "version": version}}]} + ) + + +def test_node_data_migration_defaults_missing_version_and_requires_non_empty_batch() -> None: + request_body = NodeDataMigrationPayload.model_validate( + {"nodes": [{"node_id": "node-1", "node_data": {"title": "Approval"}}]} + ) + + assert preflight_legacy_human_input_node_data(request_body.nodes[0].node_data).node_data.version == "1" + with pytest.raises(ValidationError): + NodeDataMigrationPayload.model_validate({"nodes": []}) + + +def test_node_data_migration_failure_envelope_never_serializes_partial_data() -> None: + failure = NodeDataMigrationFailureResponse.model_validate( + { + "message": "Migration failed", + "status": 400, + "blockers": [ + { + "node_id": "node-1", + "node_title": "Approval", + "code": "missing-recipients", + } + ], + "data": [{"node_id": "node-valid", "node_data": {}}], + } + ) + + assert set(failure.model_dump(mode="json")) == {"code", "message", "status", "blockers"} + + +def test_node_data_migration_failure_contract_exposes_invalid_default_value_blocker() -> None: + failure = NodeDataMigrationFailureResponse.model_validate( + { + "message": "Migration failed", + "status": 400, + "blockers": [ + { + "node_id": "node-1", + "node_title": "Approval", + "code": "invalid-default-value", + "value": "default_value", + } + ], + } + ) + + assert failure.blockers[0].code == "invalid-default-value" + + +def test_node_data_migration_marker_has_exact_typed_wire_shape() -> None: + recipient_adapter = TypeAdapter(RecipientConfig) + + marker = recipient_adapter.validate_python({"type": "all_workspace_contacts"}) + + assert marker.model_dump(mode="json") == {"type": "all_workspace_contacts"} + with pytest.raises(ValidationError): + recipient_adapter.validate_python({"type": "all_workspace_contacts", "contact_ids": []}) + + response_schema = NodeDataMigrationResponse.model_json_schema() + marker_schema = response_schema["$defs"]["AllWorkspaceContacts"] + assert marker_schema["additionalProperties"] is False + assert marker_schema["properties"]["type"]["const"] == "all_workspace_contacts" + + +def test_node_data_migration_success_schema_forces_human_input_node_type() -> None: + request_body = NodeDataMigrationPayload.model_validate( + { + "nodes": [ + { + "node_id": "node-1", + "node_data": { + "title": "Approval", + "type": "code", + "delivery_methods": [ + {"id": "22222222-2222-4222-8222-222222222222", "type": "webapp", "config": {}} + ], + }, + } + ] + } + ) + response_schema = NodeDataMigrationResponse.model_json_schema() + + assert not hasattr(request_body.nodes[0].node_data, "type") + assert response_schema["$defs"]["HumanInputNodeData"]["properties"]["type"]["const"] == "human-input" + + +@pytest.mark.parametrize( + "recipient_value", + [ + {"type": "contact", "contact_id": "contact-1"}, + {"type": "dynamic_email", "selector": ["node-1", "email"]}, + {"type": "onetime_email", "email": "approver@example.com"}, + {"type": "initiator"}, + {"type": "all_workspace_contacts"}, + ], +) +def test_recipient_union_preserves_existing_and_migration_variants(recipient_value: dict[str, object]) -> None: + recipient = TypeAdapter(RecipientConfig).validate_python(recipient_value) + + assert recipient.model_dump(mode="json") == recipient_value + + +def test_v1_and_v2_submit_payloads_are_independent() -> None: + v1_schema = HumanInputFormSubmitPayload.model_json_schema() + v2_schema = contracts.HumanInputV2FormSubmitRequest.model_json_schema() + + assert "challenge_token" not in v1_schema["properties"] + assert "otp_code" not in v1_schema["properties"] + assert "challenge_token" in v2_schema["properties"] + assert "otp_code" in v2_schema["properties"] + + +def test_public_v2_submit_requires_complete_email_proof() -> None: + submit_model = contracts.HumanInputV2FormSubmitRequest + base_payload: dict[str, object] = {"inputs": {}, "action": "approve"} + + submit_model.model_validate(base_payload) + submit_model.model_validate( + { + **base_payload, + "challenge_token": "challenge-token", + "otp_code": "123456", + } + ) + + with pytest.raises(ValidationError): + submit_model.model_validate({**base_payload, "otp_code": "123456"}) + + with pytest.raises(ValidationError): + submit_model.model_validate({**base_payload, "challenge_token": "challenge-token"}) + + +def test_access_request_response_exposes_resend_cooldown() -> None: + schema = FormAccessRequestResponse.model_json_schema() + + assert "resend_after_seconds" in schema["properties"] + + +def test_batch_get_contacts_query_forbids_extra_fields() -> None: + with pytest.raises(ValidationError): + BatchGetContactsQuery.model_validate({"contact_ids": ["contact-1"], "unexpected": True}) + + +def test_sync_result_item_has_no_contact_type() -> None: + schema = IMSyncResultItem.model_json_schema() + + assert "type" not in schema["properties"] + + +def test_im_identity_search_includes_provider_user_id() -> None: + keyword_description = ListIMIdentitiesQuery.model_json_schema()["properties"]["keyword"]["description"] + + assert "provider user ID" in keyword_description diff --git a/api/tests/unit_tests/controllers/common/test_human_input_v2_migration.py b/api/tests/unit_tests/controllers/common/test_human_input_v2_migration.py new file mode 100644 index 00000000000..f3741c9af07 --- /dev/null +++ b/api/tests/unit_tests/controllers/common/test_human_input_v2_migration.py @@ -0,0 +1,347 @@ +from importlib.util import find_spec +from uuid import UUID + +import pytest + +from controllers.common import human_input_v2_migration as migration_boundary +from core.workflow.human_input_adapter import EmailDeliveryMethod, MemberRecipient, WebAppDeliveryMethod + +_EMAIL_METHOD_ID = "11111111-1111-4111-8111-111111111111" +_WEBAPP_METHOD_ID = "22222222-2222-4222-8222-222222222222" + + +def test_migration_compatibility_preflight_has_an_explicit_transport_module() -> None: + assert find_spec("controllers.common.human_input_v2_migration") is not None + + +def test_migration_transport_exposes_typed_preflight_api() -> None: + assert {"LegacyHITLv1NodeData", "preflight_legacy_human_input_node_data"} <= set(dir(migration_boundary)) + + +def test_migration_transport_accepts_formal_v1_title_default() -> None: + transport = migration_boundary.LegacyHITLv1NodeData.model_validate({"delivery_methods": []}) + assert migration_boundary.preflight_legacy_human_input_node_data(transport).node_data.title == "" + + +def test_preflight_preserves_valid_persisted_delivery_method_id() -> None: + transport_node_data = migration_boundary.LegacyHITLv1NodeData.model_validate( + { + "title": "Approval", + "delivery_methods": [{"id": _WEBAPP_METHOD_ID, "type": "webapp", "config": {}}], + } + ) + + preflight = migration_boundary.preflight_legacy_human_input_node_data(transport_node_data) + + assert preflight.issues == () + assert preflight.method_positions == (0,) + assert preflight.recipient_positions == ((),) + method = preflight.node_data.delivery_methods[0] + assert isinstance(method, WebAppDeliveryMethod) + assert method.id == UUID(_WEBAPP_METHOD_ID) + + +def test_preflight_applies_omitted_ids_and_rejects_null_or_invalid_delivery_ids() -> None: + transport_node_data = migration_boundary.LegacyHITLv1NodeData.model_validate( + { + "title": "Approval", + "delivery_methods": [ + {"type": "webapp", "config": {}}, + {"id": None, "type": "webapp", "config": {}}, + {"id": "invalid-webapp-id", "type": "webapp", "config": {}}, + {"type": "email", "enabled": False, "config": {}}, + {"id": None, "type": "email", "enabled": False, "config": {}}, + {"id": "invalid-email-id", "type": "email", "enabled": False, "config": {}}, + {"id": _WEBAPP_METHOD_ID, "type": "webapp", "config": {}}, + ], + } + ) + + preflight = migration_boundary.preflight_legacy_human_input_node_data(transport_node_data) + + assert preflight.method_positions == (0, 6) + assert len(preflight.node_data.delivery_methods) == 2 + assert isinstance(preflight.node_data.delivery_methods[0].id, UUID) + assert [(issue.code, issue.method_position, issue.method_id, issue.value) for issue in preflight.issues] == [ + ("unsupported-delivery-method", 1, None, "webapp"), + ("unsupported-delivery-method", 2, "invalid-webapp-id", "webapp"), + ("unsupported-delivery-method", 4, None, "email"), + ("unsupported-delivery-method", 5, "invalid-email-id", "email"), + ] + + +@pytest.mark.parametrize( + "empty_config", + [ + {}, + { + "recipients": {"whole_workspace": False, "items": []}, + "subject": "", + "body": "", + "debug_mode": False, + }, + ], +) +def test_preflight_ignores_disabled_email_with_only_default_empty_configuration( + empty_config: dict[str, object], +) -> None: + transport_node_data = migration_boundary.LegacyHITLv1NodeData.model_validate( + { + "title": "Approval", + "delivery_methods": [ + {"id": _EMAIL_METHOD_ID, "type": "email", "enabled": False, "config": empty_config}, + {"id": _WEBAPP_METHOD_ID, "type": "webapp", "config": {}}, + ], + } + ) + + preflight = migration_boundary.preflight_legacy_human_input_node_data(transport_node_data) + + assert preflight.issues == () + assert preflight.method_positions == (1,) + assert len(preflight.node_data.delivery_methods) == 1 + assert isinstance(preflight.node_data.delivery_methods[0], WebAppDeliveryMethod) + + +@pytest.mark.parametrize( + ("method_type", "malformed_config"), + [ + ("email", None), + ("email", False), + ("email", ""), + ("email", []), + ("email", 0), + ("email", {"recipients": False}), + ("email", {"subject": None}), + ("email", {"debug_mode": None}), + ("email", {"unknown": False}), + ("email", {"recipients": {"items": False}}), + ("email", {"recipients": {"whole_workspace": None}}), + ("webapp", None), + ("webapp", False), + ("webapp", ""), + ("webapp", []), + ("webapp", 0), + ], +) +def test_preflight_blocks_explicit_malformed_falsy_disabled_delivery_config( + method_type: str, + malformed_config: object, +) -> None: + transport_node_data = migration_boundary.LegacyHITLv1NodeData.model_validate( + { + "title": "Approval", + "delivery_methods": [ + { + "id": _EMAIL_METHOD_ID if method_type == "email" else _WEBAPP_METHOD_ID, + "type": method_type, + "enabled": False, + "config": malformed_config, + }, + {"id": _WEBAPP_METHOD_ID, "type": "webapp", "config": {}}, + ], + } + ) + + preflight = migration_boundary.preflight_legacy_human_input_node_data(transport_node_data) + + assert preflight.method_positions == (1,) + assert [(issue.code, issue.method_position, issue.value) for issue in preflight.issues] == [ + ("configured-disabled-method", 0, method_type) + ] + + +@pytest.mark.parametrize("include_config", [False, True], ids=["omitted-config", "empty-config"]) +def test_preflight_rejects_disabled_unknown_method_before_empty_config_exemption(include_config: bool) -> None: + unknown_method: dict[str, object] = { + "id": "33333333-3333-4333-8333-333333333333", + "type": "future-channel", + "enabled": False, + } + if include_config: + unknown_method["config"] = dict[str, object]() + transport_node_data = migration_boundary.LegacyHITLv1NodeData.model_validate( + { + "title": "Approval", + "delivery_methods": [ + unknown_method, + {"id": _WEBAPP_METHOD_ID, "type": "webapp", "config": {}}, + ], + } + ) + + preflight = migration_boundary.preflight_legacy_human_input_node_data(transport_node_data) + + assert preflight.method_positions == (1,) + assert [(issue.code, issue.method_position, issue.method_id, issue.value) for issue in preflight.issues] == [ + ( + "unsupported-delivery-method", + 0, + "33333333-3333-4333-8333-333333333333", + "future-channel", + ) + ] + + +@pytest.mark.parametrize( + "invalid_default", + [ + {"key": "fallback", "type": "object", "value": False}, + {"key": "fallback", "type": "array[number]", "value": 7}, + ], +) +def test_preflight_types_invalid_historical_default_values_without_constructing_invalid_canonical_state( + invalid_default: dict[str, object], +) -> None: + transport_node_data = migration_boundary.LegacyHITLv1NodeData.model_validate( + { + "title": "Approval", + "default_value": [invalid_default], + "delivery_methods": [{"id": _WEBAPP_METHOD_ID, "type": "webapp", "config": {}}], + } + ) + + preflight = migration_boundary.preflight_legacy_human_input_node_data(transport_node_data) + + assert preflight.node_data.default_value is None + assert [(issue.code, issue.method_position, issue.method_id, issue.value) for issue in preflight.issues] == [ + ("invalid-default-value", None, None, "default_value") + ] + + +def test_preflight_normalizes_compatibility_aliases_before_constructing_canonical_v1() -> None: + transport_node_data = migration_boundary.LegacyHITLv1NodeData.model_validate( + { + "title": "Approval", + "delivery_methods": [ + { + "id": _EMAIL_METHOD_ID, + "type": "email", + "config": { + "subject": "Review", + "body": "Please review", + "recipients": { + "include_bound_group": True, + "items": [{"type": "member", "reference_id": "member-1"}], + }, + }, + } + ], + } + ) + + preflight = migration_boundary.preflight_legacy_human_input_node_data(transport_node_data) + + assert preflight.issues == () + assert preflight.method_positions == (0,) + method = preflight.node_data.delivery_methods[0] + assert isinstance(method, EmailDeliveryMethod) + assert isinstance(method.id, UUID) + assert method.config.recipients.include_bound_group is True + assert isinstance(method.config.recipients.items[0], MemberRecipient) + assert method.config.recipients.items[0].reference_id == "member-1" + assert "user_id" not in method.model_dump_json() + assert "whole_workspace" not in method.model_dump_json() + + +def test_preflight_returns_ordered_typed_issues_without_constructing_invalid_methods() -> None: + transport_node_data = migration_boundary.LegacyHITLv1NodeData.model_validate( + { + "title": "Approval", + "delivery_methods": [ + { + "id": "33333333-3333-4333-8333-333333333333", + "type": "im", + "config": {"provider": "slack", "message": "Review"}, + }, + { + "id": "44444444-4444-4444-8444-444444444444", + "type": "future-channel", + "enabled": False, + "config": {"message": "Configured"}, + }, + { + "id": _EMAIL_METHOD_ID, + "type": "email", + "config": { + "subject": "Review", + "body": "Please review", + "recipients": [{"type": "user", "user_id": "user-1"}], + }, + }, + {"id": _WEBAPP_METHOD_ID, "type": "webapp", "config": {}}, + ], + } + ) + + preflight = migration_boundary.preflight_legacy_human_input_node_data(transport_node_data) + + assert preflight.method_positions == (3,) + assert len(preflight.node_data.delivery_methods) == 1 + assert isinstance(preflight.node_data.delivery_methods[0], WebAppDeliveryMethod) + assert [(issue.code, issue.method_position, issue.method_id, issue.value) for issue in preflight.issues] == [ + ("unsupported-delivery-method", 0, "33333333-3333-4333-8333-333333333333", "im"), + ("unsupported-delivery-method", 1, "44444444-4444-4444-8444-444444444444", "future-channel"), + ("invalid-email-configuration", 2, _EMAIL_METHOD_ID, "recipients"), + ] + assert all(type(issue).__name__ == "LegacyDeliveryParseIssue" for issue in preflight.issues) + + +def test_preflight_types_malformed_recipients_without_discarding_valid_recipient_state() -> None: + transport_node_data = migration_boundary.LegacyHITLv1NodeData.model_validate( + { + "title": "Approval", + "delivery_methods": [ + { + "id": _EMAIL_METHOD_ID, + "type": "email", + "config": { + "subject": "Review", + "body": "Please review", + "recipients": { + "items": [ + {"type": "external", "email": 7}, + {"type": "member"}, + {"type": "external", "email": "approver@example.com"}, + ] + }, + }, + } + ], + } + ) + + preflight = migration_boundary.preflight_legacy_human_input_node_data(transport_node_data) + + assert [(issue.code, issue.method_id, issue.value) for issue in preflight.issues] == [ + ("invalid-email", _EMAIL_METHOD_ID, None), + ("unresolved-member", _EMAIL_METHOD_ID, None), + ] + assert [issue.recipient_position for issue in preflight.issues] == [0, 1] + assert preflight.recipient_positions == ((2,),) + method = preflight.node_data.delivery_methods[0] + assert isinstance(method, EmailDeliveryMethod) + assert [recipient.model_dump(mode="json") for recipient in method.config.recipients.items] == [ + {"type": "external", "email": "approver@example.com"} + ] + + +@pytest.mark.parametrize("input_type", ["text_input", "paragraph"]) +@pytest.mark.parametrize("default_type", ["constant", "variable"]) +def test_historical_paragraph_alias_preserves_both_default_sources(input_type: str, default_type: str) -> None: + from core.workflow.nodes.human_input_v2.migration import MemberEmailSnapshot, convert_legacy_human_input_node_data + + default = {"type": default_type, "selector": ["start", "reason"], "value": " Previous value "} + transport = migration_boundary.LegacyHITLv1NodeData.model_validate( + { + "title": "Historical paragraph", + "delivery_methods": [{"id": _WEBAPP_METHOD_ID, "type": "webapp", "config": {}}], + "inputs": [{"type": input_type, "output_variable_name": "reason", "default": default}], + } + ) + preflight = migration_boundary.preflight_legacy_human_input_node_data(transport) + converted = convert_legacy_human_input_node_data(preflight.node_data, MemberEmailSnapshot()) + assert converted.node_data is not None + assert converted.node_data.model_dump(mode="json")["inputs"] == [ + {"type": "paragraph", "output_variable_name": "reason", "default": default} + ] diff --git a/api/tests/unit_tests/controllers/common/test_schema.py b/api/tests/unit_tests/controllers/common/test_schema.py index b73a645c3eb..3cbab70e26b 100644 --- a/api/tests/unit_tests/controllers/common/test_schema.py +++ b/api/tests/unit_tests/controllers/common/test_schema.py @@ -163,6 +163,23 @@ class TestSchemaCases: assert list(namespace.models) == ["UserModel"] + @staticmethod + @pytest.mark.parametrize("strict", [False, True]) + def test_register_schema_model_keeps_annotated_enum_names_stable(strict: bool): + from controllers.common.schema import register_schema_model + + enum_types = [StrEnum("StableStatus", {"ACTIVE": "active"}, module=__name__) for _ in range(2)] + snapshots = [] + for enum_type in enum_types: + model = create_model("StatusPayload", status=(enum_type, Field(strict=strict))) + namespace = Namespace("test") + + register_schema_model(namespace, model) + + snapshots.append({name: registered.__schema__ for name, registered in namespace.models.items()}) + + assert snapshots[0] == snapshots[1] + @staticmethod def test_register_schema_model_rejects_different_model_with_same_name(): from controllers.common.schema import SchemaNameCollisionError, register_schema_model diff --git a/api/tests/unit_tests/controllers/console/app/test_model_config.py b/api/tests/unit_tests/controllers/console/app/test_model_config.py index 228bbb85609..f12596824e7 100644 --- a/api/tests/unit_tests/controllers/console/app/test_model_config.py +++ b/api/tests/unit_tests/controllers/console/app/test_model_config.py @@ -719,7 +719,6 @@ def test_post_uses_one_session_and_rolls_back_when_signal_fails( sqlite_engine: Engine, monkeypatch: pytest.MonkeyPatch, ) -> None: - App.metadata.create_all(sqlite_engine, tables=[App.__table__, AppModelConfig.__table__]) make_session = sessionmaker(bind=sqlite_engine, expire_on_commit=False) app_id = str(uuid4()) tenant_id = str(uuid4()) diff --git a/api/tests/unit_tests/controllers/console/app/test_workflow_run.py b/api/tests/unit_tests/controllers/console/app/test_workflow_run.py index 6fb5ebc1057..d53bf8a1249 100644 --- a/api/tests/unit_tests/controllers/console/app/test_workflow_run.py +++ b/api/tests/unit_tests/controllers/console/app/test_workflow_run.py @@ -5,7 +5,7 @@ from collections.abc import Callable from datetime import UTC, datetime from inspect import unwrap from types import SimpleNamespace -from typing import Any +from typing import Any, Literal from unittest.mock import Mock import pytest @@ -64,7 +64,10 @@ def _workflow_pause_details_api_mock_application_services(monkeypatch: pytest.Mo class TestWorkflowPauseDetailsApiCases: @staticmethod - def test_pause_details_returns_backstage_input_url(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: + @pytest.mark.parametrize("form_version", ["1", "2"]) + def test_pause_details_returns_backstage_input_url( + app: Flask, monkeypatch: pytest.MonkeyPatch, form_version: Literal["1", "2"] + ) -> None: apply_config_overrides(monkeypatch, APP_WEB_URL="https://web.example.com/") workflow_runs = Mock() workflow_runs.get_pause_details.return_value = WorkflowRunPauseDetails( @@ -74,6 +77,7 @@ class TestWorkflowPauseDetailsApiCases: node_id="node-1", node_title="Ask Name", form_id="form-1", + form_version=form_version, form_token="backstage-token", ), ), @@ -96,6 +100,7 @@ class TestWorkflowPauseDetailsApiCases: "pause_type": { "type": "human_input", "form_id": "form-1", + "form_version": form_version, "backstage_input_url": "https://web.example.com/form/backstage-token", }, } @@ -514,6 +519,7 @@ class TestWorkflowRunApiCases: @pytest.fixture def workflow_run_service(sqlite_session_factory: sessionmaker[Session]) -> WorkflowRunService: return WorkflowRunService( + session_factory=sqlite_session_factory, workflow_runs=DifyAPISQLAlchemyWorkflowRunRepository(sqlite_session_factory), node_executions=DifyAPISQLAlchemyWorkflowNodeExecutionRepository(sqlite_session_factory), ) diff --git a/api/tests/unit_tests/controllers/console/human_input_v2/test_contact.py b/api/tests/unit_tests/controllers/console/human_input_v2/test_contact.py new file mode 100644 index 00000000000..604ad9bfbc2 --- /dev/null +++ b/api/tests/unit_tests/controllers/console/human_input_v2/test_contact.py @@ -0,0 +1,174 @@ +"""HTTP authorization for Human Input Contact management and editor options.""" + +from typing import Never + +import pytest +from flask import Flask, g +from flask.testing import FlaskClient +from flask_login import LoginManager +from sqlalchemy import select +from sqlalchemy.orm import Session, sessionmaker + +from configs import dify_config +from controllers.console import bp as console_bp +from controllers.console.human_input_v2 import contact as controller +from enums import DeploymentEdition +from models.account import Account, AccountStatus, Tenant, TenantAccountRole +from models.human_input_v2 import HumanInputExternalContactProfile + +_BASE_URL = "/console/api/workspaces/current/human-input/v2" +_CONTACT_ID = "00000000-0000-0000-0000-000000000123" +_MANAGEMENT_REQUESTS = ( + ("GET", "/contacts"), + ("GET", f"/contacts/{_CONTACT_ID}"), + ("GET", f"/contacts/batch?contact_ids={_CONTACT_ID}"), + ("GET", "/organization-candidates"), + ("POST", "/contacts/platform"), + ("POST", "/contacts/external"), + ("PATCH", f"/contacts/external/{_CONTACT_ID}"), + ("POST", "/contacts/remove"), + ("POST", "/im-sync-runs"), + ("GET", "/im-sync-runs/latest"), + ("GET", "/im-sync-runs/latest/results"), + ("GET", "/im-identities"), + ("PUT", f"/contacts/{_CONTACT_ID}/im-override"), + ("DELETE", f"/contacts/{_CONTACT_ID}/im-override"), + ("PUT", f"/contacts/{_CONTACT_ID}/im-bindings"), + ("DELETE", f"/contacts/{_CONTACT_ID}/im-bindings"), +) + + +@pytest.fixture +def current_account(sqlite_session_factory: sessionmaker[Session]) -> Account: + with sqlite_session_factory.begin() as session: + tenant = Tenant(name="Contact authorization test") + session.add(tenant) + + account = Account(name="Reviewer", email="reviewer@example.com", status=AccountStatus.ACTIVE) + account.role = TenantAccountRole.OWNER + account._current_tenant = tenant + return account + + +@pytest.fixture(params=[False, True], ids=["legacy-roles", "rbac"]) +def client(current_account: Account, request: pytest.FixtureRequest, monkeypatch: pytest.MonkeyPatch) -> FlaskClient: + monkeypatch.setattr(dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.CLOUD) + monkeypatch.setattr(dify_config, "LOGIN_DISABLED", True) + monkeypatch.setattr(dify_config, "RBAC_ENABLED", request.param) + + app = Flask(__name__) + LoginManager().init_app(app) + app.register_blueprint(console_bp) + + @app.before_request + def authenticate() -> None: + g._login_user = current_account + + return app.test_client() + + +@pytest.mark.parametrize( + "role", [TenantAccountRole.EDITOR, TenantAccountRole.NORMAL, TenantAccountRole.DATASET_OPERATOR] +) +@pytest.mark.parametrize(("method", "path"), _MANAGEMENT_REQUESTS) +def test_management_rejects_non_administrators_before_business_work( + client: FlaskClient, + current_account: Account, + monkeypatch: pytest.MonkeyPatch, + role: TenantAccountRole, + method: str, + path: str, +) -> None: + current_account.role = role + + def reject_business_work(*_args: object, **_kwargs: object) -> Never: + raise AssertionError("Unauthorized requests must not enter Contact management") + + monkeypatch.setattr(controller, "_contact_service", reject_business_work) + monkeypatch.setattr(controller, "build_enterprise_contact_management_service", reject_business_work) + monkeypatch.setattr(controller, "build_im_contact_sync_application", reject_business_work) + + response = client.open(f"{_BASE_URL}{path}", method=method, json={}) + + assert response.status_code == 403, response.get_json() + + +@pytest.mark.parametrize("role", [TenantAccountRole.OWNER, TenantAccountRole.ADMIN]) +def test_administrators_can_read_create_edit_and_remove_contacts( + client: FlaskClient, + current_account: Account, + sqlite_session_factory: sessionmaker[Session], + role: TenantAccountRole, +) -> None: + current_account.role = role + + created = client.post(f"{_BASE_URL}/contacts/external", json={"name": "Reviewer", "email": "contact@example.com"}) + assert created.status_code == 200 + contact_id = created.get_json()["contact"]["id"] + + listed = client.get(f"{_BASE_URL}/contacts") + assert listed.status_code == 200 + assert [contact["id"] for contact in listed.get_json()["data"]] == [contact_id] + + details = client.get(f"{_BASE_URL}/contacts/{contact_id}") + assert details.status_code == 200 + assert details.get_json()["contact"]["id"] == contact_id + + edited = client.patch(f"{_BASE_URL}/contacts/external/{contact_id}", json={"name": "Updated reviewer"}) + assert edited.status_code == 200 + assert edited.get_json()["contact"]["name"] == "Updated reviewer" + with sqlite_session_factory() as session: + stored = session.scalar( + select(HumanInputExternalContactProfile).where(HumanInputExternalContactProfile.contact_id == contact_id) + ) + assert stored is not None + assert stored.name == "Updated reviewer" + + removed = client.post(f"{_BASE_URL}/contacts/remove", json={"contact_ids": [contact_id]}) + assert removed.status_code == 200 + assert removed.get_json()["removed_contact_ids"] == [contact_id] + with sqlite_session_factory() as session: + assert session.scalar(select(HumanInputExternalContactProfile)) is None + + +@pytest.mark.parametrize("role", [TenantAccountRole.OWNER, TenantAccountRole.ADMIN, TenantAccountRole.EDITOR]) +def test_editing_roles_can_select_contacts( + client: FlaskClient, current_account: Account, role: TenantAccountRole +) -> None: + created = client.post(f"{_BASE_URL}/contacts/external", json={"name": "Reviewer", "email": "contact@example.com"}) + assert created.status_code == 200 + contact_id = created.get_json()["contact"]["id"] + current_account.role = role + + options = client.get(f"{_BASE_URL}/contact-options") + assert options.status_code == 200 + assert options.get_json()["data"] == [ + {"id": contact_id, "type": "external", "name": "Reviewer", "email": "contact@example.com", "avatar_url": None} + ] + + resolved = client.get(f"{_BASE_URL}/contact-options/batch", query_string={"contact_ids": contact_id}) + assert resolved.status_code == 200 + assert resolved.get_json()["data"] == options.get_json()["data"] + if role == TenantAccountRole.EDITOR: + assert client.get(f"{_BASE_URL}/contacts").status_code == 403 + + +@pytest.mark.parametrize("role", [TenantAccountRole.NORMAL, TenantAccountRole.DATASET_OPERATOR]) +@pytest.mark.parametrize("path", ["/contact-options", f"/contact-options/batch?contact_ids={_CONTACT_ID}"]) +def test_contact_options_reject_non_editing_roles_before_query( + client: FlaskClient, + current_account: Account, + monkeypatch: pytest.MonkeyPatch, + role: TenantAccountRole, + path: str, +) -> None: + current_account.role = role + + def reject_contact_query(*_args: object, **_kwargs: object) -> Never: + raise AssertionError("Unauthorized requests must not query Contact options") + + monkeypatch.setattr(controller, "_contact_service", reject_contact_query) + + response = client.get(f"{_BASE_URL}{path}") + + assert response.status_code == 403, response.get_json() diff --git a/api/tests/unit_tests/controllers/console/test_human_input_channel_api.py b/api/tests/unit_tests/controllers/console/test_human_input_channel_api.py new file mode 100644 index 00000000000..53c898b4cf1 --- /dev/null +++ b/api/tests/unit_tests/controllers/console/test_human_input_channel_api.py @@ -0,0 +1,651 @@ +from __future__ import annotations + +import importlib +from datetime import datetime +from http import HTTPStatus +from inspect import unwrap +from typing import Never +from unittest.mock import patch +from uuid import UUID + +import pytest +from flask import Flask, g +from werkzeug.exceptions import Forbidden + +from controllers.console.human_input_v2.config_version import encode_email_config_version +from controllers.console.wraps import _is_setup_completed +from core.human_input_v2.entities import EmailProviderType, IMProvider +from core.human_input_v2.shared import AccountId, DirectoryScope, EmailProviderId, WorkspaceScope +from libs.exception import BaseHTTPException +from models.account import Account, AccountStatus, TenantAccountRole +from repositories.human_input_v2.email_channel import EmailChannelView, EmailConfigurationSnapshot, ResendCandidate +from repositories.human_input_v2.im_channel_repository import IMChannelId, IMChannelStatus +from services.human_input_v2.errors import ( + ChannelAlreadyConfiguredError, + ChannelNotFoundError, + ChannelProviderError, + ProviderConfigurationUpdatedError, + ProviderFailureKind, + ReplacementRequiredError, + UnexpectedChannelProviderError, +) +from services.human_input_v2.im_channel_service import IMChannelView +from tests.helpers.unit.model_factories import make_tenant + +_CANONICAL_ROUTES = { + "/workspaces/current/human-input/v2/channel-providers": {"GET"}, + "/workspaces/current/human-input/v2/channels": {"GET"}, + "/workspaces/current/human-input/v2/channels/email": {"POST"}, + "/workspaces/current/human-input/v2/channels/email/test": {"POST"}, + "/workspaces/current/human-input/v2/channels/email//test": {"POST"}, + "/workspaces/current/human-input/v2/channels/email/": {"GET", "PUT", "DELETE"}, + "/workspaces/current/human-input/v2/channels/im": {"POST"}, + "/workspaces/current/human-input/v2/channels/im/test": {"POST"}, + "/workspaces/current/human-input/v2/channels/im//test": {"POST"}, + "/workspaces/current/human-input/v2/channels/im/": {"GET", "PUT", "DELETE"}, + "/workspaces/current/human-input/v2/channels/im//replacement": {"POST"}, +} + + +def test_human_input_channel_controller_imports_successfully() -> None: + try: + importlib.import_module("controllers.console.human_input_v2.channel") + except (ImportError, SyntaxError) as error: + raise AssertionError("the canonical Human Input Channel controller must be importable") from error + + +def test_canonical_route_inventory_has_no_kind_specific_discovery_authority() -> None: + module = importlib.import_module("controllers.console.human_input_v2.channel") + registered_routes: dict[str, set[str]] = {} + + for resource, urls, _route_doc, _kwargs in module.console_ns.resources: + resource_methods: set[str] = { + method.upper() for method in ("get", "post", "put", "patch", "delete") if method in resource.__dict__ + } + for url in urls: + if url.startswith("/workspaces/current/human-input/v2/channel"): + registered_routes[url] = resource_methods + + assert registered_routes == _CANONICAL_ROUTES + assert "/workspaces/current/human-input/v2/channels/email" not in { + route for route, methods in registered_routes.items() if "GET" in methods + } + assert "/workspaces/current/human-input/v2/channels/im" not in { + route for route, methods in registered_routes.items() if "GET" in methods + } + + +def test_channel_transport_models_match_the_canonical_schema() -> None: + module = importlib.import_module("controllers.console.human_input_v2.channel") + + assert set(module.ChannelSummary.model_fields) == { + "id", + "created_at", + "updated_at", + "kind", + "provider", + "status", + "status_description", + "display_identifier", + "webhook_url", + "config_version", + } + assert {status.value for status in module.ChannelStatus} == { + "configured", + "connected", + "invalid_credentials", + "connection_failure", + } + assert set(module.ChannelTestResponse.model_fields) == {"status", "status_description"} + assert set(module.ListChannelProvidersResponse.model_fields) == {"email_providers", "im_providers"} + assert set(module.ChannelProvider.model_fields) == {"provider", "connection_mode"} + assert set(module.ChannelDeleteResponse.model_fields) == {"channel_id"} + assert set(module.EmailChannelTestPayload.model_fields) == {"sender_email", "sender_name", "credentials"} + assert set(module.EmailChannelCreatePayload.model_fields) == {"sender_email", "sender_name", "credentials"} + assert set(module.EmailChannelUpdatePayload.model_fields) == { + "sender_email", + "sender_name", + "credentials", + "expected_config_version", + } + assert set(module.IMChannelTestPayload.model_fields) == {"credentials"} + assert set(module.IMChannelCreatePayload.model_fields) == {"credentials"} + assert set(module.IMChannelUpdatePayload.model_fields) == { + "credentials", + "expected_config_version", + } + assert set(module.IMChannelReplacementPayload.model_fields) == { + "credentials", + "expected_config_version", + } + assert set(module.ChannelDeleteQuery.model_fields) == {"expected_config_version"} + assert all( + "channel_id" not in request_model.model_fields + for request_model in ( + module.EmailChannelTestPayload, + module.EmailChannelCreatePayload, + module.EmailChannelUpdatePayload, + module.IMChannelTestPayload, + module.IMChannelCreatePayload, + module.IMChannelUpdatePayload, + module.IMChannelReplacementPayload, + module.ChannelDeleteQuery, + ) + ) + assert set(module.ChannelConflictResponse.model_fields["code"].annotation.__args__) == { + "replacement_required", + "provider_configuration_updated", + } + + schema = module.ChannelSummary.model_json_schema() + config_version_ref = schema["properties"]["config_version"]["$ref"] + config_version_name = config_version_ref.rsplit("/", 1)[-1] + assert schema["$defs"][config_version_name]["type"] == "string" + assert "last_checked_at" not in schema["properties"] + assert set(module.ListChannelsResponse.model_fields) == {"channels"} + assert set(module.EmailChannelMutationResponse.model_fields) == {"summary"} + assert set(module.IMChannelMutationResponse.model_fields) == {"summary"} + + +def test_legacy_im_management_routes_are_not_registered() -> None: + from controllers.console import console_ns + + registered_urls = {url for _resource, urls, _route_doc, _kwargs in console_ns.resources for url in urls} + + assert "/workspaces/current/human-input/im-integration" not in registered_urls + assert "/workspaces/current/human-input/im-integration/test" not in registered_urls + + +def test_conflict_response_exposes_only_the_two_stable_codes() -> None: + module = importlib.import_module("controllers.console.human_input_v2.channel") + + replacement = module.ChannelConflictResponse( + code="replacement_required", + message="Explicit replacement is required.", + status=HTTPStatus.CONFLICT, + ) + stale = module.ChannelConflictResponse( + code="provider_configuration_updated", + message="The channel configuration was updated.", + status=HTTPStatus.CONFLICT, + ) + + assert replacement.status == HTTPStatus.CONFLICT + assert stale.status == HTTPStatus.CONFLICT + + +def test_kind_mismatch_is_a_generic_not_found_response( + app: Flask, + monkeypatch: pytest.MonkeyPatch, +) -> None: + module = importlib.import_module("controllers.console.human_input_v2.channel") + service_error = ChannelNotFoundError("the identifier belongs to another kind") + + class EmailOwner: + def get(self, _scope: DirectoryScope, _channel_id: EmailProviderId) -> Never: + raise service_error + + monkeypatch.setattr( + module, + "build_human_input_email_channel_management_service", + EmailOwner, + ) + + with app.test_request_context(method="GET"), pytest.raises(BaseHTTPException) as error_info: + unwrap(module.EmailChannelApi.get)( + module.EmailChannelApi(), + "workspace-1", + UUID("00000000-0000-0000-0000-000000000001"), + ) + + assert error_info.value.code == HTTPStatus.NOT_FOUND + assert "another kind" not in str(error_info.value.description) + assert error_info.value.__cause__ is service_error + + +@pytest.mark.parametrize( + ("service_error", "expected_status", "expected_error_code", "expected_description"), + [ + ( + ChannelAlreadyConfiguredError("persistence-conflict-detail"), + HTTPStatus.CONFLICT, + "conflict", + "A Channel is already configured for this kind.", + ), + ( + ChannelProviderError(ProviderFailureKind.CONNECTION_FAILURE, "Provider connection failed."), + HTTPStatus.BAD_REQUEST, + "bad_request", + "Provider connection failed.", + ), + ], +) +def test_email_create_translates_expected_errors_with_cause( + app: Flask, + monkeypatch: pytest.MonkeyPatch, + service_error: Exception, + expected_status: HTTPStatus, + expected_error_code: str, + expected_description: str, +) -> None: + module = importlib.import_module("controllers.console.human_input_v2.channel") + + class EmailOwner: + def create(self, *_args: object) -> Never: + raise service_error + + monkeypatch.setattr(module, "build_human_input_email_channel_management_service", EmailOwner) + account = Account(name="Owner", email="owner@example.com", status=AccountStatus.ACTIVE) + account.id = "account-1" + + with ( + app.test_request_context( + method="POST", + json={ + "credentials": {"provider": "resend", "api_key": "resend-secret"}, + "sender_email": "sender@example.com", + "sender_name": "Dify", + }, + ), + pytest.raises(BaseHTTPException) as error_info, + ): + unwrap(module.EmailChannelCreateApi.post)( + module.EmailChannelCreateApi(), + "workspace-1", + account, + ) + + assert error_info.value.code == expected_status + assert error_info.value.description == expected_description + assert error_info.value.data == { + "code": expected_error_code, + "message": expected_description, + "status": expected_status, + } + assert error_info.value.__cause__ is service_error + + +@pytest.mark.parametrize( + ("service_error", "expected_error_code", "expected_description"), + [ + ( + ReplacementRequiredError("provider-secret"), + "replacement_required", + "Explicit IM Channel replacement is required.", + ), + ( + ProviderConfigurationUpdatedError("database-detail"), + "provider_configuration_updated", + "The Channel configuration was updated.", + ), + ], +) +def test_im_update_translates_conflicts_with_cause( + app: Flask, + monkeypatch: pytest.MonkeyPatch, + service_error: Exception, + expected_error_code: str, + expected_description: str, +) -> None: + module = importlib.import_module("controllers.console.human_input_v2.channel") + config_version = importlib.import_module("controllers.console.human_input_v2.config_version") + channel_id = IMChannelId("00000000-0000-0000-0000-000000000001") + + class IMOwner: + def update(self, *_args: object) -> Never: + raise service_error + + monkeypatch.setattr(module, "_workspace_im_channel_service", lambda *_args: IMOwner()) + account = Account(name="Owner", email="owner@example.com", status=AccountStatus.ACTIVE) + account.id = "account-1" + + with ( + app.test_request_context( + method="PUT", + json={ + "credentials": { + "provider": "slack", + "client_id": "client-id", + "client_secret": "client-secret", + "signing_secret": "signing-secret", + "bot_token": "xoxb-bot-token", + "app_token": "xapp-app-token", + }, + "expected_config_version": config_version.encode_im_config_version(channel_id, 1), + }, + ), + pytest.raises(BaseHTTPException) as error_info, + ): + unwrap(module.IMChannelApi.put)( + module.IMChannelApi(), + "workspace-1", + account, + UUID(str(channel_id)), + ) + + assert error_info.value.data == { + "code": expected_error_code, + "message": expected_description, + "status": HTTPStatus.CONFLICT, + } + assert error_info.value.__cause__ is service_error + + +def test_im_update_decodes_the_opaque_version_before_calling_the_owner( + app: Flask, + monkeypatch: pytest.MonkeyPatch, +) -> None: + module = importlib.import_module("controllers.console.human_input_v2.channel") + config_version = importlib.import_module("controllers.console.human_input_v2.config_version") + channel_id = IMChannelId("00000000-0000-0000-0000-000000000001") + expected_config_version = 7 + + class IMOwner: + def update(self, addressed_id: IMChannelId, config_version: int, _credentials: object) -> Never: + assert addressed_id == channel_id + assert config_version == expected_config_version + raise ReplacementRequiredError("replacement required") + + monkeypatch.setattr(module, "_workspace_im_channel_service", lambda *_args: IMOwner()) + account = Account(name="Owner", email="owner@example.com", status=AccountStatus.ACTIVE) + account.id = "account-1" + + with ( + app.test_request_context( + method="PUT", + json={ + "credentials": { + "provider": "slack", + "client_id": "client-id", + "client_secret": "client-secret", + "signing_secret": "signing-secret", + "bot_token": "xoxb-bot-token", + "app_token": "xapp-app-token", + }, + "expected_config_version": config_version.encode_im_config_version( + channel_id, + expected_config_version, + ), + }, + ), + pytest.raises(BaseHTTPException) as error_info, + ): + unwrap(module.IMChannelApi.put)(module.IMChannelApi(), "workspace-1", account, UUID(str(channel_id))) + + assert error_info.value.error_code == "replacement_required" + + +def test_unexpected_provider_error_is_not_rewrapped( + app: Flask, + monkeypatch: pytest.MonkeyPatch, +) -> None: + module = importlib.import_module("controllers.console.human_input_v2.channel") + service_error = UnexpectedChannelProviderError() + + class EmailOwner: + def create(self, *_args: object) -> Never: + raise service_error + + monkeypatch.setattr(module, "build_human_input_email_channel_management_service", EmailOwner) + account = Account(name="Owner", email="owner@example.com", status=AccountStatus.ACTIVE) + account.id = "account-1" + + with ( + app.test_request_context( + method="POST", + json={ + "credentials": {"provider": "resend", "api_key": "resend-secret"}, + "sender_email": "sender@example.com", + "sender_name": "Dify", + }, + ), + pytest.raises(UnexpectedChannelProviderError) as error_info, + ): + unwrap(module.EmailChannelCreateApi.post)( + module.EmailChannelCreateApi(), + "workspace-1", + account, + ) + + assert error_info.value is service_error + + +def test_create_returns_the_resulting_canonical_summary( + app: Flask, + monkeypatch: pytest.MonkeyPatch, +) -> None: + module = importlib.import_module("controllers.console.human_input_v2.channel") + revision = EmailConfigurationSnapshot( + EmailProviderId("00000000-0000-0000-0000-000000000001"), + 1, + ) + snapshot = EmailChannelView( + id=EmailProviderId("00000000-0000-0000-0000-000000000001"), + created_at=datetime(2026, 8, 20, 8), + updated_at=datetime(2026, 8, 20, 9), + provider=EmailProviderType.RESEND, + sender_name="Dify", + sender_email="sender@example.com", + revision=revision, + ) + + class EmailOwner: + def create( + self, scope: DirectoryScope, actor_account_id: AccountId, candidate: ResendCandidate + ) -> EmailChannelView: + assert isinstance(scope, WorkspaceScope) + assert str(scope.id) == "workspace-1" + assert str(actor_account_id) == "account-1" + assert candidate.sender_name == "Dify" + return snapshot + + monkeypatch.setattr( + module, + "build_human_input_email_channel_management_service", + EmailOwner, + ) + account = Account(name="Owner", email="owner@example.com", status=AccountStatus.ACTIVE) + account.id = "account-1" + + with app.test_request_context( + method="POST", + json={ + "credentials": {"provider": "resend", "api_key": "resend-secret"}, + "sender_email": "sender@example.com", + "sender_name": "Dify", + }, + ): + response = unwrap(module.EmailChannelCreateApi.post)( + module.EmailChannelCreateApi(), + "workspace-1", + account, + ) + + assert response == { + "summary": { + "id": "00000000-0000-0000-0000-000000000001", + "created_at": 1787212800, + "updated_at": 1787216400, + "kind": "email", + "provider": "resend", + "status": "configured", + "status_description": "", + "display_identifier": "Dify sender@example.com", + "webhook_url": None, + "config_version": encode_email_config_version(revision), + } + } + assert "resend-secret" not in repr(response) + + +def test_email_candidate_test_delegates_the_submitted_candidate_without_a_recipient( + app: Flask, + monkeypatch: pytest.MonkeyPatch, +) -> None: + module = importlib.import_module("controllers.console.human_input_v2.channel") + + class EmailOwner: + def test(self, scope: DirectoryScope, candidate: ResendCandidate) -> None: + assert isinstance(scope, WorkspaceScope) + assert str(scope.id) == "workspace-1" + assert str(candidate.sender_email) == "sender@example.com" + assert candidate.sender_name == "Dify" + assert candidate.credentials.api_key == "resend-secret" + + monkeypatch.setattr(module, "build_human_input_email_channel_management_service", EmailOwner) + + with app.test_request_context( + method="POST", + json={ + "credentials": {"provider": "resend", "api_key": "resend-secret"}, + "sender_email": "sender@example.com", + "sender_name": "Dify", + }, + ): + response = unwrap(module.EmailChannelTestApi.post)( + module.EmailChannelTestApi(), + "workspace-1", + ) + + assert response == {"status": "connected", "status_description": ""} + + +def test_channel_collection_aggregates_configured_owner_snapshots( + monkeypatch: pytest.MonkeyPatch, +) -> None: + module = importlib.import_module("controllers.console.human_input_v2.channel") + email_snapshot = EmailChannelView( + id=EmailProviderId("email-1"), + created_at=datetime(2026, 8, 20, 8), + updated_at=datetime(2026, 8, 20, 9), + provider=EmailProviderType.RESEND, + sender_name="Dify", + sender_email="sender@example.com", + revision=EmailConfigurationSnapshot(EmailProviderId("email-1"), 1), + ) + im_snapshot = IMChannelView( + id=IMChannelId("im-1"), + created_at=datetime(2026, 8, 20, 8), + updated_at=datetime(2026, 8, 20, 9), + provider=IMProvider.SLACK, + status=IMChannelStatus.CONNECTION_FAILURE, + status_reason="Provider connection failed.", + app_identifier="client-1", + webhook_url=None, + config_version=1, + ) + + class EmailOwner: + def get_current(self, scope: DirectoryScope) -> EmailChannelView: + assert isinstance(scope, WorkspaceScope) + assert str(scope.id) == "workspace-1" + return email_snapshot + + class IMOwner: + def get_current(self) -> IMChannelView: + return im_snapshot + + monkeypatch.setattr(module, "build_human_input_email_channel_management_service", EmailOwner) + monkeypatch.setattr(module, "_workspace_im_channel_service", lambda *_args: IMOwner()) + account = Account(name="Owner", email="owner@example.com", status=AccountStatus.ACTIVE) + account.id = "account-1" + + response = unwrap(module.ListChannelsApi.get)(module.ListChannelsApi(), "workspace-1", account) + + assert isinstance(response, dict) + assert [channel["kind"] for channel in response["channels"]] == ["email", "im"] + assert [channel["display_identifier"] for channel in response["channels"]] == [ + "Dify sender@example.com", + "client-1", + ] + + +def test_channel_collection_omits_unconfigured_owner_snapshots( + monkeypatch: pytest.MonkeyPatch, +) -> None: + module = importlib.import_module("controllers.console.human_input_v2.channel") + + class EmailOwner: + def get_current(self, _scope: DirectoryScope) -> None: + return None + + class IMOwner: + def get_current(self) -> None: + return None + + monkeypatch.setattr(module, "build_human_input_email_channel_management_service", EmailOwner) + monkeypatch.setattr(module, "_workspace_im_channel_service", lambda *_args: IMOwner()) + account = Account(name="Owner", email="owner@example.com", status=AccountStatus.ACTIVE) + account.id = "account-1" + + response = unwrap(module.ListChannelsApi.get)(module.ListChannelsApi(), "workspace-1", account) + + assert response == {"channels": []} + + +def test_composite_guard_rejects_a_non_admin_workspace_member( + app: Flask, + monkeypatch: pytest.MonkeyPatch, +) -> None: + module = importlib.import_module("controllers.console.human_input_v2.channel") + account = Account(name="Editor", email="editor@example.com", status=AccountStatus.ACTIVE) + account._current_tenant = make_tenant(tenant_id="workspace-1", name="Workspace") + account.role = TenantAccountRole.EDITOR + _is_setup_completed.mark_success() + + build_calls: list[str] = [] + monkeypatch.setattr( + module, + "build_human_input_email_channel_management_service", + lambda: build_calls.append("email"), + ) + monkeypatch.setattr( + module, + "_workspace_im_channel_service", + lambda *_args: build_calls.append("im"), + ) + + with ( + app.test_request_context(method="GET"), + patch("controllers.console.wraps.dify_config.RBAC_ENABLED", True), + patch("libs.login.check_csrf_token", return_value=None), + ): + g._login_user = account + with pytest.raises(Forbidden): + module.ListChannelProvidersApi().get() + assert build_calls == [] + + +def test_composite_guard_allows_a_workspace_owner( + app: Flask, + monkeypatch: pytest.MonkeyPatch, +) -> None: + module = importlib.import_module("controllers.console.human_input_v2.channel") + account = Account(name="Owner", email="owner@example.com", status=AccountStatus.ACTIVE) + account._current_tenant = make_tenant(tenant_id="workspace-1", name="Workspace") + account.role = TenantAccountRole.OWNER + _is_setup_completed.mark_success() + + class EmailOwner: + def available_providers(self) -> tuple[EmailProviderType, ...]: + return (EmailProviderType.RESEND,) + + class IMOwner: + def available_providers(self) -> tuple[IMProvider, ...]: + return () + + monkeypatch.setattr(module, "build_human_input_email_channel_management_service", EmailOwner) + monkeypatch.setattr(module, "_workspace_im_channel_service", lambda *_args: IMOwner()) + + with ( + app.test_request_context(method="GET"), + patch("controllers.console.wraps.dify_config.RBAC_ENABLED", False), + patch("libs.login.check_csrf_token", return_value=None), + ): + g._login_user = account + response = module.ListChannelProvidersApi().get() + + assert response == { + "email_providers": [{"provider": "resend", "connection_mode": "custom_app"}], + "im_providers": [], + } diff --git a/api/tests/unit_tests/controllers/console/test_human_input_channel_config_version.py b/api/tests/unit_tests/controllers/console/test_human_input_channel_config_version.py new file mode 100644 index 00000000000..0fa972e35f2 --- /dev/null +++ b/api/tests/unit_tests/controllers/console/test_human_input_channel_config_version.py @@ -0,0 +1,54 @@ +import importlib +from types import ModuleType + +import pytest + +from core.human_input_v2.shared import EmailProviderId +from repositories.human_input_v2.email_channel import EmailConfigurationSnapshot +from repositories.human_input_v2.im_channel_repository import IMChannelId + + +def _config_version_module() -> ModuleType: + return importlib.import_module("controllers.console.human_input_v2.config_version") + + +def test_email_config_version_round_trips_the_owner_native_snapshot() -> None: + module = _config_version_module() + snapshot = EmailConfigurationSnapshot(EmailProviderId("email-1"), 3) + + encoded = module.encode_email_config_version(snapshot) + + assert module.decode_email_config_version(encoded, snapshot.configuration_id) == snapshot + + +def test_im_config_version_round_trips_channel_id_and_numeric_version() -> None: + module = _config_version_module() + channel_id = IMChannelId("channel-1") + + encoded = module.encode_im_config_version(channel_id, 5) + + assert module.decode_im_config_version(encoded, channel_id) == 5 + + +@pytest.mark.parametrize( + "encoded", + [ + "not-base64", + "", + ], +) +def test_config_version_rejects_malformed_values(encoded: str) -> None: + module = _config_version_module() + with pytest.raises(module.InvalidConfigVersionError): + module.decode_im_config_version(encoded, IMChannelId("channel-1")) + + +def test_config_version_rejects_another_kind_or_resource_identity() -> None: + module = _config_version_module() + email_snapshot = EmailConfigurationSnapshot(EmailProviderId("email-1"), 1) + encoded = module.encode_email_config_version(email_snapshot) + + with pytest.raises(module.InvalidConfigVersionError): + module.decode_email_config_version(encoded, EmailProviderId("email-2")) + with pytest.raises(module.InvalidConfigVersionError): + module.decode_im_config_version(encoded, IMChannelId("email-1")) diff --git a/api/tests/unit_tests/controllers/console/test_human_input_channel_openapi.py b/api/tests/unit_tests/controllers/console/test_human_input_channel_openapi.py new file mode 100644 index 00000000000..19ad2faa66e --- /dev/null +++ b/api/tests/unit_tests/controllers/console/test_human_input_channel_openapi.py @@ -0,0 +1,136 @@ +from __future__ import annotations + +import pytest +from flask import Flask + +_CHANNEL_PATH_PREFIX = "/workspaces/current/human-input/v2" +_HTTP_METHODS = {"delete", "get", "patch", "post", "put"} +_EXPECTED_OPERATIONS = { + f"{_CHANNEL_PATH_PREFIX}/channel-providers": {"get"}, + f"{_CHANNEL_PATH_PREFIX}/channels": {"get"}, + f"{_CHANNEL_PATH_PREFIX}/channels/email": {"post"}, + f"{_CHANNEL_PATH_PREFIX}/channels/email/test": {"post"}, + f"{_CHANNEL_PATH_PREFIX}/channels/email/{{channel_id}}/test": {"post"}, + f"{_CHANNEL_PATH_PREFIX}/channels/email/{{channel_id}}": {"delete", "get", "put"}, + f"{_CHANNEL_PATH_PREFIX}/channels/im": {"post"}, + f"{_CHANNEL_PATH_PREFIX}/channels/im/test": {"post"}, + f"{_CHANNEL_PATH_PREFIX}/channels/im/{{channel_id}}/test": {"post"}, + f"{_CHANNEL_PATH_PREFIX}/channels/im/{{channel_id}}": {"delete", "get", "put"}, + f"{_CHANNEL_PATH_PREFIX}/channels/im/{{channel_id}}/replacement": {"post"}, +} +_EXPECTED_REQUEST_REFS = { + (f"{_CHANNEL_PATH_PREFIX}/channels/email", "post"): "EmailChannelCreatePayload", + (f"{_CHANNEL_PATH_PREFIX}/channels/email/test", "post"): "EmailChannelTestPayload", + (f"{_CHANNEL_PATH_PREFIX}/channels/email/{{channel_id}}/test", "post"): "EmailChannelUpdatePayload", + (f"{_CHANNEL_PATH_PREFIX}/channels/email/{{channel_id}}", "put"): "EmailChannelUpdatePayload", + (f"{_CHANNEL_PATH_PREFIX}/channels/im", "post"): "IMChannelCreatePayload", + (f"{_CHANNEL_PATH_PREFIX}/channels/im/test", "post"): "IMChannelTestPayload", + (f"{_CHANNEL_PATH_PREFIX}/channels/im/{{channel_id}}/test", "post"): "IMChannelUpdatePayload", + (f"{_CHANNEL_PATH_PREFIX}/channels/im/{{channel_id}}", "put"): "IMChannelUpdatePayload", + (f"{_CHANNEL_PATH_PREFIX}/channels/im/{{channel_id}}/replacement", "post"): "IMChannelReplacementPayload", +} +_EXPECTED_RESPONSE_REFS = { + (f"{_CHANNEL_PATH_PREFIX}/channel-providers", "get"): "ListChannelProvidersResponse", + (f"{_CHANNEL_PATH_PREFIX}/channels", "get"): "ListChannelsResponse", + (f"{_CHANNEL_PATH_PREFIX}/channels/email", "post"): "EmailChannelMutationResponse", + (f"{_CHANNEL_PATH_PREFIX}/channels/email/test", "post"): "ChannelTestResponse", + (f"{_CHANNEL_PATH_PREFIX}/channels/email/{{channel_id}}/test", "post"): "ChannelTestResponse", + (f"{_CHANNEL_PATH_PREFIX}/channels/email/{{channel_id}}", "get"): "EmailChannelDetailResponse", + (f"{_CHANNEL_PATH_PREFIX}/channels/email/{{channel_id}}", "put"): "EmailChannelMutationResponse", + (f"{_CHANNEL_PATH_PREFIX}/channels/email/{{channel_id}}", "delete"): "ChannelDeleteResponse", + (f"{_CHANNEL_PATH_PREFIX}/channels/im", "post"): "IMChannelMutationResponse", + (f"{_CHANNEL_PATH_PREFIX}/channels/im/test", "post"): "ChannelTestResponse", + (f"{_CHANNEL_PATH_PREFIX}/channels/im/{{channel_id}}/test", "post"): "ChannelTestResponse", + (f"{_CHANNEL_PATH_PREFIX}/channels/im/{{channel_id}}", "get"): "IMChannelDetailResponse", + (f"{_CHANNEL_PATH_PREFIX}/channels/im/{{channel_id}}", "put"): "IMChannelMutationResponse", + (f"{_CHANNEL_PATH_PREFIX}/channels/im/{{channel_id}}", "delete"): "ChannelDeleteResponse", + (f"{_CHANNEL_PATH_PREFIX}/channels/im/{{channel_id}}/replacement", "post"): "IMChannelMutationResponse", +} + + +@pytest.fixture +def channel_openapi(monkeypatch: pytest.MonkeyPatch) -> dict[str, object]: + from configs import dify_config + from controllers.console import bp as console_bp + + monkeypatch.setattr(dify_config, "SWAGGER_UI_ENABLED", True) + + application = Flask(__name__) + application.config["TESTING"] = True + application.config["RESTX_INCLUDE_ALL_MODELS"] = True + application.register_blueprint(console_bp) + + response = application.test_client().get("/console/api/openapi.json") + assert response.status_code == 200 + return response.get_json() + + +def _schema_ref(operation: dict[str, object], *, response: bool) -> str: + if response: + responses = operation["responses"] + assert isinstance(responses, dict) + success = responses["200"] + assert isinstance(success, dict) + content = success["content"] + else: + request_body = operation["requestBody"] + assert isinstance(request_body, dict) + content = request_body["content"] + + assert isinstance(content, dict) + json_content = content["application/json"] + assert isinstance(json_content, dict) + schema = json_content["schema"] + assert isinstance(schema, dict) + schema_ref = schema["$ref"] + assert isinstance(schema_ref, str) + return schema_ref.removeprefix("#/components/schemas/") + + +def test_channel_openapi_uses_only_canonical_routes_and_registered_dtos( + channel_openapi: dict[str, object], +) -> None: + paths = channel_openapi["paths"] + assert isinstance(paths, dict) + channel_paths = { + path: {method for method in path_item if method in _HTTP_METHODS} + for path, path_item in paths.items() + if isinstance(path, str) and path.startswith(f"{_CHANNEL_PATH_PREFIX}/channel") and isinstance(path_item, dict) + } + + assert channel_paths == _EXPECTED_OPERATIONS + assert "/workspaces/current/human-input/im-integration" not in paths + assert "/workspaces/current/human-input/im-integration/test" not in paths + + for path, methods in _EXPECTED_OPERATIONS.items(): + path_item = paths[path] + assert isinstance(path_item, dict) + for method in methods: + operation = path_item[method] + assert isinstance(operation, dict) + if method == "get": + assert "requestBody" not in operation + + for (path, method), expected_schema in _EXPECTED_REQUEST_REFS.items(): + operation = paths[path][method] + assert isinstance(operation, dict) + assert _schema_ref(operation, response=False) == expected_schema + + for (path, method), expected_schema in _EXPECTED_RESPONSE_REFS.items(): + operation = paths[path][method] + assert isinstance(operation, dict) + assert _schema_ref(operation, response=True) == expected_schema + + for path in ( + f"{_CHANNEL_PATH_PREFIX}/channels/email/{{channel_id}}", + f"{_CHANNEL_PATH_PREFIX}/channels/im/{{channel_id}}", + ): + delete_operation = paths[path]["delete"] + assert isinstance(delete_operation, dict) + assert "requestBody" not in delete_operation + parameters = delete_operation["parameters"] + assert isinstance(parameters, list) + expected_version = next(parameter for parameter in parameters if parameter["name"] == "expected_config_version") + assert expected_version["in"] == "query" + assert expected_version["required"] is True + assert expected_version["schema"]["type"] == "string" diff --git a/api/tests/unit_tests/controllers/console/test_human_input_channel_providers.py b/api/tests/unit_tests/controllers/console/test_human_input_channel_providers.py new file mode 100644 index 00000000000..e6e888e7421 --- /dev/null +++ b/api/tests/unit_tests/controllers/console/test_human_input_channel_providers.py @@ -0,0 +1,417 @@ +from __future__ import annotations + +import logging +from typing import Annotated, get_args, get_origin + +import pytest +from pydantic import SecretStr, TypeAdapter, ValidationError + +from controllers.console.human_input_v2._common import StrictModel +from controllers.console.human_input_v2.providers import ( + DingTalkCredentialsInput, + EmailProviderCredentials, + FeishuCredentialsInput, + LarkCredentialsInput, + MSTeamsCredentialsInput, + ResendCredentials, + SlackCredentialsInput, + WeComCredentialsInput, +) +from controllers.console.human_input_v2.providers import ( + IMProviderCredentialsInput as ConsoleIMProviderCredentials, +) +from core.human_input_v2.entities import IMProvider +from core.human_input_v2.im_integration.adapters import ( + DingTalkCredentials, + FeishuCredentials, + LarkCredentials, + MSTeamsCredentials, + SlackCredentials, + WeComCredentials, +) +from core.human_input_v2.im_integration.adapters.credentials import ( + IMProviderCredentials as ProviderIMProviderCredentials, +) + +_IM_CASES = ( + ( + FeishuCredentialsInput, + FeishuCredentials, + { + "provider": "feishu", + "app_id": "app-id", + "app_secret": "app-secret", + "verification_token": "verification-token", + "encrypt_key": "encrypt-key", + }, + ), + ( + LarkCredentialsInput, + LarkCredentials, + { + "provider": "lark", + "app_id": "app-id", + "app_secret": "app-secret", + "verification_token": "verification-token", + "encrypt_key": "encrypt-key", + }, + ), + ( + SlackCredentialsInput, + SlackCredentials, + { + "provider": "slack", + "client_id": "client-id", + "client_secret": "client-secret", + "signing_secret": "signing-secret", + "bot_token": "xoxb-bot-token", + "app_token": "xapp-app-token", + }, + ), + ( + DingTalkCredentialsInput, + DingTalkCredentials, + { + "provider": "ding_talk", + "corp_id": "corp-id", + "client_id": "client-id", + "client_secret": "client-secret", + }, + ), + ( + MSTeamsCredentialsInput, + MSTeamsCredentials, + { + "provider": "ms_teams", + "tenant_id": "11111111-1111-4111-8111-111111111111", + "client_id": "22222222-2222-4222-8222-222222222222", + "client_secret": "client-secret", + }, + ), + ( + WeComCredentialsInput, + WeComCredentials, + { + "provider": "we_com", + "corp_id": "corp-id", + "agent_id": "12345", + "secret": "wecom-secret", + }, + ), +) +_RESEND_PAYLOAD = { + "provider": "resend", + "api_key": "resend-api-key", +} +_SECRET_FIELD_NAMES = { + "app_secret", + "verification_token", + "encrypt_key", + "client_secret", + "signing_secret", + "bot_token", + "app_token", + "secret", +} + + +@pytest.mark.parametrize(("_dto_type", "_owner_type", "payload"), _IM_CASES) +def test_im_update_accepts_explicit_retention_for_every_provider( + _dto_type: type[object], _owner_type: type[object], payload: dict[str, str] +) -> None: + from controllers.console.human_input_v2.channel import IMChannelUpdatePayload + + retained = {name: value if name == "provider" else {"type": "unchanged"} for name, value in payload.items()} + command = IMChannelUpdatePayload.model_validate({"expected_config_version": "version", "credentials": retained}) + + assert command.model_dump(mode="json")["credentials"] == retained + + +def test_email_update_can_retain_key_while_changing_sender() -> None: + from controllers.console.human_input_v2.channel import EmailChannelUpdatePayload + + command = EmailChannelUpdatePayload.model_validate( + { + "expected_config_version": "version", + "credentials": {"provider": "resend", "api_key": {"type": "unchanged"}}, + "sender_name": "New Sender", + "sender_email": {"type": "unchanged"}, + } + ) + + assert command.model_dump(mode="json")["credentials"]["api_key"] == {"type": "unchanged"} + + +def test_update_distinguishes_clearing_optional_secret_from_retaining_it() -> None: + from controllers.console.human_input_v2.channel import IMChannelUpdatePayload + + command = IMChannelUpdatePayload.model_validate( + { + "expected_config_version": "version", + "credentials": { + "provider": "feishu", + "app_id": {"type": "unchanged"}, + "app_secret": "rotated-secret", + "verification_token": {"type": "unchanged"}, + "encrypt_key": None, + }, + } + ) + + assert command.model_dump(mode="json")["credentials"]["encrypt_key"] is None + + +type _IMCredentialsDTO = ( + FeishuCredentialsInput + | LarkCredentialsInput + | SlackCredentialsInput + | DingTalkCredentialsInput + | MSTeamsCredentialsInput + | WeComCredentialsInput +) + + +def test_console_union_is_a_distinct_six_dto_transport_boundary() -> None: + console_value = ConsoleIMProviderCredentials.__value__ + + assert ConsoleIMProviderCredentials is not ProviderIMProviderCredentials + assert get_origin(console_value) is Annotated + console_union, field = get_args(console_value) + assert get_args(console_union) == tuple(case[0] for case in _IM_CASES) + assert field.discriminator == "provider" + assert all(dto_type is not owner_type for dto_type, owner_type, _payload in _IM_CASES) + + +@pytest.mark.parametrize(("dto_type", "owner_type", "payload"), _IM_CASES) +def test_im_provider_dto_constructs_complete_owner_credentials( + dto_type: type[_IMCredentialsDTO], + owner_type: type[object], + payload: dict[str, str], +) -> None: + credentials = TypeAdapter(ConsoleIMProviderCredentials).validate_python(payload) + owner_credentials = credentials.to_owner_credentials() + + assert type(credentials) is dto_type + assert type(owner_credentials) is owner_type + assert type(owner_credentials) in get_args(get_args(ProviderIMProviderCredentials.__value__)[0]) + + +def test_slack_app_token_is_optional_and_documented_for_socket_mode() -> None: + payload = dict(_IM_CASES[2][2]) + del payload["app_token"] + + credentials = TypeAdapter(ConsoleIMProviderCredentials).validate_python(payload) + + assert isinstance(credentials, SlackCredentialsInput) + assert credentials.app_token is None + assert credentials.to_owner_credentials().app_token is None + schema = SlackCredentialsInput.model_json_schema() + assert "app_token" not in schema["required"] + assert schema["properties"]["app_token"]["description"] == ( + "Optional Slack app-level token required only for Socket Mode." + ) + + +def test_resend_dto_constructs_complete_owner_credentials() -> None: + credentials = TypeAdapter(EmailProviderCredentials).validate_python(_RESEND_PAYLOAD) + + candidate = credentials.to_owner_credentials() + + assert isinstance(credentials, ResendCredentials) + assert candidate.api_key == _RESEND_PAYLOAD["api_key"] + assert candidate.api_key not in repr(candidate) + + +@pytest.mark.parametrize("sender_name", ["", " \t "]) +def test_resend_dto_rejects_blank_sender_name(sender_name: str) -> None: + from controllers.console.human_input_v2.channel import EmailChannelCreatePayload + + with pytest.raises(ValidationError): + EmailChannelCreatePayload.model_validate( + {"credentials": _RESEND_PAYLOAD, "sender_email": "sender@example.com", "sender_name": sender_name} + ) + + +def test_resend_dto_normalizes_sender_name_before_constructing_candidate() -> None: + from controllers.console.human_input_v2.channel import EmailChannelCreatePayload + + command = EmailChannelCreatePayload.model_validate( + {"credentials": _RESEND_PAYLOAD, "sender_email": "sender@example.com", "sender_name": " Sender "} + ) + assert command.to_owner_candidate().sender_name == "Sender" + + +@pytest.mark.parametrize(("_dto_type", "_owner_type", "payload"), _IM_CASES) +def test_every_secret_uses_secret_str_and_is_absent_from_repr_and_logs( + _dto_type: type[_IMCredentialsDTO], + _owner_type: type[object], + payload: dict[str, str], + caplog: pytest.LogCaptureFixture, +) -> None: + credentials = TypeAdapter(ConsoleIMProviderCredentials).validate_python(payload) + secret_values = {value for name, value in payload.items() if name in _SECRET_FIELD_NAMES} + + for name, secret in credentials.model_dump().items(): + if name in _SECRET_FIELD_NAMES: + assert isinstance(secret, (SecretStr, type(None))) + + with caplog.at_level(logging.INFO): + logging.getLogger(__name__).info("credentials=%r", credentials) + rendered = repr(credentials) + caplog.text + assert "credentials=" in caplog.text + assert all(secret not in rendered for secret in secret_values) + + +def test_resend_api_key_uses_secret_str_and_is_absent_from_repr_and_logs( + caplog: pytest.LogCaptureFixture, +) -> None: + credentials = TypeAdapter(EmailProviderCredentials).validate_python(_RESEND_PAYLOAD) + + assert isinstance(credentials, ResendCredentials) + assert isinstance(credentials.api_key, SecretStr) + + with caplog.at_level(logging.INFO): + logging.getLogger(__name__).info("credentials=%r", credentials) + + rendered = repr(credentials) + caplog.text + assert "credentials=" in caplog.text + assert _RESEND_PAYLOAD["api_key"] not in rendered + + +@pytest.mark.parametrize("forbidden_field", ["event_transport_mode", "unexpected"]) +def test_provider_dto_rejects_extra_fields(forbidden_field: str) -> None: + payload = dict(_IM_CASES[2][2]) + payload[forbidden_field] = "socket" + + with pytest.raises(ValidationError): + TypeAdapter(ConsoleIMProviderCredentials).validate_python(payload) + + +def test_validation_diagnostics_hide_submitted_secrets() -> None: + class IMCredentialsEnvelope(StrictModel): + credentials: ConsoleIMProviderCredentials + + payload = dict(_IM_CASES[2][2]) + payload["unexpected"] = "raw-provider-payload" + + with pytest.raises(ValidationError) as captured: + IMCredentialsEnvelope.model_validate({"credentials": payload}) + + rendered = str(captured.value) + assert "client-secret" not in rendered + assert "signing-secret" not in rendered + assert "xoxb-bot-token" not in rendered + assert "xapp-app-token" not in rendered + assert "raw-provider-payload" not in rendered + + +def test_resend_validation_diagnostics_hide_api_key_and_raw_provider_payload() -> None: + class EmailCredentialsEnvelope(StrictModel): + credentials: EmailProviderCredentials + + payload = dict(_RESEND_PAYLOAD) + payload["unexpected"] = "raw-provider-payload" + + with pytest.raises(ValidationError) as captured: + EmailCredentialsEnvelope.model_validate({"credentials": payload}) + + rendered = str(captured.value) + assert _RESEND_PAYLOAD["api_key"] not in rendered + assert "raw-provider-payload" not in rendered + + +@pytest.mark.parametrize("provider", list(IMProvider)) +def test_missing_required_secret_is_rejected(provider: IMProvider) -> None: + _, _, source = next(case for case in _IM_CASES if case[2]["provider"] == provider.value) + payload = dict(source) + secret_name = next(name for name in ("app_secret", "client_secret", "secret") if name in payload) + del payload[secret_name] + + with pytest.raises(ValidationError): + TypeAdapter(ConsoleIMProviderCredentials).validate_python(payload) + + +@pytest.mark.parametrize( + "invalid", [None, "", " ", {}, {"type": "redacted"}, {"type": "unchanged", "value": "secret"}] +) +def test_update_rejects_invalid_required_secret_and_malformed_markers(invalid: object) -> None: + from controllers.console.human_input_v2.channel import IMChannelUpdatePayload + + with pytest.raises(ValidationError): + IMChannelUpdatePayload.model_validate( + { + "expected_config_version": "version", + "credentials": { + "provider": "feishu", + "app_id": {"type": "unchanged"}, + "app_secret": invalid, + "verification_token": {"type": "unchanged"}, + "encrypt_key": {"type": "unchanged"}, + }, + } + ) + + +def test_update_requires_explicit_instruction_for_nullable_fields() -> None: + from controllers.console.human_input_v2.channel import IMChannelUpdatePayload + + with pytest.raises(ValidationError): + IMChannelUpdatePayload.model_validate( + { + "expected_config_version": "version", + "credentials": {"provider": "feishu", "app_id": "id", "app_secret": "secret"}, + } + ) + + +@pytest.mark.parametrize("operation", ["create", "test", "replacement"]) +def test_new_credentials_never_accept_retention_markers(operation: str) -> None: + from controllers.console.human_input_v2.channel import ( + IMChannelCreatePayload, + IMChannelReplacementPayload, + IMChannelTestPayload, + ) + + model = { + "create": IMChannelCreatePayload, + "test": IMChannelTestPayload, + "replacement": IMChannelReplacementPayload, + }[operation] + payload: dict[str, object] = { + "credentials": {"provider": "feishu", "app_id": "id", "app_secret": {"type": "unchanged"}} + } + if operation == "replacement": + payload["expected_config_version"] = "version" + with pytest.raises(ValidationError): + model.model_validate(payload) + + +@pytest.mark.parametrize("agent_id", ["agent-id", "00123", "0"]) +def test_wecom_update_does_not_validate_agent_id_format(agent_id: str) -> None: + from controllers.console.human_input_v2.provider_updates import WeComCredentialsUpdateInput + + credentials = WeComCredentialsUpdateInput.model_validate( + { + "provider": "we_com", + "corp_id": {"type": "unchanged"}, + "agent_id": agent_id, + "secret": {"type": "unchanged"}, + } + ) + + assert credentials.to_owner_update().agent_id == agent_id + + +def test_email_sender_settings_are_top_level_and_independent_of_credentials() -> None: + from controllers.console.human_input_v2.channel import EmailChannelCreatePayload + + payload = { + "sender_email": "sender@example.com", + "sender_name": "Sender", + "credentials": {"provider": "resend", "api_key": "private-api-key"}, + } + command = EmailChannelCreatePayload.model_validate(payload) + assert command.model_dump(mode="json")["sender_email"] == "sender@example.com" + assert command.model_dump(mode="json")["sender_name"] == "Sender" + assert "sender_email" not in command.model_dump(mode="json")["credentials"] + assert "sender_name" not in command.model_dump(mode="json")["credentials"] diff --git a/api/tests/unit_tests/controllers/console/test_human_input_channel_wire.py b/api/tests/unit_tests/controllers/console/test_human_input_channel_wire.py new file mode 100644 index 00000000000..5700bb0d383 --- /dev/null +++ b/api/tests/unit_tests/controllers/console/test_human_input_channel_wire.py @@ -0,0 +1,500 @@ +from __future__ import annotations + +from typing import Never + +import pytest +from flask import Flask +from flask_login import LoginManager +from werkzeug.local import LocalProxy + +from configs import dify_config +from controllers.console import bp as console_bp +from controllers.console.human_input_v2 import channel as channel_controller +from controllers.console.human_input_v2.config_version import encode_im_config_version +from controllers.console.wraps import _is_setup_completed +from enums import DeploymentEdition +from libs.login import AccountWithTenant +from models.account import Account, AccountStatus, TenantAccountRole +from repositories.human_input_v2.im_channel_repository import IMChannelId +from services.human_input_v2.errors import ( + ChannelAlreadyConfiguredError, + ChannelNotFoundError, + ChannelProviderError, + ProviderConfigurationUpdatedError, + ProviderFailureKind, + ReplacementRequiredError, +) + +_CHANNEL_ID = "00000000-0000-0000-0000-000000000001" +_BASE_PATH = "/console/api/workspaces/current/human-input/v2" +_EXPECTED_ROUTES = { + f"{_BASE_PATH}/channel-providers": {"GET"}, + f"{_BASE_PATH}/channels": {"GET"}, + f"{_BASE_PATH}/channels/email": {"POST"}, + f"{_BASE_PATH}/channels/email/test": {"POST"}, + f"{_BASE_PATH}/channels/email//test": {"POST"}, + f"{_BASE_PATH}/channels/email/": {"DELETE", "GET", "PUT"}, + f"{_BASE_PATH}/channels/im": {"POST"}, + f"{_BASE_PATH}/channels/im/test": {"POST"}, + f"{_BASE_PATH}/channels/im//test": {"POST"}, + f"{_BASE_PATH}/channels/im/": {"DELETE", "GET", "PUT"}, + f"{_BASE_PATH}/channels/im//replacement": {"POST"}, +} +_SLACK_CREDENTIALS = { + "provider": "slack", + "client_id": "client-id", + "client_secret": "client-secret", + "signing_secret": "signing-secret", + "bot_token": "xoxb-bot-token", + "app_token": "xapp-app-token", +} + + +@pytest.fixture +def wire_app(monkeypatch: pytest.MonkeyPatch) -> Flask: + account = Account(name="Owner", email="owner@example.com", status=AccountStatus.ACTIVE) + account.id = "account-1" + account.role = TenantAccountRole.OWNER + _is_setup_completed.mark_success() + + monkeypatch.setattr(dify_config, "LOGIN_DISABLED", True) + monkeypatch.setattr(dify_config, "RBAC_ENABLED", True) + monkeypatch.setattr("libs.login.current_user", LocalProxy(lambda: account)) + monkeypatch.setattr( + "controllers.console.wraps.current_account_with_tenant", + lambda: AccountWithTenant(account=account, tenant_id="workspace-1"), + ) + + application = Flask(__name__) + LoginManager().init_app(application) + application.config["TESTING"] = False + application.config["RESTX_ERROR_404_HELP"] = False + application.register_blueprint(console_bp) + return application + + +def test_console_blueprint_registers_only_the_canonical_channel_routes(wire_app: Flask) -> None: + registered_routes = { + rule.rule: set(rule.methods or ()) - {"HEAD", "OPTIONS"} + for rule in wire_app.url_map.iter_rules() + if rule.rule.startswith(f"{_BASE_PATH}/channel") + } + + assert registered_routes == _EXPECTED_ROUTES + + +def test_legacy_management_paths_are_route_level_not_found( + wire_app: Flask, + monkeypatch: pytest.MonkeyPatch, +) -> None: + build_calls: list[str] = [] + monkeypatch.setattr( + channel_controller, + "build_human_input_email_channel_management_service", + lambda: build_calls.append("email"), + ) + monkeypatch.setattr( + channel_controller, + "_workspace_im_channel_service", + lambda: build_calls.append("im"), + ) + + client = wire_app.test_client() + responses = [ + client.get("/console/api/workspaces/current/human-input/im-integration"), + client.post("/console/api/workspaces/current/human-input/im-integration/test", json={}), + ] + + assert [response.status_code for response in responses] == [404, 404] + assert build_calls == [] + + +@pytest.mark.parametrize( + ("error", "expected_code"), + [ + (ReplacementRequiredError("provider-secret"), "replacement_required"), + (ProviderConfigurationUpdatedError("database-detail"), "provider_configuration_updated"), + ], +) +def test_im_update_conflicts_have_stable_wire_codes( + wire_app: Flask, + monkeypatch: pytest.MonkeyPatch, + error: Exception, + expected_code: str, +) -> None: + class IMOwner: + def update(self, *_args: object) -> Never: + raise error + + monkeypatch.setattr( + channel_controller, + "_workspace_im_channel_service", + lambda *_args: IMOwner(), + ) + + response = wire_app.test_client().put( + f"{_BASE_PATH}/channels/im/{_CHANNEL_ID}", + json={ + "credentials": _SLACK_CREDENTIALS, + "expected_config_version": encode_im_config_version(IMChannelId(_CHANNEL_ID), 1), + }, + ) + + assert response.status_code == 409 + assert response.get_json() == { + "code": expected_code, + "message": ( + "Explicit IM Channel replacement is required." + if expected_code == "replacement_required" + else "The Channel configuration was updated." + ), + "status": 409, + } + assert "provider-secret" not in response.get_data(as_text=True) + assert "database-detail" not in response.get_data(as_text=True) + + +def test_kind_mismatch_is_a_detail_free_wire_not_found( + wire_app: Flask, + monkeypatch: pytest.MonkeyPatch, +) -> None: + class EmailOwner: + def get(self, *_args: object) -> Never: + raise ChannelNotFoundError("the channel belongs to another kind and scope") + + monkeypatch.setattr( + channel_controller, + "build_human_input_email_channel_management_service", + EmailOwner, + ) + + response = wire_app.test_client().get(f"{_BASE_PATH}/channels/email/{_CHANNEL_ID}") + + assert response.status_code == 404 + response_body = response.get_json() + assert response_body["status"] == 404 + assert response_body["message"] == "Channel not found." + assert "another kind" not in response.get_data(as_text=True) + + +def test_existing_im_create_is_a_generic_conflict_without_a_third_code( + wire_app: Flask, + monkeypatch: pytest.MonkeyPatch, +) -> None: + class IMOwner: + def create(self, *_args: object) -> Never: + raise ChannelAlreadyConfiguredError("persistence-conflict-detail") + + monkeypatch.setattr( + channel_controller, + "_workspace_im_channel_service", + lambda *_args: IMOwner(), + ) + + response = wire_app.test_client().post( + f"{_BASE_PATH}/channels/im", + json={"credentials": _SLACK_CREDENTIALS}, + ) + + assert response.status_code == 409 + response_body = response.get_json() + assert response_body["status"] == 409 + assert response_body["message"] == "A Channel is already configured for this kind." + assert response_body["code"] == "conflict" + assert "persistence-conflict-detail" not in response.get_data(as_text=True) + + +def test_unexpected_candidate_failure_is_a_detail_free_wire_internal_error( + wire_app: Flask, + monkeypatch: pytest.MonkeyPatch, +) -> None: + service_error = RuntimeError("provider-adapter-secret-diagnostic") + + class IMOwner: + def test(self, *_args: object) -> Never: + raise service_error + + monkeypatch.setattr( + channel_controller, + "_workspace_im_channel_service", + lambda *_args: IMOwner(), + ) + + response = wire_app.test_client().post( + f"{_BASE_PATH}/channels/im/test", + json={"credentials": _SLACK_CREDENTIALS}, + ) + + assert response.status_code == 500 + response_body = response.get_json() + assert response_body["status"] == 500 + assert response_body["message"] == "Internal Server Error" + assert "provider-adapter-secret-diagnostic" not in response.get_data(as_text=True) + + +@pytest.mark.parametrize( + "failure_kind", + [ProviderFailureKind.INVALID_CREDENTIALS, ProviderFailureKind.CONNECTION_FAILURE], +) +def test_candidate_provider_failures_are_credential_free_test_outcomes( + wire_app: Flask, + monkeypatch: pytest.MonkeyPatch, + failure_kind: ProviderFailureKind, +) -> None: + class IMOwner: + def test(self, *_args: object) -> Never: + raise ChannelProviderError(failure_kind, f"Safe {failure_kind.value} description.") + + monkeypatch.setattr( + channel_controller, + "_workspace_im_channel_service", + lambda *_args: IMOwner(), + ) + + response = wire_app.test_client().post( + f"{_BASE_PATH}/channels/im/test", + json={"credentials": _SLACK_CREDENTIALS}, + ) + + assert response.status_code == 200 + assert response.get_json() == { + "status": failure_kind.value, + "status_description": f"Safe {failure_kind.value} description.", + } + + +_CANONICAL_REQUESTS = ( + ("GET", f"{_BASE_PATH}/channel-providers"), + ("GET", f"{_BASE_PATH}/channels"), + ("POST", f"{_BASE_PATH}/channels/email"), + ("POST", f"{_BASE_PATH}/channels/email/test"), + ("GET", f"{_BASE_PATH}/channels/email/{_CHANNEL_ID}"), + ("PUT", f"{_BASE_PATH}/channels/email/{_CHANNEL_ID}"), + ("DELETE", f"{_BASE_PATH}/channels/email/{_CHANNEL_ID}"), + ("POST", f"{_BASE_PATH}/channels/im"), + ("POST", f"{_BASE_PATH}/channels/im/test"), + ("GET", f"{_BASE_PATH}/channels/im/{_CHANNEL_ID}"), + ("PUT", f"{_BASE_PATH}/channels/im/{_CHANNEL_ID}"), + ("DELETE", f"{_BASE_PATH}/channels/im/{_CHANNEL_ID}"), + ("POST", f"{_BASE_PATH}/channels/im/{_CHANNEL_ID}/replacement"), +) + + +@pytest.mark.parametrize(("method", "path"), _CANONICAL_REQUESTS) +def test_enterprise_rejects_every_canonical_path_before_auth_dto_and_service( + wire_app: Flask, + monkeypatch: pytest.MonkeyPatch, + method: str, + path: str, +) -> None: + def unexpected_authentication() -> None: + raise AssertionError("authentication must not run before edition admission") + + def unexpected_service(*_args: object) -> Never: + raise AssertionError("service composition must not run before edition admission") + + monkeypatch.setattr(dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.ENTERPRISE) + monkeypatch.setattr(dify_config, "LOGIN_DISABLED", False) + monkeypatch.setattr("libs.login._resolve_current_user", unexpected_authentication) + monkeypatch.setattr( + channel_controller, + "build_human_input_email_channel_management_service", + unexpected_service, + ) + monkeypatch.setattr(channel_controller, "_workspace_im_channel_service", unexpected_service) + + response = wire_app.test_client().open(path, method=method, json={"event_transport_mode": "STREAM"}) + + assert response.status_code == 501 + + +@pytest.mark.parametrize("edition", [DeploymentEdition.COMMUNITY, DeploymentEdition.CLOUD]) +def test_workspace_editions_continue_to_the_existing_authentication_stack( + wire_app: Flask, + monkeypatch: pytest.MonkeyPatch, + edition: DeploymentEdition, +) -> None: + monkeypatch.setattr(dify_config, "DEPLOYMENT_EDITION", edition) + monkeypatch.setattr(dify_config, "LOGIN_DISABLED", False) + monkeypatch.setattr("libs.login._resolve_current_user", lambda: None) + + response = wire_app.test_client().get(f"{_BASE_PATH}/channel-providers") + + assert response.status_code == 401 + + +@pytest.mark.parametrize("kind", ["im", "email"]) +def test_saved_connection_test_accepts_retention_without_saving( + wire_app: Flask, monkeypatch: pytest.MonkeyPatch, kind: str +) -> None: + from controllers.console.human_input_v2.config_version import encode_email_config_version + from core.human_input_v2.shared import EmailProviderId + from repositories.human_input_v2.email_channel import EmailConfigurationSnapshot + + tested: list[object] = [] + + class Owner: + def test_saved(self, *args: object) -> None: + tested.append(args[-1]) + + monkeypatch.setattr(channel_controller, "_workspace_im_channel_service", lambda *_args: Owner()) + monkeypatch.setattr(channel_controller, "build_human_input_email_channel_management_service", Owner) + credentials = ( + {name: value if name == "provider" else {"type": "unchanged"} for name, value in _SLACK_CREDENTIALS.items()} + if kind == "im" + else { + "provider": "resend", + "api_key": {"type": "unchanged"}, + } + ) + version = ( + encode_im_config_version(IMChannelId(_CHANNEL_ID), 1) + if kind == "im" + else encode_email_config_version(EmailConfigurationSnapshot(EmailProviderId(_CHANNEL_ID), 1)) + ) + + body = {"expected_config_version": version, "credentials": credentials} + if kind == "email": + body.update({"sender_email": {"type": "unchanged"}, "sender_name": {"type": "unchanged"}}) + response = wire_app.test_client().post( + f"{_BASE_PATH}/channels/{kind}/{_CHANNEL_ID}/test", + json=body, + ) + + assert response.status_code == 200 + assert response.get_json() == {"status": "connected", "status_description": ""} + assert len(tested) == 1 + + +@pytest.mark.parametrize("kind", ["im", "email"]) +@pytest.mark.parametrize( + ("failure", "expected_status", "expected_code"), + [ + (ChannelNotFoundError("private-owner"), 404, "not_found"), + (ProviderConfigurationUpdatedError("private-version"), 409, "provider_configuration_updated"), + (ChannelProviderError(ProviderFailureKind.INVALID_CREDENTIALS, "Invalid credentials."), 200, None), + ], +) +def test_saved_connection_test_translates_failures_safely( + wire_app: Flask, + monkeypatch: pytest.MonkeyPatch, + kind: str, + failure: Exception, + expected_status: int, + expected_code: str | None, +) -> None: + from controllers.console.human_input_v2.config_version import encode_email_config_version + from core.human_input_v2.shared import EmailProviderId + from repositories.human_input_v2.email_channel import EmailConfigurationSnapshot + + class Owner: + def test_saved(self, *_args: object) -> Never: + raise failure + + monkeypatch.setattr(channel_controller, "_workspace_im_channel_service", lambda *_args: Owner()) + monkeypatch.setattr(channel_controller, "build_human_input_email_channel_management_service", Owner) + credentials = ( + _SLACK_CREDENTIALS + if kind == "im" + else { + "provider": "resend", + "api_key": "api-key", + } + ) + version = ( + encode_im_config_version(IMChannelId(_CHANNEL_ID), 1) + if kind == "im" + else encode_email_config_version(EmailConfigurationSnapshot(EmailProviderId(_CHANNEL_ID), 1)) + ) + body = {"expected_config_version": version, "credentials": credentials} + if kind == "email": + body.update({"sender_email": {"type": "unchanged"}, "sender_name": {"type": "unchanged"}}) + response = wire_app.test_client().post( + f"{_BASE_PATH}/channels/{kind}/{_CHANNEL_ID}/test", + json=body, + ) + + assert response.status_code == expected_status + if expected_code is not None: + assert response.get_json()["code"] == expected_code + else: + assert response.get_json() == {"status": "invalid_credentials", "status_description": "Invalid credentials."} + assert "private-" not in response.get_data(as_text=True) + + +def test_im_detail_returns_identifiers_and_redacted_secret_states( + wire_app: Flask, + monkeypatch: pytest.MonkeyPatch, +) -> None: + from datetime import datetime + + from core.human_input_v2.entities import IMProvider + from core.human_input_v2.im_integration.adapters.credentials import FeishuCredentials + from repositories.human_input_v2.im_channel_repository import IMChannelStatus + from services.human_input_v2.im_channel_service import IMChannelDetailView, IMChannelView + + view = IMChannelView( + id=IMChannelId(_CHANNEL_ID), + created_at=datetime(2026, 10, 15), + updated_at=datetime(2026, 10, 15), + provider=IMProvider.FEISHU, + status=IMChannelStatus.CONNECTED, + status_reason=None, + app_identifier="app", + webhook_url=None, + config_version=1, + ) + + class Owner: + def get_detail(self, _channel_id: IMChannelId) -> IMChannelDetailView: + return IMChannelDetailView( + view, + FeishuCredentials( + provider=IMProvider.FEISHU, + app_id="feishu-app-id", + app_secret="stored-app-secret", + verification_token=None, + encrypt_key="stored-encrypt-key", + ), + ) + + monkeypatch.setattr(channel_controller, "_workspace_im_channel_service", lambda *_args: Owner()) + response = wire_app.test_client().get(f"{_BASE_PATH}/channels/im/{_CHANNEL_ID}") + + assert response.status_code == 200 + assert response.get_json()["credentials"] == { + "provider": "feishu", + "app_id": "feishu-app-id", + "app_secret": {"type": "redacted"}, + "verification_token": None, + "encrypt_key": {"type": "redacted"}, + } + assert "stored-app-secret" not in response.get_data(as_text=True) + assert "stored-encrypt-key" not in response.get_data(as_text=True) + + +def test_saved_im_test_rejects_provider_mismatch_as_bad_request( + wire_app: Flask, monkeypatch: pytest.MonkeyPatch +) -> None: + class Owner: + def test_saved(self, *_args: object) -> Never: + raise ReplacementRequiredError("private-provider-mismatch") + + monkeypatch.setattr(channel_controller, "_workspace_im_channel_service", lambda *_args: Owner()) + response = wire_app.test_client().post( + f"{_BASE_PATH}/channels/im/{_CHANNEL_ID}/test", + json={ + "expected_config_version": encode_im_config_version(IMChannelId(_CHANNEL_ID), 1), + "credentials": { + "provider": "feishu", + "app_id": {"type": "unchanged"}, + "app_secret": {"type": "unchanged"}, + "verification_token": {"type": "unchanged"}, + "encrypt_key": {"type": "unchanged"}, + }, + }, + ) + + assert response.status_code == 400 + assert response.get_json()["code"] == "bad_request" + assert "private-provider-mismatch" not in response.get_data(as_text=True) diff --git a/api/tests/unit_tests/controllers/console/test_human_input_message_template.py b/api/tests/unit_tests/controllers/console/test_human_input_message_template.py new file mode 100644 index 00000000000..28870f8eb15 --- /dev/null +++ b/api/tests/unit_tests/controllers/console/test_human_input_message_template.py @@ -0,0 +1,163 @@ +"""Message-template test requests dispatch through both draft routes.""" + +from collections.abc import Mapping +from inspect import unwrap +from typing import Never + +import pytest +from flask import Flask +from pydantic import JsonValue +from sqlalchemy.orm import sessionmaker + +from controllers.console.app import workflow_human_input_v2 as controller +from core.human_input_v2.entities import DebugChannel +from core.human_input_v2.im_integration.adapters.protocols import IMProviderAdapter +from core.human_input_v2.shared import AccountId, TenantId +from graphon.variable_loader import VariableLoader +from models.account import Account +from models.model import App +from repositories.human_input_v2.email_channel.repository import SQLAlchemyEmailChannelRepository +from repositories.human_input_v2.im_channel_repository import IMChannel +from services.human_input_v2.form_service import HumanInputDeliveryTestFormService +from services.human_input_v2.message_template_test_service import MessageTemplateTestService +from services.human_input_v2.resend_channel import ResendProviderGateway + + +@pytest.fixture +def template_service() -> MessageTemplateTestService: + sessions = sessionmaker() + + def build_adapter(_channel: IMChannel) -> IMProviderAdapter: + raise AssertionError("Controller dispatch must not resolve a provider") + + def variable_loader(app_id: str) -> VariableLoader: + raise AssertionError(f"Controller dispatch must not load variables for {app_id}") + + return MessageTemplateTestService( + session_factory=sessions, + tenant_id=TenantId("tenant-id"), + account_id=AccountId("editor-id"), + account_email="editor@example.com", + email_repository_factory=SQLAlchemyEmailChannelRepository, + email_gateway=ResendProviderGateway(), + im_adapter_factory=build_adapter, + form_service=HumanInputDeliveryTestFormService( + session_factory=sessions, + tenant_id=TenantId("tenant-id"), + account_id=AccountId("editor-id"), + variable_loader_factory=variable_loader, + ), + ) + + +@pytest.mark.parametrize( + "resource", + [controller.WorkflowDraftMessageTemplateTestApi, controller.AdvancedChatDraftMessageTemplateTestApi], +) +def test_template_test_dispatches_for_current_editor( + template_service: MessageTemplateTestService, + monkeypatch: pytest.MonkeyPatch, + resource: type[controller.WorkflowDraftMessageTemplateTestApi | controller.AdvancedChatDraftMessageTemplateTestApi], +) -> None: + builder_calls: list[tuple[str, str, str]] = [] + sends: list[tuple[str, str, DebugChannel, dict[str, JsonValue]]] = [] + + def build(*, tenant_id: str, account_id: str, account_email: str) -> MessageTemplateTestService: + builder_calls.append((tenant_id, account_id, account_email)) + return template_service + + def send( + _self: MessageTemplateTestService, + *, + app_id: str, + node_id: str, + channel: DebugChannel, + inputs: Mapping[str, JsonValue], + ) -> None: + sends.append((app_id, node_id, channel, dict(inputs))) + + monkeypatch.setattr(controller, "build_message_template_test_service", build) + monkeypatch.setattr(MessageTemplateTestService, "send_test", send) + account = Account(name="Editor", email="editor@example.com") + account.id = "editor-id" + app_model = App(id="app-id", tenant_id="tenant-id") + application = Flask(__name__) + + with application.test_request_context(json={"channel": "email", "inputs": {"#start.name#": "Alice"}}): + response = unwrap(resource.post)(resource(), account, app_model, "approval") + + assert response == {} + assert builder_calls == [("tenant-id", "editor-id", "editor@example.com")] + assert sends == [("app-id", "approval", DebugChannel.EMAIL, {"#start.name#": "Alice"})] + + +@pytest.mark.parametrize( + ("error", "status", "code"), + [ + (controller.MessageTemplateTestError("No channel."), 400, "message_template_test_invalid"), + (controller.MessageTemplateSendError("Provider failed."), 502, "message_template_test_send_failed"), + ], +) +def test_send_failures_have_explicit_http_errors( + template_service: MessageTemplateTestService, + monkeypatch: pytest.MonkeyPatch, + error: Exception, + status: int, + code: str, +) -> None: + def fail( + _self: MessageTemplateTestService, + *, + app_id: str, + node_id: str, + channel: DebugChannel, + inputs: Mapping[str, JsonValue], + ) -> Never: + assert (app_id, node_id, channel, dict(inputs)) == ("app-id", "approval", DebugChannel.SLACK, {}) + raise error + + monkeypatch.setattr(MessageTemplateTestService, "send_test", fail) + monkeypatch.setattr(controller, "build_message_template_test_service", lambda **_kwargs: template_service) + account = Account(name="Editor", email="editor@example.com") + account.id = "editor-id" + app_model = App(id="app-id", tenant_id="tenant-id") + with Flask(__name__).test_request_context(json={"channel": "slack"}): + with pytest.raises(controller.BaseHTTPException) as raised: + unwrap(controller.WorkflowDraftMessageTemplateTestApi.post)( + controller.WorkflowDraftMessageTemplateTestApi(), account, app_model, "approval" + ) + assert raised.value.code == status + assert raised.value.error_code == code + + +@pytest.mark.parametrize("prefix", ["workflows", "advanced-chat/workflows"]) +def test_enterprise_template_test_requires_authentication_without_channel_management_gate( + monkeypatch: pytest.MonkeyPatch, + prefix: str, +) -> None: + from flask_login import LoginManager + + from configs import dify_config + from controllers.console import bp as console_bp + from enums import DeploymentEdition + + monkeypatch.setattr(dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.ENTERPRISE) + monkeypatch.setattr(dify_config, "LOGIN_DISABLED", False) + monkeypatch.setattr("controllers.console.wraps._is_setup_completed", lambda: True) + monkeypatch.setattr("libs.login._resolve_current_user", lambda: None) + + def unexpected_build(**_kwargs: str) -> Never: + raise AssertionError("Unauthenticated requests must not build the service") + + monkeypatch.setattr(controller, "build_message_template_test_service", unexpected_build) + application = Flask(__name__) + LoginManager(application) + application.register_blueprint(console_bp) + + response = application.test_client().post( + f"/console/api/apps/00000000-0000-0000-0000-000000000001/{prefix}" + "/draft/human-input/nodes/approval/message-template/test", + json={"invalid": "payload"}, + ) + + assert response.status_code == 401 diff --git a/api/tests/unit_tests/controllers/console/test_human_input_v2_form_read.py b/api/tests/unit_tests/controllers/console/test_human_input_v2_form_read.py new file mode 100644 index 00000000000..cd3175b225f --- /dev/null +++ b/api/tests/unit_tests/controllers/console/test_human_input_v2_form_read.py @@ -0,0 +1,371 @@ +"""Read Console v2 forms through their registered HTTP and authorization boundaries.""" + +import hashlib +from dataclasses import dataclass +from datetime import datetime, timedelta +from urllib.parse import parse_qs, urlsplit +from uuid import uuid4 + +import pytest +import sqlalchemy as sa +from flask import Response, g +from flask.testing import FlaskClient +from sqlalchemy.orm import Session, sessionmaker + +from controllers.console.human_input_v2 import form as controller +from core.human_input import ButtonStyle +from core.human_input_v2.resolved_form import MarkdownFragment, ParagraphInput, ResolvedForm, UserAction +from core.human_input_v2.shared.values import ContactId, TenantId +from core.tools.signature import sign_tool_file, verify_tool_file_signature +from core.workflow.nodes.human_input.enums import HumanInputFormKind, HumanInputFormStatus +from core.workflow.nodes.human_input_v2.entities import Contact +from dify_app import DifyApp +from enums import DeploymentEdition +from extensions.ext_login import DifyLoginManager +from graphon.variables.segments import StringSegment +from libs.external_api import ExternalApi +from models.account import Account, AccountStatus, TenantAccountJoin, TenantAccountRole +from models.human_input_v2 import ( + ContactSubjectType, + HumanInputContactIdentity, + HumanInputDelivery, + HumanInputForm, + HumanInputPlatformContactWorkspaceEntry, +) +from repositories.human_input_v2.delivery_repository import DeliveryCreateParams, InitiatorSnapshot, SubmissionAuthType +from repositories.human_input_v2.form_repository import Form, FormCreateParams +from repositories.human_input_v2.recipient_repository import ContactRecipientSubject, Recipient, RecipientCreateParams +from repositories.human_input_v2.sqlalchemy_delivery_repository import SQLAlchemyDeliveryRepository +from repositories.human_input_v2.sqlalchemy_form_repository import SQLAlchemyFormRepository +from repositories.human_input_v2.sqlalchemy_recipient_repository import SQLAlchemyRecipientRepository +from tests.helpers.unit.config_override import apply_config_overrides +from tests.helpers.unit.model_factories import make_account, make_tenant + +_NOW = datetime(2030, 1, 1) +_TOKEN = "console-v2-read-token" +_PATH = f"/console/api/form/human-input/v2/{_TOKEN}" + + +@dataclass +class ConsoleForm: + client: FlaskClient + sessions: sessionmaker[Session] + form: Form + recipient: Recipient + contact_id: ContactId + actor: Account | None + + +@pytest.fixture +def console_form(sqlite_session_factory: sessionmaker[Session], monkeypatch: pytest.MonkeyPatch) -> ConsoleForm: + tenant = make_tenant(tenant_id=str(uuid4())) + active_tenant = make_tenant(tenant_id=str(uuid4())) + account = make_account(account_id=str(uuid4()), tenant=active_tenant) + identity = HumanInputContactIdentity(subject_type=ContactSubjectType.ACCOUNT, account_id=account.id) + with sqlite_session_factory.begin() as session: + session.add_all( + [ + tenant, + active_tenant, + account, + identity, + TenantAccountJoin(tenant_id=tenant.id, account_id=account.id, role=TenantAccountRole.NORMAL), + TenantAccountJoin(tenant_id=active_tenant.id, account_id=account.id, role=TenantAccountRole.NORMAL), + ] + ) + form = SQLAlchemyFormRepository(session, TenantId(tenant.id), str(uuid4())).create_form( + FormCreateParams( + workflow_run_id=None, + node_execution_id=None, + form_kind=HumanInputFormKind.DELIVERY_TEST, + resolved_form=ResolvedForm( + title="Frozen approval", + parts=( + MarkdownFragment(text="Review: "), + ParagraphInput(output_variable_name="note", default_value="Draft"), + ), + actions=(UserAction(id="approve", title="Approve", button_style=ButtonStyle.PRIMARY),), + legacy_form_content="Review: {{#$output.note#}}", + ), + expiration_time=_NOW + timedelta(hours=1), + global_timeout_deadline=_NOW + timedelta(hours=2), + ) + ) + recipient = SQLAlchemyRecipientRepository(session, form.tenant_id, form.id).create_recipients( + [ + RecipientCreateParams( + ContactRecipientSubject(ContactId(identity.id)), (Contact(contact_id=identity.id),), account.name + ) + ] + )[0] + SQLAlchemyDeliveryRepository(session).create_delivery( + tenant_id=form.tenant_id, + form_id=form.id, + params=DeliveryCreateParams( + recipient_id=recipient.id, + token_hash=hashlib.sha256(_TOKEN.encode()).hexdigest(), + auth_type=SubmissionAuthType.CONSOLE, + target_snapshot=InitiatorSnapshot(protected_form_token="encrypted-token"), + ), + ) + + apply_config_overrides(monkeypatch, DEPLOYMENT_EDITION=DeploymentEdition.CLOUD, LOGIN_DISABLED=False) + monkeypatch.setattr("services.human_input_v2.submission_service.naive_utc_now", lambda: _NOW) + # Isolate session decoding and CSRF; keep the route's login and Contact authorization real. + monkeypatch.setattr("libs.login.check_csrf_token", lambda _request, _user_id: None) + app = DifyApp(__name__) + manager = DifyLoginManager(app) + + @manager.unauthorized_handler + def unauthorized() -> Response: + return Response( + '{"code":"unauthorized","message":"Login required","status":401}', status=401, mimetype="application/json" + ) + + scenario = ConsoleForm(app.test_client(), sqlite_session_factory, form, recipient, ContactId(identity.id), account) + + @app.before_request + def load_session_actor() -> None: + g._login_user = scenario.actor + + ExternalApi(app).add_namespace(controller.console_ns, path="/console/api") + return scenario + + +def test_reads_frozen_form_from_its_workspace_without_mutating_it(console_form: ConsoleForm) -> None: + response = console_form.client.get(_PATH) + + assert response.status_code == 200 + assert response.json == { + "resolved_form": { + "title": "Frozen approval", + "parts": [ + {"type": "markdown_fragment", "text": "Review: "}, + {"type": "paragraph_input", "output_variable_name": "note", "default_value": "Draft"}, + ], + "actions": [{"id": "approve", "title": "Approve", "button_style": "primary"}], + "legacy_form_content": "Review: {{#$output.note#}}", + }, + "expiration_time": 1893459600, + } + with console_form.sessions() as session: + form = session.get(HumanInputForm, console_form.form.id) + assert form is not None + assert form.status == HumanInputFormStatus.WAITING + assert form.updated_at == console_form.form.updated_at + assert form.submitted_at is None + + +def test_requires_console_login(console_form: ConsoleForm) -> None: + console_form.actor = None + assert console_form.client.get(_PATH).status_code == 401 + + +def test_reads_refresh_file_signatures_without_changing_the_snapshot( + console_form: ConsoleForm, monkeypatch: pytest.MonkeyPatch +) -> None: + apply_config_overrides(monkeypatch, FILES_URL="https://files.example.com", FILES_ACCESS_TIMEOUT=300) + file_id = str(uuid4()) + monkeypatch.setattr("core.tools.signature.time.time", lambda: 1700000000) + old_url = sign_tool_file(file_id, ".pdf") + content = f"[Report]({old_url}) {{{{#$output.note#}}}}" + snapshot = console_form.form.resolved_form.model_copy( + update={ + "parts": ( + MarkdownFragment(text=f"[Report]({old_url}) "), + ParagraphInput(output_variable_name="note", default_value="Draft"), + ), + "legacy_form_content": content, + } + ) + with console_form.sessions.begin() as session: + session.execute( + sa.update(HumanInputForm).where(HumanInputForm.id == console_form.form.id).values(resolved_form=snapshot) + ) + + urls: list[str] = [] + for now in (1700000400, 1700000500): + monkeypatch.setattr("core.tools.signature.time.time", lambda now=now: now) + response = console_form.client.get(_PATH) + assert response.status_code == 200 + assert response.json is not None + resolved = response.json["resolved_form"] + refreshed_content = resolved["legacy_form_content"] + assert resolved["parts"][0]["text"] + "{{#$output.note#}}" == refreshed_content + url = refreshed_content.removeprefix("[Report](").removesuffix(") {{#$output.note#}}") + query = parse_qs(urlsplit(url).query) + assert query["timestamp"] == [str(now)] + assert verify_tool_file_signature( + file_id, + query["timestamp"][0], + query["nonce"][0], + query["sign"][0], + ) + assert urlsplit(url).path == urlsplit(old_url).path + urls.append(url) + assert urls[0] != old_url + assert urls[0] != urls[1] + with console_form.sessions() as session: + stored = session.get(HumanInputForm, console_form.form.id) + assert stored is not None + assert stored.resolved_form == snapshot + + +def test_unknown_token_is_not_found(console_form: ConsoleForm) -> None: + response = console_form.client.get("/console/api/form/human-input/v2/unknown") + assert response.status_code == 404 + assert response.json is not None + assert response.json["code"] == "not_found" + + +@pytest.mark.parametrize("denial", ["login", "recipient", "submitted", "timeout", "expired", "deadline"]) +def test_denied_file_form_does_not_return_its_content(console_form: ConsoleForm, denial: str) -> None: + url = sign_tool_file(str(uuid4()), ".pdf") + content = f"[Report]({url})" + snapshot = console_form.form.resolved_form.model_copy( + update={"parts": (MarkdownFragment(text=content),), "legacy_form_content": content} + ) + with console_form.sessions.begin() as session: + session.execute(sa.update(HumanInputForm).values(resolved_form=snapshot)) + match denial: + case "login": + console_form.actor = None + case "recipient": + session.execute(sa.delete(HumanInputContactIdentity)) + case "submitted": + SQLAlchemyFormRepository(session, console_form.form.tenant_id, console_form.form.app_id).submit_form( + console_form.form.id, + submitted_by_recipient_id=console_form.recipient.id, + submitted_by_delivery_id=session.scalars(sa.select(HumanInputDelivery)).one().id, + selected_action_id="approve", + raw_submission_inputs={}, + normalized_submission_data={}, + ) + case "timeout": + session.execute(sa.update(HumanInputForm).values(status=HumanInputFormStatus.TIMEOUT)) + case "expired": + session.execute(sa.update(HumanInputForm).values(status=HumanInputFormStatus.EXPIRED)) + case "deadline": + session.execute(sa.update(HumanInputForm).values(expiration_time=_NOW)) + response = console_form.client.get(_PATH) + assert response.status_code in (401, 404, 409, 410) + assert response.json is not None + assert "resolved_form" not in response.json + assert url not in response.get_data(as_text=True) + + +@pytest.mark.parametrize("auth_type", [SubmissionAuthType.EMAIL_OTP, SubmissionAuthType.WEB_APP, SubmissionAuthType.IM]) +def test_rejects_tokens_owned_by_other_surfaces(console_form: ConsoleForm, auth_type: SubmissionAuthType) -> None: + with console_form.sessions.begin() as session: + session.execute( + sa.update(HumanInputDelivery) + .where(HumanInputDelivery.form_id == console_form.form.id) + .values(auth_type=auth_type) + ) + response = console_form.client.get(_PATH) + assert response.status_code == 404 + assert response.json is not None + assert response.json["code"] == "not_found" + + +@pytest.mark.parametrize("revocation", ["membership", "contact", "account"]) +def test_rechecks_current_contact_authorization(console_form: ConsoleForm, revocation: str) -> None: + assert console_form.client.get(_PATH).status_code == 200 + assert console_form.actor is not None + with console_form.sessions.begin() as session: + match revocation: + case "membership": + session.execute( + sa.delete(TenantAccountJoin).where(TenantAccountJoin.tenant_id == console_form.form.tenant_id) + ) + case "contact": + session.execute( + sa.delete(HumanInputContactIdentity).where(HumanInputContactIdentity.id == console_form.contact_id) + ) + case "account": + session.execute( + sa.update(Account).where(Account.id == console_form.actor.id).values(status=AccountStatus.BANNED) + ) + response = console_form.client.get(_PATH) + assert response.status_code == 404 + assert response.json is not None + assert response.json["code"] == "not_found" + + +def test_platform_contact_remains_authorized_without_workspace_membership(console_form: ConsoleForm) -> None: + assert console_form.actor is not None + with console_form.sessions.begin() as session: + session.execute(sa.delete(TenantAccountJoin).where(TenantAccountJoin.tenant_id == console_form.form.tenant_id)) + session.add( + HumanInputPlatformContactWorkspaceEntry( + tenant_id=console_form.form.tenant_id, + contact_id=console_form.contact_id, + added_by_account_id=console_form.actor.id, + ) + ) + assert console_form.client.get(_PATH).status_code == 200 + + +def test_another_account_cannot_read_the_form(console_form: ConsoleForm) -> None: + assert console_form.actor is not None + console_form.actor = make_account(account_id=str(uuid4()), tenant=console_form.actor.current_tenant) + response = console_form.client.get(_PATH) + assert response.status_code == 404 + assert response.json is not None + assert response.json["code"] == "not_found" + + +@pytest.mark.parametrize("deadline", ["expiration_time", "global_timeout_deadline"]) +def test_waiting_form_is_unavailable_at_either_deadline(console_form: ConsoleForm, deadline: str) -> None: + with console_form.sessions.begin() as session: + session.execute( + sa.update(HumanInputForm).where(HumanInputForm.id == console_form.form.id).values({deadline: _NOW}) + ) + response = console_form.client.get(_PATH) + assert response.status_code == 410 + assert response.json is not None + assert response.json["code"] == "expired" + + +def test_response_uses_the_earlier_global_deadline(console_form: ConsoleForm) -> None: + with console_form.sessions.begin() as session: + session.execute( + sa.update(HumanInputForm) + .where(HumanInputForm.id == console_form.form.id) + .values(global_timeout_deadline=_NOW + timedelta(minutes=30)) + ) + response = console_form.client.get(_PATH) + assert response.status_code == 200 + assert response.json is not None + assert response.json["expiration_time"] == 1893457800 + + +@pytest.mark.parametrize("status", [HumanInputFormStatus.TIMEOUT, HumanInputFormStatus.EXPIRED]) +def test_rejects_terminal_forms(console_form: ConsoleForm, status: HumanInputFormStatus) -> None: + with console_form.sessions.begin() as session: + session.execute( + sa.update(HumanInputForm).where(HumanInputForm.id == console_form.form.id).values(status=status) + ) + response = console_form.client.get(_PATH) + assert response.status_code == 410 + assert response.json is not None + assert response.json["code"] == "expired" + + +def test_submitted_form_is_reported_only_to_its_recipient(console_form: ConsoleForm) -> None: + with console_form.sessions.begin() as session: + SQLAlchemyFormRepository(session, console_form.form.tenant_id, console_form.form.app_id).submit_form( + console_form.form.id, + submitted_by_recipient_id=console_form.recipient.id, + submitted_by_delivery_id=session.scalars(sa.select(HumanInputDelivery)).one().id, + selected_action_id="approve", + raw_submission_inputs={"note": "Approved"}, + normalized_submission_data={"note": StringSegment(value="Approved")}, + ) + response = console_form.client.get(_PATH) + assert response.status_code == 409 + assert response.json is not None + assert response.json["code"] == "already_submitted" + assert console_form.actor is not None + console_form.actor = make_account(account_id=str(uuid4()), tenant=console_form.actor.current_tenant) + assert console_form.client.get(_PATH).status_code == 404 diff --git a/api/tests/unit_tests/controllers/console/workspace/test_contact_transactions.py b/api/tests/unit_tests/controllers/console/workspace/test_contact_transactions.py new file mode 100644 index 00000000000..70e3944cd33 --- /dev/null +++ b/api/tests/unit_tests/controllers/console/workspace/test_contact_transactions.py @@ -0,0 +1,101 @@ +"""HTTP behavior of Contact writes using real services and SQLite persistence.""" + +import pytest +from flask import Flask, g +from flask.testing import FlaskClient +from sqlalchemy import select +from sqlalchemy.orm import Session, sessionmaker + +from configs import dify_config +from controllers.console.human_input_v2.contact import ( + WorkspaceContactsRemoveApi, + WorkspaceExternalContactApi, + WorkspaceExternalContactsApi, +) +from enums import DeploymentEdition +from libs.external_api import ExternalApi +from models.account import Account, AccountStatus, Tenant, TenantAccountRole +from models.human_input_v2 import HumanInputExternalContactProfile + +_CONTACTS_URL = "/console/api/workspaces/current/human-input/v2/contacts" +_TENANT_ID = "00000000-0000-0000-0000-000000000101" + + +@pytest.fixture +def client(sqlite_session_factory: sessionmaker[Session], monkeypatch: pytest.MonkeyPatch) -> FlaskClient: + with sqlite_session_factory.begin() as session: + tenant = Tenant(name="Contact transaction test") + tenant.id = _TENANT_ID + session.add(tenant) + + account = Account(name="Owner", email="owner@example.com", status=AccountStatus.ACTIVE) + account.role = TenantAccountRole.OWNER + account._current_tenant = tenant + monkeypatch.setattr(dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.CLOUD) + monkeypatch.setattr(dify_config, "LOGIN_DISABLED", True) + monkeypatch.setattr(dify_config, "RBAC_ENABLED", False) + + app = Flask(__name__) + api = ExternalApi(app) + api.add_resource(WorkspaceExternalContactsApi, f"{_CONTACTS_URL}/external") + api.add_resource(WorkspaceExternalContactApi, f"{_CONTACTS_URL}/external/") + api.add_resource(WorkspaceContactsRemoveApi, f"{_CONTACTS_URL}/remove") + + @app.before_request + def authenticate_owner() -> None: + # Supply request identity; keep controller, service, repository and transaction handling real. + g._login_user = account + + return app.test_client() + + +def test_external_contact_create_edit_and_remove( + client: FlaskClient, sqlite_session_factory: sessionmaker[Session] +) -> None: + created = client.post(f"{_CONTACTS_URL}/external", json={"name": "Original name", "email": "contact@example.com"}) + assert created.status_code == 200 + contact_id = created.get_json()["contact"]["id"] + + edited = client.patch(f"{_CONTACTS_URL}/external/{contact_id}", json={"name": "Updated name"}) + assert edited.status_code == 200 + assert edited.get_json()["contact"]["name"] == "Updated name" + assert edited.get_json()["contact"]["email"] == "contact@example.com" + with sqlite_session_factory() as session: + stored = session.scalar( + select(HumanInputExternalContactProfile).where(HumanInputExternalContactProfile.contact_id == contact_id) + ) + assert stored is not None + assert (stored.name, stored.email) == ("Updated name", "contact@example.com") + + removed = client.post(f"{_CONTACTS_URL}/remove", json={"contact_ids": [contact_id]}) + assert removed.status_code == 200 + assert removed.get_json()["removed_contact_ids"] == [contact_id] + with sqlite_session_factory() as session: + assert session.scalar(select(HumanInputExternalContactProfile)) is None + + +def test_conflicting_email_returns_409_without_changing_contact( + client: FlaskClient, sqlite_session_factory: sessionmaker[Session] +) -> None: + for name, email in (("Original name", "contact@example.com"), ("Other contact", "other@example.com")): + response = client.post(f"{_CONTACTS_URL}/external", json={"name": name, "email": email}) + assert response.status_code == 200 + + with sqlite_session_factory() as session: + contact_id = session.scalar( + select(HumanInputExternalContactProfile.contact_id).where( + HumanInputExternalContactProfile.email == "contact@example.com" + ) + ) + + response = client.patch( + f"{_CONTACTS_URL}/external/{contact_id}", + json={"name": "Must roll back", "email": "other@example.com"}, + ) + assert response.status_code == 409 + with sqlite_session_factory() as session: + stored = session.scalar( + select(HumanInputExternalContactProfile).where(HumanInputExternalContactProfile.contact_id == contact_id) + ) + assert stored is not None + assert (stored.name, stored.email) == ("Original name", "contact@example.com") diff --git a/api/tests/unit_tests/controllers/console/workspace/test_human_input_node_data_migration.py b/api/tests/unit_tests/controllers/console/workspace/test_human_input_node_data_migration.py new file mode 100644 index 00000000000..c5d1aa767ff --- /dev/null +++ b/api/tests/unit_tests/controllers/console/workspace/test_human_input_node_data_migration.py @@ -0,0 +1,637 @@ +import inspect +import json +from collections.abc import Sequence +from http import HTTPStatus +from importlib import import_module +from inspect import unwrap +from pathlib import Path +from typing import Never + +import pytest +from flask import Flask +from pydantic import ValidationError +from werkzeug.exceptions import Forbidden +from werkzeug.local import LocalProxy + +from controllers.console.human_input_v2.contact import NodeDataMigrationAPI +from core.human_input_v2.resolved_form import FileInput, FileListInput, ParagraphInput, SelectInput +from core.human_input_v2.shared.values import NormalizedEmail, TenantId +from core.workflow.nodes.human_input.entities import HumanInputNodeDataFull +from core.workflow.nodes.human_input.enums import FormInputType +from core.workflow.nodes.human_input_v2.entities import HumanInputNodeData +from core.workflow.nodes.human_input_v2.form_compiler import compile_resolved_form +from core.workflow.nodes.human_input_v2.migration import ( + MemberEmailSnapshot, + MigrationBlockerCode, + ResolvedMemberEmail, + convert_legacy_human_input_node_data, +) +from graphon.file.enums import FileTransferMethod, FileType +from graphon.runtime import VariablePool +from graphon.variables import ArrayStringSegment +from models.account import Account, TenantAccountRole +from services.human_input_v2.node_data_migration import ( + HumanInputNodeDataMigrationService, + MigratedNode, + MigrationNode, + NodeDataMigrationBlocker, + NodeDataMigrationFailure, + NodeDataMigrationSuccess, +) + +_CONTROLLER_MODULE = import_module("controllers.console.human_input_v2.contact") + + +class _StaticMemberEmailLookup: + def find_member_emails(self, tenant_id: TenantId, account_ids: Sequence[str]) -> MemberEmailSnapshot: + del tenant_id + return MemberEmailSnapshot( + tuple( + ResolvedMemberEmail(account_id, NormalizedEmail(f"{account_id}@example.com")) + for account_id in account_ids + ) + ) + + +def _legacy_webapp_node(title: str = "Approval") -> HumanInputNodeDataFull: + return HumanInputNodeDataFull.model_validate( + { + "title": title, + "delivery_methods": [{"id": "22222222-2222-4222-8222-222222222222", "type": "webapp", "config": {}}], + } + ) + + +def _migrated_webapp_node() -> HumanInputNodeData: + conversion = convert_legacy_human_input_node_data(_legacy_webapp_node(), MemberEmailSnapshot()) + assert conversion.node_data is not None + return conversion.node_data + + +def test_controller_returns_typed_ordered_success_data(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: + migrated_node_data = _migrated_webapp_node() + + class Service: + def migrate(self, *, tenant_id: TenantId, nodes: Sequence[MigrationNode]) -> NodeDataMigrationSuccess: + assert tenant_id == "workspace-1" + assert [(node.node_id, node.node_data.title) for node in nodes] == [("node-1", "Approval")] + return NodeDataMigrationSuccess((MigratedNode("node-1", migrated_node_data),)) + + monkeypatch.setattr(_CONTROLLER_MODULE, "build_human_input_node_data_migration_service", Service) + handler = unwrap(NodeDataMigrationAPI.post) + + with app.test_request_context( + method="POST", + json={ + "nodes": [ + { + "node_id": "node-1", + "node_data": { + "title": "Approval", + "delivery_methods": [ + {"id": "22222222-2222-4222-8222-222222222222", "type": "webapp", "config": {}} + ], + }, + } + ] + }, + ): + response = handler(NodeDataMigrationAPI(), "workspace-1") + + assert response == { + "data": [ + { + "node_id": "node-1", + "node_data": migrated_node_data.model_dump(mode="json"), + } + ] + } + + +def test_migrated_current_v1_inputs_preserve_authoring_configuration_and_rendered_form( + app: Flask, monkeypatch: pytest.MonkeyPatch +) -> None: + inputs: list[dict[str, object]] = [ + { + "type": "paragraph", + "output_variable_name": "reason", + "default": {"type": "constant", "selector": ["start", "reason"], "value": "Keep this reason"}, + }, + { + "type": "select", + "output_variable_name": "decision", + "option_source": {"type": "constant", "selector": ["start", "unused"], "value": ["Yes", "No"]}, + }, + { + "type": "select", + "output_variable_name": "reviewer", + "option_source": {"type": "variable", "selector": ["start", "reviewers"], "value": ["Previous"]}, + }, + { + "type": "file", + "output_variable_name": "attachment", + "allowed_file_types": ["custom"], + "allowed_file_extensions": [".csv", ".json"], + "allowed_file_upload_methods": ["local_file"], + }, + { + "type": "file-list", + "output_variable_name": "images", + "allowed_file_types": ["image"], + "allowed_file_extensions": [], + "allowed_file_upload_methods": ["remote_url", "local_file"], + "number_limits": 7, + }, + ] + form_content = ( + "{{#$output.reason#}}{{#$output.decision#}}{{#$output.reviewer#}}{{#$output.attachment#}}{{#$output.images#}}" + ) + user_actions = [{"id": "approve", "title": "A" * 100, "button_style": "primary"}] + v1_node = HumanInputNodeDataFull.model_validate( + { + "title": "All inputs", + "delivery_methods": [{"id": "22222222-2222-4222-8222-222222222222", "type": "webapp", "config": {}}], + "form_content": form_content, + "inputs": inputs, + "user_actions": user_actions, + } + ) + assert {input.type for input in v1_node.inputs} == set(FormInputType) + text_node = HumanInputNodeDataFull.model_validate( + { + "title": "Text only", + "delivery_methods": v1_node.delivery_methods, + "inputs": [{"type": "paragraph", "output_variable_name": "text"}], + "user_actions": [{"id": "submit", "title": "B" * 21}], + } + ) + service = HumanInputNodeDataMigrationService(member_email_lookup=_StaticMemberEmailLookup()) + monkeypatch.setattr(_CONTROLLER_MODULE, "build_human_input_node_data_migration_service", lambda: service) + handler = unwrap(NodeDataMigrationAPI.post) + + with app.test_request_context( + method="POST", + json={ + "nodes": [ + {"node_id": "all-inputs", "node_data": v1_node.model_dump(mode="json")}, + {"node_id": "text-only", "node_data": text_node.model_dump(mode="json")}, + ] + }, + ): + response = handler(NodeDataMigrationAPI(), "workspace-1") + + assert [node["node_id"] for node in response["data"]] == ["all-inputs", "text-only"] + assert response["data"][1]["node_data"]["user_actions"][0]["title"] == "B" * 21 + migrated = response["data"][0]["node_data"] + assert migrated["inputs"] == inputs + assert migrated["user_actions"] == user_actions + assert migrated["recipients_spec"] == [{"type": "initiator"}] + pool = VariablePool.empty() + pool.add(["start", "reviewers"], ArrayStringSegment(value=["Alice", "Bob"])) + resolved = compile_resolved_form(HumanInputNodeData.model_validate_json(json.dumps(migrated)), pool) + assert resolved.parts == ( + ParagraphInput(output_variable_name="reason", default_value="Keep this reason"), + SelectInput(output_variable_name="decision", options=("Yes", "No"), default_value=None), + SelectInput(output_variable_name="reviewer", options=("Alice", "Bob"), default_value=None), + FileInput( + output_variable_name="attachment", + allowed_file_types=(FileType.CUSTOM,), + allowed_file_extensions=(".csv", ".json"), + allowed_file_upload_methods=(FileTransferMethod.LOCAL_FILE,), + ), + FileListInput( + output_variable_name="images", + allowed_file_types=(FileType.IMAGE,), + allowed_file_extensions=(), + allowed_file_upload_methods=(FileTransferMethod.REMOTE_URL, FileTransferMethod.LOCAL_FILE), + number_limits=7, + ), + ) + assert resolved.actions[0].title == "A" * 100 + fixture_path = Path(__file__).resolve().parents[6] / ( + "web/app/components/workflow/nodes/human-input-v2/migration/__tests__/fixtures/v1-batch.json" + ) + fixture = json.loads(fixture_path.read_text()) + assert fixture["request"]["nodes"] == [ + {"node_id": "all-inputs", "node_data": v1_node.model_dump(mode="json")}, + {"node_id": "text-only", "node_data": text_node.model_dump(mode="json")}, + ] + assert response == fixture["response"] + + +@pytest.mark.parametrize( + ("method_id", "expected_method_id"), + [(None, None), ("invalid-id", "invalid-id")], + ids=["null", "invalid-uuid"], +) +def test_controller_maps_invalid_delivery_method_ids_to_typed_failure_without_partial_data( + app: Flask, + monkeypatch: pytest.MonkeyPatch, + method_id: object, + expected_method_id: str | None, +) -> None: + invalid_method: dict[str, object] = {"type": "webapp", "config": {}} + invalid_method["id"] = method_id + service = HumanInputNodeDataMigrationService(member_email_lookup=_StaticMemberEmailLookup()) + monkeypatch.setattr(_CONTROLLER_MODULE, "build_human_input_node_data_migration_service", lambda: service) + handler = unwrap(NodeDataMigrationAPI.post) + + with app.test_request_context( + method="POST", + json={ + "nodes": [ + { + "node_id": "node-1", + "node_data": { + "title": "Approval", + "delivery_methods": [ + invalid_method, + {"id": "22222222-2222-4222-8222-222222222222", "type": "webapp", "config": {}}, + ], + }, + } + ] + }, + ): + response = handler(NodeDataMigrationAPI(), "workspace-1") + + assert isinstance(response, tuple) + body, status = response + assert status == HTTPStatus.BAD_REQUEST + assert "data" not in body + assert [(blocker["code"], blocker["method_id"], blocker["value"]) for blocker in body["blockers"]] == [ + ("unsupported-delivery-method", expected_method_id, "webapp") + ] + + +def test_controller_preserves_historically_accepted_action_id_with_trailing_line_feed( + app: Flask, + monkeypatch: pytest.MonkeyPatch, +) -> None: + historical_action_id = f"{'a' * 19}\n" + service = HumanInputNodeDataMigrationService(member_email_lookup=_StaticMemberEmailLookup()) + monkeypatch.setattr(_CONTROLLER_MODULE, "build_human_input_node_data_migration_service", lambda: service) + handler = unwrap(NodeDataMigrationAPI.post) + + with app.test_request_context( + method="POST", + json={ + "nodes": [ + { + "node_id": "node-1", + "node_data": { + "title": "Approval", + "delivery_methods": [ + { + "id": "22222222-2222-4222-8222-222222222222", + "type": "webapp", + "config": {}, + } + ], + "user_actions": [{"id": historical_action_id, "title": "Approve"}], + }, + } + ] + }, + ): + response = handler(NodeDataMigrationAPI(), "workspace-1") + + assert response["data"][0]["node_data"]["user_actions"] == [ + {"id": historical_action_id, "title": "Approve", "button_style": "default"} + ] + + +def test_controller_normalizes_compatibility_member_id_before_service( + app: Flask, + monkeypatch: pytest.MonkeyPatch, +) -> None: + service = HumanInputNodeDataMigrationService(member_email_lookup=_StaticMemberEmailLookup()) + monkeypatch.setattr(_CONTROLLER_MODULE, "build_human_input_node_data_migration_service", lambda: service) + handler = unwrap(NodeDataMigrationAPI.post) + + with app.test_request_context( + method="POST", + json={ + "nodes": [ + { + "node_id": "node-1", + "node_data": { + "title": "Approval", + "delivery_methods": [ + { + "id": "11111111-1111-4111-8111-111111111111", + "type": "email", + "config": { + "subject": "Review", + "body": "Please review", + "recipients": {"items": [{"type": "member", "reference_id": "member-1"}]}, + }, + } + ], + }, + } + ] + }, + ): + response = handler(NodeDataMigrationAPI(), "workspace-1") + + assert response["data"][0]["node_data"]["recipients_spec"] == [ + {"type": "onetime_email", "email": "member-1@example.com"} + ] + + +def test_controller_maps_unsupported_methods_to_ordered_blockers_regardless_of_enabled_state( + app: Flask, + monkeypatch: pytest.MonkeyPatch, +) -> None: + service = HumanInputNodeDataMigrationService(member_email_lookup=_StaticMemberEmailLookup()) + monkeypatch.setattr(_CONTROLLER_MODULE, "build_human_input_node_data_migration_service", lambda: service) + handler = unwrap(NodeDataMigrationAPI.post) + + with app.test_request_context( + method="POST", + json={ + "nodes": [ + { + "node_id": "node-1", + "node_data": { + "title": "Approval", + "delivery_methods": [ + { + "id": "im-1", + "type": "im", + "config": { + "provider": "slack", + "message": "Review", + "recipients": {"items": [{"type": "user", "user_id": "user-1"}]}, + }, + }, + { + "id": "future-1", + "type": "future-channel", + "enabled": False, + "config": {"message": "Configured"}, + }, + ], + }, + } + ] + }, + ): + response = handler(NodeDataMigrationAPI(), "workspace-1") + + assert isinstance(response, tuple) + body, status = response + assert status == HTTPStatus.BAD_REQUEST + assert [(blocker["code"], blocker["method_id"], blocker["value"]) for blocker in body["blockers"]] == [ + ("unsupported-delivery-method", "im-1", "im"), + ("unsupported-delivery-method", "future-1", "future-channel"), + ("missing-recipients", None, None), + ] + + +def test_controller_rejects_disabled_unknown_method_with_empty_config_without_partial_data( + app: Flask, + monkeypatch: pytest.MonkeyPatch, +) -> None: + service = HumanInputNodeDataMigrationService(member_email_lookup=_StaticMemberEmailLookup()) + monkeypatch.setattr(_CONTROLLER_MODULE, "build_human_input_node_data_migration_service", lambda: service) + handler = unwrap(NodeDataMigrationAPI.post) + + with app.test_request_context( + method="POST", + json={ + "nodes": [ + { + "node_id": "node-1", + "node_data": { + "title": "Approval", + "delivery_methods": [ + { + "id": "future-1", + "type": "future-channel", + "enabled": False, + "config": {}, + }, + { + "id": "22222222-2222-4222-8222-222222222222", + "type": "webapp", + "config": {}, + }, + ], + }, + } + ] + }, + ): + response = handler(NodeDataMigrationAPI(), "workspace-1") + + assert isinstance(response, tuple) + body, status = response + assert status == HTTPStatus.BAD_REQUEST + assert "data" not in body + assert [(blocker["code"], blocker["method_id"], blocker["value"]) for blocker in body["blockers"]] == [ + ("unsupported-delivery-method", "future-1", "future-channel") + ] + + +def test_controller_maps_invalid_default_value_to_typed_all_or_error_response( + app: Flask, + monkeypatch: pytest.MonkeyPatch, +) -> None: + service = HumanInputNodeDataMigrationService(member_email_lookup=_StaticMemberEmailLookup()) + monkeypatch.setattr(_CONTROLLER_MODULE, "build_human_input_node_data_migration_service", lambda: service) + handler = unwrap(NodeDataMigrationAPI.post) + + with app.test_request_context( + method="POST", + json={ + "nodes": [ + { + "node_id": "node-invalid", + "node_data": { + "title": "Invalid", + "default_value": [{"key": "fallback", "type": "object", "value": False}], + "delivery_methods": [ + { + "id": "22222222-2222-4222-8222-222222222222", + "type": "webapp", + "config": {}, + } + ], + }, + }, + { + "node_id": "node-valid", + "node_data": { + "title": "Valid", + "delivery_methods": [ + { + "id": "22222222-2222-4222-8222-222222222222", + "type": "webapp", + "config": {}, + } + ], + }, + }, + ] + }, + ): + body, status = handler(NodeDataMigrationAPI(), "workspace-1") + + assert status == HTTPStatus.BAD_REQUEST + assert "data" not in body + assert [(blocker["node_id"], blocker["code"], blocker["value"]) for blocker in body["blockers"]] == [ + ("node-invalid", "invalid-default-value", "default_value") + ] + + +def test_controller_maps_blocked_batch_without_partial_data(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: + blocker = NodeDataMigrationBlocker( + node_id="node-1", + node_title="Approval", + code=MigrationBlockerCode.UNRESOLVED_MEMBER, + method_id="email-1", + value="member-1", + ) + + class Service: + def migrate(self, *, tenant_id: TenantId, nodes: Sequence[MigrationNode]) -> NodeDataMigrationFailure: + del tenant_id, nodes + return NodeDataMigrationFailure((blocker,)) + + monkeypatch.setattr(_CONTROLLER_MODULE, "build_human_input_node_data_migration_service", Service) + handler = unwrap(NodeDataMigrationAPI.post) + + with app.test_request_context( + method="POST", + json={"nodes": [{"node_id": "node-1", "node_data": {"title": "Approval", "delivery_methods": []}}]}, + ): + body, status = handler(NodeDataMigrationAPI(), "workspace-1") + + assert status == HTTPStatus.BAD_REQUEST + assert body == { + "code": "hitl_node_data_migration_failure", + "message": "Human Input node-data migration failed.", + "status": HTTPStatus.BAD_REQUEST, + "blockers": [ + { + "node_id": "node-1", + "node_title": "Approval", + "code": "unresolved-member", + "method_id": "email-1", + "value": "member-1", + } + ], + } + assert "data" not in body + + +def test_controller_validates_request_before_building_service(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + _CONTROLLER_MODULE, + "build_human_input_node_data_migration_service", + lambda: (_ for _ in ()).throw(AssertionError("service must not be built")), + ) + handler = unwrap(NodeDataMigrationAPI.post) + + with app.test_request_context(method="POST", json={"nodes": []}), pytest.raises(ValidationError): + handler(NodeDataMigrationAPI(), "workspace-1") + + +def test_controller_does_not_disguise_unexpected_service_errors(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: + class Service: + def migrate(self, *, tenant_id: TenantId, nodes: Sequence[MigrationNode]) -> Never: + del tenant_id, nodes + raise RuntimeError("unexpected") + + monkeypatch.setattr(_CONTROLLER_MODULE, "build_human_input_node_data_migration_service", Service) + handler = unwrap(NodeDataMigrationAPI.post) + + with ( + app.test_request_context( + method="POST", + json={"nodes": [{"node_id": "node-1", "node_data": {"title": "Approval", "delivery_methods": []}}]}, + ), + pytest.raises(RuntimeError, match="unexpected"), + ): + handler(NodeDataMigrationAPI(), "workspace-1") + + +def test_endpoint_preserves_auth_scope_decorators_and_rejects_before_service( + app: Flask, + monkeypatch: pytest.MonkeyPatch, +) -> None: + endpoint_source = inspect.getsource(NodeDataMigrationAPI) + for decorator_name in ( + "setup_required", + "login_required", + "account_initialization_required", + "edit_permission_required", + "with_current_tenant_id", + ): + assert f"@{decorator_name}" in endpoint_source + + monkeypatch.setattr( + _CONTROLLER_MODULE, + "build_human_input_node_data_migration_service", + lambda: (_ for _ in ()).throw(AssertionError("service must not be built")), + ) + account = Account(name="Viewer", email="viewer@example.com") + account.role = TenantAccountRole.NORMAL + login_module = import_module("libs.login") + wraps_module = import_module("controllers.console.wraps") + monkeypatch.setattr( + login_module, + "current_user", + LocalProxy(lambda: account), + ) + monkeypatch.setattr(wraps_module.dify_config, "RBAC_ENABLED", False) + + edit_wrapped_handler = NodeDataMigrationAPI.post.__wrapped__.__wrapped__.__wrapped__ + with app.test_request_context(method="POST"), pytest.raises(Forbidden): + edit_wrapped_handler(NodeDataMigrationAPI()) + + +def test_controller_class_has_no_persistence_or_contact_dependencies() -> None: + endpoint_source = inspect.getsource(NodeDataMigrationAPI).lower() + + for forbidden_dependency in ( + "sqlalchemy", + "repositories.", + "models.", + "contact_repository", + "contact_lookup", + ): + assert forbidden_dependency not in endpoint_source + + +@pytest.mark.parametrize( + "input_config", + [ + {"type": "paragraph", "output_variable_name": "answer"}, + {"type": "select", "output_variable_name": "answer", "option_source": {"type": "constant"}}, + {"type": "file", "output_variable_name": "answer"}, + {"type": "file-list", "output_variable_name": "answer"}, + ], +) +def test_http_migration_accepts_unserialized_v1_defaults( + app: Flask, + monkeypatch: pytest.MonkeyPatch, + input_config: dict[str, object], +) -> None: + raw_v1 = {"delivery_methods": [{"type": "webapp"}], "inputs": [input_config]} + v1 = HumanInputNodeDataFull.model_validate(raw_v1) + # A legal omitted default must remain a valid DSL configuration after save. + assert HumanInputNodeDataFull.model_validate_json(v1.model_dump_json()).model_dump(mode="json") == v1.model_dump( + mode="json" + ) + service = HumanInputNodeDataMigrationService(member_email_lookup=_StaticMemberEmailLookup()) + monkeypatch.setattr(_CONTROLLER_MODULE, "build_human_input_node_data_migration_service", lambda: service) + with app.test_request_context(method="POST", json={"nodes": [{"node_id": "defaults", "node_data": raw_v1}]}): + response = unwrap(NodeDataMigrationAPI.post)(NodeDataMigrationAPI(), "workspace-1") + assert isinstance(response, dict) + migrated = response["data"][0]["node_data"] + assert migrated["inputs"] == v1.model_dump(mode="json")["inputs"] + assert migrated["recipients_spec"] == [{"type": "initiator"}] + HumanInputNodeData.model_validate_json(json.dumps(migrated)) diff --git a/api/tests/unit_tests/controllers/console/workspace/test_im_contact_sync_api.py b/api/tests/unit_tests/controllers/console/workspace/test_im_contact_sync_api.py new file mode 100644 index 00000000000..65d4e38d405 --- /dev/null +++ b/api/tests/unit_tests/controllers/console/workspace/test_im_contact_sync_api.py @@ -0,0 +1,863 @@ +"""Workspace IM Contact Sync controller contracts.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import replace +from datetime import datetime +from importlib import import_module +from inspect import unwrap +from typing import Never, override + +import pytest +from flask import Flask +from flask_restx import Resource +from pydantic import JsonValue, ValidationError +from sqlalchemy.orm import sessionmaker +from werkzeug.exceptions import NotFound + +from controllers.console.human_input_v2.contact import ( + BatchGetContactOptionsAPI, + BatchGetContactsAPI, + WorkspaceContactApi, + WorkspaceContactIMBindingsApi, + WorkspaceContactIMOverrideApi, + WorkspaceContactOptionsApi, + WorkspaceContactsApi, + WorkspaceIMIdentitiesApi, + WorkspaceIMSyncRunsApi, + WorkspaceLatestIMSyncRunApi, + WorkspaceLatestIMSyncRunResultsApi, +) +from core.human_input_v2.entities import ( + HumanInputContactType, + IMBindingScope, + IMIdentityBindingStatus, + IMProvider, + IMSyncRemovalReason, + IMSyncResultType, +) +from core.human_input_v2.im_integration import ( + ContactIMBindingView, + IMBindingCommandError, + IMBindingCommandErrorCode, + IMChannelRevision, + IMSyncRun, + SyncContactSnapshot, + SynchronizedIMIdentity, + SynchronizedIMIdentityPage, + SyncIdentitySnapshot, + SyncResultFact, + SyncResultPage, +) +from core.human_input_v2.shared import ( + AccountId, + ContactId, + DeploymentScope, + IMBindingId, + IMIdentityId, + IMSyncResultId, + IMSyncRunId, + TenantId, + WorkspaceScope, +) +from enums import DeploymentEdition +from libs.external_api import ExternalApi +from repositories.human_input_v2.contact import Contact, ContactQuery, ContactType, IMBinding, Page +from repositories.human_input_v2.im_channel_repository import IMChannelId +from repositories.human_input_v2.im_identity_repository import IMIdentity +from services.human_input_v2.contact_service import ContactDetails, ContactWithIMBindings, IMBindingDetail +from services.human_input_v2.im_contact_sync.composition import ( + IMContactSyncApplication, + build_im_contact_sync_application, +) +from services.human_input_v2.im_contact_sync.errors import IMWriteUnavailableError +from services.human_input_v2.im_contact_sync.service import ( + IMChannelNotConfiguredError, + IMSyncDispatchUnavailableError, + IMSyncRevisionChangedError, + IMSyncRunNotFoundError, +) +from tests.helpers.unit.model_factories import make_account + +_CONTROLLER_MODULE = import_module("controllers.console.human_input_v2.contact") +_NOW = datetime(2026, 8, 11, 8) +_CONTACT_CREATED_AT = datetime(2025, 7, 10, 6) +_RUN = IMSyncRun.create( + sync_run_id=IMSyncRunId("run-1"), + channel_revision=IMChannelRevision("channel-1", 3), + provider=IMProvider.FEISHU, + started_by_account_id=AccountId("account-1"), + now=_NOW, +) +_RESULT = SyncResultFact( + id=IMSyncResultId("result-1"), + channel_id=IMChannelId("channel-1"), + sync_run_id=IMSyncRunId("run-1"), + operation_key="result:not-matched:provider-user-1", + result_type=IMSyncResultType.NOT_MATCHED, + provider_user_id="provider-user-1", + display_name="Reviewer", + email="reviewer@example.com", + normalized_email=None, + contact_id=None, + identity_id=IMIdentityId("identity-1"), + binding_id=None, + removal_reason=None, + reason_code="contact_not_found", + reason_message=None, + directory_entry_payload=None, + contact_snapshot=None, + identity_snapshot=None, + created_at=_NOW, + updated_at=_NOW, +) + + +class _SyncService: + def create_or_get_active_run( + self, + organization_scope: WorkspaceScope, + started_by_account_id: AccountId, + ) -> IMSyncRun: + assert organization_scope == WorkspaceScope(id=TenantId("workspace-1")) + assert started_by_account_id == AccountId("account-1") + return _RUN + + def get_latest_run(self, organization_scope: WorkspaceScope) -> IMSyncRun: + assert organization_scope == WorkspaceScope(id=TenantId("workspace-1")) + return _RUN + + def list_latest_results( + self, + organization_scope: WorkspaceScope, + result_type: IMSyncResultType, + *, + page: int, + limit: int, + ) -> SyncResultPage: + assert organization_scope == WorkspaceScope(id=TenantId("workspace-1")) + assert result_type is IMSyncResultType.NOT_MATCHED + assert (page, limit) == (1, 20) + return SyncResultPage((_RESULT,), page=page, limit=limit, total=1) + + def search_identities( + self, + organization_scope: WorkspaceScope, + *, + keyword: str | None, + page: int, + limit: int, + ) -> SynchronizedIMIdentityPage: + assert organization_scope == WorkspaceScope(id=TenantId("workspace-1")) + assert (keyword, page, limit) == ("provider-user", 1, 20) + return SynchronizedIMIdentityPage( + ( + SynchronizedIMIdentity( + id=IMIdentityId("identity-1"), + provider=IMProvider.FEISHU, + provider_user_id="provider-user-1", + display_name="Reviewer", + email="reviewer@example.com", + binding_status=IMIdentityBindingStatus.UNBOUND, + ), + ), + page=page, + limit=limit, + total=1, + ) + + +def _contact_view(scope: IMBindingScope) -> ContactIMBindingView: + binding = IMBinding( + id=IMBindingId(f"binding-{scope.value}"), + scope=scope, + contact_id=ContactId("00000000-0000-0000-0000-000000000001"), + identity_id=IMIdentityId("identity-1"), + provider=IMProvider.FEISHU, + ) + return ContactIMBindingView( + id=binding.contact_id, + type=HumanInputContactType.WORKSPACE, + name="Reviewer", + email="reviewer@example.com", + avatar_file_id=None, + im_bindings=(binding,), + created_at=_NOW, + ) + + +class _BindingService: + def set_organization_binding(self, **kwargs: object) -> ContactIMBindingView: + assert kwargs == { + "organization_scope": WorkspaceScope(id=TenantId("workspace-1")), + "tenant_id": TenantId("workspace-1"), + "contact_id": ContactId("00000000-0000-0000-0000-000000000001"), + "identity_id": IMIdentityId("identity-1"), + "bound_by_account_id": AccountId("account-1"), + } + return _contact_view(IMBindingScope.ORGANIZATION) + + def delete_organization_binding(self, **kwargs: object) -> None: + assert kwargs == { + "organization_scope": WorkspaceScope(id=TenantId("workspace-1")), + "contact_id": ContactId("00000000-0000-0000-0000-000000000001"), + } + + def set_workspace_override(self, **_kwargs: object) -> ContactIMBindingView: + return _contact_view(IMBindingScope.WORKSPACE) + + def reset_workspace_override(self, **_kwargs: object) -> ContactIMBindingView: + return _contact_view(IMBindingScope.ORGANIZATION) + + +@pytest.fixture +def application(monkeypatch: pytest.MonkeyPatch) -> IMContactSyncApplication: + application = build_im_contact_sync_application(session_maker=sessionmaker()) + sync = _SyncService() + binding = _BindingService() + monkeypatch.setattr(application.sync_service, "create_or_get_active_run", sync.create_or_get_active_run) + monkeypatch.setattr(application.sync_service, "get_latest_run", sync.get_latest_run) + monkeypatch.setattr(application.sync_service, "list_latest_results", sync.list_latest_results) + monkeypatch.setattr(application.sync_service, "search_identities", sync.search_identities) + monkeypatch.setattr(application.binding_service, "set_organization_binding", binding.set_organization_binding) + monkeypatch.setattr(application.binding_service, "delete_organization_binding", binding.delete_organization_binding) + monkeypatch.setattr(application.binding_service, "set_workspace_override", binding.set_workspace_override) + monkeypatch.setattr(application.binding_service, "reset_workspace_override", binding.reset_workspace_override) + monkeypatch.setattr(_CONTROLLER_MODULE, "build_im_contact_sync_application", lambda: application) + return application + + +def test_sync_command_and_queries_map_transport_neutral_results( + app: Flask, + application: IMContactSyncApplication, +) -> None: + assert application.sync_service is not None + account = make_account(account_id="account-1", name="Editor", email="editor@example.com") + with app.test_request_context(method="POST"): + created = unwrap(WorkspaceIMSyncRunsApi.post)(WorkspaceIMSyncRunsApi(), "workspace-1", account) + with app.test_request_context(method="GET"): + latest = unwrap(WorkspaceLatestIMSyncRunApi.get)(WorkspaceLatestIMSyncRunApi(), "workspace-1") + with app.test_request_context( + method="GET", + query_string={"result": "not_matched", "page": "1", "limit": "20"}, + ): + result_page = unwrap(WorkspaceLatestIMSyncRunResultsApi.get)( + WorkspaceLatestIMSyncRunResultsApi(), "workspace-1" + ) + with app.test_request_context( + method="GET", + query_string={"keyword": "provider-user", "page": "1", "limit": "20"}, + ): + identity_page = unwrap(WorkspaceIMIdentitiesApi.get)(WorkspaceIMIdentitiesApi(), "workspace-1") + + assert ( + created + == latest + == { + "run": { + "id": "run-1", + "status": "queued", + "started_at": None, + "finished_at": None, + "error_message": None, + "result_counts": {"added": 0, "not_matched": 0, "failed": 0, "removed": 0, "skipped": 0}, + "provider": "feishu", + "channel_id": "channel-1", + "channel_config_version": 3, + } + } + ) + assert result_page == { + "data": [ + { + "id": "result-1", + "result": { + "type": "not_matched", + "entry": { + "provider_user_id": "provider-user-1", + "display_name": "Reviewer", + "email": "reviewer@example.com", + }, + }, + } + ], + "page": 1, + "limit": 20, + "total": 1, + } + assert identity_page == { + "data": [ + { + "id": "identity-1", + "provider": "feishu", + "provider_user_id": "provider-user-1", + "display_name": "Reviewer", + "email": "reviewer@example.com", + "binding_status": "unbound", + } + ], + "page": 1, + "limit": 20, + "total": 1, + } + + +def test_sync_dispatch_unavailable_has_stable_retryable_http_mapping( + application: IMContactSyncApplication, + app: Flask, + monkeypatch: pytest.MonkeyPatch, +) -> None: + error = IMSyncDispatchUnavailableError("IM synchronization dispatch is temporarily unavailable") + + class SyncService: + def create_or_get_active_run(self, _scope: object, _account_id: object) -> Never: + raise error + + monkeypatch.setattr(application.sync_service, "create_or_get_active_run", SyncService().create_or_get_active_run) + + with app.test_request_context(method="POST"): + response, status = unwrap(WorkspaceIMSyncRunsApi.post)( + WorkspaceIMSyncRunsApi(), + "workspace-1", + make_account(account_id="account-1", name="Editor", email="editor@example.com"), + ) + + assert status == 503 + assert response == { + "code": "im_sync_dispatch_unavailable", + "message": str(error), + "status": 503, + } + + +@pytest.mark.parametrize("command", ["sync_create", "organization_binding", "workspace_override"]) +def test_write_unavailable_has_stable_retryable_http_mapping( + application: IMContactSyncApplication, + app: Flask, + monkeypatch: pytest.MonkeyPatch, + config_overrides: Callable[..., None], + command: str, +) -> None: + config_overrides( + DEPLOYMENT_EDITION=( + DeploymentEdition.ENTERPRISE if command == "workspace_override" else DeploymentEdition.COMMUNITY + ) + ) + error = IMWriteUnavailableError("IM write is temporarily unavailable") + + class SyncService: + def create_or_get_active_run(self, _scope: object, _account_id: object) -> Never: + raise error + + class BindingService: + def set_organization_binding(self, **_kwargs: object) -> Never: + raise error + + def set_workspace_override(self, **_kwargs: object) -> Never: + raise error + + monkeypatch.setattr(application.sync_service, "create_or_get_active_run", SyncService().create_or_get_active_run) + monkeypatch.setattr( + application.binding_service, "set_organization_binding", BindingService().set_organization_binding + ) + monkeypatch.setattr(application.binding_service, "set_workspace_override", BindingService().set_workspace_override) + account = make_account(account_id="account-1", name="Editor", email="editor@example.com") + contact_id = "00000000-0000-0000-0000-000000000001" + if command == "sync_create": + handler = unwrap(WorkspaceIMSyncRunsApi.post) + request_context = app.test_request_context(method="POST") + handler_args = (WorkspaceIMSyncRunsApi(), "workspace-1", account) + elif command == "organization_binding": + handler = unwrap(WorkspaceContactIMBindingsApi.put) + request_context = app.test_request_context(method="PUT", json={"identity_id": "identity-1"}) + handler_args = (WorkspaceContactIMBindingsApi(), "workspace-1", account, contact_id) + else: + handler = unwrap(WorkspaceContactIMOverrideApi.put) + request_context = app.test_request_context(method="PUT", json={"identity_id": "identity-1"}) + handler_args = (WorkspaceContactIMOverrideApi(), "workspace-1", account, contact_id) + + with request_context: + response, status = handler(*handler_args) + + assert status == 503 + assert response == { + "code": "im_write_unavailable", + "message": str(error), + "status": 503, + } + + +@pytest.mark.parametrize( + "sync_result", + [ + replace( + _RESULT, + result_type=IMSyncResultType.ADDED, + contact_id=ContactId("contact-1"), + binding_id=IMBindingId("binding-1"), + contact_snapshot=SyncContactSnapshot( + ContactId("contact-1"), + "Reviewer", + None, + None, + _CONTACT_CREATED_AT, + ), + ), + replace( + _RESULT, + result_type=IMSyncResultType.REMOVED, + contact_id=ContactId("contact-1"), + binding_id=IMBindingId("binding-1"), + removal_reason=IMSyncRemovalReason.NOT_PRESENT_IN_DIRECTORY, + contact_snapshot=SyncContactSnapshot(ContactId("contact-1"), "Reviewer", None, None), + identity_snapshot=SyncIdentitySnapshot( + IMIdentityId("identity-1"), + IMProvider.FEISHU, + "provider-user-1", + "Reviewer", + "reviewer@example.com", + ), + ), + replace(_RESULT, result_type=IMSyncResultType.FAILED, reason_message="Directory read failed"), + replace( + _RESULT, + result_type=IMSyncResultType.SKIPPED, + contact_id=ContactId("contact-1"), + contact_snapshot=SyncContactSnapshot(ContactId("contact-1"), "Reviewer", None, None), + ), + _RESULT, + ], +) +def test_latest_results_map_every_discriminated_bucket( + application: IMContactSyncApplication, + app: Flask, + monkeypatch: pytest.MonkeyPatch, + sync_result: SyncResultFact, +) -> None: + class SyncService: + def list_latest_results( + self, + _scope: object, + result_type: IMSyncResultType, + *, + page: int, + limit: int, + ) -> SyncResultPage: + assert result_type is sync_result.result_type + return SyncResultPage((sync_result,), page=page, limit=limit, total=1) + + monkeypatch.setattr(application.sync_service, "list_latest_results", SyncService().list_latest_results) + with app.test_request_context( + method="GET", + query_string={"result": sync_result.result_type.value, "page": "1", "limit": "20"}, + ): + response = unwrap(WorkspaceLatestIMSyncRunResultsApi.get)( + WorkspaceLatestIMSyncRunResultsApi(), + "workspace-1", + ) + + result_payload = response["data"][0]["result"] + assert result_payload["type"] == sync_result.result_type.value + if sync_result.result_type in (IMSyncResultType.ADDED, IMSyncResultType.SKIPPED, IMSyncResultType.REMOVED): + assert "type" not in result_payload["contact"] + if sync_result.result_type is IMSyncResultType.ADDED: + assert result_payload["contact"]["created_at"] == 1752127200 + if sync_result.result_type is IMSyncResultType.REMOVED: + assert result_payload["reason"] == "not_present_in_directory" + assert result_payload["last_known_identity"]["identity_id"] == "identity-1" + if sync_result.result_type is IMSyncResultType.FAILED: + assert result_payload["reason"] == "Directory read failed" + + +def test_binding_and_override_handlers_return_current_contact_projection( + app: Flask, + application: IMContactSyncApplication, + config_overrides: Callable[..., None], +) -> None: + assert application.binding_service is not None + account = make_account(account_id="account-1", name="Editor", email="editor@example.com") + contact_id = "00000000-0000-0000-0000-000000000001" + with app.test_request_context(method="PUT", json={"identity_id": "identity-1"}): + organization = unwrap(WorkspaceContactIMBindingsApi.put)( + WorkspaceContactIMBindingsApi(), "workspace-1", account, contact_id + ) + config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.ENTERPRISE) + with app.test_request_context(method="PUT", json={"identity_id": "identity-1"}): + override = unwrap(WorkspaceContactIMOverrideApi.put)( + WorkspaceContactIMOverrideApi(), "workspace-1", account, contact_id + ) + with app.test_request_context(method="DELETE"): + reset = unwrap(WorkspaceContactIMOverrideApi.delete)(WorkspaceContactIMOverrideApi(), "workspace-1", contact_id) + config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.COMMUNITY) + with app.test_request_context(method="DELETE"): + deleted = unwrap(WorkspaceContactIMBindingsApi.delete)( + WorkspaceContactIMBindingsApi(), "workspace-1", contact_id + ) + + assert organization["contact"]["im_bindings"] == [ + {"id": "binding-organization", "provider": "feishu", "scope": "organization"} + ] + assert override["contact"]["im_bindings"] == [ + {"id": "binding-workspace", "provider": "feishu", "scope": "workspace"} + ] + assert reset["contact"]["im_bindings"][0]["scope"] == "organization" + assert deleted == {} + + +@pytest.mark.parametrize( + ("edition", "override", "allowed"), + [ + (DeploymentEdition.COMMUNITY, False, True), + (DeploymentEdition.COMMUNITY, True, False), + (DeploymentEdition.CLOUD, False, True), + (DeploymentEdition.CLOUD, True, False), + (DeploymentEdition.ENTERPRISE, False, False), + (DeploymentEdition.ENTERPRISE, True, True), + ], +) +@pytest.mark.parametrize("method", ["PUT", "DELETE"]) +@pytest.mark.usefixtures("application") +def test_binding_writes_follow_the_deployment_management_mode( + config_overrides: Callable[..., None], + edition: DeploymentEdition, + override: bool, + allowed: bool, + method: str, +) -> None: + config_overrides(DEPLOYMENT_EDITION=edition) + resource = WorkspaceContactIMOverrideApi() if override else WorkspaceContactIMBindingsApi() + contact_id = "00000000-0000-0000-0000-000000000001" + + class BindingResource(Resource): + def put(self) -> dict[str, JsonValue]: + return unwrap(resource.put)( + resource, + "workspace-1", + make_account(account_id="account-1", name="Editor", email="editor@example.com"), + contact_id, + ) + + def delete(self) -> dict[str, JsonValue]: + return unwrap(resource.delete)(resource, "workspace-1", contact_id) + + transport = Flask(__name__) + api = ExternalApi(transport) + api.add_resource(BindingResource, "/binding") + response = transport.test_client().open( + "/binding", + method=method, + json={"identity_id": "identity-1"}, + ) + if allowed: + assert response.status_code == 200 + else: + assert response.status_code == 403 + expected_code = "workspace_im_override_not_allowed" if override else "im_binding_read_only" + expected_message = ( + "Workspace IM overrides require centrally managed bindings." + if override + else "Centrally managed IM bindings cannot be edited from a workspace." + ) + assert response.get_json() == {"code": expected_code, "message": expected_message, "status": 403} + + +def test_enterprise_override_and_identity_search_use_the_deployment_directory( + monkeypatch: pytest.MonkeyPatch, + app: Flask, + application: IMContactSyncApplication, + config_overrides: Callable[..., None], +) -> None: + config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.ENTERPRISE) + scopes = [] + + class BindingService(_BindingService): + @override + def set_workspace_override(self, **kwargs: object) -> ContactIMBindingView: + scopes.append(kwargs["organization_scope"]) + return super().set_workspace_override(**kwargs) + + @override + def reset_workspace_override(self, **kwargs: object) -> ContactIMBindingView: + scopes.append(kwargs["organization_scope"]) + return super().reset_workspace_override(**kwargs) + + class SyncService(_SyncService): + @override + def search_identities( + self, organization_scope: WorkspaceScope, **_kwargs: object + ) -> SynchronizedIMIdentityPage: + scopes.append(organization_scope) + return SynchronizedIMIdentityPage((), page=1, limit=20, total=0) + + monkeypatch.setattr(application.binding_service, "set_workspace_override", BindingService().set_workspace_override) + monkeypatch.setattr( + application.binding_service, "reset_workspace_override", BindingService().reset_workspace_override + ) + monkeypatch.setattr(application.sync_service, "search_identities", SyncService().search_identities) + contact_id = "00000000-0000-0000-0000-000000000001" + with app.test_request_context(method="PUT", json={"identity_id": "identity-1"}): + unwrap(WorkspaceContactIMOverrideApi.put)( + WorkspaceContactIMOverrideApi(), + "workspace-1", + make_account(account_id="account-1", name="Editor", email="editor@example.com"), + contact_id, + ) + with app.test_request_context(method="DELETE"): + unwrap(WorkspaceContactIMOverrideApi.delete)(WorkspaceContactIMOverrideApi(), "workspace-1", contact_id) + with app.test_request_context(method="GET"): + unwrap(WorkspaceIMIdentitiesApi.get)(WorkspaceIMIdentitiesApi(), "workspace-1") + + assert scopes == [DeploymentScope(), DeploymentScope(), DeploymentScope()] + + +@pytest.mark.parametrize( + ("error", "expected_status", "expected_code"), + [ + (IMChannelNotConfiguredError("missing"), 404, "im_integration_not_configured"), + (IMSyncRunNotFoundError("missing run"), 404, "im_sync_run_not_found"), + (IMSyncRevisionChangedError("stale revision"), 409, "im_sync_revision_changed"), + ( + IMBindingCommandError(IMBindingCommandErrorCode.BINDING_CONFLICT, "conflict"), + 409, + "im_binding_conflict", + ), + ( + IMBindingCommandError(IMBindingCommandErrorCode.INVALID_SCOPE, "invalid"), + 422, + "invalid_im_binding_scope", + ), + ], +) +def test_expected_application_errors_have_stable_http_mapping( + application: IMContactSyncApplication, + app: Flask, + monkeypatch: pytest.MonkeyPatch, + error: Exception, + expected_status: int, + expected_code: str, +) -> None: + if isinstance(error, IMBindingCommandError): + + class BindingService: + def set_organization_binding(self, **_kwargs: object) -> Never: + raise error + + monkeypatch.setattr( + application.binding_service, "set_organization_binding", BindingService().set_organization_binding + ) + handler = unwrap(WorkspaceContactIMBindingsApi.put) + request_context = app.test_request_context(method="PUT", json={"identity_id": "identity-1"}) + handler_args = ( + WorkspaceContactIMBindingsApi(), + "workspace-1", + make_account(account_id="account-1", name="Editor", email="editor@example.com"), + "00000000-0000-0000-0000-000000000001", + ) + elif isinstance(error, IMSyncRevisionChangedError): + + class SyncService: + def create_or_get_active_run(self, _scope: object, _account_id: object) -> Never: + raise error + + monkeypatch.setattr( + application.sync_service, "create_or_get_active_run", SyncService().create_or_get_active_run + ) + handler = unwrap(WorkspaceIMSyncRunsApi.post) + request_context = app.test_request_context(method="POST") + handler_args = ( + WorkspaceIMSyncRunsApi(), + "workspace-1", + make_account(account_id="account-1", name="Editor", email="editor@example.com"), + ) + else: + + class SyncService: + def get_latest_run(self, _scope: object) -> Never: + raise error + + monkeypatch.setattr(application.sync_service, "get_latest_run", SyncService().get_latest_run) + handler = unwrap(WorkspaceLatestIMSyncRunApi.get) + request_context = app.test_request_context(method="GET") + handler_args = (WorkspaceLatestIMSyncRunApi(), "workspace-1") + + monkeypatch.setattr(_CONTROLLER_MODULE, "build_im_contact_sync_application", lambda: application) + with request_context: + response, status = handler(*handler_args) + + assert status == expected_status + assert response == {"code": expected_code, "message": str(error), "status": expected_status} + + +def test_in_scope_handlers_validate_before_service_construction( + app: Flask, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + _CONTROLLER_MODULE, + "build_im_contact_sync_application", + lambda: (_ for _ in ()).throw(AssertionError("service must not be built")), + raising=False, + ) + with app.test_request_context(method="GET"), pytest.raises(ValidationError): + unwrap(WorkspaceLatestIMSyncRunResultsApi.get)(WorkspaceLatestIMSyncRunResultsApi(), "workspace-1") + with app.test_request_context(method="PUT", json={}), pytest.raises(ValidationError): + unwrap(WorkspaceContactIMBindingsApi.put)( + WorkspaceContactIMBindingsApi(), + "workspace-1", + make_account(account_id="account-1", name="Editor", email="editor@example.com"), + "00000000-0000-0000-0000-000000000001", + ) + + +class _ContactService: + def __init__(self) -> None: + contact = Contact( + id=ContactId("00000000-0000-0000-0000-000000000001"), + type=ContactType.WORKSPACE, + name="Reviewer", + email="reviewer@example.com", + avatar_file_id=None, + created_at=_CONTACT_CREATED_AT, + ) + self.view = ContactWithIMBindings(contact, _contact_view(IMBindingScope.ORGANIZATION).im_bindings) + + def list_contacts( + self, + tenant_id: TenantId, + *, + page: int, + limit: int, + query: ContactQuery, + ) -> tuple[Page[Contact], tuple[ContactWithIMBindings, ...]]: + assert tenant_id == TenantId("workspace-1") + assert (page, limit, query) == (1, 20, ContactQuery()) + return Page((self.view.contact,), page, limit), (self.view,) + + def count_contacts(self, tenant_id: TenantId, query: ContactQuery) -> int: + assert tenant_id == TenantId("workspace-1") + assert query == ContactQuery() + return 1 + + def get_contact(self, tenant_id: TenantId, contact_id: ContactId) -> ContactWithIMBindings | None: + assert tenant_id == TenantId("workspace-1") + return self.view if contact_id == self.view.contact.id else None + + def get_contact_details(self, tenant_id: TenantId, contact_id: ContactId) -> ContactDetails | None: + view = self.get_contact(tenant_id, contact_id) + if view is None: + return None + identity = IMIdentity( + id=IMIdentityId("identity-1"), + provider_user_id="provider-user-1", + display_name="Reviewer", + email="reviewer@example.com", + last_seen_sync_run_id=IMSyncRunId("run-1"), + last_seen_at=_NOW, + created_at=_NOW, + updated_at=_NOW, + ) + return ContactDetails(view, tuple(IMBindingDetail(binding, identity) for binding in view.im_bindings)) + + def list_contact_options( + self, + tenant_id: TenantId, + *, + page: int, + limit: int, + keyword: str, + ) -> Page[Contact]: + assert tenant_id == TenantId("workspace-1") + assert (page, limit, keyword) == (1, 20, "") + return Page((self.view.contact,), page, limit) + + def get_contacts(self, tenant_id: TenantId, contact_ids: list[ContactId]) -> tuple[ContactWithIMBindings, ...]: + assert tenant_id == TenantId("workspace-1") + assert contact_ids == [self.view.contact.id] + return (self.view,) + + def get_contact_options(self, tenant_id: TenantId, contact_ids: list[ContactId]) -> tuple[Contact, ...]: + assert tenant_id == TenantId("workspace-1") + assert contact_ids == [self.view.contact.id] + return (self.view.contact,) + + +def test_contact_read_handlers_preserve_current_and_editor_safe_projections( + app: Flask, + monkeypatch: pytest.MonkeyPatch, +) -> None: + service = _ContactService() + monkeypatch.setattr(_CONTROLLER_MODULE, "_contact_service", lambda _session, _tenant_id: service) + contact_id = str(service.view.contact.id) + + with app.test_request_context(method="GET", query_string={"page": "1", "limit": "20"}): + listed = unwrap(WorkspaceContactsApi.get)(WorkspaceContactsApi(), object(), "workspace-1") + detail = unwrap(WorkspaceContactApi.get)(WorkspaceContactApi(), object(), "workspace-1", contact_id) + options = unwrap(WorkspaceContactOptionsApi.get)(WorkspaceContactOptionsApi(), object(), "workspace-1") + with app.test_request_context(method="GET", query_string=[("contact_ids", contact_id)]): + batch = unwrap(BatchGetContactsAPI.get)(BatchGetContactsAPI(), object(), "workspace-1") + batch_options = unwrap(BatchGetContactOptionsAPI.get)(BatchGetContactOptionsAPI(), object(), "workspace-1") + + assert listed["data"][0]["id"] == contact_id + assert detail["contact"]["id"] == contact_id + assert detail["im_binding_details"] == [ + { + "id": "binding-organization", + "provider": "feishu", + "scope": "organization", + "identity": { + "id": "identity-1", + "provider_user_id": "provider-user-1", + "display_name": "Reviewer", + "email": "reviewer@example.com", + }, + } + ] + assert detail["contact"] == listed["data"][0] + assert "im_binding_details" not in listed["data"][0] + assert options["data"][0]["id"] == contact_id + assert batch["data"][0]["id"] == contact_id + assert batch_options["data"][0]["id"] == contact_id + assert "im_bindings" not in options["data"][0] + assert "email" not in batch["data"][0] + + +@pytest.mark.parametrize("scope", [IMBindingScope.WORKSPACE, None]) +def test_contact_detail_returns_workspace_binding_or_empty_details( + app: Flask, monkeypatch: pytest.MonkeyPatch, scope: IMBindingScope | None +) -> None: + service = _ContactService() + bindings = _contact_view(scope).im_bindings if scope is not None else () + service.view = ContactWithIMBindings(service.view.contact, bindings) + monkeypatch.setattr(_CONTROLLER_MODULE, "_contact_service", lambda _session, _tenant_id: service) + with app.test_request_context(method="GET"): + response = unwrap(WorkspaceContactApi.get)( + WorkspaceContactApi(), object(), "workspace-1", str(service.view.contact.id) + ) + if scope is None: + assert response["im_binding_details"] == [] + else: + assert response["im_binding_details"][0]["scope"] == "workspace" + assert response["im_binding_details"][0]["identity"]["id"] == "identity-1" + + +def test_contact_detail_preserves_not_found(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: + service = _ContactService() + monkeypatch.setattr(_CONTROLLER_MODULE, "_contact_service", lambda _session, _tenant_id: service) + with app.test_request_context(method="GET"), pytest.raises(NotFound): + unwrap(WorkspaceContactApi.get)( + WorkspaceContactApi(), object(), "workspace-1", "00000000-0000-0000-0000-000000000099" + ) + + +@pytest.mark.parametrize( + "result", + [ + replace(_RESULT, provider_user_id=None, result_type=IMSyncResultType.ADDED), + replace(_RESULT, result_type=IMSyncResultType.SKIPPED), + replace(_RESULT, result_type=IMSyncResultType.REMOVED), + ], +) +def test_sync_result_projection_rejects_incomplete_persisted_snapshots(result: SyncResultFact) -> None: + with pytest.raises(RuntimeError, match="missing"): + _CONTROLLER_MODULE._sync_result_payload(result) diff --git a/api/tests/unit_tests/controllers/service_api/app/test_human_input_form.py b/api/tests/unit_tests/controllers/service_api/app/test_human_input_form.py index cc6d57255ef..08bf011fcc7 100644 --- a/api/tests/unit_tests/controllers/service_api/app/test_human_input_form.py +++ b/api/tests/unit_tests/controllers/service_api/app/test_human_input_form.py @@ -52,7 +52,7 @@ def _configure_service( sqlite_engine: Engine, ) -> None: workflow_module = sys.modules["controllers.service_api.app.human_input_form"] - monkeypatch.setattr(workflow_module, "HumanInputService", lambda _engine: service_mock) + monkeypatch.setattr(workflow_module, "ServiceAPIHumanInputService", lambda _engine: service_mock) monkeypatch.setattr(workflow_module, "db", SimpleNamespace(engine=sqlite_engine)) diff --git a/api/tests/unit_tests/controllers/service_api/app/test_human_input_form_compatibility.py b/api/tests/unit_tests/controllers/service_api/app/test_human_input_form_compatibility.py new file mode 100644 index 00000000000..3180bff3468 --- /dev/null +++ b/api/tests/unit_tests/controllers/service_api/app/test_human_input_form_compatibility.py @@ -0,0 +1,773 @@ +"""The existing Service API form contract applies to both HITL storage versions.""" + +import hashlib +import json +from collections.abc import Iterator +from concurrent.futures import ThreadPoolExecutor +from dataclasses import dataclass +from datetime import timedelta +from threading import Barrier +from typing import NoReturn +from uuid import uuid4 + +import pytest +import sqlalchemy as sa +from flask import Flask +from flask import request as flask_request +from flask.testing import FlaskClient +from flask_login import LoginManager +from flask_sqlalchemy import SQLAlchemy +from kombu.exceptions import OperationalError +from pydantic import JsonValue +from sqlalchemy.engine import Engine +from sqlalchemy.orm import Session, scoped_session, sessionmaker +from werkzeug.exceptions import Unauthorized +from werkzeug.test import TestResponse + +from controllers.service_api import wraps +from controllers.service_api.app import human_input_form as controller +from core.human_input import ButtonStyle +from core.human_input_v2.resolved_form import ( + FileInput, + FileListInput, + ParagraphInput, + ResolvedForm, + SelectInput, + UserAction, +) +from core.human_input_v2.shared.values import TenantId +from core.workflow.nodes.human_input.entities import FormDefinition +from core.workflow.nodes.human_input.enums import HumanInputFormStatus +from core.workflow.nodes.human_input_v2.entities import Initiator +from extensions.ext_application_services import ApplicationServices +from extensions.storage.storage_type import StorageType +from graphon.enums import WorkflowExecutionStatus +from graphon.file.enums import FileTransferMethod, FileType +from graphon.variables.segments import ArrayFileSegment, FileSegment +from libs.datetime_utils import naive_utc_now +from libs.external_api import ExternalApi +from models.account import TenantAccountJoin, TenantAccountRole +from models.enums import CreatorUserRole, EndUserType, WorkflowRunTriggeredFrom +from models.human_input import HumanInputForm as LegacyForm +from models.human_input import HumanInputFormRecipient as LegacyRecipient +from models.human_input import RecipientType +from models.human_input_v2 import FormViewEvent, HumanInputAuditEvent, HumanInputDelivery, RecipientSubjectType +from models.human_input_v2 import HumanInputForm as V2Form +from models.human_input_v2 import HumanInputRecipient as V2Recipient +from models.model import ApiToken, ApiTokenType, AppMode, EndUser, UploadFile +from models.workflow import WorkflowPause, WorkflowRun, WorkflowType +from repositories.human_input_v2.delivery_repository import DeliveryCreateParams, InitiatorSnapshot, SubmissionAuthType +from repositories.human_input_v2.form_repository import FormCreateParams +from repositories.human_input_v2.recipient_repository import EndUserRecipientSubject, RecipientCreateParams +from repositories.human_input_v2.sqlalchemy_delivery_repository import SQLAlchemyDeliveryRepository +from repositories.human_input_v2.sqlalchemy_form_repository import SQLAlchemyFormRepository +from repositories.human_input_v2.sqlalchemy_recipient_repository import SQLAlchemyRecipientRepository +from tasks.app_generate.workflow_execute_task import resume_app_execution +from tests.helpers.unit.model_factories import make_account, make_app, make_tenant + +_NOW = naive_utc_now().replace(microsecond=0) +_TOKEN = "service-approval-token" +_PATH = f"/v1/form/human_input/{_TOKEN}" +_HEADERS = {"Authorization": "Bearer test-app-key"} +_CONTENT = "Review {{#$output.note#}}: {{#$output.choice#}}" +_INPUTS: dict[str, JsonValue] = {"note": "Approved", "choice": "Yes"} + + +@dataclass +class Approval: + client: FlaskClient + sessions: sessionmaker[Session] + version: str + tenant_id: str + app_id: str + run_id: str + form_id: str + recipient_id: str + submitter_id: str + published: list[dict[str, object]] + + def submit(self, *, inputs: dict[str, JsonValue], action: str = "approve", user: str = "reviewer") -> TestResponse: + return self.client.post(_PATH, headers=_HEADERS, json={"user": user, "action": action, "inputs": inputs}) + + def submission(self) -> dict[str, JsonValue] | None: + with self.sessions() as session: + if self.version == "1": + form = session.get(LegacyForm, self.form_id) + assert form is not None + return json.loads(form.submitted_data) if form.submitted_data is not None else None + form = session.get(V2Form, self.form_id) + assert form is not None + return json.loads(form.raw_submission_inputs) if form.raw_submission_inputs is not None else None + + +@pytest.fixture(params=["1", "2"]) +def approval( + sqlite_session_factory: sessionmaker[Session], + sqlite_engine: Engine, + monkeypatch: pytest.MonkeyPatch, + request: pytest.FixtureRequest, + account_application_services: ApplicationServices, +) -> Iterator[Approval]: + version = request.param + tenant = make_tenant(tenant_id=str(uuid4())) + owner = make_account(account_id=str(uuid4())) + application = make_app(app_id=str(uuid4()), tenant_id=tenant.id, mode=AppMode.WORKFLOW) + initiator = EndUser( + id=str(uuid4()), + tenant_id=tenant.id, + app_id=application.id, + type=EndUserType.SERVICE_API, + external_user_id="initiator", + session_id="initiator", + ) + submitter = EndUser( + id=str(uuid4()), + tenant_id=tenant.id, + app_id=application.id, + type=EndUserType.SERVICE_API, + external_user_id="reviewer", + session_id="reviewer", + ) + run = WorkflowRun( + id=str(uuid4()), + tenant_id=tenant.id, + app_id=application.id, + workflow_id=str(uuid4()), + type=WorkflowType.WORKFLOW, + triggered_from=WorkflowRunTriggeredFrom.APP_RUN, + version="1", + status=WorkflowExecutionStatus.PAUSED, + created_by_role=CreatorUserRole.END_USER, + created_by=initiator.id, + ) + with sqlite_session_factory.begin() as session: + session.add_all([tenant, owner, application, initiator, submitter, run]) + session.add(TenantAccountJoin(tenant_id=tenant.id, account_id=owner.id, role=TenantAccountRole.OWNER)) + if version == "1": + definition = FormDefinition.model_validate( + { + "form_content": _CONTENT, + "rendered_content": _CONTENT, + "inputs": [ + { + "type": "paragraph", + "output_variable_name": "note", + "default": {"type": "constant", "value": "Draft"}, + }, + { + "type": "select", + "output_variable_name": "choice", + "option_source": {"type": "constant", "value": ["Yes", "No"]}, + }, + ], + "default_values": {"note": "Draft"}, + "expiration_time": _NOW + timedelta(hours=1), + "user_actions": [{"id": "approve", "title": "Approve", "button_style": "primary"}], + } + ) + legacy = LegacyForm( + id=str(uuid4()), + tenant_id=tenant.id, + app_id=application.id, + workflow_run_id=run.id, + node_id="approval", + form_definition=definition.model_dump_json(), + rendered_content=_CONTENT, + expiration_time=_NOW + timedelta(hours=1), + created_at=_NOW, + ) + recipient = LegacyRecipient( + id=str(uuid4()), + form_id=legacy.id, + delivery_id=str(uuid4()), + recipient_type=RecipientType.STANDALONE_WEB_APP, + recipient_payload='{"TYPE":"standalone_web_app"}', + access_token=_TOKEN, + ) + session.add_all([legacy, recipient]) + form_id, recipient_id = legacy.id, recipient.id + else: + form = SQLAlchemyFormRepository(session, TenantId(tenant.id), application.id).create_form( + FormCreateParams( + workflow_run_id=run.id, + node_execution_id=str(uuid4()), + expiration_time=_NOW + timedelta(hours=1), + global_timeout_deadline=_NOW + timedelta(hours=2), + resolved_form=ResolvedForm( + title="Review", + legacy_form_content=_CONTENT, + parts=( + ParagraphInput(output_variable_name="note", default_value="Draft"), + SelectInput(output_variable_name="choice", options=("Yes", "No"), default_value=None), + ), + actions=(UserAction(id="approve", title="Approve", button_style=ButtonStyle.PRIMARY),), + ), + ) + ) + v2_recipient = SQLAlchemyRecipientRepository(session, form.tenant_id, form.id).create_recipients( + [ + RecipientCreateParams( + subject=EndUserRecipientSubject(initiator.id), sources=(Initiator(),), name=None + ) + ] + )[0] + SQLAlchemyDeliveryRepository(session).create_delivery( + tenant_id=form.tenant_id, + form_id=form.id, + params=DeliveryCreateParams( + recipient_id=v2_recipient.id, + token_hash=hashlib.sha256(_TOKEN.encode()).hexdigest(), + auth_type=SubmissionAuthType.WEB_APP, + target_snapshot=InitiatorSnapshot(protected_form_token="unused-by-token-lookup"), + ), + ) + session.add( + WorkflowPause(workflow_id=run.workflow_id, workflow_run_id=run.id, state_object_key="checkpoint") + ) + form_id, recipient_id = form.id, str(v2_recipient.id) + + database_sessions = scoped_session(sqlite_session_factory) + database = SQLAlchemy() + monkeypatch.setattr(database, "session", database_sessions) + monkeypatch.setattr(wraps, "db", database) + monkeypatch.setattr(controller, "db", database) + + def authenticate(_token_type: str) -> ApiToken: + if flask_request.headers.get("Authorization") != _HEADERS["Authorization"]: + raise Unauthorized("Invalid API token") + return ApiToken(app_id=application.id, type=ApiTokenType.APP, token="test-app-key") + + def get_end_user(tenant_id: str, app_id: str, external_user_id: str | None) -> EndUser: + with sqlite_session_factory() as session: + end_user = session.scalar( + sa.select(EndUser).where( + EndUser.tenant_id == tenant_id, + EndUser.app_id == app_id, + EndUser.external_user_id == external_user_id, + ) + ) + assert end_user is not None + return end_user + + monkeypatch.setattr(wraps, "validate_and_get_api_token", authenticate) + monkeypatch.setattr( + account_application_services.app_scoped_end_users.commands, "get_or_create_end_user", get_end_user + ) + monkeypatch.setattr( + wraps, + "application_services", + lambda: account_application_services, + ) + monkeypatch.setattr("services.human_input_service.naive_utc_now", lambda: _NOW) + monkeypatch.setattr("services.human_input_v2.submission_service.naive_utc_now", lambda: _NOW) + published: list[dict[str, object]] = [] + + def publish(*, kwargs: dict[str, object]) -> None: + with sqlite_session_factory() as session: + model = session.get(LegacyForm, form_id) if version == "1" else session.get(V2Form, form_id) + assert model is not None + assert model.status == HumanInputFormStatus.SUBMITTED + published.append(kwargs) + + monkeypatch.setattr(resume_app_execution, "apply_async", publish) + application_http = Flask(__name__) + database._app_engines[application_http] = {None: sqlite_engine} + LoginManager().init_app(application_http) + ExternalApi(application_http, register_default_root=False).add_namespace(controller.service_api_ns, path="/v1") + yield Approval( + application_http.test_client(), + sqlite_session_factory, + version, + tenant.id, + application.id, + run.id, + form_id, + recipient_id, + submitter.id, + published, + ) + database_sessions.remove() + + +@pytest.mark.parametrize(("remote_address", "expected_ip"), [("127.0.0.1", "127.0.0.1"), (None, None), ("", None)]) +def test_get_without_user_returns_the_existing_form_definition( + approval: Approval, remote_address: str | None, expected_ip: str | None +) -> None: + response = approval.client.get(_PATH, headers=_HEADERS, environ_overrides={"REMOTE_ADDR": remote_address}) + assert response.status_code == 200, response.json + assert response.json == { + "form_content": _CONTENT, + "inputs": [ + { + "type": "paragraph", + "output_variable_name": "note", + "default": {"type": "constant", "selector": [], "value": "Draft"}, + }, + { + "type": "select", + "output_variable_name": "choice", + "option_source": {"type": "constant", "selector": [], "value": ["Yes", "No"]}, + }, + ], + "resolved_default_values": {"note": "Draft"}, + "user_actions": [{"id": "approve", "title": "Approve", "button_style": "primary"}], + "expiration_time": int((_NOW + timedelta(hours=1)).timestamp()), + } + assert approval.submission() is None + assert approval.published == [] + with approval.sessions() as session: + audits = list(session.scalars(sa.select(HumanInputAuditEvent))) + if approval.version == "1": + assert audits == [] + else: + assert len(audits) == 1 + assert audits[0].tenant_id == approval.tenant_id + assert audits[0].form_id == approval.form_id + assert isinstance(audits[0].payload, FormViewEvent) + assert audits[0].payload.viewer_ip_address == expected_ip + + +@pytest.mark.parametrize("method", ["GET", "POST"]) +def test_requires_an_app_api_token(approval: Approval, method: str) -> None: + response = approval.client.open( + _PATH, method=method, json={"user": "reviewer", "action": "approve", "inputs": _INPUTS} + ) + assert response.status_code == 401 + assert approval.submission() is None + + +def test_post_accepts_a_different_user_and_resumes_after_commit(approval: Approval) -> None: + response = approval.submit(inputs=_INPUTS) + assert response.status_code == 200, response.json + assert response.json == {} + assert approval.submission() == _INPUTS + assert approval.published == [{"payload": {"workflow_run_id": approval.run_id}}] + with approval.sessions() as session: + if approval.version == "1": + legacy = session.get(LegacyForm, approval.form_id) + assert legacy is not None + assert legacy.submission_end_user_id == approval.submitter_id + else: + audits = session.scalars( + sa.select(HumanInputAuditEvent).where( + HumanInputAuditEvent.tenant_id == approval.tenant_id, + HumanInputAuditEvent.form_id == approval.form_id, + ) + ).all() + assert [audit.payload.model_dump(mode="json") for audit in audits] == [ + { + "type": "form_submission", + "recipient_id": approval.recipient_id, + "end_user_id": approval.submitter_id, + } + ] + + +@pytest.mark.parametrize("note", [" Unchanged ", 123, False, None, {"detail": "approved"}, ["approved"]]) +def test_preserves_paragraph_json_values_and_ignores_extra_input_fields(approval: Approval, note: JsonValue) -> None: + inputs: dict[str, JsonValue] = {"note": note, "choice": "Yes", "extra": {"metadata": True}} + response = approval.submit(inputs=inputs) + assert response.status_code == 200, response.json + assert approval.submission() == inputs + if approval.version == "2": + with approval.sessions() as session: + form = SQLAlchemyFormRepository(session, TenantId(approval.tenant_id), approval.app_id).get_form_by_id( + approval.form_id + ) + assert form is not None + assert form.submission is not None + assert { + name: segment.to_object() for name, segment in form.submission.normalized_submission_data.items() + } == { + "note": note, + "choice": "Yes", + } + + +@pytest.mark.parametrize("approval", ["1"], indirect=True) +@pytest.mark.parametrize( + ("note", "rendered_note"), + [ + (" Unchanged ", " Unchanged "), + (123, "123"), + (False, "False"), + (None, ""), + ({"detail": "approved"}, "{'detail': 'approved'}"), + (["approved"], "['approved']"), + ], +) +def test_v1_json_paragraph_submission_resumes_the_node(approval: Approval, note: JsonValue, rendered_note: str) -> None: + from core.repositories.human_input_repository import HumanInputFormRepositoryImpl + from core.workflow.nodes.human_input.callback import DifyHITLCallback + from core.workflow.nodes.human_input.constants import OUTPUT_FIELD_RENDERED_CONTENT + from core.workflow.nodes.human_input.entities import HumanInputNodeData + from graphon.engine_events import NodeRunSucceededEvent + from graphon.enums import WorkflowNodeExecutionStatus + from graphon.nodes.human_input.entities import HumanInputNodeData as GraphHumanInputNodeData + from graphon.nodes.human_input.human_input_node import HumanInputNode + from graphon.runtime import RuntimeState, VariablePool + from graphon.runtime.init_params import InitParams + + response = approval.submit(inputs={"note": note, "choice": "Yes"}) + assert response.status_code == 200, response.json + with approval.sessions() as session: + form = session.get(LegacyForm, approval.form_id) + assert form is not None + definition = FormDefinition.model_validate_json(form.form_definition) + node_data = HumanInputNodeData( + title="Review", + form_content=definition.form_content, + inputs=definition.inputs, + user_actions=definition.user_actions, + ) + repository = HumanInputFormRepositoryImpl( + tenant_id=approval.tenant_id, app_id=approval.app_id, workflow_execution_id=approval.run_id + ) + node = HumanInputNode( + node_id="approval", + data=GraphHumanInputNodeData.model_validate(node_data.model_dump()), + init_params=InitParams( + workflow_id="workflow-1", graph_config={}, run_context={"workflow_run_id": approval.run_id}, call_depth=0 + ), + runtime_state=RuntimeState(workflow_id="workflow-1", variable_pool=VariablePool(), start_at=0), + hitl_callback=DifyHITLCallback( + form_repository=repository, node_data=node_data, workflow_execution_id=approval.run_id + ), + ) + node.bind_execution_id(approval.form_id) + event = list(node.run())[-1] + assert isinstance(event, NodeRunSucceededEvent) + completed = event.node_run_result + assert completed.status == WorkflowNodeExecutionStatus.SUCCEEDED + assert completed.edge_source_handle == "approve" + assert completed.outputs["note"].to_object() == note + assert completed.outputs[OUTPUT_FIELD_RENDERED_CONTENT].to_object() == f"Review {rendered_note}: Yes" + + +@pytest.mark.parametrize( + ("action", "inputs"), + [ + ("unknown", _INPUTS), + ("approve", {"note": "Approved"}), + ("approve", {"note": "Approved", "choice": "unknown"}), + ("approve", {"note": "Approved", "choice": 123}), + ], +) +def test_invalid_form_data_does_not_submit_or_resume( + approval: Approval, action: str, inputs: dict[str, JsonValue] +) -> None: + response = approval.submit(inputs=inputs, action=action) + assert response.status_code == 400, response.json + assert response.json is not None + assert response.json["code"] == "invalid_form_data" + assert approval.submission() is None + assert approval.published == [] + + +def test_post_requires_user(approval: Approval) -> None: + response = approval.client.post(_PATH, headers=_HEADERS, json={"action": "approve", "inputs": _INPUTS}) + assert response.status_code == 400 + assert approval.submission() is None + + +def test_post_ignores_additional_request_fields(approval: Approval) -> None: + response = approval.client.post( + _PATH, + headers=_HEADERS, + json={"user": "reviewer", "action": "approve", "inputs": _INPUTS, "metadata": {"client": "legacy-sdk"}}, + ) + assert response.status_code == 200, response.json + assert response.json == {} + + +def test_accepted_submission_stays_successful_when_the_resume_broker_is_unavailable( + approval: Approval, + monkeypatch: pytest.MonkeyPatch, +) -> None: + def reject_enqueue(**_kwargs: object) -> NoReturn: + raise OperationalError("Broker unavailable") + + monkeypatch.setattr(resume_app_execution, "apply_async", reject_enqueue) + response = approval.submit(inputs=_INPUTS) + assert response.status_code == 200, response.json + assert response.json == {} + assert approval.submission() == _INPUTS + + +def test_duplicate_submission_preserves_first_response_and_returns_412(approval: Approval) -> None: + assert approval.submit(inputs=_INPUTS).status_code == 200 + response = approval.submit(inputs={"note": "Changed", "choice": "No"}, user="initiator") + assert response.status_code == 412, response.json + assert response.json is not None + assert response.json["code"] == "human_input_form_submitted" + assert approval.submission() == _INPUTS + assert len(approval.published) == 1 + read = approval.client.get(_PATH, headers=_HEADERS) + assert read.status_code == 412 + assert read.json is not None + assert read.json["code"] == "human_input_form_submitted" + + +@pytest.mark.parametrize("method", ["GET", "POST"]) +def test_unknown_token_returns_not_found(approval: Approval, method: str) -> None: + response = approval.client.open( + "/v1/form/human_input/unknown", + method=method, + headers=_HEADERS, + json={"user": "reviewer", "action": "approve", "inputs": _INPUTS}, + ) + assert response.status_code == 404 + assert response.json is not None + assert response.json["code"] == "not_found" + + +@pytest.mark.parametrize("method", ["GET", "POST"]) +@pytest.mark.parametrize("owner", ["tenant_id", "app_id"]) +def test_rejects_forms_outside_the_authenticated_application(approval: Approval, method: str, owner: str) -> None: + model = LegacyForm if approval.version == "1" else V2Form + with approval.sessions.begin() as session: + session.execute(sa.update(model).where(model.id == approval.form_id).values({owner: str(uuid4())})) + response = approval.client.open( + _PATH, + method=method, + headers=_HEADERS, + json={"user": "reviewer", "action": "approve", "inputs": _INPUTS}, + ) + assert response.status_code == 404 + assert approval.submission() is None + assert approval.published == [] + + +@pytest.mark.parametrize("method", ["GET", "POST"]) +@pytest.mark.parametrize("expiration", ["node", "global", "timeout", "expired"]) +def test_expired_forms_return_412_without_submission(approval: Approval, method: str, expiration: str) -> None: + model = LegacyForm if approval.version == "1" else V2Form + with approval.sessions.begin() as session: + updates: dict[str, object] + match expiration: + case "node": + updates = {"expiration_time": _NOW} + case "global": + updates = ( + {"created_at": _NOW - timedelta(days=365)} + if approval.version == "1" + else {"global_timeout_deadline": _NOW} + ) + case "timeout": + updates = {"status": HumanInputFormStatus.TIMEOUT} + case _: + updates = {"status": HumanInputFormStatus.EXPIRED} + session.execute(sa.update(model).where(model.id == approval.form_id).values(updates)) + response = approval.client.open( + _PATH, + method=method, + headers=_HEADERS, + json={"user": "reviewer", "action": "approve", "inputs": _INPUTS}, + ) + assert response.status_code == 412, response.json + assert response.json is not None + assert response.json["code"] == "human_input_form_expired" + assert approval.submission() is None + assert approval.published == [] + + +@pytest.mark.parametrize("method", ["GET", "POST"]) +def test_rejects_console_recipient_tokens(approval: Approval, method: str) -> None: + with approval.sessions.begin() as session: + if approval.version == "1": + session.execute( + sa.update(LegacyRecipient) + .where(LegacyRecipient.id == approval.recipient_id) + .values( + recipient_type=RecipientType.CONSOLE, + recipient_payload='{"TYPE":"console"}', + ) + ) + else: + session.execute( + sa.update(V2Recipient) + .where(V2Recipient.id == approval.recipient_id) + .values( + subject_type=RecipientSubjectType.CONTACT, + subject_value=str(uuid4()), + ) + ) + response = approval.client.open( + _PATH, + method=method, + headers=_HEADERS, + json={"user": "reviewer", "action": "approve", "inputs": _INPUTS}, + ) + assert response.status_code == 404 + assert approval.submission() is None + assert approval.published == [] + + +def test_submits_existing_upload_references_for_file_and_file_list_inputs(approval: Approval) -> None: + upload = UploadFile( + tenant_id=approval.tenant_id, + storage_type=StorageType.LOCAL, + key="approval/report.txt", + name="report.txt", + size=8, + extension="txt", + mime_type="text/plain", + created_by_role=CreatorUserRole.END_USER, + created_by=approval.submitter_id, + created_at=_NOW, + used=False, + ) + file_parts = ( + FileInput( + output_variable_name="attachment", + allowed_file_types=(FileType.DOCUMENT,), + allowed_file_extensions=(".txt",), + allowed_file_upload_methods=(FileTransferMethod.LOCAL_FILE,), + ), + FileListInput( + output_variable_name="attachments", + allowed_file_types=(FileType.DOCUMENT,), + allowed_file_extensions=(".txt",), + allowed_file_upload_methods=(FileTransferMethod.LOCAL_FILE,), + number_limits=2, + ), + ) + with approval.sessions.begin() as session: + session.add(upload) + if approval.version == "1": + legacy = session.get(LegacyForm, approval.form_id) + assert legacy is not None + definition = json.loads(legacy.form_definition) + definition["inputs"].extend( + [ + { + "type": "file", + "output_variable_name": "attachment", + "allowed_file_types": ["document"], + "allowed_file_extensions": [".txt"], + "allowed_file_upload_methods": ["local_file"], + }, + { + "type": "file-list", + "output_variable_name": "attachments", + "allowed_file_types": ["document"], + "allowed_file_extensions": [".txt"], + "allowed_file_upload_methods": ["local_file"], + "number_limits": 2, + }, + ] + ) + legacy.form_definition = json.dumps(definition) + else: + form = session.get(V2Form, approval.form_id) + assert form is not None + form.resolved_form = form.resolved_form.model_copy( + update={"parts": (*form.resolved_form.parts, *file_parts)} + ) + mapping: dict[str, JsonValue] = {"transfer_method": "local_file", "upload_file_id": upload.id, "type": "document"} + response = approval.submit(inputs={**_INPUTS, "attachment": mapping, "attachments": [mapping]}) + assert response.status_code == 200, response.json + assert response.json == {} + with approval.sessions() as session: + if approval.version == "1": + legacy = session.get(LegacyForm, approval.form_id) + assert legacy is not None + assert legacy.submitted_data is not None + submitted = json.loads(legacy.submitted_data) + assert submitted["attachment"]["related_id"] == upload.id + assert submitted["attachments"][0]["related_id"] == upload.id + else: + form_snapshot = SQLAlchemyFormRepository( + session, TenantId(approval.tenant_id), approval.app_id + ).get_form_by_id(approval.form_id) + assert form_snapshot is not None + assert form_snapshot.submission is not None + attachment = form_snapshot.submission.normalized_submission_data["attachment"] + attachments = form_snapshot.submission.normalized_submission_data["attachments"] + assert isinstance(attachment, FileSegment) + assert attachment.value.related_id == upload.id + assert isinstance(attachments, ArrayFileSegment) + assert attachments.value[0].related_id == upload.id + assert len(approval.published) == 1 + + +@pytest.mark.parametrize("approval", ["2"], indirect=True) +@pytest.mark.parametrize("method", ["GET", "POST"]) +@pytest.mark.parametrize("auth_type", [SubmissionAuthType.CONSOLE, SubmissionAuthType.EMAIL_OTP, SubmissionAuthType.IM]) +def test_v2_rejects_tokens_from_other_authentication_surfaces( + approval: Approval, + method: str, + auth_type: SubmissionAuthType, +) -> None: + with approval.sessions.begin() as session: + session.execute( + sa.update(HumanInputDelivery) + .where(HumanInputDelivery.form_id == approval.form_id) + .values( + auth_type=auth_type, + ) + ) + response = approval.client.open( + _PATH, + method=method, + headers=_HEADERS, + json={"user": "reviewer", "action": "approve", "inputs": _INPUTS}, + ) + assert response.status_code == 404 + assert approval.submission() is None + assert approval.published == [] + + +@pytest.mark.parametrize("approval", ["2"], indirect=True) +@pytest.mark.parametrize("checkpoint", ["missing", "resumed", "run_not_paused"]) +def test_v2_requires_an_active_paused_workflow_before_accepting_submission(approval: Approval, checkpoint: str) -> None: + with approval.sessions.begin() as session: + match checkpoint: + case "missing": + session.execute(sa.delete(WorkflowPause).where(WorkflowPause.workflow_run_id == approval.run_id)) + case "resumed": + session.execute( + sa.update(WorkflowPause) + .where(WorkflowPause.workflow_run_id == approval.run_id) + .values( + resumed_at=_NOW, + ) + ) + case _: + session.execute( + sa.update(WorkflowRun) + .where(WorkflowRun.id == approval.run_id) + .values( + status=WorkflowExecutionStatus.RUNNING, + ) + ) + response = approval.submit(inputs=_INPUTS) + assert response.status_code == 400, response.json + assert response.json is not None + assert response.json["code"] == "bad_request" + assert approval.submission() is None + assert approval.published == [] + with approval.sessions() as session: + assert session.scalar(sa.select(sa.func.count()).select_from(HumanInputAuditEvent)) == 0 + + +@pytest.mark.parametrize("approval", ["2"], indirect=True) +def test_concurrent_v2_submissions_accept_only_one_response(approval: Approval) -> None: + ready = Barrier(2) + + def submit(note: str) -> int: + ready.wait(timeout=10) + with approval.client.application.test_client() as client: + response = client.post( + _PATH, + headers=_HEADERS, + json={"user": "reviewer", "action": "approve", "inputs": {"note": note, "choice": "Yes"}}, + ) + return response.status_code + + with ThreadPoolExecutor(max_workers=2) as executor: + statuses = list(executor.map(submit, ["First", "Second"])) + assert sorted(statuses) == [200, 412] + assert len(approval.published) == 1 + with approval.sessions() as session: + assert session.scalar(sa.select(sa.func.count()).select_from(HumanInputAuditEvent)) == 1 diff --git a/api/tests/unit_tests/controllers/service_api/app/test_workflow_events.py b/api/tests/unit_tests/controllers/service_api/app/test_workflow_events.py index a55bb2b0ed0..e5011096700 100644 --- a/api/tests/unit_tests/controllers/service_api/app/test_workflow_events.py +++ b/api/tests/unit_tests/controllers/service_api/app/test_workflow_events.py @@ -38,7 +38,7 @@ from core.app.entities.task_entities import ( ) from core.app.layers.pause_state_persist_layer import WorkflowResumptionContext, _WorkflowGenerateEntityWrapper from core.workflow.human_input_policy import FormDisposition, HumanInputSurface -from core.workflow.nodes.human_input.entities import ParagraphInputConfig, UserActionConfig +from core.workflow.nodes.human_input.entities import FormDefinition, ParagraphInputConfig, UserActionConfig from core.workflow.nodes.human_input.enums import FormInputType, HumanInputFormKind, HumanInputFormStatus from core.workflow.nodes.human_input.pause_reason import DifyHITLEventType, HumanInputRequired from core.workflow.system_variables import build_system_variables @@ -56,7 +56,7 @@ from models.workflow import WorkflowType as ModelWorkflowType from repositories.api_workflow_node_execution_repository import WorkflowNodeExecutionSnapshot from repositories.entities.workflow_pause import WorkflowPauseEntity from services.app_generate_service import AppGenerateService -from services.workflow_event_snapshot_service import _build_snapshot_events +from services.workflow_event_snapshot_service import _build_paused_snapshot_events from tests.helpers.unit.config_override import apply_config_overrides """Dedicated tests for HITL behavior exposed through the Service API.""" @@ -151,7 +151,9 @@ def _persist_human_input_form( conversation_id=None, form_kind=HumanInputFormKind.RUNTIME, node_id="node-1", - form_definition=json.dumps({"display_in_ui": True}), + form_definition=FormDefinition( + form_content="content", rendered_content="Rendered", expiration_time=expiration_time, display_in_ui=True + ).model_dump_json(), rendered_content="Rendered", status=HumanInputFormStatus.WAITING, expiration_time=expiration_time, @@ -335,6 +337,9 @@ def _build_resumption_context(task_id: str) -> WorkflowResumptionContext: workflow_execution_id="run-1", ) runtime_state = RuntimeState(workflow_id="test-workflow", variable_pool=VariablePool(), start_at=0.0) + from graphon.entities.pause_reason import HitlRequired + + runtime_state.graph_execution.pause(HitlRequired(session_id="form-1", node_id="node-1", node_title="Human Input")) runtime_state.set_output("result", "value") wrapper = _WorkflowGenerateEntityWrapper(entity=generate_entity) return WorkflowResumptionContext( @@ -745,6 +750,17 @@ class TestHitlServiceApi: resumption_context = _build_resumption_context("task-ctx") expiration_time = datetime(2024, 1, 1, tzinfo=UTC) _persist_human_input_form(sqlite_session, expiration_time=expiration_time) + from models.workflow import WorkflowPauseReason + + sqlite_session.add( + WorkflowPauseReason.from_entity( + pause_id="pause-1", + pause_reason=HumanInputRequired( + form_id="form-1", node_id="node-1", node_title="Human Input", form_content="content" + ), + ) + ) + sqlite_session.commit() monkeypatch.setattr( "services.workflow_event_snapshot_service.load_form_dispositions_by_form_id", lambda form_ids, session=None, surface=None: { @@ -769,7 +785,7 @@ class TestHitlServiceApi: ], ) - events = _build_snapshot_events( + events = _build_paused_snapshot_events( workflow_run=workflow_run, node_snapshots=[snapshot], task_id="task-ctx", diff --git a/api/tests/unit_tests/controllers/service_api/test___init__.py b/api/tests/unit_tests/controllers/service_api/test___init__.py index 4d4822b789c..8f08bb8618a 100644 --- a/api/tests/unit_tests/controllers/service_api/test___init__.py +++ b/api/tests/unit_tests/controllers/service_api/test___init__.py @@ -241,6 +241,34 @@ def test_openapi_json_endpoints_render(): assert app.config["RESTX_INCLUDE_ALL_MODELS"] is True +def test_console_node_data_migration_documents_canonical_delivery_method_union( + openapi_json: OpenAPIJSONLoader, +) -> None: + from controllers.console import bp as console_bp + + payload = json.loads(openapi_json(console_bp, "/console/api/openapi.json", True)) + schemas = payload["components"]["schemas"] + operation = payload["paths"]["/workspaces/current/human-input/v2/node-data-migration"]["post"] + request_schema = operation["requestBody"]["content"]["application/json"]["schema"] + + assert request_schema == {"$ref": "#/components/schemas/NodeDataMigrationPayload"} + payload_schema = schemas["NodeDataMigrationPayload"] + node_input_ref = payload_schema["properties"]["nodes"]["items"]["$ref"] + node_input_schema = schemas[node_input_ref.removeprefix("#/components/schemas/")] + node_data_ref = node_input_schema["properties"]["node_data"]["$ref"] + node_data_schema = schemas[node_data_ref.removeprefix("#/components/schemas/")] + method_items = node_data_schema["properties"]["delivery_methods"]["items"] + + assert set(method_items["discriminator"]["mapping"]) == {"webapp", "email", "im"} + input_ref = node_data_schema["properties"]["inputs"]["items"]["$ref"] + input_schema = schemas[input_ref.removeprefix("#/components/schemas/")] + input_variants = input_schema["discriminator"]["mapping"] + assert set(input_variants) == {"paragraph", "select", "file", "file-list"} + action_ref = node_data_schema["properties"]["user_actions"]["items"]["$ref"] + action_schema = schemas[action_ref.removeprefix("#/components/schemas/")] + assert action_schema["properties"]["title"]["maxLength"] == 100 + + def test_agent_tts_routes_document_voice_queries_and_binary_audio(openapi_json: OpenAPIJSONLoader): from controllers.console import bp as console_bp from core.base.tts.audio_mime import SUPPORTED_TTS_AUDIO_MIME_TYPES diff --git a/api/tests/unit_tests/controllers/web/test_human_input_form_v2_stub.py b/api/tests/unit_tests/controllers/web/test_human_input_form_v2_stub.py new file mode 100644 index 00000000000..6eec03e7612 --- /dev/null +++ b/api/tests/unit_tests/controllers/web/test_human_input_form_v2_stub.py @@ -0,0 +1,29 @@ +from datetime import datetime + +import pytest +from flask import Flask +from sqlalchemy.orm import sessionmaker + +from controllers.web import human_input_form_access_request as controller +from libs.external_api import ExternalApi +from services.file_service import FileService +from services.human_input_file_upload_service import HumanInputUploadToken +from services.human_input_v2.file_upload_service import HumanInputV2FileUploadService +from services.remote_file_service import RemoteFileService + + +def test_public_v2_upload_token_uses_independent_route(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: + sessions = sessionmaker() + files = FileService(sessions) + service = HumanInputV2FileUploadService( + session_factory=sessions, files=files, remote_files=RemoteFileService(files=files) + ) + grant = HumanInputUploadToken(upload_token="hitl_v2_upload_grant", expires_at=datetime(2030, 1, 1)) + monkeypatch.setattr(service, "issue_public_upload_token", lambda _form_token: grant) + monkeypatch.setattr(controller, "build_file_upload_service", lambda: service) + ExternalApi(app).add_namespace(controller.web_ns) + client = app.test_client() + response = client.post("/form/human-input/v2/public/token/upload-token") + assert response.status_code == 200 + assert response.json == {"upload_token": "hitl_v2_upload_grant", "expires_at": 1893456000} + assert client.post("/form/human-input/token/upload-token").status_code == 404 diff --git a/api/tests/unit_tests/controllers/web/test_human_input_v2_form_read.py b/api/tests/unit_tests/controllers/web/test_human_input_v2_form_read.py new file mode 100644 index 00000000000..b702d828cac --- /dev/null +++ b/api/tests/unit_tests/controllers/web/test_human_input_v2_form_read.py @@ -0,0 +1,499 @@ +"""Public form definitions require a delivery token, independently of email proof.""" + +import hashlib +from dataclasses import dataclass +from datetime import datetime, timedelta +from typing import NoReturn +from urllib.parse import parse_qs, urlsplit +from uuid import uuid4 + +import pytest +import sqlalchemy as sa +from flask import Flask +from flask.testing import FlaskClient +from sqlalchemy.orm import Session, sessionmaker + +from controllers.web import human_input_form_access_request as controller +from core.human_input import ButtonStyle +from core.human_input_v2.resolved_form import ( + FileInput, + FileListInput, + MarkdownFragment, + ParagraphInput, + ResolvedForm, + SelectInput, + UserAction, +) +from core.human_input_v2.shared.values import ContactId, NormalizedEmail, TenantId +from core.tools.signature import sign_tool_file, verify_tool_file_signature +from core.workflow.nodes.human_input.enums import HumanInputFormKind, HumanInputFormStatus +from core.workflow.nodes.human_input_v2.entities import Contact, DynamicEmail, OnetimeEmail +from graphon.file.enums import FileTransferMethod, FileType +from graphon.variables.segments import StringSegment +from libs.external_api import ExternalApi +from models.human_input_v2 import ( + FormViewEvent, + HumanInputAuditEvent, + HumanInputContactIdentity, + HumanInputDelivery, + HumanInputExternalContactProfile, + HumanInputForm, + HumanInputRecipient, +) +from models.model import App, AppMode +from repositories.human_input_v2.contact import ExternalContact +from repositories.human_input_v2.delivery_repository import ( + DeliveryCreateParams, + EmailTargetSnapshot, + InitiatorSnapshot, + SubmissionAuthType, +) +from repositories.human_input_v2.form_repository import Form, FormCreateParams +from repositories.human_input_v2.recipient_repository import ( + ContactRecipientSubject, + EmailRecipientSubject, + Recipient, + RecipientCreateParams, +) +from repositories.human_input_v2.sqlalchemy_contact_repository import SQLAlchemyContactRepository +from repositories.human_input_v2.sqlalchemy_delivery_repository import SQLAlchemyDeliveryRepository +from repositories.human_input_v2.sqlalchemy_form_repository import SQLAlchemyFormRepository +from repositories.human_input_v2.sqlalchemy_recipient_repository import SQLAlchemyRecipientRepository +from services.human_input_v2.email_otp_service import EmailOtpService +from tests.helpers.unit.config_override import apply_config_overrides +from tests.helpers.unit.model_factories import make_tenant + +_NOW = datetime(2030, 1, 1) +_TOKEN = "public-v2-read-token" +_PATH = f"/api/form/human-input/v2/public/{_TOKEN}" + + +@dataclass +class PublicForm: + client: FlaskClient + sessions: sessionmaker[Session] + form: Form + recipient: Recipient + + +@pytest.fixture(params=["email", "dynamic_email", "external_contact"]) +def public_form( + sqlite_session_factory: sessionmaker[Session], monkeypatch: pytest.MonkeyPatch, request: pytest.FixtureRequest +) -> PublicForm: + tenant = make_tenant(tenant_id=str(uuid4())) + app_id = str(uuid4()) + with sqlite_session_factory.begin() as session: + session.add(tenant) + session.add( + App( + id=app_id, + tenant_id=tenant.id, + name="Email approval", + mode=AppMode.WORKFLOW, + enable_site=False, + enable_api=True, + ) + ) + form = SQLAlchemyFormRepository(session, TenantId(tenant.id), app_id).create_form( + FormCreateParams( + workflow_run_id=None, + node_execution_id=None, + form_kind=HumanInputFormKind.DELIVERY_TEST, + resolved_form=ResolvedForm( + title="Frozen approval", + legacy_form_content=( + "Review {{#$output.note#}}: {{#$output.choice#}} {{#$output.file#}} {{#$output.attachments#}}" + ), + parts=( + MarkdownFragment(text="Review "), + ParagraphInput(output_variable_name="note", default_value="Draft"), + SelectInput(output_variable_name="choice", options=("Yes", "No"), default_value="No"), + FileInput( + output_variable_name="file", + allowed_file_types=(FileType.DOCUMENT,), + allowed_file_extensions=(".txt",), + allowed_file_upload_methods=(FileTransferMethod.LOCAL_FILE,), + ), + FileListInput( + output_variable_name="attachments", + allowed_file_types=(FileType.IMAGE,), + allowed_file_extensions=(), + allowed_file_upload_methods=(FileTransferMethod.REMOTE_URL,), + number_limits=2, + ), + ), + actions=(UserAction(id="approve", title="Approve", button_style=ButtonStyle.PRIMARY),), + ), + expiration_time=_NOW + timedelta(hours=1), + global_timeout_deadline=_NOW + timedelta(hours=2), + ) + ) + if request.param == "external_contact": + contact = SQLAlchemyContactRepository(session).save_external_contact( + form.tenant_id, + ExternalContact( + id=ContactId(str(uuid4())), + name="Approver", + email="approver@example.com", + avatar_file_id=None, + created_at=_NOW, + ), + ) + subject = ContactRecipientSubject(contact.id) + source = Contact(contact_id=contact.id) + else: + subject = EmailRecipientSubject(NormalizedEmail("approver@example.com")) + source = ( + DynamicEmail(selector=["input", "email"]) + if request.param == "dynamic_email" + else OnetimeEmail(email="approver@example.com") + ) + recipient = SQLAlchemyRecipientRepository(session, form.tenant_id, form.id).create_recipients( + [RecipientCreateParams(subject=subject, sources=(source,), name="Approver")] + )[0] + SQLAlchemyDeliveryRepository(session).create_delivery( + tenant_id=form.tenant_id, + form_id=form.id, + params=DeliveryCreateParams( + recipient_id=recipient.id, + token_hash=hashlib.sha256(_TOKEN.encode()).hexdigest(), + auth_type=SubmissionAuthType.EMAIL_OTP, + target_snapshot=EmailTargetSnapshot(email_address="approver@example.com"), + ), + ) + + # Read before any challenge is issued; OTP operations are not read side effects. + def reject_otp(*_args: object, **_kwargs: object) -> NoReturn: + pytest.fail("Form reads must not issue or verify OTP") + + monkeypatch.setattr(EmailOtpService, "request_access", reject_otp) + monkeypatch.setattr(EmailOtpService, "verify", reject_otp) + monkeypatch.setattr("services.human_input_v2.submission_service.naive_utc_now", lambda: _NOW) + app = Flask(__name__) + ExternalApi(app).add_namespace(controller.web_ns, path="/api") + return PublicForm(app.test_client(), sqlite_session_factory, form, recipient) + + +def test_reads_complete_frozen_definition_without_otp_or_session(public_form: PublicForm) -> None: + response = public_form.client.get(_PATH) + assert response.status_code == 200, response.json + assert response.json == { + "form_content": "Review {{#$output.note#}}: {{#$output.choice#}} {{#$output.file#}} {{#$output.attachments#}}", + "inputs": [ + { + "type": "paragraph", + "output_variable_name": "note", + "default": {"type": "constant", "selector": [], "value": "Draft"}, + }, + { + "type": "select", + "output_variable_name": "choice", + "option_source": {"type": "constant", "selector": [], "value": ["Yes", "No"]}, + }, + { + "type": "file", + "output_variable_name": "file", + "allowed_file_types": ["document"], + "allowed_file_extensions": [".txt"], + "allowed_file_upload_methods": ["local_file"], + }, + { + "type": "file-list", + "output_variable_name": "attachments", + "allowed_file_types": ["image"], + "allowed_file_extensions": [], + "allowed_file_upload_methods": ["remote_url"], + "number_limits": 2, + }, + ], + "resolved_default_values": {"note": "Draft", "choice": "No"}, + "user_actions": [{"id": "approve", "title": "Approve", "button_style": "primary"}], + "expiration_time": 1893459600, + } + with public_form.sessions() as session: + form = session.get(HumanInputForm, public_form.form.id) + assert form is not None + assert form.status == HumanInputFormStatus.WAITING + assert form.updated_at == public_form.form.updated_at + assert form.submitted_at is None + audit = session.scalars(sa.select(HumanInputAuditEvent)).one() + assert audit.tenant_id == public_form.form.tenant_id + assert audit.form_id == public_form.form.id + assert audit.created_at is not None + assert audit.payload == FormViewEvent(viewer_ip_address="127.0.0.1") + + +def test_unknown_token_is_not_found(public_form: PublicForm) -> None: + response = public_form.client.get("/api/form/human-input/v2/public/unknown") + assert response.status_code == 404 + assert response.json is not None + assert response.json["code"] == "human_input_form_not_found" + + +def test_reads_refresh_file_signatures_without_changing_the_snapshot( + public_form: PublicForm, monkeypatch: pytest.MonkeyPatch +) -> None: + apply_config_overrides(monkeypatch, FILES_URL="https://files.example.com", FILES_ACCESS_TIMEOUT=300) + file_id = str(uuid4()) + monkeypatch.setattr("core.tools.signature.time.time", lambda: 1700000000) + old_url = sign_tool_file(file_id, ".pdf") + content = f"[Report]({old_url}) {{{{#$output.note#}}}}" + snapshot = public_form.form.resolved_form.model_copy( + update={ + "parts": ( + MarkdownFragment(text=f"[Report]({old_url}) "), + ParagraphInput(output_variable_name="note", default_value="Draft"), + ), + "legacy_form_content": content, + } + ) + with public_form.sessions.begin() as session: + session.execute( + sa.update(HumanInputForm).where(HumanInputForm.id == public_form.form.id).values(resolved_form=snapshot) + ) + + urls: list[str] = [] + for now in (1700000400, 1700000500): + monkeypatch.setattr("core.tools.signature.time.time", lambda now=now: now) + response = public_form.client.get(_PATH) + assert response.status_code == 200 + assert response.json is not None + url = response.json["form_content"].removeprefix("[Report](").removesuffix(") {{#$output.note#}}") + query = parse_qs(urlsplit(url).query) + assert query["timestamp"] == [str(now)] + assert verify_tool_file_signature(file_id, query["timestamp"][0], query["nonce"][0], query["sign"][0]) + assert urlsplit(url).path == urlsplit(old_url).path + urls.append(url) + assert urls[0] != old_url + assert urls[0] != urls[1] + with public_form.sessions() as session: + stored = session.get(HumanInputForm, public_form.form.id) + assert stored is not None + assert stored.resolved_form == snapshot + + +@pytest.mark.parametrize("denial", ["auth_type", "recipient", "submitted", "timeout", "expired", "deadline"]) +def test_denied_file_form_does_not_return_its_content(public_form: PublicForm, denial: str) -> None: + url = sign_tool_file(str(uuid4()), ".pdf") + content = f"[Report]({url})" + snapshot = public_form.form.resolved_form.model_copy( + update={"parts": (MarkdownFragment(text=content),), "legacy_form_content": content} + ) + with public_form.sessions.begin() as session: + session.execute(sa.update(HumanInputForm).values(resolved_form=snapshot)) + match denial: + case "auth_type": + session.execute(sa.update(HumanInputDelivery).values(auth_type=SubmissionAuthType.CONSOLE)) + case "recipient": + session.execute(sa.delete(HumanInputRecipient)) + case "submitted": + SQLAlchemyFormRepository(session, public_form.form.tenant_id, public_form.form.app_id).submit_form( + public_form.form.id, + submitted_by_recipient_id=public_form.recipient.id, + submitted_by_delivery_id=session.scalars(sa.select(HumanInputDelivery)).one().id, + selected_action_id="approve", + raw_submission_inputs={}, + normalized_submission_data={}, + ) + case "timeout": + session.execute(sa.update(HumanInputForm).values(status=HumanInputFormStatus.TIMEOUT)) + case "expired": + session.execute(sa.update(HumanInputForm).values(status=HumanInputFormStatus.EXPIRED)) + case "deadline": + session.execute(sa.update(HumanInputForm).values(expiration_time=_NOW)) + response = public_form.client.get(_PATH) + assert response.status_code in (404, 412) + assert response.json is not None + assert "form_content" not in response.json + assert url not in response.get_data(as_text=True) + + +@pytest.mark.parametrize("auth_type", [SubmissionAuthType.CONSOLE, SubmissionAuthType.WEB_APP, SubmissionAuthType.IM]) +def test_rejects_other_authentication_surfaces(public_form: PublicForm, auth_type: SubmissionAuthType) -> None: + with public_form.sessions.begin() as session: + session.execute( + sa.update(HumanInputDelivery) + .where(HumanInputDelivery.form_id == public_form.form.id) + .values(auth_type=auth_type) + ) + assert public_form.client.get(_PATH).status_code == 404 + + +def test_rejects_non_email_delivery_target(public_form: PublicForm) -> None: + with public_form.sessions.begin() as session: + delivery = session.scalar( + sa.select(HumanInputDelivery).where(HumanInputDelivery.form_id == public_form.form.id) + ) + assert delivery is not None + delivery.target_snapshot = InitiatorSnapshot(protected_form_token="ciphertext") + assert public_form.client.get(_PATH).status_code == 404 + + +@pytest.mark.parametrize( + "mismatch", ["delivery_tenant", "form_tenant", "app_tenant", "missing_app", "recipient_form", "recipient_tenant"] +) +def test_read_resolves_the_complete_owner_chain(public_form: PublicForm, mismatch: str) -> None: + with public_form.sessions.begin() as session: + match mismatch: + case "delivery_tenant": + session.execute( + sa.update(HumanInputDelivery) + .where(HumanInputDelivery.form_id == public_form.form.id) + .values(tenant_id=str(uuid4())) + ) + case "form_tenant": + session.execute( + sa.update(HumanInputForm) + .where(HumanInputForm.id == public_form.form.id) + .values(tenant_id=str(uuid4())) + ) + case "app_tenant": + session.execute(sa.update(App).where(App.id == public_form.form.app_id).values(tenant_id=str(uuid4()))) + case "missing_app": + session.execute(sa.delete(App).where(App.id == public_form.form.app_id)) + case "recipient_form": + session.execute( + sa.update(HumanInputRecipient) + .where(HumanInputRecipient.id == public_form.recipient.id) + .values(form_id=str(uuid4())) + ) + case "recipient_tenant": + session.execute( + sa.update(HumanInputRecipient) + .where(HumanInputRecipient.id == public_form.recipient.id) + .values(tenant_id=str(uuid4())) + ) + assert public_form.client.get(_PATH).status_code == 404 + + +def test_removed_recipient_is_not_found(public_form: PublicForm) -> None: + with public_form.sessions.begin() as session: + session.execute(sa.delete(HumanInputRecipient).where(HumanInputRecipient.id == public_form.recipient.id)) + assert public_form.client.get(_PATH).status_code == 404 + + +@pytest.mark.parametrize("public_form", ["external_contact"], indirect=True) +@pytest.mark.parametrize("revocation", ["email", "profile", "identity"]) +def test_unavailable_or_changed_external_contact_cannot_read(public_form: PublicForm, revocation: str) -> None: + assert isinstance(public_form.recipient.subject, ContactRecipientSubject) + with public_form.sessions.begin() as session: + contact_id = public_form.recipient.subject.contact_id + match revocation: + case "email": + session.execute( + sa.update(HumanInputExternalContactProfile) + .where(HumanInputExternalContactProfile.contact_id == contact_id) + .values(email="changed@example.com", normalized_email="changed@example.com") + ) + case "profile": + session.execute( + sa.delete(HumanInputExternalContactProfile).where( + HumanInputExternalContactProfile.contact_id == contact_id + ) + ) + case "identity": + session.execute(sa.delete(HumanInputContactIdentity).where(HumanInputContactIdentity.id == contact_id)) + assert public_form.client.get(_PATH).status_code == 404 + + +@pytest.mark.parametrize("deadline", ["expiration_time", "global_timeout_deadline"]) +def test_waiting_form_is_unavailable_at_either_deadline(public_form: PublicForm, deadline: str) -> None: + with public_form.sessions.begin() as session: + session.execute( + sa.update(HumanInputForm).where(HumanInputForm.id == public_form.form.id).values({deadline: _NOW}) + ) + response = public_form.client.get(_PATH) + assert response.status_code == 412 + assert response.json is not None + assert response.json["code"] == "human_input_form_expired" + + +def test_response_uses_the_earlier_global_deadline(public_form: PublicForm) -> None: + with public_form.sessions.begin() as session: + session.execute( + sa.update(HumanInputForm) + .where(HumanInputForm.id == public_form.form.id) + .values(global_timeout_deadline=_NOW + timedelta(minutes=30)) + ) + response = public_form.client.get(_PATH) + assert response.status_code == 200 + assert response.json is not None + assert response.json["expiration_time"] == 1893457800 + + +@pytest.mark.parametrize("status", [HumanInputFormStatus.TIMEOUT, HumanInputFormStatus.EXPIRED]) +def test_rejects_terminal_forms(public_form: PublicForm, status: HumanInputFormStatus) -> None: + with public_form.sessions.begin() as session: + session.execute(sa.update(HumanInputForm).where(HumanInputForm.id == public_form.form.id).values(status=status)) + response = public_form.client.get(_PATH) + assert response.status_code == 412 + assert response.json is not None + assert response.json["code"] == "human_input_form_expired" + + +def test_submitted_form_is_reported_as_complete(public_form: PublicForm) -> None: + with public_form.sessions.begin() as session: + SQLAlchemyFormRepository(session, public_form.form.tenant_id, public_form.form.app_id).submit_form( + public_form.form.id, + submitted_by_recipient_id=public_form.recipient.id, + submitted_by_delivery_id=session.scalars(sa.select(HumanInputDelivery)).one().id, + selected_action_id="approve", + raw_submission_inputs={"note": "Approved"}, + normalized_submission_data={"note": StringSegment(value="Approved")}, + ) + response = public_form.client.get(_PATH) + assert response.status_code == 412 + assert response.json is not None + assert response.json["code"] == "human_input_form_submitted" + + +@pytest.mark.parametrize("denial", ["unknown_token", "expired", "wrong_auth", "removed_recipient"]) +def test_denied_read_does_not_record_a_view(public_form: PublicForm, denial: str) -> None: + path = _PATH + with public_form.sessions.begin() as session: + match denial: + case "unknown_token": + path = "/api/form/human-input/v2/public/unknown" + case "expired": + session.execute(sa.update(HumanInputForm).values(expiration_time=_NOW)) + case "wrong_auth": + session.execute(sa.update(HumanInputDelivery).values(auth_type=SubmissionAuthType.CONSOLE)) + case "removed_recipient": + session.execute(sa.delete(HumanInputRecipient)) + assert public_form.client.get(path).status_code in (404, 412) + with public_form.sessions() as session: + assert session.scalar(sa.select(sa.func.count()).select_from(HumanInputAuditEvent)) == 0 + + +def test_each_page_read_records_the_request_ip(public_form: PublicForm) -> None: + for ip_address in ("192.0.2.10", "192.0.2.11"): + assert public_form.client.get(_PATH, environ_overrides={"REMOTE_ADDR": ip_address}).status_code == 200 + with public_form.sessions() as session: + audits = list(session.scalars(sa.select(HumanInputAuditEvent))) + assert len(audits) == 2 + assert {audit.payload.viewer_ip_address for audit in audits if isinstance(audit.payload, FormViewEvent)} == { + "192.0.2.10", + "192.0.2.11", + } + + +@pytest.mark.parametrize("remote_address", [None, ""]) +def test_missing_viewer_ip_is_persisted_as_null(public_form: PublicForm, remote_address: str | None) -> None: + response = public_form.client.get(_PATH, environ_overrides={"REMOTE_ADDR": remote_address}) + assert response.status_code == 200 + with public_form.sessions() as session: + audit = session.scalars(sa.select(HumanInputAuditEvent)).one() + assert isinstance(audit.payload, FormViewEvent) + assert audit.payload.viewer_ip_address is None + assert audit.payload.model_dump(mode="json")["viewer_ip_address"] is None + + +def test_form_read_without_ip_context_is_audited_as_null(public_form: PublicForm) -> None: + from services.human_input_v2.submission_service import HumanInputSubmissionService + + form = HumanInputSubmissionService(session_factory=public_form.sessions).get_public_form(form_token=_TOKEN) + assert form.id == public_form.form.id + with public_form.sessions() as session: + audit = session.scalars(sa.select(HumanInputAuditEvent)).one() + assert isinstance(audit.payload, FormViewEvent) + assert audit.payload.viewer_ip_address is None diff --git a/api/tests/unit_tests/controllers/web/test_human_input_v2_otp_access.py b/api/tests/unit_tests/controllers/web/test_human_input_v2_otp_access.py new file mode 100644 index 00000000000..e89e7efe94a --- /dev/null +++ b/api/tests/unit_tests/controllers/web/test_human_input_v2_otp_access.py @@ -0,0 +1,37 @@ +import pytest +from flask import Flask + +from controllers.web import human_input_form_access_request as controller +from libs.exception import BaseHTTPException +from services.human_input_v2.email_otp_service import AccessChallenge, EmailOtpService + + +def test_access_returns_challenge_without_changing_public_contract(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: + challenge = AccessChallenge(challenge_token="opaque-challenge", expires_in_seconds=600, resend_after_seconds=60) + monkeypatch.setattr(EmailOtpService, "request_access", lambda _self, _form_token, **_kwargs: challenge) + with app.test_request_context("/api/form/human-input/v2/public/form-token/access-request", method="POST"): + response = controller.FormAccessRequestApi().post("form-token") + assert response == { + "challenge_token": "opaque-challenge", + "expires_in_seconds": 600, + "resend_after_seconds": 60, + } + + +def test_access_rate_limit_preserves_error_code_and_retry_after(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: + + from services.human_input_v2.email_otp_service import EmailOtpError, OtpFailure + + def rate_limited(_self: EmailOtpService, form_token: str, *, ip_address: str | None = None) -> AccessChallenge: + assert form_token == "form-token" + del ip_address + raise EmailOtpError(OtpFailure.RATE_LIMIT, 45) + + monkeypatch.setattr(EmailOtpService, "request_access", rate_limited) + with app.test_request_context("/api/form/human-input/v2/public/form-token/access-request", method="POST"): + with pytest.raises(BaseHTTPException) as error: + controller.FormAccessRequestApi().post("form-token") + assert error.value.code == 429 + assert error.value.data is not None + assert error.value.data["code"] == "human_input_access_rate_limit_exceeded" + assert ("Retry-After", "45") in error.value.get_headers() diff --git a/api/tests/unit_tests/core/app/apps/advanced_chat/test_generate_task_pipeline.py b/api/tests/unit_tests/core/app/apps/advanced_chat/test_generate_task_pipeline.py index 6b69b3ef5bc..aec5b9f4be5 100644 --- a/api/tests/unit_tests/core/app/apps/advanced_chat/test_generate_task_pipeline.py +++ b/api/tests/unit_tests/core/app/apps/advanced_chat/test_generate_task_pipeline.py @@ -714,6 +714,7 @@ class TestAdvancedChatGenerateTaskPipeline: assert response.data.reasons == [ { "TYPE": DifyHITLEventType.HUMAN_INPUT_REQUIRED, + "form_version": "1", "form_id": "form-1", "node_id": "node-1", "node_title": "Approval", diff --git a/api/tests/unit_tests/core/app/apps/common/test_workflow_response_converter.py b/api/tests/unit_tests/core/app/apps/common/test_workflow_response_converter.py index 2b7753b3d3b..e9a353eadc8 100644 --- a/api/tests/unit_tests/core/app/apps/common/test_workflow_response_converter.py +++ b/api/tests/unit_tests/core/app/apps/common/test_workflow_response_converter.py @@ -1619,13 +1619,6 @@ def _build_paused_human_input_response( class TestWorkflowPauseEventsCases: - @staticmethod - @pytest.fixture - def sqlite_pause_session(sqlite_session: Session, monkeypatch: pytest.MonkeyPatch) -> Session: - """Bind pause-response queries to the shared SQLite session's database.""" - monkeypatch.setattr(workflow_response_converter, "db", SimpleNamespace(engine=sqlite_session.get_bind())) - return sqlite_session - @staticmethod @pytest.mark.parametrize("source_node_id", ["node-human", "nested-human"]) def test_graph_run_paused_event_uses_projected_nodes_for_highlighting( @@ -1650,7 +1643,7 @@ class TestWorkflowPauseEventsCases: node_id="node-human", node_title="Human Step", ) - monkeypatch.setattr("core.app.apps.workflow_app_runner.dispatch_human_input_email_task", MagicMock()) + monkeypatch.setattr("core.app.apps.workflow_app_runner.dispatch_human_input_form_delivery_task", MagicMock()) runner.handle_event(workflow_entry, event, pause_reasons=[enriched_reason]) @@ -1824,3 +1817,96 @@ class TestWorkflowPauseEventsCases: assert isinstance(responses[-1], WorkflowPauseStreamResponse) pause_resp = responses[-1] assert pause_resp.data.reasons[0]["inputs"][0]["option_source"]["value"] == ["approve", "reject"] + + +@pytest.mark.parametrize("sqlite_session", [(HumanInputForm, HumanInputFormRecipient)], indirect=True) +def test_mixed_human_input_versions_preserve_order_and_wire_payloads(sqlite_pause_session: Session): + from pydantic import SecretStr + + from core.human_input_v2.resolved_form import ResolvedForm, SelectInput + from core.human_input_v2.shared.values import TenantId + from core.workflow.nodes.human_input.enums import HumanInputFormKind, HumanInputFormStatus + from core.workflow.nodes.human_input_v2.presentation import build_human_input_v2_pause_reason + from core.workflow.nodes.human_input_v2.runtime import PreparedForm + from graphon.entities.pause_reason import SchedulingPause + from repositories.human_input_v2.form_repository import Form + + _persist_human_input_form(sqlite_pause_session) + now = datetime(2024, 1, 1) + form = Form( + id="form-2", + tenant_id=TenantId("tenant-id"), + app_id="app-id", + workflow_run_id="run-id", + node_execution_id="execution-2", + form_kind=HumanInputFormKind.RUNTIME, + status=HumanInputFormStatus.WAITING, + created_at=now, + updated_at=now, + expiration_time=now, + global_timeout_deadline=now, + submission=None, + resolved_form=ResolvedForm( + title="Frozen V2", + legacy_form_content="Choose", + actions=(), + parts=(SelectInput(output_variable_name="choice", options=("Frozen",), default_value=None),), + ), + ) + v1_reason = HumanInputRequired( + form_id="form-1", + node_id="node-1", + node_title="V1", + form_content="Choose", + inputs=[ + SelectInputConfig( + output_variable_name="choice", + option_source=StringListSource( + type=ValueSourceType.VARIABLE, + selector=["upstream", "options"], + ), + ) + ], + ) + event = QueueWorkflowPausedEvent( + reasons=[ + build_human_input_v2_pause_reason(form.resolved_form, form_id=form.id, node_id="node-2"), + SchedulingPause(message="scheduled"), + v1_reason, + ], + paused_nodes=["node-2", "node-1"], + human_input_v2_forms={form.id: PreparedForm(form, SecretStr("v2-token"))}, + ) + state = RuntimeState(workflow_id="workflow", variable_pool=VariablePool(), start_at=0) + state.variable_pool.add(["upstream", "options"], ["One", "Two"]) + converter = _workflow_pause_events_build_converter() + converter.workflow_start_to_stream_response( + task_id="task", + workflow_run_id="run-id", + workflow_id="workflow-id", + reason=WorkflowStartReason.INITIAL, + ) + responses = converter.workflow_pause_to_stream_response(event=event, task_id="task", graph_runtime_state=state) + v2, v1, paused = [response.model_dump(mode="json")["data"] for response in responses] + assert [v2["form_id"], v1["form_id"]] == ["form-2", "form-1"] + assert v2["inputs"][0]["option_source"]["value"] == ["Frozen"] + assert v1["inputs"][0]["option_source"]["value"] == ["One", "Two"] + assert v2["form_token"] == "v2-token" + assert v1["form_token"] is None + reasons = paused["reasons"] + assert reasons[0]["form_id"] == "form-2" + assert reasons[1]["message"] == "scheduled" + assert reasons[2]["form_id"] == "form-1" + assert "display_in_ui" in reasons[0] + assert "display_in_ui" not in reasons[2] + assert v2["form_version"] == "2" + assert v1["form_version"] == "1" + assert reasons[0]["form_version"] == "2" + assert reasons[2]["form_version"] == "1" + + +@pytest.fixture +def sqlite_pause_session(sqlite_session: Session, monkeypatch: pytest.MonkeyPatch) -> Session: + """Bind pause-response queries to the shared SQLite session's database.""" + monkeypatch.setattr(workflow_response_converter, "db", SimpleNamespace(engine=sqlite_session.get_bind())) + return sqlite_session diff --git a/api/tests/unit_tests/core/app/apps/test_workflow_app_runner.py b/api/tests/unit_tests/core/app/apps/test_workflow_app_runner.py index cd00141bb80..4ffc0bde565 100644 --- a/api/tests/unit_tests/core/app/apps/test_workflow_app_runner.py +++ b/api/tests/unit_tests/core/app/apps/test_workflow_app_runner.py @@ -665,7 +665,7 @@ class TestWorkflowAppRunnerCoreCases: emails.append({"kwargs": kwargs, "queue": queue}) monkeypatch.setattr( - "core.app.apps.workflow_app_runner.dispatch_human_input_email_task", + "core.app.apps.workflow_app_runner.dispatch_human_input_form_delivery_task", _Dispatch(), ) enriched_reason = HumanInputRequired( @@ -1068,7 +1068,7 @@ class TestWorkflowAppRunnerNotificationsCases: node_id="node-1", node_title="Review", ) - monkeypatch.setattr("core.app.apps.workflow_app_runner.dispatch_human_input_email_task", email_task) + monkeypatch.setattr("core.app.apps.workflow_app_runner.dispatch_human_input_form_delivery_task", email_task) runner.handle_event(workflow_entry, event, pause_reasons=[enriched_reason]) diff --git a/api/tests/unit_tests/core/app/apps/workflow/test_generate_task_pipeline.py b/api/tests/unit_tests/core/app/apps/workflow/test_generate_task_pipeline.py index 6be87f0ebab..d0568e7cabd 100644 --- a/api/tests/unit_tests/core/app/apps/workflow/test_generate_task_pipeline.py +++ b/api/tests/unit_tests/core/app/apps/workflow/test_generate_task_pipeline.py @@ -241,6 +241,7 @@ class TestGenerateTaskPipelineCoreCases: assert response.data.reasons == [ { "TYPE": "human_input_required", + "form_version": "1", "form_id": "form-1", "node_id": "node-1", "node_title": "Human Input", diff --git a/api/tests/unit_tests/core/app/llm/test_quota.py b/api/tests/unit_tests/core/app/llm/test_quota.py index 2a0c413a103..9c209f97e3a 100644 --- a/api/tests/unit_tests/core/app/llm/test_quota.py +++ b/api/tests/unit_tests/core/app/llm/test_quota.py @@ -5,7 +5,7 @@ from unittest.mock import ANY, MagicMock, patch from uuid import UUID import pytest -from sqlalchemy import create_engine, select +from sqlalchemy import select from sqlalchemy.engine import Engine from sqlalchemy.orm import sessionmaker @@ -482,7 +482,7 @@ def test_deduct_llm_quota_for_model_uses_identity_based_trial_billing() -> None: ) -def test_deduct_llm_quota_for_model_caps_trial_pool_when_usage_exceeds_remaining() -> None: +def test_deduct_llm_quota_for_model_caps_trial_pool_when_usage_exceeds_remaining(sqlite_engine: Engine) -> None: usage = LLMUsage.empty_usage() usage.total_tokens = 3 provider_configuration = _provider_configuration( @@ -502,8 +502,7 @@ def test_deduct_llm_quota_for_model_caps_trial_pool_when_usage_exceeds_remaining ), ) provider_manager = _provider_manager(provider_configuration) - engine = create_engine("sqlite:///:memory:") - TenantCreditPool.__table__.create(engine) + engine = sqlite_engine with engine.begin() as connection: connection.execute( TenantCreditPool.__table__.insert(), @@ -707,7 +706,7 @@ def test_deduct_llm_quota_for_model_uses_paid_billing_pool() -> None: ) -def test_deduct_llm_quota_for_model_updates_free_quota_usage() -> None: +def test_deduct_llm_quota_for_model_updates_free_quota_usage(sqlite_engine: Engine) -> None: usage = LLMUsage.empty_usage() usage.total_tokens = 3 provider_configuration = _provider_configuration( @@ -727,8 +726,7 @@ def test_deduct_llm_quota_for_model_updates_free_quota_usage() -> None: ), ) provider_manager = _provider_manager(provider_configuration) - engine = create_engine("sqlite:///:memory:") - Provider.__table__.create(engine) + engine = sqlite_engine with engine.begin() as connection: connection.execute( Provider.__table__.insert(), @@ -820,7 +818,9 @@ def test_deduct_llm_quota_for_model_updates_free_quota_usage() -> None: assert exhausted_quota_used == 13 -def test_deduct_llm_quota_for_model_caps_free_quota_and_raises_when_usage_exceeds_remaining() -> None: +def test_deduct_llm_quota_for_model_caps_free_quota_and_raises_when_usage_exceeds_remaining( + sqlite_engine: Engine, +) -> None: usage = LLMUsage.empty_usage() usage.total_tokens = 3 provider_configuration = _provider_configuration( @@ -840,8 +840,7 @@ def test_deduct_llm_quota_for_model_caps_free_quota_and_raises_when_usage_exceed ), ) provider_manager = _provider_manager(provider_configuration) - engine = create_engine("sqlite:///:memory:") - Provider.__table__.create(engine) + engine = sqlite_engine with engine.begin() as connection: connection.execute( Provider.__table__.insert(), diff --git a/api/tests/unit_tests/core/human_input_v2/im_message_inbox/test_contracts.py b/api/tests/unit_tests/core/human_input_v2/im_message_inbox/test_contracts.py new file mode 100644 index 00000000000..8578b3b371d --- /dev/null +++ b/api/tests/unit_tests/core/human_input_v2/im_message_inbox/test_contracts.py @@ -0,0 +1,50 @@ +"""Contract tests for durable IM callback facts.""" + +from datetime import datetime + +from core.human_input_v2.entities import IMProvider +from core.human_input_v2.im_integration.adapters import AuthenticatedIMEvent, IMEventIngressKind +from core.human_input_v2.im_message_inbox import IMInboxRecord, IMInboxRecordId, canonicalize_inbox_event +from repositories.human_input_v2.im_channel_repository import IMChannelId + + +def _event(*, event_id: str | None = "event-1") -> AuthenticatedIMEvent: + return AuthenticatedIMEvent( + provider=IMProvider.FEISHU, + provider_tenant_id="tenant-1", + event_id=event_id, + occurred_at=datetime(2026, 8, 2, 8), + received_at=datetime(2026, 8, 2, 8, 0, 1), + event_type="card.action", + ingress_kind=IMEventIngressKind.WEBHOOK, + payload=' {"token":"secret","nested":[1,true]}\n', + ) + + +def test_inbox_record_preserves_callback_facts_verbatim() -> None: + event = _event() + record = IMInboxRecord( + record_id=IMInboxRecordId("record-1"), + channel_id=IMChannelId("channel-1"), + event=event, + processed_at=None, + ) + + assert record.event is event + assert record.event.payload == ' {"token":"secret","nested":[1,true]}\n' + assert record.processed_at is None + + +def test_blank_provider_event_id_is_canonicalized_as_unidentified() -> None: + event = _event(event_id=" \t\n") + + canonical = canonicalize_inbox_event(event) + + assert canonical.event_id is None + assert canonical.payload == event.payload + + +def test_nonblank_provider_event_id_is_preserved_verbatim() -> None: + event = _event(event_id=" event-1 ") + + assert canonicalize_inbox_event(event) is event diff --git a/api/tests/unit_tests/core/human_input_v2/im_provider/fixtures/feishu_lark_card_action_stream.json b/api/tests/unit_tests/core/human_input_v2/im_provider/fixtures/feishu_lark_card_action_stream.json new file mode 100644 index 00000000000..2c794a1c245 --- /dev/null +++ b/api/tests/unit_tests/core/human_input_v2/im_provider/fixtures/feishu_lark_card_action_stream.json @@ -0,0 +1,73 @@ +{ + "transport_evidence": { + "callback": "register_p2_card_action_trigger", + "kind": "official_sdk_stream", + "payload_boundary": "sdk_object_to_authenticated_event", + "source": "sanitized_real_callback" + }, + "captured_at": "2026-08-12T10:00:02+00:00", + "stream": { + "callback": "register_p2_card_action_trigger", + "object_type": "lark_oapi.event.callback.model.p2_card_action_trigger.P2CardActionTrigger", + "sdk_serialization": { + "method": "lark_oapi.JSON.marshal", + "value": "{\"schema\":\"2.0\",\"header\":{\"app_id\":\"cli_test_only\",\"create_time\":\"1786473269367799\",\"event_id\":\"evt_test_only_stream\",\"event_type\":\"card.action.trigger\",\"tenant_key\":\"tenant_test_only\",\"token\":\"verification_test_only\"},\"event\":{\"action\":{\"form_value\":{\"comment\":\"Sanitized callback input\",\"decision\":\"ship\"},\"name\":\"approve\",\"tag\":\"button\",\"timezone\":\"Asia/Shanghai\",\"value\":{\"__dify.human_input.action\":{\"version\":1,\"action_id\":\"approve\",\"correlation_token\":\"feishu-lark-card-evidence-token\"}}},\"context\":{\"open_chat_id\":\"chat_test_only\",\"open_message_id\":\"message_test_only\"},\"host\":\"im_message\",\"operator\":{\"open_id\":\"open_test_only\",\"tenant_key\":\"tenant_test_only\",\"union_id\":\"union_test_only\",\"user_id\":\"user_test_only\"},\"token\":\"callback_test_only\"}}" + }, + "callback_response": { + "object_type": "lark_oapi.event.callback.model.p2_card_action_trigger.P2CardActionTriggerResponse", + "sdk_serialization": { + "method": "lark_oapi.JSON.marshal", + "value": "{}" + } + } + }, + "authenticated_event": { + "provider": "feishu", + "provider_tenant_id": "tenant_test_only", + "event_id": "evt_test_only_stream", + "event_type": "card.action.trigger", + "occurred_at": "2026-08-11T18:34:29.367799", + "received_at": "2026-08-12T10:00:00", + "payload": { + "schema": "2.0", + "header": { + "app_id": "cli_test_only", + "create_time": "1786473269367799", + "event_id": "evt_test_only_stream", + "event_type": "card.action.trigger", + "tenant_key": "tenant_test_only", + "token": "verification_test_only" + }, + "event": { + "action": { + "form_value": { + "comment": "Sanitized callback input", + "decision": "ship" + }, + "name": "approve", + "tag": "button", + "timezone": "Asia/Shanghai", + "value": { + "__dify.human_input.action": { + "version": 1, + "action_id": "approve", + "correlation_token": "feishu-lark-card-evidence-token" + } + } + }, + "context": { + "open_chat_id": "chat_test_only", + "open_message_id": "message_test_only" + }, + "host": "im_message", + "operator": { + "open_id": "open_test_only", + "tenant_key": "tenant_test_only", + "union_id": "union_test_only", + "user_id": "user_test_only" + }, + "token": "callback_test_only" + } + } + } +} diff --git a/api/tests/unit_tests/core/human_input_v2/im_provider/fixtures/feishu_lark_card_action_webhook.json b/api/tests/unit_tests/core/human_input_v2/im_provider/fixtures/feishu_lark_card_action_webhook.json new file mode 100644 index 00000000000..9a9fe1a1d33 --- /dev/null +++ b/api/tests/unit_tests/core/human_input_v2/im_provider/fixtures/feishu_lark_card_action_webhook.json @@ -0,0 +1,157 @@ +{ + "transport_evidence": { + "callback_path": "/im-callback/feishu", + "kind": "encrypted_webhook", + "payload_boundary": "signed_http_envelope_to_authenticated_event", + "source": "sanitized_real_callback" + }, + "captured_at": "2026-08-12T10:00:01+00:00", + "http": { + "request": { + "method": "POST", + "headers": [ + { + "name": "Host", + "value": "dev-w.example.invalid" + }, + { + "name": "User-Agent", + "value": "Feishu-Webhook/test" + }, + { + "name": "Content-Length", + "value": "1062" + }, + { + "name": "Accept", + "value": "*/*" + }, + { + "name": "Accept-Encoding", + "value": "gzip" + }, + { + "name": "Cdn-Loop", + "value": "sanitized" + }, + { + "name": "Cf-Connecting-Ip", + "value": "192.0.2.1" + }, + { + "name": "Cf-Ipcountry", + "value": "ZZ" + }, + { + "name": "Cf-Ray", + "value": "ray_test_only" + }, + { + "name": "Cf-Visitor", + "value": "{\"scheme\":\"https\"}" + }, + { + "name": "Cf-Warp-Tag-Id", + "value": "warp_test_only" + }, + { + "name": "Content-Type", + "value": "application/json" + }, + { + "name": "Via", + "value": "sanitized-proxy" + }, + { + "name": "X-Forwarded-For", + "value": "192.0.2.1" + }, + { + "name": "X-Forwarded-Host", + "value": "dev-w.example.invalid" + }, + { + "name": "X-Forwarded-Proto", + "value": "https" + }, + { + "name": "X-Lark-Request-Nonce", + "value": "nonce_test_only" + }, + { + "name": "X-Lark-Request-Timestamp", + "value": "1786473269" + }, + { + "name": "X-Lark-Signature", + "value": "e849f6eeb42d0fc415c95fc09070928b222a0ad8534b4efa142b9b29e4c5ffbb" + }, + { + "name": "X-Tt-Logid", + "value": "log_test_only" + } + ], + "body": "{\"encrypt\":\"AAECAwQFBgcICQoLDA0OD3q+5O5iO1M4nZ5xMWi/ucZ/MdtAhXm3NZqATzdYs1MoxI4ZXhYmH3UdEvSQzCfg7LNBKh6OZr/uia2nySsHH7fnl8BM3jQIH7IJ0XHFr4wTL6rAyZiGYvGhKZCUbfRhPn6hXqj0a7Y7FKpG5wh6qWntETA0i8964KmPfluzNd6jgX6ZRrCgetLRNwjBzZjQ427iAax6JIsXaMyh3n3S7PFU+mwHTYtvmTZmBH5esWbFIvOZPTV0BrxbdlHT3A1YWjlxEmmaBUJr6JEXsDDHc6tYjLukAy7wcB7VV8eN2u46BXfyc5c1O6a2aM6iMcokA3EC9gY4PnqzGetIKp/Zr4HwCdMPrRiHxYGaCjdnsuqeuu+/p97iscPbfdpjrvy8ig1u9U/0qgyMww4ERsbCxKiQ32MKhYMKRrpCI2HcF8xZl8QI4l7BcfqFGvxnmbaKRe0DRTbIBubu1ba1qJ56MhI+lCL6lxObmYl4Myzq9pZHumtskszH365qSM64CXVe+if+WFPM+C6M7gyP0DiPuAnQNbNPZdBzWaqxfubgtmpKpXws9SP5b8Sg3Syl4ocmYq+pXO3tFiT9oWnpVm/2MlSy0T6qOjTy7RJEgwMI7KAqeJzyaxrZgcqIcLYu5AXVuhEtdYcYdU+LTkBUKNOP4pJqsNxgpVqMrleHpDod7cvhTuGrTOc/n0Jtty+K7SlIQpbmNyrHfZmkvAn4kpNVUk7nsqnMfvaKEl+zPehtH0KiApjgfhhr8cw2B2B3mKw2ld6nxNxdphwEeG1x0eZTvybV45hciQ29fd5UjCEHzEaazAfhyS/TIPlCA8RRTTcZPYzB9NrtMqxrdiN7cwQuwnSg1cIdI4BwEl7kFcZju3MYLHkLD6N0Agf8egsbrZXMLpaID7GK/cZDu8kMIwgs+X9NsxIKT470cPI0phccfk/3FonALzOzRFNkrHMzmECGZl2dYQTQsKUsEPwm5y0Jc0hVvD5RytdLH6CXZXjlCU8fh2HCHlBGMrEvbxC8qfJK/w==\"}", + "received_at": "2026-08-12T10:00:00" + }, + "response": { + "status": 200, + "headers": [ + { + "name": "Content-Type", + "value": "application/json" + } + ], + "body": "{\"code\":0}" + } + }, + "authenticated_event": { + "provider": "feishu", + "provider_tenant_id": "tenant_test_only", + "event_id": "evt_test_only_webhook", + "event_type": "card.action.trigger", + "occurred_at": "2026-08-11T18:34:29.367799", + "received_at": "2026-08-12T10:00:00", + "payload": { + "schema": "2.0", + "header": { + "app_id": "cli_test_only", + "create_time": "1786473269367799", + "event_id": "evt_test_only_webhook", + "event_type": "card.action.trigger", + "tenant_key": "tenant_test_only", + "token": "verification_test_only" + }, + "event": { + "action": { + "form_value": { + "comment": "Sanitized callback input", + "decision": "ship" + }, + "name": "approve", + "tag": "button", + "timezone": "Asia/Shanghai", + "value": { + "__dify.human_input.action": { + "version": 1, + "action_id": "approve", + "correlation_token": "feishu-lark-card-evidence-token" + } + } + }, + "context": { + "open_chat_id": "chat_test_only", + "open_message_id": "message_test_only" + }, + "host": "im_message", + "operator": { + "open_id": "open_test_only", + "tenant_key": "tenant_test_only", + "union_id": "union_test_only", + "user_id": "user_test_only" + }, + "token": "callback_test_only" + } + } + } +} diff --git a/api/tests/unit_tests/core/human_input_v2/im_provider/fixtures/feishu_lark_card_action_without_inputs.json b/api/tests/unit_tests/core/human_input_v2/im_provider/fixtures/feishu_lark_card_action_without_inputs.json new file mode 100644 index 00000000000..5bf5497e1dc --- /dev/null +++ b/api/tests/unit_tests/core/human_input_v2/im_provider/fixtures/feishu_lark_card_action_without_inputs.json @@ -0,0 +1,50 @@ +{ + "transport_evidence": { + "kind": "authenticated_webhook", + "payload_boundary": "durable_inbox_to_card_decoder", + "source": "sanitized_hitl_dev_callback" + }, + "captured_at": "2026-10-22T07:39:41.203169+00:00", + "authenticated_event": { + "provider": "feishu", + "provider_tenant_id": "tenant_test_only", + "event_id": "evt_no_inputs_test_only", + "event_type": "card.action.trigger", + "payload": { + "schema": "2.0", + "header": { + "event_id": "evt_no_inputs_test_only", + "token": "verification_test_only", + "create_time": "1792654781203169", + "event_type": "card.action.trigger", + "tenant_key": "tenant_test_only", + "app_id": "cli_test_only" + }, + "event": { + "operator": { + "tenant_key": "tenant_test_only", + "open_id": "open_test_only", + "union_id": "union_test_only" + }, + "token": "callback_test_only", + "action": { + "value": { + "__dify.human_input.action": { + "action_id": "confirm", + "correlation_token": "no-inputs-correlation-token-test-only", + "version": 1 + } + }, + "tag": "button", + "timezone": "Asia/Shanghai", + "name": "confirm" + }, + "host": "im_message", + "context": { + "open_message_id": "message_test_only", + "open_chat_id": "chat_test_only" + } + } + } + } +} diff --git a/api/tests/unit_tests/core/human_input_v2/im_provider/fixtures/lark/README.md b/api/tests/unit_tests/core/human_input_v2/im_provider/fixtures/lark/README.md new file mode 100644 index 00000000000..b0318a45538 --- /dev/null +++ b/api/tests/unit_tests/core/human_input_v2/im_provider/fixtures/lark/README.md @@ -0,0 +1,122 @@ +# Lark unit test protocol fixtures + +`sanitized_protocol.json` contains 23 redacted HTTP exchanges captured on +2026-10-14 and 2026-10-15 from `https://open.larksuite.com` using the local `temp/im.env` Lark +credentials. Successful messages and card updates were sent through the actual +`LarkIMProviderAdapter` and official SDK. Raw envelopes remain under +`temp/fixtures/` in both the original workspace and capture worktree and are excluded from Git. +The raw index is `temp/fixtures/README.md`; the directory capture list is +`temp/lark-directory-manifest.json`. Raw files and the list were copied byte +for byte. Tests load only the redacted JSON beside this README and require no +`temp` directory, capture scripts, credentials, or regeneration step: + +| Fixture key | Local source capture | +| --- | --- | +| `token` | `lark-021-directory-tenant.json` | +| `tenant` | `lark-022-directory-tenant.json` | +| `scope` | `lark-023-directory-full-scope.json` | +| `users` | `lark-024-directory-full-users.json` | +| `departments` | `lark-025-directory-full-departments.json` | +| `scope_restricted` | `lark-007-scope-read.json` | +| `users_denied` | `lark-008-directory-users.json` | +| `departments_denied` | `lark-009-directory-departments.json` | +| `user_detail` | `lark-010-directory-user-detail.json` | +| `invalid_credentials` | `lark-012-invalid-credentials.json` | +| `text` | `lark-013-message-send-text.json` | +| `card` | `lark-014-message-send-card.json` | +| `replace` | `lark-015-message-update-card.json` | +| `invalid_recipient` | `lark-016-message-invalid-recipient.json` | +| `invalid_reference` | `lark-017-message-stale-reference.json` | +| `invalid_token` | `lark-018-tenant-invalid-token.json` | +| `stream_connect` | `lark-032-card-stream-prepare.json` | +| `bot_info` | `lark-stream-bot-info.json` | +| `stream_connect_invalid` | `lark-stream-invalid-credentials.json` | +| `stream_card` | `lark-037-card-send-stream.json` | +| `stream_replace` | `lark-040-card-update-stream.json` | +| `webhook_card` | `lark-047-card-send-webhook.json` | +| `webhook_replace` | `lark-050-card-update-webhook.json` | + +| Callback fixture | Local source capture | +| --- | --- | +| `sanitized_card_action_stream.json` | `lark-038-card-action-stream.json` | +| `sanitized_stream_ack.json` | `lark-039-stream-ack.json` | +| `sanitized_url_challenge.json` | `lark-041-webhook-ingress.json` | +| `sanitized_card_action_webhook.json` | `lark-049-card-action-webhook.json` | + +Methods, API domains and paths, query parameters, JSON keys, nesting, scalar +types, booleans, empty strings, and field presence are preserved. Credentials, +identifiers, email addresses, timestamps, provider diagnostics, and tenant +profile strings are replaced. Request headers and all response headers except +`Content-Type` are omitted. Controlled test message and card content is retained, +including nested JSON strings. Identifiers are replaced consistently across +requests and responses so message locators can be exercised through card updates. + +The application initially authorized one user. Root user and department listing +returned HTTP 403, code `40004`; these captures remain as negative cases. +After its directory scope was expanded on 2026-10-15, both root endpoints +returned HTTP 200, code `0`, and the actual adapter credential test succeeded. +The new user page contains two users. The successful scope response still omits +`department_ids`, and the empty terminal department page omits `items`. +Credential and directory success tests now replay these complete captured +pages; no synthetic successful root page is used. The invalid reference +capture returns HTTP 400, code `99992354`, which is a generic reference rejection, +not one of the adapter's stale-message codes. The invalid tenant token returns +code `99991663` and is classified as an authentication rejection. + +The captured `user_detail` object omits `name`, `enterprise_email`, and +`avatar`. The later root-list response likewise omits those fields for both +users. Its directory test checks their captured order, email projection, and +missing names and avatars. Another test combines the successful user page with +the earlier department rejection to verify that failure discards accumulated +users. Missing-message-ID cases remain explicit synthetic negative responses. + +`test_lark_sdk_boundary.py` replays these fixtures at `requests.Session.send` +while retaining the real Lark adapter, generated SDK requests, token exchange, +and response parsing. Each test uses an isolated SDK token cache. Unexpected +requests fail locally, including when adapter error handling catches the +transport exception; the tests never make network requests or load credentials. + +The stream discovery test also replays the real `/callback/ws/endpoint` response +and asynchronous bot identity lookup. It runs the official channel and SDK +startup/stop lifecycle, replacing only HTTP dispatch and the WebSocket +connection. The endpoint's host, ticket, device identifier, and query values are +redacted, while query field names and `ClientConfig` fields are preserved. +An invalid-secret endpoint capture retains provider code `1000040345`. Its +test verifies a safe startup failure, release of owned resources, and no socket +connection after the provider rejects discovery. + +Shared Feishu/Lark adapter, Webhook, and STREAM behavior tests run against both +typed provider wrappers. This includes authentication failures, strict complete +directory reads, avatar downloads, ambiguous sends, persisted message references, +signature/encryption rejection, consumer acceptance, wire ACK draining, shutdown +failures, and independent resource lifecycles. Synthetic callback contract tests +also run for both provider discriminators and both ingress kinds, covering +private-marker recognition, required submitted facts, safe diagnostics, and +union-ID actor identity. The historical Feishu callback captures retain their +Feishu provenance; they are not presented as real Lark callback captures. + +The two card-action captures came from separate real Lark Messenger form +submissions on 2026-10-15. Both submitted `comment=Ready to ship` and the +configured default `decision=hold`; STREAM selected `reject`, and Webhook +selected `approve`. Each original message was replaced through the real API +after its callback was accepted. The test application was restored and +published in long-connection mode, version `1.0.3`, after capture. Application +permissions and member visibility were unchanged. + +Webhook fixtures retain the encrypted-envelope shape and signing-header family. +The redacted native callback is encrypted again with a deterministic test-only +key and IV, and its signature is recomputed. The encrypted URL challenge uses +the same test key and verification token and retains the observed absence of +business-event signing headers. Original ciphertext, headers, and response bytes +remain untouched in the local source captures. + +STREAM fixtures retain the actual SDK callback class and callback-response +serialization. The ACK is a redacted protobuf frame whose original payload is +`{"code":200,"data":"e30="}`. Replay reconstructs an incoming frame using +the captured delivery-header shape and SDK event serialization; this +reconstructed incoming frame is not claimed to be original wire bytes. The +real dispatcher, channel, event stream, consumer, and response serializer run +before the captured ACK shape is compared. Consumer rejection must produce +an error response rather than a successful ACK. The tests also exercise +sender-to-decoder metadata agreement, exact native payload preservation, +signature/token/tenant rejection, and updating the original message reference. diff --git a/api/tests/unit_tests/core/human_input_v2/im_provider/fixtures/lark/sanitized_card_action_stream.json b/api/tests/unit_tests/core/human_input_v2/im_provider/fixtures/lark/sanitized_card_action_stream.json new file mode 100644 index 00000000000..225c0593384 --- /dev/null +++ b/api/tests/unit_tests/core/human_input_v2/im_provider/fixtures/lark/sanitized_card_action_stream.json @@ -0,0 +1,28 @@ +{ + "provider": "lark", + "authenticated_event": { + "provider": "lark", + "provider_tenant_id": "tenant_lark_sanitized", + "event_id": "evt_lark_stream", + "event_type": "card.action.trigger", + "occurred_at": "2026-10-15T00:00:00", + "received_at": "2026-10-15T00:00:01", + "ingress_kind": "stream", + "payload": "{\"schema\": \"2.0\", \"header\": {\"event_id\": \"evt_lark_stream\", \"token\": \"sanitized-lark-verification-token\", \"create_time\": \"1792022400000000\", \"event_type\": \"card.action.trigger\", \"tenant_key\": \"tenant_lark_sanitized\", \"app_id\": \"cli_sanitized_lark_app\"}, \"event\": {\"operator\": {\"tenant_key\": \"tenant_lark_sanitized\", \"user_id\": \"lark_user\", \"open_id\": \"open_lark_user\", \"union_id\": \"union_lark_user\"}, \"token\": \"sanitized\", \"action\": {\"value\": {\"__dify.human_input.action\": {\"action_id\": \"reject\", \"correlation_token\": \"lark-live-stream-correlation\", \"version\": 1}}, \"tag\": \"button\", \"timezone\": \"sanitized\", \"name\": \"reject\", \"form_value\": {\"comment\": \"Ready to ship\", \"decision\": \"hold\"}}, \"host\": \"sanitized\", \"context\": {\"open_message_id\": \"om_lark_stream_card\", \"open_chat_id\": \"oc_lark_chat\"}}}" + }, + "stream": { + "callback": "register_p2_card_action_trigger", + "object_type": "lark_oapi.event.callback.model.p2_card_action_trigger.P2CardActionTrigger", + "sdk_serialization": { + "method": "lark_oapi.JSON.marshal", + "value": "{\"schema\": \"2.0\", \"header\": {\"event_id\": \"evt_lark_stream\", \"token\": \"sanitized-lark-verification-token\", \"create_time\": \"1792022400000000\", \"event_type\": \"card.action.trigger\", \"tenant_key\": \"tenant_lark_sanitized\", \"app_id\": \"cli_sanitized_lark_app\"}, \"event\": {\"operator\": {\"tenant_key\": \"tenant_lark_sanitized\", \"user_id\": \"lark_user\", \"open_id\": \"open_lark_user\", \"union_id\": \"union_lark_user\"}, \"token\": \"sanitized\", \"action\": {\"value\": {\"__dify.human_input.action\": {\"action_id\": \"reject\", \"correlation_token\": \"lark-live-stream-correlation\", \"version\": 1}}, \"tag\": \"button\", \"timezone\": \"sanitized\", \"name\": \"reject\", \"form_value\": {\"comment\": \"Ready to ship\", \"decision\": \"hold\"}}, \"host\": \"sanitized\", \"context\": {\"open_message_id\": \"om_lark_stream_card\", \"open_chat_id\": \"oc_lark_chat\"}}}" + }, + "callback_response": { + "object_type": "lark_oapi.event.callback.model.p2_card_action_trigger.P2CardActionTriggerResponse", + "sdk_serialization": { + "method": "lark_oapi.JSON.marshal", + "value": "{}" + } + } + } +} diff --git a/api/tests/unit_tests/core/human_input_v2/im_provider/fixtures/lark/sanitized_card_action_webhook.json b/api/tests/unit_tests/core/human_input_v2/im_provider/fixtures/lark/sanitized_card_action_webhook.json new file mode 100644 index 00000000000..6eb59230279 --- /dev/null +++ b/api/tests/unit_tests/core/human_input_v2/im_provider/fixtures/lark/sanitized_card_action_webhook.json @@ -0,0 +1,42 @@ +{ + "provider": "lark", + "authenticated_event": { + "provider": "lark", + "provider_tenant_id": "tenant_lark_sanitized", + "event_id": "evt_lark_webhook", + "event_type": "card.action.trigger", + "occurred_at": "2026-10-15T00:00:00", + "received_at": "2026-10-15T00:00:01", + "ingress_kind": "webhook", + "payload": "{\"schema\":\"2.0\",\"header\":{\"event_id\":\"evt_lark_webhook\",\"token\":\"sanitized-lark-verification-token\",\"create_time\":\"1792022400000000\",\"event_type\":\"card.action.trigger\",\"tenant_key\":\"tenant_lark_sanitized\",\"app_id\":\"cli_sanitized_lark_app\"},\"event\":{\"operator\":{\"tenant_key\":\"tenant_lark_sanitized\",\"user_id\":\"lark_user\",\"open_id\":\"open_lark_user\",\"union_id\":\"union_lark_user\"},\"token\":\"sanitized\",\"action\":{\"value\":{\"__dify.human_input.action\":{\"action_id\":\"approve\",\"correlation_token\":\"lark-live-webhook-correlation\",\"version\":1}},\"tag\":\"button\",\"timezone\":\"sanitized\",\"form_value\":{\"comment\":\"Ready to ship\",\"decision\":\"hold\"},\"name\":\"approve\"},\"host\":\"sanitized\",\"context\":{\"open_message_id\":\"om_lark_webhook_card\",\"open_chat_id\":\"oc_lark_chat\"}}}" + }, + "http": { + "request": { + "method": "POST", + "received_at": "2026-10-15T00:00:01", + "body": "{\"encrypt\":\"AAECAwQFBgcICQoLDA0OD5wnmENS9+lqxshW8gxtx5niZMw4MhA1cX75hD2E3MRv6Q/sv/J/P7A1uv9j3nHAzYC5ysS6FOorDf04qf0jLfzWkkE5O93ttI8OzyGyScmK/qAlLL1Jek9iI0cFtxDKSd42VWKa16QOq8NwVz/wHO4gcf3k/ZNpWuVTVq/O5xMGF2FEbVn3eaOawamQH1bDkmoGk5+n7za9S4MStDQLkZb1b73HcJVBKhLRLfDPB4bB4dhDX+1lC3aZkj23VMCLdAB1HATpmikhN+7YU11Uh7HZtWXs1AcQVwHgTDC0aJGb3twIGE0nwvMltqPKArNvPryyrcnNx+bsO5ZPp6wYn4Y8oG8onFNyUn06bPXN+rA3FGA7GXfPP3m4zK7QnI/bLbnruthQJ8T5hOkswKxglUrP4AMykaCSiM5bnvc/nHfduIVzSZxPP+m4EMZNWPyVROCRcdw/vRro+WlQmuuy6+o1x7dAYSW4gSYM3GqD7YoeSLF4ohwvxRanlvUpefAOxjzk864djbjllFZgfPKHG6M+lXyp0usyn4mLFyW5plnCmVgzk/RDSQ4BDUhhVcrxN3AJPOse9NZtQCM2wahL6Uh3OJwqqNK8xG8obbxuG+nSg/RxkuCQuSiezF+yCRSpm8z9b7rNO7h8k/cxv48FYhq4n6syF0SfSK0Tll01iD1HPhcvmHWzIRvZe5GCGLBj7i1pI7IRT82rWZeBSfL6kwi1lTqNWynE9fNOUK5coF03ptCGX+aoV9IxBUVG0sHENWfpju8B7JKROPrgcmtWjgUD3N+fu2tiYJaFDcOSm3sHvsM4qrNMcgX4PsyUTq0RZPFeDWAgqR3UL8NZp2HOYtoVd31xyvoPsDAXAUmwDKs4Ki0ijtfQ/0vq7qoB7TxRQig7WkJKPoI87UJCewZ31f24X9raXBzF5ZHuha+TXlWo1zaekWhHXURSt+iKHvu73r6wB8BlJntTTMLKv2fYesJmhoxFQkoK1FACI1Wnm0wk\"}", + "headers": [ + [ + "Content-Type", + "application/json" + ], + [ + "X-Lark-Request-Timestamp", + "1792022400" + ], + [ + "X-Lark-Request-Nonce", + "lark-webhook-test-nonce" + ], + [ + "X-Lark-Signature", + "c5fca1ad9eebe665ced9803e9401af6dc5fbf88318bd9a05e9ecff7787662dcc" + ] + ] + }, + "response": { + "status": 200, + "body": "{\"code\":0}" + } + } +} diff --git a/api/tests/unit_tests/core/human_input_v2/im_provider/fixtures/lark/sanitized_protocol.json b/api/tests/unit_tests/core/human_input_v2/im_provider/fixtures/lark/sanitized_protocol.json new file mode 100644 index 00000000000..96441248d86 --- /dev/null +++ b/api/tests/unit_tests/core/human_input_v2/im_provider/fixtures/lark/sanitized_protocol.json @@ -0,0 +1,670 @@ +{ + "token": { + "request": { + "method": "POST", + "url": "https://open.larksuite.com/open-apis/auth/v3/tenant_access_token/internal", + "body": { + "app_id": "cli_sanitized_lark_app", + "app_secret": "sanitized-lark-app-secret" + } + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "application/json" + }, + "body": { + "code": 0, + "expire": 7200, + "msg": "ok", + "tenant_access_token": "t-sanitized-lark-access-token" + } + } + }, + "tenant": { + "request": { + "method": "GET", + "url": "https://open.larksuite.com/open-apis/tenant/v2/tenant/query", + "body": null + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "application/json; charset=utf-8" + }, + "body": { + "code": 0, + "data": { + "tenant": { + "avatar": { + "avatar_240": "sanitized", + "avatar_640": "sanitized", + "avatar_72": "sanitized", + "avatar_origin": "sanitized" + }, + "display_id": "sanitized", + "name": "sanitized", + "tenant_key": "tenant_lark_sanitized", + "tenant_tag": 0 + } + }, + "msg": "success" + } + } + }, + "scope": { + "request": { + "method": "GET", + "url": "https://open.larksuite.com/open-apis/contact/v3/scopes?page_size=50&department_id_type=department_id&user_id_type=union_id", + "body": null + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "application/json; charset=utf-8" + }, + "body": { + "code": 0, + "data": { + "has_more": false, + "user_ids": [ + "union_lark_user", + "union_lark_user_2" + ] + }, + "msg": "success" + } + } + }, + "users": { + "request": { + "method": "GET", + "url": "https://open.larksuite.com/open-apis/contact/v3/users/find_by_department?department_id=0&department_id_type=department_id&user_id_type=union_id&page_size=50", + "body": null + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "application/json; charset=utf-8" + }, + "body": { + "code": 0, + "data": { + "has_more": false, + "items": [ + { + "city": "", + "country": "", + "email": "lark-user@example.invalid", + "employee_no": "", + "employee_type": 1, + "is_tenant_manager": true, + "job_title": "", + "join_time": 0, + "mobile_visible": true, + "open_id": "open_lark_user", + "status": { + "is_activated": true, + "is_exited": false, + "is_frozen": false, + "is_resigned": false, + "is_unjoin": false + }, + "union_id": "union_lark_user", + "user_id": "lark_user", + "work_station": "" + }, + { + "city": "", + "country": "", + "email": "lark-user-2@example.invalid", + "employee_no": "", + "employee_type": 1, + "is_tenant_manager": false, + "job_title": "", + "join_time": 1800000000, + "mobile_visible": true, + "open_id": "open_lark_user_2", + "status": { + "is_activated": false, + "is_exited": false, + "is_frozen": false, + "is_resigned": false, + "is_unjoin": true + }, + "union_id": "union_lark_user_2", + "user_id": "lark_user_2", + "work_station": "" + } + ] + }, + "msg": "success" + } + } + }, + "departments": { + "request": { + "method": "GET", + "url": "https://open.larksuite.com/open-apis/contact/v3/departments/0/children?department_id_type=department_id&user_id_type=union_id&fetch_child=False&page_size=50", + "body": null + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "application/json; charset=utf-8" + }, + "body": { + "code": 0, + "data": { + "has_more": false + }, + "msg": "success" + } + } + }, + "scope_restricted": { + "request": { + "method": "GET", + "url": "https://open.larksuite.com/open-apis/contact/v3/scopes?page_size=50&department_id_type=department_id&user_id_type=union_id", + "body": null + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "application/json; charset=utf-8" + }, + "body": { + "code": 0, + "data": { + "has_more": false, + "user_ids": [ + "union_lark_user" + ] + }, + "msg": "success" + } + } + }, + "users_denied": { + "request": { + "method": "GET", + "url": "https://open.larksuite.com/open-apis/contact/v3/users/find_by_department?department_id=0&department_id_type=department_id&user_id_type=union_id&page_size=50", + "body": null + }, + "response": { + "status": 403, + "headers": { + "Content-Type": "application/json; charset=utf-8" + }, + "body": { + "code": 40004, + "msg": "sanitized", + "error": { + "log_id": "sanitized" + } + } + } + }, + "departments_denied": { + "request": { + "method": "GET", + "url": "https://open.larksuite.com/open-apis/contact/v3/departments/0/children?department_id_type=department_id&user_id_type=union_id&fetch_child=False&page_size=50", + "body": null + }, + "response": { + "status": 403, + "headers": { + "Content-Type": "application/json; charset=utf-8" + }, + "body": { + "code": 40004, + "msg": "sanitized", + "error": { + "log_id": "sanitized" + } + } + } + }, + "user_detail": { + "request": { + "method": "GET", + "url": "https://open.larksuite.com/open-apis/contact/v3/users/union_lark_user?user_id_type=union_id&department_id_type=department_id", + "body": null + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "application/json; charset=utf-8" + }, + "body": { + "code": 0, + "data": { + "user": { + "city": "", + "country": "", + "email": "lark-user@example.invalid", + "employee_no": "", + "employee_type": 1, + "is_tenant_manager": true, + "job_title": "", + "join_time": 0, + "mobile_visible": true, + "open_id": "open_lark_user", + "status": { + "is_activated": true, + "is_exited": false, + "is_frozen": false, + "is_resigned": false, + "is_unjoin": false + }, + "union_id": "union_lark_user", + "user_id": "lark_user", + "work_station": "" + } + }, + "msg": "success" + } + } + }, + "invalid_credentials": { + "request": { + "method": "POST", + "url": "https://open.larksuite.com/open-apis/auth/v3/tenant_access_token/internal", + "body": { + "app_id": "cli_sanitized_lark_app", + "app_secret": "invalid-test-secret" + } + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "application/json" + }, + "body": { + "code": 10014, + "msg": "sanitized" + } + } + }, + "text": { + "request": { + "method": "POST", + "url": "https://open.larksuite.com/open-apis/im/v1/messages?receive_id_type=union_id", + "body": { + "receive_id": "union_lark_user", + "msg_type": "text", + "content": "{\"text\":\"Lark protocol capture: unit tests\\n测试消息\"}" + } + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "application/json; charset=utf-8" + }, + "body": { + "code": 0, + "data": { + "body": { + "content": "{\"text\":\"Lark protocol capture: unit tests\\n测试消息\"}" + }, + "chat_id": "oc_lark_chat", + "create_time": "1800000000000", + "deleted": false, + "message_id": "om_lark_text", + "msg_type": "text", + "sender": { + "id": "cli_sanitized_lark_app", + "id_type": "app_id", + "sender_type": "app", + "tenant_key": "tenant_lark_sanitized" + }, + "update_time": "1800000000000", + "updated": false + }, + "msg": "success" + } + } + }, + "card": { + "request": { + "method": "POST", + "url": "https://open.larksuite.com/open-apis/im/v1/messages?receive_id_type=union_id", + "body": { + "receive_id": "union_lark_user", + "msg_type": "interactive", + "content": "{\"schema\":\"2.0\",\"config\":{\"update_multi\":true},\"body\":{\"direction\":\"vertical\",\"elements\":[{\"tag\":\"form\",\"name\":\"__dify.human_input\",\"elements\":[{\"tag\":\"markdown\",\"content\":\"Protocol capture for **Lark** unit tests.\"},{\"tag\":\"input\",\"name\":\"comment\",\"input_type\":\"multiline_text\",\"width\":\"fill\",\"required\":true,\"label\":{\"tag\":\"plain_text\",\"content\":\"comment\"},\"placeholder\":{\"tag\":\"plain_text\",\"content\":\"comment\"},\"default_value\":\"Initial comment\"},{\"tag\":\"select_static\",\"name\":\"decision\",\"required\":true,\"placeholder\":{\"tag\":\"plain_text\",\"content\":\"decision\"},\"options\":[{\"text\":{\"tag\":\"plain_text\",\"content\":\"Approve\"},\"value\":\"Approve\"},{\"text\":{\"tag\":\"plain_text\",\"content\":\"Reject\"},\"value\":\"Reject\"}],\"initial_option\":\"Approve\"},{\"tag\":\"column_set\",\"flex_mode\":\"none\",\"horizontal_spacing\":\"8px\",\"horizontal_align\":\"left\",\"columns\":[{\"tag\":\"column\",\"width\":\"auto\",\"elements\":[{\"tag\":\"button\",\"name\":\"approve\",\"type\":\"primary_filled\",\"text\":{\"tag\":\"plain_text\",\"content\":\"Approve\"},\"form_action_type\":\"submit\",\"behaviors\":[{\"type\":\"callback\",\"value\":{\"__dify.human_input.action\":{\"version\":1,\"action_id\":\"approve\",\"correlation_token\":\"lark-unit-test-correlation\"}}}]}]}]}]}]},\"header\":{\"title\":{\"tag\":\"plain_text\",\"content\":\"Lark unit test capture\"}}}" + } + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "application/json; charset=utf-8" + }, + "body": { + "code": 0, + "data": { + "body": { + "content": "{\"title\":\"Lark unit test capture\",\"elements\":[[{\"tag\":\"img\",\"image_key\":\"sanitized\"},{\"tag\":\"text\",\"text\":\"sanitized\"},{\"tag\":\"text\",\"text\":\"\"}]]}" + }, + "chat_id": "oc_lark_chat", + "create_time": "1800000000000", + "deleted": false, + "message_id": "om_lark_card", + "msg_type": "interactive", + "sender": { + "id": "cli_sanitized_lark_app", + "id_type": "app_id", + "sender_type": "app", + "tenant_key": "tenant_lark_sanitized" + }, + "update_time": "1800000000000", + "updated": false + }, + "msg": "success" + } + } + }, + "replace": { + "request": { + "method": "PATCH", + "url": "https://open.larksuite.com/open-apis/im/v1/messages/om_lark_card", + "body": { + "content": "{\"schema\":\"2.0\",\"body\":{\"elements\":[{\"tag\":\"markdown\",\"content\":\"Capture **completed**.\"}]}}" + } + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "application/json; charset=utf-8" + }, + "body": { + "code": 0, + "data": {}, + "msg": "success" + } + } + }, + "invalid_recipient": { + "request": { + "method": "POST", + "url": "https://open.larksuite.com/open-apis/im/v1/messages?receive_id_type=union_id", + "body": { + "receive_id": "union_lark_invalid", + "msg_type": "text", + "content": "{\"text\":\"Lark unit test invalid recipient\"}" + } + }, + "response": { + "status": 400, + "headers": { + "Content-Type": "application/json; charset=utf-8" + }, + "body": { + "code": 99992364, + "msg": "sanitized", + "error": { + "log_id": "sanitized", + "field_violations": [ + { + "field": "sanitized", + "value": "union_lark_invalid", + "description": "sanitized" + } + ] + } + } + } + }, + "invalid_reference": { + "request": { + "method": "PATCH", + "url": "https://open.larksuite.com/open-apis/im/v1/messages/om_lark_invalid", + "body": { + "content": "{\"schema\":\"2.0\",\"body\":{\"elements\":[{\"tag\":\"markdown\",\"content\":\"Capture completed.\"}]}}" + } + }, + "response": { + "status": 400, + "headers": { + "Content-Type": "application/json; charset=utf-8" + }, + "body": { + "code": 99992354, + "msg": "sanitized", + "error": { + "log_id": "sanitized", + "field_violations": [ + { + "field": "sanitized", + "value": "om_lark_invalid", + "description": "sanitized" + } + ] + } + } + } + }, + "invalid_token": { + "request": { + "method": "GET", + "url": "https://open.larksuite.com/open-apis/tenant/v2/tenant/query", + "body": null + }, + "response": { + "status": 400, + "headers": { + "Content-Type": "application/json; charset=utf-8" + }, + "body": { + "code": 99991663, + "msg": "sanitized", + "error": { + "log_id": "sanitized", + "message": "sanitized" + } + } + } + }, + "stream_connect": { + "request": { + "method": "POST", + "url": "https://open.larksuite.com/callback/ws/endpoint", + "body": { + "AppID": "cli_sanitized_lark_app", + "AppSecret": "sanitized-lark-app-secret" + } + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "application/json; charset=utf-8" + }, + "body": { + "code": 0, + "msg": "", + "data": { + "URL": "wss://ws.example.invalid/ws/v2?fpid=sanitized-fpid&aid=sanitized-aid&device_id=sanitized-device_id&access_key=sanitized-access_key&service_id=1&ticket=sanitized-ticket", + "ClientConfig": { + "ReconnectCount": -1, + "ReconnectInterval": 120, + "ReconnectNonce": 25, + "PingInterval": 120 + } + } + } + } + }, + "bot_info": { + "request": { + "method": "GET", + "url": "https://open.larksuite.com/open-apis/bot/v3/info", + "body": null + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "application/json" + }, + "body": { + "bot": { + "activate_status": 2, + "app_name": "sanitized", + "avatar_url": "sanitized", + "ip_white_list": [], + "open_id": "open_lark_bot" + }, + "code": 0, + "msg": "ok" + } + } + }, + "stream_connect_invalid": { + "request": { + "method": "POST", + "url": "https://open.larksuite.com/callback/ws/endpoint", + "body": { + "AppID": "cli_sanitized_lark_app", + "AppSecret": "invalid-test-secret" + } + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "application/json; charset=utf-8" + }, + "body": { + "code": 1000040345, + "error": { + "id": 1000040345, + "localizedMessage": { + "locale": "", + "message": "" + }, + "ErrX": null + }, + "msg": "", + "data": { + "URL": "" + } + } + } + }, + "stream_card": { + "request": { + "method": "POST", + "url": "https://open.larksuite.com/open-apis/im/v1/messages?receive_id_type=union_id", + "body": { + "receive_id": "union_lark_user", + "msg_type": "interactive", + "content": "{\"schema\":\"2.0\",\"config\":{\"update_multi\":true},\"body\":{\"direction\":\"vertical\",\"elements\":[{\"tag\":\"form\",\"name\":\"__dify.human_input\",\"elements\":[{\"tag\":\"markdown\",\"content\":\"Lark confirmed card form.\"},{\"tag\":\"input\",\"name\":\"comment\",\"input_type\":\"multiline_text\",\"width\":\"fill\",\"required\":true,\"label\":{\"tag\":\"plain_text\",\"content\":\"comment\"},\"placeholder\":{\"tag\":\"plain_text\",\"content\":\"comment\"}},{\"tag\":\"select_static\",\"name\":\"decision\",\"required\":true,\"placeholder\":{\"tag\":\"plain_text\",\"content\":\"decision\"},\"options\":[{\"text\":{\"tag\":\"plain_text\",\"content\":\"ship\"},\"value\":\"ship\"},{\"text\":{\"tag\":\"plain_text\",\"content\":\"hold\"},\"value\":\"hold\"}],\"initial_option\":\"hold\"},{\"tag\":\"column_set\",\"flex_mode\":\"none\",\"horizontal_spacing\":\"8px\",\"horizontal_align\":\"left\",\"columns\":[{\"tag\":\"column\",\"width\":\"auto\",\"elements\":[{\"tag\":\"button\",\"name\":\"approve\",\"type\":\"primary_filled\",\"text\":{\"tag\":\"plain_text\",\"content\":\"Approve\"},\"form_action_type\":\"submit\",\"behaviors\":[{\"type\":\"callback\",\"value\":{\"__dify.human_input.action\":{\"version\":1,\"action_id\":\"approve\",\"correlation_token\":\"lark-live-stream-correlation\"}}}]}]},{\"tag\":\"column\",\"width\":\"auto\",\"elements\":[{\"tag\":\"button\",\"name\":\"reject\",\"type\":\"danger_filled\",\"text\":{\"tag\":\"plain_text\",\"content\":\"Reject\"},\"form_action_type\":\"submit\",\"behaviors\":[{\"type\":\"callback\",\"value\":{\"__dify.human_input.action\":{\"version\":1,\"action_id\":\"reject\",\"correlation_token\":\"lark-live-stream-correlation\"}}}]}]}]}]}]},\"header\":{\"title\":{\"tag\":\"plain_text\",\"content\":\"Lark stream fixture capture\"}}}" + } + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "application/json; charset=utf-8" + }, + "body": { + "code": 0, + "data": { + "body": { + "content": "{\"title\":\"Lark stream fixture capture\",\"elements\":[[{\"tag\":\"img\",\"image_key\":\"sanitized\"},{\"tag\":\"text\",\"text\":\"sanitized\"},{\"tag\":\"text\",\"text\":\"\"}]]}" + }, + "chat_id": "oc_lark_chat", + "create_time": "1800000000000", + "deleted": false, + "message_id": "om_lark_stream_card", + "msg_type": "interactive", + "sender": { + "id": "cli_sanitized_lark_app", + "id_type": "app_id", + "sender_type": "app", + "tenant_key": "tenant_lark_sanitized" + }, + "update_time": "1800000000000", + "updated": false + }, + "msg": "success" + } + } + }, + "stream_replace": { + "request": { + "method": "PATCH", + "url": "https://open.larksuite.com/open-apis/im/v1/messages/om_lark_stream_card", + "body": { + "content": "{\"schema\":\"2.0\",\"body\":{\"elements\":[{\"tag\":\"markdown\",\"content\":\"Lark stream capture completed.\"}]}}" + } + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "application/json; charset=utf-8" + }, + "body": { + "code": 0, + "data": {}, + "msg": "success" + } + } + }, + "webhook_card": { + "request": { + "method": "POST", + "url": "https://open.larksuite.com/open-apis/im/v1/messages?receive_id_type=union_id", + "body": { + "receive_id": "union_lark_user", + "msg_type": "interactive", + "content": "{\"schema\":\"2.0\",\"config\":{\"update_multi\":true},\"body\":{\"direction\":\"vertical\",\"elements\":[{\"tag\":\"form\",\"name\":\"__dify.human_input\",\"elements\":[{\"tag\":\"markdown\",\"content\":\"Lark confirmed card form.\"},{\"tag\":\"input\",\"name\":\"comment\",\"input_type\":\"multiline_text\",\"width\":\"fill\",\"required\":true,\"label\":{\"tag\":\"plain_text\",\"content\":\"comment\"},\"placeholder\":{\"tag\":\"plain_text\",\"content\":\"comment\"}},{\"tag\":\"select_static\",\"name\":\"decision\",\"required\":true,\"placeholder\":{\"tag\":\"plain_text\",\"content\":\"decision\"},\"options\":[{\"text\":{\"tag\":\"plain_text\",\"content\":\"ship\"},\"value\":\"ship\"},{\"text\":{\"tag\":\"plain_text\",\"content\":\"hold\"},\"value\":\"hold\"}],\"initial_option\":\"hold\"},{\"tag\":\"column_set\",\"flex_mode\":\"none\",\"horizontal_spacing\":\"8px\",\"horizontal_align\":\"left\",\"columns\":[{\"tag\":\"column\",\"width\":\"auto\",\"elements\":[{\"tag\":\"button\",\"name\":\"approve\",\"type\":\"primary_filled\",\"text\":{\"tag\":\"plain_text\",\"content\":\"Approve\"},\"form_action_type\":\"submit\",\"behaviors\":[{\"type\":\"callback\",\"value\":{\"__dify.human_input.action\":{\"version\":1,\"action_id\":\"approve\",\"correlation_token\":\"lark-live-webhook-correlation\"}}}]}]},{\"tag\":\"column\",\"width\":\"auto\",\"elements\":[{\"tag\":\"button\",\"name\":\"reject\",\"type\":\"danger_filled\",\"text\":{\"tag\":\"plain_text\",\"content\":\"Reject\"},\"form_action_type\":\"submit\",\"behaviors\":[{\"type\":\"callback\",\"value\":{\"__dify.human_input.action\":{\"version\":1,\"action_id\":\"reject\",\"correlation_token\":\"lark-live-webhook-correlation\"}}}]}]}]}]}]},\"header\":{\"title\":{\"tag\":\"plain_text\",\"content\":\"Lark webhook fixture capture\"}}}" + } + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "application/json; charset=utf-8" + }, + "body": { + "code": 0, + "data": { + "body": { + "content": "{\"title\":\"Lark webhook fixture capture\",\"elements\":[[{\"tag\":\"img\",\"image_key\":\"sanitized\"},{\"tag\":\"text\",\"text\":\"sanitized\"},{\"tag\":\"text\",\"text\":\"\"}]]}" + }, + "chat_id": "oc_lark_chat", + "create_time": "1800000000000", + "deleted": false, + "message_id": "om_lark_webhook_card", + "msg_type": "interactive", + "sender": { + "id": "cli_sanitized_lark_app", + "id_type": "app_id", + "sender_type": "app", + "tenant_key": "tenant_lark_sanitized" + }, + "update_time": "1800000000000", + "updated": false + }, + "msg": "success" + } + } + }, + "webhook_replace": { + "request": { + "method": "PATCH", + "url": "https://open.larksuite.com/open-apis/im/v1/messages/om_lark_webhook_card", + "body": { + "content": "{\"schema\":\"2.0\",\"body\":{\"elements\":[{\"tag\":\"markdown\",\"content\":\"Lark webhook capture completed.\"}]}}" + } + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "application/json; charset=utf-8" + }, + "body": { + "code": 0, + "data": {}, + "msg": "success" + } + } + } +} diff --git a/api/tests/unit_tests/core/human_input_v2/im_provider/fixtures/lark/sanitized_stream_ack.json b/api/tests/unit_tests/core/human_input_v2/im_provider/fixtures/lark/sanitized_stream_ack.json new file mode 100644 index 00000000000..beecbee0351 --- /dev/null +++ b/api/tests/unit_tests/core/human_input_v2/im_provider/fixtures/lark/sanitized_stream_ack.json @@ -0,0 +1,5 @@ +{ + "body_encoding": "base64", + "body": "CAEQARgBIAEqDgoJdGltZXN0YW1wEgEwKiIKCm1lc3NhZ2VfaWQSFHNhbml0aXplZC1tZXNzYWdlX2lkKggKA3NlcRIBMCokCgtpbnN0YW5jZV9pZBIVc2FuaXRpemVkLWluc3RhbmNlX2lkKh4KCHRyYWNlX2lkEhJzYW5pdGl6ZWQtdHJhY2VfaWQqPgoYeF9mcm9udGllcl9yZWNpZXZlZF90aW1lEiJzYW5pdGl6ZWQteF9mcm9udGllcl9yZWNpZXZlZF90aW1lKggKA3N1bRIBMSoNCgR0eXBlEgVldmVudCoaChV4X2Zyb250aWVyX2Fycml2ZXJhdGUSATAqMQoSOnhfZnJvbnRpZXJfbXNnX2lkEhtzYW5pdGl6ZWQteF9mcm9udGllcl9tc2dfaWQqPwoZOnhfZnJvbnRpZXJfbXNnX3NlbmRfdGltZRIic2FuaXRpemVkLXhfZnJvbnRpZXJfbXNnX3NlbmRfdGltZSokCgt0cmFjZXBhcmVudBIVc2FuaXRpemVkLXRyYWNlcGFyZW50KgsKBmJpel9ydBIBMDIAOgBCHXsiY29kZSI6IDIwMCwgImRhdGEiOiAiZTMwPSJ9SiBtc2dfZG01MXlpY2d6Z3M0MDAwM25hcWUya2sxY3FhdFIAWiBtc2dfZG01MXlpY2d6Z3M0MDAwM25hcWUya2sxY3FhdGAAaAByAKABAKgBALABAA==", + "response_payload": "{\"code\": 200, \"data\": \"e30=\"}" +} diff --git a/api/tests/unit_tests/core/human_input_v2/im_provider/fixtures/lark/sanitized_url_challenge.json b/api/tests/unit_tests/core/human_input_v2/im_provider/fixtures/lark/sanitized_url_challenge.json new file mode 100644 index 00000000000..4caeb9340b2 --- /dev/null +++ b/api/tests/unit_tests/core/human_input_v2/im_provider/fixtures/lark/sanitized_url_challenge.json @@ -0,0 +1,17 @@ +{ + "request": { + "method": "POST", + "received_at": "2026-10-15T00:00:01", + "headers": [ + [ + "Content-Type", + "application/json" + ] + ], + "body": "{\"encrypt\":\"AAECAwQFBgcICQoLDA0ODw3mxchJU2yI2FdfY9pMYMDk2vHMTyXXe9WbNMvBtt+jyvSgF2HpK+9nrJZM0xc3WITlOxUEBqLhKeJ5Bimp/XatqjuLeJrrXn3Jx2ifb1fXWBrV3+cZPtPgWTutWAsJa9CPCRVRDQcW3fiuLNnawOw=\"}" + }, + "response": { + "status": 200, + "body": "{\"challenge\":\"lark-test-challenge\"}" + } +} diff --git a/api/tests/unit_tests/core/human_input_v2/im_provider/fixtures/ms_teams_card_submit.json b/api/tests/unit_tests/core/human_input_v2/im_provider/fixtures/ms_teams_card_submit.json new file mode 100644 index 00000000000..8d4a1f2a85e --- /dev/null +++ b/api/tests/unit_tests/core/human_input_v2/im_provider/fixtures/ms_teams_card_submit.json @@ -0,0 +1,37 @@ +{ + "type": "message", + "id": "test-only-card-event", + "timestamp": "2026-08-11T18:26:39Z", + "serviceUrl": "https://smba.trafficmanager.net/teams/", + "channelId": "msteams", + "from": { + "id": "29:test-only-teams-user", + "aadObjectId": "11111111-2222-4333-8444-abcdefabcdef", + "name": "Sanitized User" + }, + "conversation": { + "id": "test-only-conversation", + "tenantId": "test-only-tenant" + }, + "recipient": { + "id": "test-only-bot" + }, + "channelData": { + "tenant": { + "id": "test-only-tenant" + }, + "source": { + "name": "message" + } + }, + "value": { + "__dify.human_input": { + "version": 1, + "action_id": "approve-✅", + "correlation_token": "correlation-🔐" + }, + "comment-🧪": "Ready 🚀", + "decision": "ship" + }, + "locale": "en-US" +} diff --git a/api/tests/unit_tests/core/human_input_v2/im_provider/fixtures/slack_block_actions_live_capture_socket_mode.json b/api/tests/unit_tests/core/human_input_v2/im_provider/fixtures/slack_block_actions_live_capture_socket_mode.json new file mode 100644 index 00000000000..b92c985a2a3 --- /dev/null +++ b/api/tests/unit_tests/core/human_input_v2/im_provider/fixtures/slack_block_actions_live_capture_socket_mode.json @@ -0,0 +1,191 @@ +{ + "envelope_id": "sanitized-envelope-id", + "payload": { + "actions": [ + { + "action_id": "批准✅", + "action_ts": "1700000001.000002", + "block_id": "__dify.actions", + "style": "primary", + "text": { + "emoji": true, + "text": "Approve", + "type": "plain_text" + }, + "type": "button", + "value": "{\"action_id\":\"批准✅\",\"correlation_token\":\"关联令牌-🌍\",\"version\":1}" + } + ], + "api_app_id": "A012SANITIZED", + "channel": { + "id": "D012SANITIZED", + "name": "sanitized-user" + }, + "container": { + "channel_id": "D012SANITIZED", + "is_ephemeral": false, + "message_ts": "1700000000.000001", + "type": "message" + }, + "enterprise": null, + "is_enterprise_install": false, + "message": { + "app_id": "A012SANITIZED", + "blocks": [ + { + "block_id": "slack-generated-block-id", + "text": { + "emoji": true, + "text": "Slack callback capture [SANITIZED]", + "type": "plain_text" + }, + "type": "header" + }, + { + "block_id": "slack-generated-block-id", + "elements": [ + { + "elements": [ + { + "text": "Review the response.", + "type": "text" + } + ], + "type": "rich_text_section" + } + ], + "type": "rich_text" + }, + { + "block_id": "__dify.input.0", + "dispatch_action": false, + "element": { + "action_id": "说明📝", + "dispatch_action_config": { + "trigger_actions_on": [ + "on_enter_pressed" + ] + }, + "initial_value": "初始值", + "multiline": true, + "type": "plain_text_input" + }, + "label": { + "emoji": true, + "text": "说明📝", + "type": "plain_text" + }, + "optional": false, + "type": "input" + }, + { + "block_id": "__dify.input.1", + "dispatch_action": false, + "element": { + "action_id": "选择🌐", + "initial_option": { + "text": { + "emoji": true, + "text": "选项 α", + "type": "plain_text" + }, + "value": "选项 α" + }, + "options": [ + { + "text": { + "emoji": true, + "text": "选项 α", + "type": "plain_text" + }, + "value": "选项 α" + }, + { + "text": { + "emoji": true, + "text": "选项 β", + "type": "plain_text" + }, + "value": "选项 β" + } + ], + "placeholder": { + "emoji": true, + "text": "Select an option", + "type": "plain_text" + }, + "type": "static_select" + }, + "label": { + "emoji": true, + "text": "选择🌐", + "type": "plain_text" + }, + "optional": false, + "type": "input" + }, + { + "block_id": "__dify.actions", + "elements": [ + { + "action_id": "批准✅", + "style": "primary", + "text": { + "emoji": true, + "text": "Approve", + "type": "plain_text" + }, + "type": "button", + "value": "{\"action_id\":\"批准✅\",\"correlation_token\":\"关联令牌-🌍\",\"version\":1}" + } + ], + "type": "actions" + } + ], + "bot_id": "B012SANITIZED", + "team": "T012SANITIZED", + "text": "Slack callback capture [SANITIZED] Review the response. Approve button, with interactive elements", + "ts": "1700000000.000001", + "type": "message", + "user": "U012SANITIZED" + }, + "response_url": "https://hooks.slack.com/actions/SANITIZED", + "state": { + "values": { + "__dify.input.0": { + "说明📝": { + "type": "plain_text_input", + "value": "你好,世界 🌍" + } + }, + "__dify.input.1": { + "选择🌐": { + "selected_option": { + "text": { + "emoji": true, + "text": "选项 β", + "type": "plain_text" + }, + "value": "选项 β" + }, + "type": "static_select" + } + } + } + }, + "team": { + "domain": "sanitized-workspace", + "id": "T012SANITIZED" + }, + "token": "sanitized-verification-token", + "trigger_id": "sanitized-trigger-id", + "type": "block_actions", + "user": { + "id": "U012SANITIZED", + "name": "sanitized-user", + "team_id": "T012SANITIZED", + "username": "sanitized-user" + } + }, + "type": "interactive" +} diff --git a/api/tests/unit_tests/core/human_input_v2/im_provider/fixtures/slack_block_actions_radio_buttons_webhook.json b/api/tests/unit_tests/core/human_input_v2/im_provider/fixtures/slack_block_actions_radio_buttons_webhook.json new file mode 100644 index 00000000000..abd3d3d436d --- /dev/null +++ b/api/tests/unit_tests/core/human_input_v2/im_provider/fixtures/slack_block_actions_radio_buttons_webhook.json @@ -0,0 +1,145 @@ +{ + "actions": [ + { + "action_id": "risk_level", + "action_ts": "1786400201.000002", + "block_id": "__dify.input.1", + "selected_option": { + "text": { + "emoji": true, + "text": "High", + "type": "plain_text" + }, + "value": "high" + }, + "type": "radio_buttons" + } + ], + "api_app_id": "A012SANITIZED", + "channel": { + "id": "D012SANITIZED", + "name": "directmessage" + }, + "container": { + "channel_id": "D012SANITIZED", + "is_ephemeral": false, + "message_ts": "1786400200.000001", + "type": "message" + }, + "message": { + "app_id": "A012SANITIZED", + "blocks": [ + { + "block_id": "slack-generated-header-radio", + "text": { + "emoji": true, + "text": "Approval request", + "type": "plain_text" + }, + "type": "header" + }, + { + "block_id": "__dify.input.0", + "dispatch_action": false, + "element": { + "action_id": "review_comment", + "multiline": true, + "type": "plain_text_input" + }, + "label": { + "text": "Review comment", + "type": "plain_text" + }, + "optional": false, + "type": "input" + }, + { + "block_id": "__dify.input.1", + "dispatch_action": false, + "element": { + "action_id": "risk_level", + "options": [ + { + "text": { + "text": "Low", + "type": "plain_text" + }, + "value": "low" + }, + { + "text": { + "text": "High", + "type": "plain_text" + }, + "value": "high" + } + ], + "type": "radio_buttons" + }, + "label": { + "text": "Risk level", + "type": "plain_text" + }, + "optional": false, + "type": "input" + }, + { + "block_id": "__dify.actions", + "elements": [ + { + "action_id": "approve", + "text": { + "text": "Approve", + "type": "plain_text" + }, + "type": "button", + "value": "{\"action_id\":\"approve\",\"correlation_token\":\"sanitized-radio-token\",\"version\":1}" + } + ], + "type": "actions" + } + ], + "bot_id": "B012SANITIZED", + "team": "T012SANITIZED", + "text": "Approval request [synthetic radio interaction fixture]", + "ts": "1786400200.000001", + "type": "message", + "user": "UAPP012SANITIZED" + }, + "response_url": "https://hooks.slack.com/actions/SANITIZED_RADIO", + "state": { + "values": { + "__dify.input.0": { + "review_comment": { + "type": "plain_text_input", + "value": "Review in progress" + } + }, + "__dify.input.1": { + "risk_level": { + "selected_option": { + "text": { + "text": "High", + "type": "plain_text" + }, + "value": "high" + }, + "type": "radio_buttons" + } + } + } + }, + "team": { + "domain": "sanitized-workspace", + "id": "T012SANITIZED" + }, + "token": "sanitized-verification-token", + "trigger_id": "sanitized-radio-trigger-id", + "type": "block_actions", + "user": { + "id": "U012SANITIZED", + "name": "sanitized-user", + "team_id": "T012SANITIZED", + "username": "sanitized-user" + } +} diff --git a/api/tests/unit_tests/core/human_input_v2/im_provider/fixtures/slack_block_actions_socket_mode.json b/api/tests/unit_tests/core/human_input_v2/im_provider/fixtures/slack_block_actions_socket_mode.json new file mode 100644 index 00000000000..e931b109e26 --- /dev/null +++ b/api/tests/unit_tests/core/human_input_v2/im_provider/fixtures/slack_block_actions_socket_mode.json @@ -0,0 +1,155 @@ +{ + "envelope_id": "sanitized-envelope-id", + "payload": { + "type": "block_actions", + "team": { + "id": "T012SANITIZED", + "domain": "sanitized-workspace" + }, + "user": { + "id": "U012SANITIZED", + "username": "sanitized-user", + "name": "sanitized-user", + "team_id": "T012SANITIZED" + }, + "api_app_id": "A012SANITIZED", + "token": "sanitized-verification-token", + "container": { + "type": "message", + "message_ts": "1786400000.000001", + "channel_id": "D012SANITIZED", + "is_ephemeral": false + }, + "trigger_id": "sanitized-trigger-id", + "channel": { + "id": "D012SANITIZED", + "name": "directmessage" + }, + "message": { + "user": "UAPP012SANITIZED", + "type": "message", + "ts": "1786400000.000001", + "bot_id": "B012SANITIZED", + "app_id": "A012SANITIZED", + "text": "Approval request [sanitized fixture]", + "team": "T012SANITIZED", + "blocks": [ + { + "type": "header", + "block_id": "slack-generated-header", + "text": { + "type": "plain_text", + "text": "Approval request", + "emoji": true + } + }, + { + "type": "input", + "block_id": "__dify.input.0", + "label": { + "type": "plain_text", + "text": "说明📝" + }, + "optional": false, + "dispatch_action": false, + "element": { + "type": "plain_text_input", + "action_id": "说明📝", + "multiline": true + } + }, + { + "type": "input", + "block_id": "__dify.input.1", + "label": { + "type": "plain_text", + "text": "选择🌐" + }, + "optional": false, + "dispatch_action": false, + "element": { + "type": "static_select", + "action_id": "选择🌐", + "placeholder": { + "type": "plain_text", + "text": "Select an option", + "emoji": true + }, + "options": [ + { + "text": { + "type": "plain_text", + "text": "选项 α" + }, + "value": "选项 α" + }, + { + "text": { + "type": "plain_text", + "text": "选项 β" + }, + "value": "选项 β" + } + ] + } + }, + { + "type": "actions", + "block_id": "__dify.actions", + "elements": [ + { + "type": "button", + "action_id": "批准✅", + "text": { + "type": "plain_text", + "text": "Approve" + }, + "value": "{\"action_id\":\"批准✅\",\"correlation_token\":\"关联令牌-🌍\",\"version\":1}" + } + ] + } + ] + }, + "state": { + "values": { + "__dify.input.0": { + "说明📝": { + "type": "plain_text_input", + "value": "你好,世界 🌍" + } + }, + "__dify.input.1": { + "选择🌐": { + "type": "static_select", + "selected_option": { + "text": { + "type": "plain_text", + "text": "选项 β" + }, + "value": "选项 β" + } + } + } + } + }, + "response_url": "https://hooks.slack.com/actions/SANITIZED", + "actions": [ + { + "action_id": "批准✅", + "block_id": "__dify.actions", + "text": { + "type": "plain_text", + "text": "Approve", + "emoji": true + }, + "value": "{\"action_id\":\"批准✅\",\"correlation_token\":\"关联令牌-🌍\",\"version\":1}", + "type": "button", + "action_ts": "1786400001.000002" + } + ] + }, + "retry_attempt": null, + "retry_reason": null, + "type": "interactive", + "accepts_response_payload": false +} diff --git a/api/tests/unit_tests/core/human_input_v2/im_provider/fixtures/slack_block_actions_static_select_socket_mode.json b/api/tests/unit_tests/core/human_input_v2/im_provider/fixtures/slack_block_actions_static_select_socket_mode.json new file mode 100644 index 00000000000..1261eb550e0 --- /dev/null +++ b/api/tests/unit_tests/core/human_input_v2/im_provider/fixtures/slack_block_actions_static_select_socket_mode.json @@ -0,0 +1,154 @@ +{ + "envelope_id": "sanitized-selection-envelope-id", + "payload": { + "actions": [ + { + "action_id": "risk_level", + "action_ts": "1786400101.000002", + "block_id": "__dify.input.1", + "selected_option": { + "text": { + "emoji": true, + "text": "High", + "type": "plain_text" + }, + "value": "high" + }, + "type": "static_select" + } + ], + "api_app_id": "A012SANITIZED", + "channel": { + "id": "D012SANITIZED", + "name": "directmessage" + }, + "container": { + "channel_id": "D012SANITIZED", + "is_ephemeral": false, + "message_ts": "1786400100.000001", + "type": "message" + }, + "message": { + "app_id": "A012SANITIZED", + "blocks": [ + { + "block_id": "slack-generated-header-selection", + "text": { + "emoji": true, + "text": "Approval request", + "type": "plain_text" + }, + "type": "header" + }, + { + "block_id": "__dify.input.0", + "dispatch_action": false, + "element": { + "action_id": "review_comment", + "multiline": true, + "type": "plain_text_input" + }, + "label": { + "text": "Review comment", + "type": "plain_text" + }, + "optional": false, + "type": "input" + }, + { + "block_id": "__dify.input.1", + "dispatch_action": false, + "element": { + "action_id": "risk_level", + "options": [ + { + "text": { + "text": "Low", + "type": "plain_text" + }, + "value": "low" + }, + { + "text": { + "text": "High", + "type": "plain_text" + }, + "value": "high" + } + ], + "placeholder": { + "emoji": true, + "text": "Select an option", + "type": "plain_text" + }, + "type": "static_select" + }, + "label": { + "text": "Risk level", + "type": "plain_text" + }, + "optional": false, + "type": "input" + }, + { + "block_id": "__dify.actions", + "elements": [ + { + "action_id": "approve", + "text": { + "text": "Approve", + "type": "plain_text" + }, + "type": "button", + "value": "{\"action_id\":\"approve\",\"correlation_token\":\"sanitized-selection-token\",\"version\":1}" + } + ], + "type": "actions" + } + ], + "bot_id": "B012SANITIZED", + "team": "T012SANITIZED", + "text": "Approval request [sanitized selection fixture]", + "ts": "1786400100.000001", + "type": "message", + "user": "UAPP012SANITIZED" + }, + "response_url": "https://hooks.slack.com/actions/SANITIZED_SELECTION", + "state": { + "values": { + "__dify.input.0": { + "review_comment": { + "type": "plain_text_input", + "value": "Review in progress" + } + }, + "__dify.input.1": { + "risk_level": { + "selected_option": { + "text": { + "text": "High", + "type": "plain_text" + }, + "value": "high" + }, + "type": "static_select" + } + } + } + }, + "team": { + "domain": "sanitized-workspace", + "id": "T012SANITIZED" + }, + "token": "sanitized-verification-token", + "trigger_id": "sanitized-selection-trigger-id", + "type": "block_actions", + "user": { + "id": "U012SANITIZED", + "name": "sanitized-user", + "team_id": "T012SANITIZED", + "username": "sanitized-user" + } + }, + "type": "interactive" +} diff --git a/api/tests/unit_tests/core/human_input_v2/im_provider/fixtures/slack_block_actions_webhook.json b/api/tests/unit_tests/core/human_input_v2/im_provider/fixtures/slack_block_actions_webhook.json new file mode 100644 index 00000000000..3b3cef3ec56 --- /dev/null +++ b/api/tests/unit_tests/core/human_input_v2/im_provider/fixtures/slack_block_actions_webhook.json @@ -0,0 +1,148 @@ +{ + "type": "block_actions", + "team": { + "id": "T012SANITIZED", + "domain": "sanitized-workspace" + }, + "user": { + "id": "U012SANITIZED", + "username": "sanitized-user", + "name": "sanitized-user", + "team_id": "T012SANITIZED" + }, + "api_app_id": "A012SANITIZED", + "token": "sanitized-verification-token", + "container": { + "type": "message", + "message_ts": "1786400000.000001", + "channel_id": "D012SANITIZED", + "is_ephemeral": false + }, + "trigger_id": "sanitized-trigger-id", + "channel": { + "id": "D012SANITIZED", + "name": "directmessage" + }, + "message": { + "user": "UAPP012SANITIZED", + "type": "message", + "ts": "1786400000.000001", + "bot_id": "B012SANITIZED", + "app_id": "A012SANITIZED", + "text": "Approval request [sanitized fixture]", + "team": "T012SANITIZED", + "blocks": [ + { + "type": "header", + "block_id": "slack-generated-header", + "text": { + "type": "plain_text", + "text": "Approval request", + "emoji": true + } + }, + { + "type": "input", + "block_id": "__dify.input.0", + "label": { + "type": "plain_text", + "text": "说明📝" + }, + "optional": false, + "dispatch_action": false, + "element": { + "type": "plain_text_input", + "action_id": "说明📝", + "multiline": true + } + }, + { + "type": "input", + "block_id": "__dify.input.1", + "label": { + "type": "plain_text", + "text": "选择🌐" + }, + "optional": false, + "dispatch_action": false, + "element": { + "type": "static_select", + "action_id": "选择🌐", + "placeholder": { + "type": "plain_text", + "text": "Select an option", + "emoji": true + }, + "options": [ + { + "text": { + "type": "plain_text", + "text": "选项 α" + }, + "value": "选项 α" + }, + { + "text": { + "type": "plain_text", + "text": "选项 β" + }, + "value": "选项 β" + } + ] + } + }, + { + "type": "actions", + "block_id": "__dify.actions", + "elements": [ + { + "type": "button", + "action_id": "批准✅", + "text": { + "type": "plain_text", + "text": "Approve" + }, + "value": "{\"action_id\":\"批准✅\",\"correlation_token\":\"关联令牌-🌍\",\"version\":1}" + } + ] + } + ] + }, + "state": { + "values": { + "__dify.input.0": { + "说明📝": { + "type": "plain_text_input", + "value": "你好,世界 🌍" + } + }, + "__dify.input.1": { + "选择🌐": { + "type": "static_select", + "selected_option": { + "text": { + "type": "plain_text", + "text": "选项 β" + }, + "value": "选项 β" + } + } + } + } + }, + "response_url": "https://hooks.slack.com/actions/SANITIZED", + "actions": [ + { + "action_id": "批准✅", + "block_id": "__dify.actions", + "text": { + "type": "plain_text", + "text": "Approve", + "emoji": true + }, + "value": "{\"action_id\":\"批准✅\",\"correlation_token\":\"关联令牌-🌍\",\"version\":1}", + "type": "button", + "action_ts": "1786400001.000002" + } + ] +} diff --git a/api/tests/unit_tests/core/human_input_v2/im_provider/test_avatar_batch.py b/api/tests/unit_tests/core/human_input_v2/im_provider/test_avatar_batch.py new file mode 100644 index 00000000000..4192eefd7ae --- /dev/null +++ b/api/tests/unit_tests/core/human_input_v2/im_provider/test_avatar_batch.py @@ -0,0 +1,82 @@ +from collections import Counter +from threading import Lock + +import httpx +import pytest +from pytest_mock import MockerFixture + +from core.human_input_v2.im_integration.adapters.avatar import AvatarReadError, read_avatars +from core.human_input_v2.im_integration.adapters.entities import Avatar + + +def test_batch_preserves_order_skips_missing_urls_and_downloads_shared_urls_once(mocker: MockerFixture) -> None: + calls: Counter[str] = Counter() + lock = Lock() + + def download(_method: str, url: str, **_kwargs: object) -> httpx.Response: + with lock: + calls[url] += 1 + return httpx.Response( + 200, + headers={"Content-Type": "image/png"}, + content=b"first" if url.endswith("first") else b"second", + request=httpx.Request("GET", url), + ) + + mocker.patch("core.file.remote_fetcher.make_request", side_effect=download) + + result = read_avatars( + [ + "https://example.invalid/second", + None, + "https://example.invalid/first", + "https://example.invalid/second", + ] + ) + + assert result == ( + Avatar("image/png", b"second"), + None, + Avatar("image/png", b"first"), + Avatar("image/png", b"second"), + ) + assert calls == {"https://example.invalid/first": 1, "https://example.invalid/second": 1} + + +@pytest.mark.parametrize("status", [401, 403, 500]) +def test_batch_propagates_failed_download(mocker: MockerFixture, status: int) -> None: + def download(_method: str, url: str, **_kwargs: object) -> httpx.Response: + return httpx.Response( + status if url.endswith("failed") else 200, + headers={"Content-Type": "image/png"}, + content=b"avatar", + request=httpx.Request("GET", url), + ) + + mocker.patch("core.file.remote_fetcher.make_request", side_effect=download) + urls = ["https://example.invalid/first", "https://example.invalid/failed", "https://example.invalid/last"] + + with pytest.raises(AvatarReadError) as exc_info: + read_avatars(urls) + + cause = exc_info.value.__cause__ + assert isinstance(cause, httpx.HTTPStatusError) + assert cause.response.status_code == status + assert cause.__traceback__ is not None + + +def test_batch_returns_none_for_not_found_avatar(mocker: MockerFixture) -> None: + mocker.patch( + "core.file.remote_fetcher.make_request", + return_value=httpx.Response(404, request=httpx.Request("GET", "https://example.invalid/missing")), + ) + + assert read_avatars(["https://example.invalid/missing"]) == (None,) + + +def test_batch_with_only_missing_urls_does_not_download(mocker: MockerFixture) -> None: + download = mocker.patch("core.file.remote_fetcher.make_request") + + assert read_avatars([None, None]) == (None, None) + assert read_avatars([]) == () + download.assert_not_called() diff --git a/api/tests/unit_tests/core/human_input_v2/im_provider/test_contracts.py b/api/tests/unit_tests/core/human_input_v2/im_provider/test_contracts.py new file mode 100644 index 00000000000..32c2b169dab --- /dev/null +++ b/api/tests/unit_tests/core/human_input_v2/im_provider/test_contracts.py @@ -0,0 +1,253 @@ +from __future__ import annotations + +import operator +from collections.abc import Callable +from dataclasses import FrozenInstanceError, fields +from datetime import datetime +from typing import NewType, get_type_hints + +import pytest +from pydantic import JsonValue, ValidationError + +from core.human_input_v2.entities import IMProvider +from core.human_input_v2.im_integration import adapters as im_adapters +from core.human_input_v2.im_integration.adapters import ( + AuthenticatedIMEvent, + CorrelationToken, + CredentialTestSuccess, + Directory, + DirectoryEntry, + IMCardEvent, + IMCardEventDecoder, + IMCardEventDecodeResult, + IMCardEventDecodingError, + IMDynamicCardMessaging, + IMEventStream, + IMStreamStartError, + IMStreamStopError, + MessageAccepted, + MessageLocator, + ProviderUserId, + SlackCredentials, + UnrecognizedIMEvent, +) +from core.human_input_v2.resolved_form import ResolvedForm + + +def _credentials() -> SlackCredentials: + return SlackCredentials( + provider=IMProvider.SLACK, + client_id="client-id", + client_secret="client-secret", + signing_secret="signing-secret", + bot_token="xoxb-test-bot-token", + app_token="xapp-test-app-token", + ) + + +def test_resolved_slack_credentials_are_strict_immutable_and_secret_safe() -> None: + credentials = _credentials() + + assert credentials.provider is IMProvider.SLACK + assert "client-secret" not in repr(credentials) + assert "signing-secret" not in repr(credentials) + assert "xoxb-test-bot-token" not in repr(credentials) + assert "xapp-test-app-token" not in repr(credentials) + + with pytest.raises(ValidationError): + SlackCredentials.model_validate({**credentials.model_dump(), "unexpected": "value"}) + with pytest.raises(ValidationError): + SlackCredentials.model_validate({**credentials.model_dump(), "provider": IMProvider.FEISHU}) + pytest.raises(ValidationError, setattr, credentials, "client_id", "changed") + + +def test_resolved_slack_credentials_require_app_token_only_for_socket_mode() -> None: + credentials = SlackCredentials( + provider=IMProvider.SLACK, + client_id="client-id", + client_secret="client-secret", + signing_secret="signing-secret", + bot_token="xoxb-test-bot-token", + ) + + assert credentials.app_token is None + schema = SlackCredentials.model_json_schema() + assert "app_token" not in schema["required"] + assert schema["properties"]["app_token"]["description"] == ( + "Optional resolved Slack app-level token required only for Socket Mode." + ) + + +def test_provider_neutral_values_are_immutable() -> None: + directory = Directory((DirectoryEntry(ProviderUserId("user-1"), None, None),)) + success = CredentialTestSuccess(IMProvider.SLACK, "team-1") + + pytest.raises(FrozenInstanceError, setattr, directory, "entries", ()) + pytest.raises(FrozenInstanceError, setattr, success, "provider_tenant_id", "team-2") + + +def test_card_event_contract_is_immutable_and_requires_json_inputs() -> None: + source_inputs: dict[str, JsonValue] = {"nested": {"values": [1, True, None, "text"]}} + event = IMCardEvent( + provider_user_id=ProviderUserId("user"), + action_id="approve", + inputs=source_inputs, + correlation_token=CorrelationToken("token"), + ) + + source_inputs["nested"] = "changed" + assert event.inputs == {"nested": {"values": [1, True, None, "text"]}} + pytest.raises(TypeError, operator.setitem, event.inputs, "other", "value") + pytest.raises(TypeError, operator.delitem, event.inputs, "nested") + pytest.raises(FrozenInstanceError, setattr, event, "action_id", "changed") + with pytest.raises(ValueError, match="card event action identifier must not be empty"): + IMCardEvent(ProviderUserId("user"), "", {}, CorrelationToken("token")) + # Model an untyped caller to retain the runtime rejection beyond static guarantees. + event_constructor: Callable[..., IMCardEvent] = IMCardEvent + with pytest.raises(TypeError): + event_constructor(ProviderUserId("user"), "approve", {"invalid": object()}, CorrelationToken("token")) + + +def test_card_event_nested_json_values_remain_mutable() -> None: + event = IMCardEvent( + provider_user_id=ProviderUserId("user"), + action_id="approve", + inputs={"nested": {"values": [1, {"approved": True}]}}, + correlation_token=CorrelationToken("token"), + ) + nested = event.inputs["nested"] + assert isinstance(nested, dict) + values = nested["values"] + assert isinstance(values, list) + + nested["added"] = "mutated" + values.append("mutated") + + assert event.inputs == { + "nested": { + "values": [1, {"approved": True}, "mutated"], + "added": "mutated", + } + } + + +def test_card_event_decode_contract_exposes_explicit_safe_routing_types() -> None: + assert get_type_hints(IMCardEventDecoder.decode)["return"] is IMCardEventDecodeResult + unrecognized = UnrecognizedIMEvent() + assert unrecognized == UnrecognizedIMEvent() + + error = IMCardEventDecodingError("Card event schema is invalid.") + assert isinstance(error, ValueError) + assert vars(error) == {} + assert error.__cause__ is None + assert error.__context__ is None + + +def test_event_stream_contract_exposes_owner_managed_lifecycle() -> None: + assert hasattr(IMEventStream, "start") + assert hasattr(IMEventStream, "stop") + assert not hasattr(IMEventStream, "run") + assert issubclass(IMStreamStartError, Exception) + assert issubclass(IMStreamStopError, Exception) + + +def test_authenticated_event_preserves_provider_payload_verbatim() -> None: + payload = ' {"type":"card.action","nested":{"value":1}}\n' + + event = AuthenticatedIMEvent( + provider=IMProvider.SLACK, + provider_tenant_id="tenant-1", + event_id="event-1", + event_type="card.action", + occurred_at=datetime(2026, 8, 2, 8), + received_at=datetime(2026, 8, 2, 8, 0, 1), + ingress_kind=im_adapters.IMEventIngressKind.WEBHOOK, + payload=payload, + ) + + assert tuple(field.name for field in fields(event)) == ( + "provider", + "provider_tenant_id", + "event_id", + "event_type", + "occurred_at", + "received_at", + "ingress_kind", + "payload", + ) + assert event.payload == payload + + +def test_authenticated_event_requires_exported_closed_ingress_kind() -> None: + assert hasattr(im_adapters, "IMEventIngressKind") + ingress_kind_type = im_adapters.IMEventIngressKind + + assert {kind.value for kind in ingress_kind_type} == {"webhook", "stream"} + assert "IMEventIngressKind" in im_adapters.__all__ + + event = AuthenticatedIMEvent( + provider=IMProvider.SLACK, + provider_tenant_id="tenant-1", + event_id="event-1", + event_type="card.action", + occurred_at=None, + received_at=datetime(2026, 8, 2, 8), + ingress_kind=ingress_kind_type.WEBHOOK, + payload="{}", + ) + assert tuple(field.name for field in fields(event)) == ( + "provider", + "provider_tenant_id", + "event_id", + "event_type", + "occurred_at", + "received_at", + "ingress_kind", + "payload", + ) + + # Model an untyped caller omitting a required constructor argument. + event_constructor: Callable[..., AuthenticatedIMEvent] = AuthenticatedIMEvent + with pytest.raises(TypeError, match="ingress_kind"): + event_constructor( + provider=IMProvider.SLACK, + provider_tenant_id="tenant-1", + event_id="event-1", + event_type="card.action", + occurred_at=None, + received_at=datetime(2026, 8, 2, 8), + payload="{}", + ) + + +def test_provider_contract_does_not_export_superseded_inbox_types() -> None: + assert not hasattr(im_adapters, "ProviderNativePayload") + assert not hasattr(im_adapters, "IMEventSink") + + +def test_dynamic_card_contract_consumes_resolved_form_without_runtime_wrapper() -> None: + assert get_type_hints(IMDynamicCardMessaging.assess)["intent"] is ResolvedForm + assert get_type_hints(IMDynamicCardMessaging.send_card)["intent"] is ResolvedForm + assert not hasattr(im_adapters, "NormalizedCardIntent") + + +def test_message_locator_contract_is_a_nominal_runtime_string() -> None: + stored_value = "opaque-locator-value" + locator = MessageLocator(stored_value) + + assert isinstance(MessageLocator, NewType) + assert MessageLocator.__supertype__ is str + assert isinstance(locator, str) + assert type(locator) is str + assert locator == stored_value + assert MessageLocator(str(locator)) == locator + + +def test_provider_contract_exports_message_locator_without_legacy_reference_shape() -> None: + accepted = MessageAccepted(locator=MessageLocator("opaque-locator-value")) + + assert "MessageLocator" in im_adapters.__all__ + assert not hasattr(im_adapters, "MessageReference") + assert accepted.locator == "opaque-locator-value" + assert not hasattr(accepted, "reference") + assert get_type_hints(IMDynamicCardMessaging.replace_with_static)["locator"] is MessageLocator diff --git a/api/tests/unit_tests/core/human_input_v2/im_provider/test_dingtalk_adapter.py b/api/tests/unit_tests/core/human_input_v2/im_provider/test_dingtalk_adapter.py new file mode 100644 index 00000000000..f1d16a3206d --- /dev/null +++ b/api/tests/unit_tests/core/human_input_v2/im_provider/test_dingtalk_adapter.py @@ -0,0 +1,1272 @@ +from __future__ import annotations + +import json +from collections.abc import Callable +from dataclasses import dataclass +from email.message import Message +from io import BytesIO +from pathlib import Path +from urllib import error as urllib_error +from urllib import request as urllib_request +from urllib.parse import urlparse + +import httpx +import pytest +from alibabacloud_dingtalk.oauth2_1_0.models import GetTokenRequest, GetTokenResponse, GetTokenResponseBody +from alibabacloud_dingtalk.robot_1_0.models import ( + BatchSendOTOHeaders, + BatchSendOTORequest, + BatchSendOTOResponse, +) +from alibabacloud_tea_util.models import RuntimeOptions +from pydantic import ValidationError +from pytest_mock import MockerFixture +from Tea.exceptions import TeaException + +from core.human_input_v2.entities import IMProvider +from core.human_input_v2.im_integration.adapters import ( + CredentialTestFailure, + CredentialTestFailureKind, + CredentialTestSuccess, + DingTalkCredentials, + Directory, + DirectoryReadFailure, + EventAcceptance, + MessageAccepted, + MessageSendingError, + ProviderUserId, +) +from core.human_input_v2.im_integration.adapters import dingtalk as dingtalk_module +from core.human_input_v2.im_integration.adapters.dingtalk import ( + DingTalkIMProviderAdapter, + _DingTalkDirectory, + _DingTalkMessaging, +) +from core.human_input_v2.im_integration.adapters.entities import Avatar, IMProviderError + + +class _ProviderError(Exception): + def __init__(self, *, status_code: int | None = None) -> None: + super().__init__("sanitized provider response that must not escape") + self.status_code = status_code + + +@dataclass(frozen=True, slots=True) +class _MalformedTokenResponse: + body: object | None + status_code: int = 200 + + +class _FakeOAuthClient: + def __init__(self, *responses: object) -> None: + self.responses = list(responses) + self.calls: list[tuple[str, GetTokenRequest]] = [] + + def get_token(self, corp_id: str, request: GetTokenRequest) -> object: + self.calls.append((corp_id, request)) + response = self.responses.pop(0) + if isinstance(response, Exception): + raise response + return response + + +class _FakeTokenProvider: + def __init__(self, *responses: str | Exception) -> None: + self.responses = list(responses) + self.calls = 0 + + def get(self) -> str: + self.calls += 1 + response = self.responses.pop(0) + if isinstance(response, Exception): + raise response + return response + + +class _FakeRobotClient: + def __init__(self, *responses: object) -> None: + self.responses = list(responses) + self.calls: list[tuple[BatchSendOTORequest, BatchSendOTOHeaders, RuntimeOptions]] = [] + + def batch_send_otowith_options( + self, + request: BatchSendOTORequest, + headers: BatchSendOTOHeaders, + runtime: RuntimeOptions, + ) -> object: + self.calls.append((request, headers, runtime)) + response = self.responses.pop(0) + if isinstance(response, Exception): + raise response + return response + + +@dataclass(frozen=True) +class _RecordedRequest: + path: str + token: str | None + body: dict[str, object] + method: str = "POST" + + +class _FakeDirectoryHTTPClient: + def __init__( + self, + route: Callable[[_RecordedRequest], tuple[int, dict[str, object]]], + recorded: list[_RecordedRequest] | None = None, + ) -> None: + self._route = route + self._recorded = recorded + self.close_calls = 0 + + def post(self, url: str, *, access_token: str, body: dict[str, int]) -> dingtalk_module._DirectoryHTTPResponse: + parsed = _RecordedRequest( + path=urlparse(url).path, + token=access_token, + body=dict(body), + ) + if self._recorded is not None: + self._recorded.append(parsed) + status_code, payload = self._route(parsed) + if not 200 <= status_code < 300: + raise urllib_error.HTTPError( + url, status_code, "provider HTTP failure", Message(), BytesIO(json.dumps(payload).encode()) + ) + return dingtalk_module._DirectoryHTTPResponse(json.dumps(payload, separators=(",", ":")).encode(), status_code) + + def get(self, url: str, *, access_token: str) -> dingtalk_module._DirectoryHTTPResponse: + parsed = _RecordedRequest( + path=urlparse(url).path, + token=access_token, + body={}, + method="GET", + ) + if self._recorded is not None: + self._recorded.append(parsed) + status_code, payload = self._route(parsed) + if not 200 <= status_code < 300: + raise urllib_error.HTTPError( + url, status_code, "provider HTTP failure", Message(), BytesIO(json.dumps(payload).encode()) + ) + return dingtalk_module._DirectoryHTTPResponse(json.dumps(payload, separators=(",", ":")).encode(), status_code) + + def close(self) -> None: + self.close_calls += 1 + + +class _Consumer: + def accept(self, event: object, *, request_ip_address: str | None = None) -> EventAcceptance: + del request_ip_address + del event + return EventAcceptance.ACCEPTED + + +def _token_response(token: str = "sanitized-access-token", expires_in: int = 7200) -> GetTokenResponse: + return GetTokenResponse(body=GetTokenResponseBody(access_token=token, expires_in=expires_in)) + + +def _send_response( + *, + status_code: int = 200, + process_query_key: str | None = "sanitized-process-key", + filtered: list[str] | None = None, + flow_controlled: list[str] | None = None, + invalid: list[str] | None = None, +) -> BatchSendOTOResponse: + return BatchSendOTOResponse().from_map( + { + "statusCode": status_code, + "body": { + "processQueryKey": process_query_key, + "filteredStaffIdList": filtered, + "flowControlledStaffIdList": flow_controlled, + "invalidStaffIdList": invalid, + }, + } + ) + + +def _credentials( + *, + corp_id: str = "sanitized-corp-id", + client_id: str = "sanitized-client-id", + client_secret: str = "sanitized-client-secret", +) -> DingTalkCredentials: + return DingTalkCredentials( + provider=IMProvider.DING_TALK, + corp_id=corp_id, + client_id=client_id, + client_secret=client_secret, + ) + + +def _http_client( + route: Callable[[_RecordedRequest], tuple[int, dict[str, object]]], + recorded: list[_RecordedRequest] | None = None, +) -> _FakeDirectoryHTTPClient: + return _FakeDirectoryHTTPClient(route, recorded) + + +def _successful_directory_route(request: _RecordedRequest) -> tuple[int, dict[str, object]]: + if request.path.endswith("/auth/scopes"): + return 200, { + "errcode": 0, + "errmsg": "ok", + "auth_org_scopes": { + "authed_dept": [1, 2], + "authed_user": ["sanitized-redundant-user"], + }, + } + if request.path.endswith("/department/listsub"): + department_id = request.body["dept_id"] + children: list[dict[str, int]] = [{"dept_id": 2}] if department_id == 1 else [] + return 200, {"errcode": 0, "result": children} + assert request.path.endswith("/user/list") + department_id = request.body["dept_id"] + cursor = request.body["cursor"] + if department_id == 1 and cursor == 0: + return 200, { + "errcode": 0, + "result": { + "has_more": True, + "next_cursor": 7, + "list": [ + { + "userid": "sanitized-user-1", + "name": "Sanitized User One", + "email": "one@example.com", + "active": True, + } + ], + }, + } + if department_id == 1 and cursor == 7: + return 200, { + "errcode": 0, + "result": { + "has_more": False, + "list": [ + { + "userid": "sanitized-user-2", + "active": False, + } + ], + }, + } + assert department_id == 2 + return 200, { + "errcode": 0, + "result": { + "has_more": False, + "list": [ + { + "userid": "sanitized-user-2", + "name": "Duplicate User", + "email": "duplicate@example.com", + "active": False, + }, + { + "userid": "sanitized-user-3", + "name": " ", + "email": "", + "active": True, + }, + ], + }, + } + + +def test_construction_and_capability_inspection_perform_no_provider_io(monkeypatch: pytest.MonkeyPatch) -> None: + direct_client = _FakeOAuthClient() + provider_client = _FakeOAuthClient() + robot_client = _FakeRobotClient() + http_client = _http_client(lambda _request: pytest.fail("unexpected provider I/O")) + clients = iter((direct_client, provider_client)) + monkeypatch.setattr(dingtalk_module, "_new_oauth_client", lambda: next(clients)) + monkeypatch.setattr(dingtalk_module, "_new_robot_client", lambda: robot_client) + monkeypatch.setattr(dingtalk_module, "_new_http_client", lambda: http_client) + + adapter = DingTalkIMProviderAdapter(_credentials()) + + assert adapter.provider is IMProvider.DING_TALK + assert adapter.directory is not None + assert adapter.messaging is not None + assert adapter.dynamic_card_messaging is None + assert adapter.create_webhook_handler(_Consumer()) is None + assert adapter.create_stream_handler(_Consumer()) is None + assert direct_client.calls == [] + assert provider_client.calls == [] + assert robot_client.calls == [] + adapter.close() + adapter.close() + assert http_client.close_calls == 1 + + +def test_credential_test_always_calls_sdk_directly_and_bypasses_provider_cache( + monkeypatch: pytest.MonkeyPatch, +) -> None: + direct_client = _FakeOAuthClient(_token_response("direct-1"), _token_response("direct-2")) + provider_client = _FakeOAuthClient( + _token_response("provider-directory-token"), + _token_response("provider-messaging-token"), + ) + robot_client = _FakeRobotClient(_send_response()) + recorded: list[_RecordedRequest] = [] + http_client = _http_client(_successful_directory_route, recorded) + clients = iter((direct_client, provider_client)) + monkeypatch.setattr(dingtalk_module, "_new_oauth_client", lambda: next(clients)) + monkeypatch.setattr(dingtalk_module, "_new_robot_client", lambda: robot_client) + monkeypatch.setattr(dingtalk_module, "_new_http_client", lambda: http_client) + adapter = DingTalkIMProviderAdapter(_credentials()) + + first = adapter.test_credentials() + directory = adapter.directory.read_directory() + message = adapter.messaging.send_text(ProviderUserId("sanitized-user-1"), "Sanitized body") + second = adapter.test_credentials() + + assert first == CredentialTestSuccess(IMProvider.DING_TALK, "sanitized-corp-id") + assert second == first + assert isinstance(directory, Directory) + assert isinstance(message, MessageAccepted) + assert len(direct_client.calls) == 2 + assert len(provider_client.calls) == 2 + assert [request.client_id for _, request in direct_client.calls] == [ + "sanitized-client-id", + "sanitized-client-id", + ] + assert [request.client_secret for _, request in direct_client.calls] == [ + "sanitized-client-secret", + "sanitized-client-secret", + ] + assert [corp_id for corp_id, _ in direct_client.calls] == ["sanitized-corp-id", "sanitized-corp-id"] + baseline_tokens = [request.token for request in recorded if request.path.endswith("/department/listsub")] + scope_tokens = [request.token for request in recorded if request.path.endswith("/auth/scopes")] + assert scope_tokens == ["direct-1", "direct-2"] + assert "direct-1" in baseline_tokens + assert "direct-2" in baseline_tokens + + +def test_credential_test_verifies_complete_member_scope_before_root_baseline( + monkeypatch: pytest.MonkeyPatch, +) -> None: + clients = iter((_FakeOAuthClient(_token_response("direct-scope-token")), _FakeOAuthClient())) + recorded: list[_RecordedRequest] = [] + monkeypatch.setattr(dingtalk_module, "_new_oauth_client", lambda: next(clients)) + monkeypatch.setattr(dingtalk_module, "_new_robot_client", _FakeRobotClient) + monkeypatch.setattr( + dingtalk_module, "_new_http_client", lambda: _http_client(_successful_directory_route, recorded) + ) + + result = DingTalkIMProviderAdapter(_credentials()).test_credentials() + + assert result == CredentialTestSuccess(IMProvider.DING_TALK, "sanitized-corp-id") + assert [(request.method, request.path) for request in recorded] == [ + ("GET", "/auth/scopes"), + ("POST", "/topapi/v2/department/listsub"), + ] + assert {request.token for request in recorded} == {"direct-scope-token"} + + +@pytest.mark.parametrize( + "organization_scope", + [ + {"authed_dept": [2, 3], "authed_user": []}, + {"authed_dept": [], "authed_user": ["sanitized-direct-user"]}, + {"authed_dept": [], "authed_user": []}, + ], +) +def test_credential_test_rejects_partial_member_scope_without_root_baseline( + monkeypatch: pytest.MonkeyPatch, + organization_scope: dict[str, object], +) -> None: + clients = iter((_FakeOAuthClient(_token_response("direct-partial-scope-token")), _FakeOAuthClient())) + recorded: list[_RecordedRequest] = [] + + def route(request: _RecordedRequest) -> tuple[int, dict[str, object]]: + if request.path.endswith("/auth/scopes"): + return 200, { + "errcode": 0, + "errmsg": "ok", + "auth_org_scopes": organization_scope, + } + pytest.fail("partial authorization must not proceed to the root baseline") + + monkeypatch.setattr(dingtalk_module, "_new_oauth_client", lambda: next(clients)) + monkeypatch.setattr(dingtalk_module, "_new_robot_client", _FakeRobotClient) + monkeypatch.setattr(dingtalk_module, "_new_http_client", lambda: _http_client(route, recorded)) + + result = DingTalkIMProviderAdapter(_credentials()).test_credentials() + + assert result == CredentialTestFailure( + CredentialTestFailureKind.PERMISSION_DENIED, + "DingTalk could not verify complete member authorization.", + ) + assert [(request.method, request.path) for request in recorded] == [("GET", "/auth/scopes")] + + +@pytest.mark.parametrize( + "scope_result", + [ + pytest.param({"errcode": 0, "errmsg": "ok"}, id="missing-scope"), + pytest.param( + { + "errcode": 0, + "errmsg": "ok", + "auth_org_scopes": {"authed_user": []}, + }, + id="missing-departments", + ), + pytest.param( + { + "errcode": 0, + "errmsg": "ok", + "auth_org_scopes": {"authed_dept": [1]}, + }, + id="missing-users", + ), + pytest.param( + { + "errcode": 0, + "errmsg": "ok", + "auth_org_scopes": {"authed_dept": ["1"], "authed_user": []}, + }, + id="string-root-department", + ), + pytest.param( + { + "errcode": 0, + "errmsg": "ok", + "auth_org_scopes": {"authed_dept": None, "authed_user": []}, + }, + id="null-departments", + ), + pytest.param( + { + "errcode": 0, + "errmsg": "ok", + "auth_org_scopes": {"authed_dept": {"root": 1}, "authed_user": []}, + }, + id="wrong-department-container", + ), + pytest.param( + { + "errcode": 0, + "errmsg": "ok", + "auth_org_scopes": {"authed_dept": [True], "authed_user": []}, + }, + id="boolean-root-department", + ), + pytest.param( + { + "errcode": 0, + "errmsg": "ok", + "auth_org_scopes": {"authed_dept": [1, "2"], "authed_user": []}, + }, + id="invalid-department-element-with-root", + ), + pytest.param( + { + "errcode": 0, + "errmsg": "ok", + "auth_org_scopes": {"authed_dept": [1], "authed_user": None}, + }, + id="null-users", + ), + pytest.param( + { + "errcode": 0, + "errmsg": "ok", + "auth_org_scopes": {"authed_dept": [1], "authed_user": "sanitized-user"}, + }, + id="wrong-user-container", + ), + pytest.param( + { + "errcode": 0, + "errmsg": "ok", + "auth_org_scopes": {"authed_dept": [1], "authed_user": [123]}, + }, + id="invalid-user-element-with-root", + ), + pytest.param( + {"errcode": 60011, "errmsg": "sanitized provider detail"}, + id="provider-rejected", + ), + pytest.param(OSError("sanitized transport detail"), id="transport-failure"), + ], +) +def test_credential_test_fails_closed_when_member_scope_is_unverifiable( + monkeypatch: pytest.MonkeyPatch, + scope_result: dict[str, object] | Exception, +) -> None: + clients = iter((_FakeOAuthClient(_token_response("direct-unverifiable-scope-token")), _FakeOAuthClient())) + recorded: list[_RecordedRequest] = [] + + def route(request: _RecordedRequest) -> tuple[int, dict[str, object]]: + if not request.path.endswith("/auth/scopes"): + pytest.fail("unverifiable authorization must not proceed to the root baseline") + if isinstance(scope_result, Exception): + raise scope_result + return 200, scope_result + + monkeypatch.setattr(dingtalk_module, "_new_oauth_client", lambda: next(clients)) + monkeypatch.setattr(dingtalk_module, "_new_robot_client", _FakeRobotClient) + monkeypatch.setattr(dingtalk_module, "_new_http_client", lambda: _http_client(route, recorded)) + + if isinstance(scope_result, dict) and scope_result.get("errcode") == 60011: + result = DingTalkIMProviderAdapter(_credentials()).test_credentials() + assert isinstance(result, CredentialTestFailure) + assert result.kind is CredentialTestFailureKind.UNKNOWN + assert result.provider_error_code == "60011" + assert result.http_status == 200 + else: + expected_error = type(scope_result) if isinstance(scope_result, Exception) else ValueError + with pytest.raises(expected_error) as captured: + DingTalkIMProviderAdapter(_credentials()).test_credentials() + if isinstance(scope_result, Exception): + assert captured.value is scope_result + assert [(request.method, request.path) for request in recorded] == [("GET", "/auth/scopes")] + + +def test_credential_test_fails_closed_on_invalid_scope_json_without_leaking_body( + monkeypatch: pytest.MonkeyPatch, +) -> None: + class InvalidJSONHTTPClient: + def get(self, url: str, *, access_token: str) -> dingtalk_module._DirectoryHTTPResponse: + del url, access_token + return dingtalk_module._DirectoryHTTPResponse(b'{"secret":"sanitized-sensitive-value"', 200) + + def post(self, url: str, *, access_token: str, body: dict[str, int]) -> dingtalk_module._DirectoryHTTPResponse: + del url, access_token, body + pytest.fail("invalid scope JSON must not proceed to the root baseline") + + def close(self) -> None: + return None + + clients = iter((_FakeOAuthClient(_token_response("direct-invalid-json-token")), _FakeOAuthClient())) + monkeypatch.setattr(dingtalk_module, "_new_oauth_client", lambda: next(clients)) + monkeypatch.setattr(dingtalk_module, "_new_robot_client", _FakeRobotClient) + monkeypatch.setattr(dingtalk_module, "_new_http_client", InvalidJSONHTTPClient) + + with pytest.raises(ValidationError): + DingTalkIMProviderAdapter(_credentials()).test_credentials() + + +def test_credential_test_fails_closed_on_scope_http_error_without_root_baseline( + monkeypatch: pytest.MonkeyPatch, +) -> None: + clients = iter((_FakeOAuthClient(_token_response("direct-http-error-token")), _FakeOAuthClient())) + recorded: list[_RecordedRequest] = [] + + def route(request: _RecordedRequest) -> tuple[int, dict[str, object]]: + if not request.path.endswith("/auth/scopes"): + pytest.fail("scope HTTP failure must not proceed to the root baseline") + return 503, {"errcode": 0, "errmsg": "sanitized unexpected body"} + + monkeypatch.setattr(dingtalk_module, "_new_oauth_client", lambda: next(clients)) + monkeypatch.setattr(dingtalk_module, "_new_robot_client", _FakeRobotClient) + monkeypatch.setattr(dingtalk_module, "_new_http_client", lambda: _http_client(route, recorded)) + + result = DingTalkIMProviderAdapter(_credentials()).test_credentials() + assert isinstance(result, CredentialTestFailure) + assert result.http_status == 503 + assert result.provider_error_code == "0" + assert [(request.method, request.path) for request in recorded] == [("GET", "/auth/scopes")] + + +def test_changed_credentials_use_a_new_adapter_direct_sdk_call(monkeypatch: pytest.MonkeyPatch) -> None: + direct_clients = iter((_FakeOAuthClient(_token_response("first")), _FakeOAuthClient(_token_response("second")))) + provider_clients = iter((_FakeOAuthClient(), _FakeOAuthClient())) + created_direct: list[_FakeOAuthClient] = [] + invocation = 0 + + def new_oauth_client() -> _FakeOAuthClient: + nonlocal invocation + invocation += 1 + if invocation % 2: + client = next(direct_clients) + created_direct.append(client) + return client + return next(provider_clients) + + monkeypatch.setattr(dingtalk_module, "_new_oauth_client", new_oauth_client) + monkeypatch.setattr(dingtalk_module, "_new_robot_client", _FakeRobotClient) + monkeypatch.setattr(dingtalk_module, "_new_http_client", lambda: _http_client(_successful_directory_route)) + + first = DingTalkIMProviderAdapter(_credentials(client_secret="sanitized-old-secret")) + second = DingTalkIMProviderAdapter(_credentials(client_secret="sanitized-new-secret")) + + assert isinstance(first.test_credentials(), CredentialTestSuccess) + assert isinstance(second.test_credentials(), CredentialTestSuccess) + assert created_direct[0].calls[0][1].client_secret == "sanitized-old-secret" + assert created_direct[1].calls[0][1].client_secret == "sanitized-new-secret" + + +@pytest.mark.parametrize( + ("token_response", "expected_kind"), + [ + (_ProviderError(status_code=401), CredentialTestFailureKind.AUTHENTICATION_REJECTED), + (_ProviderError(status_code=500), CredentialTestFailureKind.UNKNOWN), + (_token_response(token=""), CredentialTestFailureKind.UNKNOWN), + (_token_response(expires_in=0), CredentialTestFailureKind.UNKNOWN), + ], +) +def test_credential_test_normalizes_sdk_failures_without_provider_material( + monkeypatch: pytest.MonkeyPatch, + token_response: object, + expected_kind: CredentialTestFailureKind, +) -> None: + clients = iter((_FakeOAuthClient(token_response), _FakeOAuthClient())) + monkeypatch.setattr(dingtalk_module, "_new_oauth_client", lambda: next(clients)) + monkeypatch.setattr(dingtalk_module, "_new_robot_client", _FakeRobotClient) + monkeypatch.setattr(dingtalk_module, "_new_http_client", lambda: _http_client(_successful_directory_route)) + + del expected_kind + expected_error = type(token_response) if isinstance(token_response, Exception) else IMProviderError + with pytest.raises(expected_error) as captured: + DingTalkIMProviderAdapter(_credentials()).test_credentials() + if isinstance(token_response, Exception): + assert captured.value is token_response + + +def test_credential_test_preserves_oauth_provider_error_code(monkeypatch: pytest.MonkeyPatch) -> None: + clients = iter( + ( + _FakeOAuthClient( + TeaException( + { + "code": "InvalidClientSecret", + "message": "sanitized-client-secret", + "data": {"statusCode": 401}, + } + ) + ), + _FakeOAuthClient(), + ) + ) + monkeypatch.setattr(dingtalk_module, "_new_oauth_client", lambda: next(clients)) + monkeypatch.setattr(dingtalk_module, "_new_robot_client", _FakeRobotClient) + monkeypatch.setattr(dingtalk_module, "_new_http_client", lambda: _http_client(_successful_directory_route)) + + result = DingTalkIMProviderAdapter(_credentials()).test_credentials() + + assert isinstance(result, CredentialTestFailure) + assert result.provider_error_code == "InvalidClientSecret" + assert result.missing_permissions == () + assert "sanitized-client-secret" not in repr(result) + + +def test_credential_test_requires_root_baseline_permission(monkeypatch: pytest.MonkeyPatch) -> None: + clients = iter((_FakeOAuthClient(_token_response()), _FakeOAuthClient())) + monkeypatch.setattr(dingtalk_module, "_new_oauth_client", lambda: next(clients)) + monkeypatch.setattr(dingtalk_module, "_new_robot_client", _FakeRobotClient) + + def route(request: _RecordedRequest) -> tuple[int, dict[str, object]]: + if request.path.endswith("/auth/scopes"): + return 200, { + "errcode": 0, + "errmsg": "ok", + "auth_org_scopes": {"authed_dept": [1], "authed_user": []}, + } + return 200, {"errcode": 60011, "errmsg": "sanitized raw detail"} + + monkeypatch.setattr(dingtalk_module, "_new_http_client", lambda: _http_client(route)) + + result = DingTalkIMProviderAdapter(_credentials()).test_credentials() + + assert result == CredentialTestFailure( + CredentialTestFailureKind.TENANT_ID_UNAVAILABLE, + "DingTalk could not verify the configured corporation directory boundary.", + provider_error_code="60011", + http_status=200, + ) + + +def test_directory_traverses_all_departments_and_pages_with_stable_deduplication() -> None: + token_provider = _FakeTokenProvider("sanitized-access-token") + recorded: list[_RecordedRequest] = [] + directory = _DingTalkDirectory(token_provider, _http_client(_successful_directory_route, recorded)) + + result = directory.read_directory() + + assert result == Directory( + ( + dingtalk_module.DirectoryEntry( + ProviderUserId("sanitized-user-1"), + "Sanitized User One", + "one@example.com", + ), + dingtalk_module.DirectoryEntry(ProviderUserId("sanitized-user-2"), None, None), + dingtalk_module.DirectoryEntry(ProviderUserId("sanitized-user-3"), None, None), + ) + ) + assert token_provider.calls == 1 + assert {request.token for request in recorded} == {"sanitized-access-token"} + assert [request.body["cursor"] for request in recorded if request.path.endswith("/user/list")][:2] == [0, 7] + + +def test_directory_discards_partial_entries_when_a_later_page_fails() -> None: + token_provider = _FakeTokenProvider("sanitized-access-token") + + def route(request: _RecordedRequest) -> tuple[int, dict[str, object]]: + if request.path.endswith("/department/listsub"): + return 200, {"errcode": 0, "result": []} + if request.body["cursor"] == 0: + return 200, { + "errcode": 0, + "result": { + "has_more": True, + "next_cursor": 9, + "list": [{"userid": "sanitized-partial-user"}], + }, + } + return 200, {"errcode": 0, "result": {"has_more": "invalid", "list": []}} + + result = _DingTalkDirectory(token_provider, _http_client(route)).read_directory() + + assert result == DirectoryReadFailure("DingTalk directory could not be read completely.") + assert token_provider.calls == 1 + + +@pytest.mark.parametrize( + "response", + [ + _send_response(process_query_key=None), + _send_response(filtered=["sanitized-user"]), + _send_response(flow_controlled=["sanitized-user"]), + _send_response(invalid=["sanitized-user"]), + _ProviderError(status_code=500), + ], +) +def test_messaging_requires_complete_acceptance_and_persistable_reference(response: object) -> None: + token_provider = _FakeTokenProvider("sanitized-access-token") + robot = _FakeRobotClient(response) + messaging = _DingTalkMessaging("sanitized-client-id", token_provider, robot) + + result = messaging.send_text(ProviderUserId("sanitized-user"), "Rendered **CommonMark** body") + + assert result == MessageSendingError("DingTalk message acceptance could not be confirmed.") + assert token_provider.calls == 1 + assert len(robot.calls) == 1 + + +def test_messaging_calls_sdk_once_and_returns_opaque_process_reference() -> None: + token_provider = _FakeTokenProvider("sanitized-access-token") + robot = _FakeRobotClient(_send_response()) + messaging = _DingTalkMessaging("sanitized-client-id", token_provider, robot) + + result = messaging.send_text(ProviderUserId("sanitized-user"), "Rendered **CommonMark** body") + + assert isinstance(result, MessageAccepted) + assert token_provider.calls == 1 + assert len(robot.calls) == 1 + request, headers, runtime = robot.calls[0] + assert request.msg_key == "sampleText" + assert json.loads(request.msg_param) == {"content": "Rendered **CommonMark** body"} + assert request.robot_code == "sanitized-client-id" + assert request.user_ids == ["sanitized-user"] + assert headers.x_acs_dingtalk_access_token == "sanitized-access-token" + assert runtime.autoretry is False + assert runtime.max_attempts == 1 + assert isinstance(result.locator, str) + assert type(result.locator) is str + assert dingtalk_module._DingTalkLocatorPayload.decode( + str(result.locator) + ) == dingtalk_module._DingTalkLocatorPayload( + v=1, + p=IMProvider.DING_TALK, + process_query_key="sanitized-process-key", + ) + + +_SANITIZED_PROTOCOL_FIXTURE = ( + Path(__file__).parents[4] / "fixtures" / "im_provider" / "dingtalk" / "sanitized_protocol.json" +) + + +def _assert_sanitized_fixture(value: object, *, field_name: str = "") -> None: + if isinstance(value, dict): + for key, nested in value.items(): + normalized_key = key.casefold().replace("-", "_") + assert normalized_key != "authorization" + _assert_sanitized_fixture(nested, field_name=normalized_key) + return + if isinstance(value, list): + for nested in value: + _assert_sanitized_fixture(nested, field_name=field_name) + return + if not isinstance(value, str): + return + assert not value.casefold().startswith("bearer ") + if field_name in {"access_token", "client_secret", "encrypted_client_secret"}: + assert value.startswith("fake-") + if field_name in { + "corp_id", + "userid", + "unionid", + "manager_userid", + "processquerykey", + "request_id", + "login_id", + }: + assert not value or value.startswith("fake-") + + +def _load_sanitized_protocol_fixture() -> dict[str, object]: + payload = json.loads(_SANITIZED_PROTOCOL_FIXTURE.read_text()) + assert isinstance(payload, dict) + return payload + + +def test_sanitized_protocol_fixture_is_complete_and_rejects_sensitive_values() -> None: + fixture = _load_sanitized_protocol_fixture() + + _assert_sanitized_fixture(fixture) + assert set(fixture) == {"authorization_scope", "credential_test", "directory", "message"} + credential_test = fixture["credential_test"] + authorization_scope = fixture["authorization_scope"] + message = fixture["message"] + assert isinstance(credential_test, dict) + assert set(credential_test) == {"headers", "statusCode", "body"} + assert isinstance(authorization_scope, dict) + assert authorization_scope["request"] == {"method": "GET", "path": "/auth/scopes"} + assert isinstance(authorization_scope["response"], dict) + assert isinstance(message, dict) + assert set(message) == {"headers", "statusCode", "body"} + directory = fixture["directory"] + assert isinstance(directory, list) + assert all(isinstance(exchange, dict) and set(exchange) == {"request", "response"} for exchange in directory) + + with pytest.raises(AssertionError): + _assert_sanitized_fixture({"Authorization": "Bearer fake-placeholder"}) + with pytest.raises(AssertionError): + _assert_sanitized_fixture({"access_token": "non-fake-token-material"}) + with pytest.raises(AssertionError): + _assert_sanitized_fixture({"userid": "non-fake-user-identifier"}) + + +def test_default_token_provider_uses_complete_sdk_responses_without_cache() -> None: + client = _FakeOAuthClient( + GetTokenResponse( + headers={"content-type": "application/json", "x-acs-request-id": "fake-request-token-001"}, + status_code=200, + body=GetTokenResponseBody(access_token="fake-access-token-001", expires_in=7200), + ), + GetTokenResponse( + headers={"content-type": "application/json", "x-acs-request-id": "fake-request-token-002"}, + status_code=200, + body=GetTokenResponseBody(access_token="fake-access-token-002", expires_in=3600), + ), + ) + provider = dingtalk_module._SDKAccessTokenProvider(_credentials(), client) + + assert provider.get() == "fake-access-token-001" + assert provider.get() == "fake-access-token-002" + assert len(client.calls) == 2 + + +@pytest.mark.parametrize( + "response", + [ + _MalformedTokenResponse(body=None), + GetTokenResponse().from_map({"statusCode": 200, "body": {"access_token": "fake-token", "expires_in": True}}), + GetTokenResponse().from_map({"statusCode": 200, "body": {"access_token": "fake-token", "expires_in": -1}}), + GetTokenResponse().from_map({"body": {"access_token": object(), "expires_in": 7200}}), + ], +) +def test_default_token_provider_rejects_malformed_sdk_responses(response: object) -> None: + provider = dingtalk_module._SDKAccessTokenProvider(_credentials(), _FakeOAuthClient(response)) + + with pytest.raises(IMProviderError, match="DingTalk returned an invalid access token response") as captured: + provider.get() + assert isinstance(captured.value.__cause__, ValidationError) + assert "body" in str(captured.value) + + +def test_adapter_rejects_non_resolved_credentials_before_client_construction(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(dingtalk_module, "_new_oauth_client", lambda: pytest.fail("unexpected client construction")) + adapter_type: Callable[..., object] = dingtalk_module.DingTalkIMProviderAdapter + + with pytest.raises(TypeError, match="resolved DingTalk credentials"): + adapter_type(object()) + + +def test_credential_test_normalizes_unknown_root_boundary_failures(monkeypatch: pytest.MonkeyPatch) -> None: + clients = iter((_FakeOAuthClient(_token_response()), _FakeOAuthClient())) + monkeypatch.setattr(dingtalk_module, "_new_oauth_client", lambda: next(clients)) + monkeypatch.setattr(dingtalk_module, "_new_robot_client", _FakeRobotClient) + + def fail_root(request: _RecordedRequest) -> tuple[int, dict[str, object]]: + if request.path.endswith("/auth/scopes"): + return 200, { + "errcode": 0, + "errmsg": "ok", + "auth_org_scopes": {"authed_dept": [1], "authed_user": []}, + } + raise OSError("fake raw transport detail") + + monkeypatch.setattr(dingtalk_module, "_new_http_client", lambda: _http_client(fail_root)) + + with pytest.raises(OSError, match="fake raw transport detail"): + DingTalkIMProviderAdapter(_credentials()).test_credentials() + + +def test_directory_fixture_preserves_full_traversal_and_minimal_projection(mocker: MockerFixture) -> None: + mocker.patch( + "core.file.remote_fetcher.make_request", + return_value=httpx.Response( + 200, + headers={"Content-Type": "image/png"}, + content=b"avatar-image", + request=httpx.Request("GET", "https://example.invalid/fake-user-001.png"), + ), + ) + fixture = _load_sanitized_protocol_fixture() + exchanges = fixture["directory"] + assert isinstance(exchanges, list) + queued = list(exchanges) + recorded: list[_RecordedRequest] = [] + + def route(request: _RecordedRequest) -> tuple[int, dict[str, object]]: + for index, exchange in enumerate(queued): + assert isinstance(exchange, dict) + expected_request = exchange["request"] + assert isinstance(expected_request, dict) + if expected_request == {"path": request.path, "body": request.body}: + response = exchange["response"] + assert isinstance(response, dict) + queued.pop(index) + return 200, response + raise AssertionError(f"unexpected sanitized fixture request: {request.path} {request.body}") + + result = _DingTalkDirectory( + _FakeTokenProvider("fake-access-token-directory-001"), + _http_client(route, recorded), + ).read_directory() + + assert result == Directory( + ( + dingtalk_module.DirectoryEntry( + ProviderUserId("fake-user-001"), + "Fake User One", + "fake.user.one@example.invalid", + avatar=Avatar(mime_type="image/png", data=b"avatar-image"), + ), + dingtalk_module.DirectoryEntry(ProviderUserId("fake-user-002"), None, None), + dingtalk_module.DirectoryEntry(ProviderUserId("fake-user-003"), None, None), + ) + ) + assert queued == [] + assert all(set(request.body) <= {"dept_id", "cursor", "size"} for request in recorded) + + +@pytest.mark.parametrize("avatar", [None, "", " "]) +def test_directory_accepts_unavailable_avatar(avatar: str | None) -> None: + def route(request: _RecordedRequest) -> tuple[int, dict[str, object]]: + if request.path.endswith("/department/listsub"): + return 200, {"errcode": 0, "result": []} + return 200, { + "errcode": 0, + "result": {"has_more": False, "list": [{"userid": "user", "avatar": avatar}]}, + } + + result = _DingTalkDirectory(_FakeTokenProvider("token"), _http_client(route)).read_directory() + + assert isinstance(result, Directory) + assert result.entries[0].avatar is None + + +def test_directory_ignores_repeated_department_edges_without_revisiting() -> None: + recorded: list[_RecordedRequest] = [] + + def route(request: _RecordedRequest) -> tuple[int, dict[str, object]]: + if request.path.endswith("/department/listsub"): + children: list[dict[str, int]] = ( + [{"dept_id": 1}, {"dept_id": 2}, {"dept_id": 2}] if request.body["dept_id"] == 1 else [] + ) + return 200, {"errcode": 0, "errmsg": "ok", "result": children} + return 200, {"errcode": 0, "errmsg": "ok", "result": {"has_more": False, "list": []}} + + result = _DingTalkDirectory( + _FakeTokenProvider("fake-access-token-directory-001"), + _http_client(route, recorded), + ).read_directory() + + assert result == Directory(()) + department_ids = [request.body["dept_id"] for request in recorded if request.path.endswith("/department/listsub")] + assert department_ids == [1, 2] + + +@pytest.mark.parametrize( + "route", + [ + lambda _request: (200, {"errcode": 0, "errmsg": "ok"}), + lambda _request: (200, {"errcode": 40035, "errmsg": "fake provider detail"}), + lambda _request: (200, {"errcode": "invalid", "errmsg": "fake malformed detail"}), + ], +) +def test_directory_normalizes_root_provider_and_parse_failures( + route: Callable[[_RecordedRequest], tuple[int, dict[str, object]]], +) -> None: + result = _DingTalkDirectory( + _FakeTokenProvider("fake-access-token-directory-001"), + _http_client(route), + ).read_directory() + + assert result == DirectoryReadFailure("DingTalk directory could not be read completely.") + + +def test_directory_rejects_blank_canonical_user_id_without_partial_result() -> None: + def route(request: _RecordedRequest) -> tuple[int, dict[str, object]]: + if request.path.endswith("/department/listsub"): + return 200, {"errcode": 0, "errmsg": "ok", "result": []} + return 200, { + "errcode": 0, + "errmsg": "ok", + "result": { + "has_more": False, + "list": [ + {"userid": "fake-user-before-failure", "name": "Fake User"}, + {"userid": " ", "name": "Fake Invalid User"}, + ], + }, + } + + result = _DingTalkDirectory( + _FakeTokenProvider("fake-access-token-directory-001"), + _http_client(route), + ).read_directory() + + assert result == DirectoryReadFailure("DingTalk directory could not be read completely.") + + +def test_directory_rejects_missing_or_repeated_pagination_cursor() -> None: + def route(request: _RecordedRequest) -> tuple[int, dict[str, object]]: + if request.path.endswith("/department/listsub"): + return 200, {"errcode": 0, "errmsg": "ok", "result": []} + return 200, { + "errcode": 0, + "errmsg": "ok", + "result": {"has_more": True, "next_cursor": 0, "list": []}, + } + + result = _DingTalkDirectory( + _FakeTokenProvider("fake-access-token-directory-001"), + _http_client(route), + ).read_directory() + + assert result == DirectoryReadFailure("DingTalk directory could not be read completely.") + + +def test_directory_token_failure_does_not_call_http() -> None: + http_client = _http_client(lambda _request: pytest.fail("unexpected HTTP call")) + + result = _DingTalkDirectory(_FakeTokenProvider(RuntimeError("fake token detail")), http_client).read_directory() + + assert result == DirectoryReadFailure("DingTalk directory could not be read completely.") + + +class _URLResponse: + def __init__(self, body: bytes) -> None: + self._body = body + self.status = 200 + + def __enter__(self) -> _URLResponse: + return self + + def __exit__(self, *args: object) -> None: + del args + + def read(self) -> bytes: + return self._body + + +def test_urllib_directory_client_uses_fixed_post_shape(monkeypatch: pytest.MonkeyPatch) -> None: + recorded: list[tuple[urllib_request.Request, float]] = [] + + def urlopen(request: urllib_request.Request, *, timeout: float) -> _URLResponse: + recorded.append((request, timeout)) + return _URLResponse(b'{"errcode":0,"errmsg":"ok","result":[]}') + + monkeypatch.setattr(dingtalk_module.urllib_request, "urlopen", urlopen) + client = dingtalk_module._UrllibDirectoryHTTPClient() + + response = client.post( + dingtalk_module._DEPARTMENT_LIST_URL, + access_token="fake-access-token-directory-001", + body={"dept_id": 1}, + ) + + assert response.body == b'{"errcode":0,"errmsg":"ok","result":[]}' + assert response.http_status == 200 + request, timeout = recorded[0] + assert request.method == "POST" + assert request.full_url.startswith(dingtalk_module._DEPARTMENT_LIST_URL) + assert "access_token=fake-access-token-directory-001" in request.full_url + assert request.data == b'{"dept_id":1}' + assert timeout == dingtalk_module._HTTP_TIMEOUT_SECONDS + assert client.close() is None + + +def test_urllib_client_uses_fixed_authorization_scope_get_shape(monkeypatch: pytest.MonkeyPatch) -> None: + recorded: list[tuple[urllib_request.Request, float]] = [] + + def urlopen(request: urllib_request.Request, *, timeout: float) -> _URLResponse: + recorded.append((request, timeout)) + return _URLResponse(b'{"errcode":0,"errmsg":"ok","auth_org_scopes":{"authed_dept":[1],"authed_user":[]}}') + + monkeypatch.setattr(dingtalk_module.urllib_request, "urlopen", urlopen) + client = dingtalk_module._UrllibDirectoryHTTPClient() + + response = client.get( + "https://oapi.dingtalk.com/auth/scopes", + access_token="fake-access-token-scope-001", + ) + + assert response.body.startswith(b'{"errcode":0') + assert response.http_status == 200 + request, timeout = recorded[0] + assert request.method == "GET" + assert request.full_url.startswith("https://oapi.dingtalk.com/auth/scopes") + assert "access_token=fake-access-token-scope-001" in request.full_url + assert request.data is None + assert timeout == dingtalk_module._HTTP_TIMEOUT_SECONDS + + +@pytest.mark.parametrize( + "error", + [OSError("fake os error"), ValueError("fake value error"), urllib_error.URLError("fake URL error")], +) +def test_urllib_client_normalizes_authorization_scope_transport_errors( + monkeypatch: pytest.MonkeyPatch, + error: Exception, +) -> None: + def fail_urlopen(*args: object, **kwargs: object) -> object: + del args, kwargs + raise error + + monkeypatch.setattr(dingtalk_module.urllib_request, "urlopen", fail_urlopen) + + with pytest.raises(type(error)) as caught: + dingtalk_module._UrllibDirectoryHTTPClient().get( + "https://oapi.dingtalk.com/auth/scopes", + access_token="fake-access-token-scope-001", + ) + assert caught.value is error + + +@pytest.mark.parametrize( + "error", + [OSError("fake os error"), ValueError("fake value error"), urllib_error.URLError("fake URL error")], +) +def test_urllib_directory_client_normalizes_transport_errors( + monkeypatch: pytest.MonkeyPatch, + error: Exception, +) -> None: + def fail_urlopen(*args: object, **kwargs: object) -> object: + del args, kwargs + raise error + + monkeypatch.setattr(dingtalk_module.urllib_request, "urlopen", fail_urlopen) + + with pytest.raises(type(error)) as caught: + dingtalk_module._UrllibDirectoryHTTPClient().post( + dingtalk_module._USER_LIST_URL, + access_token="fake-access-token-directory-001", + body={"dept_id": 1, "cursor": 0, "size": 100}, + ) + assert caught.value is error + + +@pytest.mark.parametrize( + ("subject", "expected"), + [(None, "Exact **CommonMark** body"), ("### Review", "### Review\n\nExact **CommonMark** body")], +) +def test_messaging_accepts_complete_sdk_response_and_locator_round_trips_through_json_text( + subject: str | None, expected: str +) -> None: + token_provider = _FakeTokenProvider("fake-access-token-message-001") + response = BatchSendOTOResponse().from_map( + { + "headers": {"content-type": "application/json", "x-acs-request-id": "fake-request-message-001"}, + "statusCode": 200, + "body": { + "filteredStaffIdList": [], + "flowControlledStaffIdList": [], + "invalidStaffIdList": [], + "processQueryKey": "fake-process-query-key-001", + }, + } + ) + robot = _FakeRobotClient(response) + + result = _DingTalkMessaging("fake-client-id-001", token_provider, robot).send_text( + ProviderUserId("fake-user-001"), + "Exact **CommonMark** body", + subject, + ) + + assert isinstance(result, MessageAccepted) + persisted_locator = json.loads(json.dumps({"locator": str(result.locator)}, separators=(",", ":")))["locator"] + rehydrated = dingtalk_module.MessageLocator(persisted_locator) + assert rehydrated == result.locator + assert "fake-process-query-key-001" not in repr(rehydrated) + assert token_provider.calls == 1 + assert len(robot.calls) == 1 + assert json.loads(robot.calls[0][0].msg_param) == {"content": expected} + + +@pytest.mark.parametrize( + "response", + [ + BatchSendOTOResponse().from_map({"body": None}), + BatchSendOTOResponse().from_map({"statusCode": 200, "body": None}), + BatchSendOTOResponse().from_map({"statusCode": 200, "body": {"processQueryKey": " "}}), + BatchSendOTOResponse().from_map({"statusCode": 200, "body": {"processQueryKey": 42}}), + BatchSendOTOResponse().from_map({"statusCode": True, "body": {"processQueryKey": "fake-key"}}), + BatchSendOTOResponse().from_map({"statusCode": "200", "body": {"processQueryKey": "fake-key"}}), + BatchSendOTOResponse().from_map({"statusCode": 199, "body": {"processQueryKey": "fake-key"}}), + BatchSendOTOResponse().from_map({"statusCode": 300, "body": {"processQueryKey": "fake-key"}}), + BatchSendOTOResponse().from_map( + {"statusCode": 200, "body": {"processQueryKey": "fake-key", "invalidStaffIdList": "fake-user-001"}} + ), + BatchSendOTOResponse().from_map( + {"statusCode": 200, "body": {"processQueryKey": "fake-key", "filteredStaffIdList": object()}} + ), + ], +) +def test_messaging_rejects_malformed_or_partial_sdk_responses_once(response: object) -> None: + token_provider = _FakeTokenProvider("fake-access-token-message-001") + robot = _FakeRobotClient(response) + + result = _DingTalkMessaging("fake-client-id-001", token_provider, robot).send_text( + ProviderUserId("fake-user-001"), + "Exact body", + ) + + assert result == MessageSendingError("DingTalk message acceptance could not be confirmed.") + assert token_provider.calls == 1 + assert len(robot.calls) == 1 + + +def test_messaging_token_failure_performs_no_mutation() -> None: + token_provider = _FakeTokenProvider(RuntimeError("fake token failure")) + robot = _FakeRobotClient() + + result = _DingTalkMessaging("fake-client-id-001", token_provider, robot).send_text( + ProviderUserId("fake-user-001"), + "Exact body", + ) + + assert result == MessageSendingError("DingTalk message acceptance could not be confirmed.") + assert token_provider.calls == 1 + assert robot.calls == [] + + +def test_sdk_configuration_disables_implicit_endpoint_or_retry_state() -> None: + config = dingtalk_module._sdk_config() + + assert config.protocol == "https" + assert config.connect_timeout == dingtalk_module._SDK_TIMEOUT_MILLISECONDS + assert config.read_timeout == dingtalk_module._SDK_TIMEOUT_MILLISECONDS + assert config.endpoint is None diff --git a/api/tests/unit_tests/core/human_input_v2/im_provider/test_dingtalk_redis_token_provider.py b/api/tests/unit_tests/core/human_input_v2/im_provider/test_dingtalk_redis_token_provider.py new file mode 100644 index 00000000000..e1016da6a4f --- /dev/null +++ b/api/tests/unit_tests/core/human_input_v2/im_provider/test_dingtalk_redis_token_provider.py @@ -0,0 +1,338 @@ +from __future__ import annotations + +import re +from collections.abc import Callable +from typing import NoReturn + +import pytest +from alibabacloud_dingtalk.oauth2_1_0.models import GetTokenRequest, GetTokenResponse, GetTokenResponseBody +from redis import RedisError + +from core.human_input_v2.entities import IMProvider +from core.human_input_v2.im_integration.adapters import DingTalkCredentials, dingtalk_redis +from core.human_input_v2.im_integration.adapters.entities import IMProviderError + + +class _NoIOCache: + def get(self, name: str) -> NoReturn: + del name + pytest.fail("unexpected Redis read during construction") + + def ttl(self, name: str) -> NoReturn: + del name + pytest.fail("unexpected Redis TTL read during construction") + + def set(self, name: str, value: str, *, ex: int) -> NoReturn: + del name, value, ex + pytest.fail("unexpected Redis write during construction") + + +class _NoIOOAuthClient: + def get_token(self, corp_id: str, request: GetTokenRequest) -> NoReturn: + del corp_id, request + pytest.fail("unexpected DingTalk request during construction") + + +class _ProviderError(Exception): + def __init__(self, status_code: int) -> None: + super().__init__("provider detail must not escape") + self.status_code = status_code + + +class _FakeOAuthClient: + def __init__(self, *responses: object) -> None: + self.responses = list(responses) + self.calls: list[tuple[str, GetTokenRequest]] = [] + + def get_token(self, corp_id: str, request: GetTokenRequest) -> object: + self.calls.append((corp_id, request)) + if not self.responses: + pytest.fail("unexpected DingTalk request") + response = self.responses.pop(0) + if isinstance(response, Exception): + raise response + return response + + +class _FakeCache: + def __init__( + self, + value: object = None, + *, + ttl: object = 3600, + get_error: Exception | None = None, + ttl_error: Exception | None = None, + set_error: Exception | None = None, + ) -> None: + self.value = value + self.ttl_value = ttl + self.get_error = get_error + self.ttl_error = ttl_error + self.set_error = set_error + self.get_calls: list[str] = [] + self.ttl_calls: list[str] = [] + self.set_calls: list[tuple[str, str, int]] = [] + + def get(self, name: str) -> object: + self.get_calls.append(name) + if self.get_error is not None: + raise self.get_error + return self.value + + def ttl(self, name: str) -> object: + self.ttl_calls.append(name) + if self.ttl_error is not None: + raise self.ttl_error + return self.ttl_value + + def set(self, name: str, value: str, *, ex: int) -> object: + self.set_calls.append((name, value, ex)) + if self.set_error is not None: + raise self.set_error + return True + + +def _credentials( + *, + corp_id: str = "sanitized-corp-id", + client_id: str = "sanitized-client-id", + client_secret: str = "sanitized-client-secret", +) -> DingTalkCredentials: + return DingTalkCredentials( + provider=IMProvider.DING_TALK, + corp_id=corp_id, + client_id=client_id, + client_secret=client_secret, + ) + + +def _token_response(token: str = "sanitized-fresh-token", *, expires_in: int = 7200) -> GetTokenResponse: + return GetTokenResponse( + status_code=200, + body=GetTokenResponseBody(access_token=token, expires_in=expires_in), + ) + + +def _provider( + monkeypatch: pytest.MonkeyPatch, + cache: _FakeCache, + *responses: object, + credentials: DingTalkCredentials | None = None, +) -> tuple[dingtalk_redis.RedisCacheAccessTokenProvider, _FakeOAuthClient]: + client = _FakeOAuthClient(*responses) + monkeypatch.setattr(dingtalk_redis, "_new_oauth_client", lambda: client) + return dingtalk_redis.RedisCacheAccessTokenProvider(credentials or _credentials(), cache), client + + +def test_constructor_performs_no_cache_or_provider_io(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(dingtalk_redis, "_new_oauth_client", _NoIOOAuthClient) + + dingtalk_redis.RedisCacheAccessTokenProvider(_credentials(), _NoIOCache()) + + +def test_constructor_rejects_unresolved_credentials_before_client_or_cache_io( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(dingtalk_redis, "_new_oauth_client", lambda: pytest.fail("unexpected client construction")) + provider_type: Callable[..., object] = dingtalk_redis.RedisCacheAccessTokenProvider + + with pytest.raises(TypeError, match="resolved DingTalk credentials"): + provider_type(object(), _NoIOCache()) + + +def test_cache_miss_requests_once_and_writes_with_atomic_ttl(monkeypatch: pytest.MonkeyPatch) -> None: + cache = _FakeCache() + provider, client = _provider(monkeypatch, cache, _token_response()) + + assert provider.get() == "sanitized-fresh-token" + + assert len(client.calls) == 1 + corp_id, request = client.calls[0] + assert corp_id == "sanitized-corp-id" + assert request.client_id == "sanitized-client-id" + assert request.client_secret == "sanitized-client-secret" + assert request.grant_type == "client_credentials" + assert len(cache.get_calls) == 1 + assert cache.ttl_calls == [] + assert cache.set_calls == [(cache.get_calls[0], "sanitized-fresh-token", 7140)] + cache_key = cache.get_calls[0] + assert re.fullmatch(r"dify:human-input-v2:dingtalk:access-token:[0-9a-f]{64}", cache_key) + for sensitive_value in (*_credentials().model_dump().values(), "sanitized-fresh-token"): + assert str(sensitive_value) not in cache_key + + +@pytest.mark.parametrize("cached_value", [b"sanitized-cached-token", "sanitized-cached-token"]) +def test_cache_hit_accepts_text_or_utf8_bytes(monkeypatch: pytest.MonkeyPatch, cached_value: object) -> None: + cache = _FakeCache(cached_value, ttl=1) + provider, client = _provider(monkeypatch, cache) + + assert provider.get() == "sanitized-cached-token" + assert len(cache.ttl_calls) == 1 + assert cache.ttl_calls == cache.get_calls + assert cache.set_calls == [] + assert client.calls == [] + + +@pytest.mark.parametrize("ttl", [0, -1, -2, True, None, "1", 1.0]) +def test_non_strictly_positive_integer_ttl_is_a_cache_miss( + monkeypatch: pytest.MonkeyPatch, + ttl: object, +) -> None: + cache = _FakeCache("sanitized-stale-token", ttl=ttl) + provider, client = _provider(monkeypatch, cache, _token_response()) + + assert provider.get() == "sanitized-fresh-token" + assert len(client.calls) == 1 + assert len(cache.set_calls) == 1 + + +@pytest.mark.parametrize( + "cached_value", + [ + None, + b"", + b" ", + "", + "\t", + b"\xff", + 1, + True, + [], + {}, + object(), + bytearray(b"sanitized-token"), + ], +) +def test_invalid_cache_value_is_a_miss(monkeypatch: pytest.MonkeyPatch, cached_value: object) -> None: + cache = _FakeCache(cached_value) + provider, client = _provider(monkeypatch, cache, _token_response()) + + assert provider.get() == "sanitized-fresh-token" + assert len(client.calls) == 1 + assert cache.ttl_calls == [] + assert len(cache.set_calls) == 1 + + +@pytest.mark.parametrize(("expires_in", "expected_ttl"), [(61, 1), (60, None), (1, None)]) +def test_safety_margin_bounds_cache_lifetime( + monkeypatch: pytest.MonkeyPatch, + expires_in: int, + expected_ttl: int | None, +) -> None: + cache = _FakeCache() + provider, _client = _provider(monkeypatch, cache, _token_response(expires_in=expires_in)) + + assert provider.get() == "sanitized-fresh-token" + if expected_ttl is None: + assert cache.set_calls == [] + else: + assert cache.set_calls[0][2] == expected_ttl + + +def test_cache_identity_uses_only_client_id(monkeypatch: pytest.MonkeyPatch) -> None: + credential_sets = ( + _credentials(), + _credentials(corp_id="sanitized-other-corp"), + _credentials(client_secret="sanitized-other-secret"), + _credentials(client_id="sanitized-other-client"), + ) + cache_keys: list[str] = [] + + for credentials in credential_sets: + cache = _FakeCache() + provider, _client = _provider( + monkeypatch, + cache, + _token_response(), + credentials=credentials, + ) + + assert provider.get() == "sanitized-fresh-token" + cache_keys.append(cache.get_calls[0]) + + assert cache_keys[0] == cache_keys[1] == cache_keys[2] + assert cache_keys[0] != cache_keys[3] + + +@pytest.mark.parametrize("failure_operation", ["get", "ttl"]) +def test_cache_read_failure_falls_back_with_safe_warning( + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, + failure_operation: str, +) -> None: + error = RedisError("sanitized-client-secret sanitized-cached-token") + cache = _FakeCache( + "sanitized-cached-token", + get_error=error if failure_operation == "get" else None, + ttl_error=error if failure_operation == "ttl" else None, + ) + provider, client = _provider(monkeypatch, cache, _token_response()) + + assert provider.get() == "sanitized-fresh-token" + assert len(client.calls) == 1 + assert len(cache.set_calls) == 1 + assert "access-token cache" in caplog.text + assert "sanitized-client-secret" not in caplog.text + assert "sanitized-cached-token" not in caplog.text + + +def test_cache_write_failure_returns_fresh_token_with_safe_warning( + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + cache = _FakeCache(set_error=RedisError("sanitized-client-secret sanitized-fresh-token")) + provider, client = _provider(monkeypatch, cache, _token_response()) + + assert provider.get() == "sanitized-fresh-token" + assert len(client.calls) == 1 + assert len(cache.set_calls) == 1 + assert "access-token cache" in caplog.text + assert "sanitized-client-secret" not in caplog.text + assert "sanitized-fresh-token" not in caplog.text + + +@pytest.mark.parametrize("failure_operation", ["get", "ttl", "set"]) +def test_non_redis_runtime_errors_are_not_silently_treated_as_cache_failures( + monkeypatch: pytest.MonkeyPatch, + failure_operation: str, +) -> None: + error = RuntimeError("programming error outside the Redis failure contract") + cache = _FakeCache( + None if failure_operation == "set" else "sanitized-cached-token", + get_error=error if failure_operation == "get" else None, + ttl_error=error if failure_operation == "ttl" else None, + set_error=error if failure_operation == "set" else None, + ) + provider, client = _provider(monkeypatch, cache, _token_response()) + + with pytest.raises(RuntimeError, match="programming error"): + provider.get() + + expected_sdk_calls = 1 if failure_operation == "set" else 0 + assert len(client.calls) == expected_sdk_calls + + +@pytest.mark.parametrize( + ("response", "expected_error"), + [ + (_ProviderError(401), _ProviderError), + (_ProviderError(500), _ProviderError), + (GetTokenResponse().from_map({"body": None}), IMProviderError), + (_token_response(token=""), IMProviderError), + (_token_response(expires_in=0), IMProviderError), + (_token_response(expires_in=True), IMProviderError), + ], +) +def test_provider_failure_propagates_without_retry_or_cache_write( + monkeypatch: pytest.MonkeyPatch, + response: object, + expected_error: type[Exception], +) -> None: + cache = _FakeCache() + provider, client = _provider(monkeypatch, cache, response) + + with pytest.raises(expected_error): + provider.get() + + assert len(client.calls) == 1 + assert cache.set_calls == [] diff --git a/api/tests/unit_tests/core/human_input_v2/im_provider/test_dingtalk_sdk_boundary.py b/api/tests/unit_tests/core/human_input_v2/im_provider/test_dingtalk_sdk_boundary.py new file mode 100644 index 00000000000..9ff27dda60d --- /dev/null +++ b/api/tests/unit_tests/core/human_input_v2/im_provider/test_dingtalk_sdk_boundary.py @@ -0,0 +1,497 @@ +"""Unit tests for DingTalk SDK and HTTP boundaries using test doubles.""" + +from __future__ import annotations + +import json +from collections.abc import Callable +from dataclasses import dataclass +from pathlib import Path +from urllib.parse import urlparse + +import httpx +import pytest +from alibabacloud_dingtalk.oauth2_1_0.client import Client as OAuthClient +from alibabacloud_dingtalk.oauth2_1_0.models import GetTokenRequest, GetTokenResponse, GetTokenResponseBody +from alibabacloud_dingtalk.robot_1_0.client import Client as RobotClient +from alibabacloud_dingtalk.robot_1_0.models import ( + BatchSendOTOHeaders, + BatchSendOTORequest, + BatchSendOTOResponse, + BatchSendOTOResponseBody, +) +from alibabacloud_tea_util.models import RuntimeOptions +from pydantic import ValidationError +from pytest_mock import MockerFixture + +from core.human_input_v2.entities import IMProvider +from core.human_input_v2.im_integration.adapters import ( + CredentialTestFailure, + CredentialTestFailureKind, + CredentialTestSuccess, + DingTalkCredentials, + Directory, + DirectoryReadFailure, + EventAcceptance, + MessageAccepted, + MessageSendingError, + ProviderUserId, +) +from core.human_input_v2.im_integration.adapters import dingtalk as dingtalk_module +from core.human_input_v2.im_integration.adapters.dingtalk import DingTalkIMProviderAdapter +from core.human_input_v2.im_integration.adapters.entities import IMProviderError + +_FIXTURE_PATH = Path(__file__).parents[4] / "fixtures" / "im_provider" / "dingtalk" / "sanitized_protocol.json" + + +class _SDKError(Exception): + def __init__(self, status_code: int | None = None) -> None: + super().__init__("fake provider exception detail must not escape") + self.status_code = status_code + + +@dataclass(frozen=True, slots=True) +class _MalformedTokenResponse: + body: object | None + status_code: int = 200 + + +class _OAuthSequence: + def __init__(self, *responses: object) -> None: + self.responses = list(responses) + self.calls: list[tuple[str, GetTokenRequest]] = [] + + def get_token(self, corp_id: str, request: GetTokenRequest) -> object: + self.calls.append((corp_id, request)) + response = self.responses.pop(0) + if isinstance(response, Exception): + raise response + return response + + +class _RobotSequence: + def __init__(self, *responses: object) -> None: + self.responses = list(responses) + self.calls: list[tuple[BatchSendOTORequest, BatchSendOTOHeaders, RuntimeOptions]] = [] + + def batch_send_otowith_options( + self, + request: BatchSendOTORequest, + headers: BatchSendOTOHeaders, + runtime: RuntimeOptions, + ) -> object: + self.calls.append((request, headers, runtime)) + response = self.responses.pop(0) + if isinstance(response, Exception): + raise response + return response + + +class _FixtureHTTPClient: + def __init__( + self, + route: Callable[[str, dict[str, int]], tuple[int, dict[str, object]]], + ) -> None: + self._route = route + self.calls: list[tuple[str, str, dict[str, int]]] = [] + self.close_calls = 0 + + def get(self, url: str, *, access_token: str) -> dingtalk_module._DirectoryHTTPResponse: + path = urlparse(url).path + self.calls.append((path, access_token, {})) + status_code, response = self._route(path, {}) + if not 200 <= status_code < 300: + raise OSError("fake HTTP failure detail") + return dingtalk_module._DirectoryHTTPResponse(json.dumps(response, separators=(",", ":")).encode(), status_code) + + def post(self, url: str, *, access_token: str, body: dict[str, int]) -> dingtalk_module._DirectoryHTTPResponse: + path = urlparse(url).path + self.calls.append((path, access_token, dict(body))) + status_code, response = self._route(path, dict(body)) + if not 200 <= status_code < 300: + raise OSError("fake HTTP failure detail") + return dingtalk_module._DirectoryHTTPResponse(json.dumps(response, separators=(",", ":")).encode(), status_code) + + def close(self) -> None: + self.close_calls += 1 + + +class _Consumer: + def accept(self, event: object, *, request_ip_address: str | None = None) -> EventAcceptance: + del request_ip_address + del event + return EventAcceptance.ACCEPTED + + +def _credentials( + *, + corp_id: str = "fake-corp-001", + client_id: str = "fake-client-001", + client_secret: str = "fake-client-secret-001", +) -> DingTalkCredentials: + return DingTalkCredentials( + provider=IMProvider.DING_TALK, + corp_id=corp_id, + client_id=client_id, + client_secret=client_secret, + ) + + +def _token_response(token: str) -> GetTokenResponse: + return GetTokenResponse( + headers={"content-type": "application/json", "x-acs-request-id": "fake-request-token-001"}, + status_code=200, + body=GetTokenResponseBody(access_token=token, expires_in=7200), + ) + + +def _message_response( + *, + status_code: int = 200, + process_query_key: str = "fake-process-query-key-001", + filtered: list[str] | None = None, + flow_controlled: list[str] | None = None, + invalid: list[str] | None = None, +) -> BatchSendOTOResponse: + filtered_ids: list[str] = [] if filtered is None else filtered + flow_controlled_ids: list[str] = [] if flow_controlled is None else flow_controlled + invalid_ids: list[str] = [] if invalid is None else invalid + return BatchSendOTOResponse( + headers={"content-type": "application/json", "x-acs-request-id": "fake-request-message-001"}, + status_code=status_code, + body=BatchSendOTOResponseBody( + filtered_staff_id_list=filtered_ids, + flow_controlled_staff_id_list=flow_controlled_ids, + invalid_staff_id_list=invalid_ids, + process_query_key=process_query_key, + ), + ) + + +def _message_response_without_locator() -> BatchSendOTOResponse: + return BatchSendOTOResponse( + headers={"content-type": "application/json", "x-acs-request-id": "fake-request-message-missing-locator"}, + status_code=200, + body=BatchSendOTOResponseBody( + filtered_staff_id_list=[], + flow_controlled_staff_id_list=[], + invalid_staff_id_list=[], + ), + ) + + +def _fixture_route() -> Callable[[str, dict[str, int]], tuple[int, dict[str, object]]]: + fixture = json.loads(_FIXTURE_PATH.read_text()) + scope_exchange = fixture["authorization_scope"] + exchanges = fixture["directory"] + + def route(path: str, body: dict[str, int]) -> tuple[int, dict[str, object]]: + if scope_exchange["request"] == {"method": "GET", "path": path} and body == {}: + return 200, scope_exchange["response"] + for exchange in exchanges: + request = exchange["request"] + if request == {"path": path, "body": body}: + return 200, exchange["response"] + raise AssertionError(f"unexpected fixture request: {path} {body}") + + return route + + +def _adapter( + monkeypatch: pytest.MonkeyPatch, + *, + direct: _OAuthSequence, + provider: _OAuthSequence | None = None, + robot: _RobotSequence | None = None, + http: _FixtureHTTPClient | None = None, + credentials: DingTalkCredentials | None = None, +) -> tuple[DingTalkIMProviderAdapter, _OAuthSequence, _RobotSequence, _FixtureHTTPClient]: + provider = provider or _OAuthSequence() + robot = robot or _RobotSequence() + http = http or _FixtureHTTPClient(_fixture_route()) + clients = iter((direct, provider)) + monkeypatch.setattr(dingtalk_module, "_new_oauth_client", lambda: next(clients)) + monkeypatch.setattr(dingtalk_module, "_new_robot_client", lambda: robot) + monkeypatch.setattr(dingtalk_module, "_new_http_client", lambda: http) + return DingTalkIMProviderAdapter(credentials or _credentials()), provider, robot, http + + +def test_public_adapter_round_trips_fresh_credentials_directory_and_text( + monkeypatch: pytest.MonkeyPatch, + mocker: MockerFixture, +) -> None: + mocker.patch( + "core.file.remote_fetcher.make_request", + return_value=httpx.Response( + 200, + headers={"Content-Type": "image/png"}, + content=b"avatar-image", + request=httpx.Request("GET", "https://example.invalid/fake-user-001.png"), + ), + ) + direct = _OAuthSequence( + _token_response("fake-access-token-direct-001"), + _token_response("fake-access-token-direct-002"), + ) + provider = _OAuthSequence( + _token_response("fake-access-token-directory-001"), + _token_response("fake-access-token-message-001"), + ) + robot = _RobotSequence(_message_response(filtered=[], flow_controlled=[], invalid=[])) + adapter, provider, robot, http = _adapter( + monkeypatch, + direct=direct, + provider=provider, + robot=robot, + ) + + assert adapter.provider is IMProvider.DING_TALK + assert adapter.directory is adapter.directory + assert adapter.messaging is adapter.messaging + assert adapter.dynamic_card_messaging is None + assert adapter.create_webhook_handler(_Consumer()) is None + assert adapter.create_stream_handler(_Consumer()) is None + assert direct.calls == [] + assert provider.calls == [] + assert robot.calls == [] + + first_credentials = adapter.test_credentials() + directory = adapter.directory.read_directory() + message = adapter.messaging.send_text(ProviderUserId("fake-user-001"), "Exact **CommonMark** body") + second_credentials = adapter.test_credentials() + + assert first_credentials == CredentialTestSuccess(IMProvider.DING_TALK, "fake-corp-001") + assert second_credentials == first_credentials + assert isinstance(directory, Directory) + assert directory.entries[0].avatar is not None + assert directory.entries[0].avatar.data == b"avatar-image" + assert [entry.provider_user_id for entry in directory.entries] == [ + "fake-user-001", + "fake-user-002", + "fake-user-003", + ] + assert directory.entries[1].display_name is None + assert directory.entries[2].email is None + assert isinstance(message, MessageAccepted) + persisted_locator = json.loads(json.dumps({"locator": str(message.locator)}, separators=(",", ":")))["locator"] + assert dingtalk_module.MessageLocator(persisted_locator) == message.locator + assert len(direct.calls) == 2 + assert len(provider.calls) == 2 + assert [corp_id for corp_id, _request in direct.calls] == ["fake-corp-001", "fake-corp-001"] + assert [call[1] for call in http.calls if call[0].endswith("/department/listsub")][:2] == [ + "fake-access-token-direct-001", + "fake-access-token-directory-001", + ] + request, headers, runtime = robot.calls[0] + assert request.user_ids == ["fake-user-001"] + assert request.msg_key == "sampleText" + assert json.loads(request.msg_param) == {"content": "Exact **CommonMark** body"} + assert headers.x_acs_dingtalk_access_token == "fake-access-token-message-001" + assert runtime.autoretry is False + assert runtime.max_attempts == 1 + + adapter.close() + adapter.close() + assert http.close_calls == 1 + + +@pytest.mark.parametrize( + ("direct_response", "expected_kind"), + [ + (_SDKError(401), CredentialTestFailureKind.AUTHENTICATION_REJECTED), + (_SDKError(500), CredentialTestFailureKind.UNKNOWN), + (_MalformedTokenResponse(body=None), CredentialTestFailureKind.UNKNOWN), + ( + GetTokenResponse().from_map({"statusCode": 200, "body": {"access_token": "fake-token", "expires_in": 0}}), + CredentialTestFailureKind.UNKNOWN, + ), + ], +) +def test_public_credential_test_normalizes_token_failures( + monkeypatch: pytest.MonkeyPatch, + direct_response: object, + expected_kind: CredentialTestFailureKind, +) -> None: + adapter, _provider, _robot, http = _adapter( + monkeypatch, + direct=_OAuthSequence(direct_response), + ) + + del expected_kind + expected_error = type(direct_response) if isinstance(direct_response, Exception) else IMProviderError + with pytest.raises(expected_error) as captured: + adapter.test_credentials() + if isinstance(direct_response, Exception): + assert captured.value is direct_response + assert http.calls == [] + + +def test_public_credential_test_reads_scope_fixture_before_root_baseline( + monkeypatch: pytest.MonkeyPatch, +) -> None: + adapter, _provider, _robot, http = _adapter( + monkeypatch, + direct=_OAuthSequence(_token_response("fake-access-token-direct-scope-001")), + ) + + result = adapter.test_credentials() + + assert result == CredentialTestSuccess(IMProvider.DING_TALK, "fake-corp-001") + assert http.calls[:2] == [ + ("/auth/scopes", "fake-access-token-direct-scope-001", {}), + ("/topapi/v2/department/listsub", "fake-access-token-direct-scope-001", {"dept_id": 1}), + ] + + +@pytest.mark.parametrize( + ("root_response", "expected_kind"), + [ + ({"errcode": 40035, "errmsg": "fake raw provider detail"}, CredentialTestFailureKind.TENANT_ID_UNAVAILABLE), + ({"errcode": 0, "errmsg": "ok"}, CredentialTestFailureKind.UNKNOWN), + ({"errcode": "invalid", "errmsg": "fake malformed detail"}, CredentialTestFailureKind.UNKNOWN), + ], +) +def test_public_credential_test_requires_a_valid_root_baseline( + monkeypatch: pytest.MonkeyPatch, + root_response: dict[str, object], + expected_kind: CredentialTestFailureKind, +) -> None: + def route(path: str, _body: dict[str, int]) -> tuple[int, dict[str, object]]: + if path.endswith("/auth/scopes"): + return 200, { + "errcode": 0, + "errmsg": "ok", + "auth_org_scopes": {"authed_dept": [1], "authed_user": []}, + } + return 200, root_response + + http = _FixtureHTTPClient(route) + adapter, _provider, _robot, _http = _adapter( + monkeypatch, + direct=_OAuthSequence(_token_response("fake-access-token-direct-001")), + http=http, + ) + + if expected_kind is CredentialTestFailureKind.TENANT_ID_UNAVAILABLE: + result = adapter.test_credentials() + assert isinstance(result, CredentialTestFailure) + assert result.kind is expected_kind + else: + with pytest.raises((IMProviderError, ValidationError)): + adapter.test_credentials() + assert len(http.calls) == 2 + + +def test_public_directory_discards_late_partial_state_and_detects_bad_cursor( + monkeypatch: pytest.MonkeyPatch, +) -> None: + def route(path: str, body: dict[str, int]) -> tuple[int, dict[str, object]]: + if path.endswith("/department/listsub"): + return 200, {"errcode": 0, "errmsg": "ok", "result": [{"dept_id": 2}]} + if body["dept_id"] == 1: + return 200, { + "errcode": 0, + "errmsg": "ok", + "result": { + "has_more": True, + "next_cursor": 0, + "list": [{"userid": "fake-user-partial", "name": "Fake Partial"}], + }, + } + return 200, {"errcode": 0, "errmsg": "ok", "result": {"has_more": False, "list": []}} + + adapter, provider, _robot, http = _adapter( + monkeypatch, + direct=_OAuthSequence(), + provider=_OAuthSequence(_token_response("fake-access-token-directory-001")), + http=_FixtureHTTPClient(route), + ) + + result = adapter.directory.read_directory() + + assert result == DirectoryReadFailure("DingTalk directory could not be read completely.") + assert len(provider.calls) == 1 + assert len(http.calls) >= 2 + + +@pytest.mark.parametrize( + "provider_response", + [ + _SDKError(500), + _message_response_without_locator(), + _message_response(filtered=["fake-user-001"]), + _message_response(flow_controlled=["fake-user-001"]), + _message_response(invalid=["fake-user-001"]), + BatchSendOTOResponse().from_map({"body": {"processQueryKey": "fake-key", "invalidStaffIdList": object()}}), + ], +) +def test_public_messaging_returns_one_safe_failure_without_replay( + monkeypatch: pytest.MonkeyPatch, + provider_response: object, +) -> None: + adapter, provider, robot, _http = _adapter( + monkeypatch, + direct=_OAuthSequence(), + provider=_OAuthSequence(_token_response("fake-access-token-message-001")), + robot=_RobotSequence(provider_response), + ) + + result = adapter.messaging.send_text(ProviderUserId("fake-user-001"), "Exact body") + + assert result == MessageSendingError("DingTalk message acceptance could not be confirmed.") + assert len(provider.calls) == 1 + assert len(robot.calls) == 1 + + +def test_public_messaging_does_not_mutate_when_token_acquisition_fails( + monkeypatch: pytest.MonkeyPatch, +) -> None: + adapter, provider, robot, _http = _adapter( + monkeypatch, + direct=_OAuthSequence(), + provider=_OAuthSequence(_SDKError(500)), + robot=_RobotSequence(), + ) + + result = adapter.messaging.send_text(ProviderUserId("fake-user-001"), "Exact body") + + assert result == MessageSendingError("DingTalk message acceptance could not be confirmed.") + assert len(provider.calls) == 1 + assert robot.calls == [] + + +def test_public_sdk_surface_and_local_factories_are_importable_without_io() -> None: + assert callable(OAuthClient.get_token) + assert callable(RobotClient.batch_send_otowith_options) + assert isinstance(dingtalk_module._new_oauth_client(), OAuthClient) + assert isinstance(dingtalk_module._new_robot_client(), RobotClient) + http_client = dingtalk_module._new_http_client() + assert isinstance(http_client, dingtalk_module._UrllibDirectoryHTTPClient) + assert http_client.close() is None + + +def test_public_messaging_rejects_non_success_http_status_even_with_locator( + monkeypatch: pytest.MonkeyPatch, +) -> None: + adapter, provider, robot, _http = _adapter( + monkeypatch, + direct=_OAuthSequence(), + provider=_OAuthSequence(_token_response("fake-access-token-message-001")), + robot=_RobotSequence( + _message_response( + status_code=500, + process_query_key="fake-process-query-key-must-not-confirm-acceptance", + filtered=[], + flow_controlled=[], + invalid=[], + ) + ), + ) + + result = adapter.messaging.send_text(ProviderUserId("fake-user-001"), "Exact body") + + assert result == MessageSendingError("DingTalk message acceptance could not be confirmed.") + assert len(provider.calls) == 1 + assert len(robot.calls) == 1 + _request, _headers, runtime = robot.calls[0] + assert runtime.autoretry is False + assert runtime.max_attempts == 1 diff --git a/api/tests/unit_tests/core/human_input_v2/im_provider/test_feishu_lark_adapter.py b/api/tests/unit_tests/core/human_input_v2/im_provider/test_feishu_lark_adapter.py new file mode 100644 index 00000000000..f48e8b837c3 --- /dev/null +++ b/api/tests/unit_tests/core/human_input_v2/im_provider/test_feishu_lark_adapter.py @@ -0,0 +1,1767 @@ +from __future__ import annotations + +import base64 +import json +from collections.abc import Mapping +from pathlib import Path +from threading import Barrier, Lock +from typing import Literal, overload, override + +import httpx +import pytest +from lark_oapi.core.exception import ObtainAccessTokenException +from pydantic import ValidationError +from pytest_mock import MockerFixture + +from core.human_input import ButtonStyle +from core.human_input_v2.entities import IMProvider +from core.human_input_v2.im_integration.adapters import ( + CorrelationToken, + CredentialTestFailure, + CredentialTestFailureKind, + CredentialTestSuccess, + Directory, + DirectoryEntry, + DirectoryReadFailure, + DynamicCardMessagingError, + MessageAccepted, + MessageLocator, + MessageSendingError, + ProviderUserId, + ReplacementError, + ReplacementErrorKind, + StaticCardIntent, +) +from core.human_input_v2.im_integration.adapters import feishu_lark as adapter_module +from core.human_input_v2.im_integration.adapters.credentials import FeishuCredentials, LarkCredentials +from core.human_input_v2.im_integration.adapters.entities import IMProviderError +from core.human_input_v2.im_integration.adapters.feishu_lark import ( + FeishuIMProviderAdapter, + LarkIMProviderAdapter, +) +from core.human_input_v2.resolved_form import ( + FileInput, + FileListInput, + FormPart, + MarkdownFragment, + ParagraphInput, + ResolvedForm, + SelectInput, + UserAction, +) + + +class FakeSDKGateway: + def request_tenant_access_token(self) -> str: + return "sanitized-access-token" + + def __init__(self) -> None: + self.tenant_responses: list[Mapping[str, object] | Exception] = [] + self.scope_responses: list[Mapping[str, object] | Exception] = [] + self.department_responses: list[Mapping[str, object] | Exception] = [] + self.user_responses: list[Mapping[str, object] | Exception] = [] + self.create_responses: list[Mapping[str, object] | Exception] = [] + self.patch_responses: list[Mapping[str, object] | Exception] = [] + self.calls: list[tuple[str, tuple[str, str, str | None] | tuple[str, str | None] | str | None]] = [] + + @staticmethod + def _next(responses: list[Mapping[str, object] | Exception]) -> Mapping[str, object]: + response = responses.pop(0) + if isinstance(response, Exception): + raise response + return response + + def query_tenant(self, *, request_option: object = None) -> Mapping[str, object]: + del request_option + self.calls.append(("query_tenant", None)) + return self._next(self.tenant_responses) + + def list_scope(self, page_token: str | None, *, request_option: object = None) -> Mapping[str, object]: + del request_option + self.calls.append(("list_scope", page_token)) + return self._next(self.scope_responses) + + def list_departments( + self, + department_id: adapter_module._DepartmentIdentity, + page_token: str | None, + *, + request_option: object = None, + ) -> Mapping[str, object]: + del request_option + self.calls.append(("list_departments", (department_id.value, page_token))) + return self._next(self.department_responses) + + def list_users( + self, department_id: adapter_module._DepartmentIdentity, page_token: str | None + ) -> Mapping[str, object]: + self.calls.append(("list_users", (department_id.value, page_token))) + return self._next(self.user_responses) + + def create_message(self, receive_id: str, msg_type: str, content: str) -> Mapping[str, object]: + self.calls.append(("create_message", (receive_id, msg_type, content))) + return self._next(self.create_responses) + + def patch_message(self, message_id: str, content: str) -> Mapping[str, object]: + self.calls.append(("patch_message", (message_id, content))) + return self._next(self.patch_responses) + + +class IdentityAwareFakeSDKGateway(FakeSDKGateway): + """Capture the private department identity sent across the SDK boundary.""" + + @override + def list_departments( + self, + department_id: adapter_module._DepartmentIdentity, + page_token: str | None, + *, + request_option: object = None, + ) -> Mapping[str, object]: + del request_option + self.calls.append(("list_departments", (department_id.value, department_id.id_type, page_token))) + return self._next(self.department_responses) + + @override + def list_users( + self, department_id: adapter_module._DepartmentIdentity, page_token: str | None + ) -> Mapping[str, object]: + self.calls.append(("list_users", (department_id.value, department_id.id_type, page_token))) + return self._next(self.user_responses) + + +@overload +def _credentials(provider: Literal[IMProvider.FEISHU]) -> FeishuCredentials: ... + + +@overload +def _credentials(provider: Literal[IMProvider.LARK]) -> LarkCredentials: ... + + +def _credentials( + provider: Literal[IMProvider.FEISHU, IMProvider.LARK], +) -> FeishuCredentials | LarkCredentials: + values = { + "provider": provider, + "app_id": "cli_sanitized_app", + "app_secret": "sanitized-app-secret", + "verification_token": "sanitized-verification-token", + "encrypt_key": "sanitized-encrypt-key", + } + if provider is IMProvider.FEISHU: + return FeishuCredentials.model_validate(values) + return LarkCredentials.model_validate(values) + + +def _adapter( + monkeypatch: pytest.MonkeyPatch, + provider: Literal[IMProvider.FEISHU, IMProvider.LARK], + gateway: FakeSDKGateway, +) -> FeishuIMProviderAdapter | LarkIMProviderAdapter: + monkeypatch.setattr(adapter_module, "_create_sdk_gateway", lambda _credentials, _domain: gateway) + if provider is IMProvider.FEISHU: + return FeishuIMProviderAdapter(_credentials(provider)) + return LarkIMProviderAdapter(_credentials(provider)) + + +def _tenant_response(tenant_key: str = "tenant_sanitized") -> Mapping[str, object]: + return {"code": 0, "data": {"tenant": {"tenant_key": tenant_key}}} + + +def _accepted_card_reference( + monkeypatch: pytest.MonkeyPatch, + provider: Literal[IMProvider.FEISHU, IMProvider.LARK], + *, + message_id: str = "om_sanitized_card", +) -> MessageLocator: + source_gateway = FakeSDKGateway() + source_gateway.tenant_responses.append(_tenant_response()) + source_gateway.create_responses.append({"code": 0, "data": {"message_id": message_id}}) + source = _adapter(monkeypatch, provider, source_gateway) + accepted = source.dynamic_card_messaging.send_card( + ProviderUserId("union_sanitized_user"), + _intent(), + CorrelationToken("opaque-correlation-token"), + ) + assert isinstance(accepted, MessageAccepted) + return accepted.locator + + +def _assert_invalid_reference_without_mutation( + monkeypatch: pytest.MonkeyPatch, + provider: Literal[IMProvider.FEISHU, IMProvider.LARK], + opaque: str, +) -> None: + invalid = MessageLocator(opaque) + replacement_gateway = FakeSDKGateway() + replacement = _adapter(monkeypatch, provider, replacement_gateway) + + result = replacement.dynamic_card_messaging.replace_with_static(invalid, StaticCardIntent("Submitted")) + + assert isinstance(result, ReplacementError) + assert result.kind is ReplacementErrorKind.INVALID_REFERENCE + assert replacement_gateway.calls == [] + + +def _page( + items: list[Mapping[str, object]], + *, + next_page_token: str | None = None, +) -> Mapping[str, object]: + return { + "code": 0, + "data": { + "items": items, + "has_more": next_page_token is not None, + "page_token": next_page_token, + }, + } + + +def _scope_page( + *, + department_ids: list[str] | None = None, + user_ids: list[str] | None = None, + next_page_token: str | None = None, +) -> Mapping[str, object]: + return { + "code": 0, + "data": { + "department_ids": department_ids or [], + "user_ids": user_ids or [], + "has_more": next_page_token is not None, + "page_token": next_page_token, + }, + } + + +def _intent(input_type: str = "paragraph") -> ResolvedForm: + if input_type == "select": + input_block: FormPart = SelectInput( + output_variable_name="comment", options=("Approve", "Reject"), default_value="Approve" + ) + elif input_type == "file": + input_block = FileInput( + output_variable_name="comment", + allowed_file_types=(), + allowed_file_extensions=(), + allowed_file_upload_methods=(), + ) + elif input_type == "file-list": + input_block = FileListInput( + output_variable_name="comment", + allowed_file_types=(), + allowed_file_extensions=(), + allowed_file_upload_methods=(), + number_limits=1, + ) + else: + input_block = ParagraphInput(output_variable_name="comment", default_value="Initial") + return ResolvedForm( + title="Approval", + parts=(MarkdownFragment(text="Rendered **content**"), input_block), + actions=( + UserAction(id="approve", title="Approve", button_style=ButtonStyle.PRIMARY), + UserAction(id="reject", title="Reject", button_style=ButtonStyle.DEFAULT), + ), + legacy_form_content="This value must not be rendered", + ) + + +def _provider_confirmed_form_intent() -> ResolvedForm: + return ResolvedForm( + title="Synthetic decision", + parts=( + MarkdownFragment(text="Synthetic **approval** content"), + ParagraphInput(output_variable_name="explanation", default_value="Synthetic default"), + SelectInput(output_variable_name="decision", options=("allow", "deny"), default_value="allow"), + ), + actions=( + UserAction(id="continue", title="Continue", button_style=ButtonStyle.PRIMARY), + UserAction(id="stop", title="Stop", button_style=ButtonStyle.ACCENT), + ), + legacy_form_content="This value must not be rendered", + ) + + +@pytest.mark.parametrize( + ("provider", "expected_domain"), + [ + (IMProvider.FEISHU, "https://open.feishu.cn"), + (IMProvider.LARK, "https://open.larksuite.com"), + ], +) +def test_wrappers_use_typed_provider_and_expected_sdk_domain( + monkeypatch: pytest.MonkeyPatch, + provider: Literal[IMProvider.FEISHU, IMProvider.LARK], + expected_domain: str, +) -> None: + observed: list[tuple[object, str]] = [] + gateway = FakeSDKGateway() + monkeypatch.setattr( + adapter_module, + "_create_sdk_gateway", + lambda credentials, domain: observed.append((credentials, domain)) or gateway, + ) + + adapter = ( + FeishuIMProviderAdapter(_credentials(IMProvider.FEISHU)) + if provider is IMProvider.FEISHU + else LarkIMProviderAdapter(_credentials(IMProvider.LARK)) + ) + + assert adapter.provider is provider + assert observed == [(_credentials(provider), expected_domain)] + + +@pytest.mark.parametrize("provider", [IMProvider.FEISHU, IMProvider.LARK]) +def test_credentials_validate_complete_scope_then_prove_root_department_access( + monkeypatch: pytest.MonkeyPatch, + provider: Literal[IMProvider.FEISHU, IMProvider.LARK], +) -> None: + gateway = FakeSDKGateway() + gateway.tenant_responses.append(_tenant_response()) + gateway.scope_responses.extend( + [ + _scope_page( + department_ids=["dept_first_level"], + user_ids=["union_root_user"], + next_page_token="scope-next", + ), + _scope_page(department_ids=["dept_second_page"]), + ] + ) + gateway.department_responses.append(_page([{"department_id": "dept_first_level"}])) + adapter = _adapter(monkeypatch, provider, gateway) + + assert adapter.test_credentials() == CredentialTestSuccess(provider, "tenant_sanitized") + assert gateway.calls == [ + ("query_tenant", None), + ("list_scope", None), + ("list_scope", "scope-next"), + ("list_departments", ("0", None)), + ] + + +@pytest.mark.parametrize("provider", [IMProvider.FEISHU, IMProvider.LARK]) +def test_credentials_accept_open_department_id_only_root_children( + monkeypatch: pytest.MonkeyPatch, + provider: Literal[IMProvider.FEISHU, IMProvider.LARK], +) -> None: + gateway = FakeSDKGateway() + gateway.tenant_responses.append(_tenant_response()) + gateway.scope_responses.append(_scope_page()) + gateway.department_responses.append(_page([{"open_department_id": "open_dept_sanitized"}])) + adapter = _adapter(monkeypatch, provider, gateway) + + assert adapter.test_credentials() == CredentialTestSuccess(provider, "tenant_sanitized") + + +@pytest.mark.parametrize( + ("tenant_response", "scope_response", "expected_kind"), + [ + ({"code": 99991663, "msg": "sanitized rejection"}, None, CredentialTestFailureKind.AUTHENTICATION_REJECTED), + ({"code": 0, "data": {"tenant": {}}}, None, CredentialTestFailureKind.TENANT_ID_UNAVAILABLE), + (_tenant_response(), {"code": 403, "msg": "sanitized permission rejection"}, CredentialTestFailureKind.UNKNOWN), + ], +) +@pytest.mark.parametrize("provider", [IMProvider.FEISHU, IMProvider.LARK]) +def test_credential_failure_is_classified_and_safe( + provider: Literal[IMProvider.FEISHU, IMProvider.LARK], + monkeypatch: pytest.MonkeyPatch, + tenant_response: Mapping[str, object], + scope_response: Mapping[str, object] | None, + expected_kind: CredentialTestFailureKind, +) -> None: + gateway = FakeSDKGateway() + gateway.tenant_responses.append(tenant_response) + if scope_response is not None: + gateway.scope_responses.append(scope_response) + adapter = _adapter(monkeypatch, provider, gateway) + + if expected_kind is CredentialTestFailureKind.TENANT_ID_UNAVAILABLE: + with pytest.raises(IMProviderError): + adapter.test_credentials() + return + result = adapter.test_credentials() + + assert isinstance(result, CredentialTestFailure) + assert result.kind is expected_kind + assert "sanitized rejection" not in result.reason + assert "sanitized-app-secret" not in result.reason + + +@pytest.mark.parametrize("provider", [IMProvider.FEISHU, IMProvider.LARK]) +@pytest.mark.parametrize("stage", ["tenant", "scope", "department"]) +def test_credential_failure_preserves_provider_code_and_explicit_permission_violations( + monkeypatch: pytest.MonkeyPatch, + provider: Literal[IMProvider.FEISHU, IMProvider.LARK], + stage: str, +) -> None: + rejection: dict[str, object] = { + "code": 99991672, + "data": {}, + "msg": "raw message with sanitized-app-secret", + "error": {"permission_violations": [{"type": "action", "subject": "contact:contact.base:readonly"}]}, + } + gateway = FakeSDKGateway() + gateway.tenant_responses.append(rejection if stage == "tenant" else _tenant_response()) + if stage == "scope": + gateway.scope_responses.append(rejection) + if stage == "department": + gateway.scope_responses.append(_scope_page()) + gateway.department_responses.append(rejection) + + result = _adapter(monkeypatch, provider, gateway).test_credentials() + + assert isinstance(result, CredentialTestFailure) + assert result.provider_error_code == "99991672" + assert result.missing_permissions == ("contact:contact.base:readonly",) + assert "sanitized-app-secret" not in repr(result) + + +@pytest.mark.parametrize("provider", [IMProvider.FEISHU, IMProvider.LARK]) +def test_credential_failure_preserves_token_endpoint_error_code( + monkeypatch: pytest.MonkeyPatch, provider: Literal[IMProvider.FEISHU, IMProvider.LARK] +) -> None: + gateway = FakeSDKGateway() + gateway.tenant_responses.append(ObtainAccessTokenException("token rejected", 10014, "sanitized-app-secret")) + + result = _adapter(monkeypatch, provider, gateway).test_credentials() + + assert isinstance(result, CredentialTestFailure) + assert result.provider_error_code == "10014" + assert result.missing_permissions == () + assert "sanitized-app-secret" not in repr(result) + + +@pytest.mark.parametrize( + "error_details", [None, {}, {"permission_violations": [{"type": "field", "subject": "user_id"}]}] +) +def test_credential_failure_does_not_infer_permissions_from_error_code( + monkeypatch: pytest.MonkeyPatch, error_details: Mapping[str, object] | None +) -> None: + gateway = FakeSDKGateway() + gateway.tenant_responses.append({"code": 99991672, "error": error_details}) + + result = _adapter(monkeypatch, IMProvider.FEISHU, gateway).test_credentials() + + assert isinstance(result, CredentialTestFailure) + assert result.provider_error_code == "99991672" + assert result.missing_permissions == () + + +@pytest.mark.parametrize( + "scope_responses", + [ + [ + { + "code": 0, + "data": { + "department_ids": [], + "user_ids": [], + "has_more": True, + }, + } + ], + [ + _scope_page(next_page_token="scope-repeat"), + _scope_page(next_page_token="scope-repeat"), + ], + [ + _scope_page(next_page_token="scope-next"), + {"code": 403, "msg": "sanitized rejection"}, + ], + [ + _scope_page(next_page_token="scope-next"), + RuntimeError("sanitized transport failure"), + ], + [ + { + "code": 0, + "data": { + "department_ids": [], + "user_ids": [], + "has_more": "true", + "page_token": "scope-next", + }, + } + ], + [ + { + "code": 0, + "data": { + "department_ids": [], + "user_ids": [], + "has_more": True, + "page_token": 1, + }, + } + ], + ], + ids=("missing-token", "token-loop", "rejected-page", "transport-failure", "invalid-has-more", "invalid-token"), +) +@pytest.mark.parametrize("provider", [IMProvider.FEISHU, IMProvider.LARK]) +def test_credentials_reject_incomplete_or_malformed_scope_pagination( + provider: Literal[IMProvider.FEISHU, IMProvider.LARK], + monkeypatch: pytest.MonkeyPatch, + scope_responses: list[Mapping[str, object] | Exception], +) -> None: + gateway = FakeSDKGateway() + gateway.tenant_responses.append(_tenant_response()) + gateway.scope_responses.extend(scope_responses) + adapter = _adapter(monkeypatch, provider, gateway) + + provider_rejection = any(isinstance(page, Mapping) and page.get("code") == 403 for page in scope_responses) + execution_errors = [page for page in scope_responses if isinstance(page, Exception)] + if provider_rejection: + result = adapter.test_credentials() + assert isinstance(result, CredentialTestFailure) + assert result.kind is CredentialTestFailureKind.UNKNOWN + else: + expected_error = type(execution_errors[0]) if execution_errors else (IMProviderError, ValidationError) + with pytest.raises(expected_error) as captured: + adapter.test_credentials() + if execution_errors: + assert captured.value is execution_errors[0] + assert not any(call[0] == "list_departments" for call in gateway.calls) + + +@pytest.mark.parametrize("provider", [IMProvider.FEISHU, IMProvider.LARK]) +@pytest.mark.parametrize("invalid_code", [False, "0"], ids=("boolean", "string")) +def test_credentials_reject_non_integer_scope_success_code( + monkeypatch: pytest.MonkeyPatch, + provider: Literal[IMProvider.FEISHU, IMProvider.LARK], + invalid_code: object, +) -> None: + gateway = FakeSDKGateway() + gateway.tenant_responses.append(_tenant_response()) + gateway.scope_responses.append( + { + "code": invalid_code, + "data": {"department_ids": [], "user_ids": [], "has_more": False}, + } + ) + adapter = _adapter(monkeypatch, provider, gateway) + + if invalid_code is None: + result = adapter.test_credentials() + assert isinstance(result, CredentialTestFailure) + assert result.kind is CredentialTestFailureKind.UNKNOWN + assert result.provider_error_code is None + else: + with pytest.raises(ValidationError): + adapter.test_credentials() + assert not any(call[0] == "list_departments" for call in gateway.calls) + + +@pytest.mark.parametrize( + "root_response", + [ + RuntimeError("sanitized transport failure"), + {"code": 403, "msg": "sanitized permission rejection"}, + {"code": 0, "data": None}, + {"code": 0, "data": {"items": [], "has_more": "false"}}, + {"code": 0, "data": {"items": [{}], "has_more": False}}, + ], + ids=("transport-failure", "permission-rejection", "missing-data", "invalid-shape", "invalid-department"), +) +@pytest.mark.parametrize("provider", [IMProvider.FEISHU, IMProvider.LARK]) +def test_credentials_require_authoritative_root_department_proof( + provider: Literal[IMProvider.FEISHU, IMProvider.LARK], + monkeypatch: pytest.MonkeyPatch, + root_response: Mapping[str, object] | Exception, +) -> None: + gateway = FakeSDKGateway() + gateway.tenant_responses.append(_tenant_response()) + gateway.scope_responses.append( + _scope_page( + department_ids=["dept_first_level"], + user_ids=["union_root_user"], + ) + ) + gateway.department_responses.append(root_response) + adapter = _adapter(monkeypatch, provider, gateway) + + if isinstance(root_response, Mapping) and root_response.get("code") == 403: + result = adapter.test_credentials() + assert isinstance(result, CredentialTestFailure) + assert result.kind is CredentialTestFailureKind.UNKNOWN + else: + expected_error = ( + type(root_response) if isinstance(root_response, Exception) else (IMProviderError, ValidationError) + ) + with pytest.raises(expected_error) as captured: + adapter.test_credentials() + if isinstance(root_response, Exception): + assert captured.value is root_response + assert gateway.calls[-1] == ("list_departments", ("0", None)) + + +@pytest.mark.parametrize("provider", [IMProvider.FEISHU, IMProvider.LARK]) +def test_directory_publishes_complete_ordered_union_id_snapshot( + monkeypatch: pytest.MonkeyPatch, + provider: Literal[IMProvider.FEISHU, IMProvider.LARK], +) -> None: + gateway = FakeSDKGateway() + gateway.user_responses.extend( + [ + _page( + [{"union_id": "union_root", "name": "Root User", "email": "root@example.invalid"}], + next_page_token="users-next", + ), + _page([{"union_id": "union_optional", "name": " ", "email": "optional@example.invalid"}]), + _page([{"union_id": "union_child", "name": "Child User", "enterprise_email": "child@example.invalid"}]), + ] + ) + gateway.department_responses.extend( + [ + _page([{"department_id": "dept_child"}], next_page_token="departments-next"), + _page([]), + _page([]), + ] + ) + adapter = _adapter(monkeypatch, provider, gateway) + + result = adapter.directory.read_directory() + + assert isinstance(result, Directory) + assert [(str(entry.provider_user_id), entry.display_name, entry.email) for entry in result.entries] == [ + ("union_root", "Root User", "root@example.invalid"), + ("union_optional", None, "optional@example.invalid"), + ("union_child", "Child User", "child@example.invalid"), + ] + assert gateway.calls == [ + ("list_users", ("0", None)), + ("list_users", ("0", "users-next")), + ("list_departments", ("0", None)), + ("list_departments", ("0", "departments-next")), + ("list_users", ("dept_child", None)), + ("list_departments", ("dept_child", None)), + ] + + +@pytest.mark.parametrize("provider", [IMProvider.FEISHU, IMProvider.LARK]) +def test_directory_projects_avatar_from_captured_user( + monkeypatch: pytest.MonkeyPatch, + provider: Literal[IMProvider.FEISHU, IMProvider.LARK], + mocker: MockerFixture, +) -> None: + fixture = Path(__file__).parents[4] / "fixtures/im_provider/feishu_lark/sanitized_directory_user.json" + gateway = FakeSDKGateway() + gateway.user_responses.append(_page([json.loads(fixture.read_text())])) + gateway.department_responses.append(_page([])) + mocker.patch( + "core.file.remote_fetcher.make_request", + return_value=httpx.Response( + 200, + headers={"Content-Type": "image/png"}, + content=b"avatar-image", + request=httpx.Request("GET", "https://example.invalid/avatar_240.png"), + ), + ) + + result = _adapter(monkeypatch, provider, gateway).directory.read_directory() + + assert isinstance(result, Directory) + assert len(result.entries) == 1 + assert result.entries[0].avatar is not None + assert result.entries[0].avatar.mime_type == "image/png" + assert result.entries[0].avatar.data == b"avatar-image" + + +@pytest.mark.parametrize("avatar", [None, {}, {"avatar_240": " "}]) +@pytest.mark.parametrize("provider", [IMProvider.FEISHU, IMProvider.LARK]) +def test_directory_accepts_unavailable_avatar( + monkeypatch: pytest.MonkeyPatch, avatar: object, provider: Literal[IMProvider.FEISHU, IMProvider.LARK] +) -> None: + gateway = FakeSDKGateway() + gateway.user_responses.append(_page([{"union_id": "union_user", "avatar": avatar}])) + gateway.department_responses.append(_page([])) + + result = _adapter(monkeypatch, provider, gateway).directory.read_directory() + + assert isinstance(result, Directory) + assert result.entries[0].avatar is None + + +@pytest.mark.parametrize("provider", [IMProvider.FEISHU, IMProvider.LARK]) +def test_directory_downloads_avatars_in_bounded_batches( + provider: Literal[IMProvider.FEISHU, IMProvider.LARK], + monkeypatch: pytest.MonkeyPatch, + mocker: MockerFixture, +) -> None: + gateway = FakeSDKGateway() + gateway.user_responses.append( + _page( + [ + {"union_id": f"user-{index}", "avatar": {"avatar_240": f"https://example.invalid/avatar/{index}"}} + for index in range(10) + ] + ) + ) + gateway.department_responses.append(_page([])) + first_batch = Barrier(8) + lock = Lock() + active = 0 + peak = 0 + + def download(_method: str, url: str, **_kwargs: object) -> httpx.Response: + nonlocal active, peak + index = int(url.rsplit("/", 1)[1]) + with lock: + active += 1 + peak = max(peak, active) + try: + if index < 8: + first_batch.wait(timeout=5) + return httpx.Response( + 200, + headers={"Content-Type": "image/png"}, + content=f"photo-{index}".encode(), + request=httpx.Request("GET", url), + ) + finally: + with lock: + active -= 1 + + mocker.patch("core.file.remote_fetcher.make_request", side_effect=download) + + result = _adapter(monkeypatch, provider, gateway).directory.read_directory() + + assert isinstance(result, Directory) + assert peak == 8 + assert [entry.provider_user_id for entry in result.entries] == [f"user-{index}" for index in range(10)] + assert [entry.avatar.data for entry in result.entries if entry.avatar is not None] == [ + f"photo-{index}".encode() for index in range(10) + ] + + +@pytest.mark.parametrize( + ("status", "content_type", "content", "missing"), + [ + (404, "application/json", b"{}", True), + (403, "application/json", b"{}", False), + (500, "text/html", b"error", False), + (200, "text/html", b"login page", False), + (200, "image/png", b"", False), + ], +) +@pytest.mark.parametrize("provider", [IMProvider.FEISHU, IMProvider.LARK]) +def test_directory_handles_missing_avatar_and_rejects_failed_avatar_download( + provider: Literal[IMProvider.FEISHU, IMProvider.LARK], + monkeypatch: pytest.MonkeyPatch, + mocker: MockerFixture, + status: int, + content_type: str, + content: bytes, + missing: bool, +) -> None: + gateway = FakeSDKGateway() + gateway.user_responses.append( + _page( + [ + {"union_id": "first"}, + {"union_id": "second", "avatar": {"avatar_240": "https://example.invalid/avatar.png"}}, + ] + ) + ) + gateway.department_responses.append(_page([])) + response = httpx.Response( + status, + headers={"Content-Type": content_type}, + content=content, + request=httpx.Request("GET", "https://example.invalid/avatar.png"), + ) + mocker.patch("core.file.remote_fetcher.make_request", return_value=response) + + result = _adapter(monkeypatch, provider, gateway).directory.read_directory() + + if missing: + assert isinstance(result, Directory) + assert len(result.entries) == 2 + assert result.entries[1].avatar is None + else: + assert isinstance(result, DirectoryReadFailure) + assert response.is_closed + + +@pytest.mark.parametrize("provider", [IMProvider.FEISHU, IMProvider.LARK]) +def test_directory_keeps_open_department_identity_and_accepts_omitted_empty_page_items( + monkeypatch: pytest.MonkeyPatch, + provider: Literal[IMProvider.FEISHU, IMProvider.LARK], +) -> None: + gateway = IdentityAwareFakeSDKGateway() + gateway.user_responses.extend( + [ + _page([{"union_id": "union_root", "name": "Root User"}]), + _page([{"union_id": "union_target_like", "name": "Target Like", "email": "target@example.invalid"}]), + {"code": 0, "data": {"has_more": False}}, + ] + ) + gateway.department_responses.extend( + [ + _page([{"open_department_id": "open_dept_child"}]), + {"code": 0, "data": {"has_more": False}}, + ] + ) + adapter = _adapter(monkeypatch, provider, gateway) + + result = adapter.directory.read_directory() + + assert result == Directory( + ( + DirectoryEntry(ProviderUserId("union_root"), "Root User", None), + DirectoryEntry(ProviderUserId("union_target_like"), "Target Like", "target@example.invalid"), + ) + ) + assert gateway.calls == [ + ("list_users", ("0", "department_id", None)), + ("list_departments", ("0", "department_id", None)), + ("list_users", ("open_dept_child", "open_department_id", None)), + ("list_departments", ("open_dept_child", "open_department_id", None)), + ] + assert not any(call[0] == "list_scope" for call in gateway.calls) + + +@pytest.mark.parametrize("provider", [IMProvider.FEISHU, IMProvider.LARK]) +@pytest.mark.parametrize("omitted_boundary", ["users", "departments"]) +def test_directory_accepts_independently_omitted_items_on_empty_terminal_pages( + monkeypatch: pytest.MonkeyPatch, + provider: Literal[IMProvider.FEISHU, IMProvider.LARK], + omitted_boundary: str, +) -> None: + gateway = FakeSDKGateway() + omitted_items_page = {"code": 0, "data": {"has_more": False}} + gateway.user_responses.append(omitted_items_page if omitted_boundary == "users" else _page([])) + gateway.department_responses.append(omitted_items_page if omitted_boundary == "departments" else _page([])) + adapter = _adapter(monkeypatch, provider, gateway) + + result = adapter.directory.read_directory() + + assert result == Directory(()) + assert gateway.calls == [ + ("list_users", ("0", None)), + ("list_departments", ("0", None)), + ] + + +@pytest.mark.parametrize("provider", [IMProvider.FEISHU, IMProvider.LARK]) +def test_directory_discards_partial_entries_after_later_page_failure( + monkeypatch: pytest.MonkeyPatch, provider: Literal[IMProvider.FEISHU, IMProvider.LARK] +) -> None: + gateway = FakeSDKGateway() + gateway.user_responses.extend( + [_page([{"union_id": "union_root", "name": "Root User"}], next_page_token="next"), RuntimeError("raw")] + ) + adapter = _adapter(monkeypatch, provider, gateway) + + result = adapter.directory.read_directory() + + assert isinstance(result, DirectoryReadFailure) + assert "union_root" not in result.reason + assert "raw" not in result.reason + + +@pytest.mark.parametrize("provider", [IMProvider.FEISHU, IMProvider.LARK]) +def test_empty_directory_is_a_complete_successful_snapshot( + monkeypatch: pytest.MonkeyPatch, + provider: Literal[IMProvider.FEISHU, IMProvider.LARK], +) -> None: + gateway = FakeSDKGateway() + gateway.user_responses.append(_page([])) + gateway.department_responses.append(_page([])) + adapter = _adapter(monkeypatch, provider, gateway) + + result = adapter.directory.read_directory() + + assert result == Directory(()) + + +@pytest.mark.parametrize("provider", [IMProvider.FEISHU, IMProvider.LARK]) +def test_directory_ignores_scope_entries_and_publishes_only_root_bfs_users( + provider: Literal[IMProvider.FEISHU, IMProvider.LARK], + monkeypatch: pytest.MonkeyPatch, +) -> None: + gateway = FakeSDKGateway() + gateway.scope_responses.append( + _scope_page( + department_ids=["dept_selected"], + user_ids=["union_directly_selected"], + ) + ) + gateway.user_responses.append( + _page([{"union_id": "union_root", "name": "Root User", "email": "root@example.invalid"}]) + ) + gateway.department_responses.append(_page([])) + adapter = _adapter(monkeypatch, provider, gateway) + + result = adapter.directory.read_directory() + + assert result == Directory((DirectoryEntry(ProviderUserId("union_root"), "Root User", "root@example.invalid"),)) + assert gateway.calls == [ + ("list_users", ("0", None)), + ("list_departments", ("0", None)), + ] + + +@pytest.mark.parametrize("provider", [IMProvider.FEISHU, IMProvider.LARK]) +def test_directory_preserves_listing_profile_facts_and_missing_values( + provider: Literal[IMProvider.FEISHU, IMProvider.LARK], + monkeypatch: pytest.MonkeyPatch, +) -> None: + gateway = FakeSDKGateway() + gateway.user_responses.append( + _page( + [ + { + "union_id": "union_enterprise", + "name": "Enterprise User", + "enterprise_email": "enterprise@example.invalid", + "email": "fallback@example.invalid", + }, + { + "union_id": "union_fallback", + "name": " ", + "enterprise_email": " ", + "email": "fallback@example.invalid", + }, + {"union_id": "union_withheld"}, + ] + ) + ) + gateway.department_responses.append(_page([])) + adapter = _adapter(monkeypatch, provider, gateway) + + result = adapter.directory.read_directory() + + assert result == Directory( + ( + DirectoryEntry( + ProviderUserId("union_enterprise"), + "Enterprise User", + "enterprise@example.invalid", + ), + DirectoryEntry(ProviderUserId("union_fallback"), None, "fallback@example.invalid"), + DirectoryEntry(ProviderUserId("union_withheld"), None, None), + ) + ) + + +@pytest.mark.parametrize("provider", [IMProvider.FEISHU, IMProvider.LARK]) +def test_directory_bfs_deduplicates_users_departments_and_topology_cycles( + monkeypatch: pytest.MonkeyPatch, + provider: Literal[IMProvider.FEISHU, IMProvider.LARK], +) -> None: + gateway = IdentityAwareFakeSDKGateway() + gateway.user_responses.extend( + [ + _page( + [ + { + "union_id": "union_overlap", + "name": "First Observation", + "enterprise_email": "first@example.invalid", + }, + ], + next_page_token="root-users-next", + ), + _page( + [ + {"union_id": "union_root", "name": "Root User"}, + {"union_id": "union_withheld"}, + ], + ), + _page( + [ + { + "union_id": "union_overlap", + "name": "Later Observation", + "email": "later@example.invalid", + }, + {"union_id": "union_child", "name": "Child User", "email": "child@example.invalid"}, + ] + ), + ] + ) + gateway.department_responses.extend( + [ + _page([{"open_department_id": "open_dept_child"}], next_page_token="root-departments-next"), + _page([{"open_department_id": "open_dept_child"}]), + _page([{"department_id": "0"}]), + ] + ) + adapter = _adapter(monkeypatch, provider, gateway) + + result = adapter.directory.read_directory() + + assert result == Directory( + ( + DirectoryEntry(ProviderUserId("union_overlap"), "First Observation", "first@example.invalid"), + DirectoryEntry(ProviderUserId("union_root"), "Root User", None), + DirectoryEntry(ProviderUserId("union_withheld"), None, None), + DirectoryEntry(ProviderUserId("union_child"), "Child User", "child@example.invalid"), + ) + ) + assert gateway.calls == [ + ("list_users", ("0", "department_id", None)), + ("list_users", ("0", "department_id", "root-users-next")), + ("list_departments", ("0", "department_id", None)), + ("list_departments", ("0", "department_id", "root-departments-next")), + ("list_users", ("open_dept_child", "open_department_id", None)), + ("list_departments", ("open_dept_child", "open_department_id", None)), + ] + assert not any(call[0] == "list_scope" for call in gateway.calls) + + +@pytest.mark.parametrize("provider", [IMProvider.FEISHU, IMProvider.LARK]) +@pytest.mark.parametrize( + "malformed_boundary", + [ + "user-non-list-items", + "user-missing-union-id", + "department-non-list-items", + "department-missing-open-id", + ], +) +def test_directory_rejects_malformed_non_empty_pages_after_partial_accumulation( + monkeypatch: pytest.MonkeyPatch, + provider: Literal[IMProvider.FEISHU, IMProvider.LARK], + malformed_boundary: str, +) -> None: + gateway = FakeSDKGateway() + if malformed_boundary.startswith("user-"): + malformed_items: object = ( + {"union_id": "union_malformed"} + if malformed_boundary == "user-non-list-items" + else [{"name": "Missing Union ID"}] + ) + gateway.user_responses.extend( + [ + _page([{"union_id": "union_partial"}], next_page_token="users-next"), + {"code": 0, "data": {"items": malformed_items, "has_more": False}}, + ] + ) + expected_calls = [ + ("list_users", ("0", None)), + ("list_users", ("0", "users-next")), + ] + else: + malformed_items = ( + {"open_department_id": "open_dept_malformed"} + if malformed_boundary == "department-non-list-items" + else [{"open_department_id": ""}] + ) + gateway.user_responses.append(_page([{"union_id": "union_partial"}])) + gateway.department_responses.extend( + [ + _page([{"open_department_id": "open_dept_pending"}], next_page_token="departments-next"), + {"code": 0, "data": {"items": malformed_items, "has_more": False}}, + ] + ) + expected_calls = [ + ("list_users", ("0", None)), + ("list_departments", ("0", None)), + ("list_departments", ("0", "departments-next")), + ] + adapter = _adapter(monkeypatch, provider, gateway) + + result = adapter.directory.read_directory() + + assert isinstance(result, DirectoryReadFailure) + assert "union_partial" not in result.reason + assert "malformed" not in result.reason.casefold() + assert "open_dept_pending" not in result.reason + assert gateway.calls == expected_calls + + +@pytest.mark.parametrize( + "failure_case", + [ + "user-rejected", + "user-transport", + "user-missing-token", + "user-token-loop", + "user-invalid-has-more", + "department-rejected", + "department-transport", + "department-missing-token", + "department-token-loop", + "department-invalid-identity", + "department-invalid-has-more", + ], +) +@pytest.mark.parametrize("provider", [IMProvider.FEISHU, IMProvider.LARK]) +def test_directory_rejects_incomplete_or_malformed_department_pages_without_partial_snapshot( + provider: Literal[IMProvider.FEISHU, IMProvider.LARK], + monkeypatch: pytest.MonkeyPatch, + failure_case: str, +) -> None: + gateway = FakeSDKGateway() + if failure_case == "user-rejected": + gateway.user_responses.append({"code": 403}) + elif failure_case == "user-transport": + gateway.user_responses.append(RuntimeError("sanitized transport failure")) + elif failure_case == "user-missing-token": + gateway.user_responses.append({"code": 0, "data": {"items": [{"union_id": "union_partial"}], "has_more": True}}) + elif failure_case == "user-token-loop": + gateway.user_responses.extend( + [ + _page([{"union_id": "union_partial"}], next_page_token="users-repeat"), + _page([], next_page_token="users-repeat"), + ] + ) + elif failure_case == "user-invalid-has-more": + gateway.user_responses.append({"code": 0, "data": {"items": [], "has_more": "false"}}) + else: + gateway.user_responses.append(_page([{"union_id": "union_partial", "name": "Partial User"}])) + if failure_case == "department-rejected": + gateway.department_responses.append({"code": 403}) + elif failure_case == "department-transport": + gateway.department_responses.append(RuntimeError("sanitized transport failure")) + elif failure_case == "department-missing-token": + gateway.department_responses.append({"code": 0, "data": {"items": [], "has_more": True}}) + elif failure_case == "department-token-loop": + gateway.department_responses.extend( + [ + _page([], next_page_token="departments-repeat"), + _page([], next_page_token="departments-repeat"), + ] + ) + elif failure_case == "department-invalid-identity": + gateway.department_responses.append(_page([{}])) + else: + gateway.department_responses.append({"code": 0, "data": {"items": [], "has_more": "false"}}) + adapter = _adapter(monkeypatch, provider, gateway) + + result = adapter.directory.read_directory() + + assert isinstance(result, DirectoryReadFailure) + assert "union_partial" not in result.reason + assert "sanitized" not in result.reason + + +@pytest.mark.parametrize( + "malformed_user", + [ + {"open_id": "ou_sanitized_wrong_identity"}, + {"union_id": ""}, + {"union_id": ["union_sanitized_user"]}, + ], +) +@pytest.mark.parametrize("provider", [IMProvider.FEISHU, IMProvider.LARK]) +def test_malformed_directory_identity_fails_without_partial_snapshot( + provider: Literal[IMProvider.FEISHU, IMProvider.LARK], + monkeypatch: pytest.MonkeyPatch, + malformed_user: Mapping[str, object], +) -> None: + gateway = FakeSDKGateway() + gateway.user_responses.append(_page([{"union_id": "union_valid", "name": "Valid User"}, malformed_user])) + adapter = _adapter(monkeypatch, provider, gateway) + + result = adapter.directory.read_directory() + + assert isinstance(result, DirectoryReadFailure) + assert "union_valid" not in result.reason + assert "ou_sanitized_wrong_identity" not in result.reason + + +@pytest.mark.parametrize("provider", [IMProvider.FEISHU, IMProvider.LARK]) +@pytest.mark.parametrize("invalid_code", [False, "0"], ids=("boolean", "string")) +def test_directory_rejects_non_integer_success_code_without_partial_snapshot( + monkeypatch: pytest.MonkeyPatch, + provider: Literal[IMProvider.FEISHU, IMProvider.LARK], + invalid_code: object, +) -> None: + gateway = FakeSDKGateway() + gateway.user_responses.append( + { + "code": invalid_code, + "data": {"items": [{"union_id": "union_sanitized"}], "has_more": False}, + } + ) + adapter = _adapter(monkeypatch, provider, gateway) + + result = adapter.directory.read_directory() + + assert isinstance(result, DirectoryReadFailure) + assert not any(call[0] == "list_departments" for call in gateway.calls) + + +@pytest.mark.parametrize("provider", [IMProvider.FEISHU, IMProvider.LARK]) +@pytest.mark.parametrize( + ("subject", "expected_elements"), + [ + (None, [{"tag": "markdown", "content": "# Decision\n\n**Approve**"}]), + ( + "### Review **Alice**", + [ + {"tag": "markdown", "content": r"# \#\#\# Review \*\*Alice\*\*"}, + {"tag": "markdown", "content": "# Decision\n\n**Approve**"}, + ], + ), + ], +) +def test_text_send_uses_union_id_and_preserves_markdown_in_one_creation( + monkeypatch: pytest.MonkeyPatch, + provider: Literal[IMProvider.FEISHU, IMProvider.LARK], + subject: str | None, + expected_elements: list[dict[str, str]], +) -> None: + gateway = FakeSDKGateway() + gateway.tenant_responses.append(_tenant_response()) + gateway.create_responses.append({"code": 0, "data": {"message_id": "om_sanitized_message"}}) + adapter = _adapter(monkeypatch, provider, gateway) + + result = adapter.messaging.send_text(ProviderUserId("union_sanitized_user"), "# Decision\n\n**Approve**", subject) + + assert isinstance(result, MessageAccepted) + creation = [call for call in gateway.calls if call[0] == "create_message"] + assert len(creation) == 1 + arguments = creation[0][1] + assert isinstance(arguments, tuple) + assert len(arguments) == 3 + receive_id, msg_type, content = arguments + assert (receive_id, msg_type) == ("union_sanitized_user", "interactive") + assert isinstance(content, str) + assert json.loads(content) == {"schema": "2.0", "body": {"elements": expected_elements}} + + +@pytest.mark.parametrize("provider", [IMProvider.FEISHU, IMProvider.LARK]) +def test_text_send_preserves_body_markdown_without_a_subject( + monkeypatch: pytest.MonkeyPatch, provider: Literal[IMProvider.FEISHU, IMProvider.LARK] +) -> None: + gateway = FakeSDKGateway() + gateway.tenant_responses.append(_tenant_response()) + gateway.create_responses.append({"code": 0, "data": {"message_id": "om_sanitized_message"}}) + adapter = _adapter(monkeypatch, provider, gateway) + + result = adapter.messaging.send_text( + ProviderUserId("union_sanitized_user"), + "Decision: **Approve**", + ) + + assert isinstance(result, MessageAccepted) + creation = next(call for call in gateway.calls if call[0] == "create_message") + assert isinstance(creation[1], tuple) + assert len(creation[1]) == 3 + assert isinstance(creation[1][2], str) + assert json.loads(creation[1][2]) == { + "schema": "2.0", + "body": {"elements": [{"tag": "markdown", "content": "Decision: **Approve**"}]}, + } + + +@pytest.mark.parametrize("provider", [IMProvider.FEISHU, IMProvider.LARK]) +def test_ambiguous_text_send_is_safe_and_never_replayed( + monkeypatch: pytest.MonkeyPatch, provider: Literal[IMProvider.FEISHU, IMProvider.LARK] +) -> None: + gateway = FakeSDKGateway() + gateway.tenant_responses.append(_tenant_response()) + gateway.create_responses.append(RuntimeError("sensitive raw provider response")) + adapter = _adapter(monkeypatch, provider, gateway) + + result = adapter.messaging.send_text(ProviderUserId("union_sanitized_user"), "Rendered text") + + assert isinstance(result, MessageSendingError) + assert "sensitive raw provider response" not in result.reason + assert len([call for call in gateway.calls if call[0] == "create_message"]) == 1 + + +@pytest.mark.parametrize("provider", [IMProvider.FEISHU, IMProvider.LARK]) +def test_explicit_text_rejection_is_safe_and_attempted_once( + monkeypatch: pytest.MonkeyPatch, provider: Literal[IMProvider.FEISHU, IMProvider.LARK] +) -> None: + gateway = FakeSDKGateway() + gateway.tenant_responses.append(_tenant_response()) + gateway.create_responses.append({"code": 230001, "msg": "sanitized rejection"}) + adapter = _adapter(monkeypatch, provider, gateway) + + result = adapter.messaging.send_text(ProviderUserId("union_sanitized_user"), "Rendered text") + + assert isinstance(result, MessageSendingError) + assert "sanitized rejection" not in result.reason + assert len([call for call in gateway.calls if call[0] == "create_message"]) == 1 + + +@pytest.mark.parametrize("provider", [IMProvider.FEISHU, IMProvider.LARK]) +@pytest.mark.parametrize("invalid_code", [False, "0"], ids=("boolean", "string")) +def test_text_send_rejects_non_integer_success_code( + monkeypatch: pytest.MonkeyPatch, + provider: Literal[IMProvider.FEISHU, IMProvider.LARK], + invalid_code: object, +) -> None: + gateway = FakeSDKGateway() + gateway.tenant_responses.append(_tenant_response()) + gateway.create_responses.append({"code": invalid_code, "data": {"message_id": "om_sanitized_message"}}) + adapter = _adapter(monkeypatch, provider, gateway) + + result = adapter.messaging.send_text(ProviderUserId("union_sanitized_user"), "Rendered text") + + assert isinstance(result, MessageSendingError) + assert len([call for call in gateway.calls if call[0] == "create_message"]) == 1 + + +@pytest.mark.parametrize("input_type", ["file", "file-list"]) +@pytest.mark.parametrize("provider", [IMProvider.FEISHU, IMProvider.LARK]) +def test_file_controls_make_whole_card_unrepresentable_without_mutation( + provider: Literal[IMProvider.FEISHU, IMProvider.LARK], + monkeypatch: pytest.MonkeyPatch, + input_type: str, +) -> None: + gateway = FakeSDKGateway() + adapter = _adapter(monkeypatch, provider, gateway) + intent = _intent(input_type) + + assessment = adapter.dynamic_card_messaging.assess(intent) + + assert assessment.representable is False + with pytest.raises(DynamicCardMessagingError): + adapter.dynamic_card_messaging.send_card( + ProviderUserId("union_sanitized_user"), + intent, + CorrelationToken("opaque-correlation-token"), + ) + assert not gateway.calls + + +@pytest.mark.parametrize("provider", [IMProvider.FEISHU, IMProvider.LARK]) +def test_provider_confirmed_form_preserves_markdown_after_input_without_reordering( + monkeypatch: pytest.MonkeyPatch, + provider: Literal[IMProvider.FEISHU, IMProvider.LARK], +) -> None: + gateway = FakeSDKGateway() + adapter = _adapter(monkeypatch, provider, gateway) + intent = ResolvedForm( + title="Approval", + parts=( + ParagraphInput(output_variable_name="comment", default_value=None), + MarkdownFragment(text="After input"), + ), + actions=(UserAction(id="approve", title="Approve", button_style=ButtonStyle.PRIMARY),), + legacy_form_content="This value must not be rendered", + ) + + assessment = adapter.dynamic_card_messaging.assess(intent) + encoded = adapter_module._MSFeishuLarkCardCodec().encode( + intent, + CorrelationToken("opaque-correlation-token"), + ) + + assert assessment.representable is True + body = encoded["body"] + assert isinstance(body, dict) + body_elements = body["elements"] + assert isinstance(body_elements, list) + form = body_elements[0] + assert isinstance(form, dict) + form_elements = form["elements"] + assert isinstance(form_elements, list) + tags: list[object] = [] + for element in form_elements: + assert isinstance(element, dict) + tags.append(element["tag"]) + assert tags == ["input", "markdown", "column_set"] + assert gateway.calls == [] + + +@pytest.mark.parametrize("provider", [IMProvider.FEISHU, IMProvider.LARK]) +def test_card_renderer_matches_provider_confirmed_form_contract( + monkeypatch: pytest.MonkeyPatch, + provider: Literal[IMProvider.FEISHU, IMProvider.LARK], +) -> None: + gateway = FakeSDKGateway() + gateway.tenant_responses.append(_tenant_response()) + gateway.create_responses.append({"code": 0, "data": {"message_id": "om_sanitized_card"}}) + adapter = _adapter(monkeypatch, provider, gateway) + intent = _provider_confirmed_form_intent() + + result = adapter.dynamic_card_messaging.send_card( + ProviderUserId("union_sanitized_user"), + intent, + CorrelationToken("synthetic-correlation-token"), + ) + + assert isinstance(result, MessageAccepted) + creations = [call for call in gateway.calls if call[0] == "create_message"] + assert len(creations) == 1 + creation = creations[0] + arguments = creation[1] + assert isinstance(arguments, tuple) + assert len(arguments) == 3 + _, msg_type, content = arguments + assert isinstance(content, str) + assert msg_type == "interactive" + assert json.loads(content) == adapter_module._MSFeishuLarkCardCodec().encode( + intent, + CorrelationToken("synthetic-correlation-token"), + ) + + +@pytest.mark.parametrize( + ("provider_response", "expected_stage"), + [ + ({"code": 230001, "msg": "sensitive provider rejection"}, "provider-response"), + (RuntimeError("sensitive SDK exception"), "create-message"), + ({"code": 0, "data": {"message_id": ""}}, "response-validation"), + ], +) +@pytest.mark.parametrize("provider", [IMProvider.FEISHU, IMProvider.LARK]) +def test_card_send_logs_only_the_safe_acceptance_failure_stage( + provider: Literal[IMProvider.FEISHU, IMProvider.LARK], + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, + provider_response: Mapping[str, object] | Exception, + expected_stage: str, +) -> None: + gateway = FakeSDKGateway() + gateway.tenant_responses.append(_tenant_response()) + gateway.create_responses.append(provider_response) + adapter = _adapter(monkeypatch, provider, gateway) + + result = adapter.dynamic_card_messaging.send_card( + ProviderUserId("union_sanitized_user"), + _intent(), + CorrelationToken("synthetic-correlation-token"), + ) + + assert result == MessageSendingError(f"{provider.name.title()} card acceptance could not be confirmed.") + assert len([call for call in gateway.calls if call[0] == "create_message"]) == 1 + assert any( + record.name == adapter_module.__name__ + and record.getMessage() == f"Feishu/Lark card acceptance failed at {expected_stage} stage" + for record in caplog.records + ) + assert "sensitive provider rejection" not in caplog.text + assert "sensitive SDK exception" not in caplog.text + + +@pytest.mark.parametrize("provider", [IMProvider.FEISHU, IMProvider.LARK]) +def test_reference_round_trips_and_updates_only_exact_original_message( + monkeypatch: pytest.MonkeyPatch, provider: Literal[IMProvider.FEISHU, IMProvider.LARK] +) -> None: + source_gateway = FakeSDKGateway() + source_gateway.tenant_responses.append(_tenant_response()) + source_gateway.create_responses.append({"code": 0, "data": {"message_id": "om_sanitized_card"}}) + source = _adapter(monkeypatch, provider, source_gateway) + accepted = source.dynamic_card_messaging.send_card( + ProviderUserId("union_sanitized_user"), + _intent(), + CorrelationToken("opaque-correlation-token"), + ) + assert isinstance(accepted, MessageAccepted) + padding = "=" * (-len(str(accepted.locator)) % 4) + serialized_payload = base64.urlsafe_b64decode(str(accepted.locator) + padding) + assert json.loads(serialized_payload) == { + "v": 1, + "p": provider.value, + "message_id": "om_sanitized_card", + } + persisted = MessageLocator(str(accepted.locator)) + + replacement_gateway = FakeSDKGateway() + replacement_gateway.tenant_responses.append(_tenant_response()) + replacement_gateway.patch_responses.append({"code": 0, "data": {}}) + replacement = _adapter(monkeypatch, provider, replacement_gateway) + + result = replacement.dynamic_card_messaging.replace_with_static( + persisted, + StaticCardIntent("Submitted **successfully**"), + ) + + assert result is None + mutation = next(call for call in replacement_gateway.calls if call[0] == "patch_message") + assert isinstance(mutation[1], tuple) + assert len(mutation[1]) == 2 + assert mutation[1][0] == "om_sanitized_card" + assert isinstance(mutation[1][1], str) + static_card = json.loads(mutation[1][1]) + assert static_card == { + "schema": "2.0", + "body": {"elements": [{"tag": "markdown", "content": "Submitted **successfully**"}]}, + } + + +@pytest.mark.parametrize("provider", [IMProvider.FEISHU, IMProvider.LARK]) +def test_reference_survives_provider_app_secret_rotation( + monkeypatch: pytest.MonkeyPatch, + provider: Literal[IMProvider.FEISHU, IMProvider.LARK], +) -> None: + source_gateway = FakeSDKGateway() + source_gateway.tenant_responses.append(_tenant_response()) + source_gateway.create_responses.append({"code": 0, "data": {"message_id": "om_sanitized_card"}}) + source = _adapter(monkeypatch, provider, source_gateway) + accepted = source.dynamic_card_messaging.send_card( + ProviderUserId("union_sanitized_user"), + _intent(), + CorrelationToken("opaque-correlation-token"), + ) + assert isinstance(accepted, MessageAccepted) + + replacement_gateway = FakeSDKGateway() + replacement_gateway.tenant_responses.append(_tenant_response()) + replacement_gateway.patch_responses.append({"code": 0}) + monkeypatch.setattr(adapter_module, "_create_sdk_gateway", lambda _credentials, _domain: replacement_gateway) + rotated_credentials = _credentials(provider).model_copy( + update={"app_secret": "sanitized-rotated-app-secret"}, + ) + replacement = ( + FeishuIMProviderAdapter(rotated_credentials) + if isinstance(rotated_credentials, FeishuCredentials) + else LarkIMProviderAdapter(rotated_credentials) + ) + + result = replacement.dynamic_card_messaging.replace_with_static( + accepted.locator, + StaticCardIntent("Submitted after credential rotation"), + ) + + assert result is None + mutation = next(call for call in replacement_gateway.calls if call[0] == "patch_message") + assert isinstance(mutation[1], tuple) + assert len(mutation[1]) == 2 + assert mutation[1][0] == "om_sanitized_card" + + +@pytest.mark.parametrize("provider", [IMProvider.FEISHU, IMProvider.LARK]) +def test_message_reference_does_not_depend_on_dify_secret_key( + monkeypatch: pytest.MonkeyPatch, + provider: Literal[IMProvider.FEISHU, IMProvider.LARK], +) -> None: + source_gateway = FakeSDKGateway() + source_gateway.tenant_responses.append(_tenant_response()) + source_gateway.create_responses.append({"code": 0, "data": {"message_id": "om_sanitized_card"}}) + source = _adapter(monkeypatch, provider, source_gateway) + accepted = source.dynamic_card_messaging.send_card( + ProviderUserId("union_sanitized_user"), + _intent(), + CorrelationToken("opaque-correlation-token"), + ) + assert isinstance(accepted, MessageAccepted) + + replacement_gateway = FakeSDKGateway() + replacement_gateway.tenant_responses.append(_tenant_response()) + replacement_gateway.patch_responses.append({"code": 0}) + replacement = _adapter(monkeypatch, provider, replacement_gateway) + + result = replacement.dynamic_card_messaging.replace_with_static( + accepted.locator, + StaticCardIntent("Submitted"), + ) + + assert result is None + assert len([call for call in replacement_gateway.calls if call[0] == "patch_message"]) == 1 + + +def test_cross_provider_reference_is_invalid_without_mutation(monkeypatch: pytest.MonkeyPatch) -> None: + source_gateway = FakeSDKGateway() + source_gateway.tenant_responses.append(_tenant_response()) + source_gateway.create_responses.append({"code": 0, "data": {"message_id": "om_sanitized_card"}}) + source = _adapter(monkeypatch, IMProvider.FEISHU, source_gateway) + accepted = source.dynamic_card_messaging.send_card( + ProviderUserId("union_sanitized_user"), + _intent(), + CorrelationToken("opaque-correlation-token"), + ) + assert isinstance(accepted, MessageAccepted) + lark_gateway = FakeSDKGateway() + lark = _adapter(monkeypatch, IMProvider.LARK, lark_gateway) + + result = lark.dynamic_card_messaging.replace_with_static( + accepted.locator, + StaticCardIntent("Submitted"), + ) + + assert isinstance(result, ReplacementError) + assert result.kind is ReplacementErrorKind.INVALID_REFERENCE + assert not lark_gateway.calls + + +@pytest.mark.parametrize("provider", [IMProvider.FEISHU, IMProvider.LARK]) +def test_malformed_reference_is_invalid_without_provider_io( + monkeypatch: pytest.MonkeyPatch, provider: Literal[IMProvider.FEISHU, IMProvider.LARK] +) -> None: + source_gateway = FakeSDKGateway() + source_gateway.tenant_responses.append(_tenant_response()) + source_gateway.create_responses.append({"code": 0, "data": {"message_id": "om_sanitized_card"}}) + source = _adapter(monkeypatch, provider, source_gateway) + accepted = source.dynamic_card_messaging.send_card( + ProviderUserId("union_sanitized_user"), + _intent(), + CorrelationToken("opaque-correlation-token"), + ) + assert isinstance(accepted, MessageAccepted) + locator_value = str(accepted.locator) + malformed_suffix = "A" if not locator_value.endswith("A") else "B" + malformed = MessageLocator(locator_value[:-1] + malformed_suffix) + replacement_gateway = FakeSDKGateway() + replacement = _adapter(monkeypatch, provider, replacement_gateway) + + result = replacement.dynamic_card_messaging.replace_with_static(malformed, StaticCardIntent("Submitted")) + + assert isinstance(result, ReplacementError) + assert result.kind is ReplacementErrorKind.INVALID_REFERENCE + assert replacement_gateway.calls == [] + + +@pytest.mark.parametrize("provider", [IMProvider.FEISHU, IMProvider.LARK]) +def test_reference_rejects_redundant_base64_padding_without_message_mutation( + monkeypatch: pytest.MonkeyPatch, + provider: Literal[IMProvider.FEISHU, IMProvider.LARK], +) -> None: + reference = _accepted_card_reference(monkeypatch, provider) + + _assert_invalid_reference_without_mutation(monkeypatch, provider, f"{str(reference)}=") + + +@pytest.mark.parametrize( + ("provider", "message_id", "canonical_character", "standard_base64_alias"), + [ + (IMProvider.FEISHU, "om_\u00be", "-", "+"), + (IMProvider.FEISHU, "om_\u00bf", "_", "/"), + (IMProvider.LARK, "om_xx\u00be", "-", "+"), + (IMProvider.LARK, "om_xx\u00bf", "_", "/"), + ], + ids=("feishu-plus-alias", "feishu-slash-alias", "lark-plus-alias", "lark-slash-alias"), +) +def test_reference_rejects_standard_base64_aliases_without_message_mutation( + monkeypatch: pytest.MonkeyPatch, + provider: Literal[IMProvider.FEISHU, IMProvider.LARK], + message_id: str, + canonical_character: str, + standard_base64_alias: str, +) -> None: + reference = _accepted_card_reference(monkeypatch, provider, message_id=message_id) + assert canonical_character in str(reference) + aliased_payload = str(reference).replace(canonical_character, standard_base64_alias) + + _assert_invalid_reference_without_mutation(monkeypatch, provider, aliased_payload) + + +@pytest.mark.parametrize( + "opaque_template", + [ + ".{reference}", + "{reference}.", + "{reference}.extra", + ], + ids=("leading-dot", "trailing-dot", "extra-segment"), +) +@pytest.mark.parametrize("provider", [IMProvider.FEISHU, IMProvider.LARK]) +def test_reference_rejects_extra_segments_and_dots_without_message_mutation( + monkeypatch: pytest.MonkeyPatch, + provider: Literal[IMProvider.FEISHU, IMProvider.LARK], + opaque_template: str, +) -> None: + reference = _accepted_card_reference(monkeypatch, provider) + opaque = opaque_template.format(reference=str(reference)) + + _assert_invalid_reference_without_mutation(monkeypatch, provider, opaque) + + +@pytest.mark.parametrize("provider", [IMProvider.FEISHU, IMProvider.LARK]) +def test_reference_does_not_require_tenant_lookup_before_patch( + monkeypatch: pytest.MonkeyPatch, provider: Literal[IMProvider.FEISHU, IMProvider.LARK] +) -> None: + source_gateway = FakeSDKGateway() + source_gateway.tenant_responses.append(_tenant_response("tenant_source")) + source_gateway.create_responses.append({"code": 0, "data": {"message_id": "om_sanitized_card"}}) + source = _adapter(monkeypatch, provider, source_gateway) + accepted = source.dynamic_card_messaging.send_card( + ProviderUserId("union_sanitized_user"), + _intent(), + CorrelationToken("opaque-correlation-token"), + ) + assert isinstance(accepted, MessageAccepted) + replacement_gateway = FakeSDKGateway() + replacement_gateway.patch_responses.append({"code": 0, "data": {}}) + replacement = _adapter(monkeypatch, provider, replacement_gateway) + + result = replacement.dynamic_card_messaging.replace_with_static( + accepted.locator, + StaticCardIntent("Submitted"), + ) + + assert result is None + assert [call[0] for call in replacement_gateway.calls] == ["patch_message"] + + +@pytest.mark.parametrize("provider_code", [230001, 230011, 230020]) +@pytest.mark.parametrize("provider", [IMProvider.FEISHU, IMProvider.LARK]) +def test_provider_stale_reference_is_classified_without_replay( + provider: Literal[IMProvider.FEISHU, IMProvider.LARK], + monkeypatch: pytest.MonkeyPatch, + provider_code: int, +) -> None: + source_gateway = FakeSDKGateway() + source_gateway.tenant_responses.append(_tenant_response()) + source_gateway.create_responses.append({"code": 0, "data": {"message_id": "om_sanitized_card"}}) + source = _adapter(monkeypatch, provider, source_gateway) + accepted = source.dynamic_card_messaging.send_card( + ProviderUserId("union_sanitized_user"), + _intent(), + CorrelationToken("opaque-correlation-token"), + ) + assert isinstance(accepted, MessageAccepted) + replacement_gateway = FakeSDKGateway() + replacement_gateway.tenant_responses.append(_tenant_response()) + replacement_gateway.patch_responses.append({"code": provider_code, "msg": "sanitized stale response"}) + replacement = _adapter(monkeypatch, provider, replacement_gateway) + + result = replacement.dynamic_card_messaging.replace_with_static( + accepted.locator, + StaticCardIntent("Submitted"), + ) + + assert isinstance(result, ReplacementError) + assert result.kind is ReplacementErrorKind.STALE_REFERENCE + assert "sanitized stale response" not in result.reason + assert len([call for call in replacement_gateway.calls if call[0] == "patch_message"]) == 1 + + +@pytest.mark.parametrize("provider", [IMProvider.FEISHU, IMProvider.LARK]) +@pytest.mark.parametrize("invalid_code", [False, "0"], ids=("boolean", "string")) +def test_card_replacement_rejects_non_integer_success_code( + monkeypatch: pytest.MonkeyPatch, + provider: Literal[IMProvider.FEISHU, IMProvider.LARK], + invalid_code: object, +) -> None: + source_gateway = FakeSDKGateway() + source_gateway.tenant_responses.append(_tenant_response()) + source_gateway.create_responses.append({"code": 0, "data": {"message_id": "om_sanitized_card"}}) + source = _adapter(monkeypatch, provider, source_gateway) + accepted = source.dynamic_card_messaging.send_card( + ProviderUserId("union_sanitized_user"), + _intent(), + CorrelationToken("opaque-correlation-token"), + ) + assert isinstance(accepted, MessageAccepted) + replacement_gateway = FakeSDKGateway() + replacement_gateway.tenant_responses.append(_tenant_response()) + replacement_gateway.patch_responses.append({"code": invalid_code}) + replacement = _adapter(monkeypatch, provider, replacement_gateway) + + result = replacement.dynamic_card_messaging.replace_with_static( + accepted.locator, + StaticCardIntent("Submitted"), + ) + + assert isinstance(result, ReplacementError) + assert result.kind is ReplacementErrorKind.UNKNOWN + assert len([call for call in replacement_gateway.calls if call[0] == "patch_message"]) == 1 + + +@pytest.mark.parametrize("provider", [IMProvider.FEISHU, IMProvider.LARK]) +def test_root_close_blocks_new_capability_access_without_invalidating_created_handler( + provider: Literal[IMProvider.FEISHU, IMProvider.LARK], + monkeypatch: pytest.MonkeyPatch, +) -> None: + adapter = _adapter(monkeypatch, provider, FakeSDKGateway()) + directory = adapter.directory + + adapter.close() + adapter.close() + + with pytest.raises(RuntimeError, match="closed"): + _ = adapter.directory + assert directory is not None diff --git a/api/tests/unit_tests/core/human_input_v2/im_provider/test_feishu_lark_branches.py b/api/tests/unit_tests/core/human_input_v2/im_provider/test_feishu_lark_branches.py new file mode 100644 index 00000000000..7511877fea5 --- /dev/null +++ b/api/tests/unit_tests/core/human_input_v2/im_provider/test_feishu_lark_branches.py @@ -0,0 +1,332 @@ +from __future__ import annotations + +import base64 +import hashlib +import inspect +import json +from datetime import UTC, datetime +from typing import cast, get_type_hints, is_protocol + +import pytest + +from core.human_input import ButtonStyle +from core.human_input_v2.entities import IMProvider +from core.human_input_v2.im_integration import adapters as adapters_package +from core.human_input_v2.im_integration.adapters import ( + CorrelationToken, + IMStreamStartError, + IMStreamStopError, + MessageLocator, + WebhookRequest, +) +from core.human_input_v2.im_integration.adapters import feishu_lark as adapter_module +from core.human_input_v2.im_integration.adapters.credentials import FeishuCredentials, LarkCredentials +from core.human_input_v2.im_integration.adapters.entities import IMProviderError +from core.human_input_v2.im_integration.adapters.feishu_lark import ( + FeishuIMProviderAdapter, + LarkIMProviderAdapter, +) +from core.human_input_v2.resolved_form import ( + FormPart, + MarkdownFragment, + ParagraphInput, + ResolvedForm, + SelectInput, + UserAction, +) + + +def test_stream_factory_returns_private_concrete_type_without_stream_contract_stub() -> None: + return_type = get_type_hints(adapter_module._FeishuLarkIMProviderAdapter.create_stream_handler)["return"] + stream_protocols = { + name + for name, candidate in vars(adapter_module).items() + if name.startswith("_") and "Stream" in name and is_protocol(candidate) + } + + assert return_type is adapter_module._FeishuLarkEventStream + assert not is_protocol(return_type) + assert return_type.__name__.startswith("_") + assert not hasattr(adapters_package, return_type.__name__) + public_methods = { + name for name, member in vars(return_type).items() if not name.startswith("_") and callable(member) + } + assert public_methods == {"start", "stop"} + assert not hasattr(adapter_module, "_IMEventStream") + assert not hasattr(adapter_module, "StopSignal") + assert stream_protocols == set() + source = inspect.getsource(adapter_module) + assert "def run(" not in source + assert ".run(signal" not in source + + +def test_stream_errors_use_integrated_provider_contract_split() -> None: + assert adapter_module.IMStreamStartError is IMStreamStartError + assert adapter_module.IMStreamStopError is IMStreamStopError + assert not hasattr(adapter_module, "IMStreamRunError") + + +def _credentials() -> FeishuCredentials: + return FeishuCredentials( + provider=IMProvider.FEISHU, + app_id="cli_sanitized_app", + app_secret="sanitized-app-secret", + verification_token="sanitized-verification-token", + encrypt_key="sanitized-encrypt-key", + ) + + +def _intent( + *, + blocks: tuple[FormPart, ...] = (MarkdownFragment(text="Rendered content"),), + actions: tuple[UserAction, ...] = (), + title: str = "", +) -> ResolvedForm: + return ResolvedForm( + title=title, + parts=blocks, + actions=actions, + legacy_form_content="This value must not be rendered", + ) + + +def test_official_gateway_builds_every_sdk_request_with_pagination(monkeypatch: pytest.MonkeyPatch) -> None: + client = adapter_module.lark.Client.builder().app_id("test-app").app_secret("test-secret").build() + assert client.tenant is not None + assert client.contact is not None + assert client.im is not None + tenant_requests: list[adapter_module.tenant.v2.QueryTenantRequest] = [] + + def tenant_call( + request: adapter_module.tenant.v2.QueryTenantRequest, option: adapter_module.lark.RequestOption | None = None + ) -> adapter_module.tenant.v2.QueryTenantResponse: + del option + tenant_requests.append(request) + return adapter_module.tenant.v2.QueryTenantResponse( + {"code": 0, "data": {"tenant": {"tenant_key": "tenant_sanitized"}}} + ) + + monkeypatch.setattr(client.tenant.v2.tenant, "query", tenant_call) + scope_requests: list[adapter_module.contact.v3.ListScopeRequest] = [] + + def scope_call( + request: adapter_module.contact.v3.ListScopeRequest, option: adapter_module.lark.RequestOption | None = None + ) -> adapter_module.contact.v3.ListScopeResponse: + del option + scope_requests.append(request) + return adapter_module.contact.v3.ListScopeResponse( + {"code": 0, "data": {"department_ids": [], "user_ids": [], "has_more": False}} + ) + + monkeypatch.setattr(client.contact.v3.scope, "list", scope_call) + department_requests: list[adapter_module.contact.v3.ChildrenDepartmentRequest] = [] + + def department_call( + request: adapter_module.contact.v3.ChildrenDepartmentRequest, + option: adapter_module.lark.RequestOption | None = None, + ) -> adapter_module.contact.v3.ChildrenDepartmentResponse: + del option + department_requests.append(request) + return adapter_module.contact.v3.ChildrenDepartmentResponse( + {"code": 0, "data": {"items": [], "has_more": False}} + ) + + monkeypatch.setattr(client.contact.v3.department, "children", department_call) + user_requests: list[adapter_module.contact.v3.FindByDepartmentUserRequest] = [] + + def user_call( + request: adapter_module.contact.v3.FindByDepartmentUserRequest, + option: adapter_module.lark.RequestOption | None = None, + ) -> adapter_module.contact.v3.FindByDepartmentUserResponse: + del option + user_requests.append(request) + return adapter_module.contact.v3.FindByDepartmentUserResponse( + {"code": 0, "data": {"items": [], "has_more": False}} + ) + + monkeypatch.setattr(client.contact.v3.user, "find_by_department", user_call) + create_requests: list[adapter_module.im.v1.CreateMessageRequest] = [] + + def create_call( + request: adapter_module.im.v1.CreateMessageRequest, option: adapter_module.lark.RequestOption | None = None + ) -> adapter_module.im.v1.CreateMessageResponse: + del option + create_requests.append(request) + return adapter_module.im.v1.CreateMessageResponse({"code": 0, "data": {"message_id": "om_sanitized"}}) + + monkeypatch.setattr(client.im.v1.message, "create", create_call) + patch_requests: list[adapter_module.im.v1.PatchMessageRequest] = [] + + def patch_call( + request: adapter_module.im.v1.PatchMessageRequest, option: adapter_module.lark.RequestOption | None = None + ) -> adapter_module.im.v1.PatchMessageResponse: + del option + patch_requests.append(request) + return adapter_module.im.v1.PatchMessageResponse({"code": 0, "data": {}}) + + monkeypatch.setattr(client.im.v1.message, "patch", patch_call) + + def build_client(_builder: object) -> adapter_module.lark.Client: + return client + + monkeypatch.setattr(type(adapter_module.lark.Client.builder()), "build", build_client) + gateway = adapter_module._create_sdk_gateway(_credentials(), "https://open.feishu.cn") + department = adapter_module._DepartmentIdentity("dept_sanitized", "department_id") + + assert gateway.query_tenant()["code"] == 0 + assert gateway.list_scope("scope-next")["code"] == 0 + assert gateway.list_departments(department, "departments-next")["code"] == 0 + assert gateway.list_users(department, "users-next")["code"] == 0 + assert gateway.create_message("union_sanitized", "text", '{"text":"hello"}')["code"] == 0 + assert gateway.patch_message("om_sanitized", '{"schema":"2.0"}')["code"] == 0 + assert len(tenant_requests) == len(scope_requests) == len(department_requests) == 1 + assert len(user_requests) == len(create_requests) == len(patch_requests) == 1 + assert department_requests[0].paths["department_id"] == "dept_sanitized" + assert ("page_token", "scope-next") in scope_requests[0].queries + + +def test_official_gateway_rejects_an_incomplete_sdk_client(monkeypatch: pytest.MonkeyPatch) -> None: + client = adapter_module.lark.Client() + + def build_client(_builder: object) -> adapter_module.lark.Client: + return client + + monkeypatch.setattr(type(adapter_module.lark.Client.builder()), "build", build_client) + with pytest.raises(RuntimeError, match="required services"): + adapter_module._OfficialSDKGateway(_credentials(), "https://open.feishu.cn") + + +@pytest.mark.parametrize( + ("create_time", "expected"), + [ + ("1704067200123456", datetime(2024, 1, 1, 0, 0, 0, 123456)), + ("1704067200123", datetime(2024, 1, 1, 0, 0, 0, 123000)), + ("not-a-timestamp", None), + ("9" * 64, None), + ], +) +def test_webhook_occurred_at_supports_provider_microseconds_and_legacy_milliseconds( + create_time: str, + expected: datetime | None, +) -> None: + assert adapter_module._webhook_occurred_at(create_time) == expected + + +def test_webhook_and_sdk_mapping_helpers_reject_malformed_boundaries(monkeypatch: pytest.MonkeyPatch) -> None: + received_at = datetime(2026, 8, 6, 10, tzinfo=UTC) + invalid_timestamp = WebhookRequest( + "POST", + ( + ("X-Lark-Request-Timestamp", "not-a-timestamp"), + ("X-Lark-Request-Nonce", "sanitized-nonce"), + ("X-Lark-Signature", "0" * 64), + ), + b"{}", + received_at, + ) + assert adapter_module._valid_webhook_signature(invalid_timestamp, "sanitized-encrypt-key") is None + assert adapter_module._webhook_occurred_at(None) is None + with pytest.raises(ValueError, match="invalid encrypted"): + adapter_module._decrypt_webhook_payload(base64.b64encode(b"too-short").decode(), "sanitized-key") + with pytest.raises(ValueError, match="JSON object"): + adapter_module._decode_json_object(b"[]") + + monkeypatch.setattr(adapter_module.lark.JSON, "marshal", lambda _value: None) + with pytest.raises(IMProviderError, match="response is empty"): + adapter_module._sdk_response_mapping(object()) + with pytest.raises(ValueError, match="event is empty"): + adapter_module._sdk_event_mapping(object()) + + +def test_signature_accepts_timezone_aware_received_at() -> None: + received_at = datetime(2026, 8, 6, 10, tzinfo=UTC) + timestamp = str(int(received_at.timestamp())) + nonce = "sanitized-nonce" + body = b'{"sanitized":true}' + encrypt_key = "sanitized-encrypt-key" + signature = hashlib.sha256(timestamp.encode() + nonce.encode() + encrypt_key.encode() + body).hexdigest() + request = WebhookRequest( + "POST", + ( + ("X-Lark-Request-Timestamp", timestamp), + ("X-Lark-Request-Nonce", nonce), + ("X-Lark-Signature", signature), + ), + body, + received_at, + ) + + assert ( + adapter_module._valid_webhook_signature(request, encrypt_key) + == hashlib.sha256(timestamp.encode() + nonce.encode() + encrypt_key.encode() + body).digest() + ) + + +@pytest.mark.parametrize( + "intent", + [ + _intent(blocks=(), actions=()), + _intent(blocks=(MarkdownFragment(text=""),)), + _intent( + blocks=(SelectInput(output_variable_name="decision", options=("Approve", ""), default_value="Approve"),) + ), + _intent( + blocks=( + SelectInput(output_variable_name="decision", options=("Approve", "Approve"), default_value="Approve"), + ) + ), + _intent( + blocks=( + ParagraphInput(output_variable_name="duplicate", default_value=None), + ParagraphInput(output_variable_name="duplicate", default_value=None), + ) + ), + _intent(actions=(UserAction(id="approve", title="Approve", button_style=ButtonStyle.GHOST),)), + ], +) +def test_card_assessment_rejects_every_lossy_shape(intent: ResolvedForm) -> None: + assert adapter_module._MSFeishuLarkCardCodec().assess(intent).representable is False + + +def test_headerless_resolved_paragraph_renders_without_default() -> None: + intent = _intent(blocks=(ParagraphInput(output_variable_name="comment", default_value=None),)) + card = adapter_module._MSFeishuLarkCardCodec().encode(intent, CorrelationToken("opaque-correlation-token")) + assert "header" not in card + body = card["body"] + assert isinstance(body, dict) + body_elements = body["elements"] + assert isinstance(body_elements, list) + form = body_elements[0] + assert isinstance(form, dict) + form_elements = form["elements"] + assert isinstance(form_elements, list) + paragraph = form_elements[0] + assert isinstance(paragraph, dict) + assert "default_value" not in paragraph + + +def test_wrong_wrapper_credentials_and_invalid_reference_are_rejected(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(adapter_module, "_create_sdk_gateway", lambda _credentials, _domain: object()) + feishu = _credentials() + lark = LarkCredentials( + provider=IMProvider.LARK, + app_id=feishu.app_id, + app_secret=feishu.app_secret, + verification_token=feishu.verification_token, + encrypt_key=feishu.encrypt_key, + ) + with pytest.raises(TypeError, match="Feishu adapter"): + FeishuIMProviderAdapter(cast(FeishuCredentials, lark)) + with pytest.raises(TypeError, match="Lark adapter"): + LarkIMProviderAdapter(cast(LarkCredentials, feishu)) + + with pytest.raises(ValueError, match="locator must be unpadded URL-safe Base64"): + adapter_module._FeishuLarkLocatorPayload.decode(str(MessageLocator("invalid."))) + + +def test_sdk_mapping_rejects_non_object_json(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(adapter_module.lark.JSON, "marshal", lambda _value: json.dumps(["not", "an", "object"])) + with pytest.raises(IMProviderError, match="response is not"): + adapter_module._sdk_response_mapping(object()) + with pytest.raises(ValueError, match="event is not"): + adapter_module._sdk_event_mapping(object()) diff --git a/api/tests/unit_tests/core/human_input_v2/im_provider/test_feishu_lark_card_events.py b/api/tests/unit_tests/core/human_input_v2/im_provider/test_feishu_lark_card_events.py new file mode 100644 index 00000000000..7b83e1b9d8d --- /dev/null +++ b/api/tests/unit_tests/core/human_input_v2/im_provider/test_feishu_lark_card_events.py @@ -0,0 +1,1236 @@ +from __future__ import annotations + +import builtins +import json +import sys +from collections.abc import Callable, Mapping +from concurrent.futures import ThreadPoolExecutor +from datetime import datetime +from importlib.util import resolve_name +from pathlib import Path +from types import FrameType, FunctionType + +import pytest +from lark_oapi.event.callback.model.p2_card_action_trigger import ( + P2CardActionTrigger, + P2CardActionTriggerResponse, +) + +from core.human_input import ButtonStyle +from core.human_input_v2.entities import IMProvider +from core.human_input_v2.im_integration.adapters import ( + AuthenticatedIMEvent, + CardAssessment, + CorrelationToken, + DynamicCardMessagingError, + EventAcceptance, + IMCardEvent, + IMCardEventDecodingError, + IMEventIngressKind, + ProviderUserId, + UnrecognizedIMEvent, + WebhookRequest, + feishu_lark, +) +from core.human_input_v2.resolved_form import ( + FileInput, + MarkdownFragment, + ParagraphInput, + ResolvedForm, + SelectInput, + UserAction, +) + +_FIXTURE_DIRECTORY = Path(__file__).with_name("fixtures") +_WEBHOOK_FIXTURE = _FIXTURE_DIRECTORY / "feishu_lark_card_action_webhook.json" +_STREAM_FIXTURE = _FIXTURE_DIRECTORY / "feishu_lark_card_action_stream.json" +_NO_INPUTS_FIXTURE = _FIXTURE_DIRECTORY / "feishu_lark_card_action_without_inputs.json" +_RECEIVED_AT = datetime(2026, 8, 12, 10, 0, 0) +_DIFY_ACTION_MARKER = "__dify.human_input.action" +_WEBHOOK_VERIFICATION_TOKEN = "verification_test_only" +_WEBHOOK_ENCRYPT_KEY = "encrypt_key_test_only" +_WEBHOOK_PROVENANCE_PAYLOAD_KEY = "__dify_feishu_lark.webhook" +_STREAM_PROVENANCE_PAYLOAD_KEY = "__dify_feishu_lark.stream" + + +class _WebhookGateway: + def query_tenant(self) -> Mapping[str, object]: + return {"code": 0, "data": {"tenant": {"tenant_key": "tenant_test_only"}}} + + +class _WebhookConsumer: + def __init__(self) -> None: + self.event: AuthenticatedIMEvent | None = None + + def accept(self, event: AuthenticatedIMEvent, *, request_ip_address: str | None = None) -> EventAcceptance: + del request_ip_address + self.event = event + return EventAcceptance.ACCEPTED + + +class _FixtureSDKObjectStreamClient: + def __init__( + self, + credentials: feishu_lark.FeishuCredentials, + domain: str, + callback: Callable[[feishu_lark._SDKEventEnvelope, Callable[[], None]], None], + sdk_event: P2CardActionTrigger, + ) -> None: + self._channel = feishu_lark._SynchronousEventChannel( + credentials=credentials, + domain=domain, + callback=callback, + ) + self._sdk_event = sdk_event + self.response: P2CardActionTriggerResponse | None = None + self.stopped = False + + def start(self) -> None: + self.response = self._channel._on_card_action(self._sdk_event) + + def stop(self) -> None: + self.stopped = True + + +def _replay_sanitized_webhook(monkeypatch: pytest.MonkeyPatch) -> AuthenticatedIMEvent: + fixture = json.loads(_WEBHOOK_FIXTURE.read_text(encoding="utf-8")) + assert isinstance(fixture, dict) + http = fixture["http"] + authenticated_event = fixture["authenticated_event"] + assert isinstance(http, dict) + assert isinstance(authenticated_event, dict) + request = http["request"] + expected_response = http["response"] + assert isinstance(request, dict) + assert isinstance(expected_response, dict) + headers = request["headers"] + assert isinstance(headers, list) + request_headers: list[tuple[str, str]] = [] + for header in headers: + assert isinstance(header, dict) + name = header["name"] + value = header["value"] + assert isinstance(name, str) + assert isinstance(value, str) + request_headers.append((name, value)) + + gateway = _WebhookGateway() + monkeypatch.setattr(feishu_lark, "_create_sdk_gateway", lambda _credentials, _domain: gateway) + adapter = feishu_lark.FeishuIMProviderAdapter( + feishu_lark.FeishuCredentials( + provider=IMProvider.FEISHU, + app_id="cli_test_only", + app_secret="app_secret_test_only", + verification_token=_WEBHOOK_VERIFICATION_TOKEN, + encrypt_key=_WEBHOOK_ENCRYPT_KEY, + ) + ) + consumer = _WebhookConsumer() + try: + response = adapter.create_webhook_handler(consumer).handle( + WebhookRequest( + method=request["method"], + headers=tuple(request_headers), + body=request["body"].encode(), + received_at=datetime.fromisoformat(request["received_at"]), + ) + ) + finally: + adapter.close() + + assert response.status_code == expected_response["status"] + assert response.body.decode() == expected_response["body"] + assert consumer.event is not None + assert consumer.event.provider is IMProvider.FEISHU + assert consumer.event.provider_tenant_id == authenticated_event["provider_tenant_id"] + assert consumer.event.event_id == authenticated_event["event_id"] + assert consumer.event.event_type == authenticated_event["event_type"] + assert consumer.event.occurred_at == datetime.fromisoformat(authenticated_event["occurred_at"]) + assert consumer.event.received_at == datetime.fromisoformat(authenticated_event["received_at"]) + assert consumer.event.ingress_kind is IMEventIngressKind.WEBHOOK + encrypted_envelope = json.loads(request["body"]) + assert isinstance(encrypted_envelope, dict) + encrypted_payload = encrypted_envelope["encrypt"] + assert isinstance(encrypted_payload, str) + expected_native_payload = feishu_lark._decrypt_webhook_payload( + encrypted_payload, + _WEBHOOK_ENCRYPT_KEY, + ).decode() + assert consumer.event.payload == expected_native_payload + assert json.loads(consumer.event.payload) == authenticated_event["payload"] + assert _WEBHOOK_PROVENANCE_PAYLOAD_KEY not in consumer.event.payload + return consumer.event + + +def _deliver_sanitized_stream(monkeypatch: pytest.MonkeyPatch) -> AuthenticatedIMEvent: + fixture = json.loads(_STREAM_FIXTURE.read_text(encoding="utf-8")) + assert isinstance(fixture, dict) + stream_evidence = fixture["stream"] + authenticated_event = fixture["authenticated_event"] + assert isinstance(stream_evidence, dict) + assert isinstance(authenticated_event, dict) + assert stream_evidence["callback"] == "register_p2_card_action_trigger" + assert stream_evidence["object_type"] == ( + "lark_oapi.event.callback.model.p2_card_action_trigger.P2CardActionTrigger" + ) + serialization = stream_evidence["sdk_serialization"] + callback_response = stream_evidence["callback_response"] + assert isinstance(serialization, dict) + assert isinstance(callback_response, dict) + assert serialization["method"] == "lark_oapi.JSON.marshal" + sdk_event_mapping = json.loads(serialization["value"]) + assert isinstance(sdk_event_mapping, dict) + assert sdk_event_mapping == authenticated_event["payload"] + sdk_event = P2CardActionTrigger(sdk_event_mapping) + sdk_event_type = f"{type(sdk_event).__module__}.{type(sdk_event).__qualname__}" + assert sdk_event_type == stream_evidence["object_type"] + response_serialization = callback_response["sdk_serialization"] + assert isinstance(response_serialization, dict) + assert callback_response["object_type"] == ( + "lark_oapi.event.callback.model.p2_card_action_trigger.P2CardActionTriggerResponse" + ) + assert response_serialization["method"] == "lark_oapi.JSON.marshal" + assert json.loads(response_serialization["value"]) == {} + + gateway = _WebhookGateway() + stream_clients: list[_FixtureSDKObjectStreamClient] = [] + + def create_stream_client( + credentials: feishu_lark.FeishuCredentials, + domain: str, + callback: Callable[[feishu_lark._SDKEventEnvelope, Callable[[], None]], None], + ) -> _FixtureSDKObjectStreamClient: + client = _FixtureSDKObjectStreamClient(credentials, domain, callback, sdk_event) + stream_clients.append(client) + return client + + monkeypatch.setattr(feishu_lark, "_create_sdk_gateway", lambda _credentials, _domain: gateway) + monkeypatch.setattr(feishu_lark, "_create_sdk_stream_client", create_stream_client) + adapter = feishu_lark.FeishuIMProviderAdapter( + feishu_lark.FeishuCredentials( + provider=IMProvider.FEISHU, + app_id="cli_test_only", + app_secret="app_secret_test_only", + verification_token=_WEBHOOK_VERIFICATION_TOKEN, + encrypt_key=_WEBHOOK_ENCRYPT_KEY, + ) + ) + consumer = _WebhookConsumer() + stream = adapter.create_stream_handler(consumer) + try: + stream.start() + stream.stop() + finally: + adapter.close() + + assert len(stream_clients) == 1 + stream_client = stream_clients[0] + assert stream_client.stopped is True + assert isinstance(stream_client.response, P2CardActionTriggerResponse) + response_type = f"{type(stream_client.response).__module__}.{type(stream_client.response).__qualname__}" + assert response_type == callback_response["object_type"] + assert feishu_lark.lark.JSON.marshal(stream_client.response) == response_serialization["value"] + assert consumer.event is not None + assert consumer.event.provider is IMProvider.FEISHU + assert consumer.event.provider_tenant_id == authenticated_event["provider_tenant_id"] + assert consumer.event.event_id == authenticated_event["event_id"] + assert consumer.event.event_type == authenticated_event["event_type"] + assert consumer.event.occurred_at == datetime.fromisoformat(authenticated_event["occurred_at"]) + assert consumer.event.ingress_kind is IMEventIngressKind.STREAM + assert json.loads(consumer.event.payload) == sdk_event_mapping + assert json.loads(consumer.event.payload) == authenticated_event["payload"] + assert _STREAM_PROVENANCE_PAYLOAD_KEY not in consumer.event.payload + assert stream_evidence["object_type"] not in consumer.event.payload + return consumer.event + + +def _event( + callback: dict[str, object] | None = None, + *, + provider: IMProvider = IMProvider.FEISHU, + event_type: str | None = "card.action.trigger", + ingress_kind: IMEventIngressKind = IMEventIngressKind.WEBHOOK, + payload: str | None = None, +) -> AuthenticatedIMEvent: + serialized_payload = payload + if serialized_payload is None: + serialized_payload = json.dumps( + callback if callback is not None else _synthetic_marked_callback(), ensure_ascii=False + ) + return AuthenticatedIMEvent( + provider=provider, + provider_tenant_id="tenant_test_only", + event_id="evt_test_only", + event_type=event_type, + occurred_at=None, + received_at=_RECEIVED_AT, + ingress_kind=ingress_kind, + payload=serialized_payload, + ) + + +def _form() -> ResolvedForm: + return ResolvedForm( + title="Approval request", + parts=( + MarkdownFragment(text="Review the generated answer."), + ParagraphInput(output_variable_name="comment", default_value="Looks good"), + MarkdownFragment(text="Choose the release decision."), + SelectInput(output_variable_name="decision", options=("ship", "hold"), default_value="hold"), + ), + actions=( + UserAction(id="approve", title="Approve", button_style=ButtonStyle.PRIMARY), + UserAction(id="reject", title="Reject", button_style=ButtonStyle.ACCENT), + ), + legacy_form_content="This value must not be rendered", + ) + + +def _callback_action(callback: dict[str, object]) -> dict[str, object]: + event = callback["event"] + assert isinstance(event, dict) + action = event["action"] + assert isinstance(action, dict) + return action + + +def _callback_operator(callback: dict[str, object]) -> dict[str, object]: + event = callback["event"] + assert isinstance(event, dict) + operator = event["operator"] + assert isinstance(operator, dict) + return operator + + +def _synthetic_marked_callback() -> dict[str, object]: + return { + "schema": "2.0", + "header": {"event_type": "card.action.trigger", "tenant_key": "tenant_test_only"}, + "event": { + "operator": {"union_id": "union_test_only", "open_id": "open_id_must_not_be_used"}, + "action": { + "tag": "button", + "name": "approve", + "value": { + _DIFY_ACTION_MARKER: { + "version": 1, + "action_id": "approve", + "correlation_token": "correlation-token-test-only", + } + }, + "form_value": {"comment": "Reviewed", "decision": "ship"}, + }, + }, + } + + +def test_codec_encodes_the_provider_confirmed_shallow_form_layout() -> None: + codec = feishu_lark._MSFeishuLarkCardCodec() + + assert codec.assess(_form()) == CardAssessment(representable=True) + assert codec.encode(_form(), CorrelationToken("correlation-token")) == { + "schema": "2.0", + "config": {"update_multi": True}, + "header": {"title": {"tag": "plain_text", "content": "Approval request"}}, + "body": { + "direction": "vertical", + "elements": [ + { + "tag": "form", + "name": "__dify.human_input", + "elements": [ + {"tag": "markdown", "content": "Review the generated answer."}, + { + "tag": "input", + "name": "comment", + "input_type": "multiline_text", + "width": "fill", + "required": True, + "label": {"tag": "plain_text", "content": "comment"}, + "placeholder": {"tag": "plain_text", "content": "comment"}, + "default_value": "Looks good", + }, + {"tag": "markdown", "content": "Choose the release decision."}, + { + "tag": "select_static", + "name": "decision", + "required": True, + "placeholder": {"tag": "plain_text", "content": "decision"}, + "options": [ + {"text": {"tag": "plain_text", "content": "ship"}, "value": "ship"}, + {"text": {"tag": "plain_text", "content": "hold"}, "value": "hold"}, + ], + "initial_option": "hold", + }, + { + "tag": "column_set", + "flex_mode": "none", + "horizontal_spacing": "8px", + "horizontal_align": "left", + "columns": [ + { + "tag": "column", + "width": "auto", + "elements": [ + { + "tag": "button", + "name": "approve", + "type": "primary_filled", + "text": {"tag": "plain_text", "content": "Approve"}, + "form_action_type": "submit", + "behaviors": [ + { + "type": "callback", + "value": { + _DIFY_ACTION_MARKER: { + "version": 1, + "action_id": "approve", + "correlation_token": "correlation-token", + } + }, + } + ], + } + ], + }, + { + "tag": "column", + "width": "auto", + "elements": [ + { + "tag": "button", + "name": "reject", + "type": "danger_filled", + "text": {"tag": "plain_text", "content": "Reject"}, + "form_action_type": "submit", + "behaviors": [ + { + "type": "callback", + "value": { + _DIFY_ACTION_MARKER: { + "version": 1, + "action_id": "reject", + "correlation_token": "correlation-token", + } + }, + } + ], + } + ], + }, + ], + }, + ], + } + ], + }, + } + + +def test_codec_rejects_oversized_cards_before_provider_io() -> None: + codec = feishu_lark._MSFeishuLarkCardCodec() + oversized = ResolvedForm( + title="", + parts=(MarkdownFragment(text="x" * (31 * 1024)),), + actions=(), + legacy_form_content="unused", + ) + + assessment = codec.assess(oversized) + + assert assessment.representable is False + assert assessment.reason == "Feishu/Lark cannot preserve a card beyond the Provider payload limit." + with pytest.raises(DynamicCardMessagingError, match="Provider payload limit"): + codec.encode(oversized, CorrelationToken("token")) + + +@pytest.mark.parametrize( + ("intent", "expected_reason"), + [ + ( + ResolvedForm(title="", parts=(), actions=(), legacy_form_content="unused"), + "Feishu/Lark cannot preserve an empty card.", + ), + ( + ResolvedForm(title="", parts=(MarkdownFragment(text=""),), actions=(), legacy_form_content="unused"), + "Feishu/Lark cannot preserve an empty Markdown block.", + ), + ( + ResolvedForm( + title="", + parts=( + FileInput( + output_variable_name="attachment", + allowed_file_types=(), + allowed_file_extensions=(), + allowed_file_upload_methods=(), + ), + ), + actions=(), + legacy_form_content="unused", + ), + "Feishu/Lark cards cannot represent file inputs.", + ), + ( + ResolvedForm( + title="", + parts=(SelectInput(output_variable_name="decision", options=(), default_value=None),), + actions=(), + legacy_form_content="unused", + ), + "Feishu/Lark cannot preserve one select option.", + ), + ( + ResolvedForm( + title="", + parts=(SelectInput(output_variable_name="decision", options=("",), default_value=None),), + actions=(), + legacy_form_content="unused", + ), + "Feishu/Lark cannot preserve one select option.", + ), + ( + ResolvedForm( + title="", + parts=(SelectInput(output_variable_name="decision", options=("ship", "ship"), default_value=None),), + actions=(), + legacy_form_content="unused", + ), + "Feishu/Lark cannot preserve duplicate select options.", + ), + ( + ResolvedForm( + title="", + parts=( + ParagraphInput(output_variable_name="decision", default_value=None), + SelectInput(output_variable_name="decision", options=("ship",), default_value=None), + ), + actions=(), + legacy_form_content="unused", + ), + "Feishu/Lark cannot preserve duplicate card input identifiers.", + ), + ( + ResolvedForm( + title="", + parts=(MarkdownFragment(text="Review"),), + actions=(UserAction(id="approve", title="Approve", button_style=ButtonStyle.GHOST),), + legacy_form_content="unused", + ), + "Feishu/Lark cannot preserve one card action style.", + ), + ], + ids=( + "empty-card", + "empty-markdown", + "file-input", + "empty-select", + "blank-select-option", + "duplicate-select-options", + "duplicate-input-name", + "unsupported-action-style", + ), +) +def test_codec_rejects_each_unrepresentable_provider_shape( + intent: ResolvedForm, + expected_reason: str, +) -> None: + codec = feishu_lark._MSFeishuLarkCardCodec() + + assessment = codec.assess(intent) + + assert assessment == CardAssessment(representable=False, reason=expected_reason) + with pytest.raises(DynamicCardMessagingError, match=r"^" + expected_reason.replace(".", r"\.") + r"$"): + codec.encode(intent, CorrelationToken("token")) + + +def test_codec_rechecks_wire_size_with_the_actual_correlation_token() -> None: + codec = feishu_lark._MSFeishuLarkCardCodec() + intent = ResolvedForm( + title="", + parts=(MarkdownFragment(text="Review"),), + actions=(UserAction(id="approve", title="Approve", button_style=ButtonStyle.DEFAULT),), + legacy_form_content="unused", + ) + + assert codec.assess(intent) == CardAssessment(representable=True) + with pytest.raises(DynamicCardMessagingError, match="Provider payload limit"): + codec.encode(intent, CorrelationToken("x" * (31 * 1024))) + + +@pytest.mark.parametrize( + "callback", + [ + {"schema": "1.0", "header": {}, "event": {}}, + {"schema": "2.0", "header": [], "event": {}}, + { + "schema": "2.0", + "header": {"event_type": "card.action.trigger"}, + "event": [], + }, + { + "schema": "2.0", + "header": {"event_type": "card.action.trigger"}, + "event": {"action": []}, + }, + ], + ids=("schema", "header", "event", "action"), +) +@pytest.mark.parametrize("provider", [IMProvider.FEISHU, IMProvider.LARK]) +@pytest.mark.parametrize("ingress_kind", [IMEventIngressKind.WEBHOOK, IMEventIngressKind.STREAM]) +def test_transport_discriminated_malformed_recognition_envelopes_fail_strictly( + provider: IMProvider, + ingress_kind: IMEventIngressKind, + callback: dict[str, object], +) -> None: + with pytest.raises(IMCardEventDecodingError, match="schema is invalid"): + feishu_lark._MSFeishuLarkCardCodec().decode(_event(callback, provider=provider, ingress_kind=ingress_kind)) + + +@pytest.mark.parametrize("provider", [IMProvider.FEISHU, IMProvider.LARK]) +@pytest.mark.parametrize("ingress_kind", [IMEventIngressKind.WEBHOOK, IMEventIngressKind.STREAM]) +def test_non_object_foreign_action_value_is_unrecognized( + provider: IMProvider, ingress_kind: IMEventIngressKind +) -> None: + callback: dict[str, object] = { + "schema": "2.0", + "header": {"event_type": "card.action.trigger"}, + "event": {"action": {"value": []}}, + } + + result = feishu_lark._MSFeishuLarkCardCodec().decode(_event(callback, provider=provider, ingress_kind=ingress_kind)) + + assert isinstance(result, UnrecognizedIMEvent) + + +def test_sanitized_real_webhook_envelope_authenticates_before_card_decoding( + monkeypatch: pytest.MonkeyPatch, +) -> None: + callback_event = _replay_sanitized_webhook(monkeypatch) + + result = feishu_lark._MSFeishuLarkCardCodec().decode(callback_event) + + assert result == IMCardEvent( + provider_user_id=ProviderUserId("union_test_only"), + action_id="approve", + inputs={"comment": "Sanitized callback input", "decision": "ship"}, + correlation_token=CorrelationToken("feishu-lark-card-evidence-token"), + ) + + +def test_sanitized_real_stream_sdk_delivery_authenticates_before_card_decoding( + monkeypatch: pytest.MonkeyPatch, +) -> None: + marshaled_sdk_types: list[type[object]] = [] + sdk_marshal = feishu_lark.lark.JSON.marshal + + def record_sdk_marshal(value: object) -> str | None: + marshaled_sdk_types.append(type(value)) + return sdk_marshal(value) + + monkeypatch.setattr(feishu_lark.lark.JSON, "marshal", record_sdk_marshal) + callback_event = _deliver_sanitized_stream(monkeypatch) + + result = feishu_lark._MSFeishuLarkCardCodec().decode(callback_event) + + assert marshaled_sdk_types == [P2CardActionTrigger, P2CardActionTriggerResponse] + assert result == IMCardEvent( + provider_user_id=ProviderUserId("union_test_only"), + action_id="approve", + inputs={"comment": "Sanitized callback input", "decision": "ship"}, + correlation_token=CorrelationToken("feishu-lark-card-evidence-token"), + ) + + +@pytest.mark.parametrize("provider", [IMProvider.FEISHU, IMProvider.LARK]) +def test_sanitized_real_callbacks_converge_for_both_provider_discriminators( + monkeypatch: pytest.MonkeyPatch, + provider: IMProvider, +) -> None: + webhook_event = _replay_sanitized_webhook(monkeypatch) + stream_event = _deliver_sanitized_stream(monkeypatch) + expected = IMCardEvent( + provider_user_id=ProviderUserId("union_test_only"), + action_id="approve", + inputs={"comment": "Sanitized callback input", "decision": "ship"}, + correlation_token=CorrelationToken("feishu-lark-card-evidence-token"), + ) + results = tuple( + feishu_lark._MSFeishuLarkCardCodec().decode( + AuthenticatedIMEvent( + provider=provider, + provider_tenant_id=event.provider_tenant_id, + event_id=event.event_id, + event_type=event.event_type, + occurred_at=event.occurred_at, + received_at=event.received_at, + ingress_kind=event.ingress_kind, + payload=event.payload, + ) + ) + for event in (webhook_event, stream_event) + ) + + assert results == (expected, expected) + assert all(isinstance(result, IMCardEvent) for result in results) + assert all( + str(result.provider_user_id) != "open_test_only" for result in results if isinstance(result, IMCardEvent) + ) + + +@pytest.mark.parametrize("provider", [IMProvider.FEISHU, IMProvider.LARK]) +@pytest.mark.parametrize("ingress_kind", [IMEventIngressKind.WEBHOOK, IMEventIngressKind.STREAM]) +@pytest.mark.parametrize("include_form_value", [False, True], ids=("omitted-form-values", "empty-form-values")) +def test_callbacks_without_inputs_decode_to_empty_inputs( + provider: IMProvider, + ingress_kind: IMEventIngressKind, + include_form_value: bool, +) -> None: + fixture = json.loads(_NO_INPUTS_FIXTURE.read_text(encoding="utf-8")) + callback = fixture["authenticated_event"]["payload"] + if include_form_value: + _callback_action(callback)["form_value"] = {} + adapter = ( + feishu_lark.FeishuIMProviderAdapter if provider is IMProvider.FEISHU else feishu_lark.LarkIMProviderAdapter + ) + + result = adapter.card_event_decoder().decode(_event(callback, provider=provider, ingress_kind=ingress_kind)) + + assert result == IMCardEvent( + provider_user_id=ProviderUserId("union_test_only"), + action_id="confirm", + inputs={}, + correlation_token=CorrelationToken("no-inputs-correlation-token-test-only"), + ) + + +def test_encode_and_decode_preserve_exact_unicode_and_input_metadata_names() -> None: + codec = feishu_lark._MSFeishuLarkCardCodec() + intent = ResolvedForm( + title="Approval 🌍", + parts=( + ParagraphInput(output_variable_name="action_id", default_value=None), + ParagraphInput(output_variable_name="correlation_token", default_value=None), + ParagraphInput(output_variable_name="version", default_value=None), + ), + actions=(UserAction(id="approve✅", title="Approve", button_style=ButtonStyle.PRIMARY),), + legacy_form_content="unused", + ) + token = CorrelationToken("correlation-token-🔐") + encoded = codec.encode(intent, token) + body = encoded["body"] + assert isinstance(body, dict) + body_elements = body["elements"] + assert isinstance(body_elements, list) + form = body_elements[0] + assert isinstance(form, dict) + form_elements = form["elements"] + assert isinstance(form_elements, list) + action_row = form_elements[-1] + assert isinstance(action_row, dict) + columns = action_row["columns"] + assert isinstance(columns, list) + column = columns[0] + assert isinstance(column, dict) + column_elements = column["elements"] + assert isinstance(column_elements, list) + button = column_elements[0] + assert isinstance(button, dict) + behaviors = button["behaviors"] + assert isinstance(behaviors, list) + behavior = behaviors[0] + assert isinstance(behavior, dict) + metadata = behavior["value"] + assert isinstance(metadata, dict) + + callback = _synthetic_marked_callback() + action = _callback_action(callback) + action["name"] = "approve✅" + action["value"] = metadata + action["form_value"] = { + "action_id": "user action 🧪", + "correlation_token": "user token value", + "version": {"nested": [1, None, True]}, + } + + result = codec.decode(_event(callback, provider=IMProvider.LARK)) + + assert result == IMCardEvent( + provider_user_id=ProviderUserId("union_test_only"), + action_id="approve✅", + inputs={ + "action_id": "user action 🧪", + "correlation_token": "user token value", + "version": {"nested": [1, None, True]}, + }, + correlation_token=token, + ) + + +@pytest.mark.parametrize("provider", [IMProvider.FEISHU, IMProvider.LARK]) +@pytest.mark.parametrize("ingress_kind", [IMEventIngressKind.WEBHOOK, IMEventIngressKind.STREAM]) +def test_exact_private_marker_input_name_remains_isolated_from_action_metadata( + provider: IMProvider, ingress_kind: IMEventIngressKind +) -> None: + codec = feishu_lark._MSFeishuLarkCardCodec() + token = CorrelationToken("collision-correlation-token") + intent = ResolvedForm( + title="", + parts=(ParagraphInput(output_variable_name=_DIFY_ACTION_MARKER, default_value=None),), + actions=(UserAction(id="approve", title="Approve", button_style=ButtonStyle.PRIMARY),), + legacy_form_content="unused", + ) + + encoded = codec.encode(intent, token) + body = encoded["body"] + assert isinstance(body, dict) + body_elements = body["elements"] + assert isinstance(body_elements, list) + form = body_elements[0] + assert isinstance(form, dict) + form_elements = form["elements"] + assert isinstance(form_elements, list) + input_element = form_elements[0] + action_row = form_elements[-1] + assert isinstance(input_element, dict) + assert isinstance(action_row, dict) + assert input_element["name"] == _DIFY_ACTION_MARKER + columns = action_row["columns"] + assert isinstance(columns, list) + column = columns[0] + assert isinstance(column, dict) + buttons = column["elements"] + assert isinstance(buttons, list) + button = buttons[0] + assert isinstance(button, dict) + behaviors = button["behaviors"] + assert isinstance(behaviors, list) + behavior = behaviors[0] + assert isinstance(behavior, dict) + metadata = behavior["value"] + assert metadata == { + _DIFY_ACTION_MARKER: { + "version": 1, + "action_id": "approve", + "correlation_token": token, + } + } + + submitted_value = "input value remains separate" + callback = _synthetic_marked_callback() + action = _callback_action(callback) + action["value"] = metadata + action["form_value"] = {_DIFY_ACTION_MARKER: submitted_value} + + result = codec.decode(_event(callback, provider=provider, ingress_kind=ingress_kind)) + + assert result == IMCardEvent( + provider_user_id=ProviderUserId("union_test_only"), + action_id="approve", + inputs={_DIFY_ACTION_MARKER: submitted_value}, + correlation_token=token, + ) + + +@pytest.mark.parametrize( + "event", + [ + _event(provider=IMProvider.SLACK, payload="not-json"), + _event(event_type="im.message.receive_v1", payload="not-json"), + _event( + { + "schema": "2.0", + "header": {"event_type": "card.action.trigger"}, + "event": {"action": {"tag": "button", "name": "foreign", "value": {"foreign": True}}}, + } + ), + ], + ids=("foreign-provider", "non-card-event-type", "foreign-card-action"), +) +def test_non_applicable_or_foreign_events_are_unrecognized(event: AuthenticatedIMEvent) -> None: + result = feishu_lark._MSFeishuLarkCardCodec().decode(event) + + assert isinstance(result, UnrecognizedIMEvent) + + +@pytest.mark.parametrize( + "foreign_action_value", + [ + {"version": 1}, + {"action_id": "approve"}, + {"correlation_token": "foreign-token"}, + {"version": 1, "action_id": "approve", "correlation_token": "foreign-token"}, + ], + ids=("version", "action-id", "correlation-token", "complete-legacy-triple"), +) +@pytest.mark.parametrize("provider", [IMProvider.FEISHU, IMProvider.LARK]) +@pytest.mark.parametrize("ingress_kind", [IMEventIngressKind.WEBHOOK, IMEventIngressKind.STREAM]) +def test_foreign_generic_action_metadata_is_not_a_dify_marker( + provider: IMProvider, + ingress_kind: IMEventIngressKind, + foreign_action_value: dict[str, object], +) -> None: + callback = _synthetic_marked_callback() + action = _callback_action(callback) + action["value"] = foreign_action_value + + result = feishu_lark._MSFeishuLarkCardCodec().decode(_event(callback, provider=provider, ingress_kind=ingress_kind)) + + assert isinstance(result, UnrecognizedIMEvent) + + +@pytest.mark.parametrize( + "marked_action_value", + [ + {_DIFY_ACTION_MARKER: None}, + {_DIFY_ACTION_MARKER: {}}, + {_DIFY_ACTION_MARKER: {"version": 1, "action_id": "approve"}}, + ], + ids=("non-object-marker", "empty-marker", "missing-correlation-token"), +) +@pytest.mark.parametrize("provider", [IMProvider.FEISHU, IMProvider.LARK]) +@pytest.mark.parametrize("ingress_kind", [IMEventIngressKind.WEBHOOK, IMEventIngressKind.STREAM]) +def test_exact_dify_marker_enables_strict_submission_validation( + provider: IMProvider, + ingress_kind: IMEventIngressKind, + marked_action_value: dict[str, object], +) -> None: + callback = _synthetic_marked_callback() + _callback_action(callback)["value"] = marked_action_value + + with pytest.raises(IMCardEventDecodingError, match=r"^Feishu/Lark card event schema is invalid\.$"): + feishu_lark._MSFeishuLarkCardCodec().decode(_event(callback, provider=provider, ingress_kind=ingress_kind)) + + +@pytest.mark.parametrize("payload", ["not-json", "[]", '{"unsafe":NaN}']) +@pytest.mark.parametrize("provider", [IMProvider.FEISHU, IMProvider.LARK]) +@pytest.mark.parametrize("ingress_kind", [IMEventIngressKind.WEBHOOK, IMEventIngressKind.STREAM]) +def test_transport_discriminated_invalid_json_fails_safely( + payload: str, provider: IMProvider, ingress_kind: IMEventIngressKind +) -> None: + with pytest.raises( + IMCardEventDecodingError, + match=r"^Feishu/Lark card event payload is invalid\.$", + ) as raised: + feishu_lark._MSFeishuLarkCardCodec().decode( + _event(payload=payload, provider=provider, ingress_kind=ingress_kind) + ) + + assert raised.value.__cause__ is None + assert raised.value.__context__ is None + + +def _without(callback: dict[str, object], path: tuple[str, ...]) -> None: + current = callback + for segment in path[:-1]: + child = current[segment] + assert isinstance(child, dict) + current = child + current.pop(path[-1]) + + +@pytest.mark.parametrize( + ("path", "replacement"), + [ + (("header", "event_type"), "foreign.event"), + (("event", "operator", "union_id"), ""), + (("event", "operator", "union_id"), True), + (("event", "action", "tag"), "select_static"), + (("event", "action", "name"), "reject"), + (("event", "action", "form_value"), ["sensitive-submitted-input"]), + (("event", "action", "form_value"), None), + (("event", "action", "value", _DIFY_ACTION_MARKER, "version"), 2), + (("event", "action", "value", _DIFY_ACTION_MARKER, "action_id"), "reject"), + (("event", "action", "value", _DIFY_ACTION_MARKER, "correlation_token"), None), + ], + ids=( + "payload-event-type", + "empty-union-id", + "typed-union-id", + "action-tag", + "outer-action-id", + "form-values", + "null-form-values", + "metadata-version", + "embedded-action-id", + "correlation-token", + ), +) +@pytest.mark.parametrize("provider", [IMProvider.FEISHU, IMProvider.LARK]) +@pytest.mark.parametrize("ingress_kind", [IMEventIngressKind.WEBHOOK, IMEventIngressKind.STREAM]) +def test_recognized_callbacks_fail_strictly_without_exposing_callback_values( + provider: IMProvider, + ingress_kind: IMEventIngressKind, + path: tuple[str, ...], + replacement: object, +) -> None: + callback = _synthetic_marked_callback() + current = callback + for segment in path[:-1]: + child = current[segment] + assert isinstance(child, dict) + current = child + current[path[-1]] = replacement + action = _callback_action(callback) + if path != ("event", "action", "form_value"): + action["form_value"] = {"comment": "sensitive-submitted-input"} + + with pytest.raises( + IMCardEventDecodingError, + match=r"^Feishu/Lark card event schema is invalid\.$", + ) as raised: + feishu_lark._MSFeishuLarkCardCodec().decode(_event(callback, provider=provider, ingress_kind=ingress_kind)) + + diagnostic = repr(raised.value) + str(raised.value) + repr(raised.value.args) + repr(raised.value.__dict__) + assert "sensitive-submitted-input" not in diagnostic + assert "correlation-token-test-only" not in diagnostic + assert "open_id_must_not_be_used" not in diagnostic + assert raised.value.__cause__ is None + assert raised.value.__context__ is None + + +@pytest.mark.parametrize( + "path", + [ + ("event", "operator"), + ("event", "operator", "union_id"), + ("event", "action", "value", _DIFY_ACTION_MARKER, "action_id"), + ("event", "action", "value", _DIFY_ACTION_MARKER, "correlation_token"), + ], + ids=("operator", "union-id", "action-id", "correlation-token"), +) +@pytest.mark.parametrize("provider", [IMProvider.FEISHU, IMProvider.LARK]) +@pytest.mark.parametrize("ingress_kind", [IMEventIngressKind.WEBHOOK, IMEventIngressKind.STREAM]) +def test_recognized_callbacks_reject_missing_required_facts( + path: tuple[str, ...], provider: IMProvider, ingress_kind: IMEventIngressKind +) -> None: + callback = _synthetic_marked_callback() + _without(callback, path) + + with pytest.raises(IMCardEventDecodingError, match="schema is invalid"): + feishu_lark._MSFeishuLarkCardCodec().decode(_event(callback, provider=provider, ingress_kind=ingress_kind)) + + +@pytest.mark.parametrize("provider", [IMProvider.FEISHU, IMProvider.LARK]) +@pytest.mark.parametrize("ingress_kind", [IMEventIngressKind.WEBHOOK, IMEventIngressKind.STREAM]) +def test_decoder_discovery_is_credential_free_concurrent_and_lifecycle_independent( + provider: IMProvider, + ingress_kind: IMEventIngressKind, + monkeypatch: pytest.MonkeyPatch, +) -> None: + def reject_gateway_creation(*_args: object) -> object: + raise AssertionError("decoder discovery must not construct a Provider client") + + monkeypatch.setattr(feishu_lark, "_create_sdk_gateway", reject_gateway_creation) + feishu_decoder = feishu_lark.FeishuIMProviderAdapter.card_event_decoder() + lark_decoder = feishu_lark.LarkIMProviderAdapter.card_event_decoder() + callback = _synthetic_marked_callback() + + assert isinstance(feishu_decoder, feishu_lark._MSFeishuLarkCardCodec) + assert isinstance(lark_decoder, feishu_lark._MSFeishuLarkCardCodec) + with ThreadPoolExecutor(max_workers=8) as executor: + results = tuple( + executor.map( + feishu_decoder.decode, + (_event(callback, provider=provider, ingress_kind=ingress_kind) for _ in range(64)), + ) + ) + + assert len(results) == 64 + assert isinstance(results[0], IMCardEvent) + assert all(result == results[0] for result in results) + + +@pytest.mark.parametrize("provider", [IMProvider.FEISHU, IMProvider.LARK]) +@pytest.mark.parametrize("ingress_kind", [IMEventIngressKind.WEBHOOK, IMEventIngressKind.STREAM]) +def test_decoder_remains_usable_after_differently_credentialed_root_adapters_close( + provider: IMProvider, + ingress_kind: IMEventIngressKind, + monkeypatch: pytest.MonkeyPatch, +) -> None: + gateway_constructions: list[str] = [] + + def record_gateway(_credentials: object, domain: str) -> object: + gateway_constructions.append(domain) + return object() + + monkeypatch.setattr(feishu_lark, "_create_sdk_gateway", record_gateway) + decoder = feishu_lark.FeishuIMProviderAdapter.card_event_decoder() + feishu_root = feishu_lark.FeishuIMProviderAdapter( + feishu_lark.FeishuCredentials( + provider=IMProvider.FEISHU, + app_id="first-credential-set", + app_secret="first-secret", + verification_token="first-verification-token", + encrypt_key="first-encrypt-key", + ) + ) + rotated_feishu_root = feishu_lark.FeishuIMProviderAdapter( + feishu_lark.FeishuCredentials( + provider=IMProvider.FEISHU, + app_id="rotated-credential-set", + app_secret="rotated-secret", + verification_token="rotated-verification-token", + encrypt_key="rotated-encrypt-key", + ) + ) + lark_root = feishu_lark.LarkIMProviderAdapter( + feishu_lark.LarkCredentials( + provider=IMProvider.LARK, + app_id="lark-credential-set", + app_secret="lark-secret", + verification_token="lark-verification-token", + encrypt_key="lark-encrypt-key", + ) + ) + for root in (feishu_root, rotated_feishu_root, lark_root): + root.close() + + def reject_gateway_creation(*_args: object) -> object: + raise AssertionError("decoder use must not construct a Provider client") + + monkeypatch.setattr(feishu_lark, "_create_sdk_gateway", reject_gateway_creation) + + result = decoder.decode(_event(_synthetic_marked_callback(), provider=provider, ingress_kind=ingress_kind)) + + assert result == IMCardEvent( + provider_user_id=ProviderUserId("union_test_only"), + action_id="approve", + inputs={"comment": "Reviewed", "decision": "ship"}, + correlation_token=CorrelationToken("correlation-token-test-only"), + ) + assert len(gateway_constructions) == 3 + with pytest.raises(RuntimeError, match="adapter is closed"): + _ = feishu_root.provider + + +@pytest.mark.parametrize("provider", [IMProvider.FEISHU, IMProvider.LARK]) +@pytest.mark.parametrize("ingress_kind", [IMEventIngressKind.WEBHOOK, IMEventIngressKind.STREAM]) +def test_decoder_discovery_and_decode_do_not_cross_runtime_dependency_boundaries( + provider: IMProvider, + ingress_kind: IMEventIngressKind, + monkeypatch: pytest.MonkeyPatch, +) -> None: + def reject_dependency_call(*_args: object, **_kwargs: object) -> object: + raise AssertionError("decoder path crossed a credential or Provider client boundary") + + dependency_seam_names = { + "FeishuCredentials", + "LarkCredentials", + "_FeishuLarkCredentials", + "_OfficialSDKGateway", + "_OfficialSDKStreamClient", + "_PerClientSDKWSClient", + "_SynchronousEventChannel", + "_create_sdk_gateway", + "_create_sdk_stream_client", + } + for seam_name in dependency_seam_names: + monkeypatch.setattr(feishu_lark, seam_name, reject_dependency_call) + imported_modules: list[str] = [] + delegated_imports: list[str] = [] + executed_modules: list[str] = [] + original_import = builtins.__import__ + original_profile = sys.getprofile() + + forbidden_module_prefixes = ( + "models.human_input", + "services.human_input", + "core.workflow", + "repositories.human_input_v2.contact", + "core.human_input_v2.im_integration.binding", + "repositories", + ) + current_dependency_prefixes = { + "repositories.human_input_v2.contact", + "core.human_input_v2.im_integration.binding", + } + assert current_dependency_prefixes.issubset(forbidden_module_prefixes) + + def audit_import( + name: str, + globals: dict[str, object] | None = None, + locals: dict[str, object] | None = None, + fromlist: tuple[str, ...] = (), + level: int = 0, + ) -> object: + import_names = [name] + package = globals.get("__package__") if globals is not None else None + if level > 0 and isinstance(package, str) and package: + resolved_name = resolve_name(f"{'.' * level}{name}", package) + if resolved_name != name: + import_names.append(resolved_name) + imported_modules.extend(import_names) + for import_name in import_names: + if import_name.startswith(forbidden_module_prefixes): + raise AssertionError(f"decoder path imported forbidden module family: {import_name}") + delegated_imports.extend(import_names) + return original_import(name, globals, locals, fromlist, level) + + def audit_runtime_frame(frame: FrameType, event: str, _arg: object) -> None: + if event != "call": + return + module_name = frame.f_globals.get("__name__") + if not isinstance(module_name, str): + return + executed_modules.append(module_name) + if module_name.startswith(forbidden_module_prefixes): + raise AssertionError(f"decoder path executed forbidden module family: {module_name}") + + def run_with_runtime_audit(operation: Callable[[], object]) -> object: + previous_profile = sys.getprofile() + sys.setprofile(audit_runtime_frame) + try: + return operation() + finally: + sys.setprofile(previous_profile) + + # Package aggregation may have loaded these modules before observation; the + # boundary is that decoder discovery and decode must neither import them + # again nor enter runtime frames owned by them. + monkeypatch.setattr(builtins, "__import__", audit_import) + try: + for module_prefix in current_dependency_prefixes: + with pytest.raises(AssertionError, match="imported forbidden module family"): + builtins.__import__(module_prefix) + assert not delegated_imports + imported_modules.clear() + + def no_op() -> None: + return None + + forbidden_no_op = FunctionType( + no_op.__code__, + {"__name__": "repositories.human_input_v2.contact.sentinel"}, + ) + with pytest.raises(AssertionError, match="executed forbidden module family"): + run_with_runtime_audit(forbidden_no_op) + assert sys.getprofile() is original_profile + executed_modules.clear() + + def discover_and_decode() -> object: + decoder = feishu_lark.FeishuIMProviderAdapter.card_event_decoder() + return decoder.decode(_event(_synthetic_marked_callback(), provider=provider, ingress_kind=ingress_kind)) + + result = run_with_runtime_audit(discover_and_decode) + finally: + monkeypatch.setattr(builtins, "__import__", original_import) + + assert isinstance(result, IMCardEvent) + assert builtins.__import__ is original_import + assert sys.getprofile() is original_profile + assert all(not name.startswith(forbidden_module_prefixes) for name in imported_modules) + assert all(not name.startswith(forbidden_module_prefixes) for name in executed_modules) + assert feishu_lark.__name__ in executed_modules + + +@pytest.mark.parametrize("provider", [IMProvider.FEISHU, IMProvider.LARK]) +@pytest.mark.parametrize("ingress_kind", [IMEventIngressKind.WEBHOOK, IMEventIngressKind.STREAM]) +def test_open_id_is_never_used_when_union_id_is_unavailable( + provider: IMProvider, ingress_kind: IMEventIngressKind +) -> None: + callback = _synthetic_marked_callback() + _callback_operator(callback).pop("union_id") + + with pytest.raises(IMCardEventDecodingError, match="schema is invalid"): + feishu_lark._MSFeishuLarkCardCodec().decode(_event(callback, provider=provider, ingress_kind=ingress_kind)) diff --git a/api/tests/unit_tests/core/human_input_v2/im_provider/test_feishu_lark_credentials.py b/api/tests/unit_tests/core/human_input_v2/im_provider/test_feishu_lark_credentials.py new file mode 100644 index 00000000000..d69b599a118 --- /dev/null +++ b/api/tests/unit_tests/core/human_input_v2/im_provider/test_feishu_lark_credentials.py @@ -0,0 +1,117 @@ +import pytest +from pydantic import ValidationError + +from controllers.console.human_input_v2.providers import ( + FeishuCredentialsInput as FeishuCredentialRequest, +) +from controllers.console.human_input_v2.providers import LarkCredentialsInput as LarkCredentialRequest +from core.human_input_v2.entities import IMProvider +from core.human_input_v2.im_integration.adapters.credentials import FeishuCredentials, LarkCredentials + + +@pytest.mark.parametrize( + ("resolved_type", "provider"), + [ + (FeishuCredentials, IMProvider.FEISHU), + (LarkCredentials, IMProvider.LARK), + ], +) +def test_resolved_credentials_are_strict_frozen_and_secret_safe( + resolved_type: type[FeishuCredentials] | type[LarkCredentials], + provider: IMProvider, +) -> None: + credentials = resolved_type.model_validate( + { + "provider": provider, + "app_id": "cli_sanitized_app", + "app_secret": "sanitized-app-secret", + "verification_token": "sanitized-verification-token", + "encrypt_key": "sanitized-encrypt-key", + } + ) + + rendered = repr(credentials) + assert "sanitized-app-secret" not in rendered + assert "sanitized-verification-token" not in rendered + assert "sanitized-encrypt-key" not in rendered + with pytest.raises(ValidationError) as validation_error: + resolved_type.model_validate( + { + "provider": provider, + "app_id": "cli_sanitized_app", + "app_secret": "", + "verification_token": "sanitized-verification-token", + "encrypt_key": "sanitized-encrypt-key", + } + ) + assert "sanitized-verification-token" not in str(validation_error.value) + assert "sanitized-encrypt-key" not in str(validation_error.value) + with pytest.raises(ValidationError): + resolved_type.model_validate({**credentials.model_dump(), "unexpected": "value"}) + pytest.raises(ValidationError, setattr, credentials, "app_id", "changed") + + +def test_feishu_request_projection_requires_and_maps_complete_credentials() -> None: + request = FeishuCredentialRequest.model_validate( + { + "provider": IMProvider.FEISHU, + "app_id": "cli_new_app", + "app_secret": "new-app-secret", + "verification_token": "new-verification-token", + "encrypt_key": None, + } + ) + + resolved = request.to_owner_credentials() + + assert resolved == FeishuCredentials( + provider=IMProvider.FEISHU, + app_id="cli_new_app", + app_secret="new-app-secret", + verification_token="new-verification-token", + encrypt_key=None, + ) + + +def test_lark_request_projection_applies_new_and_cleared_secrets() -> None: + request = LarkCredentialRequest.model_validate( + { + "provider": IMProvider.LARK, + "app_id": "cli_new_app", + "app_secret": "new-app-secret", + "verification_token": None, + "encrypt_key": "new-encrypt-key", + } + ) + + resolved = request.to_owner_credentials() + + assert resolved == LarkCredentials( + provider=IMProvider.LARK, + app_id="cli_new_app", + app_secret="new-app-secret", + verification_token=None, + encrypt_key="new-encrypt-key", + ) + + +def test_retention_marker_is_rejected_by_the_canonical_request() -> None: + with pytest.raises(ValidationError): + LarkCredentialRequest.model_validate( + { + "provider": IMProvider.LARK, + "app_id": "cli_sanitized_app", + "app_secret": {"tag": "preserve_original_value"}, + "verification_token": None, + "encrypt_key": None, + } + ) + + +def test_credential_schemas_remain_explicitly_aligned() -> None: + logical_fields = {"provider", "app_id", "app_secret", "verification_token", "encrypt_key"} + + assert set(FeishuCredentialRequest.model_fields) == logical_fields + assert set(LarkCredentialRequest.model_fields) == logical_fields + assert set(FeishuCredentials.model_fields) == logical_fields + assert set(LarkCredentials.model_fields) == logical_fields diff --git a/api/tests/unit_tests/core/human_input_v2/im_provider/test_feishu_lark_sdk_boundary.py b/api/tests/unit_tests/core/human_input_v2/im_provider/test_feishu_lark_sdk_boundary.py new file mode 100644 index 00000000000..df6e592bc1c --- /dev/null +++ b/api/tests/unit_tests/core/human_input_v2/im_provider/test_feishu_lark_sdk_boundary.py @@ -0,0 +1,2008 @@ +from __future__ import annotations + +import asyncio +import base64 +import hashlib +import inspect +import json +import threading +from collections.abc import Callable, Generator, Mapping +from dataclasses import dataclass, field +from datetime import UTC, datetime +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from importlib.metadata import version +from typing import Literal, override +from urllib.parse import parse_qs, urlsplit + +import pytest +from cryptography.hazmat.primitives import padding +from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes +from lark_oapi.core.cache import LocalCache +from lark_oapi.core.token import TokenManager + +from controllers.console.human_input_v2.providers import ( + FeishuCredentialsInput as FeishuCredentialRequest, +) +from controllers.console.human_input_v2.providers import LarkCredentialsInput as LarkCredentialRequest +from core.human_input import ButtonStyle +from core.human_input_v2.entities import IMProvider +from core.human_input_v2.im_integration.adapters import ( + AuthenticatedIMEvent, + CorrelationToken, + CredentialTestFailure, + CredentialTestFailureKind, + CredentialTestSuccess, + Directory, + DirectoryEntry, + DirectoryReadFailure, + DynamicCardMessagingError, + EventAcceptance, + IMEventIngressKind, + IMStreamStartError, + IMStreamStopError, + MessageAccepted, + MessageSendingError, + ProviderUserId, + ReplacementError, + ReplacementErrorKind, + StaticCardIntent, + WebhookRequest, +) +from core.human_input_v2.im_integration.adapters import feishu_lark as adapter_module +from core.human_input_v2.im_integration.adapters.credentials import FeishuCredentials, LarkCredentials +from core.human_input_v2.im_integration.adapters.entities import IMProviderError +from core.human_input_v2.im_integration.adapters.feishu_lark import ( + FeishuIMProviderAdapter, + LarkIMProviderAdapter, + _FeishuLarkDirectory, + _OfficialSDKGateway, + _SynchronousEventChannel, +) +from core.human_input_v2.resolved_form import MarkdownFragment, ParagraphInput, ResolvedForm, UserAction + + +@dataclass(slots=True) +class _RequestRecord: + method: str + path: str + body: dict[str, object] | None + authorization: str | None + + +@dataclass(slots=True) +class _ServerState: + requests: list[_RequestRecord] = field(default_factory=list) + markdown_cards: list[dict[str, object]] = field(default_factory=list) + paginated_scope: bool = False + paginated_directory: bool = False + live_readiness_directory: bool = False + omitted_empty_directory: bool = False + issued_tokens: int = 0 + token_error_code: int | None = None + + +class _SDKHTTPServer(ThreadingHTTPServer): + state: _ServerState + + +def _provider_confirmed_card_content() -> dict[str, object]: + return dict( + adapter_module._MSFeishuLarkCardCodec().encode( + _intent(), + CorrelationToken("opaque-correlation-token"), + ) + ) + + +def _is_provider_confirmed_card_request( + request_body: dict[str, object] | None, markdown_cards: list[dict[str, object]] +) -> bool: + if request_body is None or request_body.get("msg_type") != "interactive": + return False + content = request_body.get("content") + if not isinstance(content, str): + return False + try: + decoded_content = json.loads(content) + except json.JSONDecodeError: + return False + return decoded_content == _provider_confirmed_card_content() or decoded_content in markdown_cards + + +class _SDKRequestHandler(BaseHTTPRequestHandler): + def do_GET(self) -> None: + self._handle() + + def do_POST(self) -> None: + self._handle() + + def do_PATCH(self) -> None: + self._handle() + + @override + def log_message(self, format: str, *args: object) -> None: + del format, args + + def _handle(self) -> None: + assert isinstance(self.server, _SDKHTTPServer) + body = self._body() + self.server.state.requests.append( + _RequestRecord( + method=self.command, + path=self.path, + body=body, + authorization=self.headers.get("Authorization"), + ) + ) + route = self.path.split("?", maxsplit=1)[0] + if route == "/open-apis/auth/v3/tenant_access_token/internal": + if self.server.state.token_error_code is not None: + self._respond({"code": self.server.state.token_error_code, "msg": "credential rejected"}) + return + if body is None or body.get("app_secret") != "sanitized-app-secret": + self._respond({"code": 10014, "msg": "invalid app secret"}) + return + self.server.state.issued_tokens += 1 + self._respond( + { + "code": 0, + "msg": "ok", + "tenant_access_token": f"t-sanitized-access-token-{self.server.state.issued_tokens}", + "expire": 7200, + } + ) + return + if route == "/open-apis/tenant/v2/tenant/query": + self._respond({"code": 0, "msg": "ok", "data": {"tenant": {"tenant_key": "tenant_sanitized"}}}) + return + if route == "/open-apis/contact/v3/scopes": + if self.server.state.paginated_directory: + self._respond( + { + "code": 0, + "msg": "ok", + "data": { + "department_ids": ["dept_root"], + "user_ids": ["union_scope_direct"], + "has_more": False, + }, + } + ) + return + if self.server.state.paginated_scope: + if "page_token=scope-next" in self.path: + self._respond( + { + "code": 0, + "msg": "ok", + "data": { + "department_ids": [], + "user_ids": ["union_scope_second"], + "has_more": False, + }, + } + ) + return + self._respond( + { + "code": 0, + "msg": "ok", + "data": { + "department_ids": [], + "user_ids": ["union_scope_first"], + "has_more": True, + "page_token": "scope-next", + }, + } + ) + return + self._respond( + { + "code": 0, + "msg": "ok", + "data": {"department_ids": ["0"], "user_ids": [], "has_more": False}, + } + ) + return + if route == "/open-apis/contact/v3/departments/0/children": + if self.server.state.omitted_empty_directory: + self._respond({"code": 0, "msg": "ok", "data": {"has_more": False}}) + return + if self.server.state.live_readiness_directory: + self._respond( + { + "code": 0, + "msg": "ok", + "data": {"items": [{"open_department_id": "open_dept_child"}], "has_more": False}, + } + ) + return + items: list[dict[str, str]] = ( + [{"department_id": "dept_root"}] if self.server.state.paginated_directory else [] + ) + self._respond({"code": 0, "msg": "ok", "data": {"items": items, "has_more": False}}) + return + if route == "/open-apis/contact/v3/departments/dept_sanitized/children": + self._respond({"code": 0, "msg": "ok", "data": {"items": [], "has_more": False}}) + return + if route == "/open-apis/contact/v3/departments/dept_root/children": + if "page_token=departments-next" in self.path: + self._respond({"code": 0, "msg": "ok", "data": {"items": [], "has_more": False}}) + return + self._respond( + { + "code": 0, + "msg": "ok", + "data": { + "items": [{"department_id": "dept_child"}], + "has_more": True, + "page_token": "departments-next", + }, + } + ) + return + if route == "/open-apis/contact/v3/departments/dept_child/children": + self._respond({"code": 0, "msg": "ok", "data": {"items": [], "has_more": False}}) + return + if route == "/open-apis/contact/v3/departments/open_dept_child/children": + self._respond({"code": 0, "msg": "ok", "data": {"has_more": False}}) + return + if route == "/open-apis/contact/v3/users/find_by_department": + query = parse_qs(urlsplit(self.path).query) + if self.server.state.omitted_empty_directory: + self._respond({"code": 0, "msg": "ok", "data": {"has_more": False}}) + return + if self.server.state.live_readiness_directory: + department_id = query.get("department_id", [""])[0] + if department_id == "0": + self._respond( + { + "code": 0, + "msg": "ok", + "data": { + "items": [ + { + "union_id": "union_first", + "name": "First Observation", + "enterprise_email": "first@example.invalid", + }, + {"union_id": "union_withheld"}, + ], + "has_more": False, + }, + } + ) + return + if department_id == "open_dept_child": + self._respond( + { + "code": 0, + "msg": "ok", + "data": { + "items": [ + { + "union_id": "union_first", + "name": "Later Observation", + "email": "later@example.invalid", + }, + { + "union_id": "union_child", + "name": "Child User", + "email": "child@example.invalid", + }, + ], + "has_more": False, + }, + } + ) + return + if self.server.state.paginated_directory: + department_id = query.get("department_id", [""])[0] + if department_id == "dept_root" and query.get("page_token") == ["users-next"]: + self._respond( + { + "code": 0, + "msg": "ok", + "data": { + "items": [{"union_id": "union_root_second"}], + "has_more": False, + }, + } + ) + return + if department_id == "dept_root": + self._respond( + { + "code": 0, + "msg": "ok", + "data": { + "items": [ + { + "union_id": "union_root_first", + "name": "Root User", + "enterprise_email": "root@example.invalid", + } + ], + "has_more": True, + "page_token": "users-next", + }, + } + ) + return + if department_id == "dept_child": + self._respond( + { + "code": 0, + "msg": "ok", + "data": { + "items": [{"union_id": "union_child", "name": "Child User"}], + "has_more": False, + }, + } + ) + return + self._respond( + { + "code": 0, + "msg": "ok", + "data": { + "items": [ + { + "union_id": "union_sanitized_user", + "name": "Sanitized User", + "email": "user@example.invalid", + } + ], + "has_more": False, + }, + } + ) + return + if route == "/open-apis/im/v1/messages" and self.command == "POST": + if ( + body is not None + and body.get("msg_type") == "interactive" + and not _is_provider_confirmed_card_request(body, self.server.state.markdown_cards) + ): + self._respond({"code": 230001, "msg": "sanitized card contract rejection"}) + return + self._respond({"code": 0, "msg": "ok", "data": {"message_id": "om_sanitized_message"}}) + return + if route == "/open-apis/im/v1/messages/om_sanitized_message" and self.command == "PATCH": + self._respond({"code": 0, "msg": "ok", "data": {}}) + return + self._respond({"code": 404, "msg": "not found"}, status=404) + + def _body(self) -> dict[str, object] | None: + content_length = int(self.headers.get("Content-Length", "0")) + if content_length == 0: + return None + decoded = json.loads(self.rfile.read(content_length)) + assert isinstance(decoded, dict) + return decoded + + def _respond(self, body: dict[str, object], *, status: int = 200) -> None: + encoded = json.dumps(body, separators=(",", ":")).encode() + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(encoded))) + self.end_headers() + self.wfile.write(encoded) + + +@dataclass(frozen=True, slots=True) +class _RunningServer: + domain: str + state: _ServerState + + +def _credentials() -> FeishuCredentials: + return FeishuCredentials( + provider=IMProvider.FEISHU, + app_id="cli_sanitized_app", + app_secret="sanitized-app-secret", + verification_token=None, + encrypt_key=None, + ) + + +def _secure_credentials() -> FeishuCredentials: + return FeishuCredentials( + provider=IMProvider.FEISHU, + app_id="cli_sanitized_app", + app_secret="sanitized-app-secret", + verification_token="sanitized-verification-token", + encrypt_key="sanitized-encrypt-key", + ) + + +def _intent() -> ResolvedForm: + return ResolvedForm( + title="Approval", + parts=( + MarkdownFragment(text="Rendered **content**"), + ParagraphInput(output_variable_name="comment", default_value="Initial"), + ), + actions=(UserAction(id="approve", title="Approve", button_style=ButtonStyle.PRIMARY),), + legacy_form_content="This value must not be rendered", + ) + + +class _Consumer: + def __init__(self, acceptance: EventAcceptance = EventAcceptance.ACCEPTED) -> None: + self.acceptance = acceptance + self.events: list[AuthenticatedIMEvent] = [] + + def accept(self, event: AuthenticatedIMEvent, *, request_ip_address: str | None = None) -> EventAcceptance: + del request_ip_address + self.events.append(event) + return self.acceptance + + +class _FailingConsumer: + def accept(self, event: AuthenticatedIMEvent, *, request_ip_address: str | None = None) -> EventAcceptance: + del event, request_ip_address + raise RuntimeError("sanitized consumer failure") + + +class _ScriptedGateway: + def request_tenant_access_token(self) -> str: + return "sanitized-access-token" + + def __init__(self) -> None: + self.tenant: list[Mapping[str, object] | Exception] = [] + self.scope: list[Mapping[str, object] | Exception] = [] + self.departments: list[Mapping[str, object] | Exception] = [] + self.users: list[Mapping[str, object] | Exception] = [] + self.creates: list[Mapping[str, object] | Exception] = [] + self.patches: list[Mapping[str, object] | Exception] = [] + self.calls: list[str] = [] + + @staticmethod + def _next(values: list[Mapping[str, object] | Exception]) -> Mapping[str, object]: + value = values.pop(0) + if isinstance(value, Exception): + raise value + return value + + def query_tenant(self, *, request_option: object = None) -> Mapping[str, object]: + del request_option + self.calls.append("tenant") + return self._next(self.tenant) + + def list_scope(self, page_token: str | None, *, request_option: object = None) -> Mapping[str, object]: + del request_option, page_token + self.calls.append("scope") + return self._next(self.scope) + + def list_departments( + self, department: adapter_module._DepartmentIdentity, page_token: str | None, *, request_option: object = None + ) -> Mapping[str, object]: + del request_option, department, page_token + self.calls.append("departments") + return self._next(self.departments) + + def list_users( + self, department: adapter_module._DepartmentIdentity, page_token: str | None + ) -> Mapping[str, object]: + del department, page_token + self.calls.append("users") + return self._next(self.users) + + def create_message(self, receive_id: str, msg_type: str, content: str) -> Mapping[str, object]: + del receive_id, msg_type, content + self.calls.append("create") + return self._next(self.creates) + + def patch_message(self, message_id: str, content: str) -> Mapping[str, object]: + del message_id, content + self.calls.append("patch") + return self._next(self.patches) + + +def _tenant_response(tenant_key: str = "tenant_sanitized") -> Mapping[str, object]: + return {"code": 0, "data": {"tenant": {"tenant_key": tenant_key}}} + + +def _page(items: list[Mapping[str, object]]) -> Mapping[str, object]: + return {"code": 0, "data": {"items": items, "has_more": False}} + + +def _scripted_adapter(monkeypatch: pytest.MonkeyPatch, gateway: _ScriptedGateway) -> FeishuIMProviderAdapter: + monkeypatch.setattr(adapter_module, "_create_sdk_gateway", lambda _credentials, _domain: gateway) + return FeishuIMProviderAdapter(_secure_credentials()) + + +def _event_payload() -> dict[str, object]: + return { + "schema": "2.0", + "header": { + "event_id": "evt_sanitized_event", + "event_type": "card.action.trigger", + "create_time": "1785981600000", + "tenant_key": "tenant_sanitized", + "token": "sanitized-verification-token", + }, + "event": { + "action": { + "name": "approve", + "value": { + "action_id": "approve", + "correlation_token": "opaque-correlation-token", + }, + } + }, + } + + +def _sdk_transport_envelope(event: Mapping[str, object]) -> adapter_module._SDKEventEnvelope: + header = event.get("header") + assert isinstance(header, Mapping) + tenant_key = header.get("tenant_key") + event_id = header.get("event_id") + event_type = header.get("event_type") + create_time = header.get("create_time") + assert isinstance(tenant_key, str) + return adapter_module._SDKEventEnvelope( + native_payload=json.dumps(event, ensure_ascii=False, separators=(",", ":")), + provider_tenant_id=tenant_key, + event_id=event_id if isinstance(event_id, str) else None, + event_type=event_type if isinstance(event_type, str) else None, + occurred_at=adapter_module._webhook_occurred_at(create_time if isinstance(create_time, str) else None), + ) + + +def _encrypt(plaintext: bytes, encrypt_key: str = "sanitized-encrypt-key") -> str: + key = hashlib.sha256(encrypt_key.encode()).digest() + iv = bytes(range(16)) + padder = padding.PKCS7(algorithms.AES.block_size).padder() + padded = padder.update(plaintext) + padder.finalize() + encryptor = Cipher(algorithms.AES(key), modes.CBC(iv)).encryptor() + return base64.b64encode(iv + encryptor.update(padded) + encryptor.finalize()).decode() + + +def _signed_request( + body: bytes, + *, + nonce: str = "sanitized-nonce", + timestamp: str | None = None, +) -> WebhookRequest: + received_at = datetime(2026, 8, 6, 10, tzinfo=UTC) + signed_timestamp = timestamp or str(int(received_at.timestamp())) + signature = hashlib.sha256(signed_timestamp.encode() + nonce.encode() + b"sanitized-encrypt-key" + body).hexdigest() + return WebhookRequest( + "POST", + ( + ("X-Lark-Request-Timestamp", signed_timestamp), + ("X-Lark-Request-Nonce", nonce), + ("X-Lark-Signature", signature), + ), + body, + received_at, + ) + + +@pytest.fixture +def sdk_server(monkeypatch: pytest.MonkeyPatch) -> Generator[_RunningServer]: + monkeypatch.setattr(TokenManager, "cache", LocalCache()) + state = _ServerState() + server = _SDKHTTPServer(("127.0.0.1", 0), _SDKRequestHandler) + server.state = state + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + host, port = server.server_address[:2] + yield _RunningServer(f"http://{host}:{port}", state) + finally: + server.shutdown() + server.server_close() + thread.join(timeout=2) + + +@pytest.mark.parametrize("provider", [IMProvider.FEISHU, IMProvider.LARK]) +@pytest.mark.parametrize("changed_secret", [False, True], ids=["revoked-credentials", "wrong-secret"]) +def test_credential_test_rejects_invalid_credentials_despite_a_cached_token( + monkeypatch: pytest.MonkeyPatch, + sdk_server: _RunningServer, + provider: Literal[IMProvider.FEISHU, IMProvider.LARK], + changed_secret: bool, +) -> None: + monkeypatch.setattr( + adapter_module, + "_create_sdk_gateway", + lambda credentials, _domain: _OfficialSDKGateway(credentials, sdk_server.domain), + ) + credentials = _credentials() + _OfficialSDKGateway(credentials, sdk_server.domain).query_tenant() + if changed_secret: + credentials = credentials.model_copy(update={"app_secret": "invalid-secret"}) + else: + sdk_server.state.token_error_code = 10014 + adapter = ( + FeishuIMProviderAdapter(credentials) + if provider is IMProvider.FEISHU + else LarkIMProviderAdapter( + LarkCredentials(provider=IMProvider.LARK, app_id=credentials.app_id, app_secret=credentials.app_secret) + ) + ) + + result = adapter.test_credentials() + + assert isinstance(result, CredentialTestFailure) + assert result.kind is CredentialTestFailureKind.AUTHENTICATION_REJECTED + assert result.provider_error_code == "10014" + + +@pytest.mark.parametrize("provider", [IMProvider.FEISHU, IMProvider.LARK]) +def test_repeated_credential_tests_use_fresh_tokens_without_replacing_the_runtime_cache( + monkeypatch: pytest.MonkeyPatch, + sdk_server: _RunningServer, + provider: Literal[IMProvider.FEISHU, IMProvider.LARK], +) -> None: + monkeypatch.setattr( + adapter_module, + "_create_sdk_gateway", + lambda credentials, _domain: _OfficialSDKGateway(credentials, sdk_server.domain), + ) + runtime_gateway = _OfficialSDKGateway(_credentials(), sdk_server.domain) + runtime_gateway.query_tenant() + adapter = ( + FeishuIMProviderAdapter(_credentials()) + if provider is IMProvider.FEISHU + else LarkIMProviderAdapter( + LarkCredentials( + provider=IMProvider.LARK, + app_id=_credentials().app_id, + app_secret=_credentials().app_secret, + ) + ) + ) + + for token_number in (2, 3): + request_start = len(sdk_server.state.requests) + assert adapter.test_credentials() == CredentialTestSuccess(provider, "tenant_sanitized") + test_requests = sdk_server.state.requests[request_start:] + assert test_requests[0].path == "/open-apis/auth/v3/tenant_access_token/internal" + assert {request.authorization for request in test_requests[1:]} == { + f"Bearer t-sanitized-access-token-{token_number}" + } + + runtime_gateway.query_tenant() + assert sdk_server.state.requests[-1].authorization == "Bearer t-sanitized-access-token-1" + + +def test_official_sdk_gateway_maps_tenant_directory_and_message_operations(sdk_server: _RunningServer) -> None: + gateway = _OfficialSDKGateway(_credentials(), sdk_server.domain) + + tenant_response = gateway.query_tenant() + scope_response = gateway.list_scope(None) + root_department = adapter_module._DepartmentIdentity("0", "department_id") + department_response = gateway.list_departments(root_department, None) + user_response = gateway.list_users(root_department, None) + create_response = gateway.create_message( + "union_sanitized_user", + "text", + '{"text":"Rendered **CommonMark**"}', + ) + patch_response = gateway.patch_message( + "om_sanitized_message", + '{"schema":"2.0","body":{"elements":[]}}', + ) + + assert tenant_response["data"] == {"tenant": {"tenant_key": "tenant_sanitized"}} + assert scope_response["code"] == department_response["code"] == user_response["code"] == 0 + assert create_response["data"] == {"message_id": "om_sanitized_message"} + assert patch_response["code"] == 0 + operation_requests = [ + request + for request in sdk_server.state.requests + if request.path != "/open-apis/auth/v3/tenant_access_token/internal" + ] + scope_request = next(request for request in operation_requests if "/contact/v3/scopes" in request.path) + assert "department_id_type=department_id" in scope_request.path + assert "user_id_type=union_id" in scope_request.path + assert "page_size=50" in scope_request.path + department_request = next(request for request in operation_requests if "/departments/0/children" in request.path) + assert "department_id_type=department_id" in department_request.path + assert "user_id_type=union_id" in department_request.path + department_query = parse_qs(urlsplit(department_request.path).query) + assert department_query["fetch_child"][0].casefold() == "false" + user_request = next(request for request in operation_requests if "find_by_department" in request.path) + assert "department_id=0" in user_request.path + assert "department_id_type=department_id" in user_request.path + assert "user_id_type=union_id" in user_request.path + message_request = next( + request for request in operation_requests if request.path.startswith("/open-apis/im/v1/messages?") + ) + assert "receive_id_type=union_id" in message_request.path + assert message_request.body == { + "receive_id": "union_sanitized_user", + "msg_type": "text", + "content": '{"text":"Rendered **CommonMark**"}', + } + + +def test_official_sdk_gateway_preserves_provider_string_department_ids(sdk_server: _RunningServer) -> None: + gateway = _OfficialSDKGateway(_credentials(), sdk_server.domain) + + response = gateway.list_departments( + adapter_module._DepartmentIdentity("dept_sanitized", "department_id"), + None, + ) + + assert response["code"] == 0 + assert any( + request.path.startswith("/open-apis/contact/v3/departments/dept_sanitized/children?") + for request in sdk_server.state.requests + ) + + +def test_credentials_read_every_scope_page_before_root_permission_proof( + monkeypatch: pytest.MonkeyPatch, + sdk_server: _RunningServer, +) -> None: + sdk_server.state.paginated_scope = True + monkeypatch.setattr( + adapter_module, + "_create_sdk_gateway", + lambda credentials, _domain: _OfficialSDKGateway(credentials, sdk_server.domain), + ) + adapter = FeishuIMProviderAdapter(_credentials()) + + result = adapter.test_credentials() + + assert result == CredentialTestSuccess(IMProvider.FEISHU, "tenant_sanitized") + operation_requests = [ + request + for request in sdk_server.state.requests + if request.path != "/open-apis/auth/v3/tenant_access_token/internal" + ] + scope_requests = [request for request in operation_requests if "/contact/v3/scopes" in request.path] + assert len(scope_requests) == 2 + assert "page_token=scope-next" in scope_requests[1].path + root_proof_requests = [request for request in operation_requests if "/departments/0/children" in request.path] + assert len(root_proof_requests) == 1 + assert operation_requests.index(root_proof_requests[0]) > operation_requests.index(scope_requests[1]) + + +def test_directory_starts_at_root_without_reading_scope(sdk_server: _RunningServer) -> None: + directory = _FeishuLarkDirectory(_OfficialSDKGateway(_credentials(), sdk_server.domain), IMProvider.FEISHU) + + result = directory.read_directory() + + assert result == Directory( + ( + DirectoryEntry( + ProviderUserId("union_sanitized_user"), + "Sanitized User", + "user@example.invalid", + ), + ) + ) + operation_requests = [ + request + for request in sdk_server.state.requests + if request.path != "/open-apis/auth/v3/tenant_access_token/internal" + ] + assert not any("/contact/v3/scopes" in request.path for request in operation_requests) + assert "department_id=0" in operation_requests[0].path + assert "/departments/0/children" in operation_requests[1].path + + +@pytest.mark.parametrize("provider", [IMProvider.FEISHU, IMProvider.LARK]) +def test_live_readiness_minimal_shapes_preserve_official_sdk_department_identity( + monkeypatch: pytest.MonkeyPatch, + sdk_server: _RunningServer, + provider: Literal[IMProvider.FEISHU, IMProvider.LARK], +) -> None: + sdk_server.state.live_readiness_directory = True + monkeypatch.setattr( + adapter_module, + "_create_sdk_gateway", + lambda credentials, _domain: _OfficialSDKGateway(credentials, sdk_server.domain), + ) + if provider is IMProvider.FEISHU: + adapter = FeishuIMProviderAdapter(_secure_credentials()) + else: + adapter = LarkIMProviderAdapter( + LarkCredentials( + provider=IMProvider.LARK, + app_id="cli_sanitized_app", + app_secret="sanitized-app-secret", + verification_token="sanitized-verification-token", + encrypt_key="sanitized-encrypt-key", + ) + ) + + assert adapter.test_credentials() == CredentialTestSuccess(provider, "tenant_sanitized") + directory_request_start = len(sdk_server.state.requests) + + result = adapter.directory.read_directory() + + assert result == Directory( + ( + DirectoryEntry(ProviderUserId("union_first"), "First Observation", "first@example.invalid"), + DirectoryEntry(ProviderUserId("union_withheld"), None, None), + DirectoryEntry(ProviderUserId("union_child"), "Child User", "child@example.invalid"), + ) + ) + directory_requests = sdk_server.state.requests[directory_request_start:] + assert not any("/contact/v3/scopes" in request.path for request in directory_requests) + assert not any( + "/contact/v3/users/" in request.path and "find_by_department" not in request.path + for request in directory_requests + ) + user_requests = [request for request in directory_requests if "find_by_department" in request.path] + department_requests = [request for request in directory_requests if "/departments/" in request.path] + assert [parse_qs(urlsplit(request.path).query)["department_id"] for request in user_requests] == [ + ["0"], + ["open_dept_child"], + ] + assert [parse_qs(urlsplit(request.path).query)["department_id_type"] for request in user_requests] == [ + ["department_id"], + ["open_department_id"], + ] + assert all(parse_qs(urlsplit(request.path).query)["user_id_type"] == ["union_id"] for request in user_requests) + assert [urlsplit(request.path).path for request in department_requests] == [ + "/open-apis/contact/v3/departments/0/children", + "/open-apis/contact/v3/departments/open_dept_child/children", + ] + assert [parse_qs(urlsplit(request.path).query)["department_id_type"] for request in department_requests] == [ + ["department_id"], + ["open_department_id"], + ] + + +@pytest.mark.parametrize("provider", [IMProvider.FEISHU, IMProvider.LARK]) +def test_official_sdk_directory_accepts_omitted_items_on_empty_terminal_pages( + monkeypatch: pytest.MonkeyPatch, + sdk_server: _RunningServer, + provider: Literal[IMProvider.FEISHU, IMProvider.LARK], +) -> None: + sdk_server.state.omitted_empty_directory = True + monkeypatch.setattr( + adapter_module, + "_create_sdk_gateway", + lambda credentials, _domain: _OfficialSDKGateway(credentials, sdk_server.domain), + ) + if provider is IMProvider.FEISHU: + adapter = FeishuIMProviderAdapter(_secure_credentials()) + else: + adapter = LarkIMProviderAdapter( + LarkCredentials( + provider=IMProvider.LARK, + app_id="cli_sanitized_app", + app_secret="sanitized-app-secret", + verification_token="sanitized-verification-token", + encrypt_key="sanitized-encrypt-key", + ) + ) + + result = adapter.directory.read_directory() + + assert result == Directory(()) + operation_requests = [ + request + for request in sdk_server.state.requests + if request.path != "/open-apis/auth/v3/tenant_access_token/internal" + ] + assert len([request for request in operation_requests if "find_by_department" in request.path]) == 1 + assert len([request for request in operation_requests if "/departments/0/children" in request.path]) == 1 + assert not any("/contact/v3/scopes" in request.path for request in operation_requests) + + +@pytest.mark.parametrize("provider", [IMProvider.FEISHU, IMProvider.LARK]) +def test_full_adapter_over_official_http_sdk_preserves_wrapper_parity( + monkeypatch: pytest.MonkeyPatch, + sdk_server: _RunningServer, + provider: Literal[IMProvider.FEISHU, IMProvider.LARK], +) -> None: + sdk_server.state.paginated_directory = True + sdk_server.state.markdown_cards.append( + {"schema": "2.0", "body": {"elements": [{"tag": "markdown", "content": "Decision: **Approve**"}]}} + ) + observed_domains: list[str] = [] + + def gateway_factory(credentials: FeishuCredentials | LarkCredentials, domain: str) -> _OfficialSDKGateway: + observed_domains.append(domain) + return _OfficialSDKGateway(credentials, sdk_server.domain) + + monkeypatch.setattr(adapter_module, "_create_sdk_gateway", gateway_factory) + if provider is IMProvider.FEISHU: + credentials = _secure_credentials() + adapter = FeishuIMProviderAdapter(credentials) + expected_domain = "https://open.feishu.cn" + else: + credentials = LarkCredentials( + provider=IMProvider.LARK, + app_id="cli_sanitized_app", + app_secret="sanitized-app-secret", + verification_token="sanitized-verification-token", + encrypt_key="sanitized-encrypt-key", + ) + adapter = LarkIMProviderAdapter(credentials) + expected_domain = "https://open.larksuite.com" + + assert adapter.test_credentials() == CredentialTestSuccess(provider, "tenant_sanitized") + directory = adapter.directory.read_directory() + assert isinstance(directory, Directory) + assert [str(entry.provider_user_id) for entry in directory.entries] == [ + "union_sanitized_user", + "union_root_first", + "union_root_second", + "union_child", + ] + + text_result = adapter.messaging.send_text( + ProviderUserId("union_root_first"), + "Decision: **Approve**", + ) + assert isinstance(text_result, MessageAccepted) + card_result = adapter.dynamic_card_messaging.send_card( + ProviderUserId("union_root_first"), + _intent(), + CorrelationToken("opaque-correlation-token"), + ) + assert isinstance(card_result, MessageAccepted) + assert ( + adapter.dynamic_card_messaging.replace_with_static( + card_result.locator, + StaticCardIntent("Submitted **successfully**"), + ) + is None + ) + assert observed_domains == [expected_domain] + + operation_requests = [ + request + for request in sdk_server.state.requests + if request.path != "/open-apis/auth/v3/tenant_access_token/internal" + ] + scope_requests = [request for request in operation_requests if "/contact/v3/scopes" in request.path] + user_requests = [request for request in operation_requests if "find_by_department" in request.path] + department_requests = [request for request in operation_requests if "/children" in request.path] + message_requests = [ + request for request in operation_requests if request.method == "POST" and "/messages?" in request.path + ] + patch_requests = [request for request in operation_requests if request.method == "PATCH"] + assert len(scope_requests) == 1 + assert len(user_requests) == 4 + assert len(department_requests) == 5 + assert not any( + "/contact/v3/users/" in request.path and "find_by_department" not in request.path + for request in operation_requests + ) + assert len(message_requests) == 2 + assert len(patch_requests) == 1 + first_body = message_requests[0].body + second_body = message_requests[1].body + assert first_body is not None + assert second_body is not None + assert isinstance(first_body["content"], str) + assert first_body["msg_type"] == "interactive" + assert json.loads(first_body["content"]) == { + "schema": "2.0", + "body": {"elements": [{"tag": "markdown", "content": "Decision: **Approve**"}]}, + } + assert second_body["msg_type"] == "interactive" + + +def test_controller_projections_feed_typed_adapter_construction( + monkeypatch: pytest.MonkeyPatch, + sdk_server: _RunningServer, +) -> None: + monkeypatch.setattr( + adapter_module, + "_create_sdk_gateway", + lambda credentials, _domain: _OfficialSDKGateway(credentials, sdk_server.domain), + ) + feishu_request = FeishuCredentialRequest.model_validate( + { + "provider": IMProvider.FEISHU, + "app_id": "cli_sanitized_app", + "app_secret": "sanitized-app-secret", + "verification_token": "sanitized-new-verification-token", + "encrypt_key": None, + } + ) + feishu_resolved = feishu_request.to_owner_credentials() + assert FeishuIMProviderAdapter(feishu_resolved).test_credentials() == CredentialTestSuccess( + IMProvider.FEISHU, + "tenant_sanitized", + ) + lark_request = LarkCredentialRequest.model_validate( + { + "provider": IMProvider.LARK, + "app_id": "cli_sanitized_app", + "app_secret": "sanitized-app-secret", + "verification_token": None, + "encrypt_key": "sanitized-encrypt-key", + } + ) + lark_resolved = lark_request.to_owner_credentials() + assert LarkIMProviderAdapter(lark_resolved).provider is IMProvider.LARK + + +def test_webhook_crypto_challenge_and_ack_over_official_tenant_boundary( + monkeypatch: pytest.MonkeyPatch, + sdk_server: _RunningServer, +) -> None: + monkeypatch.setattr( + adapter_module, + "_create_sdk_gateway", + lambda credentials, _domain: _OfficialSDKGateway(credentials, sdk_server.domain), + ) + consumer = _Consumer() + adapter = FeishuIMProviderAdapter(_secure_credentials()) + handler = adapter.create_webhook_handler(consumer) + plaintext = json.dumps(_event_payload(), separators=(",", ":")).encode() + encrypted_body = json.dumps({"encrypt": _encrypt(plaintext)}, separators=(",", ":")).encode() + request = _signed_request(encrypted_body, timestamp="opaque-provider-timestamp") + + response = handler.handle(request) + + assert response.status_code == 200 + assert len(consumer.events) == 1 + assert consumer.events[0].ingress_kind is IMEventIngressKind.WEBHOOK + assert json.loads(consumer.events[0].payload) == json.loads(plaintext) + + challenge_consumer = _Consumer() + challenge_credentials = FeishuCredentials( + provider=IMProvider.FEISHU, + app_id="cli_sanitized_app", + app_secret="sanitized-app-secret", + verification_token="sanitized-verification-token", + encrypt_key=None, + ) + challenge_handler = adapter_module._FeishuLarkWebhookHandler( + _OfficialSDKGateway(challenge_credentials, sdk_server.domain), + challenge_credentials, + IMProvider.FEISHU, + challenge_consumer, + ) + challenge_body = json.dumps( + { + "type": "url_verification", + "token": "sanitized-verification-token", + "challenge": "sanitized-challenge", + }, + separators=(",", ":"), + ).encode() + challenge = challenge_handler.handle(WebhookRequest("POST", (), challenge_body, datetime(2026, 8, 6, 10))) + assert challenge.status_code == 200 + assert challenge.body == b'{"challenge":"sanitized-challenge"}' + assert challenge_consumer.events == [] + + +def test_socket_dispatcher_wire_ack_and_outer_lifecycle_are_integrated( + monkeypatch: pytest.MonkeyPatch, +) -> None: + trace: list[str] = [] + consumer = _Consumer() + credentials = _secure_credentials() + + class Writer: + def __init__(self) -> None: + self.frames: list[bytes] = [] + + async def _write_message(self, data: bytes) -> None: + trace.append("wire-ack") + self.frames.append(data) + + class SDKClient: + def __init__( + self, + callback: Callable[[adapter_module._SDKEventEnvelope, Callable[[], None]], None], + ) -> None: + self.channel = _SynchronousEventChannel( + credentials=credentials, + domain="https://open.feishu.cn", + callback=callback, + ) + self.writer = Writer() + self.started = False + self.stopped = False + + def start(self) -> None: + self.channel._wire_ack_tracking_enabled = True + self.channel._install_wire_ack_hook(self.writer) + self.started = True + trace.append("sdk-ready") + + def emit(self) -> None: + async def dispatch_and_write() -> None: + payload = json.dumps(_event_payload(), separators=(",", ":")).encode() + response = self.channel._build_dispatcher()._do_without_validation(payload) + assert response is not None + await self.writer._write_message(b"accepted-ack") + + asyncio.run(dispatch_and_write()) + + def stop(self) -> None: + self.channel.wait_for_pending_wire_acks() + self.stopped = True + trace.append("sdk-close") + + clients: list[SDKClient] = [] + + def client_factory( + _credentials: FeishuCredentials | LarkCredentials, + _domain: str, + callback: Callable[[adapter_module._SDKEventEnvelope, Callable[[], None]], None], + ) -> SDKClient: + client = SDKClient(callback) + clients.append(client) + return client + + monkeypatch.setattr(adapter_module, "_create_sdk_gateway", lambda _credentials, _domain: object()) + monkeypatch.setattr(adapter_module, "_create_sdk_stream_client", client_factory) + stream = FeishuIMProviderAdapter(credentials).create_stream_handler(consumer) + + stream.start() + clients[0].emit() + stream.stop() + + assert clients[0].started + assert clients[0].stopped + assert len(consumer.events) == 1 + assert trace == ["sdk-ready", "wire-ack", "sdk-close"] + + +@pytest.mark.parametrize( + ("tenant_result", "scope_result", "expected_kind"), + [ + (RuntimeError("sanitized transport failure"), None, CredentialTestFailureKind.UNKNOWN), + ({"code": 99991663}, None, CredentialTestFailureKind.AUTHENTICATION_REJECTED), + ({"code": 0, "data": {"tenant": {}}}, None, CredentialTestFailureKind.TENANT_ID_UNAVAILABLE), + (_tenant_response(), RuntimeError("sanitized scope failure"), CredentialTestFailureKind.UNKNOWN), + (_tenant_response(), {"code": 403}, CredentialTestFailureKind.UNKNOWN), + ], +) +def test_credential_failures_remain_typed_across_adapter_composition( + monkeypatch: pytest.MonkeyPatch, + tenant_result: Mapping[str, object] | Exception, + scope_result: Mapping[str, object] | Exception | None, + expected_kind: CredentialTestFailureKind, +) -> None: + gateway = _ScriptedGateway() + gateway.tenant.append(tenant_result) + if scope_result is not None: + gateway.scope.append(scope_result) + adapter = _scripted_adapter(monkeypatch, gateway) + + if isinstance(tenant_result, Exception) or isinstance(scope_result, Exception): + execution_error = tenant_result if isinstance(tenant_result, Exception) else scope_result + assert isinstance(execution_error, Exception) + with pytest.raises(type(execution_error)) as captured: + adapter.test_credentials() + assert captured.value is execution_error + return + if expected_kind is CredentialTestFailureKind.TENANT_ID_UNAVAILABLE: + with pytest.raises(IMProviderError): + adapter.test_credentials() + return + result = adapter.test_credentials() + + assert isinstance(result, CredentialTestFailure) + assert result.kind is expected_kind + assert "sanitized" not in result.reason.casefold() + + +def test_directory_message_and_card_failures_are_safe_and_single_attempt( + monkeypatch: pytest.MonkeyPatch, +) -> None: + directory_gateway = _ScriptedGateway() + directory_gateway.users.append(_page([{"union_id": "union_partial", "name": "Partial User"}])) + directory_gateway.departments.append(RuntimeError("sanitized later-page failure")) + directory_result = _scripted_adapter(monkeypatch, directory_gateway).directory.read_directory() + assert isinstance(directory_result, DirectoryReadFailure) + assert "union_partial" not in directory_result.reason + + text_gateway = _ScriptedGateway() + text_gateway.tenant.append(RuntimeError("sanitized tenant failure")) + text_result = _scripted_adapter(monkeypatch, text_gateway).messaging.send_text( + ProviderUserId("union_sanitized"), + "Rendered text", + ) + assert isinstance(text_result, MessageSendingError) + + rejected_text_gateway = _ScriptedGateway() + rejected_text_gateway.tenant.append(_tenant_response()) + rejected_text_gateway.creates.append({"code": 230001}) + rejected_text = _scripted_adapter(monkeypatch, rejected_text_gateway).messaging.send_text( + ProviderUserId("union_sanitized"), + "Rendered text", + ) + assert isinstance(rejected_text, MessageSendingError) + assert rejected_text_gateway.calls.count("create") == 1 + + card_gateway = _ScriptedGateway() + card_gateway.tenant.append(_tenant_response()) + card_gateway.creates.append(RuntimeError("sanitized create failure")) + card_adapter = _scripted_adapter(monkeypatch, card_gateway) + card_result = card_adapter.dynamic_card_messaging.send_card( + ProviderUserId("union_sanitized"), + _intent(), + CorrelationToken("opaque-correlation-token"), + ) + assert isinstance(card_result, MessageSendingError) + + empty_intent = ResolvedForm( + title="", + parts=(), + actions=(), + legacy_form_content="This value must not be rendered", + ) + assert card_adapter.dynamic_card_messaging.assess(empty_intent).representable is False + with pytest.raises(DynamicCardMessagingError): + card_adapter.dynamic_card_messaging.send_card( + ProviderUserId("union_sanitized"), + empty_intent, + CorrelationToken("opaque-correlation-token"), + ) + + +def test_card_reference_update_failures_preserve_exact_mutation_semantics( + monkeypatch: pytest.MonkeyPatch, +) -> None: + source_gateway = _ScriptedGateway() + source_gateway.tenant.append(_tenant_response()) + source_gateway.creates.append({"code": 0, "data": {"message_id": "om_sanitized_message"}}) + source = _scripted_adapter(monkeypatch, source_gateway) + accepted = source.dynamic_card_messaging.send_card( + ProviderUserId("union_sanitized"), + _intent(), + CorrelationToken("opaque-correlation-token"), + ) + assert isinstance(accepted, MessageAccepted) + + unknown_tenant_gateway = _ScriptedGateway() + unknown_tenant_gateway.tenant.append(RuntimeError("sanitized tenant failure")) + unknown_tenant = _scripted_adapter(monkeypatch, unknown_tenant_gateway).dynamic_card_messaging.replace_with_static( + accepted.locator, + StaticCardIntent("Submitted"), + ) + assert isinstance(unknown_tenant, ReplacementError) + assert unknown_tenant.kind is ReplacementErrorKind.UNKNOWN + + cross_tenant_gateway = _ScriptedGateway() + cross_tenant_gateway.tenant.append(_tenant_response("tenant_other")) + cross_tenant = _scripted_adapter(monkeypatch, cross_tenant_gateway).dynamic_card_messaging.replace_with_static( + accepted.locator, + StaticCardIntent("Submitted"), + ) + assert isinstance(cross_tenant, ReplacementError) + assert cross_tenant.kind is ReplacementErrorKind.UNKNOWN + + patch_failure_gateway = _ScriptedGateway() + patch_failure_gateway.tenant.append(_tenant_response()) + patch_failure_gateway.patches.append(RuntimeError("sanitized patch failure")) + patch_failure = _scripted_adapter(monkeypatch, patch_failure_gateway).dynamic_card_messaging.replace_with_static( + accepted.locator, + StaticCardIntent("Submitted"), + ) + assert isinstance(patch_failure, ReplacementError) + assert patch_failure.kind is ReplacementErrorKind.UNKNOWN + + stale_gateway = _ScriptedGateway() + stale_gateway.tenant.append(_tenant_response()) + stale_gateway.patches.append({"code": 230011}) + stale = _scripted_adapter(monkeypatch, stale_gateway).dynamic_card_messaging.replace_with_static( + accepted.locator, + StaticCardIntent("Submitted"), + ) + assert isinstance(stale, ReplacementError) + assert stale.kind is ReplacementErrorKind.STALE_REFERENCE + + unknown_gateway = _ScriptedGateway() + unknown_gateway.tenant.append(_tenant_response()) + unknown_gateway.patches.append({"code": 500}) + unknown = _scripted_adapter(monkeypatch, unknown_gateway).dynamic_card_messaging.replace_with_static( + accepted.locator, + StaticCardIntent("Submitted"), + ) + assert isinstance(unknown, ReplacementError) + assert unknown.kind is ReplacementErrorKind.UNKNOWN + + +def test_webhook_failure_ack_matrix_covers_auth_tenant_and_consumer_boundaries() -> None: + plaintext_credentials = FeishuCredentials( + provider=IMProvider.FEISHU, + app_id="cli_sanitized_app", + app_secret="sanitized-app-secret", + verification_token="sanitized-verification-token", + encrypt_key=None, + ) + + def handler( + gateway: _ScriptedGateway, consumer: _Consumer | _FailingConsumer | None = None + ) -> adapter_module._FeishuLarkWebhookHandler: + return adapter_module._FeishuLarkWebhookHandler( + gateway, + plaintext_credentials, + IMProvider.FEISHU, + consumer or _Consumer(), + ) + + assert handler(_ScriptedGateway()).handle(WebhookRequest("GET", (), b"{}", datetime.now())).status_code == 405 + assert handler(_ScriptedGateway()).handle(WebhookRequest("POST", (), b"{", datetime.now())).status_code == 401 + + invalid_challenge = json.dumps( + {"type": "url_verification", "token": "sanitized-verification-token"}, + separators=(",", ":"), + ).encode() + assert ( + handler(_ScriptedGateway()).handle(WebhookRequest("POST", (), invalid_challenge, datetime.now())).status_code + == 400 + ) + wrong_challenge = json.dumps( + {"type": "url_verification", "token": "wrong", "challenge": "sanitized-challenge"}, + separators=(",", ":"), + ).encode() + assert ( + handler(_ScriptedGateway()).handle(WebhookRequest("POST", (), wrong_challenge, datetime.now())).status_code + == 401 + ) + assert handler(_ScriptedGateway()).handle(WebhookRequest("POST", (), b"{}", datetime.now())).status_code == 400 + + wrong_token_body = _event_payload() + header = wrong_token_body["header"] + assert isinstance(header, dict) + header["token"] = "wrong" + assert ( + handler(_ScriptedGateway()) + .handle( + WebhookRequest( + "POST", + (), + json.dumps(wrong_token_body, separators=(",", ":")).encode(), + datetime.now(), + ) + ) + .status_code + == 401 + ) + + tenant_failure = _ScriptedGateway() + tenant_failure.tenant.append(RuntimeError("sanitized tenant failure")) + event_body = json.dumps(_event_payload(), separators=(",", ":")).encode() + assert handler(tenant_failure).handle(WebhookRequest("POST", (), event_body, datetime.now())).status_code == 503 + + cross_tenant = _ScriptedGateway() + cross_tenant.tenant.append(_tenant_response("tenant_other")) + assert handler(cross_tenant).handle(WebhookRequest("POST", (), event_body, datetime.now())).status_code == 401 + + for consumer in (_Consumer(EventAcceptance.NOT_ACCEPTED), _FailingConsumer()): + gateway = _ScriptedGateway() + gateway.tenant.append(_tenant_response()) + response = handler(gateway, consumer).handle(WebhookRequest("POST", (), event_body, datetime.now())) + assert response.status_code == 503 + + +def test_official_stream_client_and_channel_hooks_close_owned_resources( + monkeypatch: pytest.MonkeyPatch, +) -> None: + channels: list[ReadyChannel] = [] + + class ReadyChannel: + def __init__(self, **_kwargs: object) -> None: + self.stop_calls = 0 + channels.append(self) + + async def connect_until_ready(self, *, timeout: float) -> None: + assert timeout > 0 + + def wait_for_pending_wire_acks(self) -> None: + return + + def stop(self) -> None: + self.stop_calls += 1 + + monkeypatch.setattr(adapter_module, "_SynchronousEventChannel", ReadyChannel) + client = adapter_module._OfficialSDKStreamClient( + _secure_credentials(), + "https://open.feishu.cn", + lambda _event, _ack: None, + ) + client.start() + client.stop() + client.stop() + assert channels[0].stop_calls == 1 + assert client._loop.is_closed() + assert client._loop_thread is not None + assert not client._loop_thread.is_alive() + + stopped_before_start = adapter_module._OfficialSDKStreamClient( + _secure_credentials(), + "https://open.feishu.cn", + lambda _event, _ack: None, + ) + stopped_before_start.stop() + assert stopped_before_start._loop.is_closed() + + +def test_official_stream_client_releases_loop_when_sdk_close_fails( + monkeypatch: pytest.MonkeyPatch, +) -> None: + class FailingCloseChannel: + def __init__(self, **_kwargs: object) -> None: + return + + async def connect_until_ready(self, *, timeout: float) -> None: + assert timeout > 0 + + def wait_for_pending_wire_acks(self) -> None: + return + + def stop(self) -> None: + raise RuntimeError("sanitized SDK close failure") + + monkeypatch.setattr(adapter_module, "_SynchronousEventChannel", FailingCloseChannel) + client = adapter_module._OfficialSDKStreamClient( + _secure_credentials(), + "https://open.feishu.cn", + lambda _event, _ack: None, + ) + client.start() + + with pytest.raises(RuntimeError, match="sanitized SDK close failure"): + client.stop() + + assert client._loop.is_closed() + assert client._loop_thread is not None + assert not client._loop_thread.is_alive() + + +def test_official_stream_private_seam_matches_exact_lark_oapi_1_7_2() -> None: + from lark_oapi.ws import client as sdk_ws_client_module + + assert version("lark-oapi") == "1.7.2" + assert tuple(inspect.signature(sdk_ws_client_module.Client.__init__).parameters) == ( + "self", + "app_id", + "app_secret", + "log_level", + "event_handler", + "domain", + "auto_reconnect", + "source", + "extra_ua_tags", + "headers", + "client_assertion_provider", + ) + assert tuple(inspect.signature(sdk_ws_client_module.Client.start).parameters) == ("self",) + assert tuple(inspect.signature(sdk_ws_client_module.Client._connect).parameters) == ("self",) + assert tuple(inspect.signature(sdk_ws_client_module.Client._receive_message_loop).parameters) == ("self",) + assert tuple(inspect.signature(sdk_ws_client_module.Client._write_message).parameters) == ("self", "data") + assert inspect.iscoroutinefunction(sdk_ws_client_module.Client._connect) + assert inspect.iscoroutinefunction(sdk_ws_client_module.Client._receive_message_loop) + assert inspect.iscoroutinefunction(sdk_ws_client_module.Client._write_message) + assert not hasattr(sdk_ws_client_module.Client, "stop") + assert issubclass(adapter_module._PerClientSDKWSClient, sdk_ws_client_module.Client) + assert tuple(inspect.signature(adapter_module._PerClientSDKWSClient.stop).parameters) == ("self",) + + +def test_per_client_sdk_close_failure_releases_its_actual_owned_loop( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from lark_oapi.ws import client as sdk_ws_client_module + + transport_connected = threading.Event() + + class WebSocketConnection: + async def recv(self) -> bytes: + await asyncio.Event().wait() + raise AssertionError("unreachable") + + async def close(self) -> None: + return + + async def send(self, _data: bytes) -> None: + return + + async def connect(_url: str, **_kwargs: object) -> WebSocketConnection: + transport_connected.set() + return WebSocketConnection() + + monkeypatch.setattr( + sdk_ws_client_module.Client, + "_get_conn_url", + lambda _self: "wss://sanitized.invalid/connect?device_id=device-sanitized&service_id=1", + ) + monkeypatch.setattr(sdk_ws_client_module.websockets, "connect", connect) + client = adapter_module._PerClientSDKWSClient( + credentials=_secure_credentials(), + domain="https://open.feishu.cn", + event_handler=adapter_module.lark.EventDispatcherHandler.builder("", "").build(), + ) + start_errors: list[Exception] = [] + + def start_client() -> None: + try: + client.start() + except Exception as exc: + start_errors.append(exc) + + start_thread = threading.Thread(target=start_client) + start_thread.start() + assert transport_connected.wait(timeout=2) + + async def failing_disconnect() -> None: + raise RuntimeError("sanitized SDK close failure") + + monkeypatch.setattr(client, "_disconnect", failing_disconnect) + with pytest.raises(RuntimeError, match="sanitized SDK close failure"): + client.stop() + start_thread.join(timeout=2) + + assert not start_thread.is_alive() + assert start_errors == [] + assert client._dify_loop.is_closed() + + +def test_official_stream_clients_own_independent_sdk_event_loops( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from lark_oapi.ws import client as sdk_ws_client_module + + class WebSocketConnection: + def __init__(self) -> None: + self.closed = asyncio.Event() + + async def recv(self) -> bytes: + await self.closed.wait() + raise RuntimeError("sanitized connection closed") + + async def close(self) -> None: + self.closed.set() + + async def send(self, _data: bytes) -> None: + return + + transport_loops: dict[str, asyncio.AbstractEventLoop] = {} + + def connection_url(ws_client: sdk_ws_client_module.Client) -> str: + app_id = ws_client._app_id + return f"wss://sanitized.invalid/{app_id}?device_id=device-sanitized&service_id=1" + + async def connect(url: str, **_kwargs: object) -> WebSocketConnection: + app_id = urlsplit(url).path.removeprefix("/") + transport_loops[app_id] = asyncio.get_running_loop() + return WebSocketConnection() + + monkeypatch.setattr(adapter_module._SynchronousEventChannel, "_fetch_bot_identity_sync", lambda _self: None) + monkeypatch.setattr(sdk_ws_client_module.Client, "_get_conn_url", connection_url) + monkeypatch.setattr(sdk_ws_client_module.websockets, "connect", connect) + credentials = [ + FeishuCredentials( + provider=IMProvider.FEISHU, + app_id=f"cli_sanitized_app_{index}", + app_secret="sanitized-app-secret", + verification_token="sanitized-verification-token", + encrypt_key="sanitized-encrypt-key", + ) + for index in range(2) + ] + clients = [ + adapter_module._OfficialSDKStreamClient( + credential, + "https://open.feishu.cn", + lambda _event, _ack: None, + ) + for credential in credentials + ] + start_errors: list[Exception | None] = [None, None] + + def start_client(index: int) -> None: + try: + clients[index].start() + except Exception as exc: + start_errors[index] = exc + + start_threads = [threading.Thread(target=start_client, args=(index,)) for index in range(2)] + try: + for thread in start_threads: + thread.start() + for thread in start_threads: + thread.join(timeout=5) + + assert not any(thread.is_alive() for thread in start_threads) + assert start_errors == [None, None] + first_loop = transport_loops[credentials[0].app_id] + second_loop = transport_loops[credentials[1].app_id] + assert first_loop is not second_loop + + clients[0].stop() + + async def second_client_probe() -> str: + return "second-client-running" + + probe = asyncio.run_coroutine_threadsafe(second_client_probe(), second_loop) + assert probe.result(timeout=1) == "second-client-running" + assert second_loop.is_running() + finally: + for client in clients: + client.stop() + + +def test_official_stream_stop_during_start_prevents_late_transport_creation( + monkeypatch: pytest.MonkeyPatch, +) -> None: + initialization_entered = threading.Event() + release_initialization = threading.Event() + base_stop_completed = threading.Event() + created_clients: list[object] = [] + + def blocked_identity_fetch(_channel: object) -> None: + initialization_entered.set() + assert release_initialization.wait(timeout=2) + + class RecordingClient: + def __init__(self, **_kwargs: object) -> None: + self._conn: object | None = None + created_clients.append(self) + + def start(self) -> None: + self._conn = object() + + def stop(self) -> None: + self._conn = None + + async def _write_message(self, _data: bytes) -> None: + return + + original_base_stop = adapter_module.FeishuChannel.stop + + def observed_base_stop(channel: adapter_module.FeishuChannel, *, join_timeout: float = 5.0) -> None: + original_base_stop(channel, join_timeout=join_timeout) + base_stop_completed.set() + + monkeypatch.setattr(adapter_module._SynchronousEventChannel, "_fetch_bot_identity_sync", blocked_identity_fetch) + monkeypatch.setattr(adapter_module, "_PerClientSDKWSClient", RecordingClient) + monkeypatch.setattr(adapter_module.FeishuChannel, "stop", observed_base_stop) + client = adapter_module._OfficialSDKStreamClient( + _secure_credentials(), + "https://open.feishu.cn", + lambda _event, _ack: None, + ) + start_errors: list[Exception] = [] + + def start_client() -> None: + try: + client.start() + except Exception as exc: + start_errors.append(exc) + + start_thread = threading.Thread(target=start_client) + stop_thread = threading.Thread(target=client.stop) + + start_thread.start() + assert initialization_entered.wait(timeout=2) + stop_thread.start() + assert base_stop_completed.wait(timeout=2) + release_initialization.set() + start_thread.join(timeout=2) + stop_thread.join(timeout=2) + + assert not start_thread.is_alive() + assert not stop_thread.is_alive() + assert created_clients == [] + assert len(start_errors) <= 1 + + +def test_synchronous_channel_installs_write_hook_during_public_ready_boundary( + monkeypatch: pytest.MonkeyPatch, +) -> None: + writes: list[bytes] = [] + + class ReadyClient: + def __init__(self, **_kwargs: object) -> None: + self._conn: object | None = None + self.hook_installed = threading.Event() + + def start(self) -> None: + self._conn = object() + assert self.hook_installed.wait(timeout=1) + + def stop(self) -> None: + self._conn = None + + async def _write_message(self, data: bytes) -> None: + writes.append(data) + + monkeypatch.setattr(adapter_module, "_PerClientSDKWSClient", ReadyClient) + channel = adapter_module._SynchronousEventChannel( + credentials=_secure_credentials(), + domain="https://open.feishu.cn", + callback=lambda _event, acknowledge: acknowledge(), + ) + channel._fetch_bot_identity_sync = lambda: None + + original_install = channel._install_wire_ack_hook + + def install_and_release(ws_client: object) -> None: + original_install(ws_client) + assert isinstance(ws_client, ReadyClient) + ws_client.hook_installed.set() + + channel._install_wire_ack_hook = install_and_release + + asyncio.run(channel.connect_until_ready(timeout=1)) + writer = channel._ws_client + assert isinstance(writer, ReadyClient) + asyncio.run(writer._write_message(b"control-frame")) + channel.stop() + + assert channel._wire_ack_hook_installed.is_set() + assert writes == [b"control-frame"] + with pytest.raises(RuntimeError, match="write seam"): + channel._install_wire_ack_hook(object()) + + +@pytest.mark.parametrize( + "failure_case", + [ + "user_rejected", + "user_invalid_pagination", + "department_rejected", + "department_invalid_identity", + "department_invalid_pagination", + ], +) +def test_directory_rejects_incomplete_or_ambiguous_provider_pages(failure_case: str) -> None: + gateway = _ScriptedGateway() + if failure_case == "user_rejected": + gateway.users.append({"code": 403}) + elif failure_case == "user_invalid_pagination": + gateway.users.append({"code": 0, "data": {"items": [], "has_more": True, "page_token": ""}}) + else: + gateway.users.append(_page([])) + if failure_case == "department_rejected": + gateway.departments.append({"code": 403}) + elif failure_case == "department_invalid_identity": + gateway.departments.append(_page([{}])) + else: + gateway.departments.append({"code": 0, "data": {"items": [], "has_more": True, "page_token": ""}}) + + result = _FeishuLarkDirectory(gateway, IMProvider.FEISHU).read_directory() + + assert isinstance(result, DirectoryReadFailure) + assert result.reason == "Feishu directory could not be read completely." + + +@pytest.mark.parametrize("provider", [IMProvider.FEISHU, IMProvider.LARK]) +@pytest.mark.parametrize( + "malformed_boundary", + [ + "user-non-list-items", + "user-missing-union-id", + "department-non-list-items", + "department-missing-open-id", + ], +) +def test_directory_rejects_malformed_non_empty_sdk_pages_without_partial_result( + provider: Literal[IMProvider.FEISHU, IMProvider.LARK], + malformed_boundary: str, +) -> None: + gateway = _ScriptedGateway() + if malformed_boundary.startswith("user-"): + malformed_items: object = ( + {"union_id": "union_malformed"} + if malformed_boundary == "user-non-list-items" + else [{"name": "Missing Union ID"}] + ) + gateway.users.extend( + [ + { + "code": 0, + "data": { + "items": [{"union_id": "union_partial"}], + "has_more": True, + "page_token": "users-next", + }, + }, + {"code": 0, "data": {"items": malformed_items, "has_more": False}}, + ] + ) + expected_calls = ["users", "users"] + else: + malformed_items = ( + {"open_department_id": "open_dept_malformed"} + if malformed_boundary == "department-non-list-items" + else [{"open_department_id": ""}] + ) + gateway.users.append(_page([{"union_id": "union_partial"}])) + gateway.departments.extend( + [ + { + "code": 0, + "data": { + "items": [{"open_department_id": "open_dept_pending"}], + "has_more": True, + "page_token": "departments-next", + }, + }, + {"code": 0, "data": {"items": malformed_items, "has_more": False}}, + ] + ) + expected_calls = ["users", "departments", "departments"] + + result = _FeishuLarkDirectory(gateway, provider).read_directory() + + assert isinstance(result, DirectoryReadFailure) + assert "union_partial" not in result.reason + assert "malformed" not in result.reason.casefold() + assert "open_dept_pending" not in result.reason + assert gateway.calls == expected_calls + + +def test_directory_deduplicates_users_and_department_cycles_from_root() -> None: + gateway = _ScriptedGateway() + gateway.users.append( + _page( + [ + {"union_id": "union_sanitized", "name": "Sanitized User", "email": " "}, + {"union_id": "union_sanitized", "name": "Duplicate User"}, + ] + ) + ) + gateway.departments.append(_page([{"department_id": "0"}, {"department_id": "0"}])) + + result = _FeishuLarkDirectory(gateway, IMProvider.FEISHU).read_directory() + + assert result == Directory((DirectoryEntry(ProviderUserId("union_sanitized"), "Sanitized User", None),)) + assert gateway.calls == ["users", "departments"] + + +def test_subject_and_body_markdown_survive_the_official_message_boundary( + monkeypatch: pytest.MonkeyPatch, + sdk_server: _RunningServer, +) -> None: + monkeypatch.setattr( + adapter_module, + "_create_sdk_gateway", + lambda credentials, _domain: _OfficialSDKGateway(credentials, sdk_server.domain), + ) + adapter = FeishuIMProviderAdapter(_credentials()) + + body = ( + "# Decision\n\n- **Approve**\n- [Review](https://example.invalid)\n\n" + "![Diagram](https://image.invalid)\n\n`inline` \nnext\n\n```text\ncode\n```" + ) + expected_card: dict[str, object] = { + "schema": "2.0", + "body": { + "elements": [ + {"tag": "markdown", "content": r"# \#\#\# Review \*\*Alice\*\*"}, + {"tag": "markdown", "content": body}, + ] + }, + } + sdk_server.state.markdown_cards.append(expected_card) + result = adapter.messaging.send_text(ProviderUserId("union_sanitized_user"), body, subject="### Review **Alice**") + + assert isinstance(result, MessageAccepted) + message_request = next( + request + for request in sdk_server.state.requests + if request.method == "POST" and "/open-apis/im/v1/messages?" in request.path + ) + request_body = message_request.body + assert request_body is not None + assert isinstance(request_body["content"], str) + assert request_body["msg_type"] == "interactive" + assert json.loads(request_body["content"]) == expected_card + + +def test_secure_webhook_rejects_auth_crypto_boundaries() -> None: + encrypted_payload = json.dumps( + {"encrypt": _encrypt(json.dumps(_event_payload(), separators=(",", ":")).encode())}, + separators=(",", ":"), + ).encode() + unauthenticated_handler = adapter_module._FeishuLarkWebhookHandler( + _ScriptedGateway(), + _secure_credentials(), + IMProvider.FEISHU, + _Consumer(), + ) + assert ( + unauthenticated_handler.handle(WebhookRequest("POST", (), encrypted_payload, datetime.now())).status_code == 401 + ) + valid_request = _signed_request(encrypted_payload) + wrong_signature_request = WebhookRequest( + "POST", + valid_request.headers[:-1] + (("X-Lark-Signature", "wrong"),), + encrypted_payload, + valid_request.received_at, + ) + assert unauthenticated_handler.handle(wrong_signature_request).status_code == 401 + + malformed_encrypted_body = b'{"encrypt":"AA=="}' + assert unauthenticated_handler.handle(_signed_request(malformed_encrypted_body)).status_code == 401 + plaintext_handler = adapter_module._FeishuLarkWebhookHandler( + _ScriptedGateway(), + _credentials(), + IMProvider.FEISHU, + _Consumer(), + ) + assert plaintext_handler.handle(WebhookRequest("POST", (), encrypted_payload, datetime.now())).status_code == 401 + + +def test_stream_failure_stop_and_late_callback_boundaries_are_one_shot( + monkeypatch: pytest.MonkeyPatch, +) -> None: + clients: list[LifecycleClient] = [] + + class LifecycleClient: + def __init__( + self, + callback: Callable[[adapter_module._SDKEventEnvelope, Callable[[], None]], None], + *, + fail_start: bool, + fail_stop: bool, + ) -> None: + self.callback = callback + self.fail_start = fail_start + self.fail_stop = fail_stop + self.stop_calls = 0 + + def start(self) -> None: + if self.fail_start: + raise RuntimeError("sanitized start failure") + + def stop(self) -> None: + self.stop_calls += 1 + if self.fail_stop: + raise RuntimeError("sanitized stop failure") + + def emit(self, event: Mapping[str, object]) -> int: + acknowledgements = 0 + + def acknowledge() -> None: + nonlocal acknowledgements + acknowledgements += 1 + + self.callback(_sdk_transport_envelope(event), acknowledge) + return acknowledgements + + def client_factory( + _credentials: FeishuCredentials | LarkCredentials, + _domain: str, + callback: Callable[[adapter_module._SDKEventEnvelope, Callable[[], None]], None], + ) -> LifecycleClient: + client = LifecycleClient( + callback, + fail_start=not clients, + fail_stop=bool(clients), + ) + clients.append(client) + return client + + monkeypatch.setattr(adapter_module, "_create_sdk_gateway", lambda _credentials, _domain: _ScriptedGateway()) + monkeypatch.setattr(adapter_module, "_create_sdk_stream_client", client_factory) + adapter = FeishuIMProviderAdapter(_secure_credentials()) + + failed_stream = adapter.create_stream_handler(_Consumer()) + with pytest.raises(IMStreamStartError, match="could not be started"): + failed_stream.start() + with pytest.raises(IMStreamStartError, match="already been started"): + failed_stream.start() + assert clients[0].stop_calls == 1 + + consumer = _Consumer(EventAcceptance.NOT_ACCEPTED) + running_stream = adapter.create_stream_handler(consumer) + running_stream.start() + assert clients[1].emit(_event_payload()) == 0 + with pytest.raises(IMStreamStopError, match="could not be stopped"): + running_stream.stop() + with pytest.raises(IMStreamStopError, match="could not be stopped"): + running_stream.stop() + assert clients[1].stop_calls == 1 + assert clients[1].emit(_event_payload()) == 0 + assert len(consumer.events) == 1 + + stopped_before_start = adapter.create_stream_handler(_Consumer()) + stopped_before_start.stop() + with pytest.raises(IMStreamStartError, match="already been started"): + stopped_before_start.start() + assert len(clients) == 2 diff --git a/api/tests/unit_tests/core/human_input_v2/im_provider/test_feishu_lark_stream.py b/api/tests/unit_tests/core/human_input_v2/im_provider/test_feishu_lark_stream.py new file mode 100644 index 00000000000..416fc561859 --- /dev/null +++ b/api/tests/unit_tests/core/human_input_v2/im_provider/test_feishu_lark_stream.py @@ -0,0 +1,1317 @@ +from __future__ import annotations + +import asyncio +import json +import threading +from collections.abc import Callable, Mapping +from dataclasses import fields +from datetime import datetime +from typing import override + +import pytest +from lark_oapi.core.exception import EventException +from lark_oapi.event.callback.model.p2_card_action_trigger import P2CardActionTrigger + +from core.human_input_v2.entities import IMProvider +from core.human_input_v2.im_integration.adapters import ( + AuthenticatedIMEvent, + EventAcceptance, + IMEventIngressKind, + IMStreamStartError, + IMStreamStopError, +) +from core.human_input_v2.im_integration.adapters import feishu_lark as adapter_module +from core.human_input_v2.im_integration.adapters.credentials import FeishuCredentials, LarkCredentials +from core.human_input_v2.im_integration.adapters.feishu_lark import ( + FeishuIMProviderAdapter, + LarkIMProviderAdapter, +) + +_LEGACY_STREAM_PAYLOAD_KEY = "__dify_feishu_lark.stream" +_CARD_ACTION_TRIGGER_OBJECT_TYPE = "lark_oapi.event.callback.model.p2_card_action_trigger.P2CardActionTrigger" + + +def test_sdk_event_envelope_contains_only_delivery_data() -> None: + assert {field.name for field in fields(adapter_module._SDKEventEnvelope)} == { + "native_payload", + "provider_tenant_id", + "event_id", + "event_type", + "occurred_at", + } + + +class _UnusedGateway: + def query_tenant(self) -> Mapping[str, object]: + raise AssertionError("unexpected gateway call") + + def list_scope(self, _page_token: str | None) -> Mapping[str, object]: + raise AssertionError("unexpected gateway call") + + def list_departments(self, _department_id: str, _page_token: str | None) -> Mapping[str, object]: + raise AssertionError("unexpected gateway call") + + def list_users(self, _department_id: str, _page_token: str | None) -> Mapping[str, object]: + raise AssertionError("unexpected gateway call") + + def create_message(self, _receive_id: str, _msg_type: str, _content: str) -> Mapping[str, object]: + raise AssertionError("unexpected gateway call") + + def patch_message(self, _message_id: str, _content: str) -> Mapping[str, object]: + raise AssertionError("unexpected gateway call") + + +class _RecordingConsumer: + def __init__( + self, + trace: list[str], + acceptance: EventAcceptance = EventAcceptance.ACCEPTED, + ) -> None: + self._trace = trace + self._acceptance = acceptance + self.events: list[AuthenticatedIMEvent] = [] + + def accept(self, event: AuthenticatedIMEvent, *, request_ip_address: str | None = None) -> EventAcceptance: + del request_ip_address + self._trace.append("consumer") + self.events.append(event) + return self._acceptance + + +class _BlockingConsumer: + def __init__(self, trace: list[str]) -> None: + self._trace = trace + self.entered = threading.Event() + self.release = threading.Event() + self.events: list[AuthenticatedIMEvent] = [] + + def accept(self, event: AuthenticatedIMEvent, *, request_ip_address: str | None = None) -> EventAcceptance: + del request_ip_address + self._trace.append("consumer-start") + self.events.append(event) + self.entered.set() + assert self.release.wait(timeout=2) + self._trace.append("consumer-end") + return EventAcceptance.ACCEPTED + + +class _FailingConsumer: + def __init__(self) -> None: + self.attempts = 0 + + def accept(self, event: AuthenticatedIMEvent, *, request_ip_address: str | None = None) -> EventAcceptance: + del request_ip_address + del event + self.attempts += 1 + raise RuntimeError("sensitive consumer details") + + +class _FakeStreamClient: + def __init__( + self, + callback: Callable[[adapter_module._SDKEventEnvelope, Callable[[], None]], None], + trace: list[str], + *, + start_error: Exception | None = None, + ) -> None: + self._callback = callback + self._trace = trace + self._start_error = start_error + self.start_calls = 0 + self.stop_calls = 0 + + def start(self) -> None: + self.start_calls += 1 + self._trace.append("sdk-start") + if self._start_error is not None: + raise self._start_error + self._trace.append("sdk-ready") + + def stop(self) -> None: + self.stop_calls += 1 + self._trace.append("sdk-stop") + + def emit(self, event: Mapping[str, object], *, ack_error: Exception | None = None) -> None: + def ack() -> None: + self._trace.append("ack") + if ack_error is not None: + raise ack_error + + self._callback(_sdk_transport_envelope(event), ack) + + +class _DispatcherStreamClient: + def __init__( + self, + credentials: FeishuCredentials | LarkCredentials, + domain: str, + callback: Callable[[adapter_module._SDKEventEnvelope, Callable[[], None]], None], + payload: bytes, + ) -> None: + self._channel = adapter_module._SynchronousEventChannel( + credentials=credentials, + domain=domain, + callback=callback, + ) + self._payload = payload + self.dispatch_returned = False + + def start(self) -> None: + self._channel._build_dispatcher()._do_without_validation(self._payload) + self.dispatch_returned = True + + def stop(self) -> None: + return None + + +class _BlockingCloseStreamClient(_FakeStreamClient): + def __init__( + self, + callback: Callable[[adapter_module._SDKEventEnvelope, Callable[[], None]], None], + trace: list[str], + ) -> None: + super().__init__(callback, trace) + self.close_started = threading.Event() + self.release_close = threading.Event() + + @override + def stop(self) -> None: + self.stop_calls += 1 + self._trace.append("sdk-stop-start") + self.close_started.set() + assert self.release_close.wait(timeout=2) + self._trace.append("sdk-stop-end") + + +class _BlockingFailingCloseStreamClient(_BlockingCloseStreamClient): + def __init__( + self, + callback: Callable[[adapter_module._SDKEventEnvelope, Callable[[], None]], None], + trace: list[str], + sensitive_marker: str, + ) -> None: + super().__init__(callback, trace) + self._sensitive_marker = sensitive_marker + + @override + def stop(self) -> None: + self.stop_calls += 1 + self._trace.append("sdk-stop-start") + self.close_started.set() + assert self.release_close.wait(timeout=2) + self._trace.append("sdk-stop-failed") + raise RuntimeError(self._sensitive_marker) + + +class _BlockingStartStreamClient(_FakeStreamClient): + def __init__( + self, + callback: Callable[[adapter_module._SDKEventEnvelope, Callable[[], None]], None], + trace: list[str], + ) -> None: + super().__init__(callback, trace) + self.start_entered = threading.Event() + self.cancel_start = threading.Event() + + @override + def start(self) -> None: + self.start_calls += 1 + self._trace.append("sdk-start") + self.start_entered.set() + assert self.cancel_start.wait(timeout=2) + raise RuntimeError("sensitive cancelled start details") + + @override + def stop(self) -> None: + self.stop_calls += 1 + self._trace.append("sdk-stop") + self.cancel_start.set() + + +class _DeferredWireAckStreamClient(_FakeStreamClient): + """Model the SDK boundary where ACK bytes are written after dispatcher return.""" + + def __init__( + self, + callback: Callable[[adapter_module._SDKEventEnvelope, Callable[[], None]], None], + trace: list[str], + ) -> None: + super().__init__(callback, trace) + self.callback_returned = threading.Event() + self.release_ack_write = threading.Event() + self.ack_written = threading.Event() + + @override + def emit(self, event: Mapping[str, object], *, ack_error: Exception | None = None) -> None: + del ack_error + acknowledged = False + + def decide_ack() -> None: + nonlocal acknowledged + acknowledged = True + self._trace.append("ack-decided") + + self._callback(_sdk_transport_envelope(event), decide_ack) + self.callback_returned.set() + assert self.release_ack_write.wait(timeout=2) + if acknowledged: + self._trace.append("ack-written") + self.ack_written.set() + + @override + def stop(self) -> None: + self.stop_calls += 1 + assert self.ack_written.wait(timeout=2) + self._trace.append("sdk-stop") + + +def _event_payload(*, create_time: str = "1785981600000") -> Mapping[str, object]: + return { + "schema": "2.0", + "header": { + "event_id": "evt_sanitized_event", + "event_type": "card.action.trigger", + "create_time": create_time, + "tenant_key": "tenant_sanitized", + }, + "event": { + "action": { + "name": "approve", + "value": { + "action_id": "approve", + "correlation_token": "opaque-correlation-token", + }, + }, + "preserved": [1, None, True], + }, + } + + +def _message_event_payload() -> Mapping[str, object]: + return { + "schema": "2.0", + "header": { + "event_id": "evt_sanitized_message", + "event_type": "im.message.receive_v1", + "create_time": "1785981600000", + "tenant_key": "tenant_sanitized", + }, + "event": { + "sender": { + "sender_id": {"union_id": "union_sanitized"}, + "sender_type": "user", + "tenant_key": "tenant_sanitized", + }, + "message": { + "message_id": "message_sanitized", + "message_type": "text", + "content": '{"text":"sanitized"}', + }, + }, + } + + +def _sdk_transport_envelope(event: Mapping[str, object]) -> adapter_module._SDKEventEnvelope: + header = event["header"] + assert isinstance(header, Mapping) + event_id = header["event_id"] + event_type = header["event_type"] + create_time = header["create_time"] + tenant_key = header["tenant_key"] + assert isinstance(event_id, str) + assert isinstance(event_type, str) + assert isinstance(create_time, str) + assert isinstance(tenant_key, str) + return adapter_module._SDKEventEnvelope( + native_payload=json.dumps(event, ensure_ascii=False, separators=(",", ":")), + provider_tenant_id=tenant_key, + event_id=event_id, + event_type=event_type, + occurred_at=adapter_module._webhook_occurred_at(create_time), + ) + + +def _credentials(provider: IMProvider) -> FeishuCredentials | LarkCredentials: + values = { + "provider": provider, + "app_id": "cli_sanitized_app", + "app_secret": "sanitized-app-secret", + "verification_token": "sanitized-verification-token", + "encrypt_key": "sanitized-encrypt-key", + } + if provider is IMProvider.FEISHU: + return FeishuCredentials.model_validate(values) + return LarkCredentials.model_validate(values) + + +def _adapter( + monkeypatch: pytest.MonkeyPatch, + provider: IMProvider, +) -> FeishuIMProviderAdapter | LarkIMProviderAdapter: + monkeypatch.setattr(adapter_module, "_create_sdk_gateway", lambda _credentials, _domain: _UnusedGateway()) + values = { + "provider": provider, + "app_id": "cli_sanitized_app", + "app_secret": "sanitized-app-secret", + "verification_token": "sanitized-verification-token", + "encrypt_key": "sanitized-encrypt-key", + } + if provider is IMProvider.FEISHU: + return FeishuIMProviderAdapter(FeishuCredentials.model_validate(values)) + return LarkIMProviderAdapter(LarkCredentials.model_validate(values)) + + +@pytest.mark.parametrize( + ("provider", "domain"), + [(IMProvider.FEISHU, "https://open.feishu.cn"), (IMProvider.LARK, "https://open.larksuite.com")], +) +def test_per_client_sdk_transport_owns_and_closes_its_loop( + monkeypatch: pytest.MonkeyPatch, provider: IMProvider, domain: str +) -> None: + from lark_oapi.ws import client as sdk_ws_client_module + + transport_connected = threading.Event() + + class WebSocketConnection: + def __init__(self) -> None: + self.closed = asyncio.Event() + + async def recv(self) -> bytes: + await self.closed.wait() + raise RuntimeError("sanitized connection closed") + + async def close(self) -> None: + self.closed.set() + + async def send(self, _data: bytes) -> None: + return + + async def connect(_url: str, **_kwargs: object) -> WebSocketConnection: + transport_connected.set() + return WebSocketConnection() + + monkeypatch.setattr( + sdk_ws_client_module.Client, + "_get_conn_url", + lambda _self: "wss://sanitized.invalid/connect?device_id=device-sanitized&service_id=1", + ) + monkeypatch.setattr(sdk_ws_client_module.websockets, "connect", connect) + client = adapter_module._PerClientSDKWSClient( + credentials=_credentials(provider), + domain=domain, + event_handler=adapter_module.lark.EventDispatcherHandler.builder("", "").build(), + ) + start_errors: list[Exception] = [] + + def start_client() -> None: + try: + client.start() + except Exception as exc: + start_errors.append(exc) + + start_thread = threading.Thread(target=start_client) + start_thread.start() + assert transport_connected.wait(timeout=2) + + client.stop() + start_thread.join(timeout=2) + + assert not start_thread.is_alive() + assert start_errors == [] + assert client._dify_loop.is_closed() + + +@pytest.mark.parametrize("provider", [IMProvider.FEISHU, IMProvider.LARK]) +def test_factory_is_lazy_and_start_returns_only_after_sdk_ready( + monkeypatch: pytest.MonkeyPatch, + provider: IMProvider, +) -> None: + trace: list[str] = [] + clients: list[_FakeStreamClient] = [] + + def factory( + _credentials: FeishuCredentials | LarkCredentials, + _domain: str, + callback: Callable[[adapter_module._SDKEventEnvelope, Callable[[], None]], None], + ) -> _FakeStreamClient: + client = _FakeStreamClient(callback, trace) + clients.append(client) + return client + + monkeypatch.setattr(adapter_module, "_create_sdk_stream_client", factory) + stream = _adapter(monkeypatch, provider).create_stream_handler(_RecordingConsumer(trace)) + assert clients == [] + + stream.start() + + assert trace == ["sdk-start", "sdk-ready"] + assert len(clients) == 1 + with pytest.raises(IMStreamStartError): + stream.start() + assert len(clients) == 1 + stream.stop() + + +@pytest.mark.parametrize( + ("provider", "domain"), + [(IMProvider.FEISHU, "https://open.feishu.cn"), (IMProvider.LARK, "https://open.larksuite.com")], +) +def test_official_stream_client_start_returns_after_channel_readiness( + provider: IMProvider, + domain: str, + monkeypatch: pytest.MonkeyPatch, +) -> None: + class LoopBoundReadyChannel: + def __init__(self, **_kwargs: object) -> None: + self.ready = threading.Event() + self.transport_started = threading.Event() + self.release_transport = threading.Event() + channels.append(self) + + async def connect_until_ready(self, *, timeout: float) -> None: + del timeout + loop = asyncio.get_running_loop() + loop.run_in_executor(None, self._run_transport) + assert self.transport_started.wait(timeout=2) + self.ready.set() + + def _run_transport(self) -> None: + self.transport_started.set() + assert self.release_transport.wait(timeout=2) + + def stop(self) -> None: + self.release_transport.set() + + def wait_for_pending_wire_acks(self) -> None: + return + + channels: list[LoopBoundReadyChannel] = [] + monkeypatch.setattr(adapter_module, "_SynchronousEventChannel", LoopBoundReadyChannel) + client = adapter_module._OfficialSDKStreamClient( + _credentials(provider), + domain, + lambda _event, _acknowledge: None, + ) + channel = channels[0] + returned = threading.Event() + start_thread = threading.Thread(target=lambda: (client.start(), returned.set())) + start_thread.start() + assert channel.ready.wait(timeout=2) + + try: + assert returned.wait(timeout=0.2), "start() remained blocked after the SDK reported readiness" + finally: + client.stop() + start_thread.join(timeout=2) + + assert not start_thread.is_alive() + + +@pytest.mark.parametrize( + ("provider", "domain"), + [(IMProvider.FEISHU, "https://open.feishu.cn"), (IMProvider.LARK, "https://open.larksuite.com")], +) +def test_private_sdk_dispatcher_seam_keeps_consumer_acceptance_as_ack_boundary( + provider: IMProvider, domain: str +) -> None: + observed: list[adapter_module._SDKEventEnvelope] = [] + credentials = _credentials(provider) + + def accept(event: adapter_module._SDKEventEnvelope, acknowledge: Callable[[], None]) -> None: + observed.append(event) + acknowledge() + + channel = adapter_module._SynchronousEventChannel( + credentials=credentials, + domain=domain, + callback=accept, + ) + dispatcher = channel._build_dispatcher() + payload = json.dumps(_event_payload(), separators=(",", ":")).encode() + + response = dispatcher._do_without_validation(payload) + + assert len(observed) == 1 + observed_envelope = observed[0] + native_payload = observed_envelope.native_payload + assert json.loads(native_payload) == { + "schema": "2.0", + "header": { + "event_id": "evt_sanitized_event", + "create_time": "1785981600000", + "event_type": "card.action.trigger", + "tenant_key": "tenant_sanitized", + }, + "event": { + "action": { + "name": "approve", + "value": { + "action_id": "approve", + "correlation_token": "opaque-correlation-token", + }, + } + }, + } + assert response is not None + + rejecting_channel = adapter_module._SynchronousEventChannel( + credentials=credentials, + domain=domain, + callback=lambda _event, _acknowledge: None, + ) + with pytest.raises(RuntimeError, match="responsibility was not accepted"): + rejecting_channel._build_dispatcher()._do_without_validation(payload) + + +def test_sdk_event_envelope_preserves_native_payload_without_parsing( + monkeypatch: pytest.MonkeyPatch, +) -> None: + native_payload = ' { "schema": "2.0", "schema": "2.0" } ' + sdk_event = P2CardActionTrigger(_event_payload()) + + monkeypatch.setattr(adapter_module.lark.JSON, "marshal", lambda _event: native_payload) + + def fail_on_parse(_serialized: str) -> object: + raise AssertionError("transport wrapper parsed the SDK native payload") + + monkeypatch.setattr(adapter_module.json, "loads", fail_on_parse) + + assert adapter_module._sdk_event_envelope(sdk_event) == adapter_module._SDKEventEnvelope( + native_payload=native_payload, + provider_tenant_id="tenant_sanitized", + event_id="evt_sanitized_event", + event_type="card.action.trigger", + occurred_at=datetime(2026, 8, 6, 2, 0), + ) + + +@pytest.mark.parametrize("provider", [IMProvider.FEISHU, IMProvider.LARK]) +def test_card_dispatcher_preserves_native_payload_without_event_stream_parsing( + provider: IMProvider, + monkeypatch: pytest.MonkeyPatch, +) -> None: + payload_mapping = _event_payload() + payload = json.dumps(payload_mapping, ensure_ascii=False, separators=(",", ":")).encode() + expected_native_payload = adapter_module.lark.JSON.marshal(P2CardActionTrigger(payload_mapping)) + assert isinstance(expected_native_payload, str) + clients: list[_DispatcherStreamClient] = [] + + def create_client( + credentials: FeishuCredentials | LarkCredentials, + domain: str, + callback: Callable[[adapter_module._SDKEventEnvelope, Callable[[], None]], None], + ) -> _DispatcherStreamClient: + client = _DispatcherStreamClient(credentials, domain, callback, payload) + clients.append(client) + return client + + monkeypatch.setattr(adapter_module, "_create_sdk_stream_client", create_client) + + def fail_on_native_payload_parse(_payload: bytes) -> dict[str, object]: + raise AssertionError("event stream parsed the SDK native payload") + + monkeypatch.setattr(adapter_module, "_decode_json_object", fail_on_native_payload_parse) + consumer = _RecordingConsumer([]) + adapter = _adapter(monkeypatch, provider) + stream = adapter.create_stream_handler(consumer) + + try: + stream.start() + stream.stop() + finally: + adapter.close() + + assert clients[0].dispatch_returned is True + assert len(consumer.events) == 1 + event = consumer.events[0] + assert event.ingress_kind is IMEventIngressKind.STREAM + assert event.payload == expected_native_payload + assert _LEGACY_STREAM_PAYLOAD_KEY not in event.payload + assert _CARD_ACTION_TRIGGER_OBJECT_TYPE not in event.payload + + +@pytest.mark.parametrize( + ("provider", "domain"), + [(IMProvider.FEISHU, "https://open.feishu.cn"), (IMProvider.LARK, "https://open.larksuite.com")], +) +def test_message_dispatcher_rejects_unregistered_message_event(provider: IMProvider, domain: str) -> None: + observed: list[adapter_module._SDKEventEnvelope] = [] + credentials = _credentials(provider) + + def accept(event: adapter_module._SDKEventEnvelope, acknowledge: Callable[[], None]) -> None: + observed.append(event) + acknowledge() + + channel = adapter_module._SynchronousEventChannel( + credentials=credentials, + domain=domain, + callback=accept, + ) + payload = json.dumps(_message_event_payload(), ensure_ascii=False, separators=(",", ":")).encode() + + with pytest.raises(EventException) as exc_info: + channel._build_dispatcher()._do_without_validation(payload) + + assert exc_info.value.msg == "processor not found, type: im.message.receive_v1" + assert observed == [] + + +@pytest.mark.parametrize( + ("provider", "domain"), + [(IMProvider.FEISHU, "https://open.feishu.cn"), (IMProvider.LARK, "https://open.larksuite.com")], +) +def test_private_sdk_write_seam_tracks_wire_ack_completion(provider: IMProvider, domain: str) -> None: + credentials = _credentials(provider) + channel = adapter_module._SynchronousEventChannel( + credentials=credentials, + domain=domain, + callback=lambda _event, acknowledge: acknowledge(), + ) + + class DeferredWriteClient: + def __init__(self) -> None: + self.write_started = threading.Event() + self.release_write: asyncio.Event | None = None + + async def _write_message(self, _data: bytes) -> None: + self.write_started.set() + assert self.release_write is not None + await self.release_write.wait() + + client = DeferredWriteClient() + + async def exercise() -> None: + client.release_write = asyncio.Event() + channel._wire_ack_tracking_enabled = True + channel._install_wire_ack_hook(client) + payload = json.dumps(_event_payload(), separators=(",", ":")).encode() + response = channel._build_dispatcher()._do_without_validation(payload) + assert response is not None + write_task = asyncio.create_task(client._write_message(b"wire-ack")) + await asyncio.sleep(0) + assert client.write_started.is_set() + + drain_returned = threading.Event() + drain_thread = threading.Thread(target=lambda: (channel.wait_for_pending_wire_acks(), drain_returned.set())) + drain_thread.start() + assert not drain_returned.wait(timeout=0.2) + + client.release_write.set() + await write_task + drain_thread.join(timeout=2) + assert drain_returned.is_set() + + asyncio.run(exercise()) + + +@pytest.mark.parametrize("provider", [IMProvider.FEISHU, IMProvider.LARK]) +def test_accepted_event_is_consumed_once_then_acked(monkeypatch: pytest.MonkeyPatch, provider: IMProvider) -> None: + trace: list[str] = [] + clients: list[_FakeStreamClient] = [] + monkeypatch.setattr( + adapter_module, + "_create_sdk_stream_client", + lambda _credentials, _domain, callback: clients.append(_FakeStreamClient(callback, trace)) or clients[-1], + ) + consumer = _RecordingConsumer(trace) + stream = _adapter(monkeypatch, provider).create_stream_handler(consumer) + stream.start() + + clients[0].emit(_event_payload()) + + assert trace[-2:] == ["consumer", "ack"] + assert len(consumer.events) == 1 + assert consumer.events[0].provider is provider + assert consumer.events[0].provider_tenant_id == "tenant_sanitized" + assert consumer.events[0].ingress_kind is IMEventIngressKind.STREAM + persisted_callback = json.loads(consumer.events[0].payload) + assert persisted_callback["event"]["preserved"] == [1, None, True] + assert _CARD_ACTION_TRIGGER_OBJECT_TYPE not in consumer.events[0].payload + stream.stop() + + +@pytest.mark.parametrize( + ("create_time", "expected"), + [ + ("1704067200123456", datetime(2024, 1, 1, 0, 0, 0, 123456)), + ("1704067200123", datetime(2024, 1, 1, 0, 0, 0, 123000)), + ("not-a-timestamp", None), + ("9" * 64, None), + ], + ids=("microseconds", "milliseconds", "malformed", "overflow"), +) +@pytest.mark.parametrize("provider", [IMProvider.FEISHU, IMProvider.LARK]) +def test_stream_event_maps_confirmed_timestamp_units_safely( + provider: IMProvider, + monkeypatch: pytest.MonkeyPatch, + create_time: str, + expected: datetime | None, +) -> None: + trace: list[str] = [] + clients: list[_FakeStreamClient] = [] + monkeypatch.setattr( + adapter_module, + "_create_sdk_stream_client", + lambda _credentials, _domain, callback: clients.append(_FakeStreamClient(callback, trace)) or clients[-1], + ) + consumer = _RecordingConsumer(trace) + stream = _adapter(monkeypatch, provider).create_stream_handler(consumer) + stream.start() + + clients[0].emit(_event_payload(create_time=create_time)) + stream.stop() + + assert len(consumer.events) == 1 + assert consumer.events[0].occurred_at == expected + + +@pytest.mark.parametrize( + "acceptance", + [EventAcceptance.NOT_ACCEPTED], +) +@pytest.mark.parametrize("provider", [IMProvider.FEISHU, IMProvider.LARK]) +def test_unaccepted_event_is_not_acked_or_replayed( + provider: IMProvider, + monkeypatch: pytest.MonkeyPatch, + acceptance: EventAcceptance, +) -> None: + trace: list[str] = [] + clients: list[_FakeStreamClient] = [] + monkeypatch.setattr( + adapter_module, + "_create_sdk_stream_client", + lambda _credentials, _domain, callback: clients.append(_FakeStreamClient(callback, trace)) or clients[-1], + ) + consumer = _RecordingConsumer(trace, acceptance) + stream = _adapter(monkeypatch, provider).create_stream_handler(consumer) + stream.start() + + clients[0].emit(_event_payload()) + + assert trace.count("consumer") == 1 + assert "ack" not in trace + stream.stop() + + +@pytest.mark.parametrize("provider", [IMProvider.FEISHU, IMProvider.LARK]) +def test_consumer_exception_is_contained_without_ack_or_replay( + monkeypatch: pytest.MonkeyPatch, provider: IMProvider +) -> None: + trace: list[str] = [] + clients: list[_FakeStreamClient] = [] + monkeypatch.setattr( + adapter_module, + "_create_sdk_stream_client", + lambda _credentials, _domain, callback: clients.append(_FakeStreamClient(callback, trace)) or clients[-1], + ) + consumer = _FailingConsumer() + stream = _adapter(monkeypatch, provider).create_stream_handler(consumer) + stream.start() + + clients[0].emit(_event_payload()) + stream.stop() + + assert consumer.attempts == 1 + assert "ack" not in trace + assert clients[0].stop_calls == 1 + + +@pytest.mark.parametrize("provider", [IMProvider.FEISHU, IMProvider.LARK]) +def test_stop_drains_consumer_and_ack_before_sdk_close(monkeypatch: pytest.MonkeyPatch, provider: IMProvider) -> None: + trace: list[str] = [] + clients: list[_FakeStreamClient] = [] + monkeypatch.setattr( + adapter_module, + "_create_sdk_stream_client", + lambda _credentials, _domain, callback: clients.append(_FakeStreamClient(callback, trace)) or clients[-1], + ) + consumer = _BlockingConsumer(trace) + stream = _adapter(monkeypatch, provider).create_stream_handler(consumer) + stream.start() + callback_thread = threading.Thread(target=clients[0].emit, args=(_event_payload(),)) + callback_thread.start() + assert consumer.entered.wait(timeout=2) + + def stop_stream() -> None: + stream.stop() + trace.append("stop-return") + + stop_thread = threading.Thread(target=stop_stream) + stop_thread.start() + assert "sdk-stop" not in trace + consumer.release.set() + callback_thread.join(timeout=2) + stop_thread.join(timeout=2) + + assert not callback_thread.is_alive() + assert not stop_thread.is_alive() + assert trace[-5:] == ["consumer-start", "consumer-end", "ack", "sdk-stop", "stop-return"] + + +@pytest.mark.parametrize("provider", [IMProvider.FEISHU, IMProvider.LARK]) +def test_stop_waits_for_sdk_wire_ack_after_dispatcher_callback_returns( + monkeypatch: pytest.MonkeyPatch, provider: IMProvider +) -> None: + trace: list[str] = [] + clients: list[_DeferredWireAckStreamClient] = [] + monkeypatch.setattr( + adapter_module, + "_create_sdk_stream_client", + lambda _credentials, _domain, callback: ( + clients.append(_DeferredWireAckStreamClient(callback, trace)) or clients[-1] + ), + ) + stream = _adapter(monkeypatch, provider).create_stream_handler(_RecordingConsumer(trace)) + stream.start() + emit_thread = threading.Thread(target=clients[0].emit, args=(_event_payload(),)) + emit_thread.start() + assert clients[0].callback_returned.wait(timeout=2) + stop_returned = threading.Event() + stop_thread = threading.Thread(target=lambda: (stream.stop(), trace.append("stop-return"), stop_returned.set())) + stop_thread.start() + + try: + assert not stop_returned.wait(timeout=0.2), "stop() returned before the SDK wrote the accepted ACK" + finally: + clients[0].release_ack_write.set() + emit_thread.join(timeout=2) + stop_thread.join(timeout=2) + + assert clients[0].ack_written.is_set() + assert trace.index("ack-written") < trace.index("sdk-stop") < trace.index("stop-return") + + +@pytest.mark.parametrize("provider", [IMProvider.FEISHU, IMProvider.LARK]) +def test_callback_after_stop_never_consumes_or_acks(monkeypatch: pytest.MonkeyPatch, provider: IMProvider) -> None: + trace: list[str] = [] + clients: list[_FakeStreamClient] = [] + monkeypatch.setattr( + adapter_module, + "_create_sdk_stream_client", + lambda _credentials, _domain, callback: clients.append(_FakeStreamClient(callback, trace)) or clients[-1], + ) + consumer = _RecordingConsumer(trace) + stream = _adapter(monkeypatch, provider).create_stream_handler(consumer) + stream.start() + stream.stop() + + clients[0].emit(_event_payload()) + + assert consumer.events == [] + assert "ack" not in trace + assert clients[0].stop_calls == 1 + + +@pytest.mark.parametrize("provider", [IMProvider.FEISHU, IMProvider.LARK]) +def test_concurrent_repeated_stop_waits_for_the_single_sdk_close( + monkeypatch: pytest.MonkeyPatch, provider: IMProvider +) -> None: + trace: list[str] = [] + clients: list[_BlockingCloseStreamClient] = [] + monkeypatch.setattr( + adapter_module, + "_create_sdk_stream_client", + lambda _credentials, _domain, callback: ( + clients.append(_BlockingCloseStreamClient(callback, trace)) or clients[-1] + ), + ) + stream = _adapter(monkeypatch, provider).create_stream_handler(_RecordingConsumer(trace)) + stream.start() + first_returned = threading.Event() + second_returned = threading.Event() + + first_stop = threading.Thread(target=lambda: (stream.stop(), first_returned.set())) + first_stop.start() + assert clients[0].close_started.wait(timeout=2) + + second_stop = threading.Thread(target=lambda: (stream.stop(), second_returned.set())) + second_stop.start() + assert not second_returned.wait(timeout=0.1) + + clients[0].release_close.set() + first_stop.join(timeout=2) + second_stop.join(timeout=2) + + assert not first_stop.is_alive() + assert not second_stop.is_alive() + assert first_returned.is_set() + assert second_returned.is_set() + assert clients[0].stop_calls == 1 + assert trace[-2:] == ["sdk-stop-start", "sdk-stop-end"] + + stream.stop() + + assert clients[0].stop_calls == 1 + + +@pytest.mark.parametrize("provider", [IMProvider.FEISHU, IMProvider.LARK]) +def test_stop_surfaces_operator_safe_error_when_sdk_close_fails( + monkeypatch: pytest.MonkeyPatch, provider: IMProvider +) -> None: + sensitive_marker = "sensitive SDK close details" + + class CloseFailingStreamClient(_FakeStreamClient): + @override + def stop(self) -> None: + self.stop_calls += 1 + raise RuntimeError(sensitive_marker) + + trace: list[str] = [] + clients: list[CloseFailingStreamClient] = [] + monkeypatch.setattr( + adapter_module, + "_create_sdk_stream_client", + lambda _credentials, _domain, callback: ( + clients.append(CloseFailingStreamClient(callback, trace)) or clients[-1] + ), + ) + stream = _adapter(monkeypatch, provider).create_stream_handler(_RecordingConsumer(trace)) + stream.start() + + with pytest.raises(IMStreamStopError) as stop_error: + stream.stop() + + assert sensitive_marker not in str(stop_error.value) + assert clients[0].stop_calls == 1 + + +@pytest.mark.parametrize("provider", [IMProvider.FEISHU, IMProvider.LARK]) +def test_failed_close_propagates_to_concurrent_and_repeated_stop_calls( + monkeypatch: pytest.MonkeyPatch, provider: IMProvider +) -> None: + sensitive_marker = "sensitive concurrent SDK close details" + trace: list[str] = [] + clients: list[_BlockingFailingCloseStreamClient] = [] + monkeypatch.setattr( + adapter_module, + "_create_sdk_stream_client", + lambda _credentials, _domain, callback: ( + clients.append(_BlockingFailingCloseStreamClient(callback, trace, sensitive_marker)) or clients[-1] + ), + ) + stream = _adapter(monkeypatch, provider).create_stream_handler(_RecordingConsumer(trace)) + stream.start() + errors: list[IMStreamStopError | None] = [None, None] + returned = [threading.Event(), threading.Event()] + + def stop_stream(index: int) -> None: + try: + stream.stop() + except IMStreamStopError as error: + errors[index] = error + finally: + returned[index].set() + + first_stop = threading.Thread(target=stop_stream, args=(0,)) + first_stop.start() + assert clients[0].close_started.wait(timeout=2) + + second_stop = threading.Thread(target=stop_stream, args=(1,)) + second_stop.start() + assert not returned[1].wait(timeout=0.1) + + clients[0].release_close.set() + first_stop.join(timeout=2) + second_stop.join(timeout=2) + + assert not first_stop.is_alive() + assert not second_stop.is_alive() + safe_errors = [error for error in errors if error is not None] + assert len(safe_errors) == 2 + assert len({str(error) for error in safe_errors}) == 1 + assert all(sensitive_marker not in str(error) for error in safe_errors) + + with pytest.raises(IMStreamStopError) as repeated_error: + stream.stop() + + assert str(repeated_error.value) == str(safe_errors[0]) + assert sensitive_marker not in str(repeated_error.value) + assert clients[0].stop_calls == 1 + assert trace[-2:] == ["sdk-stop-start", "sdk-stop-failed"] + + +@pytest.mark.parametrize("provider", [IMProvider.FEISHU, IMProvider.LARK]) +def test_stop_during_start_closes_client_and_both_calls_finish( + monkeypatch: pytest.MonkeyPatch, provider: IMProvider +) -> None: + trace: list[str] = [] + clients: list[_BlockingStartStreamClient] = [] + monkeypatch.setattr( + adapter_module, + "_create_sdk_stream_client", + lambda _credentials, _domain, callback: ( + clients.append(_BlockingStartStreamClient(callback, trace)) or clients[-1] + ), + ) + stream = _adapter(monkeypatch, provider).create_stream_handler(_RecordingConsumer(trace)) + start_errors: list[IMStreamStartError] = [] + + def start_stream() -> None: + try: + stream.start() + except IMStreamStartError as error: + start_errors.append(error) + + start_thread = threading.Thread(target=start_stream) + start_thread.start() + assert clients[0].start_entered.wait(timeout=2) + stop_thread = threading.Thread(target=stream.stop) + stop_thread.start() + start_thread.join(timeout=2) + stop_thread.join(timeout=2) + + assert not start_thread.is_alive() + assert not stop_thread.is_alive() + assert len(start_errors) == 1 + assert "sensitive cancelled start details" not in str(start_errors[0]) + assert clients[0].stop_calls == 1 + assert trace == ["sdk-start", "sdk-stop"] + + +@pytest.mark.parametrize("provider", [IMProvider.FEISHU, IMProvider.LARK]) +def test_stop_waits_when_failed_start_claims_a_slow_sdk_close( + monkeypatch: pytest.MonkeyPatch, provider: IMProvider +) -> None: + trace: list[str] = [] + factory_entered = threading.Event() + release_factory = threading.Event() + close_started = threading.Event() + release_close = threading.Event() + stop_waiting_for_client = threading.Event() + + class SlowCloseClient: + def start(self) -> None: + raise AssertionError("start must be cancelled before the client is invoked") + + def stop(self) -> None: + trace.append("sdk-close-start") + close_started.set() + assert release_close.wait(timeout=2) + trace.append("sdk-close-end") + + def factory( + _credentials: FeishuCredentials | LarkCredentials, + _domain: str, + _callback: Callable[[adapter_module._SDKEventEnvelope, Callable[[], None]], None], + ) -> SlowCloseClient: + trace.append("factory-enter") + factory_entered.set() + assert release_factory.wait(timeout=2) + trace.append("factory-return") + return SlowCloseClient() + + class ResumeStopAfterCloseClaim(threading.Condition): + def __init__(self) -> None: + super().__init__() + self.blocked_thread: threading.Thread | None = None + self._blocked_once = False + + @override + def wait(self, timeout: float | None = None) -> bool: + if threading.current_thread() is self.blocked_thread and not self._blocked_once: + stop_waiting_for_client.set() + result = super().wait(timeout) + if threading.current_thread() is self.blocked_thread and not self._blocked_once: + self._blocked_once = True + self.release() + try: + assert close_started.wait(timeout=2) + finally: + self.acquire() + return result + + monkeypatch.setattr(adapter_module, "_create_sdk_stream_client", factory) + stream = _adapter(monkeypatch, provider).create_stream_handler(_RecordingConsumer(trace)) + condition = ResumeStopAfterCloseClaim() + stream._condition = condition + start_errors: list[IMStreamStartError] = [] + stop_returned = threading.Event() + + def start_stream() -> None: + try: + stream.start() + except IMStreamStartError as error: + start_errors.append(error) + trace.append("start-error") + + def stop_stream() -> None: + stream.stop() + trace.append("stop-return") + stop_returned.set() + + start_thread = threading.Thread(target=start_stream) + start_thread.start() + assert factory_entered.wait(timeout=2) + stop_thread = threading.Thread(target=stop_stream) + condition.blocked_thread = stop_thread + stop_thread.start() + assert stop_waiting_for_client.wait(timeout=2) + release_factory.set() + assert close_started.wait(timeout=2) + + returned_before_close = stop_returned.wait(timeout=0.2) + release_close.set() + start_thread.join(timeout=2) + stop_thread.join(timeout=2) + + assert not start_thread.is_alive() + assert not stop_thread.is_alive() + assert len(start_errors) == 1 + assert not returned_before_close, f"stop() returned before SDK close completed: {trace}" + assert trace.index("sdk-close-end") < trace.index("stop-return") + + +@pytest.mark.parametrize("provider", [IMProvider.FEISHU, IMProvider.LARK]) +def test_stop_before_start_prevents_client_construction(monkeypatch: pytest.MonkeyPatch, provider: IMProvider) -> None: + trace: list[str] = [] + clients: list[_FakeStreamClient] = [] + monkeypatch.setattr( + adapter_module, + "_create_sdk_stream_client", + lambda _credentials, _domain, callback: clients.append(_FakeStreamClient(callback, trace)) or clients[-1], + ) + stream = _adapter(monkeypatch, provider).create_stream_handler(_RecordingConsumer(trace)) + + stream.stop() + with pytest.raises(IMStreamStartError): + stream.start() + + assert clients == [] + + +def test_stream_wrappers_have_equivalent_observable_lifecycle_traces(monkeypatch: pytest.MonkeyPatch) -> None: + traces: dict[str, list[str]] = { + "https://open.feishu.cn": [], + "https://open.larksuite.com": [], + } + clients: dict[str, _FakeStreamClient] = {} + + def factory( + _credentials: FeishuCredentials | LarkCredentials, + domain: str, + callback: Callable[[adapter_module._SDKEventEnvelope, Callable[[], None]], None], + ) -> _FakeStreamClient: + client = _FakeStreamClient(callback, traces[domain]) + clients[domain] = client + return client + + monkeypatch.setattr(adapter_module, "_create_sdk_stream_client", factory) + observed_providers: list[IMProvider] = [] + for provider, domain in ( + (IMProvider.FEISHU, "https://open.feishu.cn"), + (IMProvider.LARK, "https://open.larksuite.com"), + ): + consumer = _RecordingConsumer(traces[domain]) + stream = _adapter(monkeypatch, provider).create_stream_handler(consumer) + stream.start() + clients[domain].emit(_event_payload()) + stream.stop() + observed_providers.append(consumer.events[0].provider) + + assert ( + traces["https://open.feishu.cn"] + == traces["https://open.larksuite.com"] + == [ + "sdk-start", + "sdk-ready", + "consumer", + "ack", + "sdk-stop", + ] + ) + assert observed_providers == [IMProvider.FEISHU, IMProvider.LARK] + + +@pytest.mark.parametrize("provider", [IMProvider.FEISHU, IMProvider.LARK]) +def test_start_failure_closes_partial_client_and_is_operator_safe( + monkeypatch: pytest.MonkeyPatch, provider: IMProvider +) -> None: + trace: list[str] = [] + clients: list[_FakeStreamClient] = [] + sensitive_marker = "sensitive SDK credential details" + monkeypatch.setattr( + adapter_module, + "_create_sdk_stream_client", + lambda _credentials, _domain, callback: ( + clients.append(_FakeStreamClient(callback, trace, start_error=RuntimeError(sensitive_marker))) + or clients[-1] + ), + ) + stream = _adapter(monkeypatch, provider).create_stream_handler(_RecordingConsumer(trace)) + + with pytest.raises(IMStreamStartError) as stream_error: + stream.start() + + assert sensitive_marker not in str(stream_error.value) + assert clients[0].stop_calls == 1 + assert trace == ["sdk-start", "sdk-stop"] + with pytest.raises(IMStreamStartError): + stream.start() + assert len(clients) == 1 + + +@pytest.mark.parametrize("provider", [IMProvider.FEISHU, IMProvider.LARK]) +def test_ack_failure_is_contained_and_stop_still_closes(monkeypatch: pytest.MonkeyPatch, provider: IMProvider) -> None: + trace: list[str] = [] + clients: list[_FakeStreamClient] = [] + monkeypatch.setattr( + adapter_module, + "_create_sdk_stream_client", + lambda _credentials, _domain, callback: clients.append(_FakeStreamClient(callback, trace)) or clients[-1], + ) + consumer = _RecordingConsumer(trace) + stream = _adapter(monkeypatch, provider).create_stream_handler(consumer) + stream.start() + + clients[0].emit(_event_payload(), ack_error=RuntimeError("sensitive ACK details")) + stream.stop() + + assert len(consumer.events) == 1 + assert clients[0].stop_calls == 1 + + +@pytest.mark.parametrize("provider", [IMProvider.FEISHU, IMProvider.LARK]) +def test_root_close_does_not_stop_created_stream(monkeypatch: pytest.MonkeyPatch, provider: IMProvider) -> None: + trace: list[str] = [] + clients: list[_FakeStreamClient] = [] + monkeypatch.setattr( + adapter_module, + "_create_sdk_stream_client", + lambda _credentials, _domain, callback: clients.append(_FakeStreamClient(callback, trace)) or clients[-1], + ) + adapter = _adapter(monkeypatch, provider) + consumer = _RecordingConsumer(trace) + stream = adapter.create_stream_handler(consumer) + adapter.close() + + stream.start() + clients[0].emit(_event_payload()) + stream.stop() + + assert len(consumer.events) == 1 + + +@pytest.mark.parametrize("provider", [IMProvider.FEISHU, IMProvider.LARK]) +def test_two_streams_have_independent_lifecycles(monkeypatch: pytest.MonkeyPatch, provider: IMProvider) -> None: + trace: list[str] = [] + clients: list[_FakeStreamClient] = [] + monkeypatch.setattr( + adapter_module, + "_create_sdk_stream_client", + lambda _credentials, _domain, callback: clients.append(_FakeStreamClient(callback, trace)) or clients[-1], + ) + adapter = _adapter(monkeypatch, provider) + first_consumer = _RecordingConsumer(trace) + second_consumer = _RecordingConsumer(trace) + first = adapter.create_stream_handler(first_consumer) + second = adapter.create_stream_handler(second_consumer) + first.start() + second.start() + + first.stop() + clients[1].emit(_event_payload()) + second.stop() + + assert first_consumer.events == [] + assert len(second_consumer.events) == 1 + assert [client.stop_calls for client in clients] == [1, 1] diff --git a/api/tests/unit_tests/core/human_input_v2/im_provider/test_feishu_lark_url_challenge_contract.py b/api/tests/unit_tests/core/human_input_v2/im_provider/test_feishu_lark_url_challenge_contract.py new file mode 100644 index 00000000000..abb74a51bb3 --- /dev/null +++ b/api/tests/unit_tests/core/human_input_v2/im_provider/test_feishu_lark_url_challenge_contract.py @@ -0,0 +1,217 @@ +from __future__ import annotations + +import base64 +import hashlib +import json +from collections.abc import Mapping +from datetime import datetime + +import pytest +from cryptography.hazmat.primitives import padding +from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes + +from core.human_input_v2.entities import IMProvider +from core.human_input_v2.im_integration.adapters import ( + AuthenticatedIMEvent, + EventAcceptance, + IMWebhookHandler, + WebhookRequest, +) +from core.human_input_v2.im_integration.adapters import feishu_lark as adapter_module +from core.human_input_v2.im_integration.adapters.credentials import FeishuCredentials, LarkCredentials +from core.human_input_v2.im_integration.adapters.feishu_lark import ( + FeishuIMProviderAdapter, + LarkIMProviderAdapter, +) + +_RECEIVED_AT = datetime(2026, 8, 6, 10, 0, 0) +_VERIFICATION_TOKEN = "test-only-verification-token" +_ENCRYPT_KEY = "test-only-encrypt-key" +_CHALLENGE = "test-only-challenge" + + +class _NoRemoteGateway: + def query_tenant(self) -> Mapping[str, object]: + raise AssertionError("URL verification must not query the tenant") + + def list_scope(self, page_token: str | None) -> Mapping[str, object]: + raise AssertionError(f"unexpected directory request: {page_token}") + + def list_departments(self, department_id: str, page_token: str | None) -> Mapping[str, object]: + raise AssertionError(f"unexpected department request: {department_id}:{page_token}") + + def list_users(self, department_id: str, page_token: str | None) -> Mapping[str, object]: + raise AssertionError(f"unexpected user request: {department_id}:{page_token}") + + def create_message(self, receive_id: str, msg_type: str, content: str) -> Mapping[str, object]: + raise AssertionError(f"unexpected message request: {receive_id}:{msg_type}:{content}") + + def patch_message(self, message_id: str, content: str) -> Mapping[str, object]: + raise AssertionError(f"unexpected patch request: {message_id}:{content}") + + +class _CountingConsumer: + def __init__(self) -> None: + self.events: list[AuthenticatedIMEvent] = [] + + def accept(self, event: AuthenticatedIMEvent, *, request_ip_address: str | None = None) -> EventAcceptance: + del request_ip_address + self.events.append(event) + return EventAcceptance.ACCEPTED + + +def _create_handler( + monkeypatch: pytest.MonkeyPatch, + provider: IMProvider, + consumer: _CountingConsumer, + *, + verification_token: str | None, + encrypt_key: str | None, +) -> IMWebhookHandler: + gateway = _NoRemoteGateway() + monkeypatch.setattr(adapter_module, "_create_sdk_gateway", lambda _credentials, _domain: gateway) + credential_values = { + "provider": provider, + "app_id": "cli_test_only_app", + "app_secret": "test-only-app-secret", + "verification_token": verification_token, + "encrypt_key": encrypt_key, + } + adapter: FeishuIMProviderAdapter | LarkIMProviderAdapter + if provider is IMProvider.FEISHU: + credentials = FeishuCredentials.model_validate(credential_values) + adapter = FeishuIMProviderAdapter(credentials) + else: + credentials = LarkCredentials.model_validate(credential_values) + adapter = LarkIMProviderAdapter(credentials) + return adapter.create_webhook_handler(consumer) + + +def _plaintext_challenge(*, token: str | None = _VERIFICATION_TOKEN) -> bytes: + challenge: dict[str, object] = { + "type": "url_verification", + "challenge": _CHALLENGE, + } + if token is not None: + challenge["token"] = token + return json.dumps(challenge, separators=(",", ":")).encode() + + +def _encrypted_challenge(*, token: str | None = _VERIFICATION_TOKEN, encrypt_key: str = _ENCRYPT_KEY) -> bytes: + plaintext = _plaintext_challenge(token=token) + key = hashlib.sha256(encrypt_key.encode()).digest() + iv = bytes(range(16)) + padder = padding.PKCS7(algorithms.AES.block_size).padder() + padded = padder.update(plaintext) + padder.finalize() + encryptor = Cipher(algorithms.AES(key), modes.CBC(iv)).encryptor() + ciphertext = iv + encryptor.update(padded) + encryptor.finalize() + return json.dumps({"encrypt": base64.b64encode(ciphertext).decode()}, separators=(",", ":")).encode() + + +@pytest.mark.parametrize("provider", [IMProvider.FEISHU, IMProvider.LARK]) +@pytest.mark.parametrize( + ("verification_token", "encrypt_key", "body"), + [ + (_VERIFICATION_TOKEN, None, _plaintext_challenge()), + (_VERIFICATION_TOKEN, _ENCRYPT_KEY, _plaintext_challenge()), + (_VERIFICATION_TOKEN, _ENCRYPT_KEY, _encrypted_challenge()), + (None, _ENCRYPT_KEY, _encrypted_challenge(token=None)), + (None, None, _plaintext_challenge()), + (None, None, _plaintext_challenge(token=None)), + ], + ids=( + "plaintext-token-auth", + "plaintext-token-auth-with-encryption-configured", + "encrypted-token-and-decryption-auth", + "encrypted-decryption-auth", + "plaintext-without-configured-verification", + "plaintext-without-configured-verification-or-request-token", + ), +) +def test_valid_url_verification_returns_exact_response_without_consumer_or_remote_io( + monkeypatch: pytest.MonkeyPatch, + provider: IMProvider, + verification_token: str | None, + encrypt_key: str | None, + body: bytes, +) -> None: + consumer = _CountingConsumer() + handler = _create_handler( + monkeypatch, + provider, + consumer, + verification_token=verification_token, + encrypt_key=encrypt_key, + ) + + response = handler.handle(WebhookRequest("POST", (), body, _RECEIVED_AT)) + + assert response.status_code == 200 + assert response.headers == (("Content-Type", "application/json"),) + assert response.body == b'{"challenge":"test-only-challenge"}' + assert consumer.events == [] + + +@pytest.mark.parametrize("provider", [IMProvider.FEISHU, IMProvider.LARK]) +@pytest.mark.parametrize( + ("verification_token", "encrypt_key", "body"), + [ + (_VERIFICATION_TOKEN, None, _plaintext_challenge(token="wrong-test-only-token")), + (_VERIFICATION_TOKEN, _ENCRYPT_KEY, _encrypted_challenge(token="wrong-test-only-token")), + (_VERIFICATION_TOKEN, _ENCRYPT_KEY, _encrypted_challenge(encrypt_key="wrong-test-only-key")), + (_VERIFICATION_TOKEN, None, _encrypted_challenge()), + (None, _ENCRYPT_KEY, _plaintext_challenge(token=None)), + ], + ids=( + "plaintext-wrong-token", + "encrypted-wrong-token", + "encrypted-wrong-key", + "encrypted-missing-key", + "plaintext-bypasses-configured-encryption", + ), +) +def test_invalid_or_unauthenticated_url_verification_fails_closed_without_consumer( + monkeypatch: pytest.MonkeyPatch, + provider: IMProvider, + verification_token: str | None, + encrypt_key: str | None, + body: bytes, +) -> None: + consumer = _CountingConsumer() + handler = _create_handler( + monkeypatch, + provider, + consumer, + verification_token=verification_token, + encrypt_key=encrypt_key, + ) + + response = handler.handle(WebhookRequest("POST", (), body, _RECEIVED_AT)) + + assert response.status_code != 200 + assert response.body == b'{"code":1}' + assert consumer.events == [] + + +@pytest.mark.parametrize("provider", [IMProvider.FEISHU, IMProvider.LARK]) +@pytest.mark.parametrize( + "body", + [ + b'{"type":"url_verification"}', + b'{"type":"url_verification","challenge":123}', + b'{"type":"event_callback","challenge":"test-only-challenge"}', + b'{"type":"url_verification","challenge":', + ], + ids=("missing-challenge", "invalid-challenge-type", "business-event", "malformed-json"), +) +def test_invalid_url_verification_without_configured_authentication_is_rejected( + monkeypatch: pytest.MonkeyPatch, provider: IMProvider, body: bytes +) -> None: + consumer = _CountingConsumer() + handler = _create_handler(monkeypatch, provider, consumer, verification_token=None, encrypt_key=None) + + response = handler.handle(WebhookRequest("POST", (), body, _RECEIVED_AT)) + + assert response.status_code in (400, 401) + assert response.body == b'{"code":1}' + assert consumer.events == [] diff --git a/api/tests/unit_tests/core/human_input_v2/im_provider/test_feishu_lark_webhook.py b/api/tests/unit_tests/core/human_input_v2/im_provider/test_feishu_lark_webhook.py new file mode 100644 index 00000000000..7f62e0a0609 --- /dev/null +++ b/api/tests/unit_tests/core/human_input_v2/im_provider/test_feishu_lark_webhook.py @@ -0,0 +1,549 @@ +from __future__ import annotations + +import base64 +import hashlib +import json +from collections.abc import Mapping +from concurrent.futures import ThreadPoolExecutor +from datetime import UTC, datetime + +import pytest +from cryptography.hazmat.primitives import padding +from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes + +from core.human_input_v2.entities import IMProvider +from core.human_input_v2.im_integration.adapters import ( + AuthenticatedIMEvent, + EventAcceptance, + IMEventIngressKind, + WebhookRequest, +) +from core.human_input_v2.im_integration.adapters import feishu_lark as adapter_module +from core.human_input_v2.im_integration.adapters.credentials import FeishuCredentials, LarkCredentials +from core.human_input_v2.im_integration.adapters.feishu_lark import ( + FeishuIMProviderAdapter, + LarkIMProviderAdapter, +) + +_RECEIVED_AT = datetime(2026, 8, 6, 10, 0, 0) +_TIMESTAMP = str(int(_RECEIVED_AT.replace(tzinfo=UTC).timestamp())) +_VERIFICATION_TOKEN = "sanitized-verification-token" +_ENCRYPT_KEY = "sanitized-encrypt-key" + + +class _Gateway: + def __init__(self, tenant_keys: list[str]) -> None: + self._tenant_keys = tenant_keys + self.calls: list[str] = [] + + def query_tenant(self) -> Mapping[str, object]: + self.calls.append("query_tenant") + return {"code": 0, "data": {"tenant": {"tenant_key": self._tenant_keys.pop(0)}}} + + def list_scope(self, _page_token: str | None) -> Mapping[str, object]: + raise AssertionError("unexpected scope call") + + def list_departments(self, _department_id: str, _page_token: str | None) -> Mapping[str, object]: + raise AssertionError("unexpected department call") + + def list_users(self, _department_id: str, _page_token: str | None) -> Mapping[str, object]: + raise AssertionError("unexpected user call") + + def create_message(self, _receive_id: str, _msg_type: str, _content: str) -> Mapping[str, object]: + raise AssertionError("unexpected create call") + + def patch_message(self, _message_id: str, _content: str) -> Mapping[str, object]: + raise AssertionError("unexpected patch call") + + +class _Consumer: + def __init__(self, acceptance: EventAcceptance = EventAcceptance.ACCEPTED) -> None: + self.acceptance = acceptance + self.events: list[AuthenticatedIMEvent] = [] + + def accept(self, event: AuthenticatedIMEvent, *, request_ip_address: str | None = None) -> EventAcceptance: + del request_ip_address + self.events.append(event) + return self.acceptance + + +class _FailingConsumer: + def accept(self, event: AuthenticatedIMEvent, *, request_ip_address: str | None = None) -> EventAcceptance: + del request_ip_address + del event + raise RuntimeError("sensitive consumer details") + + +def _adapter( + monkeypatch: pytest.MonkeyPatch, + provider: IMProvider, + gateway: _Gateway, + *, + verification_token: str | None = _VERIFICATION_TOKEN, + encrypt_key: str | None = _ENCRYPT_KEY, +) -> FeishuIMProviderAdapter | LarkIMProviderAdapter: + monkeypatch.setattr(adapter_module, "_create_sdk_gateway", lambda _credentials, _domain: gateway) + values = { + "provider": provider, + "app_id": "cli_sanitized_app", + "app_secret": "sanitized-app-secret", + "verification_token": verification_token, + "encrypt_key": encrypt_key, + } + if provider is IMProvider.FEISHU: + return FeishuIMProviderAdapter(FeishuCredentials.model_validate(values)) + return LarkIMProviderAdapter(LarkCredentials.model_validate(values)) + + +def _event_body( + *, + token: str = _VERIFICATION_TOKEN, + create_time: str = "1785981600000", +) -> bytes: + return json.dumps( + { + "schema": "2.0", + "header": { + "event_id": "evt_sanitized_event", + "event_type": "card.action.trigger", + "create_time": create_time, + "tenant_key": "tenant_sanitized", + "token": token, + }, + "event": { + "action": { + "name": "approve", + "value": { + "action_id": "approve", + "correlation_token": "opaque-correlation-token", + }, + }, + "nested": [1, None, {"preserved": True}], + }, + }, + separators=(",", ":"), + ).encode() + + +def _encrypt(plaintext: bytes, *, encrypt_key: str = _ENCRYPT_KEY) -> str: + key = hashlib.sha256(encrypt_key.encode()).digest() + iv = bytes(range(16)) + padder = padding.PKCS7(algorithms.AES.block_size).padder() + padded = padder.update(plaintext) + padder.finalize() + encryptor = Cipher(algorithms.AES(key), modes.CBC(iv)).encryptor() + return base64.b64encode(iv + encryptor.update(padded) + encryptor.finalize()).decode() + + +def _signed_request( + body: bytes, + *, + timestamp: str = _TIMESTAMP, + nonce: str = "sanitized-nonce", + signature: str | None = None, +) -> WebhookRequest: + calculated = hashlib.sha256(timestamp.encode() + nonce.encode() + _ENCRYPT_KEY.encode() + body).hexdigest() + return WebhookRequest( + method="POST", + headers=( + ("X-Lark-Request-Timestamp", timestamp), + ("X-Lark-Request-Nonce", nonce), + ("X-Lark-Signature", calculated if signature is None else signature), + ), + body=body, + received_at=_RECEIVED_AT, + ) + + +@pytest.mark.parametrize("provider", [IMProvider.FEISHU, IMProvider.LARK]) +def test_authenticated_plaintext_event_preserves_complete_payload_and_acks_after_acceptance( + monkeypatch: pytest.MonkeyPatch, + provider: IMProvider, +) -> None: + gateway = _Gateway(["tenant_sanitized"]) + consumer = _Consumer() + adapter = _adapter(monkeypatch, provider, gateway, encrypt_key=None) + handler = adapter.create_webhook_handler(consumer) + body = _event_body() + + response = handler.handle( + WebhookRequest( + method="POST", + headers=(("Content-Type", "application/json"),), + body=body, + received_at=_RECEIVED_AT, + ) + ) + + assert response.status_code == 200 + assert json.loads(response.body) == {"code": 0} + assert len(consumer.events) == 1 + event = consumer.events[0] + assert event.provider is provider + assert event.provider_tenant_id == "tenant_sanitized" + assert event.event_id == "evt_sanitized_event" + assert event.event_type == "card.action.trigger" + assert event.ingress_kind is IMEventIngressKind.WEBHOOK + assert json.loads(event.payload) == json.loads(body) + + +@pytest.mark.parametrize( + ("create_time", "expected"), + [ + ("1704067200123456", datetime(2024, 1, 1, 0, 0, 0, 123456)), + ("1704067200123", datetime(2024, 1, 1, 0, 0, 0, 123000)), + ("not-a-timestamp", None), + ("9" * 64, None), + ], + ids=("microseconds", "milliseconds", "malformed", "overflow"), +) +@pytest.mark.parametrize("provider", [IMProvider.FEISHU, IMProvider.LARK]) +def test_authenticated_webhook_event_maps_confirmed_timestamp_units_safely( + provider: IMProvider, + monkeypatch: pytest.MonkeyPatch, + create_time: str, + expected: datetime | None, +) -> None: + consumer = _Consumer() + handler = _adapter( + monkeypatch, + provider, + _Gateway(["tenant_sanitized"]), + encrypt_key=None, + ).create_webhook_handler(consumer) + + response = handler.handle(WebhookRequest("POST", (), _event_body(create_time=create_time), _RECEIVED_AT)) + + assert response.status_code == 200 + assert len(consumer.events) == 1 + assert consumer.events[0].occurred_at == expected + + +@pytest.mark.parametrize("provider", [IMProvider.FEISHU, IMProvider.LARK]) +@pytest.mark.parametrize("include_token", [False, True], ids=("missing-token", "unconfigured-token")) +def test_plaintext_business_event_without_optional_authentication_is_accepted( + monkeypatch: pytest.MonkeyPatch, + provider: IMProvider, + include_token: bool, +) -> None: + gateway = _Gateway(["tenant_sanitized"]) + consumer = _Consumer() + handler = _adapter( + monkeypatch, + provider, + gateway, + verification_token=None, + encrypt_key=None, + ).create_webhook_handler(consumer) + payload = json.loads(_event_body(token="unconfigured-token")) + if not include_token: + del payload["header"]["token"] + body = json.dumps(payload).encode() + + response = handler.handle(WebhookRequest("POST", (), body, _RECEIVED_AT)) + + assert response.status_code == 200 + assert json.loads(response.body) == {"code": 0} + assert len(consumer.events) == 1 + event = consumer.events[0] + assert event.provider is provider + assert event.provider_tenant_id == "tenant_sanitized" + assert event.event_id == "evt_sanitized_event" + assert event.payload == body.decode() + + +@pytest.mark.parametrize("provider", [IMProvider.FEISHU, IMProvider.LARK]) +def test_encrypted_event_authenticates_exact_envelope_and_exposes_only_plaintext( + provider: IMProvider, + monkeypatch: pytest.MonkeyPatch, +) -> None: + gateway = _Gateway(["tenant_sanitized"]) + consumer = _Consumer() + handler = _adapter(monkeypatch, provider, gateway).create_webhook_handler(consumer) + plaintext = json.dumps(json.loads(_event_body()), ensure_ascii=False, indent=2).encode() + assert b"\n" in plaintext + body = json.dumps({"encrypt": _encrypt(plaintext)}, separators=(",", ":")).encode() + + response = handler.handle(_signed_request(body)) + + assert response.status_code == 200 + assert len(consumer.events) == 1 + event = consumer.events[0] + assert event.ingress_kind is IMEventIngressKind.WEBHOOK + assert json.loads(event.payload) == json.loads(plaintext) + assert "encrypt" not in json.loads(event.payload) + + +@pytest.mark.parametrize("provider", [IMProvider.FEISHU, IMProvider.LARK]) +def test_signed_event_treats_timestamp_as_opaque_signature_material( + provider: IMProvider, + monkeypatch: pytest.MonkeyPatch, +) -> None: + gateway = _Gateway(["tenant_sanitized"]) + consumer = _Consumer() + handler = _adapter(monkeypatch, provider, gateway).create_webhook_handler(consumer) + body = json.dumps({"encrypt": _encrypt(_event_body())}, separators=(",", ":")).encode() + + response = handler.handle(_signed_request(body, timestamp="opaque-provider-timestamp")) + + assert response.status_code == 200 + assert len(consumer.events) == 1 + + +@pytest.mark.parametrize( + ("timestamp", "nonce"), + [("", "sanitized-nonce"), ("opaque-provider-timestamp", "")], + ids=("empty-timestamp", "empty-nonce"), +) +@pytest.mark.parametrize("provider", [IMProvider.FEISHU, IMProvider.LARK]) +def test_signed_event_preserves_official_sdk_empty_signature_material_semantics( + provider: IMProvider, + monkeypatch: pytest.MonkeyPatch, + timestamp: str, + nonce: str, +) -> None: + gateway = _Gateway(["tenant_sanitized"]) + consumer = _Consumer() + handler = _adapter(monkeypatch, provider, gateway).create_webhook_handler(consumer) + body = json.dumps({"encrypt": _encrypt(_event_body())}, separators=(",", ":")).encode() + + response = handler.handle(_signed_request(body, timestamp=timestamp, nonce=nonce)) + + assert response.status_code == 200 + assert len(consumer.events) == 1 + + +@pytest.mark.parametrize("provider", [IMProvider.FEISHU, IMProvider.LARK]) +def test_tampered_signature_never_reaches_consumer( + provider: IMProvider, + monkeypatch: pytest.MonkeyPatch, +) -> None: + gateway = _Gateway([]) + consumer = _Consumer() + handler = _adapter(monkeypatch, provider, gateway).create_webhook_handler(consumer) + body = json.dumps({"encrypt": _encrypt(_event_body())}, separators=(",", ":")).encode() + + response = handler.handle(_signed_request(body, signature="0" * 64)) + + assert response.status_code != 200 + assert consumer.events == [] + assert gateway.calls == [] + + +@pytest.mark.parametrize("provider", [IMProvider.FEISHU, IMProvider.LARK]) +def test_empty_signature_never_reaches_consumer_or_gateway( + provider: IMProvider, + monkeypatch: pytest.MonkeyPatch, +) -> None: + gateway = _Gateway([]) + consumer = _Consumer() + handler = _adapter(monkeypatch, provider, gateway).create_webhook_handler(consumer) + body = json.dumps({"encrypt": _encrypt(_event_body())}, separators=(",", ":")).encode() + request = _signed_request(body, signature="") + + assert tuple(value for name, value in request.headers if name == "X-Lark-Signature") == ("",) + response = handler.handle(request) + + assert response.status_code == 401 + assert consumer.events == [] + assert gateway.calls == [] + + +@pytest.mark.parametrize("provider", [IMProvider.FEISHU, IMProvider.LARK]) +def test_signature_is_bound_to_exact_request_body(monkeypatch: pytest.MonkeyPatch, provider: IMProvider) -> None: + gateway = _Gateway([]) + consumer = _Consumer() + handler = _adapter(monkeypatch, provider, gateway).create_webhook_handler(consumer) + body = json.dumps({"encrypt": _encrypt(_event_body())}, separators=(",", ":")).encode() + signed = _signed_request(body) + modified = WebhookRequest(signed.method, signed.headers, signed.body + b" ", signed.received_at) + + response = handler.handle(modified) + + assert response.status_code != 200 + assert consumer.events == [] + assert gateway.calls == [] + + +@pytest.mark.parametrize( + "header_name", + ["X-Lark-Request-Timestamp", "X-Lark-Request-Nonce", "X-Lark-Signature"], +) +@pytest.mark.parametrize("provider", [IMProvider.FEISHU, IMProvider.LARK]) +def test_signed_event_requires_exactly_one_signature_tuple_header( + provider: IMProvider, + monkeypatch: pytest.MonkeyPatch, + header_name: str, +) -> None: + gateway = _Gateway([]) + consumer = _Consumer() + handler = _adapter(monkeypatch, provider, gateway).create_webhook_handler(consumer) + body = json.dumps({"encrypt": _encrypt(_event_body())}, separators=(",", ":")).encode() + signed = _signed_request(body) + header = next(header for header in signed.headers if header[0] == header_name) + missing = WebhookRequest( + signed.method, + tuple(candidate for candidate in signed.headers if candidate[0] != header_name), + signed.body, + signed.received_at, + ) + duplicate = WebhookRequest( + signed.method, + signed.headers + (header,), + signed.body, + signed.received_at, + ) + + assert handler.handle(missing).status_code != 200 + assert handler.handle(duplicate).status_code != 200 + assert consumer.events == [] + assert gateway.calls == [] + + +@pytest.mark.parametrize("provider", [IMProvider.FEISHU, IMProvider.LARK]) +def test_replayed_signed_delivery_is_forwarded_to_the_durable_consumer( + provider: IMProvider, + monkeypatch: pytest.MonkeyPatch, +) -> None: + gateway = _Gateway(["tenant_sanitized", "tenant_sanitized"]) + consumer = _Consumer() + handler = _adapter(monkeypatch, provider, gateway).create_webhook_handler(consumer) + body = json.dumps({"encrypt": _encrypt(_event_body())}, separators=(",", ":")).encode() + request = _signed_request(body) + + first = handler.handle(request) + replay = handler.handle(request) + + assert first.status_code == 200 + assert replay.status_code == 200 + assert len(consumer.events) == 2 + + +@pytest.mark.parametrize("provider", [IMProvider.FEISHU, IMProvider.LARK]) +def test_wrong_encryption_material_never_reaches_consumer( + monkeypatch: pytest.MonkeyPatch, provider: IMProvider +) -> None: + gateway = _Gateway([]) + consumer = _Consumer() + handler = _adapter(monkeypatch, provider, gateway).create_webhook_handler(consumer) + body = json.dumps( + {"encrypt": _encrypt(_event_body(), encrypt_key="sanitized-wrong-encrypt-key")}, + separators=(",", ":"), + ).encode() + + response = handler.handle(_signed_request(body)) + + assert response.status_code != 200 + assert consumer.events == [] + assert gateway.calls == [] + + +@pytest.mark.parametrize("provider", [IMProvider.FEISHU, IMProvider.LARK]) +def test_wrong_verification_token_never_reaches_consumer(monkeypatch: pytest.MonkeyPatch, provider: IMProvider) -> None: + gateway = _Gateway([]) + consumer = _Consumer() + handler = _adapter(monkeypatch, provider, gateway, encrypt_key=None).create_webhook_handler(consumer) + + response = handler.handle(WebhookRequest("POST", (), _event_body(token="wrong-token"), _RECEIVED_AT)) + + assert response.status_code != 200 + assert consumer.events == [] + assert gateway.calls == [] + + +@pytest.mark.parametrize("provider", [IMProvider.FEISHU, IMProvider.LARK]) +def test_cross_tenant_webhook_never_reaches_consumer(monkeypatch: pytest.MonkeyPatch, provider: IMProvider) -> None: + gateway = _Gateway(["tenant_other"]) + consumer = _Consumer() + handler = _adapter(monkeypatch, provider, gateway, encrypt_key=None).create_webhook_handler(consumer) + + response = handler.handle(WebhookRequest("POST", (), _event_body(), _RECEIVED_AT)) + + assert response.status_code == 401 + assert consumer.events == [] + assert gateway.calls == ["query_tenant"] + + +@pytest.mark.parametrize("provider", [IMProvider.FEISHU, IMProvider.LARK]) +def test_url_challenge_returns_exact_challenge_without_consumer_or_remote_io( + provider: IMProvider, + monkeypatch: pytest.MonkeyPatch, +) -> None: + gateway = _Gateway([]) + consumer = _Consumer() + handler = _adapter(monkeypatch, provider, gateway, encrypt_key=None).create_webhook_handler(consumer) + body = json.dumps( + {"type": "url_verification", "token": _VERIFICATION_TOKEN, "challenge": "sanitized-challenge"}, + separators=(",", ":"), + ).encode() + + response = handler.handle(WebhookRequest("POST", (), body, _RECEIVED_AT)) + + assert response.status_code == 200 + assert response.body == b'{"challenge":"sanitized-challenge"}' + assert consumer.events == [] + assert gateway.calls == [] + + +@pytest.mark.parametrize("provider", [IMProvider.FEISHU, IMProvider.LARK]) +def test_url_challenge_does_not_require_business_event_signature( + provider: IMProvider, + monkeypatch: pytest.MonkeyPatch, +) -> None: + gateway = _Gateway([]) + consumer = _Consumer() + handler = _adapter(monkeypatch, provider, gateway).create_webhook_handler(consumer) + body = json.dumps( + {"type": "url_verification", "token": _VERIFICATION_TOKEN, "challenge": "sanitized-challenge"}, + separators=(",", ":"), + ).encode() + + response = handler.handle(WebhookRequest("POST", (), body, _RECEIVED_AT)) + + assert response.status_code == 200 + assert response.body == b'{"challenge":"sanitized-challenge"}' + assert consumer.events == [] + assert gateway.calls == [] + + +@pytest.mark.parametrize( + ("consumer", "expected_status"), + [(_Consumer(EventAcceptance.NOT_ACCEPTED), 503), (_FailingConsumer(), 503)], +) +@pytest.mark.parametrize("provider", [IMProvider.FEISHU, IMProvider.LARK]) +def test_consumer_must_accept_before_successful_ack( + provider: IMProvider, + monkeypatch: pytest.MonkeyPatch, + consumer: _Consumer | _FailingConsumer, + expected_status: int, +) -> None: + handler = _adapter( + monkeypatch, + provider, + _Gateway(["tenant_sanitized"]), + encrypt_key=None, + ).create_webhook_handler(consumer) + + response = handler.handle(WebhookRequest("POST", (), _event_body(), _RECEIVED_AT)) + + assert response.status_code == expected_status + assert response.body == b'{"code":1}' + + +@pytest.mark.parametrize("provider", [IMProvider.FEISHU, IMProvider.LARK]) +def test_webhook_handler_is_independent_of_root_close_and_safe_for_concurrent_requests( + provider: IMProvider, + monkeypatch: pytest.MonkeyPatch, +) -> None: + request_count = 8 + gateway = _Gateway(["tenant_sanitized"] * request_count) + consumer = _Consumer() + adapter = _adapter(monkeypatch, provider, gateway, encrypt_key=None) + handler = adapter.create_webhook_handler(consumer) + adapter.close() + request = WebhookRequest("POST", (), _event_body(), _RECEIVED_AT) + + with ThreadPoolExecutor(max_workers=request_count) as executor: + responses = tuple(executor.map(handler.handle, (request,) * request_count)) + + assert all(response.status_code == 200 for response in responses) + assert len(consumer.events) == request_count diff --git a/api/tests/unit_tests/core/human_input_v2/im_provider/test_lark_sdk_boundary.py b/api/tests/unit_tests/core/human_input_v2/im_provider/test_lark_sdk_boundary.py new file mode 100644 index 00000000000..fd9c42a21dc --- /dev/null +++ b/api/tests/unit_tests/core/human_input_v2/im_provider/test_lark_sdk_boundary.py @@ -0,0 +1,742 @@ +"""Replay redacted Lark captures through the real SDK without network I/O.""" + +from __future__ import annotations + +import asyncio +import base64 +import json +from collections.abc import Callable, Iterator +from datetime import datetime +from pathlib import Path +from urllib.parse import parse_qs, urlsplit + +import httpx +import pytest +import requests +from lark_oapi.core.cache import LocalCache +from lark_oapi.core.token import TokenManager +from lark_oapi.ws import client as sdk_ws_module +from lark_oapi.ws.pb.pbbp2_pb2 import Frame +from pydantic import BaseModel, ConfigDict, JsonValue, TypeAdapter + +from core.human_input import ButtonStyle +from core.human_input_v2.entities import IMProvider +from core.human_input_v2.im_integration.adapters import feishu_lark as adapter_module +from core.human_input_v2.im_integration.adapters.credentials import FeishuCredentials, LarkCredentials +from core.human_input_v2.im_integration.adapters.entities import ( + AuthenticatedIMEvent, + CorrelationToken, + CredentialTestFailure, + CredentialTestFailureKind, + CredentialTestSuccess, + Directory, + DirectoryEntry, + DirectoryReadFailure, + EventAcceptance, + IMCardEvent, + IMEventIngressKind, + IMStreamStartError, + MessageAccepted, + MessageSendingError, + ProviderUserId, + ReplacementError, + ReplacementErrorKind, + StaticCardIntent, + WebhookRequest, +) +from core.human_input_v2.im_integration.adapters.feishu_lark import FeishuIMProviderAdapter, LarkIMProviderAdapter +from core.human_input_v2.resolved_form import MarkdownFragment, ParagraphInput, ResolvedForm, SelectInput, UserAction + +_FIXTURE_PATH = Path(__file__).with_name("fixtures") / "lark" / "sanitized_protocol.json" +_JSON_OBJECT = TypeAdapter(dict[str, JsonValue]) + + +class _CapturedRequest(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + method: str + url: str + body: dict[str, JsonValue] | None + + +class _CapturedResponse(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + status: int + headers: dict[str, str] + body: dict[str, JsonValue] + + +class _CapturedExchange(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + request: _CapturedRequest + response: _CapturedResponse + + +class _CapturedEvent(BaseModel): + provider: IMProvider + provider_tenant_id: str + event_id: str + event_type: str + occurred_at: datetime + received_at: datetime + ingress_kind: IMEventIngressKind + payload: str + + +class _CallbackRequest(BaseModel): + method: str + received_at: datetime + body: str + headers: tuple[tuple[str, str], ...] + + +class _CallbackResponse(BaseModel): + status: int + body: str + + +class _CallbackHTTP(BaseModel): + request: _CallbackRequest + response: _CallbackResponse + + +class _SDKSerialization(BaseModel): + method: str + value: str + + +class _SDKCallbackResponse(BaseModel): + object_type: str + sdk_serialization: _SDKSerialization + + +class _SDKCallback(BaseModel): + callback: str + object_type: str + sdk_serialization: _SDKSerialization + callback_response: _SDKCallbackResponse + + +class _WebhookCapture(BaseModel): + provider: IMProvider + authenticated_event: _CapturedEvent + http: _CallbackHTTP + + +class _StreamCapture(BaseModel): + provider: IMProvider + authenticated_event: _CapturedEvent + stream: _SDKCallback + + +class _ACKCapture(BaseModel): + body_encoding: str + body: str + response_payload: str + + +class _RecordingConsumer: + def __init__(self, acceptance: EventAcceptance = EventAcceptance.ACCEPTED) -> None: + self.events: list[AuthenticatedIMEvent] = [] + self.acceptance = acceptance + + def accept(self, event: AuthenticatedIMEvent, *, request_ip_address: str | None = None) -> EventAcceptance: + del request_ip_address + self.events.append(event) + return self.acceptance + + +def _live_intent(mode: str) -> ResolvedForm: + return ResolvedForm( + title=f"Lark {mode} fixture capture", + parts=( + MarkdownFragment(text="Lark confirmed card form."), + ParagraphInput(output_variable_name="comment", default_value=None), + SelectInput(output_variable_name="decision", options=("ship", "hold"), default_value="hold"), + ), + actions=( + UserAction(id="approve", title="Approve", button_style=ButtonStyle.PRIMARY), + UserAction(id="reject", title="Reject", button_style=ButtonStyle.ACCENT), + ), + legacy_form_content="", + ) + + +def _webhook_capture() -> _WebhookCapture: + return _WebhookCapture.model_validate_json( + _FIXTURE_PATH.with_name("sanitized_card_action_webhook.json").read_text() + ) + + +def _webhook_request(capture: _WebhookCapture) -> WebhookRequest: + request = capture.http.request + return WebhookRequest( + method=request.method, headers=request.headers, body=request.body.encode(), received_at=request.received_at + ) + + +class _RecordedSDKStreamClient: + def __init__( + self, + credentials: FeishuCredentials | LarkCredentials, + domain: str, + callback: Callable[[adapter_module._SDKEventEnvelope, Callable[[], None]], None], + ) -> None: + capture = _StreamCapture.model_validate_json( + _FIXTURE_PATH.with_name("sanitized_card_action_stream.json").read_text() + ) + ack = _ACKCapture.model_validate_json(_FIXTURE_PATH.with_name("sanitized_stream_ack.json").read_text()) + channel = adapter_module._SynchronousEventChannel(credentials=credentials, domain=domain, callback=callback) + self._credentials = credentials + self._domain = domain + self._dispatcher = channel._build_dispatcher() + self._frame = Frame() + self._frame.ParseFromString(base64.b64decode(ack.body)) + # The recorded ACK supplies the delivery headers. The incoming payload is + # reconstructed from the separately captured, redacted SDK serialization. + self._frame.payload = capture.stream.sdk_serialization.value.encode() + self.writes: list[bytes] = [] + self.stopped = False + + async def _write(self, data: bytes) -> None: + self.writes.append(data) + + async def _deliver(self) -> None: + client = sdk_ws_module.Client( + self._credentials.app_id, + self._credentials.app_secret, + event_handler=self._dispatcher, + domain=self._domain, + ) + client._write_message = self._write + await client._handle_data_frame(self._frame) + + def start(self) -> None: + asyncio.run(self._deliver()) + + def stop(self) -> None: + self.stopped = True + + +class _LarkHTTPReplay: + def __init__(self) -> None: + self.exchanges = TypeAdapter(dict[str, _CapturedExchange]).validate_json(_FIXTURE_PATH.read_text()) + # Reuse captured responses while checking the current Markdown request contract. + for operation, markdown in ( + ("text", "Lark protocol capture: **unit tests**\n\n测试消息"), + ("invalid_recipient", "Lark unit test invalid recipient"), + ): + exchange = self.exchanges[operation] + assert exchange.request.body is not None + request_body = { + **exchange.request.body, + "msg_type": "interactive", + "content": json.dumps( + {"schema": "2.0", "body": {"elements": [{"tag": "markdown", "content": markdown}]}}, + ensure_ascii=False, + separators=(",", ":"), + ), + } + current_request = exchange.request.model_copy(update={"body": request_body}) + self.exchanges[operation] = exchange.model_copy(update={"request": current_request}) + self.responses: dict[str, _CapturedResponse] = {} + self.requests: list[_CapturedRequest] = [] + self.unmatched: list[_CapturedRequest] = [] + + def send(self, request: requests.PreparedRequest) -> requests.Response: + assert request.method is not None + assert request.url is not None + body = None + if request.body is not None: + assert isinstance(request.body, (bytes, str)) + body = _JSON_OBJECT.validate_json(request.body) + observed = _CapturedRequest(method=request.method, url=request.url, body=body) + self.requests.append(observed) + self.unmatched.append(observed) + url = urlsplit(observed.url) + assert url.scheme == "https" + assert url.netloc == "open.larksuite.com" + for operation, exchange in self.exchanges.items(): + expected = exchange.request + expected_url = urlsplit(expected.url) + if ( + observed.method == expected.method + and url.path == expected_url.path + and parse_qs(url.query) == parse_qs(expected_url.query) + and body == expected.body + ): + if operation not in {"token", "invalid_credentials", "stream_connect", "stream_connect_invalid"}: + assert request.headers["Authorization"] == "Bearer t-sanitized-lark-access-token" + captured = self.responses.get(operation, exchange.response) + response = requests.Response() + response.status_code = captured.status + response.headers.update(captured.headers) + response._content = json.dumps(captured.body, ensure_ascii=False).encode() + response.request = request + response.url = request.url + self.unmatched.pop() + return response + raise AssertionError("SDK request did not match a sanitized Lark capture") + + +@pytest.fixture +def replay(monkeypatch: pytest.MonkeyPatch) -> Iterator[_LarkHTTPReplay]: + replay = _LarkHTTPReplay() + # The SDK token cache is process-wide; isolate it so every test exercises auth. + monkeypatch.setattr(TokenManager, "cache", LocalCache()) + + def send(_session: requests.Session, request: requests.PreparedRequest, **_kwargs: object) -> requests.Response: + return replay.send(request) + + monkeypatch.setattr(requests.Session, "send", send) + + async def async_send(_client: httpx.AsyncClient, request: httpx.Request, **_kwargs: object) -> httpx.Response: + prepared = requests.Request( + request.method, str(request.url), headers=dict(request.headers), data=request.content or None + ).prepare() + response = replay.send(prepared) + return httpx.Response(response.status_code, headers=response.headers, content=response.content, request=request) + + monkeypatch.setattr(httpx.AsyncClient, "send", async_send) + yield replay + # Adapters translate transport exceptions into failures; do not let that hide + # an unmatched request in a test which expects a provider failure. + assert replay.unmatched == [] + + +def _credentials(*, app_secret: str = "sanitized-lark-app-secret") -> LarkCredentials: + return LarkCredentials( + provider=IMProvider.LARK, + app_id="cli_sanitized_lark_app", + app_secret=app_secret, + verification_token="sanitized-lark-verification-token", + encrypt_key="sanitized-lark-encrypt-key", + ) + + +def _intent() -> ResolvedForm: + return ResolvedForm( + title="Lark unit test capture", + parts=( + MarkdownFragment(text="Protocol capture for **Lark** unit tests."), + ParagraphInput(output_variable_name="comment", default_value="Initial comment"), + SelectInput(output_variable_name="decision", options=("Approve", "Reject"), default_value="Approve"), + ), + actions=(UserAction(id="approve", title="Approve", button_style=ButtonStyle.PRIMARY),), + legacy_form_content="", + ) + + +def _send_card(adapter: LarkIMProviderAdapter) -> MessageAccepted: + result = adapter.dynamic_card_messaging.send_card( + ProviderUserId("union_lark_user"), _intent(), CorrelationToken("lark-unit-test-correlation") + ) + assert isinstance(result, MessageAccepted) + return result + + +class _NoEventsExpected: + def accept(self, event: AuthenticatedIMEvent, *, request_ip_address: str | None = None) -> EventAcceptance: + del request_ip_address + del event + raise AssertionError("Endpoint discovery must not fabricate a callback") + + +def test_lark_credentials_require_root_access_despite_a_successful_user_scope(replay: _LarkHTTPReplay) -> None: + replay.responses["scope"] = replay.exchanges["scope_restricted"].response + replay.responses["departments"] = replay.exchanges["departments_denied"].response + adapter = LarkIMProviderAdapter(_credentials()) + + assert adapter.test_credentials() == CredentialTestFailure( + CredentialTestFailureKind.UNKNOWN, + "Lark baseline permissions could not be confirmed.", + provider_error_code="40004", + http_status=403, + ) + + +@pytest.mark.usefixtures("replay") +def test_lark_credentials_accept_the_captured_scope_without_department_ids() -> None: + assert LarkIMProviderAdapter(_credentials()).test_credentials() == CredentialTestSuccess( + IMProvider.LARK, "tenant_lark_sanitized" + ) + + +@pytest.mark.usefixtures("replay") +def test_lark_invalid_app_secret_returns_a_safe_failure() -> None: + adapter = LarkIMProviderAdapter(_credentials(app_secret="invalid-test-secret")) + + assert adapter.test_credentials() == CredentialTestFailure( + CredentialTestFailureKind.AUTHENTICATION_REJECTED, + "Lark rejected the credential test.", + provider_error_code="10014", + http_status=200, + ) + + +def test_lark_invalid_tenant_token_is_classified_as_authentication_rejected(replay: _LarkHTTPReplay) -> None: + replay.responses["tenant"] = replay.exchanges["invalid_token"].response + + assert LarkIMProviderAdapter(_credentials()).test_credentials() == CredentialTestFailure( + CredentialTestFailureKind.AUTHENTICATION_REJECTED, + "Lark rejected the credential test.", + provider_error_code="99991663", + http_status=400, + ) + + +def test_lark_directory_does_not_publish_the_scoped_user_when_root_access_is_denied(replay: _LarkHTTPReplay) -> None: + replay.responses["users"] = replay.exchanges["users_denied"].response + assert LarkIMProviderAdapter(_credentials()).directory.read_directory() == DirectoryReadFailure( + "Lark directory could not be read completely." + ) + + +@pytest.mark.usefixtures("replay") +def test_lark_directory_preserves_captured_order_and_missing_names_and_avatars() -> None: + assert LarkIMProviderAdapter(_credentials()).directory.read_directory() == Directory( + ( + DirectoryEntry(ProviderUserId("union_lark_user"), None, "lark-user@example.invalid", None), + DirectoryEntry(ProviderUserId("union_lark_user_2"), None, "lark-user-2@example.invalid", None), + ) + ) + + +def test_lark_directory_discards_captured_users_when_department_access_fails(replay: _LarkHTTPReplay) -> None: + replay.responses["departments"] = replay.exchanges["departments_denied"].response + + assert LarkIMProviderAdapter(_credentials()).directory.read_directory() == DirectoryReadFailure( + "Lark directory could not be read completely." + ) + + +def test_lark_text_send_matches_the_captured_union_id_and_plain_text_request(replay: _LarkHTTPReplay) -> None: + result = LarkIMProviderAdapter(_credentials()).messaging.send_text( + ProviderUserId("union_lark_user"), "Lark protocol capture: **unit tests**\n\n测试消息" + ) + + assert isinstance(result, MessageAccepted) + assert str(result.locator) + assert replay.requests[-1] == replay.exchanges["text"].request + + +def test_lark_card_is_sent_and_only_the_original_message_is_replaced(replay: _LarkHTTPReplay) -> None: + adapter = LarkIMProviderAdapter(_credentials()) + accepted = _send_card(adapter) + + assert ( + adapter.dynamic_card_messaging.replace_with_static(accepted.locator, StaticCardIntent("Capture **completed**.")) + is None + ) + assert replay.requests[-2:] == [replay.exchanges["card"].request, replay.exchanges["replace"].request] + + +def test_lark_invalid_recipient_is_rejected_without_retry(replay: _LarkHTTPReplay) -> None: + result = LarkIMProviderAdapter(_credentials()).messaging.send_text( + ProviderUserId("union_lark_invalid"), "Lark unit test invalid recipient" + ) + + assert result == MessageSendingError("Lark message acceptance could not be confirmed.") + assert replay.requests == [replay.exchanges["token"].request, replay.exchanges["invalid_recipient"].request] + + +def test_lark_reference_rejection_does_not_recreate_the_card(replay: _LarkHTTPReplay) -> None: + adapter = LarkIMProviderAdapter(_credentials()) + accepted = _send_card(adapter) + replay.responses["replace"] = replay.exchanges["invalid_reference"].response + + assert adapter.dynamic_card_messaging.replace_with_static( + accepted.locator, StaticCardIntent("Capture **completed**.") + ) == ReplacementError(ReplacementErrorKind.UNKNOWN, "Lark replacement acceptance is unknown.") + assert replay.requests == [ + replay.exchanges["token"].request, + replay.exchanges["card"].request, + replay.exchanges["replace"].request, + ] + + +def test_lark_card_rejection_is_safe_and_does_not_retry(replay: _LarkHTTPReplay) -> None: + replay.responses["card"] = replay.exchanges["invalid_recipient"].response + + result = LarkIMProviderAdapter(_credentials()).dynamic_card_messaging.send_card( + ProviderUserId("union_lark_user"), _intent(), CorrelationToken("lark-unit-test-correlation") + ) + + assert result == MessageSendingError("Lark card acceptance could not be confirmed.") + assert replay.requests == [replay.exchanges["token"].request, replay.exchanges["card"].request] + + +@pytest.mark.parametrize("operation", ["text", "card"]) +@pytest.mark.parametrize( + "response_body", [{"code": 0}, {"code": 0, "data": {}}, {"code": 0, "data": {"message_id": ""}}] +) +def test_lark_cannot_confirm_acceptance_without_a_message_id( + replay: _LarkHTTPReplay, operation: str, response_body: dict[str, JsonValue] +) -> None: + replay.responses[operation] = _CapturedResponse( + status=200, headers={"Content-Type": "application/json"}, body=response_body + ) + adapter = LarkIMProviderAdapter(_credentials()) + + if operation == "text": + result = adapter.messaging.send_text( + ProviderUserId("union_lark_user"), "Lark protocol capture: **unit tests**\n\n测试消息" + ) + else: + result = adapter.dynamic_card_messaging.send_card( + ProviderUserId("union_lark_user"), _intent(), CorrelationToken("lark-unit-test-correlation") + ) + + assert isinstance(result, MessageSendingError) + assert replay.requests == [replay.exchanges["token"].request, replay.exchanges[operation].request] + + +def test_lark_message_locator_is_rejected_by_feishu_without_a_mutation(replay: _LarkHTTPReplay) -> None: + accepted = _send_card(LarkIMProviderAdapter(_credentials())) + feishu = FeishuIMProviderAdapter( + FeishuCredentials( + provider=IMProvider.FEISHU, + app_id="cli_sanitized_feishu_app", + app_secret="sanitized-feishu-app-secret", + verification_token=None, + encrypt_key=None, + ) + ) + + assert feishu.dynamic_card_messaging.replace_with_static( + accepted.locator, StaticCardIntent("Capture **completed**.") + ) == ReplacementError(ReplacementErrorKind.INVALID_REFERENCE, "The Feishu message reference is invalid.") + assert replay.requests == [replay.exchanges["token"].request, replay.exchanges["card"].request] + + +def test_lark_stream_discovers_its_endpoint_and_closes_the_official_transport( + replay: _LarkHTTPReplay, monkeypatch: pytest.MonkeyPatch +) -> None: + class Socket: + def __init__(self) -> None: + self.closed = asyncio.Event() + + async def recv(self) -> bytes: + await self.closed.wait() + raise sdk_ws_module.ConnectionClosedException("sanitized connection closed") + + async def send(self, _body: bytes) -> None: + return + + async def close(self) -> None: + self.closed.set() + + connections: list[Socket] = [] + connected_urls: list[str] = [] + + async def connect(url: str, **_kwargs: object) -> Socket: + connected_urls.append(url) + socket = Socket() + connections.append(socket) + return socket + + monkeypatch.setattr(sdk_ws_module.websockets, "connect", connect) + adapter = LarkIMProviderAdapter(_credentials()) + stream = adapter.create_stream_handler(_NoEventsExpected()) + try: + stream.start() + stream.stop() + finally: + adapter.close() + + endpoint = replay.exchanges["stream_connect"].response.body["data"] + assert isinstance(endpoint, dict) + assert connected_urls == [endpoint["URL"]] + assert len(connections) == 1 + assert connections[0].closed.is_set() + assert replay.exchanges["stream_connect"].request in replay.requests + assert replay.exchanges["bot_info"].request in replay.requests + + +def test_lark_stream_discovery_rejection_returns_a_safe_error_without_opening_a_socket( + replay: _LarkHTTPReplay, monkeypatch: pytest.MonkeyPatch +) -> None: + connection_attempts: list[str] = [] + + async def connect(url: str, **_kwargs: object) -> None: + connection_attempts.append(url) + raise AssertionError("Rejected credentials must not open a WebSocket") + + monkeypatch.setattr(sdk_ws_module.websockets, "connect", connect) + adapter = LarkIMProviderAdapter(_credentials(app_secret="invalid-test-secret")) + stream = adapter.create_stream_handler(_NoEventsExpected()) + try: + with pytest.raises(IMStreamStartError, match=r"^The Lark event stream could not be started\.$"): + stream.start() + finally: + stream.stop() + adapter.close() + + assert connection_attempts == [] + assert replay.exchanges["stream_connect_invalid"].request in replay.requests + + +def test_lark_captured_webhook_authenticates_decodes_and_updates_the_sent_card(replay: _LarkHTTPReplay) -> None: + capture = _webhook_capture() + consumer = _RecordingConsumer() + adapter = LarkIMProviderAdapter(_credentials()) + accepted = adapter.dynamic_card_messaging.send_card( + ProviderUserId("union_lark_user"), _live_intent("webhook"), CorrelationToken("lark-live-webhook-correlation") + ) + assert isinstance(accepted, MessageAccepted) + + response = adapter.create_webhook_handler(consumer).handle(_webhook_request(capture)) + + assert response.status_code == capture.http.response.status + assert response.body.decode() == capture.http.response.body + assert len(consumer.events) == 1 + event = consumer.events[0] + assert event.provider is IMProvider.LARK + assert event.provider_tenant_id == capture.authenticated_event.provider_tenant_id + assert event.event_id == capture.authenticated_event.event_id + assert event.occurred_at == capture.authenticated_event.occurred_at + assert event.ingress_kind is IMEventIngressKind.WEBHOOK + assert event.payload == capture.authenticated_event.payload + assert adapter.card_event_decoder().decode(event) == IMCardEvent( + provider_user_id=ProviderUserId("union_lark_user"), + action_id="approve", + inputs={"comment": "Ready to ship", "decision": "hold"}, + correlation_token=CorrelationToken("lark-live-webhook-correlation"), + ) + assert ( + adapter.dynamic_card_messaging.replace_with_static( + accepted.locator, StaticCardIntent("Lark webhook capture completed.") + ) + is None + ) + assert replay.requests[-1] == replay.exchanges["webhook_replace"].request + + +def test_lark_captured_encrypted_url_challenge_needs_no_signature_or_tenant_lookup(replay: _LarkHTTPReplay) -> None: + capture = _CallbackHTTP.model_validate_json(_FIXTURE_PATH.with_name("sanitized_url_challenge.json").read_text()) + request = capture.request + handler = LarkIMProviderAdapter(_credentials()).create_webhook_handler(_NoEventsExpected()) + + response = handler.handle( + WebhookRequest( + method=request.method, headers=request.headers, body=request.body.encode(), received_at=request.received_at + ) + ) + + assert response.status_code == capture.response.status + assert json.loads(response.body) == json.loads(capture.response.body) + assert replay.requests == [] + + +@pytest.mark.parametrize("rejection", ["signature", "verification-token", "tenant"]) +def test_lark_captured_webhook_rejects_untrusted_delivery_before_consuming( + replay: _LarkHTTPReplay, rejection: str +) -> None: + capture = _webhook_capture() + credentials = _credentials() + request = _webhook_request(capture) + if rejection == "signature": + request = WebhookRequest( + method=request.method, headers=request.headers, body=request.body + b" ", received_at=request.received_at + ) + elif rejection == "verification-token": + credentials = credentials.model_copy(update={"verification_token": "different-test-token"}) + else: + replay.responses["tenant"] = _CapturedResponse( + status=200, + headers={"Content-Type": "application/json"}, + body={"code": 0, "data": {"tenant": {"tenant_key": "different-test-tenant"}}}, + ) + consumer = _RecordingConsumer() + + response = LarkIMProviderAdapter(credentials).create_webhook_handler(consumer).handle(request) + + assert response.status_code == 401 + assert consumer.events == [] + + +@pytest.mark.usefixtures("replay") +def test_lark_captured_webhook_does_not_ack_success_before_consumer_acceptance() -> None: + consumer = _RecordingConsumer(EventAcceptance.NOT_ACCEPTED) + handler = LarkIMProviderAdapter(_credentials()).create_webhook_handler(consumer) + + response = handler.handle(_webhook_request(_webhook_capture())) + + assert response.status_code == 503 + assert len(consumer.events) == 1 + + +@pytest.mark.parametrize("acceptance", [EventAcceptance.ACCEPTED, EventAcceptance.NOT_ACCEPTED]) +def test_lark_captured_stream_callback_and_wire_ack_follow_consumer_acceptance( + replay: _LarkHTTPReplay, monkeypatch: pytest.MonkeyPatch, acceptance: EventAcceptance +) -> None: + capture = _StreamCapture.model_validate_json( + _FIXTURE_PATH.with_name("sanitized_card_action_stream.json").read_text() + ) + ack = _ACKCapture.model_validate_json(_FIXTURE_PATH.with_name("sanitized_stream_ack.json").read_text()) + clients: list[_RecordedSDKStreamClient] = [] + + def create_client( + credentials: FeishuCredentials | LarkCredentials, + domain: str, + callback: Callable[[adapter_module._SDKEventEnvelope, Callable[[], None]], None], + ) -> _RecordedSDKStreamClient: + client = _RecordedSDKStreamClient(credentials, domain, callback) + clients.append(client) + return client + + monkeypatch.setattr(adapter_module, "_create_sdk_stream_client", create_client) + consumer = _RecordingConsumer(acceptance) + adapter = LarkIMProviderAdapter(_credentials()) + accepted = adapter.dynamic_card_messaging.send_card( + ProviderUserId("union_lark_user"), _live_intent("stream"), CorrelationToken("lark-live-stream-correlation") + ) + assert isinstance(accepted, MessageAccepted) + stream = adapter.create_stream_handler(consumer) + try: + stream.start() + finally: + stream.stop() + adapter.close() + + assert len(consumer.events) == 1 + assert replay.exchanges["stream_card"].request in replay.requests + event = consumer.events[0] + assert event.provider is IMProvider.LARK + assert event.ingress_kind is IMEventIngressKind.STREAM + assert event.provider_tenant_id == capture.authenticated_event.provider_tenant_id + assert event.event_id == capture.authenticated_event.event_id + assert event.payload == capture.stream.sdk_serialization.value + assert LarkIMProviderAdapter.card_event_decoder().decode(event) == IMCardEvent( + provider_user_id=ProviderUserId("union_lark_user"), + action_id="reject", + inputs={"comment": "Ready to ship", "decision": "hold"}, + correlation_token=CorrelationToken("lark-live-stream-correlation"), + ) + assert clients[0].stopped is True + assert len(clients[0].writes) == 1 + response = Frame() + response.ParseFromString(clients[0].writes[0]) + if acceptance is EventAcceptance.ACCEPTED: + assert json.loads(response.payload) == json.loads(ack.response_payload) + assert json.loads(base64.b64decode(json.loads(response.payload)["data"])) == {} + else: + assert json.loads(response.payload)["code"] == 500 + + +def test_lark_captured_stream_card_reference_updates_only_the_sent_message(replay: _LarkHTTPReplay) -> None: + adapter = LarkIMProviderAdapter(_credentials()) + accepted = adapter.dynamic_card_messaging.send_card( + ProviderUserId("union_lark_user"), _live_intent("stream"), CorrelationToken("lark-live-stream-correlation") + ) + assert isinstance(accepted, MessageAccepted) + + assert ( + adapter.dynamic_card_messaging.replace_with_static( + accepted.locator, StaticCardIntent("Lark stream capture completed.") + ) + is None + ) + assert replay.requests[-1] == replay.exchanges["stream_replace"].request diff --git a/api/tests/unit_tests/core/human_input_v2/im_provider/test_message_locator_conformance.py b/api/tests/unit_tests/core/human_input_v2/im_provider/test_message_locator_conformance.py new file mode 100644 index 00000000000..ed99f7c34cc --- /dev/null +++ b/api/tests/unit_tests/core/human_input_v2/im_provider/test_message_locator_conformance.py @@ -0,0 +1,399 @@ +from __future__ import annotations + +import base64 +import inspect +import json +import re +from dataclasses import dataclass +from pathlib import Path +from typing import Annotated, Literal, Self, TypeAliasType, get_args, get_origin, get_type_hints + +import pytest +from hypothesis import given, settings +from hypothesis import strategies as st +from pydantic import ValidationError + +from core.human_input_v2.entities import IMProvider +from core.human_input_v2.im_integration.adapters import MessageLocator +from core.human_input_v2.im_integration.adapters import dingtalk as dingtalk_module +from core.human_input_v2.im_integration.adapters import feishu_lark as feishu_lark_module +from core.human_input_v2.im_integration.adapters import ms_teams as ms_teams_module +from core.human_input_v2.im_integration.adapters import slack as slack_module +from core.human_input_v2.im_integration.adapters import wecom as wecom_module +from core.human_input_v2.im_integration.adapters.message_locator import _Base64JSONLocatorPayload + +_URLSAFE_BASE64_PATTERN = re.compile(r"^[A-Za-z0-9_-]+$") +_ASCII_TEXT = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_:/.@" + + +@dataclass(frozen=True, slots=True) +class _PayloadCase: + payload_cls: type[ + slack_module._SlackLocatorPayload + | feishu_lark_module._FeishuLarkLocatorPayload + | ms_teams_module._MSTeamsLocatorPayload + | dingtalk_module._DingTalkLocatorPayload + | wecom_module._WeComLocatorPayload + ] + example: dict[str, object] + valid_providers: tuple[IMProvider, ...] + expected_fields: frozenset[str] + class_source_snippets: tuple[str, ...] + + +def _payload_cases() -> tuple[_PayloadCase, ...]: + return ( + _PayloadCase( + payload_cls=slack_module._SlackLocatorPayload, + example={ + "v": 1, + "p": IMProvider.SLACK, + "channel_id": "D0123456789", + "message_ts": "1712345678.123456", + }, + valid_providers=(IMProvider.SLACK,), + expected_fields=frozenset({"v", "p", "channel_id", "message_ts"}), + class_source_snippets=( + "# version of the locator", + "# provider of the locator", + "# Slack channel containing the message to update:", + "# https://docs.slack.dev/reference/methods/chat.update/", + "# Slack timestamp of the message to update:", + ), + ), + _PayloadCase( + payload_cls=feishu_lark_module._FeishuLarkLocatorPayload, + example={ + "v": 1, + "p": IMProvider.FEISHU, + "message_id": "om_sanitized_card", + }, + valid_providers=(IMProvider.FEISHU, IMProvider.LARK), + expected_fields=frozenset({"v", "p", "message_id"}), + class_source_snippets=( + "# version of the locator", + "# provider of the locator", + "# Feishu/Lark message identifier used to update the card:", + "# Feishu: https://open.feishu.cn/document/uAjLw4CM/ukTMukTMukTM/reference/im-v1/message/patch", + "# Lark: https://open.larksuite.com/document/server-docs/im-v1/message-card/patch", + ), + ), + _PayloadCase( + payload_cls=dingtalk_module._DingTalkLocatorPayload, + example={ + "v": 1, + "p": IMProvider.DING_TALK, + "process_query_key": "sanitized-process-key", + }, + valid_providers=(IMProvider.DING_TALK,), + expected_fields=frozenset({"v", "p", "process_query_key"}), + class_source_snippets=( + "# version of the locator", + "# provider of the locator", + "# DingTalk process query key returned for the sent message:", + "# https://open.dingtalk.com/document/orgapp/chatbots-send-one-on-one-chat-messages-in-batches.md", + ), + ), + _PayloadCase( + payload_cls=wecom_module._WeComLocatorPayload, + example={ + "v": 1, + "p": IMProvider.WE_COM, + "message_id": "fake-message-id-001", + }, + valid_providers=(IMProvider.WE_COM,), + expected_fields=frozenset({"v", "p", "message_id"}), + class_source_snippets=( + "# version of the locator", + "# provider of the locator", + "# WeCom application message identifier returned by the send API:", + "# https://developer.work.weixin.qq.com/document/path/90236", + ), + ), + _PayloadCase( + payload_cls=ms_teams_module._MSTeamsLocatorPayload, + example={ + "v": 1, + "p": IMProvider.MS_TEAMS, + "service_url": "https://smba.trafficmanager.net/teams/", + "conversation_id": "sanitized-conversation", + "activity_id": "sanitized-activity", + }, + valid_providers=(IMProvider.MS_TEAMS,), + expected_fields=frozenset({"v", "p", "service_url", "conversation_id", "activity_id"}), + class_source_snippets=( + "# version of the locator", + "# provider of the locator", + "# Bot Framework service endpoint used for subsequent message operations:", + "# https://learn.microsoft.com/en-us/azure/bot-service/rest-api/bot-framework-rest-connector-api-reference?view=azure-bot-service-4.0", + "# Bot Framework conversation containing the activity:", + "# Bot Framework activity identifier of the exact message:", + ), + ), + ) + + +def _json_text_round_trip(locator: MessageLocator) -> MessageLocator: + stored = json.dumps({"locator": str(locator)}, separators=(",", ":")) + recovered = json.loads(stored)["locator"] + assert isinstance(recovered, str) + return MessageLocator(recovered) + + +def _make_invalid_json_locator(payload: bytes) -> str: + return base64.urlsafe_b64encode(payload).rstrip(b"=").decode("ascii") + + +def _non_blank_text(*, max_size: int = 24) -> st.SearchStrategy[str]: + return st.text(alphabet=_ASCII_TEXT, min_size=1, max_size=max_size) + + +@st.composite +def _teams_service_url(draw: st.DrawFn) -> str: + path = draw(st.text(alphabet="abcdefghijklmnopqrstuvwxyz0123456789-/", min_size=0, max_size=24)) + return f"https://smba.trafficmanager.net/{path}" if path else "https://smba.trafficmanager.net/teams/" + + +def _is_scalar_like(annotation: object) -> bool: + if isinstance(annotation, TypeAliasType): + return _is_scalar_like(annotation.__value__) + origin = get_origin(annotation) + if origin is None: + return annotation in (str, int, IMProvider) or isinstance(annotation, (str, int, IMProvider)) + if origin is Literal: + return all(isinstance(argument, (str, int, IMProvider)) for argument in get_args(annotation)) + if origin is Annotated: + return _is_scalar_like(get_args(annotation)[0]) + return False + + +@pytest.mark.parametrize("case", _payload_cases(), ids=lambda case: case.payload_cls.__name__) +def test_private_locator_payload_models_are_strict_frozen_scalar_only_and_provider_typed( + case: _PayloadCase, +) -> None: + payload_cls = case.payload_cls + payload = payload_cls.model_validate(case.example) + + assert issubclass(payload_cls, _Base64JSONLocatorPayload) + assert payload_cls.model_config.get("strict") is True + assert payload_cls.model_config.get("frozen") is True + assert payload_cls.model_config.get("extra") == "forbid" + assert payload_cls.model_fields["v"].is_required() + assert payload_cls.model_fields["p"].is_required() + assert get_type_hints(payload_cls.encode)["return"] is MessageLocator + assert get_type_hints(payload_cls.decode)["return"] in {Self, payload_cls} + + mutable_field = next(field_name for field_name in case.example if field_name not in {"v", "p"}) + pytest.raises(ValidationError, setattr, payload, mutable_field, "mutated") + with pytest.raises(ValidationError): + payload_cls.model_validate({**case.example, "unexpected": "value"}) + with pytest.raises(ValidationError): + payload_cls.model_validate({**case.example, "v": "1"}) + blank_field = next(field_name for field_name in case.example if field_name not in {"v", "p"}) + with pytest.raises(ValidationError): + payload_cls.model_validate({**case.example, blank_field: " "}) + with pytest.raises(ValidationError): + payload_cls.model_validate({key: value for key, value in case.example.items() if key != "v"}) + with pytest.raises(ValidationError): + payload_cls.model_validate({key: value for key, value in case.example.items() if key != "p"}) + with pytest.raises(ValidationError): + payload_cls.model_validate({**case.example, "v": 2}) + assert payload_cls.model_validate({**case.example, "p": case.example["p"]}) == payload + + for valid_provider in case.valid_providers: + validated = payload_cls.model_validate({**case.example, "p": valid_provider}) + assert isinstance(validated.p, IMProvider) + assert validated.p is valid_provider + + wrong_provider = next(provider for provider in IMProvider if provider not in case.valid_providers) + with pytest.raises(ValidationError): + payload_cls.model_validate({**case.example, "p": wrong_provider}) + + for field_name, field in payload_cls.model_fields.items(): + assert _is_scalar_like(field.annotation), field_name + + +@pytest.mark.parametrize("case", _payload_cases(), ids=lambda case: case.payload_cls.__name__) +def test_private_locator_payload_source_comments_and_urls_are_verbatim(case: _PayloadCase) -> None: + payload_source = inspect.getsource(case.payload_cls) + for snippet in case.class_source_snippets: + assert snippet in payload_source + + +@pytest.mark.parametrize("case", _payload_cases(), ids=lambda case: case.payload_cls.__name__) +def test_locator_json_shape_is_exact_and_round_trips_through_public_scalar_boundary(case: _PayloadCase) -> None: + payload = case.payload_cls.model_validate(case.example) + encoded = payload.encode() + recovered_locator = _json_text_round_trip(MessageLocator(encoded)) + decoded_payload = case.payload_cls.decode(str(recovered_locator)) + serialized_payload = base64.urlsafe_b64decode(str(recovered_locator) + ("=" * (-len(str(recovered_locator)) % 4))) + decoded_json = json.loads(serialized_payload) + + assert decoded_payload == payload + assert isinstance(decoded_json, dict) + assert set(decoded_json) == case.expected_fields + assert decoded_json["v"] == 1 + expected_provider = case.example["p"] + assert isinstance(expected_provider, IMProvider) + assert decoded_json["p"] == expected_provider.value + + +@pytest.mark.parametrize( + ("payload_cls", "invalid_locator"), + [ + pytest.param(slack_module._SlackLocatorPayload, "invalid.", id="slack-invalid-alphabet"), + pytest.param(slack_module._SlackLocatorPayload, "AA=", id="slack-malformed-padding"), + pytest.param(slack_module._SlackLocatorPayload, "A", id="slack-invalid-length"), + pytest.param( + slack_module._SlackLocatorPayload, + _make_invalid_json_locator(b"{"), + id="slack-malformed-json", + ), + pytest.param( + slack_module._SlackLocatorPayload, + _make_invalid_json_locator(b'{"v":1,"p":"slack"}'), + id="slack-incomplete-json", + ), + pytest.param(feishu_lark_module._FeishuLarkLocatorPayload, "invalid.", id="feishu-invalid-alphabet"), + pytest.param(feishu_lark_module._FeishuLarkLocatorPayload, "AA=", id="feishu-malformed-padding"), + pytest.param(feishu_lark_module._FeishuLarkLocatorPayload, "A", id="feishu-invalid-length"), + pytest.param(dingtalk_module._DingTalkLocatorPayload, "invalid.", id="dingtalk-invalid-alphabet"), + pytest.param(dingtalk_module._DingTalkLocatorPayload, "AA=", id="dingtalk-malformed-padding"), + pytest.param(dingtalk_module._DingTalkLocatorPayload, "A", id="dingtalk-invalid-length"), + pytest.param(wecom_module._WeComLocatorPayload, "invalid.", id="wecom-invalid-alphabet"), + pytest.param(wecom_module._WeComLocatorPayload, "AA=", id="wecom-malformed-padding"), + pytest.param(wecom_module._WeComLocatorPayload, "A", id="wecom-invalid-length"), + pytest.param(ms_teams_module._MSTeamsLocatorPayload, "invalid.", id="teams-invalid-alphabet"), + pytest.param(ms_teams_module._MSTeamsLocatorPayload, "AA=", id="teams-malformed-padding"), + pytest.param(ms_teams_module._MSTeamsLocatorPayload, "A", id="teams-invalid-length"), + ], +) +def test_locator_decoder_rejects_invalid_public_scalar_forms( + payload_cls: type[_Base64JSONLocatorPayload], + invalid_locator: str, +) -> None: + with pytest.raises((ValidationError, ValueError)): + payload_cls.decode(invalid_locator) + + +def test_locator_codec_helper_owns_model_json_and_base64_conversion_without_json_dumps_loads() -> None: + helper_source = Path(inspect.getsourcefile(_Base64JSONLocatorPayload) or "").read_text(encoding="utf-8") + + assert 'self.model_dump_json().encode("utf-8")' in helper_source + assert "return cls.model_validate_json(serialized_payload)" in helper_source + assert helper_source.count("urlsafe_b64encode") == 1 + assert "json.dumps" not in helper_source + assert "json.loads" not in helper_source + + +def test_adapter_modules_do_not_duplicate_locator_codec_logic() -> None: + module_paths = ( + Path(slack_module.__file__).resolve(), + Path(feishu_lark_module.__file__).resolve(), + Path(dingtalk_module.__file__).resolve(), + Path(wecom_module.__file__).resolve(), + Path(ms_teams_module.__file__).resolve(), + ) + + for module_path in module_paths: + source = module_path.read_text(encoding="utf-8") + assert "urlsafe_b64encode" not in source + assert ".model_dump_json()" not in source + assert "_encode_reference(" not in source + assert "_decode_reference(" not in source + assert "_encode_message_locator(" not in source + assert "_decode_message_locator(" not in source + assert "_urlsafe_encode(" not in source + assert "_urlsafe_decode(" not in source + + +@settings(max_examples=40) +@given( + channel_id=_non_blank_text(), + message_ts=st.tuples( + st.integers(min_value=0, max_value=10**12), + st.integers(min_value=0, max_value=999999), + ).map(lambda parts: f"{parts[0]}.{parts[1]:06d}"), +) +def test_slack_locator_payload_obeys_the_codec_law(channel_id: str, message_ts: str) -> None: + payload = slack_module._SlackLocatorPayload( + v=1, + p=IMProvider.SLACK, + channel_id=channel_id, + message_ts=message_ts, + ) + encoded = payload.encode() + + assert _URLSAFE_BASE64_PATTERN.fullmatch(encoded) is not None + assert slack_module._SlackLocatorPayload.decode(encoded) == payload + + +@settings(max_examples=40) +@given( + provider=st.sampled_from((IMProvider.FEISHU, IMProvider.LARK)), + message_id=_non_blank_text(), +) +def test_feishu_lark_locator_payload_obeys_the_codec_law( + provider: Literal[IMProvider.FEISHU, IMProvider.LARK], message_id: str +) -> None: + payload = feishu_lark_module._FeishuLarkLocatorPayload( + v=1, + p=provider, + message_id=message_id, + ) + encoded = payload.encode() + + assert _URLSAFE_BASE64_PATTERN.fullmatch(encoded) is not None + assert feishu_lark_module._FeishuLarkLocatorPayload.decode(encoded) == payload + + +@settings(max_examples=40) +@given(process_query_key=_non_blank_text()) +def test_dingtalk_locator_payload_obeys_the_codec_law(process_query_key: str) -> None: + payload = dingtalk_module._DingTalkLocatorPayload( + v=1, + p=IMProvider.DING_TALK, + process_query_key=process_query_key, + ) + encoded = payload.encode() + + assert _URLSAFE_BASE64_PATTERN.fullmatch(encoded) is not None + assert dingtalk_module._DingTalkLocatorPayload.decode(encoded) == payload + + +@settings(max_examples=40) +@given(message_id=_non_blank_text()) +def test_wecom_locator_payload_obeys_the_codec_law(message_id: str) -> None: + payload = wecom_module._WeComLocatorPayload( + v=1, + p=IMProvider.WE_COM, + message_id=message_id, + ) + encoded = payload.encode() + + assert _URLSAFE_BASE64_PATTERN.fullmatch(encoded) is not None + assert wecom_module._WeComLocatorPayload.decode(encoded) == payload + + +@settings(max_examples=40) +@given( + service_url=_teams_service_url(), + conversation_id=_non_blank_text(), + activity_id=_non_blank_text(), +) +def test_ms_teams_locator_payload_obeys_the_codec_law( + service_url: str, + conversation_id: str, + activity_id: str, +) -> None: + payload = ms_teams_module._MSTeamsLocatorPayload( + v=1, + p=IMProvider.MS_TEAMS, + service_url=service_url, + conversation_id=conversation_id, + activity_id=activity_id, + ) + encoded = payload.encode() + + assert _URLSAFE_BASE64_PATTERN.fullmatch(encoded) is not None + assert ms_teams_module._MSTeamsLocatorPayload.decode(encoded) == payload diff --git a/api/tests/unit_tests/core/human_input_v2/im_provider/test_ms_teams_adapter.py b/api/tests/unit_tests/core/human_input_v2/im_provider/test_ms_teams_adapter.py new file mode 100644 index 00000000000..b20db94d880 --- /dev/null +++ b/api/tests/unit_tests/core/human_input_v2/im_provider/test_ms_teams_adapter.py @@ -0,0 +1,1727 @@ +from __future__ import annotations + +import base64 +import importlib.util +import json +import re +import threading +from collections.abc import Callable +from concurrent.futures import ThreadPoolExecutor +from datetime import datetime +from pathlib import Path +from unittest.mock import MagicMock + +import httpx +import jwt +import pytest +import requests +from azure.core.credentials import AccessToken +from azure.core.exceptions import ClientAuthenticationError +from botbuilder.schema import ConversationResourceResponse, ResourceResponse +from botframework.connector.auth import ChannelValidation, ClaimsIdentity, JwtTokenExtractor +from cryptography.hazmat.primitives.asymmetric import rsa +from jwt.algorithms import RSAAlgorithm +from msrest.exceptions import HttpOperationError +from pytest_mock import MockerFixture + +from core.human_input import ButtonStyle +from core.human_input_v2.entities import IMProvider +from core.human_input_v2.im_integration.adapters import ( + AuthenticatedIMEvent, + CardAssessment, + CorrelationToken, + CredentialTestFailure, + CredentialTestFailureKind, + CredentialTestSuccess, + Directory, + DirectoryReadFailure, + DynamicCardMessagingError, + EventAcceptance, + IMEventConsumer, + IMEventIngressKind, + IMWebhookHandler, + MessageAccepted, + MessageLocator, + MessageSendingError, + MSTeamsCredentials, + ProviderUserId, + ReplacementError, + ReplacementErrorKind, + StaticCardIntent, + WebhookRequest, + ms_teams, +) +from core.human_input_v2.resolved_form import ( + FileInput, + FileListInput, + FormPart, + MarkdownFragment, + ParagraphInput, + ResolvedForm, + SelectInput, + UserAction, +) + +_RECEIVED_AT = datetime(2026, 8, 6, 8, 0, 0) +_WEBHOOK_FIXTURE_PATH = Path(__file__).parents[4] / "fixtures" / "im_provider" / "ms_teams_webhook_activity.json" +_UUID_PATTERN = re.compile(r"\b[0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}\b") + + +def _credentials() -> MSTeamsCredentials: + return MSTeamsCredentials( + provider=IMProvider.MS_TEAMS, + tenant_id="11111111-1111-1111-1111-111111111111", + client_id="22222222-2222-2222-2222-222222222222", + client_secret="test-only-client-secret", + ) + + +class _Consumer: + def accept(self, event: AuthenticatedIMEvent, *, request_ip_address: str | None = None) -> EventAcceptance: + del request_ip_address + del event + return EventAcceptance.ACCEPTED + + +class _RecordingConsumer: + def __init__(self, acceptance: EventAcceptance = EventAcceptance.ACCEPTED) -> None: + self.acceptance = acceptance + self.events = [] + + def accept(self, event: AuthenticatedIMEvent, *, request_ip_address: str | None = None) -> EventAcceptance: + del request_ip_address + self.events.append(event) + return self.acceptance + + +class _ThreadSafeRecordingConsumer: + def __init__(self) -> None: + self._lock = threading.Lock() + self.events = [] + + def accept(self, event: AuthenticatedIMEvent, *, request_ip_address: str | None = None) -> EventAcceptance: + del request_ip_address + with self._lock: + self.events.append(event) + return EventAcceptance.ACCEPTED + + +class _FailingConsumer: + def accept(self, event: AuthenticatedIMEvent, *, request_ip_address: str | None = None) -> EventAcceptance: + del request_ip_address + del event + raise RuntimeError("sensitive consumer details") + + +class _ProviderMetadataResponse: + def __init__(self, response_body: dict[str, object]) -> None: + self._response_body = response_body + + def raise_for_status(self) -> None: + return None + + def json(self) -> dict[str, object]: + return self._response_body + + +def _unsigned_token(claims: dict[str, object]) -> str: + header = base64.urlsafe_b64encode(b'{"alg":"none"}').rstrip(b"=").decode() + payload = base64.urlsafe_b64encode(json.dumps(claims).encode()).rstrip(b"=").decode() + return f"{header}.{payload}." + + +def _adapter_with_tokens( + mocker: MockerFixture, *, graph_claims: dict[str, object], bot_claims: dict[str, object] +) -> tuple[ms_teams.MSTeamsIMProviderAdapter, MagicMock, MagicMock, MagicMock]: + mocker.patch("core.file.remote_fetcher.make_request", return_value=httpx.Response(404)) + graph_credential = mocker.patch.object(ms_teams, "ClientSecretCredential", autospec=True).return_value + graph_credential.get_token.return_value = AccessToken(expires_on=2000000000, token=_unsigned_token(graph_claims)) + graph_client = mocker.patch.object(ms_teams.httpx, "Client", autospec=True).return_value + graph_client.get.return_value = httpx.Response(200, json={"value": []}) + bot_credentials = mocker.patch.object(ms_teams, "_MSTeamsBotCredentials", autospec=True).return_value + bot_credentials.get_access_token.return_value = _unsigned_token(bot_claims) + adapter = ms_teams.MSTeamsIMProviderAdapter(_credentials()) + return adapter, graph_credential, graph_client, bot_credentials + + +def _locator( + *, + service_url: str = "https://smba.trafficmanager.net/teams/", + conversation_id: str = "sanitized-conversation", + activity_id: str = "sanitized-card-activity", +) -> MessageLocator: + return MessageLocator( + ms_teams._MSTeamsLocatorPayload( + v=1, + p=IMProvider.MS_TEAMS, + service_url=service_url, + conversation_id=conversation_id, + activity_id=activity_id, + ).encode() + ) + + +def _card_intent( + *, + input_type: str = "paragraph", + markdown_text: str = "Rendered **content**", + action_style: ButtonStyle = ButtonStyle.PRIMARY, +) -> ResolvedForm: + if input_type == "select": + input_block: FormPart = SelectInput(output_variable_name="comment", options=("One", "Two"), default_value="One") + elif input_type == "file": + input_block = FileInput( + output_variable_name="comment", + allowed_file_types=(), + allowed_file_extensions=(), + allowed_file_upload_methods=(), + ) + elif input_type == "file-list": + input_block = FileListInput( + output_variable_name="comment", + allowed_file_types=(), + allowed_file_extensions=(), + allowed_file_upload_methods=(), + number_limits=1, + ) + else: + input_block = ParagraphInput(output_variable_name="comment", default_value="Initial") + return ResolvedForm( + title="Approval", + parts=(MarkdownFragment(text=markdown_text), input_block, MarkdownFragment(text="After input")), + actions=( + UserAction(id="approve", title="Approve", button_style=action_style), + UserAction(id="reject", title="Reject", button_style=ButtonStyle.ACCENT), + ), + legacy_form_content="This value must not be rendered", + ) + + +def _custom_card_intent( + *, + blocks: tuple[FormPart, ...], + title: str = "Approval", + action_style: ButtonStyle = ButtonStyle.DEFAULT, +) -> ResolvedForm: + return ResolvedForm( + title=title, + parts=blocks, + actions=(UserAction(id="approve", title="Approve", button_style=action_style),), + legacy_form_content="This value must not be rendered", + ) + + +def _teams_activity_body(*, include_event_id: bool = True) -> dict[str, object]: + activity = json.loads(_WEBHOOK_FIXTURE_PATH.read_text()) + assert isinstance(activity, dict) + conversation = activity["conversation"] + channel_data = activity["channelData"] + assert isinstance(conversation, dict) + assert isinstance(channel_data, dict) + tenant = channel_data["tenant"] + assert isinstance(tenant, dict) + conversation["tenantId"] = _credentials().tenant_id + tenant["id"] = _credentials().tenant_id + if include_event_id: + activity["id"] = "sanitized-provider-event" + else: + activity.pop("id", None) + return activity + + +def _webhook_request(activity: dict[str, object], *, authorization: str = "Bearer sanitized-token") -> WebhookRequest: + return WebhookRequest( + method="POST", + headers=( + ("Authorization", authorization), + ("Content-Type", "application/json; charset=utf-8"), + ), + body=json.dumps(activity, separators=(",", ":")).encode(), + received_at=_RECEIVED_AT, + ) + + +def _authenticated_webhook_handler( + mocker: MockerFixture, consumer: IMEventConsumer +) -> tuple[ms_teams.MSTeamsIMProviderAdapter, IMWebhookHandler, MagicMock]: + adapter, _, _, _ = _adapter_with_tokens( + mocker, + graph_claims={ + "aud": "https://graph.microsoft.com", + "tid": _credentials().tenant_id, + "roles": ["User.Read.All"], + }, + bot_claims={ + "aud": "https://api.botframework.com", + "appid": _credentials().client_id, + "tid": _credentials().tenant_id, + }, + ) + authenticate_request = mocker.patch.object( + ms_teams.JwtTokenValidation, + "authenticate_request", + autospec=True, + ) + authenticate_request.return_value = ClaimsIdentity( + {"aud": _credentials().client_id, "iss": "https://api.botframework.com"}, + True, + ) + return adapter, adapter.create_webhook_handler(consumer), authenticate_request + + +def test_ms_teams_adapter_module_is_available() -> None: + assert importlib.util.find_spec("core.human_input_v2.im_integration.adapters.ms_teams") is not None + + +def test_ms_teams_adapter_class_is_available() -> None: + assert hasattr(ms_teams, "MSTeamsIMProviderAdapter") + + +def test_ms_teams_adapter_rejects_any_other_credential_type() -> None: + # Model an untyped caller to retain the runtime rejection beyond static guarantees. + adapter_constructor: Callable[..., ms_teams.MSTeamsIMProviderAdapter] = ms_teams.MSTeamsIMProviderAdapter + with pytest.raises(TypeError): + adapter_constructor(None) + + +def test_construction_exposes_webhook_only_capabilities_without_provider_io(mocker: MockerFixture) -> None: + assert all(hasattr(ms_teams, name) for name in ("ClientSecretCredential", "_MSTeamsBotCredentials", "httpx")) + graph_credential = mocker.patch.object(ms_teams, "ClientSecretCredential", autospec=True).return_value + graph_client = mocker.patch.object(ms_teams.httpx, "Client", autospec=True).return_value + bot_credentials = mocker.patch.object(ms_teams, "_MSTeamsBotCredentials", autospec=True).return_value + adapter = ms_teams.MSTeamsIMProviderAdapter(_credentials()) + + assert adapter.provider.value == "ms_teams" + assert adapter.directory is adapter.directory + assert adapter.messaging is adapter.messaging + assert adapter.dynamic_card_messaging is adapter.dynamic_card_messaging + assert adapter.create_webhook_handler(_Consumer()) is not None + assert adapter.create_stream_handler(_Consumer()) is None + graph_credential.get_token.assert_not_called() + graph_client.request.assert_not_called() + bot_credentials.get_access_token.assert_not_called() + + adapter.close() + adapter.close() + assert graph_credential.close.call_count == 2 + assert graph_client.close.call_count == 2 + + +def test_credential_test_confirms_graph_permission_bot_auth_and_tenant(mocker: MockerFixture) -> None: + adapter, graph_credential, graph_client, bot_credentials = _adapter_with_tokens( + mocker, + graph_claims={}, + bot_claims={}, + ) + graph_credential.get_token.return_value = AccessToken(expires_on=2000000000, token="opaque-graph-access-token") + bot_credentials.get_access_token.return_value = "opaque-bot-access-token" + graph_client.get.return_value = httpx.Response(200, json={"value": []}) + + result = adapter.test_credentials() + + assert result == CredentialTestSuccess(adapter.provider, _credentials().tenant_id) + graph_client.get.assert_called_once_with( + "https://graph.microsoft.com/v1.0/users", + headers={"Authorization": "Bearer opaque-graph-access-token"}, + params={"$select": "id,displayName,mail", "$top": 1}, + ) + bot_credentials.get_access_token.assert_called_once_with() + + +@pytest.mark.parametrize("rejected_scope", ["graph", "bot"]) +def test_credential_test_rejects_revoked_credentials_after_a_cached_success( + monkeypatch: pytest.MonkeyPatch, + mocker: MockerFixture, + rejected_scope: str, +) -> None: + credentials_valid = True + + class GraphCredential: + def __init__(self, *_args: object, **_kwargs: object) -> None: + self.cached = False + + def get_token(self, _scope: str) -> AccessToken: + if not self.cached and not credentials_valid and rejected_scope == "graph": + raise ClientAuthenticationError("AADSTS7000215: invalid client secret") + self.cached = True + return AccessToken(expires_on=2000000000, token="opaque-graph-token") + + def close(self) -> None: + pass + + class BotCredentials: + def __init__(self, *_args: object, **_kwargs: object) -> None: + self.cached = False + + def get_access_token(self) -> str: + if not self.cached and not credentials_valid and rejected_scope == "bot": + raise PermissionError("Failed to get access token with error: invalid_client") + self.cached = True + return "opaque-bot-token" + + monkeypatch.setattr(ms_teams, "ClientSecretCredential", GraphCredential) + monkeypatch.setattr(ms_teams, "_MSTeamsBotCredentials", BotCredentials) + graph_client = mocker.patch.object(ms_teams.httpx, "Client", autospec=True).return_value + graph_client.get.return_value = httpx.Response(200, json={"value": []}) + adapter = ms_teams.MSTeamsIMProviderAdapter(_credentials()) + + assert adapter.test_credentials() == CredentialTestSuccess(IMProvider.MS_TEAMS, _credentials().tenant_id) + credentials_valid = False + rejected = adapter.test_credentials() + + assert isinstance(rejected, CredentialTestFailure) + assert rejected.kind is CredentialTestFailureKind.AUTHENTICATION_REJECTED + + +def test_credential_test_returns_safe_typed_failures(mocker: MockerFixture) -> None: + adapter, _, graph_client, bot_credentials = _adapter_with_tokens(mocker, graph_claims={}, bot_claims={}) + graph_client.get.return_value = httpx.Response( + 403, + json={ + "error": {"code": "Authorization_RequestDenied", "message": "Insufficient privileges"}, + }, + ) + + denied = adapter.test_credentials() + + assert isinstance(denied, CredentialTestFailure) + assert denied.kind is CredentialTestFailureKind.PERMISSION_DENIED + assert denied.http_status == 403 + assert denied.provider_error_code == "Authorization_RequestDenied" + assert denied.missing_permissions == () + + graph_client.get.return_value = httpx.Response(200, json={"value": []}) + bot_credentials.get_access_token.side_effect = PermissionError( + "Failed to get access token with error: invalid_client, error_description: sensitive provider details" + ) + rejected = adapter.test_credentials() + assert isinstance(rejected, CredentialTestFailure) + assert rejected.kind is CredentialTestFailureKind.AUTHENTICATION_REJECTED + assert rejected.provider_error_code == "invalid_client" + assert "sensitive provider details" not in rejected.reason + + +def test_credential_test_treats_transient_bot_auth_failure_as_unknown(mocker: MockerFixture) -> None: + adapter, _, _, bot_credentials = _adapter_with_tokens( + mocker, + graph_claims={ + "aud": "https://graph.microsoft.com", + "tid": _credentials().tenant_id, + "roles": ["User.Read.All"], + }, + bot_claims={ + "aud": "https://api.botframework.com", + "appid": _credentials().client_id, + "tid": _credentials().tenant_id, + }, + ) + bot_credentials.get_access_token.side_effect = PermissionError( + "Failed to get access token with error: temporarily_unavailable, error_description: test-only upstream outage" + ) + + result = adapter.test_credentials() + + assert isinstance(result, CredentialTestFailure) + assert result.kind is CredentialTestFailureKind.UNKNOWN + + +def test_credential_test_preserves_the_configured_tenant_identity(mocker: MockerFixture) -> None: + credentials = MSTeamsCredentials( + provider=IMProvider.MS_TEAMS, + tenant_id="AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA", + client_id="BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB", + client_secret="test-only-client-secret", + ) + graph_credential = mocker.patch.object(ms_teams, "ClientSecretCredential", autospec=True).return_value + graph_credential.get_token.return_value = AccessToken(expires_on=2000000000, token="opaque-graph-access-token") + graph_client = mocker.patch.object(ms_teams.httpx, "Client", autospec=True).return_value + graph_client.get.return_value = httpx.Response(200, json={"value": []}) + bot_credentials = mocker.patch.object(ms_teams, "_MSTeamsBotCredentials", autospec=True).return_value + bot_credentials.get_access_token.return_value = "opaque-bot-access-token" + adapter = ms_teams.MSTeamsIMProviderAdapter(credentials) + + result = adapter.test_credentials() + + assert result == CredentialTestSuccess(adapter.provider, credentials.tenant_id.lower()) + + +def test_credential_test_propagates_graph_and_bot_execution_failures( + mocker: MockerFixture, caplog: pytest.LogCaptureFixture +) -> None: + adapter, graph_credential, graph_client, bot_credentials = _adapter_with_tokens( + mocker, + graph_claims={}, + bot_claims={}, + ) + graph_credential.get_token.side_effect = ClientAuthenticationError("AADSTS7000215: sensitive graph rejection") + graph_rejected = adapter.test_credentials() + assert isinstance(graph_rejected, CredentialTestFailure) + assert graph_rejected.kind is CredentialTestFailureKind.AUTHENTICATION_REJECTED + assert graph_rejected.provider_error_code == "AADSTS7000215" + + graph_credential.get_token.side_effect = RuntimeError("sensitive graph failure") + with pytest.raises(RuntimeError) as captured: + adapter.test_credentials() + assert captured.value is graph_credential.get_token.side_effect + + graph_credential.get_token.side_effect = None + graph_client.get.side_effect = httpx.ReadTimeout("sensitive Graph request timeout") + with pytest.raises(ms_teams.CredentialTestTimeoutError) as captured_timeout: + adapter.test_credentials() + assert captured_timeout.value.__cause__ is graph_client.get.side_effect + + graph_client.get.side_effect = None + bot_credentials.get_access_token.side_effect = RuntimeError("sensitive bot failure") + with pytest.raises(RuntimeError) as captured: + adapter.test_credentials() + assert captured.value is bot_credentials.get_access_token.side_effect + assert "sensitive graph rejection" not in caplog.text + assert "sensitive graph failure" not in caplog.text + assert "sensitive bot failure" not in caplog.text + + +def test_directory_returns_one_ordered_complete_graph_snapshot(mocker: MockerFixture) -> None: + adapter, _, graph_client, _ = _adapter_with_tokens( + mocker, + graph_claims={ + "aud": "https://graph.microsoft.com", + "tid": _credentials().tenant_id, + "roles": ["User.Read.All"], + }, + bot_claims={ + "aud": "https://api.botframework.com", + "appid": _credentials().client_id, + "tid": _credentials().tenant_id, + }, + ) + graph_client.get.side_effect = [ + httpx.Response( + 200, + request=httpx.Request("GET", "https://graph.microsoft.com/v1.0/users"), + json={ + "value": [ + {"id": "sanitized-user-a", "displayName": "Ada", "mail": "ada@example.test"}, + {"id": "sanitized-user-b", "displayName": None, "mail": None}, + ], + "@odata.nextLink": "https://graph.microsoft.com/v1.0/users?$skiptoken=test-only-page", + }, + ), + httpx.Response( + 200, + request=httpx.Request("GET", "https://graph.microsoft.com/v1.0/users"), + json={"value": [{"id": "sanitized-user-c", "displayName": "Lin", "mail": None}]}, + ), + ] + + assert hasattr(adapter.directory, "read_directory") + snapshot = adapter.directory.read_directory() + + assert isinstance(snapshot, Directory) + assert [(str(entry.provider_user_id), entry.display_name, entry.email) for entry in snapshot.entries] == [ + ("sanitized-user-a", "Ada", "ada@example.test"), + ("sanitized-user-b", None, None), + ("sanitized-user-c", "Lin", None), + ] + assert graph_client.get.call_count == 2 + + +@pytest.mark.parametrize("status", [200, 404, 403, 500]) +def test_directory_reads_avatar_with_provider_authentication(mocker: MockerFixture, status: int) -> None: + adapter, credential, graph_client, _ = _adapter_with_tokens( + mocker, + graph_claims={ + "aud": "https://graph.microsoft.com", + "tid": _credentials().tenant_id, + "roles": ["User.Read.All"], + }, + bot_claims={"aud": "https://api.botframework.com", "appid": _credentials().client_id}, + ) + graph_client.get.return_value = httpx.Response( + 200, + json={"value": [{"id": "user/with space", "displayName": "Ada"}]}, + request=httpx.Request("GET", "https://graph.microsoft.com/v1.0/users"), + ) + download = mocker.patch( + "core.file.remote_fetcher.make_request", + return_value=httpx.Response( + status, + headers={"Content-Type": "image/jpeg"}, + content=b"avatar-image", + request=httpx.Request("GET", "https://graph.microsoft.com/v1.0/users/user/photo/$value"), + ), + ) + + result = adapter.directory.read_directory() + + assert download.call_args.args == ( + "GET", + "https://graph.microsoft.com/v1.0/users/user%2Fwith%20space/photo/$value", + ) + assert download.call_args.kwargs["headers"] == { + "Authorization": f"Bearer {credential.get_token.return_value.token}" + } + if status in (403, 500): + assert isinstance(result, DirectoryReadFailure) + return + assert isinstance(result, Directory) + if status == 404: + assert result.entries[0].avatar is None + else: + assert result.entries[0].avatar is not None + assert result.entries[0].avatar.mime_type == "image/jpeg" + assert result.entries[0].avatar.data == b"avatar-image" + + +def test_directory_reads_avatar_from_captured_graph_response(mocker: MockerFixture) -> None: + adapter, _, graph_client, _ = _adapter_with_tokens( + mocker, + graph_claims={ + "aud": "https://graph.microsoft.com", + "tid": _credentials().tenant_id, + "roles": ["User.Read.All"], + }, + bot_claims={"aud": "https://api.botframework.com", "appid": _credentials().client_id}, + ) + graph_client.get.return_value = httpx.Response( + 200, + json={"value": [{"id": "sanitized-user", "displayName": "Ada"}]}, + request=httpx.Request("GET", "https://graph.microsoft.com/v1.0/users"), + ) + fixture = Path(__file__).parents[4] / "fixtures/im_provider/ms_teams/sanitized_avatar_success.json" + captured = json.loads(fixture.read_text()) + image_bytes = base64.b64decode(captured["body"], validate=True) + mocker.patch( + "core.file.remote_fetcher.make_request", + return_value=httpx.Response( + captured["status"], + headers=captured["headers"], + content=image_bytes, + request=httpx.Request("GET", "https://graph.microsoft.com/v1.0/users/sanitized-user/photo/$value"), + ), + ) + + result = adapter.directory.read_directory() + + assert isinstance(result, Directory) + assert len(result.entries) == 1 + assert result.entries[0].provider_user_id == "sanitized-user" + assert result.entries[0].avatar is not None + assert result.entries[0].avatar.mime_type == "image/jpeg" + assert result.entries[0].avatar.data == image_bytes + + +def test_directory_batch_keeps_other_avatars_when_graph_returns_captured_image_not_found(mocker: MockerFixture) -> None: + adapter, _, graph_client, _ = _adapter_with_tokens( + mocker, + graph_claims={ + "aud": "https://graph.microsoft.com", + "tid": _credentials().tenant_id, + "roles": ["User.Read.All"], + }, + bot_claims={"aud": "https://api.botframework.com", "appid": _credentials().client_id}, + ) + graph_client.get.return_value = httpx.Response( + 200, + json={"value": [{"id": "first"}, {"id": "missing"}, {"id": "last"}]}, + request=httpx.Request("GET", "https://graph.microsoft.com/v1.0/users"), + ) + fixture = Path(__file__).parents[4] / "fixtures/im_provider/ms_teams/sanitized_avatar_not_found.json" + not_found = json.loads(fixture.read_text()) + + def download(_method: str, url: str, **_kwargs: object) -> httpx.Response: + if "/missing/" in url: + return httpx.Response(404, json=not_found, request=httpx.Request("GET", url)) + return httpx.Response( + 200, + headers={"Content-Type": "image/jpeg"}, + content=b"first" if "/first/" in url else b"last", + request=httpx.Request("GET", url), + ) + + mocker.patch("core.file.remote_fetcher.make_request", side_effect=download) + + result = adapter.directory.read_directory() + + assert isinstance(result, Directory) + assert [entry.provider_user_id for entry in result.entries] == ["first", "missing", "last"] + assert result.entries[0].avatar is not None + assert result.entries[0].avatar.data == b"first" + assert result.entries[1].avatar is None + assert result.entries[2].avatar is not None + assert result.entries[2].avatar.data == b"last" + + +def test_directory_discards_partial_entries_and_rejects_untrusted_pagination(mocker: MockerFixture) -> None: + adapter, _, graph_client, _ = _adapter_with_tokens( + mocker, + graph_claims={ + "aud": "https://graph.microsoft.com", + "tid": _credentials().tenant_id, + "roles": ["User.Read.All"], + }, + bot_claims={ + "aud": "https://api.botframework.com", + "appid": _credentials().client_id, + "tid": _credentials().tenant_id, + }, + ) + graph_client.get.return_value = httpx.Response( + 200, + request=httpx.Request("GET", "https://graph.microsoft.com/v1.0/users"), + json={ + "value": [{"id": "sanitized-user-a", "displayName": "Ada", "mail": None}], + "@odata.nextLink": "https://example.invalid/v1.0/users?$skiptoken=not-followed", + }, + ) + + result = adapter.directory.read_directory() + + assert isinstance(result, DirectoryReadFailure) + assert "sanitized-user-a" not in result.reason + graph_client.get.assert_called_once() + + +def test_directory_normalizes_blank_optional_fields_and_rejects_pagination_loops(mocker: MockerFixture) -> None: + adapter, _, graph_client, _ = _adapter_with_tokens( + mocker, + graph_claims={ + "aud": "https://graph.microsoft.com", + "tid": _credentials().tenant_id, + "roles": ["User.Read.All"], + }, + bot_claims={ + "aud": "https://api.botframework.com", + "appid": _credentials().client_id, + "tid": _credentials().tenant_id, + }, + ) + next_link = "https://graph.microsoft.com/v1.0/users?$skiptoken=test-only-page" + graph_client.get.side_effect = [ + httpx.Response( + 200, + request=httpx.Request("GET", "https://graph.microsoft.com/v1.0/users"), + json={ + "value": [{"id": "sanitized-user-a", "displayName": " ", "mail": ""}], + "@odata.nextLink": next_link, + }, + ), + httpx.Response( + 200, + request=httpx.Request("GET", next_link), + json={"value": [], "@odata.nextLink": next_link}, + ), + ] + + result = adapter.directory.read_directory() + + assert isinstance(result, DirectoryReadFailure) + assert graph_client.get.call_count == 2 + + +def test_directory_normalizes_unexpected_failure_without_provider_details( + mocker: MockerFixture, caplog: pytest.LogCaptureFixture +) -> None: + adapter, _, graph_client, _ = _adapter_with_tokens( + mocker, + graph_claims={ + "aud": "https://graph.microsoft.com", + "tid": _credentials().tenant_id, + "roles": ["User.Read.All"], + }, + bot_claims={ + "aud": "https://api.botframework.com", + "appid": _credentials().client_id, + "tid": _credentials().tenant_id, + }, + ) + graph_client.get.side_effect = RuntimeError("sensitive graph response") + + result = adapter.directory.read_directory() + + assert isinstance(result, DirectoryReadFailure) + assert "sensitive graph response" not in result.reason + assert "sensitive graph response" not in caplog.text + + +@pytest.mark.parametrize( + ("subject", "expected"), + [(None, "**Review requested**"), ("### Review", "# \\#\\#\\# Review\n\n**Review requested**")], +) +def test_text_send_resolves_personal_conversation_and_returns_exact_reference( + mocker: MockerFixture, subject: str | None, expected: str +) -> None: + adapter, _, _, bot_credentials = _adapter_with_tokens( + mocker, + graph_claims={ + "aud": "https://graph.microsoft.com", + "tid": _credentials().tenant_id, + "roles": ["User.Read.All"], + }, + bot_claims={ + "aud": "https://api.botframework.com", + "appid": _credentials().client_id, + "tid": _credentials().tenant_id, + }, + ) + assert hasattr(ms_teams, "ConnectorClient") + connector = ms_teams.ConnectorClient(bot_credentials, base_url="https://smba.trafficmanager.net/teams/") + mocker.patch.object(ms_teams, "ConnectorClient", autospec=True, return_value=connector) + create_conversation = mocker.patch.object(connector.conversations, "create_conversation", autospec=True) + send_to_conversation = mocker.patch.object(connector.conversations, "send_to_conversation", autospec=True) + create_conversation.return_value = ConversationResourceResponse( + id="sanitized-conversation", + service_url="https://smba.trafficmanager.net/teams/", + ) + send_to_conversation.return_value = ResourceResponse(id="sanitized-activity") + + accepted = adapter.messaging.send_text(ProviderUserId("sanitized-user-a"), "**Review requested**", subject) + + assert isinstance(accepted, MessageAccepted) + assert "sanitized-conversation" not in repr(accepted.locator) + conversation_parameters = create_conversation.call_args.args[0] + assert conversation_parameters.tenant_id == _credentials().tenant_id + assert conversation_parameters.bot.id == f"28:{_credentials().client_id}" + assert [member.id for member in conversation_parameters.members] == ["sanitized-user-a"] + create_conversation.assert_called_once() + send_to_conversation.assert_called_once() + conversation_id, activity = send_to_conversation.call_args.args + assert conversation_id == "sanitized-conversation" + assert activity.type == "message" + assert activity.text == expected + assert activity.text_format == "extendedmarkdown" + assert connector.config.retry_policy.retries == 0 + + +def test_text_send_failure_is_safe_and_does_not_replay_message_creation(mocker: MockerFixture) -> None: + adapter, _, _, bot_credentials = _adapter_with_tokens( + mocker, + graph_claims={ + "aud": "https://graph.microsoft.com", + "tid": _credentials().tenant_id, + "roles": ["User.Read.All"], + }, + bot_claims={ + "aud": "https://api.botframework.com", + "appid": _credentials().client_id, + "tid": _credentials().tenant_id, + }, + ) + connector = ms_teams.ConnectorClient(bot_credentials, base_url="https://smba.trafficmanager.net/teams/") + mocker.patch.object(ms_teams, "ConnectorClient", autospec=True, return_value=connector) + create_conversation = mocker.patch.object(connector.conversations, "create_conversation", autospec=True) + send_to_conversation = mocker.patch.object(connector.conversations, "send_to_conversation", autospec=True) + create_conversation.return_value = ConversationResourceResponse( + id="sanitized-conversation", + service_url="https://smba.trafficmanager.net/teams/", + ) + send_to_conversation.side_effect = RuntimeError("sensitive provider response") + + result = adapter.messaging.send_text(ProviderUserId("sanitized-user-a"), "Review requested") + + assert isinstance(result, MessageSendingError) + assert "sensitive provider response" not in result.reason + create_conversation.assert_called_once() + send_to_conversation.assert_called_once() + + +@pytest.mark.parametrize( + ("conversation_response", "message_response", "expected_send_count"), + [ + (None, ResourceResponse(id="unused"), 0), + (ConversationResourceResponse.deserialize({"id": None}), ResourceResponse(id="unused"), 0), + ( + ConversationResourceResponse(id="sanitized-conversation", service_url="https://example.invalid/"), + ResourceResponse(id="unused"), + 0, + ), + (ConversationResourceResponse(id="sanitized-conversation"), None, 1), + (ConversationResourceResponse(id="sanitized-conversation"), ResourceResponse.deserialize({"id": None}), 1), + ], +) +def test_text_send_requires_typed_exact_provider_acceptance( + mocker: MockerFixture, + conversation_response: object, + message_response: object, + expected_send_count: int, +) -> None: + adapter, _, _, bot_credentials = _adapter_with_tokens( + mocker, + graph_claims={ + "aud": "https://graph.microsoft.com", + "tid": _credentials().tenant_id, + "roles": ["User.Read.All"], + }, + bot_claims={ + "aud": "https://api.botframework.com", + "appid": _credentials().client_id, + "tid": _credentials().tenant_id, + }, + ) + connector = ms_teams.ConnectorClient(bot_credentials, base_url="https://smba.trafficmanager.net/teams/") + mocker.patch.object(ms_teams, "ConnectorClient", autospec=True, return_value=connector) + create_conversation = mocker.patch.object(connector.conversations, "create_conversation", autospec=True) + send_to_conversation = mocker.patch.object(connector.conversations, "send_to_conversation", autospec=True) + create_conversation.return_value = conversation_response + send_to_conversation.return_value = message_response + + result = adapter.messaging.send_text(ProviderUserId("sanitized-user-a"), "Review requested") + + assert isinstance(result, MessageSendingError) + create_conversation.assert_called_once() + assert send_to_conversation.call_count == expected_send_count + + +def test_card_assessment_accepts_complete_adaptive_card_intent_without_provider_io(mocker: MockerFixture) -> None: + adapter, _, _, _ = _adapter_with_tokens( + mocker, + graph_claims={ + "aud": "https://graph.microsoft.com", + "tid": _credentials().tenant_id, + "roles": ["User.Read.All"], + }, + bot_claims={ + "aud": "https://api.botframework.com", + "appid": _credentials().client_id, + "tid": _credentials().tenant_id, + }, + ) + connector_client = mocker.patch.object(ms_teams, "ConnectorClient", autospec=True) + + assessment = adapter.dynamic_card_messaging.assess(_card_intent(input_type="select")) + + assert assessment == CardAssessment(representable=True) + connector_client.assert_not_called() + + +@pytest.mark.parametrize("input_type", ["file", "file-list"]) +def test_card_assessment_rejects_any_unsupported_input_as_one_complete_intent( + mocker: MockerFixture, input_type: str +) -> None: + adapter, _, _, _ = _adapter_with_tokens( + mocker, + graph_claims={ + "aud": "https://graph.microsoft.com", + "tid": _credentials().tenant_id, + "roles": ["User.Read.All"], + }, + bot_claims={ + "aud": "https://api.botframework.com", + "appid": _credentials().client_id, + "tid": _credentials().tenant_id, + }, + ) + connector_client = mocker.patch.object(ms_teams, "ConnectorClient", autospec=True) + + assessment = adapter.dynamic_card_messaging.assess(_card_intent(input_type=input_type)) + + assert assessment.representable is False + assert assessment.reason + connector_client.assert_not_called() + + +@pytest.mark.parametrize( + "intent", + [ + _card_intent(action_style=ButtonStyle.GHOST), + _card_intent(markdown_text="x" * 30_000), + ], + ids=("unsupported-action-style", "provider-payload-limit"), +) +def test_card_assessment_rejects_unpreservable_presentation_facts(mocker: MockerFixture, intent: ResolvedForm) -> None: + adapter, _, _, _ = _adapter_with_tokens( + mocker, + graph_claims={ + "aud": "https://graph.microsoft.com", + "tid": _credentials().tenant_id, + "roles": ["User.Read.All"], + }, + bot_claims={ + "aud": "https://api.botframework.com", + "appid": _credentials().client_id, + "tid": _credentials().tenant_id, + }, + ) + + assessment = adapter.dynamic_card_messaging.assess(intent) + + assert assessment.representable is False + assert assessment.reason + + +def test_unrepresentable_card_send_fails_before_provider_state_creation(mocker: MockerFixture) -> None: + adapter, _, _, _ = _adapter_with_tokens( + mocker, + graph_claims={ + "aud": "https://graph.microsoft.com", + "tid": _credentials().tenant_id, + "roles": ["User.Read.All"], + }, + bot_claims={ + "aud": "https://api.botframework.com", + "appid": _credentials().client_id, + "tid": _credentials().tenant_id, + }, + ) + connector_client = mocker.patch.object(ms_teams, "ConnectorClient", autospec=True) + + with pytest.raises(DynamicCardMessagingError): + adapter.dynamic_card_messaging.send_card( + ProviderUserId("sanitized-user-a"), + _card_intent(input_type="file"), + CorrelationToken("sanitized-correlation"), + ) + + connector_client.assert_not_called() + + +@pytest.mark.parametrize( + "intent", + [ + _custom_card_intent( + blocks=(SelectInput(output_variable_name="choice", options=(), default_value=None),), + ), + _custom_card_intent( + blocks=(SelectInput(output_variable_name="choice", options=("",), default_value=None),), + ), + _custom_card_intent( + blocks=(SelectInput(output_variable_name="choice", options=("One", "One"), default_value="One"),), + ), + _custom_card_intent( + blocks=( + ParagraphInput(output_variable_name="comment", default_value=None), + ParagraphInput(output_variable_name="comment", default_value=None), + ), + ), + _custom_card_intent(blocks=(MarkdownFragment(text=""),)), + ], + ids=("empty-options", "empty-option", "duplicate-options", "duplicate-input", "empty-markdown"), +) +def test_card_assessment_rejects_unpreservable_resolved_controls_and_presentation( + mocker: MockerFixture, + intent: ResolvedForm, +) -> None: + adapter, _, _, _ = _adapter_with_tokens( + mocker, + graph_claims={ + "aud": "https://graph.microsoft.com", + "tid": _credentials().tenant_id, + "roles": ["User.Read.All"], + }, + bot_claims={ + "aud": "https://api.botframework.com", + "appid": _credentials().client_id, + "tid": _credentials().tenant_id, + }, + ) + + assessment = adapter.dynamic_card_messaging.assess(intent) + + assert assessment.representable is False + + +def test_card_send_rejects_correlation_that_exceeds_provider_payload_limit_before_io(mocker: MockerFixture) -> None: + adapter, _, _, _ = _adapter_with_tokens( + mocker, + graph_claims={ + "aud": "https://graph.microsoft.com", + "tid": _credentials().tenant_id, + "roles": ["User.Read.All"], + }, + bot_claims={ + "aud": "https://api.botframework.com", + "appid": _credentials().client_id, + "tid": _credentials().tenant_id, + }, + ) + connector_client = mocker.patch.object(ms_teams, "ConnectorClient", autospec=True) + + with pytest.raises(DynamicCardMessagingError): + adapter.dynamic_card_messaging.send_card( + ProviderUserId("sanitized-user-a"), + _card_intent(), + CorrelationToken("x" * 30_000), + ) + + connector_client.assert_not_called() + + +@pytest.mark.parametrize("button_style", [ButtonStyle.PRIMARY, ButtonStyle.ACCENT, ButtonStyle.DEFAULT]) +def test_card_renderer_omits_unsupported_teams_action_style(button_style: ButtonStyle) -> None: + card = ms_teams._MSTeamsCardCodec().encode( + _custom_card_intent(blocks=(), action_style=button_style), + CorrelationToken("sanitized-correlation"), + ) + + assert card["actions"] == [ + { + "type": "Action.Submit", + "title": "Approve", + "data": { + "__dify.human_input": { + "version": 1, + "action_id": "approve", + "correlation_token": "sanitized-correlation", + } + }, + } + ] + + +def test_card_send_preserves_controls_actions_and_correlation_in_one_message(mocker: MockerFixture) -> None: + adapter, _, _, bot_credentials = _adapter_with_tokens( + mocker, + graph_claims={ + "aud": "https://graph.microsoft.com", + "tid": _credentials().tenant_id, + "roles": ["User.Read.All"], + }, + bot_claims={ + "aud": "https://api.botframework.com", + "appid": _credentials().client_id, + "tid": _credentials().tenant_id, + }, + ) + connector = ms_teams.ConnectorClient(bot_credentials, base_url="https://smba.trafficmanager.net/teams/") + mocker.patch.object(ms_teams, "ConnectorClient", autospec=True, return_value=connector) + create_conversation = mocker.patch.object(connector.conversations, "create_conversation", autospec=True) + send_to_conversation = mocker.patch.object(connector.conversations, "send_to_conversation", autospec=True) + create_conversation.return_value = ConversationResourceResponse( + id="sanitized-conversation", + service_url="https://smba.trafficmanager.net/teams/", + ) + send_to_conversation.return_value = ResourceResponse(id="sanitized-card-activity") + + accepted = adapter.dynamic_card_messaging.send_card( + ProviderUserId("sanitized-user-a"), + _card_intent(input_type="select"), + CorrelationToken("sanitized-correlation"), + ) + + assert isinstance(accepted, MessageAccepted) + assert "sanitized-card-activity" not in repr(accepted.locator) + create_conversation.assert_called_once() + send_to_conversation.assert_called_once() + _, activity = send_to_conversation.call_args.args + assert activity.type == "message" + assert activity.summary == "Approval" + assert len(activity.attachments) == 1 + attachment = activity.attachments[0] + assert attachment.content_type == "application/vnd.microsoft.card.adaptive" + content = attachment.content + assert content["$schema"] == "http://adaptivecards.io/schemas/adaptive-card.json" + assert content["version"] == "1.5" + assert [element["type"] for element in content["body"]] == [ + "TextBlock", + "TextBlock", + "Input.ChoiceSet", + "TextBlock", + ] + choice_input = content["body"][2] + assert choice_input["id"] == "comment" + assert choice_input["value"] == "One" + assert [choice["value"] for choice in choice_input["choices"]] == ["One", "Two"] + assert content["actions"] == [ + { + "type": "Action.Submit", + "title": "Approve", + "data": { + "__dify.human_input": { + "version": 1, + "action_id": "approve", + "correlation_token": "sanitized-correlation", + } + }, + }, + { + "type": "Action.Submit", + "title": "Reject", + "data": { + "__dify.human_input": { + "version": 1, + "action_id": "reject", + "correlation_token": "sanitized-correlation", + } + }, + }, + ] + + +def test_card_locator_updates_the_exact_activity_after_adapter_recreation(mocker: MockerFixture) -> None: + adapter, _, _, bot_credentials = _adapter_with_tokens( + mocker, + graph_claims={ + "aud": "https://graph.microsoft.com", + "tid": _credentials().tenant_id, + "roles": ["User.Read.All"], + }, + bot_claims={ + "aud": "https://api.botframework.com", + "appid": _credentials().client_id, + "tid": _credentials().tenant_id, + }, + ) + connector = ms_teams.ConnectorClient(bot_credentials, base_url="https://smba.trafficmanager.net/teams/") + mocker.patch.object(ms_teams, "ConnectorClient", autospec=True, return_value=connector) + create_conversation = mocker.patch.object(connector.conversations, "create_conversation", autospec=True) + send_to_conversation = mocker.patch.object(connector.conversations, "send_to_conversation", autospec=True) + create_conversation.return_value = ConversationResourceResponse( + id="sanitized-conversation", + service_url="https://smba.trafficmanager.net/teams/", + ) + send_to_conversation.return_value = ResourceResponse(id="sanitized-card-activity") + accepted = adapter.dynamic_card_messaging.send_card( + ProviderUserId("sanitized-user-a"), + _card_intent(), + CorrelationToken("sanitized-correlation"), + ) + assert isinstance(accepted, MessageAccepted) + persisted_reference = MessageLocator( + json.loads(json.dumps({"locator": str(accepted.locator)}, separators=(",", ":")))["locator"] + ) + update_activity = mocker.patch.object(connector.conversations, "update_activity", autospec=True) + recreated_adapter = ms_teams.MSTeamsIMProviderAdapter(_credentials()) + + result = recreated_adapter.dynamic_card_messaging.replace_with_static( + persisted_reference, + StaticCardIntent("Decision **recorded**"), + ) + + assert result is None + update_activity.assert_called_once() + conversation_id, activity_id, activity = update_activity.call_args.args + assert conversation_id == "sanitized-conversation" + assert activity_id == "sanitized-card-activity" + assert activity.type == "message" + assert activity.text == "Decision **recorded**" + assert not activity.attachments + assert connector.config.retry_policy.retries == 0 + + +def test_message_locator_does_not_expose_provider_locator_fields() -> None: + reference = _locator() + + exposed_locator_fields = { + name + for name in ("tenant_id", "client_id", "service_url", "conversation_id", "activity_id") + if hasattr(reference, name) + } + + assert exposed_locator_fields == set() + + +def test_message_locator_serializes_only_the_locator_payload() -> None: + reference = _locator() + padding = "=" * (-len(str(reference)) % 4) + serialized_locator = base64.urlsafe_b64decode(str(reference) + padding) + + assert json.loads(serialized_locator) == { + "v": 1, + "p": "ms_teams", + "activity_id": "sanitized-card-activity", + "conversation_id": "sanitized-conversation", + "service_url": "https://smba.trafficmanager.net/teams/", + } + + +def test_message_locator_rejects_a_malformed_serialized_value_without_provider_io(mocker: MockerFixture) -> None: + reference = _locator() + serialized_value = str(reference) + replacement_character = "A" if serialized_value[-1] != "A" else "B" + malformed_reference = MessageLocator(serialized_value[:-1] + replacement_character) + adapter, _, _, _ = _adapter_with_tokens( + mocker, + graph_claims={ + "aud": "https://graph.microsoft.com", + "tid": _credentials().tenant_id, + "roles": ["User.Read.All"], + }, + bot_claims={ + "aud": "https://api.botframework.com", + "appid": _credentials().client_id, + "tid": _credentials().tenant_id, + }, + ) + connector_client = mocker.patch.object(ms_teams, "ConnectorClient", autospec=True) + + result = adapter.dynamic_card_messaging.replace_with_static( + malformed_reference, + StaticCardIntent("Recorded"), + ) + + assert result == ReplacementError( + ReplacementErrorKind.INVALID_REFERENCE, + "The Microsoft Teams message reference is invalid.", + ) + connector_client.assert_not_called() + + +@pytest.mark.parametrize( + "reference", + [ + MessageLocator("invalid."), + MessageLocator(base64.urlsafe_b64encode(b'{"v":1,"p":"slack"}').rstrip(b"=").decode()), + ], +) +def test_card_update_rejects_incompatible_reference_without_provider_io( + mocker: MockerFixture, + reference: MessageLocator, +) -> None: + adapter, _, _, _ = _adapter_with_tokens( + mocker, + graph_claims={ + "aud": "https://graph.microsoft.com", + "tid": _credentials().tenant_id, + "roles": ["User.Read.All"], + }, + bot_claims={ + "aud": "https://api.botframework.com", + "appid": _credentials().client_id, + "tid": _credentials().tenant_id, + }, + ) + connector_client = mocker.patch.object(ms_teams, "ConnectorClient", autospec=True) + + result = adapter.dynamic_card_messaging.replace_with_static(reference, StaticCardIntent("Recorded")) + + assert result == ReplacementError( + ReplacementErrorKind.INVALID_REFERENCE, + "The Microsoft Teams message reference is invalid.", + ) + connector_client.assert_not_called() + + +@pytest.mark.parametrize( + ("provider_error", "expected_kind"), + [ + ("stale", ReplacementErrorKind.STALE_REFERENCE), + ("http_unknown", ReplacementErrorKind.UNKNOWN), + ("unknown", ReplacementErrorKind.UNKNOWN), + ], +) +def test_card_update_classifies_provider_failure_without_replaying_or_leaking_details( + mocker: MockerFixture, + caplog: pytest.LogCaptureFixture, + provider_error: str, + expected_kind: ReplacementErrorKind, +) -> None: + adapter, _, _, bot_credentials = _adapter_with_tokens( + mocker, + graph_claims={ + "aud": "https://graph.microsoft.com", + "tid": _credentials().tenant_id, + "roles": ["User.Read.All"], + }, + bot_claims={ + "aud": "https://api.botframework.com", + "appid": _credentials().client_id, + "tid": _credentials().tenant_id, + }, + ) + connector = ms_teams.ConnectorClient(bot_credentials, base_url="https://smba.trafficmanager.net/teams/") + mocker.patch.object(ms_teams, "ConnectorClient", autospec=True, return_value=connector) + update_activity = mocker.patch.object(connector.conversations, "update_activity", autospec=True) + if provider_error == "stale": + response = requests.Response() + response.status_code = 404 + response.reason = "sanitized-not-found" + response._content = b"{}" + update_activity.side_effect = HttpOperationError(None, response) + elif provider_error == "http_unknown": + response = requests.Response() + response.status_code = 400 + response.reason = "sanitized-bad-request" + response._content = b"{}" + update_activity.side_effect = HttpOperationError(None, response) + else: + update_activity.side_effect = RuntimeError("sensitive provider response") + reference = _locator() + + result = adapter.dynamic_card_messaging.replace_with_static(reference, StaticCardIntent("Recorded")) + + assert isinstance(result, ReplacementError) + assert result.kind is expected_kind + assert "sensitive provider response" not in result.reason + assert "sensitive provider response" not in caplog.text + update_activity.assert_called_once() + + +@pytest.mark.parametrize("include_event_id", [True, False]) +def test_webhook_authenticates_tenant_and_preserves_the_complete_activity_payload( + mocker: MockerFixture, + include_event_id: bool, +) -> None: + adapter, _, _, _ = _adapter_with_tokens( + mocker, + graph_claims={ + "aud": "https://graph.microsoft.com", + "tid": _credentials().tenant_id, + "roles": ["User.Read.All"], + }, + bot_claims={ + "aud": "https://api.botframework.com", + "appid": _credentials().client_id, + "tid": _credentials().tenant_id, + }, + ) + authenticate_request = mocker.patch.object( + ms_teams.JwtTokenValidation, + "authenticate_request", + autospec=True, + ) + authenticate_request.return_value = ClaimsIdentity( + {"aud": _credentials().client_id, "iss": "https://api.botframework.com"}, + True, + ) + consumer = _RecordingConsumer() + handler = adapter.create_webhook_handler(consumer) + activity_body = _teams_activity_body(include_event_id=include_event_id) + + response = handler.handle(_webhook_request(activity_body)) + + assert response.status_code == 200 + assert response.body == b"" + assert len(consumer.events) == 1 + event = consumer.events[0] + assert event.provider.value == "ms_teams" + assert event.provider_tenant_id == _credentials().tenant_id + assert event.event_id == ("sanitized-provider-event" if include_event_id else None) + assert event.event_type == "invoke" + assert event.occurred_at == datetime(2026, 8, 6, 7, 59, 58) + assert event.received_at == _RECEIVED_AT + assert event.ingress_kind is IMEventIngressKind.WEBHOOK + assert json.loads(event.payload) == activity_body + authenticate_request.assert_awaited_once() + authenticated_activity, authorization, _, _ = authenticate_request.call_args.args + assert authenticated_activity.channel_id == "msteams" + assert authenticated_activity.service_url == "https://smba.trafficmanager.net/teams/" + assert authorization == "Bearer sanitized-token" + + +@pytest.mark.parametrize( + ("acceptance", "expected_status"), + [ + (EventAcceptance.ACCEPTED, 200), + (EventAcceptance.NOT_ACCEPTED, 503), + ], +) +def test_webhook_maps_consumer_acceptance_to_bot_framework_ack( + mocker: MockerFixture, + acceptance: EventAcceptance, + expected_status: int, +) -> None: + consumer = _RecordingConsumer(acceptance) + _, handler, authenticate_request = _authenticated_webhook_handler(mocker, consumer) + + response = handler.handle(_webhook_request(_teams_activity_body())) + + assert response.status_code == expected_status + assert len(consumer.events) == 1 + authenticate_request.assert_awaited_once() + + +def test_webhook_rejects_authentication_and_tenant_mismatch_before_consumer(mocker: MockerFixture) -> None: + consumer = _RecordingConsumer() + _, handler, authenticate_request = _authenticated_webhook_handler(mocker, consumer) + authenticate_request.side_effect = PermissionError("sensitive token details") + + unauthenticated = handler.handle(_webhook_request(_teams_activity_body())) + + assert unauthenticated.status_code == 401 + assert consumer.events == [] + + authenticate_request.side_effect = None + authenticate_request.return_value = ClaimsIdentity( + {"aud": _credentials().client_id, "iss": "https://api.botframework.com"}, + True, + ) + mismatched_body = _teams_activity_body() + mismatched_body["conversation"] = { + "id": "sanitized-conversation", + "tenantId": "33333333-3333-3333-3333-333333333333", + } + mismatched = handler.handle(_webhook_request(mismatched_body)) + + assert mismatched.status_code == 403 + assert consumer.events == [] + assert authenticate_request.await_count == 2 + + +@pytest.mark.parametrize( + "webhook_request", + [ + WebhookRequest( + method="GET", + headers=(("Authorization", "Bearer sanitized-token"), ("Content-Type", "application/json")), + body=b"{}", + received_at=_RECEIVED_AT, + ), + WebhookRequest( + method="POST", + headers=(("Content-Type", "application/json"),), + body=b"{}", + received_at=_RECEIVED_AT, + ), + WebhookRequest( + method="POST", + headers=( + ("Authorization", "Bearer first"), + ("authorization", "Bearer second"), + ("Content-Type", "application/json"), + ), + body=b"{}", + received_at=_RECEIVED_AT, + ), + WebhookRequest( + method="POST", + headers=(("Authorization", "Bearer sanitized-token"), ("Content-Type", "text/plain")), + body=b"{}", + received_at=_RECEIVED_AT, + ), + WebhookRequest( + method="POST", + headers=(("Authorization", "Bearer sanitized-token"), ("Content-Type", "application/json")), + body=b"not-json", + received_at=_RECEIVED_AT, + ), + WebhookRequest( + method="POST", + headers=(("Authorization", "Bearer sanitized-token"), ("Content-Type", "application/json")), + body=b"[]", + received_at=_RECEIVED_AT, + ), + WebhookRequest( + method="POST", + headers=( + ("Authorization", "Bearer sanitized-token"), + ("Content-Type", "application/json"), + ("content-type", "application/json"), + ), + body=b"{}", + received_at=_RECEIVED_AT, + ), + ], + ids=("method", "missing-auth", "duplicate-auth", "content-type", "body", "non-object", "duplicate-content-type"), +) +def test_webhook_rejects_invalid_http_boundary_without_authentication_or_consumption( + mocker: MockerFixture, + webhook_request: WebhookRequest, +) -> None: + consumer = _RecordingConsumer() + _, handler, authenticate_request = _authenticated_webhook_handler(mocker, consumer) + + response = handler.handle(webhook_request) + + assert response.status_code >= 400 + assert consumer.events == [] + authenticate_request.assert_not_awaited() + + +def test_webhook_rejects_invalid_activity_shape_and_service_url_before_authentication(mocker: MockerFixture) -> None: + consumer = _RecordingConsumer() + _, handler, authenticate_request = _authenticated_webhook_handler(mocker, consumer) + + invalid_shape = handler.handle(_webhook_request({"type": "invoke"})) + untrusted_activity = _teams_activity_body() + untrusted_activity["serviceUrl"] = "https://example.invalid/teams/" + untrusted_service_url = handler.handle(_webhook_request(untrusted_activity)) + + assert invalid_shape.status_code == 400 + assert untrusted_service_url.status_code == 400 + assert consumer.events == [] + authenticate_request.assert_not_awaited() + + +@pytest.mark.parametrize("non_standard_value", [float("nan"), float("inf"), float("-inf")]) +def test_webhook_rejects_non_standard_json_constants_before_authentication( + mocker: MockerFixture, non_standard_value: float +) -> None: + consumer = _RecordingConsumer() + _, handler, authenticate_request = _authenticated_webhook_handler(mocker, consumer) + activity = _teams_activity_body() + activity["nonStandard"] = non_standard_value + + response = handler.handle(_webhook_request(activity)) + + assert response.status_code == 400 + assert consumer.events == [] + authenticate_request.assert_not_awaited() + + +def test_webhook_accepts_authoritative_tenant_with_blank_event_id_and_no_provider_time(mocker: MockerFixture) -> None: + consumer = _RecordingConsumer() + _, handler, _ = _authenticated_webhook_handler(mocker, consumer) + activity = _teams_activity_body() + activity["id"] = " " + activity.pop("timestamp") + conversation = activity["conversation"] + assert isinstance(conversation, dict) + conversation.pop("tenantId") + + response = handler.handle(_webhook_request(activity)) + + assert response.status_code == 200 + assert len(consumer.events) == 1 + assert consumer.events[0].event_id is None + assert consumer.events[0].occurred_at is None + + +def test_webhook_rejects_an_identity_not_marked_authenticated(mocker: MockerFixture) -> None: + consumer = _RecordingConsumer() + _, handler, authenticate_request = _authenticated_webhook_handler(mocker, consumer) + authenticate_request.return_value = ClaimsIdentity({}, False) + + response = handler.handle(_webhook_request(_teams_activity_body())) + + assert response.status_code == 401 + assert consumer.events == [] + + +def test_webhook_consumer_failure_is_safe_and_not_acknowledged( + mocker: MockerFixture, caplog: pytest.LogCaptureFixture +) -> None: + _, handler, _ = _authenticated_webhook_handler(mocker, _FailingConsumer()) + + response = handler.handle(_webhook_request(_teams_activity_body())) + + assert response.status_code == 503 + assert "sensitive consumer details" not in response.body.decode() + assert "sensitive consumer details" not in caplog.text + + +def test_webhook_handler_remains_usable_after_root_close(mocker: MockerFixture) -> None: + consumer = _RecordingConsumer() + adapter, handler, _ = _authenticated_webhook_handler(mocker, consumer) + adapter.close() + adapter.close() + + response = handler.handle(_webhook_request(_teams_activity_body())) + + assert response.status_code == 200 + assert len(consumer.events) == 1 + + +def test_webhook_handler_supports_overlapping_calls_without_shared_request_state(mocker: MockerFixture) -> None: + consumer = _ThreadSafeRecordingConsumer() + adapter, _, _, _ = _adapter_with_tokens( + mocker, + graph_claims={ + "aud": "https://graph.microsoft.com", + "tid": _credentials().tenant_id, + "roles": ["User.Read.All"], + }, + bot_claims={ + "aud": "https://api.botframework.com", + "appid": _credentials().client_id, + "tid": _credentials().tenant_id, + }, + ) + + async def authenticate_request(*args: object, **kwargs: object) -> ClaimsIdentity: + del args, kwargs + return ClaimsIdentity({"aud": _credentials().client_id}, True) + + mocker.patch.object(ms_teams.JwtTokenValidation, "authenticate_request", new=authenticate_request) + handler = adapter.create_webhook_handler(consumer) + requests_to_handle = [ + _webhook_request({**_teams_activity_body(), "id": f"sanitized-event-{index}"}) for index in range(8) + ] + + with ThreadPoolExecutor(max_workers=8) as executor: + responses = tuple(executor.map(handler.handle, requests_to_handle)) + + assert [response.status_code for response in responses] == [200] * 8 + assert {event.event_id for event in consumer.events} == {f"sanitized-event-{index}" for index in range(8)} + + +def test_committed_ms_teams_fixture_contains_only_sanitized_protocol_evidence() -> None: + fixture_text = _WEBHOOK_FIXTURE_PATH.read_text() + normalized_fixture = fixture_text.casefold() + + assert _UUID_PATTERN.search(fixture_text) is None + assert "authorization" not in normalized_fixture + assert "bearer " not in normalized_fixture + assert "client_secret" not in normalized_fixture + assert "access_token" not in normalized_fixture + assert "refresh_token" not in normalized_fixture + assert "private_key" not in normalized_fixture + assert "password" not in normalized_fixture + + fixture = json.loads(fixture_text) + assert isinstance(fixture, dict) + assert fixture["id"] == "test-only-event" + assert fixture["conversation"] == { + "id": "test-only-conversation", + "tenantId": "test-only-tenant", + } + assert fixture["channelData"]["tenant"] == {"id": "test-only-tenant"} + assert fixture["value"]["correlation_token"] == "test-only-correlation" + + +def test_webhook_official_jwt_validation_rejects_tampering_wrong_audience_and_expiry(mocker: MockerFixture) -> None: + signing_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + wrong_signing_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + public_jwk = RSAAlgorithm.to_jwk(signing_key.public_key(), as_dict=True) + public_jwk.update({"kid": "test-only-key", "endorsements": []}) + metadata_url = "https://test-only.invalid/openid-configuration" + jwks_url = "https://test-only.invalid/keys" + mocker.patch.object(ChannelValidation, "open_id_metadata_endpoint", metadata_url) + JwtTokenExtractor.metadataCache.pop(metadata_url, None) + metadata_get = mocker.patch( + "botframework.connector.auth.jwt_token_extractor.requests.get", + side_effect=[ + _ProviderMetadataResponse({"jwks_uri": jwks_url}), + _ProviderMetadataResponse({"keys": [public_jwk]}), + ], + ) + adapter, _, _, _ = _adapter_with_tokens( + mocker, + graph_claims={ + "aud": "https://graph.microsoft.com", + "tid": _credentials().tenant_id, + "roles": ["User.Read.All"], + }, + bot_claims={ + "aud": "https://api.botframework.com", + "appid": _credentials().client_id, + "tid": _credentials().tenant_id, + }, + ) + consumer = _RecordingConsumer() + handler = adapter.create_webhook_handler(consumer) + current_timestamp = int(datetime.now().timestamp()) + base_claims = { + "iss": "https://api.botframework.com", + "aud": _credentials().client_id, + "serviceurl": "https://smba.trafficmanager.net/teams/", + "nbf": current_timestamp - 60, + "exp": current_timestamp + 600, + } + + valid_token = jwt.encode(base_claims, signing_key, algorithm="RS256", headers={"kid": "test-only-key"}) + valid_response = handler.handle(_webhook_request(_teams_activity_body(), authorization=f"Bearer {valid_token}")) + + assert valid_response.status_code == 200 + assert len(consumer.events) == 1 + invalid_tokens = ( + jwt.encode(base_claims, wrong_signing_key, algorithm="RS256", headers={"kid": "test-only-key"}), + jwt.encode( + {**base_claims, "aud": "test-only-wrong-audience"}, + signing_key, + algorithm="RS256", + headers={"kid": "test-only-key"}, + ), + jwt.encode( + {**base_claims, "exp": current_timestamp - 600}, + signing_key, + algorithm="RS256", + headers={"kid": "test-only-key"}, + ), + ) + for invalid_token in invalid_tokens: + invalid_response = handler.handle( + _webhook_request(_teams_activity_body(), authorization=f"Bearer {invalid_token}") + ) + assert invalid_response.status_code == 401 + assert len(consumer.events) == 1 + assert metadata_get.call_count == 2 + JwtTokenExtractor.metadataCache.pop(metadata_url, None) diff --git a/api/tests/unit_tests/core/human_input_v2/im_provider/test_ms_teams_card_events.py b/api/tests/unit_tests/core/human_input_v2/im_provider/test_ms_teams_card_events.py new file mode 100644 index 00000000000..a92b5eeda18 --- /dev/null +++ b/api/tests/unit_tests/core/human_input_v2/im_provider/test_ms_teams_card_events.py @@ -0,0 +1,566 @@ +from __future__ import annotations + +import inspect +import json +from concurrent.futures import ThreadPoolExecutor +from copy import deepcopy +from dataclasses import fields +from datetime import datetime +from pathlib import Path + +import pytest + +from core.human_input import ButtonStyle +from core.human_input_v2.entities import IMProvider +from core.human_input_v2.im_integration.adapters import ( + AuthenticatedIMEvent, + CardAssessment, + CorrelationToken, + DynamicCardMessagingError, + IMCardEvent, + IMEventIngressKind, + ProviderUserId, + UnrecognizedIMEvent, + ms_teams, +) +from core.human_input_v2.resolved_form import MarkdownFragment, ParagraphInput, ResolvedForm, SelectInput, UserAction + +_CALLBACK_FIXTURE_PATH = Path(__file__).parent / "fixtures" / "ms_teams_card_submit.json" +_RECEIVED_AT = datetime(2026, 8, 12, 2, 26, 40) +_MISSING_CALLBACK_VALUE = object() + + +def _form(*, first_input_name: str = "comment-🧪") -> ResolvedForm: + return ResolvedForm( + title="Approval request", + parts=( + MarkdownFragment(text="Review the generated answer."), + ParagraphInput(output_variable_name=first_input_name, default_value="Looks good"), + SelectInput(output_variable_name="decision", options=("ship", "hold"), default_value="hold"), + ), + actions=( + UserAction(id="approve-✅", title="Approve", button_style=ButtonStyle.PRIMARY), + UserAction(id="reject", title="Reject", button_style=ButtonStyle.ACCENT), + ), + legacy_form_content="This value must not be rendered", + ) + + +def _callback_fixture() -> dict[str, object]: + callback = json.loads(_CALLBACK_FIXTURE_PATH.read_text()) + assert isinstance(callback, dict) + return callback + + +def _event( + callback: dict[str, object] | None = None, + *, + provider: IMProvider = IMProvider.MS_TEAMS, + event_type: str | None = "message", + payload: str | None = None, +) -> AuthenticatedIMEvent: + serialized_payload = payload + if serialized_payload is None: + serialized_payload = json.dumps(callback or _callback_fixture(), ensure_ascii=False, separators=(",", ":")) + return AuthenticatedIMEvent( + provider=provider, + provider_tenant_id="test-only-tenant", + event_id="test-only-card-event", + event_type=event_type, + occurred_at=None, + received_at=_RECEIVED_AT, + ingress_kind=IMEventIngressKind.WEBHOOK, + payload=serialized_payload, + ) + + +def test_class_level_codec_encodes_the_complete_collision_safe_adaptive_card() -> None: + decoder = ms_teams.MSTeamsIMProviderAdapter.card_event_decoder() + + assert isinstance(decoder, ms_teams._MSTeamsCardCodec) + assert fields(decoder) == () + assert decoder.assess(_form()) == CardAssessment(representable=True) + assert decoder.encode(_form(), CorrelationToken("correlation-🔐")) == { + "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", + "type": "AdaptiveCard", + "version": "1.5", + "body": [ + { + "type": "TextBlock", + "text": "Approval request", + "wrap": True, + "size": "Medium", + "weight": "Bolder", + }, + { + "type": "TextBlock", + "text": "Review the generated answer.", + "wrap": True, + }, + { + "type": "Input.Text", + "id": "comment-🧪", + "label": "comment-🧪", + "isMultiline": True, + "value": "Looks good", + }, + { + "type": "Input.ChoiceSet", + "id": "decision", + "label": "decision", + "choices": [ + {"title": "ship", "value": "ship"}, + {"title": "hold", "value": "hold"}, + ], + "style": "compact", + "value": "hold", + }, + ], + "actions": [ + { + "type": "Action.Submit", + "title": "Approve", + "data": { + "__dify.human_input": { + "version": 1, + "action_id": "approve-✅", + "correlation_token": "correlation-🔐", + } + }, + }, + { + "type": "Action.Submit", + "title": "Reject", + "data": { + "__dify.human_input": { + "version": 1, + "action_id": "reject", + "correlation_token": "correlation-🔐", + } + }, + }, + ], + } + + +def test_codec_renders_select_input_as_compact_choice_set() -> None: + form = ResolvedForm( + title="", + parts=(SelectInput(output_variable_name="decision", options=("ship", "hold"), default_value="hold"),), + actions=(), + legacy_form_content="unused", + ) + + card = ms_teams._MSTeamsCardCodec().encode(form, CorrelationToken("correlation")) + + assert card["body"] == [ + { + "type": "Input.ChoiceSet", + "id": "decision", + "label": "decision", + "choices": [ + {"title": "ship", "value": "ship"}, + {"title": "hold", "value": "hold"}, + ], + "style": "compact", + "value": "hold", + } + ] + + +def test_codec_rejects_the_reserved_metadata_member_before_provider_io() -> None: + codec = ms_teams._MSTeamsCardCodec() + colliding_form = _form(first_input_name="__dify.human_input") + + assessment = codec.assess(colliding_form) + + assert assessment.representable is False + assert assessment.reason == "Microsoft Teams card input identifier is reserved." + with pytest.raises(DynamicCardMessagingError) as raised: + codec.encode(colliding_form, CorrelationToken("correlation")) + assert str(raised.value) == assessment.reason + + +@pytest.mark.parametrize("activity_type", ["message", "invoke"]) +def test_sanitized_submit_fixture_decodes_exact_unicode_round_trip(activity_type: str) -> None: + callback = _callback_fixture() + callback["type"] = activity_type + + result = ms_teams.MSTeamsIMProviderAdapter.card_event_decoder().decode(_event(callback, event_type=activity_type)) + + assert result == IMCardEvent( + provider_user_id=ProviderUserId("11111111-2222-4333-8444-abcdefabcdef"), + action_id="approve-✅", + inputs={"comment-🧪": "Ready 🚀", "decision": "ship"}, + correlation_token=CorrelationToken("correlation-🔐"), + ) + assert isinstance(result, IMCardEvent) + assert "__dify.human_input" not in result.inputs + + +@pytest.mark.parametrize("activity_type", ["message", "invoke"]) +def test_card_actor_uses_the_directory_object_id(activity_type: str) -> None: + callback = _callback_fixture() + callback["type"] = activity_type + callback["from"] = { + "id": "29:test-only-bot-framework-user", + "aadObjectId": "11111111-2222-4333-8444-ABCDEFABCDEF", + } + + result = ms_teams.MSTeamsIMProviderAdapter.card_event_decoder().decode(_event(callback, event_type=activity_type)) + + assert isinstance(result, IMCardEvent) + assert result.provider_user_id == ProviderUserId("11111111-2222-4333-8444-abcdefabcdef") + + +@pytest.mark.parametrize("aad_object_id", [..., None, "", " ", "invalid-guid", "29:teams-user", True, 7, [], {}]) +def test_card_actor_requires_a_valid_directory_object_id(aad_object_id: object) -> None: + callback = _callback_fixture() + actor: dict[str, object] = {"id": "29:test-only-bot-framework-user"} + if aad_object_id is not ...: + actor["aadObjectId"] = aad_object_id + callback["from"] = actor + + with pytest.raises( + ms_teams.IMCardEventDecodingError, match=r"^Microsoft Teams card event schema is invalid\.$" + ) as raised: + ms_teams.MSTeamsIMProviderAdapter.card_event_decoder().decode(_event(callback)) + + assert raised.value.__cause__ is None + assert raised.value.__context__ is None + + +@pytest.mark.parametrize( + "event", + [ + _event(provider=IMProvider.SLACK, payload="not-json"), + _event(event_type="conversationUpdate", payload="not-json"), + _event( + { + "type": "message", + "from": {"id": "foreign-user"}, + "value": {"foreign": "interaction"}, + } + ), + _event( + { + "type": "invoke", + "from": {"id": "foreign-user"}, + "value": {"__dify.human_input.legacy": {"action_id": "legacy"}}, + }, + event_type="invoke", + ), + ], + ids=("foreign-provider", "non-card-type", "foreign-message", "near-marker"), +) +def test_non_applicable_or_foreign_events_are_unrecognized(event: AuthenticatedIMEvent) -> None: + result = ms_teams.MSTeamsIMProviderAdapter.card_event_decoder().decode(event) + + assert isinstance(result, UnrecognizedIMEvent) + + +def test_decoder_removes_only_the_exact_internal_metadata_member() -> None: + callback = _callback_fixture() + callback_value = callback["value"] + assert isinstance(callback_value, dict) + callback_value["__dify.other"] = {"preserved": [True, None]} + + result = ms_teams.MSTeamsIMProviderAdapter.card_event_decoder().decode(_event(callback)) + + assert isinstance(result, IMCardEvent) + assert result.inputs == { + "comment-🧪": "Ready 🚀", + "decision": "ship", + "__dify.other": {"preserved": [True, None]}, + } + + +@pytest.mark.parametrize( + "payload", + [ + "not-json", + "[]", + '{"type":"message","value":{"__dify.human_input":NaN}}', + '{"type":"message","value":{"__dify.human_input":{},"unsafe":1e400}}', + ], + ids=("invalid-json", "non-object-root", "non-standard-constant", "overflowing-input-number"), +) +def test_transport_discriminated_invalid_json_fails_safely(payload: str) -> None: + with pytest.raises(ms_teams.IMCardEventDecodingError) as raised: + ms_teams.MSTeamsIMProviderAdapter.card_event_decoder().decode(_event(payload=payload)) + + assert str(raised.value) in { + "Microsoft Teams card event payload is invalid.", + "Microsoft Teams card event schema is invalid.", + } + + +@pytest.mark.parametrize("activity_type", ["message", "invoke"]) +@pytest.mark.parametrize( + "foreign_value", + [_MISSING_CALLBACK_VALUE, None, "value", [], True, 1], + ids=("missing", "null", "string", "array", "boolean", "integer"), +) +def test_applicable_callback_without_object_marker_is_unrecognized( + activity_type: str, + foreign_value: object, +) -> None: + callback = _callback_fixture() + callback["type"] = activity_type + if foreign_value is _MISSING_CALLBACK_VALUE: + callback.pop("value") + else: + callback["value"] = foreign_value + + result = ms_teams.MSTeamsIMProviderAdapter.card_event_decoder().decode(_event(callback, event_type=activity_type)) + + assert isinstance(result, UnrecognizedIMEvent) + + +def test_recognized_callback_rejects_internal_actor_field_name() -> None: + callback = _callback_fixture() + callback.pop("from") + callback["actor"] = {"id": "fabricated-user", "aadObjectId": "11111111-2222-4333-8444-abcdefabcdef"} + + with pytest.raises( + ms_teams.IMCardEventDecodingError, + match=r"^Microsoft Teams card event schema is invalid\.$", + ) as raised: + ms_teams.MSTeamsIMProviderAdapter.card_event_decoder().decode(_event(callback)) + + assert "fabricated-user" not in repr(raised.value) + assert raised.value.__cause__ is None + assert raised.value.__context__ is None + + +@pytest.mark.parametrize("invalid_actor", [None, "actor", {}]) +def test_recognized_callback_rejects_missing_or_invalid_actor(invalid_actor: object) -> None: + callback = _callback_fixture() + if invalid_actor is None: + callback.pop("from") + else: + callback["from"] = invalid_actor + + with pytest.raises( + ms_teams.IMCardEventDecodingError, + match=r"^Microsoft Teams card event schema is invalid\.$", + ): + ms_teams.MSTeamsIMProviderAdapter.card_event_decoder().decode(_event(callback)) + + +@pytest.mark.parametrize( + "invalid_metadata", + [ + None, + "metadata", + [], + {}, + {"version": 2, "action_id": "approve", "correlation_token": "correlation"}, + {"version": 1, "correlation_token": "correlation"}, + {"version": 1, "action_id": "", "correlation_token": "correlation"}, + {"version": 1, "action_id": True, "correlation_token": "correlation"}, + {"version": 1, "action_id": "approve"}, + {"version": 1, "action_id": "approve", "correlation_token": None}, + {"version": 1, "action_id": "approve", "correlation_token": 7}, + { + "version": 1, + "action_id": "approve", + "correlation_token": "correlation", + "foreign": "member", + }, + ], + ids=( + "null", + "string", + "array", + "missing-members", + "version", + "missing-action", + "empty-action", + "typed-action", + "missing-token", + "null-token", + "typed-token", + "extra-member", + ), +) +def test_recognized_callback_rejects_invalid_metadata(invalid_metadata: object) -> None: + callback = _callback_fixture() + callback_value = callback["value"] + assert isinstance(callback_value, dict) + callback_value["__dify.human_input"] = invalid_metadata + + with pytest.raises( + ms_teams.IMCardEventDecodingError, + match=r"^Microsoft Teams card event schema is invalid\.$", + ): + ms_teams.MSTeamsIMProviderAdapter.card_event_decoder().decode(_event(callback)) + + +def test_decoding_error_does_not_retain_callback_secrets_or_validation_exceptions() -> None: + callback = _callback_fixture() + callback["from"] = { + "id": "secret-actor", + "aadObjectId": "11111111-2222-4333-8444-abcdefabcdef", + "name": "secret-profile", + } + callback_value = callback["value"] + assert isinstance(callback_value, dict) + callback_value["__dify.human_input"] = { + "version": 1, + "action_id": 7, + "correlation_token": "secret-correlation-token", + } + callback_value["secret-input-name"] = "secret-submitted-value" + + with pytest.raises(ms_teams.IMCardEventDecodingError) as raised: + ms_teams.MSTeamsIMProviderAdapter.card_event_decoder().decode(_event(callback)) + + diagnostic = " ".join((str(raised.value), repr(raised.value), repr(raised.value.args))) + for sensitive_value in ( + "secret-actor", + "secret-profile", + "secret-correlation-token", + "secret-input-name", + "secret-submitted-value", + ): + assert sensitive_value not in diagnostic + assert raised.value.__cause__ is None + assert raised.value.__context__ is None + + +def test_sanitized_callback_fixture_contains_no_live_identity_or_credential_facts() -> None: + fixture_text = _CALLBACK_FIXTURE_PATH.read_text() + normalized_fixture = fixture_text.casefold() + + for forbidden_marker in ( + "authorization", + "bearer ", + "client_secret", + "access_token", + "refresh_token", + "private_key", + "password", + "experiment_id", + ): + assert forbidden_marker not in normalized_fixture + + +def test_class_level_decoder_discovery_constructs_no_credential_or_provider_clients( + monkeypatch: pytest.MonkeyPatch, +) -> None: + def unexpected_dependency(*args: object, **kwargs: object) -> object: + del args, kwargs + raise AssertionError("class-level decoder discovery must not construct provider dependencies") + + monkeypatch.setattr(ms_teams, "ClientSecretCredential", unexpected_dependency) + monkeypatch.setattr(ms_teams.httpx, "Client", unexpected_dependency) + monkeypatch.setattr(ms_teams, "MicrosoftAppCredentials", unexpected_dependency) + monkeypatch.setattr(ms_teams, "ConnectorClient", unexpected_dependency) + + decoder = ms_teams.MSTeamsIMProviderAdapter.card_event_decoder() + + assert isinstance(decoder, ms_teams._MSTeamsCardCodec) + codec_source = inspect.getsource(ms_teams._MSTeamsCardCodec) + for forbidden_dependency in ( + "ClientSecretCredential", + "MicrosoftAppCredentials", + "ConnectorClient", + "MSTeamsCredentials", + "Contact", + "VerifiedIMIdentityProof", + "SubmissionService", + "Workflow", + ): + assert forbidden_dependency not in codec_source + + +def test_one_decoder_handles_independent_callbacks_concurrently() -> None: + decoder = ms_teams.MSTeamsIMProviderAdapter.card_event_decoder() + + def decode_callback(index: int) -> IMCardEvent: + callback = deepcopy(_callback_fixture()) + callback["from"] = { + "id": f"29:test-only-user-{index}", + "aadObjectId": f"00000000-0000-4000-8000-{index:012x}", + } + callback_value = callback["value"] + assert isinstance(callback_value, dict) + callback_value["__dify.human_input"] = { + "version": 1, + "action_id": f"action-{index}", + "correlation_token": f"correlation-{index}", + } + callback_value["sequence"] = index + result = decoder.decode(_event(callback)) + assert isinstance(result, IMCardEvent) + return result + + with ThreadPoolExecutor(max_workers=8) as executor: + results = tuple(executor.map(decode_callback, range(32))) + + assert [result.action_id for result in results] == [f"action-{index}" for index in range(32)] + assert [str(result.provider_user_id) for result in results] == [ + f"00000000-0000-4000-8000-{index:012x}" for index in range(32) + ] + assert [result.inputs["sequence"] for result in results] == list(range(32)) + assert [str(result.correlation_token) for result in results] == [f"correlation-{index}" for index in range(32)] + + +def test_decoder_remains_usable_across_credential_rotation_and_root_close( + monkeypatch: pytest.MonkeyPatch, +) -> None: + class _ClosableDependency: + def __init__(self) -> None: + self.close_count = 0 + + def close(self) -> None: + self.close_count += 1 + + graph_credentials: list[_ClosableDependency] = [] + graph_clients: list[_ClosableDependency] = [] + + def graph_credential_factory(*args: object, **kwargs: object) -> _ClosableDependency: + del args, kwargs + dependency = _ClosableDependency() + graph_credentials.append(dependency) + return dependency + + def graph_client_factory(*args: object, **kwargs: object) -> _ClosableDependency: + del args, kwargs + dependency = _ClosableDependency() + graph_clients.append(dependency) + return dependency + + def bot_credential_factory(*args: object, **kwargs: object) -> object: + del args, kwargs + return object() + + monkeypatch.setattr(ms_teams, "ClientSecretCredential", graph_credential_factory) + monkeypatch.setattr(ms_teams.httpx, "Client", graph_client_factory) + monkeypatch.setattr(ms_teams, "MicrosoftAppCredentials", bot_credential_factory) + decoder = ms_teams.MSTeamsIMProviderAdapter.card_event_decoder() + first_adapter = ms_teams.MSTeamsIMProviderAdapter( + ms_teams.MSTeamsCredentials( + provider=IMProvider.MS_TEAMS, + tenant_id="11111111-1111-1111-1111-111111111111", + client_id="22222222-2222-2222-2222-222222222222", + client_secret="first-secret", + ) + ) + second_adapter = ms_teams.MSTeamsIMProviderAdapter( + ms_teams.MSTeamsCredentials( + provider=IMProvider.MS_TEAMS, + tenant_id="33333333-3333-3333-3333-333333333333", + client_id="44444444-4444-4444-4444-444444444444", + client_secret="rotated-secret", + ) + ) + + first_adapter.close() + second_adapter.close() + result = decoder.decode(_event()) + + assert isinstance(result, IMCardEvent) + assert [dependency.close_count for dependency in graph_credentials] == [1, 1] + assert [dependency.close_count for dependency in graph_clients] == [1, 1] diff --git a/api/tests/unit_tests/core/human_input_v2/im_provider/test_ms_teams_credential_alignment.py b/api/tests/unit_tests/core/human_input_v2/im_provider/test_ms_teams_credential_alignment.py new file mode 100644 index 00000000000..4e198286f70 --- /dev/null +++ b/api/tests/unit_tests/core/human_input_v2/im_provider/test_ms_teams_credential_alignment.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +from controllers.console.human_input_v2.providers import MSTeamsCredentialsInput as MSTeamsCredentialUpdate +from core.human_input_v2.entities import IMProvider +from core.human_input_v2.im_integration.adapters import credentials as provider_credentials + + +def test_ms_teams_resolved_credential_projection_is_registered() -> None: + assert hasattr(provider_credentials, "MSTeamsCredentials") + + +def test_ms_teams_credential_projections_are_field_complete_and_secret_safe() -> None: + resolved_type = provider_credentials.MSTeamsCredentials + assert set(resolved_type.model_fields) == {"provider", "tenant_id", "client_id", "client_secret"} + assert set(MSTeamsCredentialUpdate.model_fields) == {"provider", "tenant_id", "client_id", "client_secret"} + + credentials = resolved_type( + provider=IMProvider.MS_TEAMS, + tenant_id="11111111-1111-1111-1111-111111111111", + client_id="22222222-2222-2222-2222-222222222222", + client_secret="test-only-client-secret", + ) + + assert credentials.model_config["frozen"] is True + assert credentials.model_config["extra"] == "forbid" + assert "test-only-client-secret" not in repr(credentials) diff --git a/api/tests/unit_tests/core/human_input_v2/im_provider/test_ms_teams_sdk_boundary.py b/api/tests/unit_tests/core/human_input_v2/im_provider/test_ms_teams_sdk_boundary.py new file mode 100644 index 00000000000..3d20a34b97b --- /dev/null +++ b/api/tests/unit_tests/core/human_input_v2/im_provider/test_ms_teams_sdk_boundary.py @@ -0,0 +1,1062 @@ +from __future__ import annotations + +# These tests exercise local SDK boundaries with test doubles, not Microsoft systems. +import json +import threading +from collections import deque +from collections.abc import Callable, Generator, Mapping +from concurrent.futures import ThreadPoolExecutor +from dataclasses import dataclass +from datetime import UTC, datetime +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from typing import override + +import httpx +import jwt +import pytest +from azure.core.credentials import AccessToken +from botframework.connector import ConnectorClient +from botframework.connector.auth import ( + ChannelValidation, + JwtTokenExtractor, + MicrosoftAppCredentials, +) +from cryptography.hazmat.primitives.asymmetric import rsa +from jwt.algorithms import RSAAlgorithm +from msrest.authentication import BasicTokenAuthentication + +from core.file import remote_fetcher +from core.human_input import ButtonStyle +from core.human_input_v2.entities import IMProvider +from core.human_input_v2.im_integration.adapters import ( + AuthenticatedIMEvent, + CorrelationToken, + CredentialTestFailure, + CredentialTestFailureKind, + CredentialTestSuccess, + Directory, + DirectoryReadFailure, + DynamicCardMessagingError, + EventAcceptance, + MessageAccepted, + MessageLocator, + MessageSendingError, + MSTeamsCredentials, + ProviderUserId, + ReplacementError, + ReplacementErrorKind, + StaticCardIntent, + WebhookRequest, + ms_teams, +) +from core.human_input_v2.resolved_form import ( + FileInput, + FileListInput, + FormPart, + MarkdownFragment, + ParagraphInput, + ResolvedForm, + SelectInput, + UserAction, +) + +_PUBLIC_SERVICE_URL = "https://smba.trafficmanager.net/teams/" +_RECEIVED_AT = datetime(2026, 8, 6, 8) +_WEBHOOK_FIXTURE_PATH = Path(__file__).parents[4] / "fixtures" / "im_provider" / "ms_teams_webhook_activity.json" + + +@dataclass(frozen=True, slots=True) +class _QueuedResponse: + status_code: int + body: Mapping[str, object] + + +@dataclass(frozen=True, slots=True) +class _RecordedRequest: + operation: str + method: str + path: str + headers: Mapping[str, str] + body: bytes + + def json_body(self) -> dict[str, object]: + value = json.loads(self.body) + assert isinstance(value, dict) + return value + + +class _ConnectorState: + def __init__(self) -> None: + self._lock = threading.Lock() + self._responses: dict[str, deque[_QueuedResponse]] = { + "create": deque(), + "send": deque(), + "update": deque(), + } + self.requests: list[_RecordedRequest] = [] + + def enqueue(self, operation: str, status_code: int, body: Mapping[str, object]) -> None: + with self._lock: + self._responses[operation].append(_QueuedResponse(status_code, body)) + + def respond(self, request: _RecordedRequest) -> _QueuedResponse: + with self._lock: + self.requests.append(request) + responses = self._responses[request.operation] + if not responses: + return _QueuedResponse(500, {"error": {"message": "unconfigured sanitized response"}}) + return responses.popleft() + + def requests_for(self, operation: str) -> list[_RecordedRequest]: + with self._lock: + return [request for request in self.requests if request.operation == operation] + + +def _connector_operation(method: str, path: str) -> str | None: + if method == "POST" and path == "/v3/conversations": + return "create" + if method == "POST" and path.endswith("/activities"): + return "send" + if method == "PUT" and "/activities/" in path: + return "update" + return None + + +class _ConnectorHandler(BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + + def do_POST(self) -> None: + self._respond() + + def do_PUT(self) -> None: + self._respond() + + def _respond(self) -> None: + assert isinstance(self.server, _ConnectorHTTPServer) + operation = _connector_operation(self.command, self.path) + if operation is None: + self.send_error(404) + return + body = self.rfile.read(int(self.headers.get("Content-Length", "0"))) + response = self.server.state.respond( + _RecordedRequest( + operation=operation, + method=self.command, + path=self.path, + headers=dict(self.headers.items()), + body=body, + ) + ) + response_body = json.dumps(response.body, separators=(",", ":")).encode() + self.send_response(response.status_code) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(response_body))) + self.send_header("Connection", "close") + self.end_headers() + self.wfile.write(response_body) + + @override + def log_message(self, format: str, *args: object) -> None: + del format, args + + +class _ConnectorHTTPServer(ThreadingHTTPServer): + state: _ConnectorState + + +@dataclass(frozen=True, slots=True) +class _ConnectorServer: + server: _ConnectorHTTPServer + state: _ConnectorState + + @property + def base_url(self) -> str: + host = self.server.server_address[0] + port = self.server.server_address[1] + assert isinstance(host, str) + assert isinstance(port, int) + return f"http://{host}:{port}/" + + +@pytest.fixture +def connector_server() -> Generator[_ConnectorServer]: + state = _ConnectorState() + server = _ConnectorHTTPServer(("127.0.0.1", 0), _ConnectorHandler) + server.state = state + thread = threading.Thread(target=server.serve_forever) + thread.start() + try: + yield _ConnectorServer(server, state) + finally: + server.shutdown() + server.server_close() + thread.join(2) + assert not thread.is_alive() + + +class _GraphCredential: + def __init__(self, token: str, error: Exception | None = None) -> None: + self.token = token + self.error = error + self.close_count = 0 + + def get_token(self, scope: str) -> AccessToken: + assert scope == "https://graph.microsoft.com/.default" + if self.error is not None: + raise self.error + return AccessToken(expires_on=2000000000, token=self.token) + + def close(self) -> None: + self.close_count += 1 + + +class _GraphBoundary: + def __init__(self, responses: list[httpx.Response | Exception]) -> None: + self.responses = deque(responses) + self.requests: list[httpx.Request] = [] + + def __call__(self, request: httpx.Request) -> httpx.Response: + self.requests.append(request) + response = self.responses.popleft() + if isinstance(response, Exception): + raise response + return response + + +def _credentials() -> MSTeamsCredentials: + return MSTeamsCredentials( + provider=IMProvider.MS_TEAMS, + tenant_id="11111111-1111-1111-1111-111111111111", + client_id="22222222-2222-2222-2222-222222222222", + client_secret="test-only-client-secret", + ) + + +def _connector_factory(server: _ConnectorServer) -> Callable[..., ConnectorClient]: + def factory(*args: object, **kwargs: object) -> ConnectorClient: + del args, kwargs + return ConnectorClient( + BasicTokenAuthentication({"access_token": "test-only-connector-token"}), + base_url=server.base_url, + ) + + return factory + + +def _adapter( + monkeypatch: pytest.MonkeyPatch, + *, + graph_responses: list[httpx.Response | Exception] | None = None, + graph_token: str | None = None, + graph_error: Exception | None = None, + bot_token: str | None = None, + bot_error: Exception | None = None, + connector_factory: Callable[..., ConnectorClient] | None = None, + credentials: MSTeamsCredentials | None = None, +) -> tuple[ms_teams.MSTeamsIMProviderAdapter, _GraphCredential, _GraphBoundary]: + selected_credentials = credentials or _credentials() + graph_credential = _GraphCredential(graph_token or "opaque-graph-access-token", graph_error) + graph_boundary = _GraphBoundary( + graph_responses if graph_responses is not None else [httpx.Response(200, json={"value": []})] + ) + graph_client = httpx.Client(transport=httpx.MockTransport(graph_boundary), timeout=2) + bot_credentials = ms_teams._MSTeamsBotCredentials( + selected_credentials.client_id, + selected_credentials.client_secret, + channel_auth_tenant=selected_credentials.tenant_id, + ) + + def bot_access_token(force_refresh: bool = False) -> str: + del force_refresh + if bot_error is not None: + raise bot_error + return bot_token or "opaque-bot-access-token" + + def graph_credential_factory(*_args: object, **_kwargs: object) -> _GraphCredential: + return graph_credential + + def graph_client_factory(*_args: object, **_kwargs: object) -> httpx.Client: + return graph_client + + def bot_credentials_factory(*_args: object, **_kwargs: object) -> MicrosoftAppCredentials: + return bot_credentials + + monkeypatch.setattr(bot_credentials, "get_access_token", bot_access_token) + monkeypatch.setattr(ms_teams, "ClientSecretCredential", graph_credential_factory) + monkeypatch.setattr(ms_teams.httpx, "Client", graph_client_factory) + monkeypatch.setattr(ms_teams, "_MSTeamsBotCredentials", bot_credentials_factory) + if connector_factory is not None: + monkeypatch.setattr(ms_teams, "ConnectorClient", connector_factory) + return ms_teams.MSTeamsIMProviderAdapter(selected_credentials), graph_credential, graph_boundary + + +def _card_intent( + *, + input_type: str = "select", + markdown_text: str = "Sanitized **rendered** content", + action_style: ButtonStyle = ButtonStyle.PRIMARY, +) -> ResolvedForm: + if input_type == "select": + input_block: FormPart = SelectInput( + output_variable_name="decision", options=("Approve", "Reject"), default_value="Approve" + ) + elif input_type == "file": + input_block = FileInput( + output_variable_name="decision", + allowed_file_types=(), + allowed_file_extensions=(), + allowed_file_upload_methods=(), + ) + elif input_type == "file-list": + input_block = FileListInput( + output_variable_name="decision", + allowed_file_types=(), + allowed_file_extensions=(), + allowed_file_upload_methods=(), + number_limits=1, + ) + else: + input_block = ParagraphInput(output_variable_name="decision", default_value="Sanitized initial value") + return ResolvedForm( + title="Sanitized title", + parts=(MarkdownFragment(text=markdown_text), input_block, MarkdownFragment(text="Sanitized trailing content")), + actions=( + UserAction(id="approve", title="Approve", button_style=action_style), + UserAction(id="reject", title="Reject", button_style=ButtonStyle.ACCENT), + ), + legacy_form_content="This value must not be rendered", + ) + + +def _activity_body( + *, + event_id: str | None = "test-only-event", + action_id: str = "approve", +) -> dict[str, object]: + activity = json.loads(_WEBHOOK_FIXTURE_PATH.read_text()) + assert isinstance(activity, dict) + conversation = activity["conversation"] + channel_data = activity["channelData"] + value = activity["value"] + assert isinstance(conversation, dict) + assert isinstance(channel_data, dict) + assert isinstance(value, dict) + tenant = channel_data["tenant"] + assert isinstance(tenant, dict) + conversation["tenantId"] = _credentials().tenant_id + tenant["id"] = _credentials().tenant_id + value["action_id"] = action_id + if event_id is None: + activity.pop("id", None) + else: + activity["id"] = event_id + return activity + + +def _webhook_request(body: dict[str, object], token: str) -> WebhookRequest: + return WebhookRequest( + method="POST", + headers=( + ("Authorization", f"Bearer {token}"), + ("Content-Type", "application/json; charset=utf-8"), + ), + body=json.dumps(body, separators=(",", ":")).encode(), + received_at=_RECEIVED_AT, + ) + + +class _RecordingConsumer: + def __init__(self, acceptance: EventAcceptance = EventAcceptance.ACCEPTED) -> None: + self.acceptance = acceptance + self._lock = threading.Lock() + self.events: list[AuthenticatedIMEvent] = [] + + def accept(self, event: AuthenticatedIMEvent, *, request_ip_address: str | None = None) -> EventAcceptance: + del request_ip_address + with self._lock: + self.events.append(event) + return self.acceptance + + +class _MetadataResponse: + def __init__(self, body: Mapping[str, object]) -> None: + self.body = body + + def json(self) -> Mapping[str, object]: + return self.body + + def raise_for_status(self) -> None: + return None + + +def test_real_connector_and_graph_boundaries_round_trip_all_outbound_capabilities( + monkeypatch: pytest.MonkeyPatch, + connector_server: _ConnectorServer, +) -> None: + monkeypatch.setattr(remote_fetcher, "make_request", lambda *_args, **_kwargs: httpx.Response(404)) + state = connector_server.state + for conversation_id in ("test-only-text-conversation", "test-only-card-conversation"): + state.enqueue( + "create", + 201, + {"id": conversation_id, "serviceUrl": _PUBLIC_SERVICE_URL}, + ) + state.enqueue("send", 201, {"id": "test-only-text-activity"}) + state.enqueue("send", 201, {"id": "test-only-card-activity"}) + state.enqueue("update", 200, {"id": "test-only-card-activity"}) + next_link = "https://graph.microsoft.com/v1.0/users?$skiptoken=test-only-page" + adapter, _, graph_boundary = _adapter( + monkeypatch, + graph_responses=[ + httpx.Response(200, json={"value": []}), + httpx.Response( + 200, + json={ + "value": [ + {"id": "test-only-user-a", "displayName": "First", "mail": "first@example.test"}, + {"id": "test-only-user-b", "displayName": None, "mail": None}, + {"id": "test-only-user-blank", "displayName": " ", "mail": ""}, + ], + "@odata.nextLink": next_link, + }, + ), + httpx.Response( + 200, + json={"value": [{"id": "test-only-user-c", "displayName": "Third", "mail": None}]}, + ), + ], + connector_factory=_connector_factory(connector_server), + ) + + credential_result = adapter.test_credentials() + directory_result = adapter.directory.read_directory() + text_result = adapter.messaging.send_text(ProviderUserId("test-only-user-a"), "Sanitized **text**") + card_result = adapter.dynamic_card_messaging.send_card( + ProviderUserId("test-only-user-b"), + _card_intent(), + CorrelationToken("test-only-correlation"), + ) + assert isinstance(card_result, MessageAccepted) + persisted_reference = MessageLocator(str(card_result.locator)) + recreated_adapter = ms_teams.MSTeamsIMProviderAdapter(_credentials()) + replacement_result = recreated_adapter.dynamic_card_messaging.replace_with_static( + persisted_reference, + StaticCardIntent("Sanitized **recorded** presentation"), + ) + + assert credential_result == CredentialTestSuccess(adapter.provider, _credentials().tenant_id) + assert isinstance(directory_result, Directory) + assert [str(entry.provider_user_id) for entry in directory_result.entries] == [ + "test-only-user-a", + "test-only-user-b", + "test-only-user-blank", + "test-only-user-c", + ] + assert directory_result.entries[2].display_name is None + assert directory_result.entries[2].email is None + assert isinstance(text_result, MessageAccepted) + assert replacement_result is None + assert len(graph_boundary.requests) == 3 + assert graph_boundary.requests[0].url.params["$top"] == "1" + assert graph_boundary.requests[1].url.params["$select"] == "id,displayName,mail" + assert graph_boundary.requests[2].url == next_link + + create_requests = state.requests_for("create") + assert len(create_requests) == 2 + for request, expected_user in zip(create_requests, ("test-only-user-a", "test-only-user-b"), strict=True): + body = request.json_body() + assert body["tenantID"] == _credentials().tenant_id + assert body["bot"] == {"id": f"28:{_credentials().client_id}"} + assert body["members"] == [{"id": expected_user}] + assert request.headers["Authorization"] == "Bearer test-only-connector-token" + + send_requests = state.requests_for("send") + assert len(send_requests) == 2 + text_body = send_requests[0].json_body() + assert text_body["type"] == "message" + assert text_body["text"] == "Sanitized **text**" + assert text_body["textFormat"] == "extendedmarkdown" + card_body = send_requests[1].json_body() + assert card_body["type"] == "message" + assert card_body["summary"] == "Sanitized title" + attachments = card_body["attachments"] + assert isinstance(attachments, list) + attachment = attachments[0] + assert isinstance(attachment, dict) + assert attachment["contentType"] == "application/vnd.microsoft.card.adaptive" + content = attachment["content"] + assert isinstance(content, dict) + card_elements = content["body"] + assert isinstance(card_elements, list) + assert all(isinstance(element, dict) for element in card_elements) + assert [element["type"] for element in card_elements] == [ + "TextBlock", + "TextBlock", + "Input.ChoiceSet", + "TextBlock", + ] + assert content["actions"] == [ + { + "type": "Action.Submit", + "title": "Approve", + "data": { + "__dify.human_input": { + "version": 1, + "action_id": "approve", + "correlation_token": "test-only-correlation", + } + }, + }, + { + "type": "Action.Submit", + "title": "Reject", + "data": { + "__dify.human_input": { + "version": 1, + "action_id": "reject", + "correlation_token": "test-only-correlation", + } + }, + }, + ] + + update_requests = state.requests_for("update") + assert len(update_requests) == 1 + assert update_requests[0].path == ( + "/v3/conversations/test-only-card-conversation/activities/test-only-card-activity" + ) + assert update_requests[0].json_body() == { + "type": "message", + "text": "Sanitized **recorded** presentation", + } + + +@pytest.mark.parametrize( + ("subject", "expected_text"), + [ + (None, "## Body\n\n**Approved**"), + ("", "## Body\n\n**Approved**"), + ("### Review **Alice**", "# \\#\\#\\# Review \\*\\*Alice\\*\\*\n\n## Body\n\n**Approved**"), + ], +) +def test_real_connector_sends_h1_subject_and_body_with_extended_markdown( + monkeypatch: pytest.MonkeyPatch, + connector_server: _ConnectorServer, + subject: str | None, + expected_text: str, +) -> None: + state = connector_server.state + state.enqueue("create", 201, {"id": "test-only-conversation", "serviceUrl": _PUBLIC_SERVICE_URL}) + state.enqueue("send", 201, {"id": "test-only-activity"}) + adapter, _, _ = _adapter(monkeypatch, connector_factory=_connector_factory(connector_server)) + + result = adapter.messaging.send_text(ProviderUserId("test-only-user"), "## Body\n\n**Approved**", subject) + + assert isinstance(result, MessageAccepted) + requests = state.requests_for("send") + assert len(requests) == 1 + assert requests[0].json_body() == { + "type": "message", + "textFormat": "extendedmarkdown", + "text": expected_text, + } + + +def test_real_connector_disables_automatic_replay_for_ambiguous_send( + monkeypatch: pytest.MonkeyPatch, + connector_server: _ConnectorServer, +) -> None: + state = connector_server.state + state.enqueue( + "create", + 201, + {"id": "test-only-conversation", "serviceUrl": _PUBLIC_SERVICE_URL}, + ) + state.enqueue("send", 500, {"error": {"message": "test-only ambiguous outcome"}}) + adapter, _, _ = _adapter(monkeypatch, connector_factory=_connector_factory(connector_server)) + + result = adapter.messaging.send_text(ProviderUserId("test-only-user"), "Sanitized text") + + assert isinstance(result, MessageSendingError) + assert len(state.requests_for("create")) == 1 + assert len(state.requests_for("send")) == 1 + + +def test_real_connector_maps_exact_update_failures_without_selecting_another_message( + monkeypatch: pytest.MonkeyPatch, + connector_server: _ConnectorServer, +) -> None: + state = connector_server.state + state.enqueue( + "create", + 201, + {"id": "test-only-conversation", "serviceUrl": _PUBLIC_SERVICE_URL}, + ) + state.enqueue("send", 201, {"id": "test-only-card-activity"}) + state.enqueue("update", 404, {"error": {"message": "test-only stale activity"}}) + state.enqueue("update", 500, {"error": {"message": "test-only unknown outcome"}}) + adapter, _, _ = _adapter(monkeypatch, connector_factory=_connector_factory(connector_server)) + accepted = adapter.dynamic_card_messaging.send_card( + ProviderUserId("test-only-user"), + _card_intent(), + CorrelationToken("test-only-correlation"), + ) + assert isinstance(accepted, MessageAccepted) + serialized_value = str(accepted.locator) + replacement_character = "A" if serialized_value[-1] != "A" else "B" + malformed_reference = MessageLocator(serialized_value[:-1] + replacement_character) + + result = adapter.dynamic_card_messaging.replace_with_static( + accepted.locator, + StaticCardIntent("Sanitized static content"), + ) + invalid = adapter.dynamic_card_messaging.replace_with_static( + MessageLocator("invalid."), + StaticCardIntent("Sanitized static content"), + ) + malformed = adapter.dynamic_card_messaging.replace_with_static( + malformed_reference, + StaticCardIntent("Sanitized static content"), + ) + unknown = adapter.dynamic_card_messaging.replace_with_static( + accepted.locator, + StaticCardIntent("Sanitized static content"), + ) + + assert isinstance(result, ReplacementError) + assert result.kind is ReplacementErrorKind.STALE_REFERENCE + assert isinstance(invalid, ReplacementError) + assert invalid.kind is ReplacementErrorKind.INVALID_REFERENCE + assert isinstance(malformed, ReplacementError) + assert malformed.kind is ReplacementErrorKind.INVALID_REFERENCE + assert isinstance(unknown, ReplacementError) + assert unknown.kind is ReplacementErrorKind.UNKNOWN + assert len(state.requests_for("update")) == 2 + + +def test_replacement_rejects_locator_without_serialized_payload(monkeypatch: pytest.MonkeyPatch) -> None: + adapter, _, _ = _adapter(monkeypatch) + undersized_reference = MessageLocator("") + + result = adapter.dynamic_card_messaging.replace_with_static( + undersized_reference, + StaticCardIntent("Sanitized static content"), + ) + + assert isinstance(result, ReplacementError) + assert result.kind is ReplacementErrorKind.INVALID_REFERENCE + + +@pytest.mark.parametrize( + ("responses", "expected_request_count"), + [ + ([httpx.Response(500, json={"error": "test-only"})], 1), + ([RuntimeError("test-only unexpected Graph failure")], 1), + ( + [ + httpx.Response( + 200, + json={ + "value": [{"id": "test-only-partial"}], + "@odata.nextLink": "https://example.invalid/v1.0/users?$skiptoken=test-only", + }, + ) + ], + 1, + ), + ( + [ + httpx.Response( + 200, + json={ + "value": [{"id": "test-only-partial"}], + "@odata.nextLink": "https://graph.microsoft.com/v1.0/users?$skiptoken=test-only", + }, + ), + httpx.Response(200, json={"value": "invalid"}), + ], + 2, + ), + ], + ids=("http-failure", "unexpected-failure", "untrusted-next-link", "malformed-later-page"), +) +def test_graph_boundary_never_publishes_partial_directory( + monkeypatch: pytest.MonkeyPatch, + responses: list[httpx.Response | Exception], + expected_request_count: int, +) -> None: + monkeypatch.setattr(remote_fetcher, "make_request", lambda *_args, **_kwargs: httpx.Response(404)) + adapter, _, graph_boundary = _adapter(monkeypatch, graph_responses=responses) + + result = adapter.directory.read_directory() + + assert isinstance(result, DirectoryReadFailure) + assert "test-only-partial" not in result.reason + assert len(graph_boundary.requests) == expected_request_count + + +@pytest.mark.parametrize( + "intent", + [ + _card_intent(input_type="file"), + _card_intent(input_type="file-list"), + _card_intent(action_style=ButtonStyle.GHOST), + _card_intent(markdown_text="x" * 30_000), + ResolvedForm( + title="Sanitized title", + parts=(SelectInput(output_variable_name="decision", options=("One", "One"), default_value="One"),), + actions=(UserAction(id="approve", title="Approve", button_style=ButtonStyle.PRIMARY),), + legacy_form_content="This value must not be rendered", + ), + ], + ids=("file", "file-list", "action-style", "size", "duplicate-options"), +) +def test_complete_card_assessment_rejects_unrepresentable_intent_without_sdk_io( + monkeypatch: pytest.MonkeyPatch, + intent: ResolvedForm, +) -> None: + connector_calls = 0 + + def connector_factory(*args: object, **kwargs: object) -> ConnectorClient: + nonlocal connector_calls + del args, kwargs + connector_calls += 1 + raise AssertionError("unrepresentable card must not reach the connector") + + adapter, _, _ = _adapter(monkeypatch, connector_factory=connector_factory) + + assessment = adapter.dynamic_card_messaging.assess(intent) + with pytest.raises(DynamicCardMessagingError): + adapter.dynamic_card_messaging.send_card( + ProviderUserId("test-only-user"), + intent, + CorrelationToken("test-only-correlation"), + ) + + assert assessment.representable is False + assert assessment.reason + assert connector_calls == 0 + + +@pytest.mark.parametrize( + ("graph_response", "graph_error", "bot_error", "expected_kind", "expected_code", "expected_status"), + [ + ( + httpx.Response(401, json={"error": {"code": "InvalidAuthenticationToken"}}), + None, + None, + CredentialTestFailureKind.AUTHENTICATION_REJECTED, + "InvalidAuthenticationToken", + 401, + ), + ( + httpx.Response(403, json={"error": {"code": "Authorization_RequestDenied"}}), + None, + None, + CredentialTestFailureKind.PERMISSION_DENIED, + "Authorization_RequestDenied", + 403, + ), + ( + httpx.Response(403, json={"error": {}}), + None, + None, + CredentialTestFailureKind.PERMISSION_DENIED, + None, + 403, + ), + ( + httpx.Response(429, json={"error": {"code": "TooManyRequests"}}), + None, + None, + CredentialTestFailureKind.UNKNOWN, + "TooManyRequests", + 429, + ), + ( + httpx.Response(200, json={"value": []}), + RuntimeError("test-only Graph outage"), + None, + None, + None, + None, + ), + ( + httpx.Response(200, json={"value": []}), + None, + RuntimeError("test-only Bot outage"), + None, + None, + None, + ), + ( + httpx.Response(200, json={"value": []}), + None, + PermissionError( + "Failed to get access token with error: invalid_client, error_description: test-only rejection" + ), + CredentialTestFailureKind.AUTHENTICATION_REJECTED, + "invalid_client", + None, + ), + ( + httpx.Response(200, json={"value": []}), + None, + PermissionError( + "Failed to get access token with error: temporarily_unavailable, error_description: test-only outage" + ), + CredentialTestFailureKind.UNKNOWN, + "temporarily_unavailable", + None, + ), + ], + ids=( + "graph-token-rejected", + "graph-permission-denied", + "graph-no-api-code", + "graph-throttled", + "graph-outage", + "bot-outage", + "bot-rejection", + "bot-transient", + ), +) +def test_public_credential_boundaries_return_capability_scoped_failures( + monkeypatch: pytest.MonkeyPatch, + graph_response: httpx.Response, + graph_error: Exception | None, + bot_error: Exception | None, + expected_kind: CredentialTestFailureKind | None, + expected_code: str | None, + expected_status: int | None, +) -> None: + adapter, _, _ = _adapter( + monkeypatch, + graph_responses=[graph_response], + graph_token="opaque-graph-access-token", + graph_error=graph_error, + bot_token="opaque-bot-access-token", + bot_error=bot_error, + ) + if expected_kind is None: + execution_error = graph_error if graph_error is not None else bot_error + assert isinstance(execution_error, Exception) + with pytest.raises(type(execution_error)) as captured: + adapter.test_credentials() + assert captured.value is execution_error + return + + result = adapter.test_credentials() + + assert isinstance(result, CredentialTestFailure) + assert result.kind is expected_kind + assert result.provider_error_code == expected_code + assert result.http_status == expected_status + assert result.missing_permissions == () + assert "test-only" not in result.reason + + +@pytest.mark.parametrize("action_id", ["approve", "reject"]) +def test_official_jwt_boundary_authenticates_complete_payload_ack_and_concurrency_for_each_action( + monkeypatch: pytest.MonkeyPatch, + action_id: str, +) -> None: + signing_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + wrong_signing_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + public_jwk = RSAAlgorithm.to_jwk(signing_key.public_key(), as_dict=True) + public_jwk.update({"kid": "test-only-key", "endorsements": []}) + metadata_url = "https://test-only.invalid/openid-configuration" + jwks_url = "https://test-only.invalid/keys" + monkeypatch.setattr(ChannelValidation, "open_id_metadata_endpoint", metadata_url) + JwtTokenExtractor.metadataCache.pop(metadata_url, None) + metadata_requests: list[str] = [] + + def metadata_get(url: str, *args: object, **kwargs: object) -> _MetadataResponse: + del args, kwargs + metadata_requests.append(url) + if url == metadata_url: + return _MetadataResponse({"jwks_uri": jwks_url}) + assert url == jwks_url + return _MetadataResponse({"keys": [public_jwk]}) + + monkeypatch.setattr( + "botframework.connector.auth.jwt_token_extractor.requests.get", + metadata_get, + ) + adapter, _, _ = _adapter(monkeypatch) + accepted_consumer = _RecordingConsumer() + rejected_consumer = _RecordingConsumer(EventAcceptance.NOT_ACCEPTED) + accepted_handler = adapter.create_webhook_handler(accepted_consumer) + rejected_handler = adapter.create_webhook_handler(rejected_consumer) + current_timestamp = int(datetime.now(tz=UTC).timestamp()) + claims = { + "iss": "https://api.botframework.com", + "aud": _credentials().client_id, + "serviceurl": _PUBLIC_SERVICE_URL, + "nbf": current_timestamp - 60, + "exp": current_timestamp + 600, + } + token = jwt.encode(claims, signing_key, algorithm="RS256", headers={"kid": "test-only-key"}) + requests = [ + _webhook_request( + _activity_body( + event_id=f"test-only-event-{index}", + action_id=action_id, + ), + token, + ) + for index in range(6) + ] + + with ThreadPoolExecutor(max_workers=6) as executor: + responses = tuple(executor.map(accepted_handler.handle, requests)) + rejected_response = rejected_handler.handle(_webhook_request(_activity_body(action_id=action_id), token)) + adapter.close() + adapter.close() + after_close_response = accepted_handler.handle( + _webhook_request( + _activity_body( + event_id="test-only-after-close", + action_id=action_id, + ), + token, + ) + ) + + assert [(response.status_code, response.body) for response in responses] == [(200, b"")] * 6 + assert (rejected_response.status_code, rejected_response.body) == (503, b"event not accepted") + assert (after_close_response.status_code, after_close_response.body) == (200, b"") + assert len(accepted_consumer.events) == 7 + assert len(rejected_consumer.events) == 1 + assert metadata_requests + first_event = accepted_consumer.events[0] + assert first_event.provider.value == "ms_teams" + assert first_event.provider_tenant_id == _credentials().tenant_id + assert first_event.received_at == _RECEIVED_AT + first_payload = json.loads(first_event.payload) + assert first_payload in [ + _activity_body( + event_id=f"test-only-event-{index}", + action_id=action_id, + ) + for index in range(6) + ] + assert first_payload["value"]["action_id"] == action_id + + body_without_timestamp = _activity_body( + event_id="test-only-no-timestamp", + action_id=action_id, + ) + body_without_timestamp.pop("timestamp") + no_timestamp_response = accepted_handler.handle(_webhook_request(body_without_timestamp, token)) + + class FailingConsumer: + def accept(self, event: AuthenticatedIMEvent, *, request_ip_address: str | None = None) -> EventAcceptance: + del request_ip_address + del event + raise RuntimeError("test-only consumer failure") + + failing_handler = adapter.create_webhook_handler(FailingConsumer()) + consumer_failure_response = failing_handler.handle(_webhook_request(_activity_body(action_id=action_id), token)) + + assert (no_timestamp_response.status_code, no_timestamp_response.body) == (200, b"") + assert accepted_consumer.events[-1].occurred_at is None + assert (consumer_failure_response.status_code, consumer_failure_response.body) == ( + 503, + b"event processing failed", + ) + + invalid_tokens = ( + jwt.encode(claims, wrong_signing_key, algorithm="RS256", headers={"kid": "test-only-key"}), + jwt.encode( + {**claims, "aud": "test-only-wrong-audience"}, + signing_key, + algorithm="RS256", + headers={"kid": "test-only-key"}, + ), + jwt.encode( + {**claims, "exp": current_timestamp - 600}, + signing_key, + algorithm="RS256", + headers={"kid": "test-only-key"}, + ), + ) + previous_event_count = len(accepted_consumer.events) + for invalid_token in invalid_tokens: + response = accepted_handler.handle(_webhook_request(_activity_body(action_id=action_id), invalid_token)) + assert (response.status_code, response.body) == (401, b"request authentication failed") + mismatched_tenant = _activity_body(action_id=action_id) + mismatched_tenant["conversation"] = { + "id": "test-only-conversation", + "tenantId": "33333333-3333-3333-3333-333333333333", + } + mismatch_response = accepted_handler.handle(_webhook_request(mismatched_tenant, token)) + assert (mismatch_response.status_code, mismatch_response.body) == ( + 403, + b"Microsoft Teams tenant mismatch", + ) + assert len(accepted_consumer.events) == previous_event_count + JwtTokenExtractor.metadataCache.pop(metadata_url, None) + + +@pytest.mark.parametrize( + "webhook_request", + [ + WebhookRequest("GET", (), b"{}", _RECEIVED_AT), + WebhookRequest("POST", (("Content-Type", "application/json"),), b"{}", _RECEIVED_AT), + WebhookRequest( + "POST", + (("Authorization", "Bearer test-only"), ("Content-Type", "text/plain")), + b"{}", + _RECEIVED_AT, + ), + WebhookRequest( + "POST", + (("Authorization", "Bearer test-only"), ("Content-Type", "application/json")), + b"not-json", + _RECEIVED_AT, + ), + WebhookRequest( + "POST", + (("Authorization", "Bearer test-only"), ("Content-Type", "application/json")), + b"[]", + _RECEIVED_AT, + ), + _webhook_request({}, "test-only"), + _webhook_request( + {**_activity_body(), "serviceUrl": "https://example.invalid/teams/"}, + "test-only", + ), + _webhook_request({**_activity_body(), "nonStandard": float("nan")}, "test-only"), + ], + ids=("method", "auth", "content-type", "invalid-json", "non-object", "schema", "service-url", "non-standard-json"), +) +def test_webhook_public_boundary_rejects_invalid_requests_without_consumer( + monkeypatch: pytest.MonkeyPatch, + webhook_request: WebhookRequest, +) -> None: + adapter, _, _ = _adapter(monkeypatch) + consumer = _RecordingConsumer() + handler = adapter.create_webhook_handler(consumer) + + response = handler.handle(webhook_request) + + assert response.status_code >= 400 + assert consumer.events == [] + + +def test_provider_capabilities_are_webhook_only_and_factories_are_local( + monkeypatch: pytest.MonkeyPatch, +) -> None: + adapter, graph_credential, graph_boundary = _adapter(monkeypatch) + consumer = _RecordingConsumer() + + assert adapter.provider.value == "ms_teams" + assert adapter.directory is adapter.directory + assert adapter.messaging is adapter.messaging + assert adapter.dynamic_card_messaging is adapter.dynamic_card_messaging + assert adapter.create_webhook_handler(consumer) is not None + assert adapter.create_stream_handler(consumer) is None + assert graph_boundary.requests == [] + assert graph_credential.close_count == 0 diff --git a/api/tests/unit_tests/core/human_input_v2/im_provider/test_slack_adapter.py b/api/tests/unit_tests/core/human_input_v2/im_provider/test_slack_adapter.py new file mode 100644 index 00000000000..843df2280cc --- /dev/null +++ b/api/tests/unit_tests/core/human_input_v2/im_provider/test_slack_adapter.py @@ -0,0 +1,1473 @@ +from __future__ import annotations + +import hashlib +import hmac +import json +import logging +import threading +from collections.abc import Callable +from datetime import UTC, datetime +from pathlib import Path +from types import MappingProxyType +from typing import override + +import httpx +import pytest +from pytest_mock import MockerFixture +from slack_sdk.errors import SlackApiError, SlackClientError +from slack_sdk.socket_mode.request import SocketModeRequest +from slack_sdk.socket_mode.response import SocketModeResponse +from slack_sdk.web import WebClient +from slack_sdk.web.slack_response import SlackResponse + +from core.human_input import ButtonStyle +from core.human_input_v2.entities import IMProvider +from core.human_input_v2.im_integration.adapters import ( + AuthenticatedIMEvent, + CorrelationToken, + CredentialTestFailure, + CredentialTestFailureKind, + CredentialTestSuccess, + Directory, + DirectoryReadFailure, + DynamicCardMessagingError, + EventAcceptance, + IMEventIngressKind, + IMStreamStartError, + IMStreamStopError, + MessageAccepted, + MessageLocator, + MessageSendingError, + ProviderUserId, + ReplacementError, + ReplacementErrorKind, + SlackCredentials, + StaticCardIntent, + WebhookRequest, +) +from core.human_input_v2.im_integration.adapters import slack as slack_module +from core.human_input_v2.im_integration.adapters.entities import IMProviderError +from core.human_input_v2.im_integration.adapters.slack import SlackIMProviderAdapter +from core.human_input_v2.resolved_form import ( + FileInput, + FileListInput, + MarkdownFragment, + ParagraphInput, + ResolvedForm, + SelectInput, + UserAction, +) + +_RECEIVED_AT = datetime(2026, 8, 6, 8, 0, 0) +_REQUEST_TIMESTAMP = str(int(_RECEIVED_AT.replace(tzinfo=UTC).timestamp())) + + +def _slack_response(values: dict[str, object], *, headers: dict[str, str] | None = None) -> SlackResponse: + return SlackResponse( + client=WebClient(token="test-token"), + http_verb="POST", + api_url="https://slack.com/api/test", + req_args={}, + data=values, + headers=headers or {}, + status_code=200, + ) + + +class FakeWebClient(WebClient): + def __init__(self) -> None: + self.auth_responses: list[SlackResponse | Exception] = [] + self.connection_responses: list[SlackResponse | Exception] = [] + self.directory_responses: list[SlackResponse | Exception] = [] + self.post_responses: list[SlackResponse | Exception] = [] + self.update_responses: list[SlackResponse | Exception] = [] + self.auth_calls = 0 + self.connection_calls = 0 + self.directory_calls: list[dict[str, object]] = [] + self.post_calls: list[dict[str, object]] = [] + self.update_calls: list[dict[str, object]] = [] + + @staticmethod + def _next(responses: list[SlackResponse | Exception]) -> SlackResponse: + response = responses.pop(0) + if isinstance(response, Exception): + raise response + return response + + @override + def auth_test(self, **kwargs: object) -> SlackResponse: + self.auth_calls += 1 + return self._next(self.auth_responses) + + @override + def apps_connections_open(self, *, app_token: str, **kwargs: object) -> SlackResponse: + del app_token + self.connection_calls += 1 + return self._next(self.connection_responses) + + @override + def users_list(self, **kwargs: object) -> SlackResponse: + self.directory_calls.append(kwargs) + return self._next(self.directory_responses) + + @override + def chat_postMessage(self, **kwargs: object) -> SlackResponse: + self.post_calls.append(kwargs) + return self._next(self.post_responses) + + @override + def chat_update(self, **kwargs: object) -> SlackResponse: + self.update_calls.append(kwargs) + return self._next(self.update_responses) + + +class RecordingConsumer: + def __init__(self, acceptance: EventAcceptance = EventAcceptance.ACCEPTED) -> None: + self.acceptance = acceptance + self.events = [] + + def accept(self, event: AuthenticatedIMEvent, *, request_ip_address: str | None = None) -> EventAcceptance: + del request_ip_address + self.events.append(event) + return self.acceptance + + +def _credentials( + *, + signing_secret: str = "signing-secret", + app_token: str | None = "xapp-test-app-token", +) -> SlackCredentials: + return SlackCredentials( + provider=IMProvider.SLACK, + client_id="client-id", + client_secret="client-secret", + signing_secret=signing_secret, + bot_token="xoxb-test-bot-token", + app_token=app_token, + ) + + +def _successful_auth_response(team_id: str = "team-1") -> SlackResponse: + return _slack_response( + {"ok": True, "team_id": team_id}, + headers={"x-oauth-scopes": "chat:write,users:read,users:read.email"}, + ) + + +def _adapter( + mocker: MockerFixture, + client: FakeWebClient, + credentials: SlackCredentials | None = None, +) -> SlackIMProviderAdapter: + web_client = mocker.patch("core.human_input_v2.im_integration.adapters.slack.WebClient") + web_client.return_value = client + return SlackIMProviderAdapter(credentials or _credentials()) + + +def _intent(*, input_type: str = "paragraph") -> ResolvedForm: + if input_type == "select": + input_block = SelectInput(output_variable_name="comment", options=("One", "Two"), default_value="One") + elif input_type == "file": + input_block = FileInput( + output_variable_name="comment", + allowed_file_types=(), + allowed_file_extensions=(), + allowed_file_upload_methods=(), + ) + elif input_type == "file-list": + input_block = FileListInput( + output_variable_name="comment", + allowed_file_types=(), + allowed_file_extensions=(), + allowed_file_upload_methods=(), + number_limits=1, + ) + else: + input_block = ParagraphInput(output_variable_name="comment", default_value="Initial") + return ResolvedForm( + title="Approval", + parts=(MarkdownFragment(text="Rendered **content**"), input_block, MarkdownFragment(text="After input")), + actions=( + UserAction(id="approve", title="Approve", button_style=ButtonStyle.PRIMARY), + UserAction(id="reject", title="Reject", button_style=ButtonStyle.ACCENT), + ), + legacy_form_content="This value must not be rendered", + ) + + +def _intent_with_paragraph_default(default_value: str) -> ResolvedForm: + return ResolvedForm( + title="Approval", + parts=(ParagraphInput(output_variable_name="comment", default_value=default_value),), + actions=(UserAction(id="approve", title="Approve", button_style=ButtonStyle.PRIMARY),), + legacy_form_content="This value must not be rendered", + ) + + +def _intent_with_select_option_count(option_count: int) -> ResolvedForm: + options = tuple(f"option-{ordinal}" for ordinal in range(option_count)) + return ResolvedForm( + title="Approval", + parts=(SelectInput(output_variable_name="risk_level", options=options, default_value=options[0]),), + actions=(UserAction(id="approve", title="Approve", button_style=ButtonStyle.PRIMARY),), + legacy_form_content="This value must not be rendered", + ) + + +def _signed_request(body: bytes, *, signature: str | None = None) -> WebhookRequest: + calculated = ( + "v0=" + + hmac.new( + b"signing-secret", + f"v0:{_REQUEST_TIMESTAMP}:".encode() + body, + hashlib.sha256, + ).hexdigest() + ) + return WebhookRequest( + method="POST", + headers=( + ("X-Slack-Request-Timestamp", _REQUEST_TIMESTAMP), + ("X-Slack-Signature", signature or calculated), + ("Content-Type", "application/json"), + ), + body=body, + received_at=_RECEIVED_AT, + ) + + +def test_construction_and_capability_inspection_perform_no_provider_io(mocker: MockerFixture) -> None: + client = FakeWebClient() + adapter = _adapter(mocker, client) + consumer = RecordingConsumer() + + first_stream = adapter.create_stream_handler(consumer) + second_stream = adapter.create_stream_handler(consumer) + + assert adapter.provider is IMProvider.SLACK + assert adapter.directory is not None + assert adapter.messaging is not None + assert adapter.dynamic_card_messaging is not None + assert adapter.create_webhook_handler(consumer) is not None + assert first_stream is not second_stream + assert client.auth_calls == 0 + assert client.connection_calls == 0 + adapter.close() + adapter.close() + + +def test_credential_test_authenticates_both_tokens_and_checks_baseline_scopes(mocker: MockerFixture) -> None: + client = FakeWebClient() + client.auth_responses.append(_successful_auth_response()) + client.connection_responses.append(_slack_response({"ok": True, "url": "wss://example.invalid"})) + adapter = _adapter(mocker, client) + + result = adapter.test_credentials() + + assert result == CredentialTestSuccess(IMProvider.SLACK, "team-1") + assert client.auth_calls == 1 + assert client.connection_calls == 1 + + +def test_credential_test_does_not_require_socket_mode_app_token(mocker: MockerFixture) -> None: + client = FakeWebClient() + client.auth_responses.append(_successful_auth_response()) + adapter = _adapter(mocker, client, _credentials(app_token=None)) + + result = adapter.test_credentials() + + assert result == CredentialTestSuccess(IMProvider.SLACK, "team-1") + assert client.auth_calls == 1 + assert client.connection_calls == 0 + + +def test_stream_capability_is_unavailable_without_socket_mode_app_token(mocker: MockerFixture) -> None: + client = FakeWebClient() + adapter = _adapter(mocker, client, _credentials(app_token=None)) + + assert adapter.create_stream_handler(RecordingConsumer()) is None + assert adapter.create_webhook_handler(RecordingConsumer()) is not None + + +def test_credential_test_returns_safe_typed_failures(mocker: MockerFixture) -> None: + client = FakeWebClient() + api_response = _slack_response({"ok": False, "error": "invalid_auth", "sensitive": "must-not-leak"}) + client.auth_responses.append(SlackApiError("raw-provider-message", response=api_response)) + adapter = _adapter(mocker, client) + + rejected = adapter.test_credentials() + + assert isinstance(rejected, CredentialTestFailure) + assert rejected.kind is CredentialTestFailureKind.AUTHENTICATION_REJECTED + assert rejected.provider_error_code == "invalid_auth" + assert "raw-provider-message" not in rejected.reason + assert "must-not-leak" not in rejected.reason + + second_client = FakeWebClient() + second_client.auth_responses.append( + _slack_response({"ok": True}, headers={"x-oauth-scopes": "chat:write,users:read,users:read.email"}) + ) + second_adapter = _adapter(mocker, second_client) + with pytest.raises(IMProviderError, match="workspace identity"): + second_adapter.test_credentials() + + +def test_credential_test_reports_only_scopes_explicitly_missing_from_provider_response(mocker: MockerFixture) -> None: + client = FakeWebClient() + client.auth_responses.append( + SlackApiError( + "secret raw message", + response=_slack_response( + { + "error": "missing_scope", + "needed": "users:read,users:read.email", + "provided": "users:read", + } + ), + ) + ) + + result = _adapter(mocker, client).test_credentials() + + assert isinstance(result, CredentialTestFailure) + assert result.provider_error_code == "missing_scope" + assert result.missing_permissions == ("users:read.email",) + assert "secret raw message" not in repr(result) + + +def test_credential_test_identifies_missing_baseline_scopes_from_granted_scopes(mocker: MockerFixture) -> None: + client = FakeWebClient() + client.auth_responses.append(_slack_response({"team_id": "T1"}, headers={"x-oauth-scopes": "users:read"})) + + result = _adapter(mocker, client).test_credentials() + + assert isinstance(result, CredentialTestFailure) + assert result.missing_permissions == ("chat:write", "users:read.email") + + +@pytest.mark.parametrize( + "scope_details", + [ + {}, + {"needed": "users:read.email"}, + {"needed": ["users:read.email"], "provided": "users:read"}, + ], +) +def test_credential_test_keeps_error_code_without_guessing_unavailable_scope_details( + mocker: MockerFixture, scope_details: dict[str, object] +) -> None: + client = FakeWebClient() + client.auth_responses.append( + SlackApiError( + "raw secret", + response=_slack_response( + { + "error": "missing_scope", + **scope_details, + } + ), + ) + ) + + result = _adapter(mocker, client).test_credentials() + + assert isinstance(result, CredentialTestFailure) + assert result.provider_error_code == "missing_scope" + assert result.missing_permissions == () + + +@pytest.mark.parametrize("connection_url", ["", " "]) +def test_credential_test_rejects_blank_socket_url(mocker: MockerFixture, connection_url: str) -> None: + client = FakeWebClient() + client.auth_responses.append(_successful_auth_response()) + client.connection_responses.append(_slack_response({"ok": True, "url": connection_url})) + adapter = _adapter(mocker, client) + + with pytest.raises(IMProviderError, match="connection URL"): + adapter.test_credentials() + + +def test_credential_test_normalizes_unexpected_socket_failure_without_details( + mocker: MockerFixture, caplog: pytest.LogCaptureFixture +) -> None: + sensitive_marker = "sensitive socket credential details" + client = FakeWebClient() + client.auth_responses.append(_successful_auth_response()) + execution_error = RuntimeError(sensitive_marker) + client.connection_responses.append(execution_error) + adapter = _adapter(mocker, client) + + with pytest.raises(RuntimeError, match=sensitive_marker) as captured: + adapter.test_credentials() + assert captured.value is execution_error + assert sensitive_marker not in caplog.text + + +def test_directory_returns_one_ordered_complete_snapshot(mocker: MockerFixture) -> None: + client = FakeWebClient() + client.directory_responses.extend( + ( + _slack_response( + { + "ok": True, + "members": [ + { + "id": "user-1", + "deleted": False, + "is_bot": False, + "is_app_user": False, + "profile": {"display_name_normalized": "First", "email": "first@example.com"}, + }, + { + "id": "bot-1", + "deleted": False, + "is_bot": True, + "profile": {"real_name_normalized": "Bot"}, + }, + ], + "response_metadata": {"next_cursor": "cursor-2"}, + } + ), + _slack_response( + { + "ok": True, + "members": [ + { + "id": "user-2", + "deleted": False, + "is_bot": False, + "is_app_user": False, + "profile": {"real_name_normalized": "Second"}, + } + ], + "response_metadata": {"next_cursor": ""}, + } + ), + ) + ) + adapter = _adapter(mocker, client) + + result = adapter.directory.read_directory() + + assert isinstance(result, Directory) + assert [entry.provider_user_id for entry in result.entries] == ["user-1", "user-2"] + assert result.entries[0].email == "first@example.com" + assert result.entries[1].display_name == "Second" + assert result.entries[1].email is None + assert client.directory_calls == [{"limit": 200}, {"limit": 200, "cursor": "cursor-2"}] + + +def test_directory_projects_avatar_from_captured_user(mocker: MockerFixture) -> None: + fixture = Path(__file__).parents[4] / "fixtures/im_provider/slack/sanitized_directory_user.json" + client = FakeWebClient() + mocker.patch( + "core.file.remote_fetcher.make_request", + return_value=httpx.Response( + 200, + headers={"Content-Type": "image/jpeg; charset=binary"}, + content=b"avatar-image", + request=httpx.Request("GET", "https://example.invalid/image_192.png"), + ), + ) + client.directory_responses.append( + _slack_response( + {"ok": True, "members": [json.loads(fixture.read_text())], "response_metadata": {"next_cursor": ""}} + ) + ) + + result = _adapter(mocker, client).directory.read_directory() + + assert isinstance(result, Directory) + assert len(result.entries) == 1 + assert result.entries[0].avatar is not None + assert result.entries[0].avatar.mime_type == "image/jpeg" + assert result.entries[0].avatar.data == b"avatar-image" + + +@pytest.mark.parametrize("profile", [{}, {"image_192": None}, {"image_192": " "}]) +def test_directory_accepts_unavailable_avatar(mocker: MockerFixture, profile: dict[str, object]) -> None: + client = FakeWebClient() + client.directory_responses.append( + _slack_response( + {"ok": True, "members": [{"id": "user", "profile": profile}], "response_metadata": {"next_cursor": ""}} + ) + ) + + result = _adapter(mocker, client).directory.read_directory() + + assert isinstance(result, Directory) + assert result.entries[0].avatar is None + + +def test_directory_excludes_slack_owned_special_users(mocker: MockerFixture) -> None: + client = FakeWebClient() + client.directory_responses.append( + _slack_response( + { + "ok": True, + "members": [ + { + "id": "USLACKBOT", + "deleted": False, + "is_bot": False, + "is_app_user": False, + "profile": {"real_name_normalized": "Slackbot"}, + }, + { + "id": "USLACK", + "deleted": False, + "is_bot": False, + "is_app_user": False, + "profile": {"real_name_normalized": "Slack"}, + }, + { + "id": "user-1", + "deleted": False, + "is_bot": False, + "is_app_user": False, + "profile": { + "display_name_normalized": "First", + "email": "first@example.com", + }, + }, + ], + "response_metadata": {"next_cursor": ""}, + } + ) + ) + adapter = _adapter(mocker, client) + + result = adapter.directory.read_directory() + + assert isinstance(result, Directory) + assert [entry.provider_user_id for entry in result.entries] == ["user-1"] + + +def test_directory_rejects_unhashable_provider_user_id_without_raising(mocker: MockerFixture) -> None: + client = FakeWebClient() + client.directory_responses.append( + _slack_response( + { + "ok": True, + "members": [{"id": [], "profile": {}}], + "response_metadata": {"next_cursor": ""}, + } + ) + ) + adapter = _adapter(mocker, client) + + result = adapter.directory.read_directory() + + assert isinstance(result, DirectoryReadFailure) + assert result.reason == "Slack returned an invalid directory entry." + + +def test_directory_page_failure_discards_entries(mocker: MockerFixture) -> None: + client = FakeWebClient() + client.directory_responses.extend( + ( + _slack_response( + { + "ok": True, + "members": [{"id": "user-1", "profile": {}}], + "response_metadata": {"next_cursor": "cursor-2"}, + } + ), + SlackApiError("provider details", _slack_response({"ok": False, "error": "ratelimited"})), + ) + ) + adapter = _adapter(mocker, client) + + result = adapter.directory.read_directory() + + assert isinstance(result, DirectoryReadFailure) + assert not hasattr(result, "entries") + assert "provider details" not in result.reason + + +@pytest.mark.parametrize( + "response_values", + [ + {"ok": True, "members": [{"id": "user-1", "profile": {}}]}, + {"ok": True, "members": [{"id": "user-1", "profile": {}}], "response_metadata": None}, + {"ok": True, "members": [{"id": "user-1", "profile": {}}], "response_metadata": []}, + {"ok": True, "members": [{"id": "user-1", "profile": {}}], "response_metadata": {}}, + { + "ok": True, + "members": [{"id": "user-1", "profile": {}}], + "response_metadata": {"other": "value"}, + }, + { + "ok": True, + "members": [{"id": "user-1", "profile": {}}], + "response_metadata": {"next_cursor": None}, + }, + { + "ok": True, + "members": [{"id": "user-1", "profile": {}}], + "response_metadata": {"next_cursor": 1}, + }, + ], + ids=( + "missing-metadata", + "null-metadata", + "sequence-metadata", + "missing-cursor-empty-mapping", + "missing-cursor-nonempty-mapping", + "null-cursor", + "non-string-cursor", + ), +) +def test_directory_rejects_malformed_pagination_without_partial_snapshot( + mocker: MockerFixture, + response_values: dict[str, object], +) -> None: + client = FakeWebClient() + client.directory_responses.append(_slack_response(response_values)) + adapter = _adapter(mocker, client) + + result = adapter.directory.read_directory() + + assert isinstance(result, DirectoryReadFailure) + assert not hasattr(result, "entries") + assert "user-1" not in result.reason + assert client.directory_calls == [{"limit": 200}] + + +def test_directory_second_page_malformed_pagination_discards_all_accumulated_entries(mocker: MockerFixture) -> None: + client = FakeWebClient() + client.directory_responses.extend( + ( + _slack_response( + { + "ok": True, + "members": [{"id": "user-1", "profile": {}}], + "response_metadata": {"next_cursor": "cursor-2"}, + } + ), + _slack_response( + { + "ok": True, + "members": [{"id": "user-2", "profile": {}}], + "response_metadata": {"unexpected": "value"}, + } + ), + ) + ) + adapter = _adapter(mocker, client) + + result = adapter.directory.read_directory() + + assert isinstance(result, DirectoryReadFailure) + assert not hasattr(result, "entries") + assert "user-1" not in result.reason + assert "user-2" not in result.reason + assert client.directory_calls == [{"limit": 200}, {"limit": 200, "cursor": "cursor-2"}] + + +def test_directory_rejects_repeated_pagination_cursor_without_request_loop(mocker: MockerFixture) -> None: + client = FakeWebClient() + client.directory_responses.extend( + ( + _slack_response( + { + "ok": True, + "members": [{"id": "user-1", "profile": {}}], + "response_metadata": {"next_cursor": "cursor-2"}, + } + ), + _slack_response( + { + "ok": True, + "members": [{"id": "user-2", "profile": {}}], + "response_metadata": {"next_cursor": "cursor-2"}, + } + ), + ) + ) + adapter = _adapter(mocker, client) + + result = adapter.directory.read_directory() + + assert isinstance(result, DirectoryReadFailure) + assert not hasattr(result, "entries") + assert client.directory_calls == [{"limit": 200}, {"limit": 200, "cursor": "cursor-2"}] + + +def test_directory_accepts_mapping_and_sequence_sdk_values(mocker: MockerFixture) -> None: + client = FakeWebClient() + client.directory_responses.append( + _slack_response( + { + "ok": True, + "members": ( + MappingProxyType( + { + "id": "user-1", + "profile": MappingProxyType({"display_name_normalized": "First"}), + } + ), + ), + "response_metadata": MappingProxyType({"next_cursor": ""}), + } + ) + ) + adapter = _adapter(mocker, client) + + result = adapter.directory.read_directory() + + assert isinstance(result, Directory) + assert [entry.provider_user_id for entry in result.entries] == ["user-1"] + assert result.entries[0].display_name == "First" + + +def test_text_send_attempts_creation_once_and_returns_persistable_locator(mocker: MockerFixture) -> None: + client = FakeWebClient() + client.auth_responses.append(_successful_auth_response()) + client.post_responses.append(_slack_response({"ok": True, "channel": "dm-1", "ts": "1000.000001"})) + adapter = _adapter(mocker, client) + + result = adapter.messaging.send_text(ProviderUserId("user-1"), "Exact **CommonMark**") + + assert isinstance(result, MessageAccepted) + assert isinstance(result.locator, str) + assert slack_module._SlackLocatorPayload.decode(str(result.locator)) == slack_module._SlackLocatorPayload( + v=1, + p=IMProvider.SLACK, + channel_id="dm-1", + message_ts="1000.000001", + ) + assert client.auth_calls == 0 + assert client.post_calls == [{"channel": "user-1", "markdown_text": "Exact **CommonMark**"}] + + +def test_card_send_returns_persistable_locator(mocker: MockerFixture) -> None: + client = FakeWebClient() + client.auth_responses.append(_successful_auth_response()) + client.post_responses.append(_slack_response({"ok": True, "channel": "dm-1", "ts": "1000.000001"})) + adapter = _adapter(mocker, client) + + result = adapter.dynamic_card_messaging.send_card( + ProviderUserId("user-1"), + _intent(), + CorrelationToken("correlation-1"), + ) + + assert isinstance(result, MessageAccepted) + assert isinstance(result.locator, str) + assert slack_module._SlackLocatorPayload.decode(str(result.locator)) == slack_module._SlackLocatorPayload( + v=1, + p=IMProvider.SLACK, + channel_id="dm-1", + message_ts="1000.000001", + ) + assert client.auth_calls == 0 + + +def test_static_replacement_accepts_locator_across_adapter_instances(mocker: MockerFixture) -> None: + source_client = FakeWebClient() + source_client.auth_responses.append(_successful_auth_response("T0123456789")) + source_client.post_responses.append( + _slack_response({"ok": True, "channel": "D0123456789", "ts": "1712345678.123456"}) + ) + source_adapter = _adapter(mocker, source_client) + accepted = source_adapter.dynamic_card_messaging.send_card( + ProviderUserId("U0123456789"), + _intent(), + CorrelationToken("correlation-1"), + ) + assert isinstance(accepted, MessageAccepted) + + replacement_client = FakeWebClient() + replacement_client.auth_responses.append(_successful_auth_response("T0123456789")) + replacement_client.update_responses.append(_slack_response({"ok": True})) + replacement_adapter = _adapter(mocker, replacement_client) + + result = replacement_adapter.dynamic_card_messaging.replace_with_static( + accepted.locator, + StaticCardIntent("Submitted"), + ) + + assert result is None + assert source_client.auth_calls == 0 + assert replacement_client.auth_calls == 0 + assert replacement_client.update_calls == [ + { + "channel": "D0123456789", + "ts": "1712345678.123456", + "text": "Submitted", + "blocks": [{"type": "markdown", "text": "Submitted"}], + } + ] + + +def test_static_replacement_updates_the_exact_message_from_a_valid_locator(mocker: MockerFixture) -> None: + client = FakeWebClient() + client.auth_responses.append(_successful_auth_response()) + client.post_responses.append(_slack_response({"ok": True, "channel": "dm-1", "ts": "1000.000001"})) + client.update_responses.append(_slack_response({"ok": True})) + adapter = _adapter(mocker, client) + accepted = adapter.messaging.send_text(ProviderUserId("user-1"), "Body") + assert isinstance(accepted, MessageAccepted) + + result = adapter.dynamic_card_messaging.replace_with_static( + accepted.locator, + StaticCardIntent("Submitted"), + ) + + assert result is None + assert client.update_calls == [ + { + "channel": "dm-1", + "ts": "1000.000001", + "text": "Submitted", + "blocks": [{"type": "markdown", "text": "Submitted"}], + } + ] + + +def test_static_replacement_rejects_foreign_reference_without_provider_io(mocker: MockerFixture) -> None: + client = FakeWebClient() + adapter = _adapter(mocker, client) + + result = adapter.dynamic_card_messaging.replace_with_static( + MessageLocator("invalid."), + StaticCardIntent("Submitted"), + ) + + assert isinstance(result, ReplacementError) + assert result.kind is ReplacementErrorKind.INVALID_REFERENCE + assert client.update_calls == [] + + +def test_message_send_failure_is_safe_and_never_replayed(mocker: MockerFixture) -> None: + client = FakeWebClient() + client.auth_responses.append(_successful_auth_response()) + client.post_responses.append( + SlackApiError("raw message", _slack_response({"ok": False, "error": "internal_error", "token": "secret"})) + ) + adapter = _adapter(mocker, client) + + result = adapter.messaging.send_text(ProviderUserId("user-1"), "body") + + assert isinstance(result, MessageSendingError) + assert len(client.post_calls) == 1 + assert "raw message" not in result.reason + assert "secret" not in result.reason + + +@pytest.mark.parametrize("input_type", ["file", "file-list"]) +def test_card_assessment_rejects_file_controls_without_side_effects(mocker: MockerFixture, input_type: str) -> None: + client = FakeWebClient() + adapter = _adapter(mocker, client) + + assessment = adapter.dynamic_card_messaging.assess(_intent(input_type=input_type)) + + assert assessment.representable is False + assert client.post_calls == [] + with pytest.raises(DynamicCardMessagingError): + adapter.dynamic_card_messaging.send_card( + ProviderUserId("user-1"), + _intent(input_type=input_type), + CorrelationToken("correlation-1"), + ) + assert client.post_calls == [] + + +@pytest.mark.parametrize( + ("option_count", "expected_representable"), + [ + pytest.param(100, True, id="maximum-static-select-options"), + pytest.param(101, False, id="too-many-static-select-options"), + ], +) +def test_card_assessment_uses_static_select_option_limit( + mocker: MockerFixture, + option_count: int, + expected_representable: bool, +) -> None: + client = FakeWebClient() + adapter = _adapter(mocker, client) + + assessment = adapter.dynamic_card_messaging.assess(_intent_with_select_option_count(option_count)) + + assert assessment.representable is expected_representable + assert client.post_calls == [] + + +def test_card_send_preserves_controls_actions_defaults_and_correlation(mocker: MockerFixture) -> None: + client = FakeWebClient() + client.auth_responses.append(_successful_auth_response()) + client.post_responses.append(_slack_response({"ok": True, "channel": "dm-1", "ts": "1000.000001"})) + adapter = _adapter(mocker, client) + intent = _intent(input_type="select") + + result = adapter.dynamic_card_messaging.send_card( + ProviderUserId("user-1"), + intent, + CorrelationToken("correlation-1"), + ) + + assert isinstance(result, MessageAccepted) + assert set(client.post_calls[0]) == {"channel", "blocks"} + blocks = client.post_calls[0]["blocks"] + assert isinstance(blocks, list) + assert "text" not in client.post_calls[0] + assert "markdown_text" not in client.post_calls[0] + markdown_blocks = [block for block in blocks if block["type"] == "markdown"] + input_blocks = [block for block in blocks if block["type"] == "input"] + action_blocks = [block for block in blocks if block["type"] == "actions"] + assert markdown_blocks == [ + {"type": "markdown", "text": "Rendered **content**"}, + {"type": "markdown", "text": "After input"}, + ] + assert [block["type"] for block in blocks] == ["header", "markdown", "input", "markdown", "actions"] + assert len(input_blocks) == 1 + assert input_blocks[0]["element"]["type"] == "static_select" + assert input_blocks[0]["element"]["initial_option"]["value"] == "One" + assert len(action_blocks) == 1 + assert [element["action_id"] for element in action_blocks[0]["elements"]] == ["approve", "reject"] + for element in action_blocks[0]["elements"]: + action_value = json.loads(element["value"]) + assert action_value["correlation_token"] == "correlation-1" + assert action_value["action_id"] == element["action_id"] + + +def test_paragraph_uses_resolved_default_in_assessment_and_rendering(mocker: MockerFixture) -> None: + client = FakeWebClient() + client.auth_responses.append(_successful_auth_response()) + client.post_responses.append(_slack_response({"ok": True, "channel": "dm-1", "ts": "1000.000001"})) + adapter = _adapter(mocker, client) + intent = _intent_with_paragraph_default("Resolved default") + + assessment = adapter.dynamic_card_messaging.assess(intent) + result = adapter.dynamic_card_messaging.send_card( + ProviderUserId("user-1"), + intent, + CorrelationToken("correlation-1"), + ) + + assert assessment.representable is True + assert isinstance(result, MessageAccepted) + blocks = client.post_calls[0]["blocks"] + assert isinstance(blocks, list) + input_element = next(block["element"] for block in blocks if block["type"] == "input") + assert input_element["initial_value"] == "Resolved default" + + +def test_static_replacement_validates_reference_before_one_exact_mutation(mocker: MockerFixture) -> None: + client = FakeWebClient() + client.auth_responses.extend((_successful_auth_response(), _successful_auth_response())) + client.post_responses.append(_slack_response({"ok": True, "channel": "dm-1", "ts": "1000.000001"})) + client.update_responses.append(_slack_response({"ok": True, "channel": "dm-1", "ts": "1000.000001"})) + adapter = _adapter(mocker, client) + accepted = adapter.dynamic_card_messaging.send_card( + ProviderUserId("user-1"), + _intent(), + CorrelationToken("correlation-1"), + ) + assert isinstance(accepted, MessageAccepted) + + invalid = adapter.dynamic_card_messaging.replace_with_static( + MessageLocator("invalid."), + StaticCardIntent("Submitted"), + ) + replaced = adapter.dynamic_card_messaging.replace_with_static( + accepted.locator, + StaticCardIntent("Submitted"), + ) + + assert isinstance(invalid, ReplacementError) + assert invalid.kind is ReplacementErrorKind.INVALID_REFERENCE + assert replaced is None + assert len(client.update_calls) == 1 + update_call = client.update_calls[0] + assert set(update_call) == {"channel", "ts", "text", "blocks"} + assert update_call["channel"] == "dm-1" + assert update_call["ts"] == "1000.000001" + assert update_call["text"] == "Submitted" + assert update_call["blocks"] == [{"type": "markdown", "text": "Submitted"}] + assert "markdown_text" not in update_call + + +def test_webhook_authenticates_challenge_and_business_events_before_consumer(mocker: MockerFixture) -> None: + client = FakeWebClient() + adapter = _adapter(mocker, client) + consumer = RecordingConsumer() + handler = adapter.create_webhook_handler(consumer) + challenge_body = json.dumps({"type": "url_verification", "challenge": "challenge-value"}).encode() + + challenge = handler.handle(_signed_request(challenge_body)) + + assert challenge.status_code == 200 + assert json.loads(challenge.body) == {"challenge": "challenge-value"} + assert consumer.events == [] + + event_body = json.dumps( + { + "type": "event_callback", + "team_id": "team-1", + "event_id": "event-1", + "event_time": 1786003200, + "event": {"type": "message", "nested": [1, None, {"kept": True}]}, + "extra": "preserved", + } + ).encode() + response = handler.handle(_signed_request(event_body)) + + assert response.status_code == 200 + assert len(consumer.events) == 1 + event = consumer.events[0] + assert event.event_id == "event-1" + assert event.event_type == "message" + assert event.ingress_kind is IMEventIngressKind.WEBHOOK + assert json.loads(event.payload) == json.loads(event_body) + + unauthenticated = handler.handle(_signed_request(event_body, signature="v0=invalid")) + assert unauthenticated.status_code == 401 + assert len(consumer.events) == 1 + + +@pytest.mark.parametrize( + "authentication_headers", + [ + (), + (("X-Slack-Request-Timestamp", _REQUEST_TIMESTAMP), ("X-Slack-Signature", "v0=invalid")), + ], + ids=("missing-signature", "invalid-signature"), +) +def test_url_verification_rejects_unauthenticated_challenge( + mocker: MockerFixture, authentication_headers: tuple[tuple[str, str], ...] +) -> None: + adapter = _adapter(mocker, FakeWebClient()) + consumer = RecordingConsumer() + handler = adapter.create_webhook_handler(consumer) + body = b'{"type":"url_verification","challenge":"challenge-value"}' + request = WebhookRequest( + method="POST", + headers=(("Content-Type", "application/json"), *authentication_headers), + body=body, + received_at=_RECEIVED_AT, + ) + + response = handler.handle(request) + + assert response.status_code == 401 + assert consumer.events == [] + + +def test_webhook_not_accepted_is_not_successfully_acknowledged(mocker: MockerFixture) -> None: + adapter = _adapter(mocker, FakeWebClient()) + consumer = RecordingConsumer(EventAcceptance.NOT_ACCEPTED) + handler = adapter.create_webhook_handler(consumer) + event_body = json.dumps({"type": "block_actions", "team": {"id": "team-1"}, "actions": []}).encode() + + response = handler.handle(_signed_request(event_body)) + + assert response.status_code >= 400 + assert len(consumer.events) == 1 + + +def test_webhook_consumer_exception_returns_safe_failure( + mocker: MockerFixture, caplog: pytest.LogCaptureFixture +) -> None: + class FailingConsumer: + def accept(self, event: AuthenticatedIMEvent, *, request_ip_address: str | None = None) -> EventAcceptance: + del request_ip_address + del event + raise RuntimeError("sensitive consumer details") + + adapter = _adapter(mocker, FakeWebClient()) + handler = adapter.create_webhook_handler(FailingConsumer()) + event_body = json.dumps({"type": "block_actions", "team": {"id": "team-1"}, "actions": []}).encode() + + with caplog.at_level(logging.ERROR, logger="core.human_input_v2.im_integration.adapters.slack"): + response = handler.handle(_signed_request(event_body)) + + assert response.status_code == 503 + assert b"sensitive consumer details" not in response.body + assert "sensitive consumer details" not in caplog.text + + +def test_stream_start_delivers_complete_sdk_serialization_and_stop_is_idempotent(mocker: MockerFixture) -> None: + consumer = RecordingConsumer() + request = SocketModeRequest( + type="events_api", + envelope_id="envelope-1", + payload={ + "team_id": "team-1", + "event_id": "event-1", + "event_time": 1786003200, + "event": {"type": "message", "text": "hello"}, + }, + ) + + class FakeSocketModeClient: + instances: list[FakeSocketModeClient] = [] + + def __init__( + self, *, on_error_listeners: list[Callable[[Exception], None]] | None = None, **kwargs: object + ) -> None: + self.on_error_listeners = on_error_listeners or [] + self.socket_mode_request_listeners: list[Callable[[object, SocketModeRequest], None]] = [] + self.responses: list[SocketModeResponse] = [] + self.closed = False + self.kwargs = kwargs + self.__class__.instances.append(self) + + def connect(self) -> None: + self.socket_mode_request_listeners[0](self, request) + + def send_socket_mode_response(self, response: SocketModeResponse) -> None: + self.responses.append(response) + + def close(self) -> None: + self.closed = True + + mocker.patch("core.human_input_v2.im_integration.adapters.slack.SocketModeClient", FakeSocketModeClient) + adapter = _adapter(mocker, FakeWebClient()) + stream = adapter.create_stream_handler(consumer) + assert stream is not None + + stream.start() + + socket_client = FakeSocketModeClient.instances[0] + assert socket_client.kwargs["app_token"] == "xapp-test-app-token" + assert socket_client.closed is False + assert len(socket_client.responses) == 1 + assert len(consumer.events) == 1 + event = consumer.events[0] + assert event.event_id == "event-1" + assert event.event_type == "message" + assert event.occurred_at == datetime.fromtimestamp(1786003200, tz=UTC).replace(tzinfo=None) + assert event.ingress_kind is IMEventIngressKind.STREAM + assert json.loads(event.payload) == request.to_dict() + assert json.loads(event.payload)["payload"] == request.payload + + stream.stop() + stream.stop() + + assert socket_client.closed + with pytest.raises(IMStreamStartError, match="already been started"): + stream.start() + + +def test_stream_consumer_exception_is_one_event_failure( + mocker: MockerFixture, caplog: pytest.LogCaptureFixture +) -> None: + first_request = SocketModeRequest( + type="events_api", + envelope_id="envelope-1", + payload={"team_id": "team-1", "event": {"type": "message"}}, + ) + second_request = SocketModeRequest( + type="events_api", + envelope_id="envelope-2", + payload={"team_id": "team-1", "event": {"type": "message"}}, + ) + + class FailOnceConsumer: + def __init__(self) -> None: + self.calls = 0 + + def accept(self, event: AuthenticatedIMEvent, *, request_ip_address: str | None = None) -> EventAcceptance: + del request_ip_address + del event + self.calls += 1 + if self.calls == 1: + raise RuntimeError("sensitive consumer details") + return EventAcceptance.ACCEPTED + + class FakeSocketModeClient: + instances: list[FakeSocketModeClient] = [] + + def __init__( + self, *, on_error_listeners: list[Callable[[Exception], None]] | None = None, **kwargs: object + ) -> None: + self.on_error_listeners = on_error_listeners or [] + del kwargs + self.socket_mode_request_listeners: list[Callable[[object, SocketModeRequest], None]] = [] + self.responses: list[SocketModeResponse] = [] + self.closed = False + self.__class__.instances.append(self) + + def connect(self) -> None: + return None + + def send_socket_mode_response(self, response: SocketModeResponse) -> None: + self.responses.append(response) + + def close(self) -> None: + self.closed = True + + mocker.patch("core.human_input_v2.im_integration.adapters.slack.SocketModeClient", FakeSocketModeClient) + adapter = _adapter(mocker, FakeWebClient()) + consumer = FailOnceConsumer() + stream = adapter.create_stream_handler(consumer) + assert stream is not None + + with caplog.at_level(logging.ERROR, logger="core.human_input_v2.im_integration.adapters.slack"): + stream.start() + socket_client = FakeSocketModeClient.instances[0] + listener = socket_client.socket_mode_request_listeners[0] + listener(socket_client, first_request) + listener(socket_client, second_request) + stream.stop() + + assert "sensitive consumer details" not in caplog.text + assert consumer.calls == 2 + assert len(socket_client.responses) == 1 + assert socket_client.responses[0].envelope_id == "envelope-2" + assert socket_client.closed + + +def test_stream_protocol_response_exception_is_one_event_failure( + mocker: MockerFixture, caplog: pytest.LogCaptureFixture +) -> None: + first_request = SocketModeRequest( + type="events_api", + envelope_id="envelope-1", + payload={"team_id": "team-1", "event": {"type": "message"}}, + ) + second_request = SocketModeRequest( + type="events_api", + envelope_id="envelope-2", + payload={"team_id": "team-1", "event": {"type": "message"}}, + ) + + class FakeSocketModeClient: + instances: list[FakeSocketModeClient] = [] + + def __init__( + self, *, on_error_listeners: list[Callable[[Exception], None]] | None = None, **kwargs: object + ) -> None: + self.on_error_listeners = on_error_listeners or [] + del kwargs + self.socket_mode_request_listeners: list[Callable[[object, SocketModeRequest], None]] = [] + self.response_attempts = 0 + self.closed = False + self.__class__.instances.append(self) + + def connect(self) -> None: + return None + + def send_socket_mode_response(self, response: SocketModeResponse) -> None: + del response + self.response_attempts += 1 + if self.response_attempts == 1: + raise SlackClientError("sensitive response details") + + def close(self) -> None: + self.closed = True + + mocker.patch("core.human_input_v2.im_integration.adapters.slack.SocketModeClient", FakeSocketModeClient) + adapter = _adapter(mocker, FakeWebClient()) + stream = adapter.create_stream_handler(RecordingConsumer()) + assert stream is not None + + with caplog.at_level(logging.ERROR, logger="core.human_input_v2.im_integration.adapters.slack"): + stream.start() + socket_client = FakeSocketModeClient.instances[0] + listener = socket_client.socket_mode_request_listeners[0] + listener(socket_client, first_request) + listener(socket_client, second_request) + stream.stop() + + assert "sensitive response details" not in caplog.text + assert socket_client.response_attempts == 2 + assert socket_client.closed + + +def test_stream_wraps_connect_exception_as_safe_start_error_and_cleans_up( + mocker: MockerFixture, caplog: pytest.LogCaptureFixture +) -> None: + class FakeSocketModeClient: + instances: list[FakeSocketModeClient] = [] + + def __init__( + self, *, on_error_listeners: list[Callable[[Exception], None]] | None = None, **kwargs: object + ) -> None: + self.on_error_listeners = on_error_listeners or [] + del kwargs + self.socket_mode_request_listeners: list[Callable[[object, SocketModeRequest], None]] = [] + self.closed = False + self.__class__.instances.append(self) + + def connect(self) -> None: + raise RuntimeError("sensitive connect details") + + def close(self) -> None: + self.closed = True + + mocker.patch("core.human_input_v2.im_integration.adapters.slack.SocketModeClient", FakeSocketModeClient) + adapter = _adapter(mocker, FakeWebClient()) + stream = adapter.create_stream_handler(RecordingConsumer()) + assert stream is not None + + with caplog.at_level(logging.ERROR, logger="core.human_input_v2.im_integration.adapters.slack"): + with pytest.raises(IMStreamStartError) as raised: + stream.start() + stream.stop() + stream.stop() + + assert "sensitive connect details" not in str(raised.value) + assert "sensitive connect details" not in caplog.text + assert FakeSocketModeClient.instances[0].closed is True + + +def test_stream_runtime_error_is_observable_without_adapter_lifecycle_transition( + mocker: MockerFixture, caplog: pytest.LogCaptureFixture +) -> None: + sensitive_marker = "sensitive remote socket error" + consumer = RecordingConsumer() + request = SocketModeRequest( + type="events_api", + envelope_id="envelope-1", + payload={"team_id": "team-1", "event": {"type": "message"}}, + ) + + class FailingSocketModeClient: + instances: list[FailingSocketModeClient] = [] + + def __init__( + self, *, on_error_listeners: list[Callable[[Exception], None]] | None = None, **kwargs: object + ) -> None: + self.on_error_listeners = on_error_listeners or [] + self.kwargs = kwargs + self.socket_mode_request_listeners: list[Callable[[object, SocketModeRequest], None]] = [] + self.responses: list[SocketModeResponse] = [] + self.closed = False + self.__class__.instances.append(self) + + def connect(self) -> None: + return None + + def send_socket_mode_response(self, response: SocketModeResponse) -> None: + self.responses.append(response) + + def close(self) -> None: + self.closed = True + + mocker.patch("core.human_input_v2.im_integration.adapters.slack.SocketModeClient", FailingSocketModeClient) + stream = _adapter(mocker, FakeWebClient()).create_stream_handler(consumer) + assert stream is not None + + with caplog.at_level(logging.ERROR, logger="core.human_input_v2.im_integration.adapters.slack"): + stream.start() + socket_client = FailingSocketModeClient.instances[0] + socket_client.on_error_listeners[0](RuntimeError(sensitive_marker)) + socket_client.socket_mode_request_listeners[0](socket_client, request) + stream.stop() + + assert len(consumer.events) == 1 + assert len(socket_client.responses) == 1 + assert sensitive_marker not in caplog.text + assert "Slack Socket Mode remote error" in caplog.text + assert socket_client.closed + + +def test_stream_stop_delegates_to_sdk_without_waiting_for_inflight_consumer(mocker: MockerFixture) -> None: + consumer_started = threading.Event() + release_consumer = threading.Event() + stop_finished = threading.Event() + request = SocketModeRequest( + type="events_api", + envelope_id="envelope-1", + payload={"team_id": "team-1", "event": {"type": "message"}}, + ) + + class BlockingConsumer: + def __init__(self) -> None: + self.calls = 0 + + def accept(self, event: AuthenticatedIMEvent, *, request_ip_address: str | None = None) -> EventAcceptance: + del request_ip_address + del event + self.calls += 1 + consumer_started.set() + assert release_consumer.wait(2) + return EventAcceptance.ACCEPTED + + class FakeSocketModeClient: + instances: list[FakeSocketModeClient] = [] + + def __init__( + self, *, on_error_listeners: list[Callable[[Exception], None]] | None = None, **kwargs: object + ) -> None: + self.on_error_listeners = on_error_listeners or [] + del kwargs + self.socket_mode_request_listeners: list[Callable[[object, SocketModeRequest], None]] = [] + self.responses: list[SocketModeResponse] = [] + self.closed = False + self.__class__.instances.append(self) + + def connect(self) -> None: + return None + + def send_socket_mode_response(self, response: SocketModeResponse) -> None: + self.responses.append(response) + + def close(self) -> None: + self.closed = True + + mocker.patch("core.human_input_v2.im_integration.adapters.slack.SocketModeClient", FakeSocketModeClient) + blocking_consumer = BlockingConsumer() + stream = _adapter(mocker, FakeWebClient()).create_stream_handler(blocking_consumer) + assert stream is not None + stream.start() + socket_client = FakeSocketModeClient.instances[0] + listener = socket_client.socket_mode_request_listeners[0] + callback_thread = threading.Thread(target=listener, args=(socket_client, request)) + callback_thread.start() + assert consumer_started.wait(2) + + def stop_stream() -> None: + stream.stop() + stop_finished.set() + + stop_thread = threading.Thread(target=stop_stream) + stop_thread.start() + + assert stop_finished.wait(2) + assert socket_client.closed + + release_consumer.set() + callback_thread.join(2) + stop_thread.join(2) + + assert not callback_thread.is_alive() + assert not stop_thread.is_alive() + assert stop_finished.is_set() + assert socket_client.closed + assert len(socket_client.responses) == 1 + + +def test_stream_wraps_close_exception_as_safe_stop_error( + mocker: MockerFixture, caplog: pytest.LogCaptureFixture +) -> None: + + class FakeSocketModeClient: + def __init__( + self, *, on_error_listeners: list[Callable[[Exception], None]] | None = None, **kwargs: object + ) -> None: + self.on_error_listeners = on_error_listeners or [] + del kwargs + self.socket_mode_request_listeners: list[Callable[[object, SocketModeRequest], None]] = [] + + def connect(self) -> None: + return None + + def close(self) -> None: + raise RuntimeError("sensitive close details") + + mocker.patch("core.human_input_v2.im_integration.adapters.slack.SocketModeClient", FakeSocketModeClient) + adapter = _adapter(mocker, FakeWebClient()) + stream = adapter.create_stream_handler(RecordingConsumer()) + assert stream is not None + + with caplog.at_level(logging.ERROR, logger="core.human_input_v2.im_integration.adapters.slack"): + stream.start() + with pytest.raises(IMStreamStopError) as raised: + stream.stop() + + assert "sensitive close details" not in str(raised.value) + assert "sensitive close details" not in caplog.text diff --git a/api/tests/unit_tests/core/human_input_v2/im_provider/test_slack_card_events.py b/api/tests/unit_tests/core/human_input_v2/im_provider/test_slack_card_events.py new file mode 100644 index 00000000000..fc8b6120a72 --- /dev/null +++ b/api/tests/unit_tests/core/human_input_v2/im_provider/test_slack_card_events.py @@ -0,0 +1,945 @@ +from __future__ import annotations + +import json +import os +import subprocess +import sys +from collections.abc import Callable, Mapping +from concurrent.futures import ThreadPoolExecutor +from datetime import datetime +from pathlib import Path + +import pytest +from pydantic import JsonValue, TypeAdapter + +from core.human_input import ButtonStyle +from core.human_input_v2.entities import IMProvider +from core.human_input_v2.im_integration.adapters import ( + AuthenticatedIMEvent, + CorrelationToken, + IMCardEvent, + IMCardEventDecodingError, + IMEventIngressKind, + ProviderUserId, + UnrecognizedIMEvent, +) +from core.human_input_v2.im_integration.adapters import slack as slack_module +from core.human_input_v2.im_integration.adapters.slack import SlackIMProviderAdapter, _SlackCardCodec +from core.human_input_v2.resolved_form import MarkdownFragment, ParagraphInput, ResolvedForm, SelectInput, UserAction + +_FIXTURE_DIRECTORY = Path(__file__).with_name("fixtures") +_WEBHOOK_FIXTURE = _FIXTURE_DIRECTORY / "slack_block_actions_webhook.json" +_SOCKET_MODE_FIXTURE = _FIXTURE_DIRECTORY / "slack_block_actions_socket_mode.json" +_LIVE_CAPTURE_SOCKET_MODE_FIXTURE = _FIXTURE_DIRECTORY / "slack_block_actions_live_capture_socket_mode.json" +_STATIC_SELECT_INTERACTION_FIXTURE = _FIXTURE_DIRECTORY / "slack_block_actions_static_select_socket_mode.json" +_RADIO_BUTTONS_INTERACTION_FIXTURE = _FIXTURE_DIRECTORY / "slack_block_actions_radio_buttons_webhook.json" +_RECEIVED_AT = datetime(2026, 8, 11, 12, 0, 0) + + +def _fixture(path: Path) -> dict[str, object]: + decoded = json.loads(path.read_text(encoding="utf-8")) + assert isinstance(decoded, dict) + return decoded + + +def _event( + callback: dict[str, object], + *, + provider: IMProvider = IMProvider.SLACK, + event_type: str = "block_actions", + ingress_kind: IMEventIngressKind = IMEventIngressKind.WEBHOOK, +) -> AuthenticatedIMEvent: + return AuthenticatedIMEvent( + provider=provider, + provider_tenant_id="T012SANITIZED", + event_id=None, + event_type=event_type, + occurred_at=None, + received_at=_RECEIVED_AT, + ingress_kind=ingress_kind, + payload=json.dumps(callback, ensure_ascii=False, sort_keys=True), + ) + + +def _form() -> ResolvedForm: + return ResolvedForm( + title="Approval", + parts=( + MarkdownFragment(text="Review the response."), + ParagraphInput(output_variable_name="说明📝", default_value="初始值"), + SelectInput(output_variable_name="选择🌐", options=("选项 α", "选项 β"), default_value="选项 β"), + ), + actions=(UserAction(id="批准✅", title="Approve", button_style=ButtonStyle.PRIMARY),), + legacy_form_content="unused", + ) + + +def _message_blocks(callback: dict[str, object]) -> list[dict[str, object]]: + message = callback["message"] + assert isinstance(message, dict) + blocks = message["blocks"] + assert isinstance(blocks, list) + assert all(isinstance(block, dict) for block in blocks) + return blocks + + +def _input_blocks(callback: dict[str, object]) -> list[dict[str, object]]: + return [block for block in _message_blocks(callback) if block.get("type") == "input"] + + +def _encoded_blocks(encoded: Mapping[str, JsonValue]) -> list[dict[str, JsonValue]]: + return TypeAdapter(list[dict[str, JsonValue]]).validate_python(encoded["blocks"], strict=True) + + +def _json_object(value: JsonValue) -> dict[str, JsonValue]: + assert isinstance(value, dict) + return value + + +def _state_values(callback: dict[str, object]) -> dict[str, object]: + state = callback["state"] + assert isinstance(state, dict) + values = state["values"] + assert isinstance(values, dict) + return values + + +def _event_with_raw_json_extra(raw_json_value: str) -> AuthenticatedIMEvent: + callback = _fixture(_WEBHOOK_FIXTURE) + serialized_callback = json.dumps(callback, ensure_ascii=False, sort_keys=True) + assert serialized_callback.endswith("}") + return AuthenticatedIMEvent( + provider=IMProvider.SLACK, + provider_tenant_id="T012SANITIZED", + event_id=None, + event_type="block_actions", + occurred_at=None, + received_at=_RECEIVED_AT, + ingress_kind=IMEventIngressKind.WEBHOOK, + payload=f'{serialized_callback[:-1]},"ignored_extra":{raw_json_value}}}', + ) + + +def test_sender_renders_dify_namespaced_form_structure() -> None: + codec = _SlackCardCodec() + encoded = codec.encode(_form(), CorrelationToken("关联令牌-🌍")) + repeated = codec.encode(_form(), CorrelationToken("关联令牌-🌍")) + + assert set(encoded) == {"blocks"} + blocks = _encoded_blocks(encoded) + input_blocks = [block for block in blocks if block["type"] == "input"] + assert len(input_blocks) == 2 + assert [block["block_id"] for block in input_blocks] == ["__dify.input.0", "__dify.input.1"] + assert [_json_object(block["element"])["action_id"] for block in input_blocks] == ["说明📝", "选择🌐"] + assert input_blocks[0]["element"] == { + "action_id": "说明📝", + "type": "plain_text_input", + "multiline": True, + "initial_value": "初始值", + } + assert input_blocks[1]["element"] == { + "action_id": "选择🌐", + "type": "static_select", + "placeholder": {"type": "plain_text", "text": "Select an option"}, + "options": [ + {"text": {"type": "plain_text", "text": "选项 α"}, "value": "选项 α"}, + {"text": {"type": "plain_text", "text": "选项 β"}, "value": "选项 β"}, + ], + "initial_option": {"text": {"type": "plain_text", "text": "选项 β"}, "value": "选项 β"}, + } + + actions_block = next(block for block in blocks if block["type"] == "actions") + assert actions_block["block_id"] == "__dify.actions" + elements = actions_block["elements"] + assert isinstance(elements, list) + action = _json_object(elements[0]) + assert action["action_id"] == "批准✅" + serialized_metadata = action["value"] + assert isinstance(serialized_metadata, str) + assert json.loads(serialized_metadata) == { + "version": 1, + "action_id": "批准✅", + "correlation_token": "关联令牌-🌍", + } + assert repeated == encoded + + +def test_sender_static_select_has_provider_owned_placeholder() -> None: + encoded = _SlackCardCodec().encode(_form(), CorrelationToken("token")) + blocks = _encoded_blocks(encoded) + selection_element = next( + _json_object(block["element"]) + for block in blocks + if block["type"] == "input" and _json_object(block["element"])["action_id"] == "选择🌐" + ) + + placeholder = selection_element.get("placeholder") + assert isinstance(placeholder, dict) + assert placeholder == {"type": "plain_text", "text": "Select an option"} + placeholder_text = placeholder["text"] + assert isinstance(placeholder_text, str) + assert len(placeholder_text) <= 150 + + +def test_encode_relies_on_the_callers_representability_precondition() -> None: + codec = _SlackCardCodec() + empty_form = ResolvedForm( + title="", + parts=(), + actions=(), + legacy_form_content="unused", + ) + + assert codec.assess(empty_form).representable is False + assert codec.encode(empty_form, CorrelationToken("token")) == {"blocks": []} + + +def test_webhook_and_socket_mode_callbacks_converge_with_exact_unicode_round_trip() -> None: + decoder = SlackIMProviderAdapter.card_event_decoder() + + webhook_result = decoder.decode(_event(_fixture(_WEBHOOK_FIXTURE))) + socket_result = decoder.decode(_event(_fixture(_SOCKET_MODE_FIXTURE), ingress_kind=IMEventIngressKind.STREAM)) + + expected = IMCardEvent( + provider_user_id=ProviderUserId("U012SANITIZED"), + action_id="批准✅", + inputs={"说明📝": "你好,世界 🌍", "选择🌐": "选项 β"}, + correlation_token=CorrelationToken("关联令牌-🌍"), + ) + assert webhook_result == expected + assert socket_result == expected + + +@pytest.mark.timeout(60) +def test_decoder_remains_usable_after_garbage_collection() -> None: + # A fresh process prevents earlier callbacks from resolving the lazy model annotations before GC. + script = """ +import gc +import sys +from datetime import datetime +from pathlib import Path + +from core.human_input_v2.entities import IMProvider +from core.human_input_v2.im_integration.adapters import ( + AuthenticatedIMEvent, CorrelationToken, IMCardEvent, IMEventIngressKind, ProviderUserId, +) +from core.human_input_v2.im_integration.adapters.slack import SlackIMProviderAdapter + +gc.collect() +event = AuthenticatedIMEvent( + provider=IMProvider.SLACK, + provider_tenant_id="T012SANITIZED", + event_id=None, + event_type="block_actions", + occurred_at=None, + received_at=datetime(2026, 8, 11, 12), + ingress_kind=IMEventIngressKind.WEBHOOK, + payload=Path(sys.argv[1]).read_text(encoding="utf-8"), +) +assert SlackIMProviderAdapter.card_event_decoder().decode(event) == IMCardEvent( + provider_user_id=ProviderUserId("U012SANITIZED"), + action_id="批准✅", + inputs={"说明📝": "你好,世界 🌍", "选择🌐": "选项 β"}, + correlation_token=CorrelationToken("关联令牌-🌍"), +) +""" + api_root = Path(__file__).resolve().parents[5] + completed = subprocess.run( + [sys.executable, "-c", script, str(_WEBHOOK_FIXTURE)], + cwd=api_root, + env={**os.environ, "PYTHONPATH": str(api_root)}, + capture_output=True, + text=True, + timeout=45, + check=False, + ) + + assert completed.returncode == 0, completed.stdout + completed.stderr + + +@pytest.mark.parametrize( + ("fixture_path", "declared_ingress_kind"), + [ + pytest.param(_SOCKET_MODE_FIXTURE, IMEventIngressKind.WEBHOOK, id="socket-envelope-as-webhook"), + pytest.param(_WEBHOOK_FIXTURE, IMEventIngressKind.STREAM, id="webhook-root-as-stream"), + ], +) +def test_decoder_rejects_declared_ingress_payload_mismatch( + fixture_path: Path, + declared_ingress_kind: IMEventIngressKind, +) -> None: + event = _event(_fixture(fixture_path), ingress_kind=declared_ingress_kind) + + with pytest.raises(IMCardEventDecodingError): + SlackIMProviderAdapter.card_event_decoder().decode(event) + + +def test_real_socket_mode_callback_capture_decodes_with_exact_unicode_round_trip() -> None: + assert _LIVE_CAPTURE_SOCKET_MODE_FIXTURE.exists() + + decoded = _SlackCardCodec().decode( + _event(_fixture(_LIVE_CAPTURE_SOCKET_MODE_FIXTURE), ingress_kind=IMEventIngressKind.STREAM) + ) + + assert decoded == IMCardEvent( + provider_user_id=ProviderUserId("U012SANITIZED"), + action_id="批准✅", + inputs={"说明📝": "你好,世界 🌍", "选择🌐": "选项 β"}, + correlation_token=CorrelationToken("关联令牌-🌍"), + ) + + +def test_real_socket_mode_callback_capture_sanitizes_provider_generated_block_ids() -> None: + callback = _fixture(_LIVE_CAPTURE_SOCKET_MODE_FIXTURE) + payload = callback["payload"] + assert isinstance(payload, dict) + message = payload["message"] + assert isinstance(message, dict) + blocks = message["blocks"] + assert isinstance(blocks, list) + + provider_generated_block_ids = [ + block["block_id"] + for block in blocks + if isinstance(block, dict) + and isinstance(block.get("block_id"), str) + and not block["block_id"].startswith("__dify.") + ] + assert len(provider_generated_block_ids) == 2 + assert all(block_id == "slack-generated-block-id" for block_id in provider_generated_block_ids) + + +@pytest.mark.parametrize( + ("fixture_path", "ingress_kind"), + [ + pytest.param(_STATIC_SELECT_INTERACTION_FIXTURE, IMEventIngressKind.STREAM, id="static-select"), + pytest.param(_RADIO_BUTTONS_INTERACTION_FIXTURE, IMEventIngressKind.WEBHOOK, id="radio-buttons"), + ], +) +def test_selection_change_block_actions_are_unrecognized( + fixture_path: Path, + ingress_kind: IMEventIngressKind, +) -> None: + result = SlackIMProviderAdapter.card_event_decoder().decode( + _event(_fixture(fixture_path), ingress_kind=ingress_kind) + ) + + assert isinstance(result, UnrecognizedIMEvent) + + +def test_non_dify_button_block_actions_is_unrecognized_without_full_dify_schema() -> None: + callback: dict[str, object] = { + "type": "block_actions", + "actions": [ + { + "type": "button", + "block_id": "foreign.actions", + "action_id": "foreign_action", + "value": "foreign-value", + } + ], + } + + result = SlackIMProviderAdapter.card_event_decoder().decode(_event(callback)) + + assert isinstance(result, UnrecognizedIMEvent) + + +@pytest.mark.parametrize( + "callback", + [ + pytest.param({"type": "block_actions"}, id="missing-actions"), + pytest.param({"type": "block_actions", "actions": []}, id="empty-actions"), + pytest.param( + {"type": "block_actions", "actions": [None, 42, "foreign-action", {}]}, + id="non-object-actions-without-dify-marker", + ), + pytest.param( + {"type": "block_actions", "actions": [{"block_id": "__dify.actions.legacy"}]}, + id="near-dify-action-block-id", + ), + ], +) +def test_block_actions_without_any_dify_submission_marker_are_unrecognized( + callback: dict[str, object], +) -> None: + result = SlackIMProviderAdapter.card_event_decoder().decode(_event(callback)) + + assert isinstance(result, UnrecognizedIMEvent) + + +@pytest.mark.parametrize( + ("action_type", "action_value"), + [ + pytest.param("static_select", None, id="wrong-action-type-without-value"), + pytest.param("button", {"unexpected": "object"}, id="wrong-action-value-type"), + ], +) +def test_dify_submission_marker_enables_strict_action_validation( + action_type: str, + action_value: object, +) -> None: + callback = _fixture(_WEBHOOK_FIXTURE) + actions = callback["actions"] + assert isinstance(actions, list) + action = actions[0] + assert isinstance(action, dict) + action["type"] = action_type + if action_value is None: + action.pop("value") + else: + action["value"] = action_value + + with pytest.raises(IMCardEventDecodingError): + SlackIMProviderAdapter.card_event_decoder().decode(_event(callback)) + + +def test_zero_input_callback_may_omit_state() -> None: + callback = _fixture(_WEBHOOK_FIXTURE) + message_blocks = _message_blocks(callback) + message_blocks[:] = [block for block in message_blocks if block.get("type") != "input"] + callback.pop("state") + + decoded = SlackIMProviderAdapter.card_event_decoder().decode(_event(callback)) + + assert decoded == IMCardEvent( + provider_user_id=ProviderUserId("U012SANITIZED"), + action_id="批准✅", + inputs={}, + correlation_token=CorrelationToken("关联令牌-🌍"), + ) + + +def test_zero_input_callback_may_have_empty_state() -> None: + callback = _fixture(_WEBHOOK_FIXTURE) + message_blocks = _message_blocks(callback) + message_blocks[:] = [block for block in message_blocks if block.get("type") != "input"] + callback["state"] = {"values": dict[str, object]()} + + decoded = SlackIMProviderAdapter.card_event_decoder().decode(_event(callback)) + + assert isinstance(decoded, IMCardEvent) + assert decoded.inputs == {} + + +def test_decoder_rejects_state_missing_one_expected_input() -> None: + callback = _fixture(_WEBHOOK_FIXTURE) + _state_values(callback).pop("__dify.input.1") + + with pytest.raises(IMCardEventDecodingError): + SlackIMProviderAdapter.card_event_decoder().decode(_event(callback)) + + +def test_decoder_rejects_non_input_message_block_claiming_reserved_input_id() -> None: + callback = _fixture(_WEBHOOK_FIXTURE) + input_block = _input_blocks(callback)[1] + input_block["type"] = "section" + _state_values(callback).pop("__dify.input.1") + + with pytest.raises(IMCardEventDecodingError): + SlackIMProviderAdapter.card_event_decoder().decode(_event(callback)) + + +def test_decoder_rejects_non_actions_message_block_claiming_reserved_actions_id() -> None: + callback = _fixture(_WEBHOOK_FIXTURE) + actions_block = next(block for block in _message_blocks(callback) if block.get("type") == "actions") + actions_block["type"] = "section" + + with pytest.raises(IMCardEventDecodingError): + SlackIMProviderAdapter.card_event_decoder().decode(_event(callback)) + + +def test_decoder_rejects_message_without_sender_owned_actions_block() -> None: + callback = _fixture(_WEBHOOK_FIXTURE) + message_blocks = _message_blocks(callback) + message_blocks[:] = [block for block in message_blocks if block.get("type") != "actions"] + + with pytest.raises(IMCardEventDecodingError): + SlackIMProviderAdapter.card_event_decoder().decode(_event(callback)) + + +def test_decoder_rejects_invoked_action_missing_from_sender_owned_actions_block() -> None: + callback = _fixture(_WEBHOOK_FIXTURE) + actions_block = next(block for block in _message_blocks(callback) if block.get("type") == "actions") + elements = actions_block["elements"] + assert isinstance(elements, list) + button = elements[0] + assert isinstance(button, dict) + button["action_id"] = "other-action" + metadata = json.loads(button["value"]) + assert isinstance(metadata, dict) + metadata["action_id"] = "other-action" + button["value"] = json.dumps(metadata) + + with pytest.raises(IMCardEventDecodingError): + SlackIMProviderAdapter.card_event_decoder().decode(_event(callback)) + + +@pytest.mark.parametrize( + "foreign_block_id", + [ + pytest.param("__dify.input.legacy", id="input-near-id"), + pytest.param("__dify.input.01", id="input-noncanonical-ordinal"), + pytest.param("__dify.actions.legacy", id="actions-near-id"), + ], +) +def test_decoder_allows_near_reserved_id_on_foreign_message_block(foreign_block_id: str) -> None: + callback = _fixture(_WEBHOOK_FIXTURE) + _message_blocks(callback).insert(0, {"type": "section", "block_id": foreign_block_id}) + + decoded = SlackIMProviderAdapter.card_event_decoder().decode(_event(callback)) + + assert isinstance(decoded, IMCardEvent) + assert decoded.inputs == {"说明📝": "你好,世界 🌍", "选择🌐": "选项 β"} + + +def test_decoder_rejects_extra_dify_input_state() -> None: + callback = _fixture(_WEBHOOK_FIXTURE) + _state_values(callback)["__dify.input.2"] = {"unexpected": {"type": "plain_text_input", "value": "unexpected"}} + + with pytest.raises(IMCardEventDecodingError): + SlackIMProviderAdapter.card_event_decoder().decode(_event(callback)) + + +def test_decoder_rejects_state_action_id_mismatched_with_message_schema() -> None: + callback = _fixture(_WEBHOOK_FIXTURE) + text_state = _state_values(callback)["__dify.input.0"] + assert isinstance(text_state, dict) + text_state["renamed"] = text_state.pop("说明📝") + + with pytest.raises(IMCardEventDecodingError): + SlackIMProviderAdapter.card_event_decoder().decode(_event(callback)) + + +def test_decoder_rejects_unexpected_non_dify_state() -> None: + callback = _fixture(_WEBHOOK_FIXTURE) + _state_values(callback)["unrelated.block"] = { + "unrelated": {"type": "checkboxes", "selected_options": [{"value": "external"}]} + } + + with pytest.raises(IMCardEventDecodingError): + SlackIMProviderAdapter.card_event_decoder().decode(_event(callback)) + + +def test_decoder_rejects_malformed_dify_input_block_id_in_message_schema() -> None: + callback = _fixture(_WEBHOOK_FIXTURE) + _input_blocks(callback)[0]["block_id"] = "__dify.input.legacy" + + with pytest.raises(IMCardEventDecodingError): + SlackIMProviderAdapter.card_event_decoder().decode(_event(callback)) + + +def test_decoder_rejects_input_block_id_not_matching_form_ordinal() -> None: + callback = _fixture(_WEBHOOK_FIXTURE) + _input_blocks(callback)[0]["block_id"] = "__dify.input.2" + state_values = _state_values(callback) + state_values["__dify.input.2"] = state_values.pop("__dify.input.0") + + with pytest.raises(IMCardEventDecodingError): + SlackIMProviderAdapter.card_event_decoder().decode(_event(callback)) + + +def test_decoder_rejects_input_block_with_non_dify_block_id() -> None: + callback = _fixture(_WEBHOOK_FIXTURE) + input_block = _input_blocks(callback)[0] + block_id = input_block["block_id"] + assert isinstance(block_id, str) + input_block["block_id"] = "external.block" + _state_values(callback).pop(block_id) + + with pytest.raises(IMCardEventDecodingError): + SlackIMProviderAdapter.card_event_decoder().decode(_event(callback)) + + +def test_decoder_rejects_input_block_without_block_id() -> None: + callback = _fixture(_WEBHOOK_FIXTURE) + input_block = _input_blocks(callback)[0] + block_id = input_block.pop("block_id") + assert isinstance(block_id, str) + _state_values(callback).pop(block_id) + + with pytest.raises(IMCardEventDecodingError): + SlackIMProviderAdapter.card_event_decoder().decode(_event(callback)) + + +def test_decoder_rejects_empty_expected_input_action_id() -> None: + callback = _fixture(_WEBHOOK_FIXTURE) + element = _input_blocks(callback)[0]["element"] + assert isinstance(element, dict) + element["action_id"] = "" + + with pytest.raises(IMCardEventDecodingError): + SlackIMProviderAdapter.card_event_decoder().decode(_event(callback)) + + +def test_decoder_rejects_duplicate_expected_input_action_ids() -> None: + callback = _fixture(_WEBHOOK_FIXTURE) + element = _input_blocks(callback)[1]["element"] + assert isinstance(element, dict) + element["action_id"] = "说明📝" + + with pytest.raises(IMCardEventDecodingError): + SlackIMProviderAdapter.card_event_decoder().decode(_event(callback)) + + +def test_decoder_rejects_state_type_mismatched_with_message_schema() -> None: + callback = _fixture(_WEBHOOK_FIXTURE) + element = _input_blocks(callback)[0]["element"] + assert isinstance(element, dict) + element["type"] = "static_select" + + with pytest.raises(IMCardEventDecodingError): + SlackIMProviderAdapter.card_event_decoder().decode(_event(callback)) + + +def test_decoder_normalizes_explicit_null_text_input() -> None: + callback = _fixture(_WEBHOOK_FIXTURE) + text_state = _state_values(callback)["__dify.input.0"] + assert isinstance(text_state, dict) + text_element_state = text_state["说明📝"] + assert isinstance(text_element_state, dict) + text_element_state["value"] = None + + decoded = SlackIMProviderAdapter.card_event_decoder().decode(_event(callback)) + + assert isinstance(decoded, IMCardEvent) + assert decoded.inputs == {"说明📝": None, "选择🌐": "选项 β"} + + +def test_decoder_normalizes_static_select_value() -> None: + decoded = SlackIMProviderAdapter.card_event_decoder().decode(_event(_fixture(_WEBHOOK_FIXTURE))) + + assert isinstance(decoded, IMCardEvent) + assert decoded.inputs["选择🌐"] == "选项 β" + + +def test_decoder_normalizes_explicit_null_static_select_selection() -> None: + callback = _fixture(_WEBHOOK_FIXTURE) + select_state = _state_values(callback)["__dify.input.1"] + assert isinstance(select_state, dict) + select_element_state = select_state["选择🌐"] + assert isinstance(select_element_state, dict) + select_element_state["selected_option"] = None + + decoded = SlackIMProviderAdapter.card_event_decoder().decode(_event(callback)) + + assert isinstance(decoded, IMCardEvent) + assert decoded.inputs == {"说明📝": "你好,世界 🌍", "选择🌐": None} + + +def test_decoder_rejects_radio_buttons_wire_schema() -> None: + callback = _fixture(_WEBHOOK_FIXTURE) + select_element = _input_blocks(callback)[1]["element"] + assert isinstance(select_element, dict) + select_element["type"] = "radio_buttons" + select_state = _state_values(callback)["__dify.input.1"] + assert isinstance(select_state, dict) + select_element_state = select_state["选择🌐"] + assert isinstance(select_element_state, dict) + select_element_state["type"] = "radio_buttons" + + with pytest.raises(IMCardEventDecodingError): + SlackIMProviderAdapter.card_event_decoder().decode(_event(callback)) + + +def test_decoder_is_class_level_stateless_and_concurrent() -> None: + decoder = SlackIMProviderAdapter.card_event_decoder() + event = _event(_fixture(_WEBHOOK_FIXTURE)) + + with ThreadPoolExecutor(max_workers=8) as executor: + results = tuple(executor.map(decoder.decode, (event,) * 32)) + + assert all(result == results[0] for result in results) + + +def test_slack_card_implementation_has_one_module_owner() -> None: + source_path = Path(slack_module.__file__) + + assert not source_path.with_name("slack_card_events.py").exists() + assert "slack_card_events" not in source_path.read_text(encoding="utf-8") + + +@pytest.mark.parametrize( + ("provider", "event_type"), + [ + (IMProvider.SLACK, "message"), + (IMProvider.MS_TEAMS, "block_actions"), + ], +) +def test_authenticated_non_card_events_are_unrecognized(provider: IMProvider, event_type: str) -> None: + event = AuthenticatedIMEvent( + provider=provider, + provider_tenant_id="tenant", + event_id=None, + event_type=event_type, + occurred_at=None, + received_at=_RECEIVED_AT, + ingress_kind=IMEventIngressKind.WEBHOOK, + payload="not parsed for non-card events", + ) + + result = SlackIMProviderAdapter.card_event_decoder().decode(event) + + assert isinstance(result, UnrecognizedIMEvent) + + +def test_recognized_card_with_invalid_json_raises_safe_error() -> None: + sensitive_marker = "sensitive-callback-marker" + event = AuthenticatedIMEvent( + provider=IMProvider.SLACK, + provider_tenant_id="tenant", + event_id=None, + event_type="block_actions", + occurred_at=None, + received_at=_RECEIVED_AT, + ingress_kind=IMEventIngressKind.WEBHOOK, + payload=f'{{"submitted":"{sensitive_marker}"', + ) + + with pytest.raises(IMCardEventDecodingError) as captured: + SlackIMProviderAdapter.card_event_decoder().decode(event) + + error = captured.value + assert sensitive_marker not in str(error) + assert sensitive_marker not in repr(error) + assert vars(error) == {} + assert error.__cause__ is None + assert error.__context__ is None + + +@pytest.mark.parametrize( + ("fixture_path", "ingress_kind", "non_finite_value"), + [ + pytest.param(_WEBHOOK_FIXTURE, IMEventIngressKind.WEBHOOK, float("nan"), id="webhook-nan"), + pytest.param(_WEBHOOK_FIXTURE, IMEventIngressKind.WEBHOOK, float("inf"), id="webhook-infinity"), + pytest.param(_WEBHOOK_FIXTURE, IMEventIngressKind.WEBHOOK, float("-inf"), id="webhook-negative-infinity"), + pytest.param(_SOCKET_MODE_FIXTURE, IMEventIngressKind.STREAM, float("nan"), id="socket-nan"), + pytest.param(_SOCKET_MODE_FIXTURE, IMEventIngressKind.STREAM, float("inf"), id="socket-infinity"), + pytest.param(_SOCKET_MODE_FIXTURE, IMEventIngressKind.STREAM, float("-inf"), id="socket-negative-infinity"), + ], +) +def test_recognized_card_rejects_non_finite_json_constants( + fixture_path: Path, + ingress_kind: IMEventIngressKind, + non_finite_value: float, +) -> None: + sensitive_marker = "non-finite-sensitive-marker" + callback = _fixture(fixture_path) + callback["ignored_extra"] = {"number": non_finite_value, "marker": sensitive_marker} + + with pytest.raises(IMCardEventDecodingError) as captured: + SlackIMProviderAdapter.card_event_decoder().decode(_event(callback, ingress_kind=ingress_kind)) + + assert sensitive_marker not in str(captured.value) + assert sensitive_marker not in repr(captured.value) + + +@pytest.mark.parametrize( + "raw_json_value", + [ + pytest.param("1" * (sys.get_int_max_str_digits() + 1), id="oversized-integer"), + pytest.param( + "[" * (sys.getrecursionlimit() * 20) + "null" + "]" * (sys.getrecursionlimit() * 20), + id="excessive-nesting", + ), + ], +) +def test_recognized_card_maps_json_parser_implementation_limits_to_safe_error(raw_json_value: str) -> None: + with pytest.raises(IMCardEventDecodingError) as captured: + SlackIMProviderAdapter.card_event_decoder().decode(_event_with_raw_json_extra(raw_json_value)) + + error = captured.value + assert vars(error) == {} + assert error.__cause__ is None + assert error.__context__ is None + + +@pytest.mark.parametrize("raw_json_value", ["1e400", "-1e400"]) +def test_recognized_card_rejects_exponents_that_decode_to_non_finite_floats(raw_json_value: str) -> None: + with pytest.raises(IMCardEventDecodingError): + SlackIMProviderAdapter.card_event_decoder().decode(_event_with_raw_json_extra(raw_json_value)) + + +def test_recognized_card_accepts_json_safe_finite_exponent() -> None: + decoded = SlackIMProviderAdapter.card_event_decoder().decode(_event_with_raw_json_extra("1e300")) + + assert isinstance(decoded, IMCardEvent) + assert decoded.inputs == {"说明📝": "你好,世界 🌍", "选择🌐": "选项 β"} + + +def _remove_actor(callback: dict[str, object]) -> None: + callback.pop("user") + + +def _set_wrong_actor_type(callback: dict[str, object]) -> None: + callback["user"] = {"id": 42} + + +def _append_non_dify_action(callback: dict[str, object]) -> None: + actions = callback["actions"] + assert isinstance(actions, list) + actions.append( + { + "type": "button", + "block_id": "foreign.actions", + "action_id": "foreign_action", + "value": "foreign-value", + } + ) + + +def _remove_state(callback: dict[str, object]) -> None: + callback.pop("state") + + +def _set_wrong_text_value_type(callback: dict[str, object]) -> None: + state = callback["state"] + assert isinstance(state, dict) + values = state["values"] + assert isinstance(values, dict) + values["__dify.input.0"] = {"说明📝": {"type": "plain_text_input", "value": 42}} + + +def _remove_text_value(callback: dict[str, object]) -> None: + values = _state_values(callback) + values["__dify.input.0"] = {"说明📝": {"type": "plain_text_input"}} + + +def _remove_static_select_selection(callback: dict[str, object]) -> None: + state = callback["state"] + assert isinstance(state, dict) + values = state["values"] + assert isinstance(values, dict) + values["__dify.input.1"] = {"选择🌐": {"type": "static_select"}} + + +def _make_input_state_ambiguous(callback: dict[str, object]) -> None: + state = callback["state"] + assert isinstance(state, dict) + values = state["values"] + assert isinstance(values, dict) + values["__dify.input.0"] = { + "说明📝": {"type": "plain_text_input", "value": "first"}, + "duplicate": {"type": "plain_text_input", "value": "second"}, + } + + +def _set_unsupported_state_type(callback: dict[str, object]) -> None: + state = callback["state"] + assert isinstance(state, dict) + values = state["values"] + assert isinstance(values, dict) + values["__dify.input.0"] = {"说明📝": {"type": "checkboxes", "selected_options": list[object]()}} + + +def _set_wrong_static_select_option_value_type(callback: dict[str, object]) -> None: + values = _state_values(callback) + values["__dify.input.1"] = { + "选择🌐": { + "type": "static_select", + "selected_option": {"value": 42}, + } + } + + +@pytest.mark.parametrize( + "mutate", + [ + pytest.param(_remove_actor, id="missing-actor"), + pytest.param(_set_wrong_actor_type, id="wrong-actor-type"), + pytest.param(_append_non_dify_action, id="multiple-actions-including-dify"), + pytest.param(_remove_state, id="missing-state"), + pytest.param(_remove_text_value, id="missing-text-value"), + pytest.param(_set_wrong_text_value_type, id="wrong-text-value-type"), + pytest.param(_remove_static_select_selection, id="missing-static-select-selection"), + pytest.param(_make_input_state_ambiguous, id="ambiguous-input-state"), + pytest.param(_set_unsupported_state_type, id="unsupported-input-state"), + pytest.param(_set_wrong_static_select_option_value_type, id="wrong-static-select-option-value-type"), + ], +) +def test_decoder_rejects_missing_wrong_or_ambiguous_callback_facts( + mutate: Callable[[dict[str, object]], None], +) -> None: + callback = _fixture(_WEBHOOK_FIXTURE) + mutate(callback) + + with pytest.raises(IMCardEventDecodingError): + SlackIMProviderAdapter.card_event_decoder().decode(_event(callback)) + + +@pytest.mark.parametrize( + "metadata", + [ + pytest.param("not-json", id="invalid-json"), + pytest.param(json.dumps({"version": 1, "action_id": "批准✅"}), id="missing-token"), + pytest.param( + json.dumps({"version": 1, "action_id": "批准✅", "correlation_token": 42}), + id="wrong-token-type", + ), + pytest.param( + json.dumps({"version": 2, "action_id": "批准✅", "correlation_token": "token"}), + id="unsupported-version", + ), + pytest.param( + json.dumps( + { + "version": 1, + "action_id": "批准✅", + "correlation_token": "token", + "unexpected": "value", + } + ), + id="unexpected-metadata", + ), + ], +) +def test_decoder_rejects_invalid_button_metadata(metadata: str) -> None: + callback = _fixture(_WEBHOOK_FIXTURE) + actions = callback["actions"] + assert isinstance(actions, list) + actions[0]["value"] = metadata + + with pytest.raises(IMCardEventDecodingError): + SlackIMProviderAdapter.card_event_decoder().decode(_event(callback)) + + +def test_decoder_requires_outer_and_embedded_action_identity_to_agree() -> None: + callback = _fixture(_WEBHOOK_FIXTURE) + actions = callback["actions"] + assert isinstance(actions, list) + actions[0]["action_id"] = "拒绝" + + with pytest.raises(IMCardEventDecodingError): + SlackIMProviderAdapter.card_event_decoder().decode(_event(callback)) + + +def test_decoder_rejects_malformed_socket_mode_envelope() -> None: + callback = _fixture(_SOCKET_MODE_FIXTURE) + callback["payload"] = "wrong-type" + + with pytest.raises(IMCardEventDecodingError): + SlackIMProviderAdapter.card_event_decoder().decode(_event(callback, ingress_kind=IMEventIngressKind.STREAM)) + + +def test_decoder_rejects_socket_mode_envelope_without_envelope_id() -> None: + callback = _fixture(_SOCKET_MODE_FIXTURE) + del callback["envelope_id"] + + with pytest.raises(IMCardEventDecodingError): + SlackIMProviderAdapter.card_event_decoder().decode(_event(callback, ingress_kind=IMEventIngressKind.STREAM)) + + +@pytest.mark.parametrize( + "invalid_envelope_id", + [ + pytest.param(None, id="null"), + pytest.param("", id="empty-string"), + pytest.param(0, id="integer"), + ], +) +def test_decoder_rejects_socket_mode_envelope_with_invalid_envelope_id(invalid_envelope_id: object) -> None: + callback = _fixture(_SOCKET_MODE_FIXTURE) + callback["envelope_id"] = invalid_envelope_id + + with pytest.raises(IMCardEventDecodingError): + SlackIMProviderAdapter.card_event_decoder().decode(_event(callback, ingress_kind=IMEventIngressKind.STREAM)) diff --git a/api/tests/unit_tests/core/human_input_v2/im_provider/test_slack_requirement_conformance.py b/api/tests/unit_tests/core/human_input_v2/im_provider/test_slack_requirement_conformance.py new file mode 100644 index 00000000000..6c36c63b89a --- /dev/null +++ b/api/tests/unit_tests/core/human_input_v2/im_provider/test_slack_requirement_conformance.py @@ -0,0 +1,1504 @@ +from __future__ import annotations + +import json +import logging +import threading +from collections import UserDict +from collections.abc import Callable, Mapping +from datetime import UTC, datetime +from typing import get_args, get_origin, get_type_hints +from urllib.parse import urlencode + +import pytest +from pytest_mock import MockerFixture +from slack_sdk.errors import SlackApiError, SlackClientError +from slack_sdk.signature import SignatureVerifier +from slack_sdk.socket_mode.request import SocketModeRequest +from slack_sdk.web import WebClient + +from core.human_input import ButtonStyle +from core.human_input_v2.entities import IMProvider +from core.human_input_v2.im_integration.adapters import ( + AuthenticatedIMEvent, + CorrelationToken, + CredentialTestFailure, + CredentialTestFailureKind, + Directory, + DirectoryReadFailure, + DynamicCardMessagingError, + EventAcceptance, + IMStreamStartError, + MessageAccepted, + MessageLocator, + MessageSendingError, + ProviderUserId, + ReplacementError, + ReplacementErrorKind, + SlackCredentials, + StaticCardIntent, + WebhookRequest, +) +from core.human_input_v2.im_integration.adapters import slack as slack_adapter_module +from core.human_input_v2.im_integration.adapters.slack import SlackIMProviderAdapter +from core.human_input_v2.resolved_form import ( + FileInput, + FormPart, + MarkdownFragment, + ParagraphInput, + ResolvedForm, + SelectInput, + UserAction, +) + +_SIGNING_SECRET = "sanitized-signing-material" + + +class _SlackResponse(UserDict[str, object]): + def __init__(self, values: Mapping[str, object], *, headers: Mapping[str, str] | None = None) -> None: + super().__init__(values) + self.headers = dict(headers or {}) + + +class _RecordingConsumer: + def __init__(self, acceptance: EventAcceptance = EventAcceptance.ACCEPTED) -> None: + self.acceptance = acceptance + self.events: list[AuthenticatedIMEvent] = [] + self._lock = threading.Lock() + + def accept(self, event: AuthenticatedIMEvent, *, request_ip_address: str | None = None) -> EventAcceptance: + del request_ip_address + with self._lock: + self.events.append(event) + return self.acceptance + + +def _credentials() -> SlackCredentials: + return SlackCredentials( + provider=IMProvider.SLACK, + client_id="sanitized-client-id", + client_secret="sanitized-client-secret", + signing_secret=_SIGNING_SECRET, + bot_token="xoxb-sanitized-placeholder", + app_token="xapp-sanitized-placeholder", + ) + + +def _successful_auth_response(team_id: str = "sanitized-team") -> _SlackResponse: + return _SlackResponse( + {"ok": True, "team_id": team_id}, + headers={"x-oauth-scopes": "chat:write,users:read,users:read.email"}, + ) + + +def _adapter(mocker: MockerFixture, client: WebClient) -> SlackIMProviderAdapter: + mocker.patch.object(slack_adapter_module, "WebClient", return_value=client) + return SlackIMProviderAdapter(_credentials()) + + +def _signed_request( + body: bytes, + *, + content_type: str | None = "application/json", + received_at: datetime | None = None, + timestamp_seconds: int | None = None, + signing_secret: str = _SIGNING_SECRET, + signature_body: bytes | None = None, +) -> WebhookRequest: + if received_at is None: + received_at = datetime.now(tz=UTC).replace(tzinfo=None) + trusted_receive_time = ( + received_at.replace(tzinfo=UTC) if received_at.tzinfo is None else received_at.astimezone(UTC) + ) + if timestamp_seconds is None: + timestamp_seconds = int(trusted_receive_time.timestamp()) + timestamp = str(timestamp_seconds) + signature = SignatureVerifier(signing_secret).generate_signature( + timestamp=timestamp, + body=body if signature_body is None else signature_body, + ) + assert signature is not None + headers = [ + ("X-Slack-Request-Timestamp", timestamp), + ("X-Slack-Signature", signature), + ] + if content_type is not None: + headers.append(("Content-Type", content_type)) + return WebhookRequest( + method="POST", + headers=tuple(headers), + body=body, + received_at=received_at, + ) + + +def _card_intent( + *, + blocks: tuple[FormPart, ...] = (MarkdownFragment(text="Sanitized rendered content"),), + actions: tuple[UserAction, ...] | None = None, + title: str = "Sanitized title", +) -> ResolvedForm: + if actions is None: + actions = (UserAction(id="approve", title="Approve", button_style=ButtonStyle.PRIMARY),) + return ResolvedForm( + title=title, + parts=blocks, + actions=actions, + legacy_form_content="This value must not be rendered", + ) + + +def _annotation_contains_mapping(annotation: object) -> bool: + origin = get_origin(annotation) + if origin is Mapping: + return True + return any(_annotation_contains_mapping(argument) for argument in get_args(annotation)) + + +def test_webhook_authentication_delegates_exact_body_to_official_sdk_verifier(mocker: MockerFixture) -> None: + verifier_class = mocker.patch.object(slack_adapter_module, "SignatureVerifier", create=True) + verifier = verifier_class.return_value + verifier.is_valid.return_value = True + verifier.is_valid_request.return_value = True + consumer = _RecordingConsumer() + handler = _adapter(mocker, mocker.create_autospec(WebClient, instance=True)).create_webhook_handler(consumer) + body = json.dumps( + {"type": "event_callback", "team_id": "sanitized-team", "event": {"type": "message"}}, + separators=(",", ":"), + ).encode() + request = WebhookRequest( + method="POST", + headers=( + ("X-Slack-Request-Timestamp", "1"), + ("X-Slack-Signature", "invalid-without-sdk-delegation"), + ("Content-Type", "application/json"), + ), + body=body, + received_at=datetime.now(tz=UTC).replace(tzinfo=None), + ) + + response = handler.handle(request) + + assert response.status_code == 200 + assert verifier_class.call_count >= 1 + verification_calls = verifier.is_valid.call_args_list + verifier.is_valid_request.call_args_list + assert verification_calls + assert any(body in call.args or body in call.kwargs.values() for call in verification_calls) + assert len(consumer.events) == 1 + + +def test_decoded_provider_json_boundaries_use_mapping_abstractions() -> None: + authenticated_hints = get_type_hints(slack_adapter_module._authenticated_event) + tenant_hints = get_type_hints(slack_adapter_module._provider_tenant_id) + + assert _annotation_contains_mapping(authenticated_hints["body"]) + assert _annotation_contains_mapping(authenticated_hints["serialized_body"]) + assert _annotation_contains_mapping(tenant_hints["body"]) + + +def test_credential_test_normalizes_unexpected_failure_without_secret_leak(mocker: MockerFixture) -> None: + client = mocker.create_autospec(WebClient, instance=True) + client.auth_test.side_effect = RuntimeError("sanitized-client-secret") + adapter = _adapter(mocker, client) + + with pytest.raises(RuntimeError, match="sanitized-client-secret") as captured: + adapter.test_credentials() + assert captured.value is client.auth_test.side_effect + + +def test_credential_test_maps_transport_and_scope_failures_to_safe_results(mocker: MockerFixture) -> None: + transport_client = mocker.create_autospec(WebClient, instance=True) + transport_client.auth_test.side_effect = SlackClientError("sanitized transport details") + with pytest.raises(SlackClientError) as captured: + _adapter(mocker, transport_client).test_credentials() + assert captured.value is transport_client.auth_test.side_effect + + scope_client = mocker.create_autospec(WebClient, instance=True) + scope_client.auth_test.return_value = _SlackResponse( + {"ok": True, "team_id": "sanitized-team"}, + headers={"X-OAuth-Scopes": "chat:write, users:read"}, + ) + scope_result = _adapter(mocker, scope_client).test_credentials() + + assert scope_result == CredentialTestFailure( + CredentialTestFailureKind.PERMISSION_DENIED, + "Slack baseline permissions could not be confirmed.", + missing_permissions=("users:read.email",), + ) + scope_client.apps_connections_open.assert_not_called() + + +@pytest.mark.parametrize( + ("socket_failure", "expected_kind"), + [ + ( + SlackApiError( + "sanitized authentication details", + response=_SlackResponse({"ok": False, "error": "invalid_auth"}), + ), + CredentialTestFailureKind.AUTHENTICATION_REJECTED, + ), + ( + SlackApiError( + "sanitized provider details", + response=_SlackResponse({"ok": False, "error": "internal_error"}), + ), + CredentialTestFailureKind.UNKNOWN, + ), + (SlackClientError("sanitized socket transport details"), CredentialTestFailureKind.UNKNOWN), + ], + ids=("authentication-rejected", "provider-unknown", "transport-unknown"), +) +def test_credential_test_maps_socket_mode_sdk_failures( + mocker: MockerFixture, + socket_failure: SlackClientError, + expected_kind: CredentialTestFailureKind, +) -> None: + client = mocker.create_autospec(WebClient, instance=True) + client.auth_test.return_value = _successful_auth_response() + client.apps_connections_open.side_effect = socket_failure + + if not isinstance(socket_failure, SlackApiError): + with pytest.raises(type(socket_failure)) as captured: + _adapter(mocker, client).test_credentials() + assert captured.value is socket_failure + return + result = _adapter(mocker, client).test_credentials() + + assert isinstance(result, CredentialTestFailure) + assert result.kind is expected_kind + assert "sanitized" not in result.reason + + +@pytest.mark.parametrize( + "provider_response", + [ + _SlackResponse({"ok": True}), + _SlackResponse({"ok": True, "members": [None]}), + _SlackResponse({"ok": True, "members": [{"profile": {}}]}), + ], + ids=("missing-members", "non-object-member", "missing-member-id"), +) +def test_directory_rejects_incomplete_or_invalid_pages( + mocker: MockerFixture, + provider_response: _SlackResponse, +) -> None: + client = mocker.create_autospec(WebClient, instance=True) + client.users_list.return_value = provider_response + + result = _adapter(mocker, client).directory.read_directory() + + assert isinstance(result, DirectoryReadFailure) + assert "Slack" in result.reason + + +def test_directory_skips_non_people_and_normalizes_optional_profile_fields(mocker: MockerFixture) -> None: + client = mocker.create_autospec(WebClient, instance=True) + client.users_list.return_value = _SlackResponse( + { + "ok": True, + "members": [ + {"id": "sanitized-deleted", "deleted": True}, + {"id": "sanitized-bot", "is_bot": True}, + {"id": "sanitized-app", "is_app_user": True}, + {"id": "sanitized-user", "profile": "malformed-profile"}, + ], + "response_metadata": {"next_cursor": ""}, + } + ) + + result = _adapter(mocker, client).directory.read_directory() + + assert isinstance(result, Directory) + assert len(result.entries) == 1 + assert result.entries[0].provider_user_id == "sanitized-user" + assert result.entries[0].display_name is None + assert result.entries[0].email is None + + +@pytest.mark.parametrize( + "provider_response", + [ + _SlackResponse( + { + "ok": True, + "members": [{"id": "sanitized-user", "profile": {}}], + } + ), + _SlackResponse( + { + "ok": True, + "members": [{"id": "sanitized-user", "profile": {}}], + "response_metadata": "malformed-metadata", + } + ), + _SlackResponse( + { + "ok": True, + "members": [{"id": "sanitized-user", "profile": {}}], + "response_metadata": {}, + } + ), + _SlackResponse( + { + "ok": True, + "members": [{"id": "sanitized-user", "profile": {}}], + "response_metadata": {"next_cursor": None}, + } + ), + _SlackResponse( + { + "ok": True, + "members": [{"id": "sanitized-user", "profile": {}}], + "response_metadata": {"next_cursor": 1}, + } + ), + ], + ids=( + "missing-metadata", + "malformed-metadata", + "missing-cursor", + "null-cursor", + "non-string-cursor", + ), +) +def test_directory_rejects_invalid_pagination_without_partial_snapshot( + mocker: MockerFixture, + provider_response: _SlackResponse, +) -> None: + client = mocker.create_autospec(WebClient, instance=True) + client.users_list.return_value = provider_response + + result = _adapter(mocker, client).directory.read_directory() + + assert isinstance(result, DirectoryReadFailure) + assert not hasattr(result, "entries") + assert "sanitized-user" not in result.reason + client.users_list.assert_called_once_with(limit=200) + + +@pytest.mark.parametrize( + "second_page", + [ + SlackClientError("sanitized second-page transport details"), + _SlackResponse( + { + "ok": True, + "members": [{"id": "sanitized-user-2", "profile": {}}], + "response_metadata": "malformed-metadata", + } + ), + ], + ids=("transport-failure", "malformed-pagination"), +) +def test_directory_later_page_failure_discards_accumulated_entries( + mocker: MockerFixture, + second_page: _SlackResponse | SlackClientError, +) -> None: + client = mocker.create_autospec(WebClient, instance=True) + client.users_list.side_effect = ( + _SlackResponse( + { + "ok": True, + "members": [{"id": "sanitized-user-1", "profile": {}}], + "response_metadata": {"next_cursor": "sanitized-cursor"}, + } + ), + second_page, + ) + + result = _adapter(mocker, client).directory.read_directory() + + assert isinstance(result, DirectoryReadFailure) + assert not hasattr(result, "entries") + assert "sanitized-user-1" not in result.reason + assert "sanitized-user-2" not in result.reason + assert client.users_list.call_args_list == [ + mocker.call(limit=200), + mocker.call(limit=200, cursor="sanitized-cursor"), + ] + + +def test_directory_rejects_repeated_cursor_without_request_loop_or_partial_snapshot( + mocker: MockerFixture, +) -> None: + client = mocker.create_autospec(WebClient, instance=True) + client.users_list.side_effect = ( + _SlackResponse( + { + "ok": True, + "members": [{"id": "sanitized-user-1", "profile": {}}], + "response_metadata": {"next_cursor": "sanitized-cursor"}, + } + ), + _SlackResponse( + { + "ok": True, + "members": [{"id": "sanitized-user-2", "profile": {}}], + "response_metadata": {"next_cursor": "sanitized-cursor"}, + } + ), + ) + + result = _adapter(mocker, client).directory.read_directory() + + assert isinstance(result, DirectoryReadFailure) + assert not hasattr(result, "entries") + assert client.users_list.call_args_list == [ + mocker.call(limit=200), + mocker.call(limit=200, cursor="sanitized-cursor"), + ] + + +def test_messaging_maps_card_transport_failure_without_workspace_preflight( + mocker: MockerFixture, +) -> None: + transport_client = mocker.create_autospec(WebClient, instance=True) + transport_client.chat_postMessage.side_effect = SlackClientError("sanitized transport details") + transport_result = _adapter(mocker, transport_client).dynamic_card_messaging.send_card( + ProviderUserId("sanitized-user"), + _card_intent(), + CorrelationToken("sanitized-correlation"), + ) + + assert isinstance(transport_result, MessageSendingError) + assert "sanitized" not in transport_result.reason + transport_client.auth_test.assert_not_called() + + +def test_static_replacement_maps_transport_failures_without_workspace_preflight( + mocker: MockerFixture, +) -> None: + source_client = mocker.create_autospec(WebClient, instance=True) + source_client.chat_postMessage.return_value = _SlackResponse( + {"ok": True, "channel": "sanitized-channel", "ts": "1000.000001"} + ) + accepted = _adapter(mocker, source_client).dynamic_card_messaging.send_card( + ProviderUserId("sanitized-user"), + _card_intent(), + CorrelationToken("sanitized-correlation"), + ) + assert isinstance(accepted, MessageAccepted) + + transport_client = mocker.create_autospec(WebClient, instance=True) + transport_client.chat_update.side_effect = SlackClientError("sanitized transport details") + transport_result = _adapter(mocker, transport_client).dynamic_card_messaging.replace_with_static( + accepted.locator, + StaticCardIntent("Sanitized static content"), + ) + + unconfirmed_client = mocker.create_autospec(WebClient, instance=True) + unconfirmed_client.chat_update.return_value = _SlackResponse({"ok": False}) + unconfirmed_result = _adapter(mocker, unconfirmed_client).dynamic_card_messaging.replace_with_static( + accepted.locator, + StaticCardIntent("Sanitized static content"), + ) + + assert isinstance(transport_result, ReplacementError) + assert transport_result.kind is ReplacementErrorKind.UNKNOWN + assert isinstance(unconfirmed_result, ReplacementError) + assert unconfirmed_result.kind is ReplacementErrorKind.UNKNOWN + source_client.auth_test.assert_not_called() + transport_client.auth_test.assert_not_called() + unconfirmed_client.auth_test.assert_not_called() + + +@pytest.mark.parametrize( + ("channel_id", "message_ts"), + [ + ("", "1000.000001"), + ("sanitized-channel", "not-a-slack-timestamp"), + ], +) +def test_message_acceptance_requires_a_persistently_round_trippable_locator( + mocker: MockerFixture, + channel_id: str, + message_ts: str, +) -> None: + client = mocker.create_autospec(WebClient, instance=True) + client.auth_test.return_value = _successful_auth_response() + client.chat_postMessage.return_value = _SlackResponse({"ok": True, "channel": channel_id, "ts": message_ts}) + adapter = _adapter(mocker, client) + + result = adapter.messaging.send_text(ProviderUserId("sanitized-user"), "Sanitized message") + + assert isinstance(result, MessageSendingError) + client.chat_postMessage.assert_called_once() + + +def test_real_paragraph_resolved_default_is_preserved_and_rendered(mocker: MockerFixture) -> None: + intent = _card_intent( + blocks=( + MarkdownFragment(text="Before input"), + ParagraphInput(output_variable_name="comment", default_value="Sanitized preserved default"), + MarkdownFragment(text="After input"), + ) + ) + client = mocker.create_autospec(WebClient, instance=True) + client.auth_test.return_value = _successful_auth_response() + client.chat_postMessage.return_value = _SlackResponse( + {"ok": True, "channel": "sanitized-channel", "ts": "1000.000001"} + ) + adapter = _adapter(mocker, client) + + assessment = adapter.dynamic_card_messaging.assess(intent) + result = adapter.dynamic_card_messaging.send_card( + ProviderUserId("sanitized-user"), + intent, + CorrelationToken("sanitized-correlation"), + ) + + assert assessment.representable is True + assert isinstance(result, MessageAccepted) + input_block = next( + block for block in client.chat_postMessage.call_args.kwargs["blocks"] if block["type"] == "input" + ) + assert input_block["element"]["initial_value"] == "Sanitized preserved default" + assert [block["type"] for block in client.chat_postMessage.call_args.kwargs["blocks"]] == [ + "header", + "markdown", + "input", + "markdown", + "actions", + ] + + +@pytest.mark.parametrize( + "intent", + [ + _card_intent(blocks=(), actions=(), title=""), + _card_intent(blocks=(MarkdownFragment(text=""),)), + _card_intent(blocks=(MarkdownFragment(text="x" * 20_000),)), + _card_intent(title="x" * 151), + _card_intent( + blocks=tuple( + ParagraphInput(output_variable_name=f"input_{index}", default_value=None) for index in range(49) + ) + ), + _card_intent( + actions=tuple( + UserAction(id=f"action_{index}", title=f"Action {index}", button_style=ButtonStyle.DEFAULT) + for index in range(26) + ) + ), + _card_intent( + blocks=( + ParagraphInput(output_variable_name="duplicate", default_value=None), + ParagraphInput(output_variable_name="duplicate", default_value=None), + ) + ), + _card_intent(actions=(UserAction(id="x" * 256, title="Action", button_style=ButtonStyle.DEFAULT),)), + _card_intent(actions=(UserAction(id="action", title="Action", button_style=ButtonStyle.GHOST),)), + _card_intent( + blocks=( + FileInput( + output_variable_name="attachment", + allowed_file_types=(), + allowed_file_extensions=(), + allowed_file_upload_methods=(), + ), + ) + ), + _card_intent(blocks=(ParagraphInput(output_variable_name="x" * 256, default_value=None),)), + _card_intent(blocks=(ParagraphInput(output_variable_name="input", default_value="x" * 3_001),)), + _card_intent(blocks=(SelectInput(output_variable_name="input", options=(), default_value=None),)), + _card_intent(blocks=(SelectInput(output_variable_name="input", options=("",), default_value=None),)), + ], + ids=( + "empty-card", + "empty-markdown", + "oversized-markdown", + "oversized-title", + "block-count", + "action-count", + "duplicate-input", + "action-identifier", + "action-style", + "file-input", + "input-identifier", + "paragraph-default", + "select-count", + "select-option", + ), +) +def test_card_assessment_rejects_slack_representation_boundaries( + mocker: MockerFixture, + intent: ResolvedForm, +) -> None: + client = mocker.create_autospec(WebClient, instance=True) + adapter = _adapter(mocker, client) + + assessment = adapter.dynamic_card_messaging.assess(intent) + + assert assessment.representable is False + with pytest.raises(DynamicCardMessagingError): + adapter.dynamic_card_messaging.send_card( + ProviderUserId("sanitized-user"), + intent, + CorrelationToken("sanitized-correlation"), + ) + client.chat_postMessage.assert_not_called() + + +def test_card_render_preserves_optional_sections_and_default_action_style(mocker: MockerFixture) -> None: + client = mocker.create_autospec(WebClient, instance=True) + client.auth_test.return_value = _successful_auth_response() + client.chat_postMessage.side_effect = ( + _SlackResponse({"ok": True, "channel": "sanitized-channel", "ts": "1000.000001"}), + _SlackResponse({"ok": True, "channel": "sanitized-channel", "ts": "1000.000002"}), + ) + adapter = _adapter(mocker, client) + paragraph_intent = _card_intent( + blocks=(ParagraphInput(output_variable_name="comment", default_value=None),), + actions=(), + title="", + ) + default_action_intent = _card_intent( + actions=(UserAction(id="continue", title="Continue", button_style=ButtonStyle.DEFAULT),), + title="", + ) + + paragraph_result = adapter.dynamic_card_messaging.send_card( + ProviderUserId("sanitized-user"), + paragraph_intent, + CorrelationToken("sanitized-correlation"), + ) + default_action_result = adapter.dynamic_card_messaging.send_card( + ProviderUserId("sanitized-user"), + default_action_intent, + CorrelationToken("sanitized-correlation"), + ) + + assert isinstance(paragraph_result, MessageAccepted) + assert isinstance(default_action_result, MessageAccepted) + paragraph_blocks = client.chat_postMessage.call_args_list[0].kwargs["blocks"] + paragraph_element = next(block["element"] for block in paragraph_blocks if block["type"] == "input") + assert "initial_value" not in paragraph_element + assert all(block["type"] != "header" for block in paragraph_blocks) + assert all(block["type"] != "actions" for block in paragraph_blocks) + action_blocks = client.chat_postMessage.call_args_list[1].kwargs["blocks"] + action_element = next(block["elements"][0] for block in action_blocks if block["type"] == "actions") + assert "style" not in action_element + + +def test_card_send_rejects_oversized_serialized_correlation_before_provider_io(mocker: MockerFixture) -> None: + client = mocker.create_autospec(WebClient, instance=True) + adapter = _adapter(mocker, client) + + with pytest.raises(DynamicCardMessagingError, match="correlation token"): + adapter.dynamic_card_messaging.send_card( + ProviderUserId("sanitized-user"), + _card_intent(), + CorrelationToken("x" * 2_100), + ) + + client.auth_test.assert_not_called() + client.chat_postMessage.assert_not_called() + + +def test_webhook_consumer_failure_does_not_log_exception_details( + mocker: MockerFixture, + caplog: pytest.LogCaptureFixture, +) -> None: + sensitive_marker = "sanitized-sensitive-consumer-marker" + + class _FailingConsumer: + def accept(self, event: object, *, request_ip_address: str | None = None) -> EventAcceptance: + del request_ip_address + del event + raise RuntimeError(sensitive_marker) + + handler = _adapter(mocker, mocker.create_autospec(WebClient, instance=True)).create_webhook_handler( + _FailingConsumer() + ) + body = json.dumps({"type": "event_callback", "team_id": "sanitized-team", "event": {"type": "message"}}).encode() + + with caplog.at_level(logging.ERROR, logger=slack_adapter_module.__name__): + response = handler.handle(_signed_request(body)) + + assert response.status_code == 503 + assert sensitive_marker not in caplog.text + + +def test_message_locator_can_be_reused_across_adapter_instances(mocker: MockerFixture) -> None: + first_client = mocker.create_autospec(WebClient, instance=True) + first_client.chat_postMessage.return_value = _SlackResponse( + {"ok": True, "channel": "D0123456789", "ts": "1712345678.123456"} + ) + second_client = mocker.create_autospec(WebClient, instance=True) + second_client.chat_update.return_value = _SlackResponse({"ok": True}) + web_client = mocker.patch.object(slack_adapter_module, "WebClient", side_effect=(first_client, second_client)) + first_adapter = SlackIMProviderAdapter(_credentials()) + accepted = first_adapter.dynamic_card_messaging.send_card( + ProviderUserId("U0123456789"), + _card_intent(), + CorrelationToken("sanitized-correlation"), + ) + assert isinstance(accepted, MessageAccepted) + first_adapter.close() + second_adapter = SlackIMProviderAdapter(_credentials()) + + result = second_adapter.dynamic_card_messaging.replace_with_static( + accepted.locator, + StaticCardIntent("Sanitized static content"), + ) + + assert result is None + assert web_client.call_count == 2 + first_client.auth_test.assert_not_called() + second_client.auth_test.assert_not_called() + second_client.chat_update.assert_called_once() + update_parameters = second_client.chat_update.call_args.kwargs + assert set(update_parameters) == {"blocks", "channel", "text", "ts"} + assert update_parameters["channel"] == "D0123456789" + assert update_parameters["ts"] == "1712345678.123456" + assert update_parameters["text"] == "Sanitized static content" + assert update_parameters["blocks"] == [{"type": "markdown", "text": "Sanitized static content"}] + assert "markdown_text" not in update_parameters + + +def test_static_replacement_rejects_foreign_reference_before_provider_io(mocker: MockerFixture) -> None: + client = mocker.create_autospec(WebClient, instance=True) + adapter = _adapter(mocker, client) + + result = adapter.dynamic_card_messaging.replace_with_static( + MessageLocator("invalid."), + StaticCardIntent("Sanitized static content"), + ) + + assert isinstance(result, ReplacementError) + assert result.kind is ReplacementErrorKind.INVALID_REFERENCE + client.auth_test.assert_not_called() + client.chat_update.assert_not_called() + + +def test_static_replacement_rejects_public_text_reference_without_mutation(mocker: MockerFixture) -> None: + client = mocker.create_autospec(WebClient, instance=True) + client.auth_test.return_value = _successful_auth_response() + client.chat_postMessage.return_value = _SlackResponse( + {"ok": True, "channel": "sanitized-channel", "ts": "1000.000001"} + ) + adapter = _adapter(mocker, client) + accepted = adapter.messaging.send_text(ProviderUserId("sanitized-user"), "Sanitized message") + assert isinstance(accepted, MessageAccepted) + client.reset_mock() + client.chat_update.return_value = _SlackResponse({"ok": True, "channel": "sanitized-channel", "ts": "1000.000001"}) + + result = adapter.dynamic_card_messaging.replace_with_static( + accepted.locator, + StaticCardIntent("Sanitized static content"), + ) + + assert result is None + client.auth_test.assert_not_called() + client.chat_update.assert_called_once() + + +@pytest.mark.parametrize( + ("error_code", "expected_kind"), + [ + ("message_not_found", ReplacementErrorKind.STALE_REFERENCE), + ("internal_error", ReplacementErrorKind.UNKNOWN), + ], +) +def test_static_replacement_maps_provider_failures_without_replay( + mocker: MockerFixture, + error_code: str, + expected_kind: ReplacementErrorKind, +) -> None: + first_client = mocker.create_autospec(WebClient, instance=True) + first_client.auth_test.return_value = _successful_auth_response() + first_client.chat_postMessage.return_value = _SlackResponse( + {"ok": True, "channel": "sanitized-channel", "ts": "1000.000001"} + ) + adapter = _adapter(mocker, first_client) + accepted = adapter.dynamic_card_messaging.send_card( + ProviderUserId("sanitized-user"), + _card_intent(), + CorrelationToken("sanitized-correlation"), + ) + assert isinstance(accepted, MessageAccepted) + first_client.chat_update.side_effect = SlackApiError( + "sanitized-provider-error", + response=_SlackResponse({"ok": False, "error": error_code}), + ) + + result = adapter.dynamic_card_messaging.replace_with_static( + accepted.locator, + StaticCardIntent("Sanitized static content"), + ) + + assert isinstance(result, ReplacementError) + assert result.kind is expected_kind + first_client.chat_update.assert_called_once() + assert "sanitized-provider-error" not in result.reason + + +def test_webhook_rejects_authenticated_non_post_requests_before_parsing(mocker: MockerFixture) -> None: + consumer = _RecordingConsumer() + handler = _adapter(mocker, mocker.create_autospec(WebClient, instance=True)).create_webhook_handler(consumer) + body = json.dumps({"type": "event_callback", "team_id": "sanitized-team", "event": {"type": "message"}}).encode() + signed = _signed_request(body, received_at=datetime(2026, 8, 6, 8, tzinfo=UTC)) + request = WebhookRequest( + method="GET", + headers=signed.headers, + body=signed.body, + received_at=signed.received_at, + ) + + response = handler.handle(request) + + assert response.status_code == 405 + assert consumer.events == [] + + +@pytest.mark.parametrize( + ("body", "content_type", "expected_status", "expected_events"), + [ + (b"not-json", "application/json", 400, 0), + (b"payload=not-json", "application/x-www-form-urlencoded", 400, 0), + (b"payload=%7B%7D&payload=%7B%7D", "application/x-www-form-urlencoded", 400, 0), + (b"payload=%5B%5D", "application/x-www-form-urlencoded", 400, 0), + (json.dumps({"type": "url_verification", "challenge": 1}).encode(), "application/json", 400, 0), + ( + json.dumps({"type": "event_callback", "event": {"type": "message"}}).encode(), + "application/json", + 400, + 0, + ), + ( + urlencode( + { + "payload": json.dumps( + { + "type": "block_actions", + "team": {"id": "sanitized-team"}, + "actions": [], + } + ) + } + ).encode(), + "application/x-www-form-urlencoded; charset=utf-8", + 200, + 1, + ), + ], + ids=( + "malformed-json", + "malformed-payload-json", + "duplicate-form-payload", + "non-object-payload", + "invalid-challenge", + "missing-tenant", + "valid-form-payload", + ), +) +def test_webhook_parsing_fails_closed_without_partial_consumption( + mocker: MockerFixture, + body: bytes, + content_type: str, + expected_status: int, + expected_events: int, +) -> None: + consumer = _RecordingConsumer() + handler = _adapter(mocker, mocker.create_autospec(WebClient, instance=True)).create_webhook_handler(consumer) + + response = handler.handle(_signed_request(body, content_type=content_type)) + + assert response.status_code == expected_status + assert len(consumer.events) == expected_events + + +@pytest.mark.parametrize( + ("body", "content_type"), + [ + ( + json.dumps({"type": "event_callback", "team_id": "sanitized-team", "event": {"type": "message"}}).encode(), + "application/x-www-form-urlencoded", + ), + ( + urlencode( + {"payload": json.dumps({"type": "block_actions", "team": {"id": "sanitized-team"}, "actions": []})} + ).encode(), + "application/json", + ), + ( + json.dumps({"type": "event_callback", "team_id": "sanitized-team", "event": {"type": "message"}}).encode(), + "text/plain", + ), + ], + ids=("json-as-form", "form-as-json", "unsupported-media-type"), +) +def test_webhook_dispatches_body_parser_only_by_content_type( + mocker: MockerFixture, + body: bytes, + content_type: str, +) -> None: + consumer = _RecordingConsumer() + handler = _adapter(mocker, mocker.create_autospec(WebClient, instance=True)).create_webhook_handler(consumer) + + response = handler.handle(_signed_request(body, content_type=content_type)) + + assert response.status_code == 400 + assert consumer.events == [] + + +def test_webhook_rejects_missing_or_duplicate_content_type(mocker: MockerFixture) -> None: + consumer = _RecordingConsumer() + handler = _adapter(mocker, mocker.create_autospec(WebClient, instance=True)).create_webhook_handler(consumer) + body = json.dumps({"type": "event_callback", "team_id": "sanitized-team", "event": {"type": "message"}}).encode() + missing = _signed_request(body, content_type=None) + duplicate = _signed_request(body) + duplicate = WebhookRequest( + method=duplicate.method, + headers=(*duplicate.headers, ("content-type", "application/json")), + body=duplicate.body, + received_at=duplicate.received_at, + ) + + missing_response = handler.handle(missing) + duplicate_response = handler.handle(duplicate) + + assert missing_response.status_code == 400 + assert duplicate_response.status_code == 400 + assert consumer.events == [] + + +@pytest.mark.parametrize( + "verification_failure", + [ValueError("sanitized verifier value details"), RuntimeError("sanitized verifier runtime details")], + ids=("expected-sdk-failure", "unexpected-sdk-failure"), +) +def test_webhook_signature_verifier_failures_are_safe_and_unauthenticated( + mocker: MockerFixture, + caplog: pytest.LogCaptureFixture, + verification_failure: Exception, +) -> None: + consumer = _RecordingConsumer() + handler = _adapter(mocker, mocker.create_autospec(WebClient, instance=True)).create_webhook_handler(consumer) + + def fail_verification(self: slack_adapter_module.SignatureVerifier, **kwargs: object) -> bool: + del self, kwargs + raise verification_failure + + mocker.patch.object(slack_adapter_module.SignatureVerifier, "is_valid", new=fail_verification) + body = json.dumps({"type": "event_callback", "team_id": "sanitized-team", "event": {"type": "message"}}).encode() + + with caplog.at_level(logging.ERROR, logger=slack_adapter_module.__name__): + response = handler.handle(_signed_request(body)) + + assert response.status_code == 401 + assert consumer.events == [] + assert "sanitized verifier" not in caplog.text + + +def test_webhook_rejects_duplicate_verification_headers_before_consuming(mocker: MockerFixture) -> None: + consumer = _RecordingConsumer() + handler = _adapter(mocker, mocker.create_autospec(WebClient, instance=True)).create_webhook_handler(consumer) + body = json.dumps({"type": "event_callback", "team_id": "sanitized-team", "event": {"type": "message"}}).encode() + request = _signed_request(body) + request = WebhookRequest( + method=request.method, + headers=(*request.headers, ("x-slack-signature", "sanitized-duplicate")), + body=request.body, + received_at=request.received_at, + ) + + response = handler.handle(request) + + assert response.status_code == 401 + assert consumer.events == [] + + +def test_webhook_freshness_treats_trusted_naive_receive_time_as_utc(mocker: MockerFixture) -> None: + consumer = _RecordingConsumer() + handler = _adapter(mocker, mocker.create_autospec(WebClient, instance=True)).create_webhook_handler(consumer) + body = json.dumps({"type": "event_callback", "team_id": "sanitized-team", "event": {"type": "message"}}).encode() + received_at = datetime.now(tz=UTC).replace(tzinfo=None) + + response = handler.handle(_signed_request(body, received_at=received_at)) + + assert response.status_code == 200 + assert len(consumer.events) == 1 + + +@pytest.mark.parametrize( + ("case", "timestamp_delta", "signing_secret", "tamper_body", "expected_status"), + [ + ("valid", 0, _SIGNING_SECRET, False, 200), + ("body-tamper", 0, _SIGNING_SECRET, True, 401), + ("wrong-material", 0, "sanitized-wrong-signing-material", False, 401), + ("past-boundary", -300, _SIGNING_SECRET, False, 200), + ("future-boundary", 300, _SIGNING_SECRET, False, 200), + ("stale", -301, _SIGNING_SECRET, False, 401), + ("future", 301, _SIGNING_SECRET, False, 401), + ], + ids=("valid", "body-tamper", "wrong-material", "past-boundary", "future-boundary", "stale", "future"), +) +def test_sanitized_signed_webhook_structure_obeys_sdk_verification_boundaries( + mocker: MockerFixture, + case: str, + timestamp_delta: int, + signing_secret: str, + tamper_body: bool, + expected_status: int, +) -> None: + del case + consumer = _RecordingConsumer() + handler = _adapter(mocker, mocker.create_autospec(WebClient, instance=True)).create_webhook_handler(consumer) + signed_body = json.dumps( + { + "type": "event_callback", + "team_id": "sanitized-team", + "event_id": "sanitized-event", + "event_time": 1786003200, + "event": { + "type": "message", + "text": "Sanitized content", + "metadata": {"nested": [1, True, None]}, + }, + }, + separators=(",", ":"), + ).encode() + request_body = signed_body + b" " if tamper_body else signed_body + received_at = datetime(2026, 8, 6, 8) + received_timestamp = int(received_at.replace(tzinfo=UTC).timestamp()) + request = _signed_request( + request_body, + received_at=received_at, + timestamp_seconds=received_timestamp + timestamp_delta, + signing_secret=signing_secret, + signature_body=signed_body, + ) + + response = handler.handle(request) + + assert response.status_code == expected_status + assert len(consumer.events) == (1 if expected_status == 200 else 0) + + +def test_webhook_replay_within_freshness_window_remains_consumer_owned(mocker: MockerFixture) -> None: + consumer = _RecordingConsumer() + handler = _adapter(mocker, mocker.create_autospec(WebClient, instance=True)).create_webhook_handler(consumer) + body = json.dumps( + { + "type": "event_callback", + "team_id": "sanitized-team", + "event_id": "sanitized-replayed-event", + "event": {"type": "message"}, + }, + separators=(",", ":"), + ).encode() + request = _signed_request(body) + + first_response = handler.handle(request) + second_response = handler.handle(request) + + assert first_response.status_code == 200 + assert second_response.status_code == 200 + assert [event.event_id for event in consumer.events] == [ + "sanitized-replayed-event", + "sanitized-replayed-event", + ] + + +def test_webhook_handler_supports_overlapping_calls_with_independent_responses(mocker: MockerFixture) -> None: + overlap_barrier = threading.Barrier(2) + state_lock = threading.Lock() + consumed_event_ids: list[str | None] = [] + active_calls = 0 + maximum_active_calls = 0 + + class _OverlappingConsumer: + def accept(self, event: AuthenticatedIMEvent, *, request_ip_address: str | None = None) -> EventAcceptance: + del request_ip_address + nonlocal active_calls, maximum_active_calls + with state_lock: + active_calls += 1 + maximum_active_calls = max(maximum_active_calls, active_calls) + consumed_event_ids.append(event.event_id) + try: + overlap_barrier.wait(timeout=2) + if event.event_id == "sanitized-accepted-event": + return EventAcceptance.ACCEPTED + return EventAcceptance.NOT_ACCEPTED + finally: + with state_lock: + active_calls -= 1 + + handler = _adapter(mocker, mocker.create_autospec(WebClient, instance=True)).create_webhook_handler( + _OverlappingConsumer() + ) + requests = { + "accepted": _signed_request( + json.dumps( + { + "type": "event_callback", + "team_id": "sanitized-team", + "event_id": "sanitized-accepted-event", + "event": {"type": "message"}, + }, + separators=(",", ":"), + ).encode() + ), + "not_accepted": _signed_request( + json.dumps( + { + "type": "event_callback", + "team_id": "sanitized-team", + "event_id": "sanitized-not-accepted-event", + "event": {"type": "message"}, + }, + separators=(",", ":"), + ).encode() + ), + } + responses: dict[str, int] = {} + failures: list[BaseException] = [] + + def _invoke(name: str) -> None: + try: + responses[name] = handler.handle(requests[name]).status_code + except BaseException as error: + failures.append(error) + + threads = [threading.Thread(target=_invoke, args=(name,)) for name in requests] + for thread in threads: + thread.start() + for thread in threads: + thread.join(2) + + assert all(not thread.is_alive() for thread in threads) + assert failures == [] + assert maximum_active_calls == 2 + assert responses == {"accepted": 200, "not_accepted": 503} + assert len(consumed_event_ids) == 2 + assert "sanitized-accepted-event" in consumed_event_ids + assert "sanitized-not-accepted-event" in consumed_event_ids + + +def test_webhook_without_confirmed_event_identity_does_not_synthesize_facts(mocker: MockerFixture) -> None: + consumer = _RecordingConsumer() + handler = _adapter(mocker, mocker.create_autospec(WebClient, instance=True)).create_webhook_handler(consumer) + body = json.dumps( + { + "type": "event_callback", + "team_id": "sanitized-team", + "event": {"type": "message", "nested": [1, None, {"preserved": True}]}, + } + ).encode() + + response = handler.handle(_signed_request(body)) + + assert response.status_code == 200 + assert len(consumer.events) == 1 + event = consumer.events[0] + assert event.event_id is None + assert event.occurred_at is None + assert json.loads(event.payload) == json.loads(body) + + +def test_webhook_handler_remains_usable_after_root_close(mocker: MockerFixture) -> None: + consumer = _RecordingConsumer() + adapter = _adapter(mocker, mocker.create_autospec(WebClient, instance=True)) + handler = adapter.create_webhook_handler(consumer) + adapter.close() + adapter.close() + body = json.dumps({"type": "event_callback", "team_id": "sanitized-team", "event": {"type": "message"}}).encode() + + response = handler.handle(_signed_request(body)) + + assert response.status_code == 200 + assert len(consumer.events) == 1 + + +def test_socket_control_frames_and_not_accepted_business_events_are_not_acked(mocker: MockerFixture) -> None: + consumer = _RecordingConsumer(EventAcceptance.NOT_ACCEPTED) + control = SocketModeRequest(type="hello", envelope_id="sanitized-control", payload={}) + business = SocketModeRequest( + type="events_api", + envelope_id="sanitized-business", + payload={"team_id": "sanitized-team", "event": {"type": "message"}}, + ) + + class _FakeSocketModeClient: + instances: list[_FakeSocketModeClient] = [] + + def __init__(self, **kwargs: object) -> None: + self.kwargs = kwargs + self.socket_mode_request_listeners: list[Callable[[object, SocketModeRequest], None]] = [] + self.responses: list[object] = [] + self.closed = False + self.__class__.instances.append(self) + + def connect(self) -> None: + listener = self.socket_mode_request_listeners[0] + listener(self, control) + listener(self, business) + + def send_socket_mode_response(self, response: object) -> None: + self.responses.append(response) + + def close(self) -> None: + self.closed = True + + mocker.patch.object(slack_adapter_module, "SocketModeClient", _FakeSocketModeClient) + stream = _adapter(mocker, mocker.create_autospec(WebClient, instance=True)).create_stream_handler(consumer) + assert stream is not None + + stream.start() + + socket_client = _FakeSocketModeClient.instances[0] + assert socket_client.kwargs["app_token"] == "xapp-sanitized-placeholder" + assert socket_client.responses == [] + assert socket_client.closed is False + assert len(consumer.events) == 1 + + stream.stop() + + assert socket_client.closed + + +def test_socket_stop_before_start_opens_no_connection_and_consumes_one_shot(mocker: MockerFixture) -> None: + socket_client = mocker.patch.object(slack_adapter_module, "SocketModeClient") + stream = _adapter(mocker, mocker.create_autospec(WebClient, instance=True)).create_stream_handler( + _RecordingConsumer() + ) + assert stream is not None + + stream.stop() + stream.stop() + + socket_client.assert_not_called() + with pytest.raises(IMStreamStartError): + stream.start() + socket_client.assert_not_called() + + +@pytest.mark.parametrize("disconnect_kind", ["error", "close"]) +def test_socket_remote_disconnect_is_observable_without_adapter_lifecycle_transition( + mocker: MockerFixture, + caplog: pytest.LogCaptureFixture, + disconnect_kind: str, +) -> None: + sensitive_marker = f"sanitized-sensitive-remote-{disconnect_kind}" + request = SocketModeRequest( + type="events_api", + envelope_id="sanitized-business", + payload={"team_id": "sanitized-team", "event": {"type": "message"}}, + ) + consumer = _RecordingConsumer() + + class _ListenerSocketModeClient: + instance: _ListenerSocketModeClient | None = None + + def __init__( + self, + *, + on_error_listeners: list[Callable[[Exception], None]], + on_close_listeners: list[Callable[[int, str | None], None]], + **kwargs: object, + ) -> None: + del kwargs + self.on_error_listeners = on_error_listeners + self.on_close_listeners = on_close_listeners + self.socket_mode_request_listeners: list[Callable[[object, SocketModeRequest], None]] = [] + self.responses: list[object] = [] + self.closed = False + self.__class__.instance = self + + def connect(self) -> None: + return None + + def send_socket_mode_response(self, response: object) -> None: + self.responses.append(response) + + def close(self) -> None: + self.closed = True + + mocker.patch.object(slack_adapter_module, "SocketModeClient", _ListenerSocketModeClient) + stream = _adapter(mocker, mocker.create_autospec(WebClient, instance=True)).create_stream_handler(consumer) + assert stream is not None + + with caplog.at_level(logging.ERROR, logger=slack_adapter_module.__name__): + stream.start() + socket_client = _ListenerSocketModeClient.instance + assert socket_client is not None + if disconnect_kind == "error": + socket_client.on_error_listeners[0](RuntimeError(sensitive_marker)) + else: + socket_client.on_close_listeners[0](1006, sensitive_marker) + socket_client.socket_mode_request_listeners[0](socket_client, request) + stream.stop() + + socket_client = _ListenerSocketModeClient.instance + assert socket_client is not None + assert socket_client.closed + assert len(socket_client.responses) == 1 + assert len(consumer.events) == 1 + assert sensitive_marker not in caplog.text + + +def test_socket_close_listener_after_local_stop_is_normal_and_redacted( + mocker: MockerFixture, + caplog: pytest.LogCaptureFixture, +) -> None: + sensitive_marker = "sanitized-sensitive-local-close" + + class _LocallyClosedSocketModeClient: + instance: _LocallyClosedSocketModeClient | None = None + + def __init__( + self, + *, + on_close_listeners: list[Callable[[int, str | None], None]], + **kwargs: object, + ) -> None: + del kwargs + self.on_close_listeners = on_close_listeners + self.socket_mode_request_listeners: list[Callable[[object, SocketModeRequest], None]] = [] + self.closed = False + self.__class__.instance = self + + def connect(self) -> None: + return None + + def close(self) -> None: + self.closed = True + self.on_close_listeners[0](1000, sensitive_marker) + + mocker.patch.object(slack_adapter_module, "SocketModeClient", _LocallyClosedSocketModeClient) + stream = _adapter(mocker, mocker.create_autospec(WebClient, instance=True)).create_stream_handler( + _RecordingConsumer() + ) + assert stream is not None + + with caplog.at_level(logging.ERROR, logger=slack_adapter_module.__name__): + stream.start() + stream.stop() + + socket_client = _LocallyClosedSocketModeClient.instance + assert socket_client is not None + assert socket_client.closed + assert sensitive_marker not in caplog.text + + +def test_socket_connect_failure_is_normalized_and_cleans_up_partial_resources( + mocker: MockerFixture, + caplog: pytest.LogCaptureFixture, +) -> None: + sensitive_marker = "sanitized-sensitive-connect-failure" + + class _FailingSocketModeClient: + instance: _FailingSocketModeClient | None = None + + def __init__(self, **kwargs: object) -> None: + del kwargs + self.socket_mode_request_listeners: list[Callable[[object, SocketModeRequest], None]] = [] + self.connect_calls = 0 + self.closed = False + self.__class__.instance = self + + def connect(self) -> None: + self.connect_calls += 1 + raise SlackClientError(sensitive_marker) + + def close(self) -> None: + self.closed = True + + mocker.patch.object(slack_adapter_module, "SocketModeClient", _FailingSocketModeClient) + stream = _adapter(mocker, mocker.create_autospec(WebClient, instance=True)).create_stream_handler( + _RecordingConsumer() + ) + assert stream is not None + + with caplog.at_level(logging.ERROR, logger=slack_adapter_module.__name__): + with pytest.raises(IMStreamStartError) as raised: + stream.start() + stream.stop() + + socket_client = _FailingSocketModeClient.instance + assert socket_client is not None + assert socket_client.connect_calls == 1 + assert socket_client.closed + assert sensitive_marker not in str(raised.value) + assert sensitive_marker not in caplog.text + + +def test_socket_stop_delegates_to_sdk_without_waiting_for_inflight_consumer(mocker: MockerFixture) -> None: + consumer_started = threading.Event() + release_consumer = threading.Event() + stop_finished = threading.Event() + request = SocketModeRequest( + type="events_api", + envelope_id="sanitized-business", + payload={"team_id": "sanitized-team", "event": {"type": "message"}}, + ) + + class _BlockingConsumer: + def __init__(self) -> None: + self.calls = 0 + + def accept(self, event: object, *, request_ip_address: str | None = None) -> EventAcceptance: + del request_ip_address + del event + self.calls += 1 + consumer_started.set() + assert release_consumer.wait(2) + return EventAcceptance.ACCEPTED + + consumer = _BlockingConsumer() + + class _FakeSocketModeClient: + instance: _FakeSocketModeClient | None = None + + def __init__(self, **kwargs: object) -> None: + del kwargs + self.socket_mode_request_listeners: list[Callable[[object, SocketModeRequest], None]] = [] + self.responses: list[object] = [] + self.closed = False + self.callback_thread: threading.Thread | None = None + self.__class__.instance = self + + def connect(self) -> None: + listener = self.socket_mode_request_listeners[0] + self.callback_thread = threading.Thread(target=listener, args=(self, request)) + self.callback_thread.start() + + def send_socket_mode_response(self, response: object) -> None: + self.responses.append(response) + + def close(self) -> None: + self.closed = True + + mocker.patch.object(slack_adapter_module, "SocketModeClient", _FakeSocketModeClient) + adapter = _adapter(mocker, mocker.create_autospec(WebClient, instance=True)) + stream = adapter.create_stream_handler(consumer) + assert stream is not None + + stream.start() + assert consumer_started.wait(2) + adapter.close() + + def _stop() -> None: + stream.stop() + stop_finished.set() + + stop_thread = threading.Thread(target=_stop) + stop_thread.start() + + assert stop_finished.wait(2) + socket_client = _FakeSocketModeClient.instance + assert socket_client is not None + assert socket_client.closed + + release_consumer.set() + stop_thread.join(2) + assert socket_client.callback_thread is not None + socket_client.callback_thread.join(2) + + assert not stop_thread.is_alive() + assert not socket_client.callback_thread.is_alive() + assert stop_finished.is_set() + assert socket_client.closed + assert len(socket_client.responses) == 1 + assert consumer.calls == 1 diff --git a/api/tests/unit_tests/core/human_input_v2/im_provider/test_slack_sdk_boundary.py b/api/tests/unit_tests/core/human_input_v2/im_provider/test_slack_sdk_boundary.py new file mode 100644 index 00000000000..cfe315748f4 --- /dev/null +++ b/api/tests/unit_tests/core/human_input_v2/im_provider/test_slack_sdk_boundary.py @@ -0,0 +1,1371 @@ +"""Slack SDK boundary tests backed by local test doubles rather than Slack.""" + +from __future__ import annotations + +import json +import threading +from collections import defaultdict, deque +from collections.abc import Callable, Generator, Mapping +from dataclasses import dataclass +from datetime import UTC, datetime +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from typing import override +from urllib.parse import parse_qs, urlencode + +import pytest +from slack_sdk.errors import SlackClientError +from slack_sdk.signature import SignatureVerifier +from slack_sdk.socket_mode.request import SocketModeRequest +from slack_sdk.web import WebClient + +from core.human_input import ButtonStyle +from core.human_input_v2.entities import IMProvider +from core.human_input_v2.im_integration.adapters import ( + AuthenticatedIMEvent, + CorrelationToken, + CredentialTestFailure, + CredentialTestFailureKind, + CredentialTestSuccess, + Directory, + DirectoryReadFailure, + DynamicCardMessagingError, + EventAcceptance, + IMStreamStartError, + IMStreamStopError, + MessageAccepted, + MessageLocator, + MessageSendingError, + ProviderUserId, + ReplacementError, + ReplacementErrorKind, + SlackCredentials, + StaticCardIntent, + WebhookRequest, +) +from core.human_input_v2.im_integration.adapters import slack as slack_adapter_module +from core.human_input_v2.im_integration.adapters.entities import IMProviderError +from core.human_input_v2.im_integration.adapters.slack import SlackIMProviderAdapter +from core.human_input_v2.resolved_form import ( + FileInput, + FormPart, + MarkdownFragment, + ParagraphInput, + ResolvedForm, + SelectInput, + UserAction, +) + +_SIGNING_SECRET = "sanitized-signing-material" +_RECEIVED_AT = datetime(2026, 8, 6, 8) + + +@dataclass(frozen=True, slots=True) +class _QueuedResponse: + body: Mapping[str, object] + status: int = 200 + headers: tuple[tuple[str, str], ...] = () + + +@dataclass(frozen=True, slots=True) +class _RecordedRequest: + api_method: str + headers: Mapping[str, str] + body: bytes + + def parameters(self) -> dict[str, object]: + content_type = self.headers.get("Content-Type", "") + if content_type.startswith("application/json"): + decoded = json.loads(self.body) + assert isinstance(decoded, dict) + return decoded + return { + name: values[0] if len(values) == 1 else values + for name, values in parse_qs(self.body.decode(), keep_blank_values=True).items() + } + + +class _SlackApiState: + def __init__(self) -> None: + self._lock = threading.Lock() + self._responses: defaultdict[str, deque[_QueuedResponse]] = defaultdict(deque) + self.requests: list[_RecordedRequest] = [] + + def enqueue( + self, + api_method: str, + body: Mapping[str, object], + *, + status: int = 200, + headers: tuple[tuple[str, str], ...] = (), + ) -> None: + with self._lock: + self._responses[api_method].append(_QueuedResponse(body, status, headers)) + + def respond(self, request: _RecordedRequest) -> _QueuedResponse: + with self._lock: + self.requests.append(request) + responses = self._responses[request.api_method] + if not responses: + return _QueuedResponse({"ok": False, "error": "unconfigured_test_response"}, status=500) + return responses.popleft() + + def requests_for(self, api_method: str) -> list[_RecordedRequest]: + with self._lock: + return [request for request in self.requests if request.api_method == api_method] + + +class _SlackApiHTTPServer(ThreadingHTTPServer): + state: _SlackApiState + + +class _SlackApiHandler(BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + + def do_POST(self) -> None: + assert isinstance(self.server, _SlackApiHTTPServer) + content_length = int(self.headers.get("Content-Length", "0")) + request_body = self.rfile.read(content_length) + api_method = self.path.removeprefix("/api/") + recorded = _RecordedRequest(api_method, dict(self.headers.items()), request_body) + response = self.server.state.respond(recorded) + response_body = json.dumps(response.body, separators=(",", ":")).encode() + self.send_response(response.status) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Content-Length", str(len(response_body))) + self.send_header("Connection", "close") + for name, value in response.headers: + self.send_header(name, value) + self.end_headers() + self.wfile.write(response_body) + + @override + def log_message(self, format: str, *args: object) -> None: + del format, args + + +@dataclass(frozen=True, slots=True) +class _SlackApiServer: + server: _SlackApiHTTPServer + state: _SlackApiState + + @property + def base_url(self) -> str: + address = self.server.server_address + host = str(address[0]) + port = address[1] + return f"http://{host}:{port}/api/" + + +@pytest.fixture +def slack_api_server() -> Generator[_SlackApiServer]: + state = _SlackApiState() + server = _SlackApiHTTPServer(("127.0.0.1", 0), _SlackApiHandler) + server.state = state + thread = threading.Thread(target=server.serve_forever) + thread.start() + try: + yield _SlackApiServer(server, state) + finally: + server.shutdown() + server.server_close() + thread.join(2) + assert not thread.is_alive() + + +class _RecordingConsumer: + def __init__(self, acceptance: EventAcceptance = EventAcceptance.ACCEPTED) -> None: + self.acceptance = acceptance + self.events: list[AuthenticatedIMEvent] = [] + + def accept(self, event: AuthenticatedIMEvent, *, request_ip_address: str | None = None) -> EventAcceptance: + del request_ip_address + self.events.append(event) + return self.acceptance + + +def _credentials() -> SlackCredentials: + return SlackCredentials( + provider=IMProvider.SLACK, + client_id="sanitized-client-id", + client_secret="sanitized-client-secret", + signing_secret=_SIGNING_SECRET, + bot_token="xoxb-sanitized-placeholder", + app_token="xapp-sanitized-placeholder", + ) + + +def _intent(*, input_type: str = "select") -> ResolvedForm: + if input_type == "select": + input_block = SelectInput( + output_variable_name="decision", options=("Approve", "Reject"), default_value="Approve" + ) + else: + input_block = ParagraphInput(output_variable_name="decision", default_value="Sanitized initial value") + return ResolvedForm( + title="Sanitized title", + parts=( + MarkdownFragment(text="Sanitized rendered content"), + input_block, + MarkdownFragment(text="Sanitized trailing content"), + ), + actions=( + UserAction(id="approve", title="Approve", button_style=ButtonStyle.PRIMARY), + UserAction(id="reject", title="Reject", button_style=ButtonStyle.ACCENT), + ), + legacy_form_content="This value must not be rendered", + ) + + +def _custom_intent( + *, + blocks: tuple[FormPart, ...] = (MarkdownFragment(text="Sanitized rendered content"),), + actions: tuple[UserAction, ...] | None = None, + title: str = "Sanitized title", +) -> ResolvedForm: + if actions is None: + actions = (UserAction(id="approve", title="Approve", button_style=ButtonStyle.PRIMARY),) + return ResolvedForm( + title=title, + parts=blocks, + actions=actions, + legacy_form_content="This value must not be rendered", + ) + + +def _adapter(monkeypatch: pytest.MonkeyPatch, server: _SlackApiServer) -> tuple[SlackIMProviderAdapter, WebClient]: + client = WebClient( + token="xoxb-sanitized-placeholder", + base_url=server.base_url, + timeout=2, + retry_handlers=[], + ) + + def _client_factory(**kwargs: object) -> WebClient: + del kwargs + return client + + monkeypatch.setattr(slack_adapter_module, "WebClient", _client_factory) + return SlackIMProviderAdapter(_credentials()), client + + +def _signed_request( + body: bytes, + *, + method: str = "POST", + valid_signature: bool = True, + content_type: str = "application/json", +) -> WebhookRequest: + timestamp = str(int(_RECEIVED_AT.replace(tzinfo=UTC).timestamp())) + signature = SignatureVerifier(_SIGNING_SECRET).generate_signature(timestamp=timestamp, body=body) + assert signature is not None + if not valid_signature: + signature = "v0=invalid" + return WebhookRequest( + method=method, + headers=( + ("X-Slack-Request-Timestamp", timestamp), + ("X-Slack-Signature", signature), + ("Content-Type", content_type), + ), + body=body, + received_at=_RECEIVED_AT, + ) + + +def test_real_web_client_round_trips_credentials_directory_messages_and_cards( + monkeypatch: pytest.MonkeyPatch, + slack_api_server: _SlackApiServer, +) -> None: + state = slack_api_server.state + state.enqueue( + "auth.test", + {"ok": True, "team_id": "sanitized-team"}, + headers=(("X-OAuth-Scopes", "chat:write,users:read,users:read.email"),), + ) + state.enqueue("apps.connections.open", {"ok": True, "url": "wss://sanitized.invalid/socket"}) + state.enqueue( + "users.list", + { + "ok": True, + "members": [ + { + "id": "sanitized-user-1", + "profile": {"display_name_normalized": "First", "email": "first@example.com"}, + }, + {"id": "sanitized-bot", "is_bot": True, "profile": {}}, + ], + "response_metadata": {"next_cursor": "sanitized-cursor"}, + }, + ) + state.enqueue( + "users.list", + { + "ok": True, + "members": [{"id": "sanitized-user-2", "profile": {"real_name_normalized": "Second"}}], + "response_metadata": {"next_cursor": ""}, + }, + ) + state.enqueue("chat.postMessage", {"ok": True, "channel": "sanitized-channel", "ts": "1000.000001"}) + state.enqueue("chat.postMessage", {"ok": True, "channel": "sanitized-channel", "ts": "1000.000002"}) + state.enqueue("chat.update", {"ok": True, "channel": "sanitized-channel", "ts": "1000.000002"}) + adapter, client = _adapter(monkeypatch, slack_api_server) + card_intent = _intent() + + credential_result = adapter.test_credentials() + directory_result = adapter.directory.read_directory() + text_result = adapter.messaging.send_text(ProviderUserId("sanitized-user-1"), "Sanitized text") + card_result = adapter.dynamic_card_messaging.send_card( + ProviderUserId("sanitized-user-2"), + card_intent, + CorrelationToken("sanitized-correlation"), + ) + assert isinstance(card_result, MessageAccepted) + replacement_result = adapter.dynamic_card_messaging.replace_with_static( + card_result.locator, + StaticCardIntent("Sanitized static content"), + ) + + assert isinstance(client, WebClient) + assert credential_result == CredentialTestSuccess(IMProvider.SLACK, "sanitized-team") + assert isinstance(directory_result, Directory) + assert [entry.provider_user_id for entry in directory_result.entries] == [ + "sanitized-user-1", + "sanitized-user-2", + ] + assert isinstance(text_result, MessageAccepted) + assert replacement_result is None + assert len(state.requests_for("auth.test")) == 1 + assert state.requests_for("users.list")[1].parameters()["cursor"] == "sanitized-cursor" + text_parameters = state.requests_for("chat.postMessage")[0].parameters() + assert text_parameters["channel"] == "sanitized-user-1" + assert text_parameters["markdown_text"] == "Sanitized text" + card_parameters = state.requests_for("chat.postMessage")[1].parameters() + assert set(card_parameters) == {"blocks", "channel"} + assert card_parameters["channel"] == "sanitized-user-2" + assert "text" not in card_parameters + assert "markdown_text" not in card_parameters + blocks = card_parameters["blocks"] + if isinstance(blocks, str): + blocks = json.loads(blocks) + assert isinstance(blocks, list) + assert [block["type"] for block in blocks] == ["header", "markdown", "input", "markdown", "actions"] + update_parameters = state.requests_for("chat.update")[0].parameters() + assert set(update_parameters) == {"blocks", "channel", "text", "ts"} + assert update_parameters["channel"] == "sanitized-channel" + assert update_parameters["ts"] == "1000.000002" + assert update_parameters["text"] == "Sanitized static content" + assert update_parameters["blocks"] == [{"type": "markdown", "text": "Sanitized static content"}] + assert "markdown_text" not in update_parameters + + +@pytest.mark.parametrize( + ("subject", "heading"), + [ + ("### HITL v2 Migration Check", r"# \#\#\# HITL v2 Migration Check"), + ("**Review** _Alice_", r"# \*\*Review\*\* \_Alice\_"), + ("[Review](https://example.com)", r"# \[Review\]\(https\:\/\/example\.com\)"), + ], +) +def test_real_web_client_sends_plain_subject_separately_from_markdown_body( + monkeypatch: pytest.MonkeyPatch, + slack_api_server: _SlackApiServer, + subject: str, + heading: str, +) -> None: + slack_api_server.state.enqueue("chat.postMessage", {"ok": True, "channel": "D0123456789", "ts": "1000.000001"}) + adapter, _ = _adapter(monkeypatch, slack_api_server) + body = "# H1\n\n## H2\n\n[Approval](https://example.com/human-input-test)" + + result = adapter.messaging.send_text(ProviderUserId("sanitized-user"), body, subject=subject) + + assert isinstance(result, MessageAccepted) + requests = slack_api_server.state.requests_for("chat.postMessage") + assert len(requests) == 1 + parameters = requests[0].parameters() + assert parameters["channel"] == "sanitized-user" + assert parameters["blocks"] == [ + {"type": "markdown", "text": heading}, + {"type": "markdown", "text": body}, + ] + assert parameters["text"] == f"{subject}\n\n{body}" + + +@pytest.mark.parametrize( + ("content", "expected_blocks"), + [ + ( + "# Hello\n\n**Name**\nAccepted\n\n[Details](https://example.com)\n\nSubmitted: approve", + [ + { + "type": "markdown", + "text": "# Hello\n\n**Name**\nAccepted\n\n[Details](https://example.com)\n\nSubmitted: approve", + } + ], + ), + ("x" * 12_000, [{"type": "markdown", "text": "x" * 12_000}]), + ("x" * 12_001, []), + ], + ids=("commonmark", "markdown-limit", "oversized-text-fallback"), +) +def test_real_web_client_static_replacement_preserves_content_without_interactive_blocks( + monkeypatch: pytest.MonkeyPatch, + slack_api_server: _SlackApiServer, + content: str, + expected_blocks: list[dict[str, str]], +) -> None: + state = slack_api_server.state + state.enqueue("chat.postMessage", {"ok": True, "channel": "D0123456789", "ts": "1712345678.123456"}) + state.enqueue("chat.update", {"ok": True}) + adapter, _ = _adapter(monkeypatch, slack_api_server) + accepted = adapter.dynamic_card_messaging.send_card( + ProviderUserId("U0123456789"), + _intent(), + CorrelationToken("sanitized-correlation"), + ) + assert isinstance(accepted, MessageAccepted) + replacement = adapter.dynamic_card_messaging.replace_with_static(accepted.locator, StaticCardIntent(content)) + + assert replacement is None + updates = state.requests_for("chat.update") + assert len(updates) == 1 + parameters = updates[0].parameters() + assert parameters["channel"] == "D0123456789" + assert parameters["ts"] == "1712345678.123456" + assert parameters["text"] == content + assert parameters["blocks"] == expected_blocks + + +def test_real_web_client_reuses_card_reference_across_in_process_adapter_instances( + monkeypatch: pytest.MonkeyPatch, + slack_api_server: _SlackApiServer, +) -> None: + state = slack_api_server.state + state.enqueue("chat.postMessage", {"ok": True, "channel": "D0123456789", "ts": "1712345678.123456"}) + state.enqueue("chat.update", {"ok": True, "channel": "D0123456789", "ts": "1712345678.123456"}) + first_adapter, _ = _adapter(monkeypatch, slack_api_server) + accepted = first_adapter.dynamic_card_messaging.send_card( + ProviderUserId("U0123456789"), + _intent(), + CorrelationToken("sanitized-correlation"), + ) + assert isinstance(accepted, MessageAccepted) + first_adapter.close() + second_adapter, _ = _adapter(monkeypatch, slack_api_server) + + replacement = second_adapter.dynamic_card_messaging.replace_with_static( + accepted.locator, + StaticCardIntent("Sanitized static content"), + ) + + assert replacement is None + assert state.requests_for("auth.test") == [] + assert len(state.requests_for("chat.update")) == 1 + + +def test_real_adapter_rejects_foreign_reference_before_http( + monkeypatch: pytest.MonkeyPatch, + slack_api_server: _SlackApiServer, +) -> None: + adapter, _ = _adapter(monkeypatch, slack_api_server) + result = adapter.dynamic_card_messaging.replace_with_static( + MessageLocator("invalid."), + StaticCardIntent("Sanitized static content"), + ) + + assert isinstance(result, ReplacementError) + assert result.kind is ReplacementErrorKind.INVALID_REFERENCE + + assert slack_api_server.state.requests == [] + + +def test_real_adapter_rejects_text_reference_without_update( + monkeypatch: pytest.MonkeyPatch, + slack_api_server: _SlackApiServer, +) -> None: + state = slack_api_server.state + state.enqueue("chat.postMessage", {"ok": True, "channel": "sanitized-channel", "ts": "1000.000001"}) + state.enqueue("chat.update", {"ok": True, "channel": "sanitized-channel", "ts": "1000.000001"}) + adapter, _ = _adapter(monkeypatch, slack_api_server) + accepted = adapter.messaging.send_text(ProviderUserId("sanitized-user"), "Sanitized text") + assert isinstance(accepted, MessageAccepted) + + result = adapter.dynamic_card_messaging.replace_with_static( + accepted.locator, + StaticCardIntent("Sanitized static content"), + ) + + assert result is None + assert state.requests_for("auth.test") == [] + assert len(state.requests_for("chat.update")) == 1 + + +def test_repeated_credential_tests_reject_a_previously_valid_token( + monkeypatch: pytest.MonkeyPatch, + slack_api_server: _SlackApiServer, +) -> None: + state = slack_api_server.state + state.enqueue( + "auth.test", + {"ok": True, "team_id": "sanitized-team"}, + headers=(("X-OAuth-Scopes", "chat:write,users:read,users:read.email"),), + ) + state.enqueue("apps.connections.open", {"ok": True, "url": "wss://sanitized.invalid/socket"}) + state.enqueue("auth.test", {"ok": False, "error": "token_revoked"}) + adapter, _ = _adapter(monkeypatch, slack_api_server) + + assert adapter.test_credentials() == CredentialTestSuccess(IMProvider.SLACK, "sanitized-team") + rejected = adapter.test_credentials() + + assert isinstance(rejected, CredentialTestFailure) + assert rejected.kind is CredentialTestFailureKind.AUTHENTICATION_REJECTED + assert rejected.provider_error_code == "token_revoked" + + +@pytest.mark.parametrize( + ("auth_body", "auth_headers", "expected_kind"), + [ + ({"ok": False, "error": "invalid_auth"}, (), CredentialTestFailureKind.AUTHENTICATION_REJECTED), + ( + {"ok": True}, + (("X-OAuth-Scopes", "chat:write,users:read,users:read.email"),), + CredentialTestFailureKind.TENANT_ID_UNAVAILABLE, + ), + ( + {"ok": True, "team_id": "sanitized-team"}, + (("X-OAuth-Scopes", "chat:write"),), + CredentialTestFailureKind.UNKNOWN, + ), + ], + ids=("authentication-rejected", "missing-tenant", "missing-scopes"), +) +def test_real_web_client_credential_failures_are_typed_and_sanitized( + monkeypatch: pytest.MonkeyPatch, + slack_api_server: _SlackApiServer, + auth_body: Mapping[str, object], + auth_headers: tuple[tuple[str, str], ...], + expected_kind: CredentialTestFailureKind, +) -> None: + slack_api_server.state.enqueue("auth.test", auth_body, headers=auth_headers) + adapter, _ = _adapter(monkeypatch, slack_api_server) + + if expected_kind is CredentialTestFailureKind.TENANT_ID_UNAVAILABLE: + with pytest.raises(IMProviderError): + adapter.test_credentials() + return + if auth_body.get("ok") is True: + if not auth_headers: + with pytest.raises(IMProviderError): + adapter.test_credentials() + return + expected_kind = CredentialTestFailureKind.PERMISSION_DENIED + result = adapter.test_credentials() + + assert isinstance(result, CredentialTestFailure) + assert result.kind is expected_kind + assert "invalid_auth" not in result.reason + + +@pytest.mark.parametrize( + ("connection_body", "expected_kind"), + [ + ({"ok": False, "error": "invalid_auth"}, CredentialTestFailureKind.AUTHENTICATION_REJECTED), + ({"ok": False, "error": "internal_error"}, CredentialTestFailureKind.UNKNOWN), + ({"ok": True, "url": ""}, CredentialTestFailureKind.UNKNOWN), + ], + ids=("socket-authentication", "socket-provider", "socket-url"), +) +def test_real_web_client_socket_credential_failures_are_typed( + monkeypatch: pytest.MonkeyPatch, + slack_api_server: _SlackApiServer, + connection_body: Mapping[str, object], + expected_kind: CredentialTestFailureKind, +) -> None: + slack_api_server.state.enqueue( + "auth.test", + {"ok": True, "team_id": "sanitized-team"}, + headers=(("X-OAuth-Scopes", "chat:write,users:read,users:read.email"),), + ) + slack_api_server.state.enqueue("apps.connections.open", connection_body) + adapter, _ = _adapter(monkeypatch, slack_api_server) + + if connection_body.get("ok") is True: + with pytest.raises(IMProviderError): + adapter.test_credentials() + return + result = adapter.test_credentials() + + assert isinstance(result, CredentialTestFailure) + assert result.kind is expected_kind + + +def test_real_web_client_provider_failures_do_not_return_partial_success( + monkeypatch: pytest.MonkeyPatch, + slack_api_server: _SlackApiServer, +) -> None: + state = slack_api_server.state + state.enqueue( + "users.list", + { + "ok": True, + "members": [{"id": "sanitized-user", "profile": {}}], + "response_metadata": {"next_cursor": "sanitized-cursor"}, + }, + ) + state.enqueue("users.list", {"ok": False, "error": "ratelimited"}) + state.enqueue( + "auth.test", + {"ok": True, "team_id": "sanitized-team"}, + headers=(("X-OAuth-Scopes", "chat:write,users:read,users:read.email"),), + ) + state.enqueue("chat.postMessage", {"ok": False, "error": "internal_error"}) + state.enqueue("chat.postMessage", {"ok": False, "error": "internal_error"}) + adapter, _ = _adapter(monkeypatch, slack_api_server) + + directory_result = adapter.directory.read_directory() + message_result = adapter.messaging.send_text(ProviderUserId("sanitized-user"), "Sanitized text") + card_result = adapter.dynamic_card_messaging.send_card( + ProviderUserId("sanitized-user"), + _intent(), + CorrelationToken("sanitized-correlation"), + ) + + assert isinstance(directory_result, DirectoryReadFailure) + assert not hasattr(directory_result, "entries") + assert "sanitized-user" not in directory_result.reason + assert isinstance(message_result, MessageSendingError) + assert isinstance(card_result, MessageSendingError) + assert len(state.requests_for("users.list")) == 2 + assert len(state.requests_for("chat.postMessage")) == 2 + + +@pytest.mark.parametrize( + "response_body", + [ + { + "ok": True, + "members": [{"id": "sanitized-user", "profile": {}}], + }, + { + "ok": True, + "members": [{"id": "sanitized-user", "profile": {}}], + "response_metadata": "malformed-metadata", + }, + { + "ok": True, + "members": [{"id": "sanitized-user", "profile": {}}], + "response_metadata": {}, + }, + { + "ok": True, + "members": [{"id": "sanitized-user", "profile": {}}], + "response_metadata": {"next_cursor": None}, + }, + { + "ok": True, + "members": [{"id": "sanitized-user", "profile": {}}], + "response_metadata": {"next_cursor": 1}, + }, + ], + ids=( + "missing-metadata", + "malformed-metadata", + "missing-cursor", + "null-cursor", + "non-string-cursor", + ), +) +def test_real_web_client_rejects_invalid_directory_pagination_without_partial_snapshot( + monkeypatch: pytest.MonkeyPatch, + slack_api_server: _SlackApiServer, + response_body: Mapping[str, object], +) -> None: + slack_api_server.state.enqueue("users.list", response_body) + adapter, _ = _adapter(monkeypatch, slack_api_server) + + result = adapter.directory.read_directory() + + assert isinstance(result, DirectoryReadFailure) + assert not hasattr(result, "entries") + assert "sanitized-user" not in result.reason + assert len(slack_api_server.state.requests_for("users.list")) == 1 + + +@pytest.mark.parametrize( + "second_page_body", + [ + { + "ok": True, + "members": [{"id": "sanitized-user-2", "profile": {}}], + "response_metadata": "malformed-metadata", + }, + { + "ok": True, + "members": [{"id": "sanitized-user-2", "profile": {}}], + "response_metadata": {"next_cursor": "sanitized-cursor"}, + }, + ], + ids=("malformed-pagination", "repeated-cursor"), +) +def test_real_web_client_later_pagination_failure_discards_accumulated_entries( + monkeypatch: pytest.MonkeyPatch, + slack_api_server: _SlackApiServer, + second_page_body: Mapping[str, object], +) -> None: + state = slack_api_server.state + state.enqueue( + "users.list", + { + "ok": True, + "members": [{"id": "sanitized-user-1", "profile": {}}], + "response_metadata": {"next_cursor": "sanitized-cursor"}, + }, + ) + state.enqueue("users.list", second_page_body) + adapter, _ = _adapter(monkeypatch, slack_api_server) + + result = adapter.directory.read_directory() + + assert isinstance(result, DirectoryReadFailure) + assert not hasattr(result, "entries") + assert "sanitized-user-1" not in result.reason + assert "sanitized-user-2" not in result.reason + requests = state.requests_for("users.list") + assert len(requests) == 2 + assert requests[1].parameters()["cursor"] == "sanitized-cursor" + + +@pytest.mark.parametrize( + "intent", + [ + _custom_intent(blocks=(), actions=(), title=""), + _custom_intent(blocks=(MarkdownFragment(text=""),)), + _custom_intent(blocks=(MarkdownFragment(text="x" * 20_000),)), + _custom_intent(title="x" * 151), + _custom_intent( + blocks=tuple( + ParagraphInput(output_variable_name=f"input_{index}", default_value=None) for index in range(49) + ) + ), + _custom_intent( + actions=tuple( + UserAction(id=f"action_{index}", title=f"Action {index}", button_style=ButtonStyle.DEFAULT) + for index in range(26) + ) + ), + _custom_intent( + blocks=( + ParagraphInput(output_variable_name="duplicate", default_value=None), + ParagraphInput(output_variable_name="duplicate", default_value=None), + ) + ), + _custom_intent(actions=(UserAction(id="x" * 256, title="Action", button_style=ButtonStyle.DEFAULT),)), + _custom_intent(actions=(UserAction(id="action", title="Action", button_style=ButtonStyle.GHOST),)), + _custom_intent( + blocks=( + FileInput( + output_variable_name="attachment", + allowed_file_types=(), + allowed_file_extensions=(), + allowed_file_upload_methods=(), + ), + ) + ), + _custom_intent(blocks=(ParagraphInput(output_variable_name="x" * 256, default_value=None),)), + _custom_intent(blocks=(ParagraphInput(output_variable_name="input", default_value="x" * 3_001),)), + _custom_intent(blocks=(SelectInput(output_variable_name="input", options=(), default_value=None),)), + _custom_intent(blocks=(SelectInput(output_variable_name="input", options=("",), default_value=None),)), + ], + ids=( + "empty-card", + "empty-markdown", + "oversized-markdown", + "oversized-title", + "block-count", + "action-count", + "duplicate-input", + "action-identifier", + "action-style", + "file-input", + "input-identifier", + "paragraph-default", + "select-count", + "select-option", + ), +) +def test_card_assessment_rejects_sdk_representation_boundaries_without_http( + monkeypatch: pytest.MonkeyPatch, + slack_api_server: _SlackApiServer, + intent: ResolvedForm, +) -> None: + adapter, _ = _adapter(monkeypatch, slack_api_server) + + assessment = adapter.dynamic_card_messaging.assess(intent) + + assert assessment.representable is False + with pytest.raises(DynamicCardMessagingError): + adapter.dynamic_card_messaging.send_card( + ProviderUserId("sanitized-user"), + intent, + CorrelationToken("sanitized-correlation"), + ) + assert slack_api_server.state.requests == [] + + +def test_real_paragraph_resolved_default_survives_sdk_serialization( + monkeypatch: pytest.MonkeyPatch, + slack_api_server: _SlackApiServer, +) -> None: + state = slack_api_server.state + state.enqueue( + "auth.test", + {"ok": True, "team_id": "sanitized-team"}, + headers=(("X-OAuth-Scopes", "chat:write,users:read,users:read.email"),), + ) + state.enqueue("chat.postMessage", {"ok": True, "channel": "sanitized-channel", "ts": "1000.000001"}) + intent = _custom_intent( + blocks=(ParagraphInput(output_variable_name="comment", default_value="Sanitized preserved default"),), + actions=(), + title="", + ) + adapter, _ = _adapter(monkeypatch, slack_api_server) + + assessment = adapter.dynamic_card_messaging.assess(intent) + result = adapter.dynamic_card_messaging.send_card( + ProviderUserId("sanitized-user"), + intent, + CorrelationToken("sanitized-correlation"), + ) + + assert assessment.representable is True + assert isinstance(result, MessageAccepted) + blocks = state.requests_for("chat.postMessage")[0].parameters()["blocks"] + if isinstance(blocks, str): + blocks = json.loads(blocks) + assert isinstance(blocks, list) + input_element = next(block["element"] for block in blocks if block["type"] == "input") + assert input_element["initial_value"] == "Sanitized preserved default" + + +def test_real_web_client_renders_optional_card_sections( + monkeypatch: pytest.MonkeyPatch, + slack_api_server: _SlackApiServer, +) -> None: + state = slack_api_server.state + state.enqueue( + "auth.test", + {"ok": True, "team_id": "sanitized-team"}, + headers=(("X-OAuth-Scopes", "chat:write,users:read,users:read.email"),), + ) + state.enqueue("chat.postMessage", {"ok": True, "channel": "sanitized-channel", "ts": "1000.000001"}) + state.enqueue("chat.postMessage", {"ok": True, "channel": "sanitized-channel", "ts": "1000.000002"}) + adapter, _ = _adapter(monkeypatch, slack_api_server) + paragraph_intent = _custom_intent( + blocks=(ParagraphInput(output_variable_name="comment", default_value=None),), + actions=(), + title="", + ) + default_action_intent = _custom_intent( + actions=(UserAction(id="continue", title="Continue", button_style=ButtonStyle.DEFAULT),), + title="", + ) + + paragraph_result = adapter.dynamic_card_messaging.send_card( + ProviderUserId("sanitized-user"), + paragraph_intent, + CorrelationToken("sanitized-correlation"), + ) + default_action_result = adapter.dynamic_card_messaging.send_card( + ProviderUserId("sanitized-user"), + default_action_intent, + CorrelationToken("sanitized-correlation"), + ) + + assert isinstance(paragraph_result, MessageAccepted) + assert isinstance(default_action_result, MessageAccepted) + first_blocks = state.requests_for("chat.postMessage")[0].parameters()["blocks"] + second_blocks = state.requests_for("chat.postMessage")[1].parameters()["blocks"] + if isinstance(first_blocks, str): + first_blocks = json.loads(first_blocks) + if isinstance(second_blocks, str): + second_blocks = json.loads(second_blocks) + assert isinstance(first_blocks, list) + assert isinstance(second_blocks, list) + paragraph_element = next(block["element"] for block in first_blocks if block["type"] == "input") + assert "initial_value" not in paragraph_element + assert all(block["type"] != "header" for block in first_blocks) + assert all(block["type"] != "actions" for block in first_blocks) + default_action = next(block["elements"][0] for block in second_blocks if block["type"] == "actions") + assert "style" not in default_action + + +def test_card_rejects_oversized_serialized_correlation_before_http( + monkeypatch: pytest.MonkeyPatch, + slack_api_server: _SlackApiServer, +) -> None: + adapter, _ = _adapter(monkeypatch, slack_api_server) + + with pytest.raises(DynamicCardMessagingError, match="correlation token"): + adapter.dynamic_card_messaging.send_card( + ProviderUserId("sanitized-user"), + _custom_intent(), + CorrelationToken("x" * 2_100), + ) + + assert slack_api_server.state.requests == [] + + +@pytest.mark.parametrize( + ("provider_error", "expected_kind"), + [ + ("message_not_found", ReplacementErrorKind.STALE_REFERENCE), + ("internal_error", ReplacementErrorKind.UNKNOWN), + ], + ids=("stale", "unknown"), +) +def test_real_web_client_replacement_failures_are_typed( + monkeypatch: pytest.MonkeyPatch, + slack_api_server: _SlackApiServer, + provider_error: str, + expected_kind: ReplacementErrorKind, +) -> None: + state = slack_api_server.state + state.enqueue( + "auth.test", + {"ok": True, "team_id": "sanitized-team"}, + headers=(("X-OAuth-Scopes", "chat:write,users:read,users:read.email"),), + ) + state.enqueue("chat.postMessage", {"ok": True, "channel": "sanitized-channel", "ts": "1000.000001"}) + state.enqueue("chat.update", {"ok": False, "error": provider_error}) + adapter, _ = _adapter(monkeypatch, slack_api_server) + accepted = adapter.dynamic_card_messaging.send_card( + ProviderUserId("sanitized-user"), + _intent(), + CorrelationToken("sanitized-correlation"), + ) + assert isinstance(accepted, MessageAccepted) + + result = adapter.dynamic_card_messaging.replace_with_static( + accepted.locator, + StaticCardIntent("Sanitized static content"), + ) + + assert isinstance(result, ReplacementError) + assert result.kind is expected_kind + assert provider_error not in result.reason + assert len(state.requests_for("chat.update")) == 1 + + +def test_real_signature_verifier_authenticates_json_and_form_webhooks( + monkeypatch: pytest.MonkeyPatch, + slack_api_server: _SlackApiServer, +) -> None: + adapter, _ = _adapter(monkeypatch, slack_api_server) + consumer = _RecordingConsumer() + handler = adapter.create_webhook_handler(consumer) + challenge_body = json.dumps({"type": "url_verification", "challenge": "sanitized-challenge"}).encode() + event_body = json.dumps( + { + "type": "event_callback", + "team_id": "sanitized-team", + "event_id": "sanitized-event", + "event_time": 1e300, + "event": {"type": "message", "text": "Sanitized text"}, + }, + separators=(",", ":"), + ).encode() + form_body = urlencode( + { + "payload": json.dumps( + { + "type": "block_actions", + "payload": {"team_id": "sanitized-team"}, + "actions": [], + }, + separators=(",", ":"), + ) + } + ).encode() + + challenge_response = handler.handle(_signed_request(challenge_body)) + event_response = handler.handle(_signed_request(event_body, content_type="Application/JSON; charset=utf-8")) + form_response = handler.handle( + _signed_request(form_body, content_type="application/x-www-form-urlencoded; charset=utf-8") + ) + + assert challenge_response.status_code == 200 + assert json.loads(challenge_response.body) == {"challenge": "sanitized-challenge"} + assert event_response.status_code == 200 + assert form_response.status_code == 200 + assert [event.provider_tenant_id for event in consumer.events] == ["sanitized-team", "sanitized-team"] + assert consumer.events[0].occurred_at is None + assert json.loads(consumer.events[1].payload)["payload"]["team_id"] == "sanitized-team" + + +@pytest.mark.parametrize( + ("webhook_request", "expected_status"), + [ + (_signed_request(b"not-json"), 400), + ( + _signed_request( + json.dumps( + {"type": "event_callback", "team_id": "sanitized-team", "event": {"type": "message"}} + ).encode(), + content_type="text/plain", + ), + 400, + ), + (_signed_request(json.dumps({"type": "url_verification", "challenge": 1}).encode()), 400), + (_signed_request(json.dumps({"type": "event_callback", "event": {"type": "message"}}).encode()), 400), + ( + _signed_request( + json.dumps( + {"type": "event_callback", "team_id": "sanitized-team", "event": {"type": "message"}} + ).encode(), + method="GET", + ), + 405, + ), + ( + _signed_request( + json.dumps( + {"type": "event_callback", "team_id": "sanitized-team", "event": {"type": "message"}} + ).encode(), + valid_signature=False, + ), + 401, + ), + ], + ids=("malformed", "media-type", "challenge", "tenant", "method", "signature"), +) +def test_webhook_rejects_invalid_transport_and_payload_boundaries( + monkeypatch: pytest.MonkeyPatch, + slack_api_server: _SlackApiServer, + webhook_request: WebhookRequest, + expected_status: int, +) -> None: + adapter, _ = _adapter(monkeypatch, slack_api_server) + consumer = _RecordingConsumer() + + response = adapter.create_webhook_handler(consumer).handle(webhook_request) + + assert response.status_code == expected_status + assert consumer.events == [] + + +def test_webhook_only_acknowledges_consumer_acceptance( + monkeypatch: pytest.MonkeyPatch, + slack_api_server: _SlackApiServer, +) -> None: + adapter, _ = _adapter(monkeypatch, slack_api_server) + body = json.dumps({"type": "event_callback", "team_id": "sanitized-team", "event": {"type": "message"}}).encode() + rejected_consumer = _RecordingConsumer(EventAcceptance.NOT_ACCEPTED) + + class _FailingConsumer: + def accept(self, event: AuthenticatedIMEvent, *, request_ip_address: str | None = None) -> EventAcceptance: + del request_ip_address + del event + raise RuntimeError("sanitized consumer details") + + rejected_response = adapter.create_webhook_handler(rejected_consumer).handle(_signed_request(body)) + failed_response = adapter.create_webhook_handler(_FailingConsumer()).handle(_signed_request(body)) + + assert rejected_response.status_code == 503 + assert failed_response.status_code == 503 + assert len(rejected_consumer.events) == 1 + + +def test_real_socket_request_serialization_is_acked_only_after_acceptance( + monkeypatch: pytest.MonkeyPatch, + slack_api_server: _SlackApiServer, +) -> None: + control_request = SocketModeRequest(type="hello", envelope_id="sanitized-control", payload={}) + invalid_request = SocketModeRequest(type="events_api", envelope_id="sanitized-invalid", payload={}) + accepted_request = SocketModeRequest( + type="events_api", + envelope_id="sanitized-accepted", + payload={ + "team_id": "sanitized-team", + "event_id": "sanitized-event", + "event_time": 1786003200, + "event": {"type": "message", "text": "Sanitized text"}, + }, + ) + + class _SocketTransport: + instance: _SocketTransport | None = None + + def __init__(self, **kwargs: object) -> None: + self.kwargs = kwargs + self.socket_mode_request_listeners: list[Callable[[object, SocketModeRequest], None]] = [] + self.responses: list[object] = [] + self.closed = False + self.__class__.instance = self + + def connect(self) -> None: + listener = self.socket_mode_request_listeners[0] + listener(self, control_request) + listener(self, invalid_request) + listener(self, accepted_request) + + def send_socket_mode_response(self, response: object) -> None: + self.responses.append(response) + + def close(self) -> None: + self.closed = True + + monkeypatch.setattr(slack_adapter_module, "SocketModeClient", _SocketTransport) + adapter, _ = _adapter(monkeypatch, slack_api_server) + consumer = _RecordingConsumer() + stream = adapter.create_stream_handler(consumer) + assert stream is not None + + stream.start() + + transport = _SocketTransport.instance + assert transport is not None + assert transport.kwargs["app_token"] == "xapp-sanitized-placeholder" + assert transport.closed is False + assert len(transport.responses) == 1 + assert [event.event_id for event in consumer.events] == ["sanitized-event"] + assert json.loads(consumer.events[0].payload) == accepted_request.to_dict() + + stream.stop() + + assert transport.closed + + +@pytest.mark.parametrize("failure_stage", ["connect", "close"]) +def test_socket_transport_failures_are_normalized( + monkeypatch: pytest.MonkeyPatch, + slack_api_server: _SlackApiServer, + failure_stage: str, +) -> None: + class _FailingSocketTransport: + def __init__(self, **kwargs: object) -> None: + del kwargs + self.socket_mode_request_listeners: list[Callable[[object, SocketModeRequest], None]] = [] + + def connect(self) -> None: + if failure_stage == "connect": + raise SlackClientError("sanitized connection details") + + def close(self) -> None: + if failure_stage == "close": + raise SlackClientError("sanitized close details") + + monkeypatch.setattr(slack_adapter_module, "SocketModeClient", _FailingSocketTransport) + adapter, _ = _adapter(monkeypatch, slack_api_server) + stream = adapter.create_stream_handler(_RecordingConsumer()) + assert stream is not None + + if failure_stage == "connect": + with pytest.raises(IMStreamStartError): + stream.start() + stream.stop() + else: + stream.start() + with pytest.raises(IMStreamStopError): + stream.stop() + + +@pytest.mark.parametrize("disconnect_kind", ["error", "close"]) +def test_socket_sdk_disconnect_listeners_are_observable_and_safe( + monkeypatch: pytest.MonkeyPatch, + slack_api_server: _SlackApiServer, + disconnect_kind: str, + caplog: pytest.LogCaptureFixture, +) -> None: + sensitive_marker = f"sanitized-sensitive-remote-{disconnect_kind}" + request = SocketModeRequest( + type="events_api", + envelope_id="sanitized-business", + payload={"team_id": "sanitized-team", "event": {"type": "message"}}, + ) + consumer = _RecordingConsumer() + + class _ListenerSocketTransport: + instance: _ListenerSocketTransport | None = None + + def __init__( + self, + *, + on_error_listeners: list[Callable[[Exception], None]], + on_close_listeners: list[Callable[[int, str | None], None]], + **kwargs: object, + ) -> None: + del kwargs + self.on_error_listeners = on_error_listeners + self.on_close_listeners = on_close_listeners + self.socket_mode_request_listeners: list[Callable[[object, SocketModeRequest], None]] = [] + self.responses: list[object] = [] + self.closed = False + self.__class__.instance = self + + def connect(self) -> None: + return None + + def send_socket_mode_response(self, response: object) -> None: + self.responses.append(response) + + def close(self) -> None: + self.closed = True + + monkeypatch.setattr(slack_adapter_module, "SocketModeClient", _ListenerSocketTransport) + adapter, _ = _adapter(monkeypatch, slack_api_server) + stream = adapter.create_stream_handler(consumer) + assert stream is not None + + with caplog.at_level("ERROR", logger=slack_adapter_module.__name__): + stream.start() + transport = _ListenerSocketTransport.instance + assert transport is not None + if disconnect_kind == "error": + transport.on_error_listeners[0](RuntimeError(sensitive_marker)) + else: + transport.on_close_listeners[0](1006, sensitive_marker) + transport.socket_mode_request_listeners[0](transport, request) + stream.stop() + + transport = _ListenerSocketTransport.instance + assert transport is not None + assert transport.closed + assert len(transport.responses) == 1 + assert len(consumer.events) == 1 + assert sensitive_marker not in caplog.text + + +def test_socket_start_waits_for_transport_construction( + monkeypatch: pytest.MonkeyPatch, + slack_api_server: _SlackApiServer, +) -> None: + construction_started = threading.Event() + release_construction = threading.Event() + start_returned = threading.Event() + allow_stop = threading.Event() + lifecycle_errors: list[BaseException] = [] + + class _ConstructionBlockedSocketTransport: + instance: _ConstructionBlockedSocketTransport | None = None + + def __init__(self, **kwargs: object) -> None: + del kwargs + self.socket_mode_request_listeners: list[Callable[[object, SocketModeRequest], None]] = [] + self.connect_calls = 0 + self.closed = False + self.__class__.instance = self + construction_started.set() + assert release_construction.wait(2) + + def connect(self) -> None: + self.connect_calls += 1 + + def close(self) -> None: + self.closed = True + + monkeypatch.setattr(slack_adapter_module, "SocketModeClient", _ConstructionBlockedSocketTransport) + adapter, _ = _adapter(monkeypatch, slack_api_server) + stream = adapter.create_stream_handler(_RecordingConsumer()) + assert stream is not None + + def _manage_stream() -> None: + try: + stream.start() + start_returned.set() + assert allow_stop.wait(2) + stream.stop() + except BaseException as error: + lifecycle_errors.append(error) + + owner_thread = threading.Thread(target=_manage_stream) + owner_thread.start() + assert construction_started.wait(2) + assert start_returned.is_set() is False + release_construction.set() + assert start_returned.wait(2) + + transport = _ConstructionBlockedSocketTransport.instance + assert transport is not None + assert transport.connect_calls == 1 + assert transport.closed is False + + allow_stop.set() + owner_thread.join(2) + + assert not owner_thread.is_alive() + assert transport.closed + assert lifecycle_errors == [] + + +def test_socket_start_waits_for_connection_readiness_and_accepts_sdk_callback( + monkeypatch: pytest.MonkeyPatch, + slack_api_server: _SlackApiServer, +) -> None: + connect_started = threading.Event() + release_connect = threading.Event() + start_returned = threading.Event() + allow_stop = threading.Event() + lifecycle_errors: list[BaseException] = [] + consumer = _RecordingConsumer() + request = SocketModeRequest( + type="events_api", + envelope_id="sanitized-business", + payload={"team_id": "sanitized-team", "event": {"type": "message"}}, + ) + + class _SuccessfulSocketTransport: + instance: _SuccessfulSocketTransport | None = None + + def __init__(self, **kwargs: object) -> None: + del kwargs + self.socket_mode_request_listeners: list[Callable[[object, SocketModeRequest], None]] = [] + self.responses: list[object] = [] + self.connect_calls = 0 + self.closed = False + self.__class__.instance = self + + def connect(self) -> None: + self.connect_calls += 1 + connect_started.set() + assert release_connect.wait(2) + self.socket_mode_request_listeners[0](self, request) + + def send_socket_mode_response(self, response: object) -> None: + self.responses.append(response) + + def close(self) -> None: + self.closed = True + + monkeypatch.setattr(slack_adapter_module, "SocketModeClient", _SuccessfulSocketTransport) + adapter, _ = _adapter(monkeypatch, slack_api_server) + stream = adapter.create_stream_handler(consumer) + assert stream is not None + + def _manage_stream() -> None: + try: + stream.start() + start_returned.set() + assert allow_stop.wait(2) + stream.stop() + except BaseException as error: + lifecycle_errors.append(error) + + owner_thread = threading.Thread(target=_manage_stream) + owner_thread.start() + assert connect_started.wait(2) + assert start_returned.is_set() is False + release_connect.set() + assert start_returned.wait(2) + + transport = _SuccessfulSocketTransport.instance + assert transport is not None + assert transport.connect_calls == 1 + assert len(transport.responses) == 1 + assert len(consumer.events) == 1 + assert transport.closed is False + + allow_stop.set() + owner_thread.join(2) + + assert not owner_thread.is_alive() + assert transport.closed + assert lifecycle_errors == [] diff --git a/api/tests/unit_tests/core/human_input_v2/im_provider/test_wecom_adapter.py b/api/tests/unit_tests/core/human_input_v2/im_provider/test_wecom_adapter.py new file mode 100644 index 00000000000..d3dbf3351a1 --- /dev/null +++ b/api/tests/unit_tests/core/human_input_v2/im_provider/test_wecom_adapter.py @@ -0,0 +1,362 @@ +from __future__ import annotations + +import gc +import importlib +import weakref + +import httpx +import pytest +from pydantic import ValidationError +from wechatpy.enterprise import WeChatClient +from wechatpy.exceptions import WeChatClientException + +from core.human_input_v2.entities import IMProvider +from core.human_input_v2.im_integration.adapters import ( + CredentialTestFailure, + CredentialTestFailureKind, + CredentialTestSuccess, + EventAcceptance, + WeComCredentials, +) +from core.human_input_v2.im_integration.adapters.entities import IMProviderError + + +class _Consumer: + def __init__(self) -> None: + self.calls = 0 + + def accept(self, event: object, *, request_ip_address: str | None = None) -> EventAcceptance: + del request_ip_address + del event + self.calls += 1 + return EventAcceptance.ACCEPTED + + +class _FakeAgentAPI: + def __init__(self, *responses: object) -> None: + self.responses = list(responses) + self.calls: list[int] = [] + + def get(self, agent_id: int) -> object: + self.calls.append(agent_id) + response = self.responses.pop(0) + if isinstance(response, Exception): + raise response + return response + + +class _FakeClient: + def __init__(self, token_response: object, *agent_responses: object) -> None: + self.token_response = token_response + self.fetch_calls = 0 + self.agent = _FakeAgentAPI(*agent_responses) + + def fetch_access_token(self) -> object: + self.fetch_calls += 1 + if isinstance(self.token_response, Exception): + raise self.token_response + return self.token_response + + +def _credentials() -> WeComCredentials: + return WeComCredentials( + provider=IMProvider.WE_COM, + corp_id="fake-corp-001", + agent_id="1000001", + secret="fake-secret-001", + ) + + +def _token_response(*, token: str = "fake-access-token-001", expires_in: int = 7200) -> dict[str, object]: + return { + "errcode": 0, + "errmsg": "ok", + "access_token": token, + "expires_in": expires_in, + } + + +def _agent_response(*, agent_id: int = 1000001) -> dict[str, object]: + return { + "errcode": 0, + "errmsg": "ok", + "agentid": agent_id, + "allow_partys": {"partyid": [1]}, + } + + +def test_root_construction_capabilities_and_close_perform_no_provider_io( + monkeypatch: pytest.MonkeyPatch, +) -> None: + wecom_module = importlib.import_module("core.human_input_v2.im_integration.adapters.wecom") + adapter_type = wecom_module.WeComIMProviderAdapter + + monkeypatch.setattr( + wecom_module, + "_new_client", + lambda *_args, **_kwargs: pytest.fail("unexpected provider I/O"), + ) + + adapter = adapter_type(_credentials()) + directory = adapter.directory + messaging = adapter.messaging + consumer = _Consumer() + consumer_reference = weakref.ref(consumer) + + assert adapter.provider is IMProvider.WE_COM + assert adapter.directory is directory + assert adapter.messaging is messaging + assert adapter.dynamic_card_messaging is None + assert adapter.create_webhook_handler(consumer) is None + assert adapter.create_stream_handler(consumer) is None + assert consumer.calls == 0 + + del consumer + gc.collect() + assert consumer_reference() is None + assert adapter.close() is None + assert adapter.close() is None + + +def test_root_rejects_a_different_provider_credential_type() -> None: + wecom_module = importlib.import_module("core.human_input_v2.im_integration.adapters.wecom") + adapter_type = wecom_module.WeComIMProviderAdapter + + with pytest.raises(TypeError, match="resolved WeCom credentials"): + adapter_type(object()) + + +def test_public_sdk_client_factory_disables_automatic_retry_without_io() -> None: + wecom_module = importlib.import_module("core.human_input_v2.im_integration.adapters.wecom") + + client = wecom_module._new_client(_credentials(), access_token="fake-access-token-001") + + assert isinstance(client, WeChatClient) + assert client.corp_id == "fake-corp-001" + assert client.timeout == 5.0 + assert client.auto_retry is False + + +def test_credential_token_fetch_applies_a_bounded_network_timeout( + monkeypatch: pytest.MonkeyPatch, +) -> None: + wecom_module = importlib.import_module("core.human_input_v2.im_integration.adapters.wecom") + observed_requests: list[tuple[object, object]] = [] + + class TokenResponse: + def raise_for_status(self) -> None: + return None + + def json(self) -> dict[str, object]: + return { + "errcode": 0, + "access_token": " ", + "expires_in": 7200, + } + + def get( + url: httpx.URL | str, + **kwargs: object, + ) -> TokenResponse: + observed_requests.append((url, kwargs.get("timeout"))) + return TokenResponse() + + monkeypatch.setattr(httpx, "get", get) + + with pytest.raises(ValidationError): + wecom_module.WeComIMProviderAdapter(_credentials()).test_credentials() + assert observed_requests == [(wecom_module._ACCESS_TOKEN_URL, 5.0)] + + +def test_credential_token_fetch_preserves_authentication_rejection_classification( + monkeypatch: pytest.MonkeyPatch, +) -> None: + wecom_module = importlib.import_module("core.human_input_v2.im_integration.adapters.wecom") + + class TokenResponse: + def raise_for_status(self) -> None: + return None + + def json(self) -> dict[str, object]: + return { + "errcode": 40013, + "errmsg": "fake raw provider detail", + } + + monkeypatch.setattr(httpx, "get", lambda *_args, **_kwargs: TokenResponse()) + + result = wecom_module.WeComIMProviderAdapter(_credentials()).test_credentials() + + assert result == CredentialTestFailure( + CredentialTestFailureKind.AUTHENTICATION_REJECTED, + "WeCom rejected the credential test.", + provider_error_code="40013", + ) + assert "fake raw provider detail" not in result.reason + + +def test_credential_test_calls_the_sdk_directly_for_every_invocation( + monkeypatch: pytest.MonkeyPatch, +) -> None: + wecom_module = importlib.import_module("core.human_input_v2.im_integration.adapters.wecom") + clients = [ + _FakeClient(_token_response(token="fake-direct-token-001"), _agent_response()), + _FakeClient(_token_response(token="fake-direct-token-002"), _agent_response()), + ] + factory_calls: list[tuple[WeComCredentials, str | None]] = [] + + def new_client( + credentials: WeComCredentials, + *, + access_token: str | None = None, + ) -> _FakeClient: + factory_calls.append((credentials, access_token)) + return clients[len(factory_calls) - 1] + + monkeypatch.setattr(wecom_module, "_new_client", new_client) + adapter = wecom_module.WeComIMProviderAdapter(_credentials()) + + first = adapter.test_credentials() + second = adapter.test_credentials() + + assert first == CredentialTestSuccess(IMProvider.WE_COM, "fake-corp-001") + assert second == first + assert [client.fetch_calls for client in clients] == [1, 1] + assert [client.agent.calls for client in clients] == [[1000001], [1000001]] + assert [access_token for _credentials_value, access_token in factory_calls] == [None, None] + + +@pytest.mark.parametrize( + ("token_response", "expected_kind"), + [ + (WeChatClientException(40013, "fake raw provider detail"), CredentialTestFailureKind.AUTHENTICATION_REJECTED), + (WeChatClientException(None, "fake transport detail"), CredentialTestFailureKind.UNKNOWN), + (OSError("fake transport detail"), CredentialTestFailureKind.UNKNOWN), + (_token_response(token=""), CredentialTestFailureKind.UNKNOWN), + (_token_response(token=" "), CredentialTestFailureKind.UNKNOWN), + (_token_response(expires_in=0), CredentialTestFailureKind.UNKNOWN), + (_token_response(expires_in=-1), CredentialTestFailureKind.UNKNOWN), + ( + {"errcode": 0, "access_token": "fake-access-token-001", "expires_in": "7200"}, + CredentialTestFailureKind.UNKNOWN, + ), + ( + {"errcode": "0", "access_token": "fake-access-token-001", "expires_in": 7200}, + CredentialTestFailureKind.UNKNOWN, + ), + ({"errcode": 0, "access_token": "fake-access-token-001"}, CredentialTestFailureKind.UNKNOWN), + ([], CredentialTestFailureKind.UNKNOWN), + ], +) +def test_credential_test_normalizes_token_failures_without_provider_material( + monkeypatch: pytest.MonkeyPatch, + token_response: object, + expected_kind: CredentialTestFailureKind, +) -> None: + wecom_module = importlib.import_module("core.human_input_v2.im_integration.adapters.wecom") + client = _FakeClient(token_response) + monkeypatch.setattr(wecom_module, "_new_client", lambda *_args, **_kwargs: client) + + if isinstance(token_response, WeChatClientException) and token_response.errcode is not None: + result = wecom_module.WeComIMProviderAdapter(_credentials()).test_credentials() + assert isinstance(result, CredentialTestFailure) + assert result.kind is expected_kind + assert result.provider_error_code == str(token_response.errcode) + else: + expected_error = type(token_response) if isinstance(token_response, Exception) else ValueError + with pytest.raises(expected_error) as captured: + wecom_module.WeComIMProviderAdapter(_credentials()).test_credentials() + if isinstance(token_response, Exception): + assert captured.value is token_response + assert client.agent.calls == [] + + +@pytest.mark.parametrize( + ("agent_response", "expected"), + [ + ( + WeChatClientException(40056, "fake raw provider detail"), + CredentialTestFailure( + CredentialTestFailureKind.TENANT_ID_UNAVAILABLE, + "WeCom could not verify the configured corporation directory boundary.", + provider_error_code="40056", + ), + ), + ( + WeChatClientException(None, "fake transport detail"), + CredentialTestFailure( + CredentialTestFailureKind.UNKNOWN, + "WeCom credential testing could not be completed.", + ), + ), + ( + _agent_response(agent_id=1000002), + CredentialTestFailure( + CredentialTestFailureKind.UNKNOWN, + "WeCom credential testing could not be completed.", + ), + ), + ( + {"errcode": 0, "allow_partys": {"partyid": [1]}}, + CredentialTestFailure( + CredentialTestFailureKind.UNKNOWN, + "WeCom credential testing could not be completed.", + ), + ), + ], +) +def test_credential_test_requires_the_bound_agent_directory_boundary( + monkeypatch: pytest.MonkeyPatch, + agent_response: object, + expected: CredentialTestFailure, +) -> None: + wecom_module = importlib.import_module("core.human_input_v2.im_integration.adapters.wecom") + client = _FakeClient(_token_response(), agent_response) + monkeypatch.setattr(wecom_module, "_new_client", lambda *_args, **_kwargs: client) + + if isinstance(agent_response, WeChatClientException) and agent_response.errcode is not None: + result = wecom_module.WeComIMProviderAdapter(_credentials()).test_credentials() + assert isinstance(result, CredentialTestFailure) + assert result.kind is CredentialTestFailureKind.PERMISSION_DENIED + assert result.provider_error_code == expected.provider_error_code + else: + expected_error = ( + type(agent_response) if isinstance(agent_response, Exception) else (IMProviderError, ValidationError) + ) + with pytest.raises(expected_error) as captured: + wecom_module.WeComIMProviderAdapter(_credentials()).test_credentials() + if isinstance(agent_response, Exception): + assert captured.value is agent_response + assert client.fetch_calls == 1 + assert client.agent.calls == [1000001] + + +@pytest.mark.parametrize( + "agent_response", + [ + { + **_agent_response(), + "allow_partys": {"partyid": [0]}, + }, + { + **_agent_response(), + "allow_partys": {"partyid": []}, + "allow_userinfos": {"user": [{"userid": " "}]}, + }, + { + **_agent_response(), + "allow_partys": {"partyid": []}, + "allow_tags": {"tagid": [0]}, + }, + ], +) +def test_credential_test_rejects_malformed_directory_scope( + monkeypatch: pytest.MonkeyPatch, + agent_response: object, +) -> None: + wecom_module = importlib.import_module("core.human_input_v2.im_integration.adapters.wecom") + client = _FakeClient(_token_response(), agent_response) + monkeypatch.setattr(wecom_module, "_new_client", lambda *_args, **_kwargs: client) + + with pytest.raises(ValidationError): + wecom_module.WeComIMProviderAdapter(_credentials()).test_credentials() diff --git a/api/tests/unit_tests/core/human_input_v2/im_provider/test_wecom_credential_alignment.py b/api/tests/unit_tests/core/human_input_v2/im_provider/test_wecom_credential_alignment.py new file mode 100644 index 00000000000..ef3f2fe390b --- /dev/null +++ b/api/tests/unit_tests/core/human_input_v2/im_provider/test_wecom_credential_alignment.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +import pytest +from pydantic import ValidationError + +from controllers.console.human_input_v2.providers import WeComCredentialsInput as WeComCredentialUpdate +from core.human_input_v2.entities import IMProvider +from core.human_input_v2.im_integration import adapters as im_adapters + + +def test_wecom_credential_projections_are_field_aligned() -> None: + resolved_credentials = im_adapters.WeComCredentials + + assert set(WeComCredentialUpdate.model_fields) == {"provider", "corp_id", "agent_id", "secret"} + assert set(resolved_credentials.model_fields) == {"provider", "corp_id", "agent_id", "secret"} + + +def test_wecom_credentials_are_strict_immutable_and_secret_safe() -> None: + resolved_credentials = im_adapters.WeComCredentials + credentials = resolved_credentials( + provider=IMProvider.WE_COM, + corp_id="fake-corp-001", + agent_id="1000001", + secret="fake-secret-001", + ) + update = WeComCredentialUpdate.model_validate( + {"provider": IMProvider.WE_COM, "corp_id": "fake-corp-001", "agent_id": "1000001", "secret": "fake-secret-001"} + ) + assert credentials.model_config["frozen"] is True + assert credentials.model_config["extra"] == "forbid" + assert credentials.model_config["strict"] is True + assert "fake-secret-001" not in repr(credentials) + assert "fake-secret-001" not in repr(update) + + with pytest.raises(ValidationError): + resolved_credentials.model_validate({**credentials.model_dump(), "unexpected": "fake-value"}) + pytest.raises(ValidationError, setattr, credentials, "agent_id", "1000002") + with pytest.raises(ValidationError): + resolved_credentials.model_validate({**credentials.model_dump(), "agent_id": 1000001}) + + +@pytest.mark.parametrize("field_name", ["corp_id", "agent_id", "secret"]) +def test_wecom_resolved_credentials_reject_blank_fields(field_name: str) -> None: + resolved_credentials = im_adapters.WeComCredentials + values = { + "provider": IMProvider.WE_COM, + "corp_id": "fake-corp-001", + "agent_id": "1000001", + "secret": "fake-secret-001", + } + values[field_name] = " " + + with pytest.raises(ValidationError): + resolved_credentials.model_validate(values) + + +def test_wecom_resolved_credentials_require_a_positive_decimal_agent_id() -> None: + resolved_credentials = im_adapters.WeComCredentials + + with pytest.raises(ValidationError): + resolved_credentials( + provider=IMProvider.WE_COM, + corp_id="fake-corp-001", + agent_id="not-a-decimal-agent-id", + secret="fake-secret-001", + ) + + +def test_preserve_original_value_never_enters_wecom_resolved_credentials() -> None: + resolved_credentials = im_adapters.WeComCredentials + + with pytest.raises(ValidationError): + WeComCredentialUpdate.model_validate( + { + "provider": IMProvider.WE_COM, + "corp_id": "fake-corp-001", + "agent_id": "1000001", + "secret": {"tag": "preserve_original_value"}, + } + ) + with pytest.raises(ValidationError): + resolved_credentials.model_validate( + { + "provider": IMProvider.WE_COM, + "corp_id": "fake-corp-001", + "agent_id": "1000001", + "secret": {"tag": "preserve_original_value"}, + } + ) diff --git a/api/tests/unit_tests/core/human_input_v2/im_provider/test_wecom_directory.py b/api/tests/unit_tests/core/human_input_v2/im_provider/test_wecom_directory.py new file mode 100644 index 00000000000..04db0506d9a --- /dev/null +++ b/api/tests/unit_tests/core/human_input_v2/im_provider/test_wecom_directory.py @@ -0,0 +1,589 @@ +from __future__ import annotations + +import importlib +from collections.abc import Callable + +import httpx +import pytest +from pytest_mock import MockerFixture +from wechatpy.exceptions import WeChatClientException + +from core.human_input_v2.entities import IMProvider +from core.human_input_v2.im_integration.adapters import ( + Directory, + DirectoryEntry, + DirectoryReadFailure, + ProviderUserId, + WeComCredentials, +) + + +class _TokenClient: + def __init__(self, token: str) -> None: + self._token = token + self.fetch_calls = 0 + + def fetch_access_token(self) -> dict[str, object]: + self.fetch_calls += 1 + return { + "errcode": 0, + "errmsg": "ok", + "access_token": self._token, + "expires_in": 7200, + } + + +class _AgentAPI: + def __init__(self, response: object) -> None: + self._response = response + self.calls: list[int] = [] + + def get(self, agent_id: int) -> object: + self.calls.append(agent_id) + if isinstance(self._response, Exception): + raise self._response + return self._response + + +class _DepartmentAPI: + def __init__(self, route: Callable[[int], object]) -> None: + self._route = route + self.calls: list[int] = [] + + def get(self, department_id: int) -> object: + self.calls.append(department_id) + response = self._route(department_id) + if isinstance(response, Exception): + raise response + return response + + +class _UserAPI: + def __init__( + self, + list_route: Callable[[int], object], + get_route: Callable[[str], object] | None = None, + ) -> None: + self._list_route = list_route + self._get_route = get_route or (lambda user_id: {"errcode": 0, "userid": user_id}) + self.list_calls: list[tuple[int, bool, int, bool]] = [] + self.get_calls: list[str] = [] + + def list( + self, + department_id: int, + fetch_child: bool = False, + status: int = 0, + simple: bool = False, + ) -> object: + self.list_calls.append((department_id, fetch_child, status, simple)) + response = self._list_route(department_id) + if isinstance(response, Exception): + raise response + return response + + def get(self, user_id: str) -> object: + self.get_calls.append(user_id) + response = self._get_route(user_id) + if isinstance(response, Exception): + raise response + return response + + +class _TagAPI: + def __init__(self, route: Callable[[int], object] | None = None) -> None: + def empty_tag(_tag_id: int) -> dict[str, object]: + return {"errcode": 0, "userlist": [], "partylist": []} + + self._route = route or empty_tag + self.calls: list[int] = [] + + def get_users(self, tag_id: int) -> object: + self.calls.append(tag_id) + response = self._route(tag_id) + if isinstance(response, Exception): + raise response + return response + + +class _DirectoryClient: + def __init__( + self, + agent_response: object, + department_route: Callable[[int], object], + user_list_route: Callable[[int], object], + *, + user_get_route: Callable[[str], object] | None = None, + tag_route: Callable[[int], object] | None = None, + ) -> None: + self.agent = _AgentAPI(agent_response) + self.department = _DepartmentAPI(department_route) + self.user = _UserAPI(user_list_route, user_get_route) + self.tag = _TagAPI(tag_route) + + +def _credentials() -> WeComCredentials: + return WeComCredentials( + provider=IMProvider.WE_COM, + corp_id="fake-corp-001", + agent_id="1000001", + secret="fake-secret-001", + ) + + +def _scope_response( + *, + departments: tuple[int, ...] = (), + users: tuple[str, ...] = (), + tags: tuple[int, ...] = (), +) -> dict[str, object]: + return { + "errcode": 0, + "errmsg": "ok", + "agentid": 1000001, + "allow_partys": {"partyid": list(departments)}, + "allow_userinfos": {"user": [{"userid": user_id} for user_id in users]}, + "allow_tags": {"tagid": list(tags)}, + } + + +def _install_clients( + monkeypatch: pytest.MonkeyPatch, + token_clients: list[_TokenClient], + directory_clients: dict[str, _DirectoryClient], +) -> list[tuple[str | None, object]]: + wecom_module = importlib.import_module("core.human_input_v2.im_integration.adapters.wecom") + calls: list[tuple[str | None, object]] = [] + + def new_client( + credentials: WeComCredentials, + *, + access_token: str | None = None, + ) -> object: + assert credentials is not None + client = token_clients.pop(0) if access_token is None else directory_clients[access_token] + calls.append((access_token, client)) + return client + + monkeypatch.setattr(wecom_module, "_new_client", new_client) + return calls + + +def test_directory_uses_a_current_token_for_each_complete_read( + monkeypatch: pytest.MonkeyPatch, +) -> None: + wecom_module = importlib.import_module("core.human_input_v2.im_integration.adapters.wecom") + first_directory_client = _DirectoryClient(_scope_response(), lambda _department_id: [], lambda _department_id: []) + second_directory_client = _DirectoryClient(_scope_response(), lambda _department_id: [], lambda _department_id: []) + first_token_client = _TokenClient("fake-access-token-001") + second_token_client = _TokenClient("fake-access-token-002") + factory_calls = _install_clients( + monkeypatch, + [first_token_client, second_token_client], + { + "fake-access-token-001": first_directory_client, + "fake-access-token-002": second_directory_client, + }, + ) + adapter = wecom_module.WeComIMProviderAdapter(_credentials()) + + first = adapter.directory.read_directory() + second = adapter.directory.read_directory() + + assert first == Directory(()) + assert second == first + assert first_token_client.fetch_calls == 1 + assert second_token_client.fetch_calls == 1 + assert [access_token for access_token, _client in factory_calls] == [ + None, + "fake-access-token-001", + None, + "fake-access-token-002", + ] + + +@pytest.mark.parametrize( + ("avatar_url", "status", "content_type", "has_avatar"), + [ + (None, 200, "image/png", False), + ("", 200, "image/png", False), + ("https://example.invalid/avatar.png", 200, "image/png", True), + ("https://example.invalid/avatar.png", 404, "application/json", False), + ("https://example.invalid/avatar.png", 403, "application/json", False), + ("https://example.invalid/avatar.png", 500, "text/html", False), + ("https://example.invalid/avatar.png", 200, "text/html", False), + ], +) +def test_directory_includes_available_avatar_and_fails_on_download_errors( + monkeypatch: pytest.MonkeyPatch, + mocker: MockerFixture, + avatar_url: str | None, + status: int, + content_type: str, + has_avatar: bool, +) -> None: + wecom_module = importlib.import_module("core.human_input_v2.im_integration.adapters.wecom") + directory_client = _DirectoryClient( + _scope_response(users=("user",)), + lambda _department_id: [], + lambda _department_id: [], + user_get_route=lambda user_id: {"userid": user_id, "name": "User", "avatar": avatar_url}, + ) + _install_clients(monkeypatch, [_TokenClient("token")], {"token": directory_client}) + download = mocker.patch( + "core.file.remote_fetcher.make_request", + return_value=httpx.Response( + status, + headers={"Content-Type": content_type}, + content=b"avatar-image", + request=httpx.Request("GET", "https://example.invalid/avatar.png"), + ), + ) + + result = wecom_module.WeComIMProviderAdapter(_credentials()).directory.read_directory() + + if avatar_url and (status not in (200, 404) or (status == 200 and content_type != "image/png")): + assert isinstance(result, DirectoryReadFailure) + return + + assert isinstance(result, Directory) + assert result.entries[0].display_name == "User" + if has_avatar: + assert result.entries[0].avatar is not None + assert result.entries[0].avatar.mime_type == "image/png" + assert result.entries[0].avatar.data == b"avatar-image" + else: + assert result.entries[0].avatar is None + if not avatar_url: + download.assert_not_called() + + +def test_directory_fails_on_avatar_transport_failure( + monkeypatch: pytest.MonkeyPatch, + mocker: MockerFixture, +) -> None: + wecom_module = importlib.import_module("core.human_input_v2.im_integration.adapters.wecom") + directory_client = _DirectoryClient( + _scope_response(users=("user",)), + lambda _department_id: [], + lambda _department_id: [], + user_get_route=lambda user_id: {"userid": user_id, "avatar": "https://example.invalid/avatar.png"}, + ) + _install_clients(monkeypatch, [_TokenClient("token")], {"token": directory_client}) + mocker.patch("core.file.remote_fetcher.make_request", side_effect=httpx.ReadTimeout("private request details")) + + result = wecom_module.WeComIMProviderAdapter(_credentials()).directory.read_directory() + + assert isinstance(result, DirectoryReadFailure) + + +def test_directory_traverses_configured_scope_with_stable_deduplication( + monkeypatch: pytest.MonkeyPatch, +) -> None: + wecom_module = importlib.import_module("core.human_input_v2.im_integration.adapters.wecom") + + def departments(department_id: int) -> object: + if department_id == 1: + return [ + {"id": 1, "name": "Root", "parentid": 0, "order": 1}, + {"id": 2, "name": "Child", "parentid": 1, "order": 2}, + ] + assert department_id == 3 + return [{"id": 3, "name": "Tagged", "parentid": 0, "order": 3}] + + def users(department_id: int) -> object: + if department_id == 1: + return [ + { + "userid": "fake-user-001", + "name": "Fake User One", + "email": "one@example.com", + "department": [1], + }, + {"userid": "fake-user-002", "department": [1]}, + ] + if department_id == 2: + return [ + { + "userid": "fake-user-002", + "name": "Duplicate User", + "email": "duplicate@example.com", + "department": [1, 2], + }, + {"userid": "fake-user-003", "name": " ", "email": "", "department": [2]}, + ] + assert department_id == 3 + return [{"userid": "fake-user-006", "name": "Fake User Six", "department": [3]}] + + def user(user_id: str) -> object: + values = { + "fake-user-004": { + "errcode": 0, + "userid": "fake-user-004", + "name": "Fake User Four", + "email": "four@example.com", + }, + "fake-user-005": { + "errcode": 0, + "userid": "fake-user-005", + "name": "Fake User Five", + "email": " ", + }, + } + return values[user_id] + + directory_client = _DirectoryClient( + _scope_response(departments=(1,), users=("fake-user-004", "fake-user-001"), tags=(7,)), + departments, + users, + user_get_route=user, + tag_route=lambda tag_id: ( + { + "errcode": 0, + "tagname": "Fake Tag", + "userlist": [ + {"userid": "fake-user-005", "name": "Tag User"}, + {"userid": "fake-user-004", "name": "Duplicate Direct User"}, + ], + "partylist": [3], + } + if tag_id == 7 + else pytest.fail("unexpected tag") + ), + ) + _install_clients( + monkeypatch, + [_TokenClient("fake-access-token-001")], + {"fake-access-token-001": directory_client}, + ) + + result = wecom_module.WeComIMProviderAdapter(_credentials()).directory.read_directory() + + assert result == Directory( + ( + DirectoryEntry(ProviderUserId("fake-user-001"), "Fake User One", "one@example.com"), + DirectoryEntry(ProviderUserId("fake-user-002"), None, None), + DirectoryEntry(ProviderUserId("fake-user-003"), None, None), + DirectoryEntry(ProviderUserId("fake-user-004"), "Fake User Four", "four@example.com"), + DirectoryEntry(ProviderUserId("fake-user-005"), "Fake User Five", None), + DirectoryEntry(ProviderUserId("fake-user-006"), "Fake User Six", None), + ) + ) + assert directory_client.agent.calls == [1000001] + assert directory_client.department.calls == [1, 3] + assert directory_client.user.list_calls == [ + (1, False, 0, False), + (2, False, 0, False), + (3, False, 0, False), + ] + assert directory_client.user.get_calls == ["fake-user-004", "fake-user-005"] + assert directory_client.tag.calls == [7] + + +def test_directory_discards_partial_entries_when_a_later_scope_call_fails( + monkeypatch: pytest.MonkeyPatch, +) -> None: + wecom_module = importlib.import_module("core.human_input_v2.im_integration.adapters.wecom") + + def users(department_id: int) -> object: + if department_id == 1: + return [{"userid": "fake-partial-user", "name": "Partial User"}] + return WeChatClientException(60011, "fake raw provider detail") + + directory_client = _DirectoryClient( + _scope_response(departments=(1,)), + lambda _department_id: [ + {"id": 1, "parentid": 0}, + {"id": 2, "parentid": 1}, + ], + users, + ) + _install_clients( + monkeypatch, + [_TokenClient("fake-access-token-001")], + {"fake-access-token-001": directory_client}, + ) + + result = wecom_module.WeComIMProviderAdapter(_credentials()).directory.read_directory() + + assert result == DirectoryReadFailure("WeCom directory could not be read completely.") + assert "fake raw provider detail" not in result.reason + assert directory_client.user.list_calls == [(1, False, 0, False), (2, False, 0, False)] + + +@pytest.mark.parametrize( + "departments", + [ + [ + {"id": 1, "parentid": 0}, + {"id": 2, "parentid": 1}, + {"id": 2, "parentid": 1}, + ], + [ + {"id": 1, "parentid": 2}, + {"id": 2, "parentid": 1}, + ], + [ + {"id": 1, "parentid": 0}, + {"id": 2, "parentid": 99}, + ], + [{"id": 2, "parentid": 1}], + ], +) +def test_directory_rejects_malformed_department_topology( + monkeypatch: pytest.MonkeyPatch, + departments: list[dict[str, int]], +) -> None: + wecom_module = importlib.import_module("core.human_input_v2.im_integration.adapters.wecom") + directory_client = _DirectoryClient( + _scope_response(departments=(1,)), + lambda _department_id: departments, + lambda _department_id: [], + ) + _install_clients( + monkeypatch, + [_TokenClient("fake-access-token-001")], + {"fake-access-token-001": directory_client}, + ) + + result = wecom_module.WeComIMProviderAdapter(_credentials()).directory.read_directory() + + assert result == DirectoryReadFailure("WeCom directory could not be read completely.") + assert directory_client.user.list_calls == [] + + +def test_directory_rejects_an_invalid_provider_identity( + monkeypatch: pytest.MonkeyPatch, +) -> None: + wecom_module = importlib.import_module("core.human_input_v2.im_integration.adapters.wecom") + directory_client = _DirectoryClient( + _scope_response(departments=(1,)), + lambda _department_id: [{"id": 1, "parentid": 0}], + lambda _department_id: [{"userid": " ", "name": "Invalid Identity"}], + ) + _install_clients( + monkeypatch, + [_TokenClient("fake-access-token-001")], + {"fake-access-token-001": directory_client}, + ) + + result = wecom_module.WeComIMProviderAdapter(_credentials()).directory.read_directory() + + assert result == DirectoryReadFailure("WeCom directory could not be read completely.") + + +def test_directory_rejects_an_invalid_provider_email( + monkeypatch: pytest.MonkeyPatch, +) -> None: + wecom_module = importlib.import_module("core.human_input_v2.im_integration.adapters.wecom") + directory_client = _DirectoryClient( + _scope_response(departments=(1,)), + lambda _department_id: [{"id": 1, "parentid": 0}], + lambda _department_id: [ + { + "userid": "fake-user-001", + "name": "Fake User", + "email": "not-an-email-address", + } + ], + ) + _install_clients( + monkeypatch, + [_TokenClient("fake-access-token-001")], + {"fake-access-token-001": directory_client}, + ) + + result = wecom_module.WeComIMProviderAdapter(_credentials()).directory.read_directory() + + assert result == DirectoryReadFailure("WeCom directory could not be read completely.") + + +def test_directory_rejects_a_scope_for_a_different_agent( + monkeypatch: pytest.MonkeyPatch, +) -> None: + wecom_module = importlib.import_module("core.human_input_v2.im_integration.adapters.wecom") + directory_client = _DirectoryClient( + {**_scope_response(), "agentid": 1000002}, + lambda _department_id: [], + lambda _department_id: [], + ) + _install_clients( + monkeypatch, + [_TokenClient("fake-access-token-001")], + {"fake-access-token-001": directory_client}, + ) + + result = wecom_module.WeComIMProviderAdapter(_credentials()).directory.read_directory() + + assert result == DirectoryReadFailure("WeCom directory could not be read completely.") + assert directory_client.department.calls == [] + + +@pytest.mark.parametrize( + ("scope", "expected_department_calls", "expected_tag_calls"), + [ + (_scope_response(departments=(0,)), [], []), + (_scope_response(tags=(0,)), [], []), + ], +) +def test_directory_rejects_non_positive_scope_identifiers( + monkeypatch: pytest.MonkeyPatch, + scope: object, + expected_department_calls: list[int], + expected_tag_calls: list[int], +) -> None: + wecom_module = importlib.import_module("core.human_input_v2.im_integration.adapters.wecom") + directory_client = _DirectoryClient(scope, lambda _department_id: [], lambda _department_id: []) + _install_clients( + monkeypatch, + [_TokenClient("fake-access-token-001")], + {"fake-access-token-001": directory_client}, + ) + + result = wecom_module.WeComIMProviderAdapter(_credentials()).directory.read_directory() + + assert result == DirectoryReadFailure("WeCom directory could not be read completely.") + assert directory_client.department.calls == expected_department_calls + assert directory_client.tag.calls == expected_tag_calls + + +def test_directory_rejects_a_mismatched_direct_user_lookup( + monkeypatch: pytest.MonkeyPatch, +) -> None: + wecom_module = importlib.import_module("core.human_input_v2.im_integration.adapters.wecom") + directory_client = _DirectoryClient( + _scope_response(users=("fake-user-001",)), + lambda _department_id: [], + lambda _department_id: [], + user_get_route=lambda _user_id: {"errcode": 0, "userid": "fake-user-002"}, + ) + _install_clients( + monkeypatch, + [_TokenClient("fake-access-token-001")], + {"fake-access-token-001": directory_client}, + ) + + result = wecom_module.WeComIMProviderAdapter(_credentials()).directory.read_directory() + + assert result == DirectoryReadFailure("WeCom directory could not be read completely.") + + +def test_directory_skips_a_repeated_department_scope( + monkeypatch: pytest.MonkeyPatch, +) -> None: + wecom_module = importlib.import_module("core.human_input_v2.im_integration.adapters.wecom") + directory_client = _DirectoryClient( + _scope_response(departments=(1, 1)), + lambda _department_id: [{"id": 1, "parentid": 0}], + lambda _department_id: [{"userid": "fake-user-001"}], + ) + _install_clients( + monkeypatch, + [_TokenClient("fake-access-token-001")], + {"fake-access-token-001": directory_client}, + ) + + result = wecom_module.WeComIMProviderAdapter(_credentials()).directory.read_directory() + + assert result == Directory((DirectoryEntry(ProviderUserId("fake-user-001"), None, None),)) + assert directory_client.department.calls == [1] diff --git a/api/tests/unit_tests/core/human_input_v2/im_provider/test_wecom_messaging.py b/api/tests/unit_tests/core/human_input_v2/im_provider/test_wecom_messaging.py new file mode 100644 index 00000000000..3a22d605896 --- /dev/null +++ b/api/tests/unit_tests/core/human_input_v2/im_provider/test_wecom_messaging.py @@ -0,0 +1,207 @@ +from __future__ import annotations + +import importlib + +import pytest +from wechatpy.exceptions import WeChatClientException + +from core.human_input_v2.entities import IMProvider +from core.human_input_v2.im_integration.adapters import ( + MessageAccepted, + MessageLocator, + MessageSendingError, + ProviderUserId, + WeComCredentials, +) + + +class _TokenClient: + def __init__(self, response: object) -> None: + self._response = response + self.fetch_calls = 0 + + def fetch_access_token(self) -> object: + self.fetch_calls += 1 + if isinstance(self._response, Exception): + raise self._response + return self._response + + +class _MessageAPI: + def __init__(self, response: object) -> None: + self._response = response + self.calls: list[tuple[int, list[str], str, str, str, int]] = [] + + def send_text( + self, + agent_id: int, + user_ids: list[str], + content: str, + party_ids: str = "", + tag_ids: str = "", + safe: int = 0, + ) -> object: + self.calls.append((agent_id, list(user_ids), content, party_ids, tag_ids, safe)) + if isinstance(self._response, Exception): + raise self._response + return self._response + + +class _MessagingClient: + def __init__(self, response: object) -> None: + self.message = _MessageAPI(response) + + +def _credentials() -> WeComCredentials: + return WeComCredentials( + provider=IMProvider.WE_COM, + corp_id="fake-corp-001", + agent_id="1000001", + secret="fake-secret-001", + ) + + +def _token_response(token: str = "fake-access-token-001") -> dict[str, object]: + return { + "errcode": 0, + "errmsg": "ok", + "access_token": token, + "expires_in": 7200, + } + + +def _message_response( + *, + msg_id: object = "fake-message-id-001", + invalid_user: object = "", + invalid_party: object = "", + invalid_tag: object = "", +) -> dict[str, object]: + return { + "errcode": 0, + "errmsg": "ok", + "invaliduser": invalid_user, + "invalidparty": invalid_party, + "invalidtag": invalid_tag, + "msgid": msg_id, + } + + +def _install_clients( + monkeypatch: pytest.MonkeyPatch, + token_client: _TokenClient, + messaging_client: _MessagingClient, +) -> list[str | None]: + wecom_module = importlib.import_module("core.human_input_v2.im_integration.adapters.wecom") + access_tokens: list[str | None] = [] + + def new_client( + credentials: WeComCredentials, + *, + access_token: str | None = None, + ) -> object: + assert credentials is not None + access_tokens.append(access_token) + return token_client if access_token is None else messaging_client + + monkeypatch.setattr(wecom_module, "_new_client", new_client) + return access_tokens + + +@pytest.mark.parametrize( + ("subject", "expected"), + [ + (None, "Rendered **CommonMark** body\nwithout custom tags"), + ("### Review", "### Review\n\nRendered **CommonMark** body\nwithout custom tags"), + ], +) +def test_messaging_calls_sdk_once_with_the_directory_user_id_and_exact_body( + monkeypatch: pytest.MonkeyPatch, + subject: str | None, + expected: str, +) -> None: + wecom_module = importlib.import_module("core.human_input_v2.im_integration.adapters.wecom") + token_client = _TokenClient(_token_response()) + messaging_client = _MessagingClient(_message_response()) + access_tokens = _install_clients(monkeypatch, token_client, messaging_client) + adapter = wecom_module.WeComIMProviderAdapter(_credentials()) + + result = adapter.messaging.send_text( + ProviderUserId("fake-user-001"), + "Rendered **CommonMark** body\nwithout custom tags", + subject, + ) + + assert isinstance(result, MessageAccepted) + assert isinstance(result.locator, str) + assert type(result.locator) is str + assert MessageLocator(str(result.locator)) == result.locator + assert wecom_module._WeComLocatorPayload.decode(str(result.locator)) == wecom_module._WeComLocatorPayload( + v=1, + p=IMProvider.WE_COM, + message_id="fake-message-id-001", + ) + assert token_client.fetch_calls == 1 + assert access_tokens == [None, "fake-access-token-001"] + assert messaging_client.message.calls == [ + ( + 1000001, + ["fake-user-001"], + expected, + "", + "", + 0, + ) + ] + + +@pytest.mark.parametrize( + "provider_response", + [ + _message_response(msg_id=""), + _message_response(msg_id=None), + _message_response(msg_id=123), + _message_response(invalid_user="fake-user-001"), + _message_response(invalid_party="1"), + _message_response(invalid_tag="7"), + {"errcode": 0, "errmsg": "ok"}, + {"errcode": 40003, "errmsg": "fake raw provider detail"}, + WeChatClientException(40003, "fake raw provider detail"), + OSError("fake transport detail"), + ], +) +def test_messaging_returns_one_safe_failure_without_replay( + monkeypatch: pytest.MonkeyPatch, + provider_response: object, +) -> None: + wecom_module = importlib.import_module("core.human_input_v2.im_integration.adapters.wecom") + token_client = _TokenClient(_token_response()) + messaging_client = _MessagingClient(provider_response) + _install_clients(monkeypatch, token_client, messaging_client) + adapter = wecom_module.WeComIMProviderAdapter(_credentials()) + + result = adapter.messaging.send_text(ProviderUserId("fake-user-001"), "Exact body") + + assert result == MessageSendingError("WeCom message acceptance could not be confirmed.") + assert token_client.fetch_calls == 1 + assert len(messaging_client.message.calls) == 1 + assert "fake raw provider detail" not in result.reason + assert "fake transport detail" not in result.reason + assert "fake-secret-001" not in result.reason + + +def test_messaging_does_not_attempt_creation_when_token_acquisition_fails( + monkeypatch: pytest.MonkeyPatch, +) -> None: + wecom_module = importlib.import_module("core.human_input_v2.im_integration.adapters.wecom") + token_client = _TokenClient(WeChatClientException(40013, "fake raw provider detail")) + messaging_client = _MessagingClient(_message_response()) + access_tokens = _install_clients(monkeypatch, token_client, messaging_client) + adapter = wecom_module.WeComIMProviderAdapter(_credentials()) + + result = adapter.messaging.send_text(ProviderUserId("fake-user-001"), "Exact body") + + assert result == MessageSendingError("WeCom message acceptance could not be confirmed.") + assert token_client.fetch_calls == 1 + assert messaging_client.message.calls == [] + assert access_tokens == [None] diff --git a/api/tests/unit_tests/core/human_input_v2/im_provider/test_wecom_sdk_boundary.py b/api/tests/unit_tests/core/human_input_v2/im_provider/test_wecom_sdk_boundary.py new file mode 100644 index 00000000000..d9e60db96f2 --- /dev/null +++ b/api/tests/unit_tests/core/human_input_v2/im_provider/test_wecom_sdk_boundary.py @@ -0,0 +1,102 @@ +from __future__ import annotations + +import json +from pathlib import Path + +from core.human_input_v2.im_integration.adapters import wecom as wecom_module + +_FIXTURE_PATH = Path(__file__).parents[4] / "fixtures" / "im_provider" / "wecom" / "sanitized_protocol.json" + + +def _load_fixture() -> dict[str, object]: + value = json.loads(_FIXTURE_PATH.read_text()) + assert isinstance(value, dict) + return value + + +def _assert_sanitized(value: object, *, field_name: str = "") -> None: + if isinstance(value, dict): + for key, nested in value.items(): + normalized_key = key.casefold().replace("-", "_") + assert normalized_key != "authorization" + _assert_sanitized(nested, field_name=normalized_key) + return + if isinstance(value, list): + if field_name == "headers": + for header in value: + assert isinstance(header, list) + assert len(header) == 2 + header_name, header_value = header + assert isinstance(header_name, str) + assert isinstance(header_value, str) + assert header_name.casefold() != "authorization" + _assert_sanitized(header_value, field_name="header_value") + return + for nested in value: + _assert_sanitized(nested, field_name=field_name) + return + if field_name in {"agent_id", "agentid"}: + assert value in {"1000001", 1000001} + return + if not isinstance(value, str): + return + assert not value.casefold().startswith("bearer ") + if field_name in {"access_token", "corpsecret", "secret", "encrypted_secret"}: + assert value.startswith("fake-") + if field_name in {"corpid", "corp_id", "userid", "msgid"}: + assert not value or value.startswith("fake-") + + +def test_sanitized_protocol_fixture_is_complete_and_contains_no_sensitive_values() -> None: + fixture = _load_fixture() + + _assert_sanitized(fixture) + assert set(fixture) == {"credential_test", "directory", "message"} + credential_test = fixture["credential_test"] + directory = fixture["directory"] + message = fixture["message"] + assert isinstance(credential_test, dict) + assert isinstance(directory, dict) + assert isinstance(message, dict) + assert set(credential_test) == {"token", "agent"} + assert set(directory) == {"departments", "users"} + assert set(message) == {"request", "response"} + + +def test_sanitized_provider_responses_match_the_production_boundary_models() -> None: + fixture = _load_fixture() + credential_test = fixture["credential_test"] + directory = fixture["directory"] + message = fixture["message"] + assert isinstance(credential_test, dict) + assert isinstance(directory, dict) + assert isinstance(message, dict) + + token = credential_test["token"] + agent = credential_test["agent"] + departments = directory["departments"] + users = directory["users"] + assert isinstance(token, dict) + assert isinstance(agent, dict) + assert isinstance(departments, dict) + assert isinstance(users, dict) + token_response = token["response"] + agent_response = agent["response"] + department_response = departments["response"] + user_response = users["response"] + message_response = message["response"] + assert isinstance(token_response, dict) + assert isinstance(agent_response, dict) + assert isinstance(department_response, dict) + assert isinstance(user_response, dict) + assert isinstance(message_response, dict) + department_body = department_response["body"] + user_body = user_response["body"] + assert isinstance(department_body, dict) + assert isinstance(user_body, dict) + + wecom_module._AccessTokenResponse.model_validate(token_response["body"]) + wecom_module._AgentResponse.model_validate(agent_response["body"]) + wecom_module._DepartmentList.model_validate(department_body["department"]) + wecom_module._DirectoryUserList.model_validate(user_body["userlist"]) + wecom_module._MessageSendResponse.model_validate(message_response["body"]) diff --git a/api/tests/unit_tests/core/human_input_v2/test_im_reconciliation_change_log.py b/api/tests/unit_tests/core/human_input_v2/test_im_reconciliation_change_log.py new file mode 100644 index 00000000000..b049514ca07 --- /dev/null +++ b/api/tests/unit_tests/core/human_input_v2/test_im_reconciliation_change_log.py @@ -0,0 +1,23 @@ +"""Domain-contract tests for append-only IM reconciliation changes.""" + +from __future__ import annotations + +import dataclasses + +from core.human_input_v2 import im_integration + +_CHANGE_VALUE_TYPES = ( + im_integration.IMIdentityChangeSnapshot, + im_integration.IMBindingChangeSnapshot, + im_integration.IMReconciliationChange, +) + +_FORBIDDEN_FIELD_FRAGMENTS = ("credential", "raw_payload", "client", "transport") + + +def test_change_log_values_are_immutable_and_contain_no_transport_material() -> None: + field_names = {field.name for value_type in _CHANGE_VALUE_TYPES for field in dataclasses.fields(value_type)} + assert not any(fragment in field_name for field_name in field_names for fragment in _FORBIDDEN_FIELD_FRAGMENTS) + for value_type in _CHANGE_VALUE_TYPES: + assert value_type.__dict__["__dataclass_params__"].frozen is True + assert "__slots__" in value_type.__dict__ diff --git a/api/tests/unit_tests/core/human_input_v2/test_im_sync_reconciliation.py b/api/tests/unit_tests/core/human_input_v2/test_im_sync_reconciliation.py new file mode 100644 index 00000000000..d0747d8d3a4 --- /dev/null +++ b/api/tests/unit_tests/core/human_input_v2/test_im_sync_reconciliation.py @@ -0,0 +1,476 @@ +"""Infrastructure-free tests for composite IM reconciliation planning.""" + +from __future__ import annotations + +from datetime import datetime + +import pytest + +from core.human_input_v2.entities import IMProvider, IMSyncRemovalReason, IMSyncResultType, IMSyncRunStatus +from core.human_input_v2.im_integration import ( + BlockedReconciliation, + ContactEmailMatchState, + CreateIMBinding, + CurrentIMBindingState, + CurrentIMIdentityState, + DeleteIMBinding, + ExistingIMIdentityRef, + IMChannelRevision, + IMIdentityUpsertKind, + IMSyncRun, + NewIMIdentityRef, + ReconciliationBlockCode, + ReconciliationInput, + ReconciliationPlan, + ReconciliationReasonCode, + ReconciliationRunRef, + ReplaceIMBinding, + SyncReconciler, +) +from core.human_input_v2.im_integration.adapters import DirectoryEntry, ProviderUserId +from core.human_input_v2.shared import ( + AccountId, + ContactId, + IMBindingId, + IMIdentityId, + IMSyncRunId, + NormalizedEmail, +) + +_NOW = datetime(2026, 7, 25, 8) +_RUN = ReconciliationRunRef( + sync_run_id=IMSyncRunId("run-1"), + channel_revision=IMChannelRevision("channel-1", 3), + provider=IMProvider.FEISHU, +) + + +def _entry(provider_user_id: str, *, display_name: str | None = None, email: str | None = None) -> DirectoryEntry: + return DirectoryEntry(ProviderUserId(provider_user_id), display_name, email) + + +def _identity( + identity_id: str, + provider_user_id: str, + *, + display_name: str | None = None, + email: str | None = None, + last_seen_sync_run_id: str | None = None, +) -> CurrentIMIdentityState: + return CurrentIMIdentityState( + identity_id=IMIdentityId(identity_id), + provider_user_id=ProviderUserId(provider_user_id), + display_name=display_name, + email=email, + normalized_email=NormalizedEmail(email) if email else None, + last_seen_sync_run_id=IMSyncRunId(last_seen_sync_run_id) if last_seen_sync_run_id else None, + ) + + +def _binding(binding_id: str, identity_id: str, contact_id: str) -> CurrentIMBindingState: + return CurrentIMBindingState( + binding_id=IMBindingId(binding_id), + identity_id=IMIdentityId(identity_id), + contact_id=ContactId(contact_id), + ) + + +def _contact(contact_id: str, email: str) -> ContactEmailMatchState: + return ContactEmailMatchState( + contact_id=ContactId(contact_id), + display_name=f"Contact {contact_id}", + email=email, + normalized_email=NormalizedEmail(email), + avatar_file_id=None, + ) + + +def _input( + *, + entries: tuple[DirectoryEntry, ...] = (), + identities: tuple[CurrentIMIdentityState, ...] = (), + bindings: tuple[CurrentIMBindingState, ...] = (), + reconciled_binding_ids: frozenset[IMBindingId] = frozenset(), + contacts: tuple[ContactEmailMatchState, ...] = (), +) -> ReconciliationInput: + return ReconciliationInput( + run=_RUN, + directory_entries=entries, + current_identities=identities, + current_bindings=bindings, + reconciled_binding_ids=reconciled_binding_ids, + contacts_for_email_matching=contacts, + ) + + +def _plan(reconciliation_input: ReconciliationInput) -> ReconciliationPlan: + generated = SyncReconciler.generate_plan(reconciliation_input) + assert isinstance(generated, ReconciliationPlan) + return generated + + +def test_sync_run_captures_complete_channel_revision() -> None: + run = IMSyncRun.create( + sync_run_id=IMSyncRunId("run-1"), + channel_revision=_RUN.channel_revision, + provider=IMProvider.FEISHU, + started_by_account_id=AccountId("account-1"), + now=_NOW, + ) + + assert run.channel_revision == _RUN.channel_revision + assert run.status is IMSyncRunStatus.QUEUED + assert run.is_active is True + assert run.start(_NOW).start(_NOW).status is IMSyncRunStatus.RUNNING + + +def test_empty_input_produces_empty_composite_plan() -> None: + plan = _plan(_input()) + + assert plan == ReconciliationPlan(_RUN, (), (), (), (), ()) + + +def test_each_directory_entry_plans_create_update_or_refresh() -> None: + existing_update = _identity("identity-update", "update", display_name="Old", email="old@example.com") + existing_refresh = _identity("identity-refresh", "refresh", display_name="Same", email="same@example.com") + + plan = _plan( + _input( + entries=( + _entry("refresh", display_name="Same", email="same@example.com"), + _entry("create", display_name="New", email=" NEW@EXAMPLE.COM "), + _entry("update", display_name="New", email="new@example.com"), + ), + identities=(existing_refresh, existing_update), + ) + ) + + assert [upsert.entry.provider_user_id for upsert in plan.identity_upserts] == ["create", "refresh", "update"] + assert [upsert.kind for upsert in plan.identity_upserts] == [ + IMIdentityUpsertKind.CREATE, + IMIdentityUpsertKind.REFRESH, + IMIdentityUpsertKind.UPDATE, + ] + assert plan.identity_upserts[0].identity_ref == NewIMIdentityRef(ProviderUserId("create")) + assert plan.identity_upserts[0].normalized_email == NormalizedEmail("new@example.com") + assert plan.identity_upserts[1].identity_ref == ExistingIMIdentityRef(existing_refresh.identity_id) + assert plan.identity_upserts[2].changed_fields == ("display_name", "email", "normalized_email") + + +def test_unmatched_entry_still_plans_identity_create_and_not_matched_result() -> None: + plan = _plan(_input(entries=(_entry("unmatched", display_name="Unmatched"),))) + + assert plan.identity_upserts[0].kind is IMIdentityUpsertKind.CREATE + assert plan.binding_mutations == () + assert [(item.result_type, item.reason_code) for item in plan.sync_results] == [ + (IMSyncResultType.NOT_MATCHED, ReconciliationReasonCode.MISSING_EMAIL.value) + ] + + +def test_absent_identity_deletes_all_referencing_bindings_before_identity() -> None: + identity = _identity("identity-1", "absent") + organization_binding = _binding("binding-org", "identity-1", "contact-1") + workspace_override = _binding("binding-workspace", "identity-1", "contact-2") + + plan = _plan( + _input( + identities=(identity,), + bindings=(workspace_override, organization_binding), + reconciled_binding_ids=frozenset({organization_binding.binding_id}), + ) + ) + + delete_mutations = tuple(mutation for mutation in plan.binding_mutations if isinstance(mutation, DeleteIMBinding)) + assert len(delete_mutations) == len(plan.binding_mutations) + assert [mutation.before.binding_id for mutation in delete_mutations] == [ + organization_binding.binding_id, + workspace_override.binding_id, + ] + assert plan.identity_deletions[0].before == identity + assert [item.result_type for item in plan.sync_results] == [IMSyncResultType.REMOVED, IMSyncResultType.REMOVED] + + +def test_unbound_absent_identity_has_no_removed_product_result() -> None: + plan = _plan(_input(identities=(_identity("identity-1", "absent"),))) + + assert len(plan.identity_deletions) == 1 + assert plan.sync_results == () + + +def test_provider_user_id_binding_precedes_conflicting_email_and_contact_admission() -> None: + identity = _identity("identity-1", "provider-user", email="old@example.com") + binding = _binding("binding-1", "identity-1", "contact-bound") + + plan = _plan( + _input( + entries=(_entry("provider-user", email="other@example.com"),), + identities=(identity,), + bindings=(binding,), + reconciled_binding_ids=frozenset({binding.binding_id}), + contacts=(_contact("contact-other", "other@example.com"),), + ) + ) + + assert plan.binding_mutations == () + assert [(item.result_type, item.binding_id, item.contact_id) for item in plan.sync_results] == [ + (IMSyncResultType.SKIPPED, binding.binding_id, binding.contact_id) + ] + + +@pytest.mark.parametrize( + ("email", "expected_reason"), + [ + (None, ReconciliationReasonCode.MISSING_EMAIL), + ("missing@example.com", ReconciliationReasonCode.NO_CONTACT_MATCH), + ], +) +def test_email_fallback_without_contact_is_not_matched( + email: str | None, expected_reason: ReconciliationReasonCode +) -> None: + plan = _plan(_input(entries=(_entry("provider-user", email=email),))) + + assert plan.binding_mutations == () + assert plan.sync_results[0].reason_code == expected_reason.value + + +def test_unique_email_match_creates_binding() -> None: + contact = _contact("contact-1", "reviewer@example.com") + + plan = _plan(_input(entries=(_entry("provider-user", email=" REVIEWER@EXAMPLE.COM "),), contacts=(contact,))) + + assert plan.binding_mutations == ( + CreateIMBinding( + operation_key=plan.binding_mutations[0].operation_key, + identity_ref=NewIMIdentityRef(ProviderUserId("provider-user")), + contact_id=contact.contact_id, + contact_precondition=contact, + reason=ReconciliationReasonCode.NORMALIZED_EMAIL_MATCH, + ), + ) + assert plan.sync_results[0].result_type is IMSyncResultType.ADDED + + +def test_binding_mutations_reject_a_contact_precondition_for_another_target() -> None: + contact = _contact("contact-1", "reviewer@example.com") + + with pytest.raises(ValueError, match="binding target"): + CreateIMBinding( + operation_key="create-binding", + identity_ref=NewIMIdentityRef(ProviderUserId("provider-user")), + contact_id=ContactId("contact-2"), + contact_precondition=contact, + reason=ReconciliationReasonCode.NORMALIZED_EMAIL_MATCH, + ) + with pytest.raises(ValueError, match="binding target"): + ReplaceIMBinding( + operation_key="replace-binding", + before=_binding("binding-1", "identity-1", "contact-2"), + next_identity_ref=NewIMIdentityRef(ProviderUserId("provider-user")), + contact_precondition=contact, + reason=ReconciliationReasonCode.NORMALIZED_EMAIL_MATCH, + removal_reason=IMSyncRemovalReason.BINDING_REPLACED, + ) + + +def test_duplicate_contact_email_recovers_with_warning_for_all_affected_identities() -> None: + contacts = (_contact("contact-2", "same@example.com"), _contact("contact-1", "SAME@example.com")) + entries = (_entry("provider-2", email="same@example.com"), _entry("provider-1", email="same@example.com")) + + plan = _plan(_input(entries=entries, contacts=contacts)) + + assert plan.binding_mutations == () + assert [(item.provider_user_id, item.reason_code) for item in plan.sync_results] == [ + ("provider-1", ReconciliationReasonCode.AMBIGUOUS_CONTACT_EMAIL.value), + ("provider-2", ReconciliationReasonCode.AMBIGUOUS_CONTACT_EMAIL.value), + ] + assert len(plan.warnings) == 1 + assert plan.warnings[0].reason is ReconciliationReasonCode.AMBIGUOUS_CONTACT_EMAIL + assert plan.warnings[0].identity_refs == ( + NewIMIdentityRef(ProviderUserId("provider-1")), + NewIMIdentityRef(ProviderUserId("provider-2")), + ) + assert plan.warnings[0].contact_ids == (ContactId("contact-1"), ContactId("contact-2")) + assert "same@example.com" not in plan.warnings[0].warning_key + + +def test_competing_provider_identities_do_not_bind_same_contact() -> None: + contact = _contact("contact-1", "same@example.com") + + plan = _plan( + _input( + entries=(_entry("provider-2", email="same@example.com"), _entry("provider-1", email="same@example.com")), + contacts=(contact,), + ) + ) + + assert plan.binding_mutations == () + assert [item.reason_code for item in plan.sync_results] == [ + ReconciliationReasonCode.AMBIGUOUS_PROVIDER_EMAIL.value, + ReconciliationReasonCode.AMBIGUOUS_PROVIDER_EMAIL.value, + ] + + +def test_contact_bound_to_present_identity_is_not_stolen() -> None: + bound_identity = _identity("identity-bound", "bound", email="same@example.com") + binding = _binding("binding-1", "identity-bound", "contact-1") + + plan = _plan( + _input( + entries=( + _entry("bound", email="same@example.com"), + _entry("candidate", email="same@example.com"), + ), + identities=(bound_identity,), + bindings=(binding,), + reconciled_binding_ids=frozenset({binding.binding_id}), + contacts=(_contact("contact-1", "same@example.com"),), + ) + ) + + assert plan.binding_mutations == () + candidate_result = next(item for item in plan.sync_results if item.provider_user_id == "candidate") + assert candidate_result.reason_code == ReconciliationReasonCode.CONTACT_ALREADY_BOUND.value + + +def test_binding_to_absent_identity_is_uniquely_replaced() -> None: + absent_identity = _identity("identity-absent", "absent", email="same@example.com") + binding = _binding("binding-1", "identity-absent", "contact-1") + + plan = _plan( + _input( + entries=(_entry("replacement", email="same@example.com"),), + identities=(absent_identity,), + bindings=(binding,), + reconciled_binding_ids=frozenset({binding.binding_id}), + contacts=(_contact("contact-1", "same@example.com"),), + ) + ) + + assert plan.binding_mutations == ( + ReplaceIMBinding( + operation_key=plan.binding_mutations[0].operation_key, + before=binding, + next_identity_ref=NewIMIdentityRef(ProviderUserId("replacement")), + contact_precondition=_contact("contact-1", "same@example.com"), + reason=ReconciliationReasonCode.NORMALIZED_EMAIL_MATCH, + removal_reason=IMSyncRemovalReason.BINDING_REPLACED, + ), + ) + assert [item.result_type for item in plan.sync_results] == [IMSyncResultType.ADDED, IMSyncResultType.REMOVED] + assert len(plan.identity_deletions) == 1 + + +def test_non_reconciled_override_does_not_compete_but_is_deleted_before_absent_identity() -> None: + absent_identity = _identity("identity-absent", "absent") + override = _binding("binding-override", "identity-absent", "contact-override") + contact = _contact("contact-override", "same@example.com") + + plan = _plan( + _input( + entries=(_entry("new", email="same@example.com"),), + identities=(absent_identity,), + bindings=(override,), + contacts=(contact,), + ) + ) + + assert isinstance(plan.binding_mutations[0], CreateIMBinding) + assert isinstance(plan.binding_mutations[1], DeleteIMBinding) + assert plan.identity_deletions[0].before == absent_identity + + +def test_structural_corruption_returns_all_deterministically_ordered_blockers() -> None: + duplicate_identity_a = _identity("identity-1", "duplicate") + duplicate_identity_b = _identity("identity-1", "duplicate") + dangling_binding = _binding("binding-1", "missing", "contact-1") + + generated = SyncReconciler.generate_plan( + _input( + entries=(_entry("provider-duplicate"), _entry("provider-duplicate")), + identities=(duplicate_identity_b, duplicate_identity_a), + bindings=(dangling_binding, dangling_binding), + reconciled_binding_ids=frozenset({IMBindingId("missing-binding")}), + ) + ) + + assert isinstance(generated, BlockedReconciliation) + assert [block.code for block in generated.blockers] == sorted([block.code for block in generated.blockers], key=str) + assert {block.code for block in generated.blockers} == { + ReconciliationBlockCode.DUPLICATE_PROVIDER_USER_ID, + ReconciliationBlockCode.DUPLICATE_CURRENT_IDENTITY, + ReconciliationBlockCode.INVALID_CURRENT_BINDING, + ReconciliationBlockCode.INVALID_RECONCILED_BINDING_SET, + } + + +@pytest.mark.parametrize( + "bindings", + [ + (_binding("binding-1", "identity-1", "contact-1"), _binding("binding-2", "identity-1", "contact-2")), + (_binding("binding-1", "identity-1", "contact-1"), _binding("binding-2", "identity-2", "contact-1")), + ], +) +def test_reconciled_binding_subset_must_be_one_to_one(bindings: tuple[CurrentIMBindingState, ...]) -> None: + generated = SyncReconciler.generate_plan( + _input( + identities=(_identity("identity-1", "provider-1"), _identity("identity-2", "provider-2")), + bindings=bindings, + reconciled_binding_ids=frozenset(binding.binding_id for binding in bindings), + ) + ) + + assert isinstance(generated, BlockedReconciliation) + assert ReconciliationBlockCode.INVALID_RECONCILED_BINDING_SET in {block.code for block in generated.blockers} + + +def test_planner_output_is_independent_of_fact_order() -> None: + facts = { + "entries": (_entry("provider-2", email="two@example.com"), _entry("provider-1", email="one@example.com")), + "identities": (_identity("identity-3", "absent"),), + "contacts": (_contact("contact-2", "two@example.com"), _contact("contact-1", "one@example.com")), + } + + first = SyncReconciler.generate_plan( + _input(entries=facts["entries"], identities=facts["identities"], contacts=facts["contacts"]) + ) + second = SyncReconciler.generate_plan( + _input( + entries=tuple(reversed(facts["entries"])), + identities=tuple(reversed(facts["identities"])), + contacts=tuple(reversed(facts["contacts"])), + ) + ) + + assert first == second + + +def test_projected_plan_converges_without_second_binding_mutation() -> None: + contact = _contact("contact-1", "same@example.com") + first = _plan(_input(entries=(_entry("provider-1", email="same@example.com"),), contacts=(contact,))) + created_identity = CurrentIMIdentityState( + identity_id=IMIdentityId("created-provider-1"), + provider_user_id=ProviderUserId("provider-1"), + display_name=None, + email="same@example.com", + normalized_email=NormalizedEmail("same@example.com"), + last_seen_sync_run_id=_RUN.sync_run_id, + ) + created_binding = CurrentIMBindingState( + binding_id=IMBindingId("created-binding-1"), + identity_id=created_identity.identity_id, + contact_id=contact.contact_id, + ) + + second = _plan( + _input( + entries=(_entry("provider-1", email="same@example.com"),), + identities=(created_identity,), + bindings=(created_binding,), + reconciled_binding_ids=frozenset({created_binding.binding_id}), + contacts=(contact,), + ) + ) + + assert len(first.binding_mutations) == 1 + assert second.binding_mutations == () + assert second.sync_results[0].result_type is IMSyncResultType.SKIPPED diff --git a/api/tests/unit_tests/core/human_input_v2/test_im_sync_reconciliation_contract.py b/api/tests/unit_tests/core/human_input_v2/test_im_sync_reconciliation_contract.py new file mode 100644 index 00000000000..dfcceb7678f --- /dev/null +++ b/api/tests/unit_tests/core/human_input_v2/test_im_sync_reconciliation_contract.py @@ -0,0 +1,77 @@ +"""Architecture tests for the pure IM reconciliation planner contract.""" + +from __future__ import annotations + +import ast +import dataclasses +import inspect + +from core.human_input_v2.im_integration import sync_reconciliation + +_COMPOSITE_VALUE_TYPES = ( + sync_reconciliation.ReconciliationRunRef, + sync_reconciliation.CurrentIMIdentityState, + sync_reconciliation.ContactEmailMatchState, + sync_reconciliation.CurrentIMBindingState, + sync_reconciliation.ReconciliationInput, + sync_reconciliation.ExistingIMIdentityRef, + sync_reconciliation.NewIMIdentityRef, + sync_reconciliation.IMIdentityUpsert, + sync_reconciliation.CreateIMBinding, + sync_reconciliation.ReplaceIMBinding, + sync_reconciliation.DeleteIMBinding, + sync_reconciliation.IMIdentityDeletion, + sync_reconciliation.PlannedSyncResult, + sync_reconciliation.PlannedReconciliationWarning, + sync_reconciliation.ReconciliationPlan, + sync_reconciliation.ReconciliationBlock, + sync_reconciliation.BlockedReconciliation, +) + +_FORBIDDEN_IMPORT_PREFIXES = ( + "flask", + "sqlalchemy", + "controllers", + "repositories", + "services", +) + +_FORBIDDEN_PUBLIC_FIELD_FRAGMENTS = ( + "credential", + "workspace", + "edition", + "binding_scope", + "raw_payload", +) + + +def test_planner_exposes_immutable_composite_plan_values() -> None: + for value_type in _COMPOSITE_VALUE_TYPES: + parameters = value_type.__dict__["__dataclass_params__"] + assert parameters.frozen is True + assert "__slots__" in value_type.__dict__ + + +def test_planner_imports_no_transport_or_infrastructure_layer() -> None: + source = inspect.getsource(sync_reconciliation) + tree = ast.parse(source) + imported_modules = {alias.name for node in ast.walk(tree) if isinstance(node, ast.Import) for alias in node.names} + imported_modules.update( + node.module for node in ast.walk(tree) if isinstance(node, ast.ImportFrom) and node.module is not None + ) + + assert not any( + module == prefix or module.startswith(f"{prefix}.") + for module in imported_modules + for prefix in _FORBIDDEN_IMPORT_PREFIXES + ) + + +def test_planner_public_values_do_not_expose_scope_or_transport_material() -> None: + public_field_names = { + field.name for value_type in _COMPOSITE_VALUE_TYPES for field in dataclasses.fields(value_type) + } + + assert not any( + fragment in field_name for field_name in public_field_names for fragment in _FORBIDDEN_PUBLIC_FIELD_FRAGMENTS + ) diff --git a/api/tests/unit_tests/core/human_input_v2/test_im_sync_reconciliation_properties.py b/api/tests/unit_tests/core/human_input_v2/test_im_sync_reconciliation_properties.py new file mode 100644 index 00000000000..8322658718d --- /dev/null +++ b/api/tests/unit_tests/core/human_input_v2/test_im_sync_reconciliation_properties.py @@ -0,0 +1,1558 @@ +"""Law-first properties for pure IM reconciliation planning. + +The valid generator constructs relationship graphs that satisfy the planner's +input invariants. Small transforms then derive business ambiguity, conservative +recovery, and exactly-one-structural-violation domains. The projector interprets +only public typed mutations; it deliberately contains no matching policy. + +Human-readable decision-table regressions remain in +``test_im_sync_reconciliation.py``. Canonical ``@example`` cases are repeated +here so each law has a local, readable anchor before Hypothesis explores the +larger relationship-graph domain. +""" + +from __future__ import annotations + +from collections import Counter, defaultdict +from dataclasses import dataclass, replace +from enum import StrEnum + +from hypothesis import example, given, settings +from hypothesis import strategies as st + +from core.human_input_v2.entities import IMProvider, IMSyncResultType +from core.human_input_v2.im_integration import ( + BlockedReconciliation, + ContactEmailMatchState, + CreateIMBinding, + CurrentIMBindingState, + CurrentIMIdentityState, + DeleteIMBinding, + ExistingIMIdentityRef, + IMChannelRevision, + IMIdentityUpsertKind, + NewIMIdentityRef, + PlannedSyncResult, + ReconciliationBlockCode, + ReconciliationInput, + ReconciliationPlan, + ReconciliationRunRef, + ReplaceIMBinding, + SyncReconciler, +) +from core.human_input_v2.im_integration.adapters import DirectoryEntry, ProviderUserId +from core.human_input_v2.shared import ( + ContactId, + IMBindingId, + IMIdentityId, + IMSyncRunId, + NormalizedEmail, +) + +_RUN = ReconciliationRunRef( + sync_run_id=IMSyncRunId("property-run"), + channel_revision=IMChannelRevision("property-channel", 7), + provider=IMProvider.FEISHU, +) + + +class _Topology(StrEnum): + EMPTY = "empty" + DIRECTORY_ONLY = "directory_only" + CURRENT_ONLY = "current_only" + OVERLAP = "overlap" + MINIMAL_NON_EMPTY = "minimal_non_empty" + MIXED = "mixed" + + +class _DerivedDomain(StrEnum): + VALID = "valid" + MISSING_OR_NO_MATCH = "missing_or_no_match" + PROVIDER_EMAIL_AMBIGUITY = "provider_email_ambiguity" + CONTACT_ALREADY_BOUND = "contact_already_bound" + REPLACEMENT_WITH_COMPETITION = "replacement_with_competition" + DUPLICATE_CONTACT_RECOVERY = "duplicate_contact_recovery" + + +class _InvalidDomain(StrEnum): + DUPLICATE_DIRECTORY_PROVIDER = "duplicate_directory_provider" + DUPLICATE_CURRENT_IDENTITY = "duplicate_current_identity" + INVALID_CURRENT_BINDING = "invalid_current_binding" + INVALID_RECONCILED_BINDING_SET = "invalid_reconciled_binding_set" + + +@dataclass(frozen=True, slots=True) +class _GeneratedCase: + reconciliation_input: ReconciliationInput + domain: _DerivedDomain + + +@dataclass(frozen=True, slots=True) +class _InvalidCase: + reconciliation_input: ReconciliationInput + domain: _InvalidDomain + expected_code: ReconciliationBlockCode + + +@dataclass(frozen=True, slots=True) +class _ExistingBindingCase: + reconciliation_input: ReconciliationInput + provider_user_id: ProviderUserId + identity: CurrentIMIdentityState + binding: CurrentIMBindingState + override: CurrentIMBindingState + contact: ContactEmailMatchState + + +@dataclass(frozen=True, slots=True) +class _ProjectedState: + identities: tuple[CurrentIMIdentityState, ...] + bindings: tuple[CurrentIMBindingState, ...] + reconciled_binding_ids: frozenset[IMBindingId] + + +@dataclass(frozen=True, slots=True) +class _SemanticPlan: + identity_upserts: tuple[tuple[str, ...], ...] + binding_mutations: tuple[tuple[str, ...], ...] + identity_deletions: tuple[tuple[str, ...], ...] + sync_results: tuple[tuple[str, ...], ...] + warnings: tuple[tuple[str, ...], ...] + + +@dataclass(frozen=True, slots=True) +class _SemanticResult: + plan: _SemanticPlan | None + blockers: tuple[tuple[str, ...], ...] + + +def _entry( + provider_user_id: ProviderUserId, + *, + display_name: str | None, + email: str | None, +) -> DirectoryEntry: + return DirectoryEntry(provider_user_id=provider_user_id, display_name=display_name, email=email) + + +def _normalized(email: str | None) -> NormalizedEmail | None: + if email is None or not email.strip(): + return None + try: + return NormalizedEmail(email) + except ValueError: + return None + + +def _identity( + identity_id: IMIdentityId, + provider_user_id: ProviderUserId, + *, + display_name: str | None, + email: str | None, +) -> CurrentIMIdentityState: + return CurrentIMIdentityState( + identity_id=identity_id, + provider_user_id=provider_user_id, + display_name=display_name, + email=email, + normalized_email=_normalized(email), + last_seen_sync_run_id=None, + ) + + +def _contact(contact_id: ContactId, email: str) -> ContactEmailMatchState: + return ContactEmailMatchState( + contact_id=contact_id, + display_name=f"Contact {contact_id}", + email=email, + normalized_email=NormalizedEmail(email), + avatar_file_id=None, + ) + + +def _append_facts( + reconciliation_input: ReconciliationInput, + *, + entries: tuple[DirectoryEntry, ...] = (), + identities: tuple[CurrentIMIdentityState, ...] = (), + bindings: tuple[CurrentIMBindingState, ...] = (), + reconciled_binding_ids: frozenset[IMBindingId] = frozenset(), + contacts: tuple[ContactEmailMatchState, ...] = (), +) -> ReconciliationInput: + return replace( + reconciliation_input, + directory_entries=(*reconciliation_input.directory_entries, *entries), + current_identities=(*reconciliation_input.current_identities, *identities), + current_bindings=(*reconciliation_input.current_bindings, *bindings), + reconciled_binding_ids=reconciliation_input.reconciled_binding_ids | reconciled_binding_ids, + contacts_for_email_matching=(*reconciliation_input.contacts_for_email_matching, *contacts), + ) + + +# These named examples make the laws reviewable without reverse-engineering the +# generators. They complement, rather than replace, the decision-table tests. +def _canonical_empty_case() -> _GeneratedCase: + return _GeneratedCase( + reconciliation_input=ReconciliationInput( + run=_RUN, + directory_entries=(), + current_identities=(), + current_bindings=(), + reconciled_binding_ids=frozenset(), + contacts_for_email_matching=(), + ), + domain=_DerivedDomain.VALID, + ) + + +def _canonical_unique_match_case() -> _GeneratedCase: + email = "canonical-unique@example.com" + return _GeneratedCase( + reconciliation_input=_append_facts( + _canonical_empty_case().reconciliation_input, + entries=( + _entry( + ProviderUserId("canonical-unique-provider"), + display_name="Canonical Unique", + email=email, + ), + ), + contacts=(_contact(ContactId("canonical-unique-contact"), email),), + ), + domain=_DerivedDomain.VALID, + ) + + +def _canonical_missing_email_case() -> _GeneratedCase: + return _GeneratedCase( + reconciliation_input=_append_facts( + _canonical_empty_case().reconciliation_input, + entries=( + _entry( + ProviderUserId("canonical-missing-email-provider"), + display_name="Canonical Missing Email", + email=None, + ), + ), + ), + domain=_DerivedDomain.MISSING_OR_NO_MATCH, + ) + + +def _canonical_replacement_case() -> _GeneratedCase: + email = "canonical-replacement@example.com" + previous_identity = _identity( + IMIdentityId("canonical-previous-identity"), + ProviderUserId("canonical-previous-provider"), + display_name="Previous Identity", + email=email, + ) + contact = _contact(ContactId("canonical-replacement-contact"), email) + binding = CurrentIMBindingState( + binding_id=IMBindingId("canonical-replacement-binding"), + identity_id=previous_identity.identity_id, + contact_id=contact.contact_id, + ) + return _GeneratedCase( + reconciliation_input=_append_facts( + _canonical_empty_case().reconciliation_input, + entries=( + _entry( + ProviderUserId("canonical-replacement-provider"), + display_name="Replacement Identity", + email=email, + ), + ), + identities=(previous_identity,), + bindings=(binding,), + reconciled_binding_ids=frozenset({binding.binding_id}), + contacts=(contact,), + ), + domain=_DerivedDomain.VALID, + ) + + +def _canonical_existing_binding_case() -> _ExistingBindingCase: + email = "canonical-preserved@example.com" + provider_user_id = ProviderUserId("canonical-preserved-provider") + identity = _identity( + IMIdentityId("canonical-preserved-identity"), + provider_user_id, + display_name="Canonical Preserved", + email=email, + ) + contact = _contact(ContactId("canonical-preserved-contact"), email) + binding = CurrentIMBindingState( + binding_id=IMBindingId("canonical-preserved-binding"), + identity_id=identity.identity_id, + contact_id=contact.contact_id, + ) + override = CurrentIMBindingState( + binding_id=IMBindingId("canonical-preserved-override"), + identity_id=identity.identity_id, + contact_id=ContactId("canonical-override-contact"), + ) + reconciliation_input = _append_facts( + _canonical_empty_case().reconciliation_input, + entries=( + _entry( + provider_user_id, + display_name="Canonical Preserved", + email=email, + ), + ), + identities=(identity,), + bindings=(binding, override), + reconciled_binding_ids=frozenset({binding.binding_id}), + contacts=(contact,), + ) + return _ExistingBindingCase( + reconciliation_input=reconciliation_input, + provider_user_id=provider_user_id, + identity=identity, + binding=binding, + override=override, + contact=contact, + ) + + +def _canonical_duplicate_provider_case() -> _InvalidCase: + entry = _entry( + ProviderUserId("canonical-duplicate-provider"), + display_name=None, + email=None, + ) + return _InvalidCase( + reconciliation_input=_append_facts( + _canonical_empty_case().reconciliation_input, + entries=(entry, entry), + ), + domain=_InvalidDomain.DUPLICATE_DIRECTORY_PROVIDER, + expected_code=ReconciliationBlockCode.DUPLICATE_PROVIDER_USER_ID, + ) + + +def _canonical_duplicate_contact_recovery_case() -> _GeneratedCase: + email = "canonical-collision@example.com" + return _GeneratedCase( + reconciliation_input=_append_facts( + _canonical_empty_case().reconciliation_input, + entries=( + _entry( + ProviderUserId("canonical-collision-provider"), + display_name="Canonical Collision", + email=email, + ), + ), + contacts=( + _contact(ContactId("canonical-collision-contact-a"), email), + _contact(ContactId("canonical-collision-contact-b"), email.upper()), + ), + ), + domain=_DerivedDomain.DUPLICATE_CONTACT_RECOVERY, + ) + + +def _draw_topology(draw: st.DrawFn) -> tuple[tuple[int, ...], tuple[int, ...]]: + """Choose bounded directory/current membership shapes with explicit structural boundaries. + + Named topology families keep the generated domain intentional and make empty, + disjoint, and overlapping relationship graphs recurring test inputs. + """ + topology = draw(st.sampled_from(tuple(_Topology))) + provider_indices = tuple(range(6)) + index_sets = st.sets(st.sampled_from(provider_indices), max_size=5) + if topology is _Topology.EMPTY: + return (), () + if topology in {_Topology.DIRECTORY_ONLY, _Topology.MINIMAL_NON_EMPTY}: + if topology is _Topology.MINIMAL_NON_EMPTY: + return (0,), () + return tuple(sorted(draw(st.sets(st.sampled_from(provider_indices), min_size=1, max_size=5)))), () + if topology is _Topology.CURRENT_ONLY: + return (), tuple(sorted(draw(st.sets(st.sampled_from(provider_indices), min_size=1, max_size=5)))) + if topology is _Topology.OVERLAP: + pivot = draw(st.sampled_from(provider_indices)) + remaining = tuple(index for index in provider_indices if index != pivot) + extras = st.sets(st.sampled_from(remaining), max_size=4) + directory_indices = {pivot, *draw(extras)} + current_indices = {pivot, *draw(extras)} + return tuple(sorted(directory_indices)), tuple(sorted(current_indices)) + return tuple(sorted(draw(index_sets))), tuple(sorted(draw(index_sets))) + + +@st.composite +def _valid_relationship_graphs(draw: st.DrawFn) -> _GeneratedCase: + """Generate varied relationship graphs that satisfy every planner input invariant. + + Keeping this domain structurally valid ensures failures describe reconciliation + laws instead of accidental noise from impossible persisted state. + """ + directory_indices, current_indices = _draw_topology(draw) + contact_count = draw(st.integers(min_value=0, max_value=4)) + contacts = tuple( + _contact(ContactId(f"contact-{index}"), f"contact-{index}@example.com") for index in range(contact_count) + ) + + entries: list[DirectoryEntry] = [] + entry_by_index: dict[int, DirectoryEntry] = {} + contact_emails = tuple(contact.email for contact in contacts if contact.email is not None) + for provider_index in directory_indices: + email_options: tuple[str | None, ...] = ( + None, + " ", + f"missing-{provider_index}@example.com", + *contact_emails, + ) + email = draw(st.sampled_from(email_options)) + if email is not None and email.strip() and draw(st.booleans()): + email = f" {email.upper()} " + display_name = draw(st.sampled_from((None, f"User {provider_index}", f"Renamed {provider_index}"))) + entry = _entry( + ProviderUserId(f"provider-{provider_index}"), + display_name=display_name, + email=email, + ) + entries.append(entry) + entry_by_index[provider_index] = entry + + identities: list[CurrentIMIdentityState] = [] + for provider_index in current_indices: + provider_user_id = ProviderUserId(f"provider-{provider_index}") + if provider_index in entry_by_index and draw(st.booleans()): + current_entry = entry_by_index[provider_index] + display_name = current_entry.display_name + email = current_entry.email + else: + display_name = f"Stored {provider_index}" + email = f"stored-{provider_index}@example.com" + identities.append( + _identity( + IMIdentityId(f"identity-{provider_index}"), + provider_user_id, + display_name=display_name, + email=email, + ) + ) + + reconciled_identity_indices: tuple[int, ...] + if current_indices: + reconciled_identity_indices = tuple( + sorted( + draw( + st.sets( + st.sampled_from(current_indices), + max_size=min(len(current_indices), 4), + ) + ) + ) + ) + else: + reconciled_identity_indices = () + endpoint_indices = draw( + st.lists( + st.integers(min_value=0, max_value=contact_count + 6), + min_size=len(reconciled_identity_indices), + max_size=len(reconciled_identity_indices), + unique=True, + ) + ) + bindings: list[CurrentIMBindingState] = [] + reconciled_binding_ids: set[IMBindingId] = set() + for provider_index, endpoint_index in zip(reconciled_identity_indices, endpoint_indices, strict=True): + binding_id = IMBindingId(f"binding-reconciled-{provider_index}") + contact_id = ( + ContactId(f"contact-{endpoint_index}") + if endpoint_index < contact_count + else ContactId(f"outside-contact-{endpoint_index}") + ) + bindings.append( + CurrentIMBindingState( + binding_id=binding_id, + identity_id=IMIdentityId(f"identity-{provider_index}"), + contact_id=contact_id, + ) + ) + reconciled_binding_ids.add(binding_id) + + if current_indices: + override_identity_indices = draw(st.lists(st.sampled_from(current_indices), min_size=0, max_size=4)) + else: + override_identity_indices: list[int] = [] + for override_index, provider_index in enumerate(override_identity_indices): + bindings.append( + CurrentIMBindingState( + binding_id=IMBindingId(f"binding-override-{override_index}"), + identity_id=IMIdentityId(f"identity-{provider_index}"), + contact_id=ContactId(f"override-contact-{override_index}"), + ) + ) + + return _GeneratedCase( + reconciliation_input=ReconciliationInput( + run=_RUN, + directory_entries=tuple(entries), + current_identities=tuple(identities), + current_bindings=tuple(bindings), + reconciled_binding_ids=frozenset(reconciled_binding_ids), + contacts_for_email_matching=contacts, + ), + domain=_DerivedDomain.VALID, + ) + + +@st.composite +def _business_or_recovery_cases(draw: st.DrawFn) -> _GeneratedCase: + """Extend valid graphs with one business ambiguity or recoverable contact collision. + + These cases exercise conservative outcomes without conflating them with the + structural corruption that must block an entire reconciliation run. + """ + base = draw(_valid_relationship_graphs()).reconciliation_input + suffix = draw(st.integers(min_value=0, max_value=10_000)) + domain = draw( + st.sampled_from( + ( + _DerivedDomain.MISSING_OR_NO_MATCH, + _DerivedDomain.PROVIDER_EMAIL_AMBIGUITY, + _DerivedDomain.CONTACT_ALREADY_BOUND, + _DerivedDomain.REPLACEMENT_WITH_COMPETITION, + _DerivedDomain.DUPLICATE_CONTACT_RECOVERY, + ) + ) + ) + prefix = f"derived-{suffix}" + + if domain is _DerivedDomain.MISSING_OR_NO_MATCH: + email = draw(st.sampled_from((None, " ", f"{prefix}-missing@example.com"))) + entry = _entry(ProviderUserId(f"{prefix}-entry"), display_name=None, email=email) + return _GeneratedCase(_append_facts(base, entries=(entry,)), domain) + + if domain is _DerivedDomain.PROVIDER_EMAIL_AMBIGUITY: + email = f"{prefix}-shared@example.com" + entries = ( + _entry(ProviderUserId(f"{prefix}-entry-a"), display_name=None, email=email), + _entry(ProviderUserId(f"{prefix}-entry-b"), display_name=None, email=email), + ) + contact = _contact(ContactId(f"{prefix}-contact"), email) + return _GeneratedCase(_append_facts(base, entries=entries, contacts=(contact,)), domain) + + if domain is _DerivedDomain.CONTACT_ALREADY_BOUND: + email = f"{prefix}-occupied@example.com" + bound_provider_user_id = ProviderUserId(f"{prefix}-bound") + identity = _identity( + IMIdentityId(f"{prefix}-identity"), + bound_provider_user_id, + display_name="Bound", + email=email, + ) + contact = _contact(ContactId(f"{prefix}-contact"), email) + binding = CurrentIMBindingState( + binding_id=IMBindingId(f"{prefix}-binding"), + identity_id=identity.identity_id, + contact_id=contact.contact_id, + ) + entries = ( + _entry(bound_provider_user_id, display_name="Bound", email=email), + _entry(ProviderUserId(f"{prefix}-candidate"), display_name=None, email=email), + ) + return _GeneratedCase( + _append_facts( + base, + entries=entries, + identities=(identity,), + bindings=(binding,), + reconciled_binding_ids=frozenset({binding.binding_id}), + contacts=(contact,), + ), + domain, + ) + + if domain is _DerivedDomain.REPLACEMENT_WITH_COMPETITION: + replacement_email = f"{prefix}-replacement@example.com" + competition_email = f"{prefix}-competition@example.com" + absent_identity = _identity( + IMIdentityId(f"{prefix}-absent-identity"), + ProviderUserId(f"{prefix}-absent"), + display_name="Absent", + email=replacement_email, + ) + replacement_contact = _contact(ContactId(f"{prefix}-replacement-contact"), replacement_email) + competition_contact = _contact(ContactId(f"{prefix}-competition-contact"), competition_email) + binding = CurrentIMBindingState( + binding_id=IMBindingId(f"{prefix}-binding"), + identity_id=absent_identity.identity_id, + contact_id=replacement_contact.contact_id, + ) + entries = ( + _entry(ProviderUserId(f"{prefix}-replacement"), display_name=None, email=replacement_email), + _entry(ProviderUserId(f"{prefix}-competitor-a"), display_name=None, email=competition_email), + _entry(ProviderUserId(f"{prefix}-competitor-b"), display_name=None, email=competition_email), + ) + return _GeneratedCase( + _append_facts( + base, + entries=entries, + identities=(absent_identity,), + bindings=(binding,), + reconciled_binding_ids=frozenset({binding.binding_id}), + contacts=(replacement_contact, competition_contact), + ), + domain, + ) + + collision_email = f"{prefix}-collision@example.com" + bound_provider_user_id = ProviderUserId(f"{prefix}-bound") + bound_identity = _identity( + IMIdentityId(f"{prefix}-bound-identity"), + bound_provider_user_id, + display_name="Bound", + email=collision_email, + ) + first_contact = _contact(ContactId(f"{prefix}-contact-a"), collision_email) + second_contact = _contact(ContactId(f"{prefix}-contact-b"), collision_email.upper()) + binding = CurrentIMBindingState( + binding_id=IMBindingId(f"{prefix}-binding"), + identity_id=bound_identity.identity_id, + contact_id=first_contact.contact_id, + ) + entries = ( + _entry(bound_provider_user_id, display_name="Bound", email=collision_email), + _entry(ProviderUserId(f"{prefix}-unbound"), display_name=None, email=collision_email), + ) + return _GeneratedCase( + _append_facts( + base, + entries=entries, + identities=(bound_identity,), + bindings=(binding,), + reconciled_binding_ids=frozenset({binding.binding_id}), + contacts=(first_contact, second_contact), + ), + domain, + ) + + +def _plan_cases() -> st.SearchStrategy[_GeneratedCase]: + return st.one_of(_valid_relationship_graphs(), _business_or_recovery_cases()) + + +@st.composite +def _single_violation_cases(draw: st.DrawFn) -> _InvalidCase: + """Add exactly one structural violation to an otherwise valid relationship graph. + + Isolating the broken invariant makes the expected blocker a trustworthy oracle + for both soundness and completeness. + """ + base = draw(_valid_relationship_graphs()).reconciliation_input + suffix = draw(st.integers(min_value=0, max_value=10_000)) + domain = draw(st.sampled_from(tuple(_InvalidDomain))) + prefix = f"invalid-{suffix}" + + if domain is _InvalidDomain.DUPLICATE_DIRECTORY_PROVIDER: + entry = _entry(ProviderUserId(f"{prefix}-provider"), display_name=None, email=None) + return _InvalidCase( + _append_facts(base, entries=(entry, entry)), + domain, + ReconciliationBlockCode.DUPLICATE_PROVIDER_USER_ID, + ) + + if domain is _InvalidDomain.DUPLICATE_CURRENT_IDENTITY: + duplicate_kind = draw(st.sampled_from(("identity_id", "provider_user_id"))) + first_identity_id = IMIdentityId(f"{prefix}-identity") + second_identity_id = ( + first_identity_id if duplicate_kind == "identity_id" else IMIdentityId(f"{prefix}-identity-other") + ) + first_provider_user_id = ProviderUserId(f"{prefix}-provider") + second_provider_user_id = ( + ProviderUserId(f"{prefix}-provider-other") if duplicate_kind == "identity_id" else first_provider_user_id + ) + identities = ( + _identity(first_identity_id, first_provider_user_id, display_name=None, email=None), + _identity(second_identity_id, second_provider_user_id, display_name=None, email=None), + ) + return _InvalidCase( + _append_facts(base, identities=identities), + domain, + ReconciliationBlockCode.DUPLICATE_CURRENT_IDENTITY, + ) + + if domain is _InvalidDomain.INVALID_CURRENT_BINDING: + invalid_kind = draw(st.sampled_from(("duplicate_binding_id", "dangling_identity"))) + if invalid_kind == "dangling_identity": + binding = CurrentIMBindingState( + binding_id=IMBindingId(f"{prefix}-binding"), + identity_id=IMIdentityId(f"{prefix}-missing-identity"), + contact_id=ContactId(f"{prefix}-contact"), + ) + invalid_input = _append_facts(base, bindings=(binding,)) + else: + identity = _identity( + IMIdentityId(f"{prefix}-identity"), + ProviderUserId(f"{prefix}-provider"), + display_name=None, + email=None, + ) + binding_id = IMBindingId(f"{prefix}-binding") + bindings = ( + CurrentIMBindingState(binding_id, identity.identity_id, ContactId(f"{prefix}-contact-a")), + CurrentIMBindingState(binding_id, identity.identity_id, ContactId(f"{prefix}-contact-b")), + ) + invalid_input = _append_facts(base, identities=(identity,), bindings=bindings) + return _InvalidCase( + invalid_input, + domain, + ReconciliationBlockCode.INVALID_CURRENT_BINDING, + ) + + invalid_kind = draw(st.sampled_from(("outside_subset", "duplicate_identity", "duplicate_contact"))) + if invalid_kind == "outside_subset": + invalid_input = replace( + base, + reconciled_binding_ids=base.reconciled_binding_ids | {IMBindingId(f"{prefix}-missing-binding")}, + ) + elif invalid_kind == "duplicate_identity": + identity = _identity( + IMIdentityId(f"{prefix}-identity"), + ProviderUserId(f"{prefix}-provider"), + display_name=None, + email=None, + ) + bindings = ( + CurrentIMBindingState( + IMBindingId(f"{prefix}-binding-a"), identity.identity_id, ContactId(f"{prefix}-contact-a") + ), + CurrentIMBindingState( + IMBindingId(f"{prefix}-binding-b"), identity.identity_id, ContactId(f"{prefix}-contact-b") + ), + ) + invalid_input = _append_facts( + base, + identities=(identity,), + bindings=bindings, + reconciled_binding_ids=frozenset(binding.binding_id for binding in bindings), + ) + else: + identities = ( + _identity( + IMIdentityId(f"{prefix}-identity-a"), + ProviderUserId(f"{prefix}-provider-a"), + display_name=None, + email=None, + ), + _identity( + IMIdentityId(f"{prefix}-identity-b"), + ProviderUserId(f"{prefix}-provider-b"), + display_name=None, + email=None, + ), + ) + contact_id = ContactId(f"{prefix}-contact") + bindings = tuple( + CurrentIMBindingState(IMBindingId(f"{prefix}-binding-{index}"), identity.identity_id, contact_id) + for index, identity in enumerate(identities) + ) + invalid_input = _append_facts( + base, + identities=identities, + bindings=bindings, + reconciled_binding_ids=frozenset(binding.binding_id for binding in bindings), + ) + return _InvalidCase( + invalid_input, + domain, + ReconciliationBlockCode.INVALID_RECONCILED_BINDING_SET, + ) + + +@st.composite +def _existing_binding_cases(draw: st.DrawFn) -> _ExistingBindingCase: + """Embed a known reconciled binding and manual override in a valid background graph. + + This focused domain exposes non-interference regressions while unrelated facts + continue to vary around the preserved bindings. + """ + base = draw(_valid_relationship_graphs()).reconciliation_input + suffix = draw(st.integers(min_value=0, max_value=10_000)) + prefix = f"preserved-{suffix}" + email = f"{prefix}@example.com" + provider_user_id = ProviderUserId(f"{prefix}-provider") + identity = _identity( + IMIdentityId(f"{prefix}-identity"), + provider_user_id, + display_name="Preserved", + email=email, + ) + contact = _contact(ContactId(f"{prefix}-contact"), email) + binding = CurrentIMBindingState( + IMBindingId(f"{prefix}-binding"), + identity.identity_id, + contact.contact_id, + ) + override = CurrentIMBindingState( + IMBindingId(f"{prefix}-override"), + identity.identity_id, + ContactId(f"{prefix}-override-contact"), + ) + entry = _entry(provider_user_id, display_name="Preserved", email=email) + reconciliation_input = _append_facts( + base, + entries=(entry,), + identities=(identity,), + bindings=(binding, override), + reconciled_binding_ids=frozenset({binding.binding_id}), + contacts=(contact,), + ) + return _ExistingBindingCase(reconciliation_input, provider_user_id, identity, binding, override, contact) + + +def _generate_plan(reconciliation_input: ReconciliationInput) -> ReconciliationPlan: + result = SyncReconciler.generate_plan(reconciliation_input) + assert isinstance(result, ReconciliationPlan) + return result + + +def _provider_for_ref( + reconciliation_input: ReconciliationInput, + identity_ref: ExistingIMIdentityRef | NewIMIdentityRef | None, +) -> ProviderUserId | None: + if identity_ref is None: + return None + if isinstance(identity_ref, NewIMIdentityRef): + return identity_ref.provider_user_id + identity_by_id = {identity.identity_id: identity for identity in reconciliation_input.current_identities} + return identity_by_id[identity_ref.identity_id].provider_user_id + + +def _semantic_result( + reconciliation_input: ReconciliationInput, + result: ReconciliationPlan | BlockedReconciliation, +) -> _SemanticResult: + """Normalize a result to public semantics while discarding collection order. + + Permutation laws need this observable because output ordering is not the contract, + while mutation, result, warning, and blocker meaning is. + """ + if isinstance(result, BlockedReconciliation): + return _SemanticResult( + plan=None, + blockers=tuple( + sorted((block.code.value, block.subject_key or "", block.message) for block in result.blockers) + ), + ) + + identity_upserts = tuple( + sorted( + ( + upsert.kind.value, + str(upsert.entry.provider_user_id), + str(upsert.normalized_email or ""), + "\x1f".join(upsert.changed_fields), + upsert.operation_key, + ) + for upsert in result.identity_upserts + ) + ) + binding_mutations: list[tuple[str, ...]] = [] + for mutation in result.binding_mutations: + if isinstance(mutation, CreateIMBinding): + binding_mutations.append( + ( + "create", + str(_provider_for_ref(reconciliation_input, mutation.identity_ref)), + str(mutation.contact_id), + mutation.reason.value, + mutation.operation_key, + ) + ) + elif isinstance(mutation, ReplaceIMBinding): + binding_mutations.append( + ( + "replace", + str(mutation.before.binding_id), + str(_provider_for_ref(reconciliation_input, ExistingIMIdentityRef(mutation.before.identity_id))), + str(_provider_for_ref(reconciliation_input, mutation.next_identity_ref)), + str(mutation.before.contact_id), + mutation.reason.value, + mutation.removal_reason.value, + mutation.operation_key, + ) + ) + else: + binding_mutations.append( + ( + "delete", + str(mutation.before.binding_id), + str(_provider_for_ref(reconciliation_input, ExistingIMIdentityRef(mutation.before.identity_id))), + str(mutation.before.contact_id), + mutation.reason.value, + mutation.removal_reason.value, + mutation.operation_key, + ) + ) + identity_deletions = tuple( + sorted( + ( + str(deletion.before.provider_user_id), + deletion.reason.value, + deletion.operation_key, + ) + for deletion in result.identity_deletions + ) + ) + sync_results = tuple( + sorted( + ( + item.result_type.value, + str(item.provider_user_id or ""), + str(_provider_for_ref(reconciliation_input, item.identity_ref) or ""), + str(item.binding_id or ""), + str(item.contact_id or ""), + item.reason_code or "", + item.operation_key, + ) + for item in result.sync_results + ) + ) + warnings = tuple( + sorted( + ( + warning.reason.value, + "\x1f".join(sorted(str(_provider_for_ref(reconciliation_input, ref)) for ref in warning.identity_refs)), + "\x1f".join(sorted(str(contact_id) for contact_id in warning.contact_ids)), + warning.warning_key, + ) + for warning in result.warnings + ) + ) + return _SemanticResult( + plan=_SemanticPlan( + identity_upserts=identity_upserts, + binding_mutations=tuple(sorted(binding_mutations)), + identity_deletions=identity_deletions, + sync_results=sync_results, + warnings=warnings, + ), + blockers=(), + ) + + +def _resolve_identity_id(identity_ref: ExistingIMIdentityRef | NewIMIdentityRef) -> IMIdentityId: + if isinstance(identity_ref, ExistingIMIdentityRef): + return identity_ref.identity_id + return IMIdentityId(f"projected-identity:{identity_ref.provider_user_id}") + + +def _project_plan(reconciliation_input: ReconciliationInput, plan: ReconciliationPlan) -> _ProjectedState: + """Interpret typed plan mutations as the persisted state visible to the next run. + + This policy-free projector enables closure and convergence laws without duplicating + the reconciler's matching decisions in the test oracle. + """ + identities = {identity.identity_id: identity for identity in reconciliation_input.current_identities} + for upsert in plan.identity_upserts: + identity_id = _resolve_identity_id(upsert.identity_ref) + identities[identity_id] = CurrentIMIdentityState( + identity_id=identity_id, + provider_user_id=upsert.entry.provider_user_id, + display_name=upsert.entry.display_name, + email=upsert.entry.email, + normalized_email=upsert.normalized_email, + last_seen_sync_run_id=plan.run.sync_run_id, + ) + + bindings = {binding.binding_id: binding for binding in reconciliation_input.current_bindings} + reconciled_binding_ids = set(reconciliation_input.reconciled_binding_ids) + for mutation in plan.binding_mutations: + if isinstance(mutation, CreateIMBinding): + provider_user_id = _provider_for_ref(reconciliation_input, mutation.identity_ref) + assert provider_user_id is not None + binding_id = IMBindingId(f"projected-binding:{provider_user_id}:{mutation.contact_id}") + bindings[binding_id] = CurrentIMBindingState( + binding_id=binding_id, + identity_id=_resolve_identity_id(mutation.identity_ref), + contact_id=mutation.contact_id, + ) + reconciled_binding_ids.add(binding_id) + elif isinstance(mutation, ReplaceIMBinding): + was_reconciled = mutation.before.binding_id in reconciled_binding_ids + bindings.pop(mutation.before.binding_id) + reconciled_binding_ids.discard(mutation.before.binding_id) + provider_user_id = _provider_for_ref(reconciliation_input, mutation.next_identity_ref) + assert provider_user_id is not None + binding_id = IMBindingId(f"projected-replacement:{mutation.before.binding_id}:{provider_user_id}") + bindings[binding_id] = CurrentIMBindingState( + binding_id=binding_id, + identity_id=_resolve_identity_id(mutation.next_identity_ref), + contact_id=mutation.before.contact_id, + ) + if was_reconciled: + reconciled_binding_ids.add(binding_id) + else: + bindings.pop(mutation.before.binding_id) + reconciled_binding_ids.discard(mutation.before.binding_id) + + for deletion in plan.identity_deletions: + identities.pop(deletion.before.identity_id) + + return _ProjectedState( + identities=tuple(sorted(identities.values(), key=lambda identity: str(identity.identity_id))), + bindings=tuple(sorted(bindings.values(), key=lambda binding: str(binding.binding_id))), + reconciled_binding_ids=frozenset(reconciled_binding_ids), + ) + + +def _next_input(reconciliation_input: ReconciliationInput, projected: _ProjectedState) -> ReconciliationInput: + next_run = ReconciliationRunRef( + sync_run_id=IMSyncRunId(f"{reconciliation_input.run.sync_run_id}:next"), + channel_revision=reconciliation_input.run.channel_revision, + provider=reconciliation_input.run.provider, + ) + return ReconciliationInput( + run=next_run, + directory_entries=reconciliation_input.directory_entries, + current_identities=projected.identities, + current_bindings=projected.bindings, + reconciled_binding_ids=projected.reconciled_binding_ids, + contacts_for_email_matching=reconciliation_input.contacts_for_email_matching, + ) + + +def _binding_mutation_before_id( + mutation: CreateIMBinding | ReplaceIMBinding | DeleteIMBinding, +) -> IMBindingId | None: + if isinstance(mutation, CreateIMBinding): + return None + return mutation.before.binding_id + + +def _results_for_directory_provider( + plan: ReconciliationPlan, + provider_user_id: ProviderUserId, +) -> tuple[PlannedSyncResult, ...]: + return tuple(item for item in plan.sync_results if item.provider_user_id == provider_user_id) + + +def _reversed_fact_inputs(reconciliation_input: ReconciliationInput) -> tuple[ReconciliationInput, ...]: + return ( + replace( + reconciliation_input, + directory_entries=tuple(reversed(reconciliation_input.directory_entries)), + ), + replace( + reconciliation_input, + current_identities=tuple(reversed(reconciliation_input.current_identities)), + ), + replace( + reconciliation_input, + current_bindings=tuple(reversed(reconciliation_input.current_bindings)), + ), + replace( + reconciliation_input, + contacts_for_email_matching=tuple(reversed(reconciliation_input.contacts_for_email_matching)), + ), + ) + + +@settings(max_examples=100, deadline=None) +@given(case=_plan_cases()) +@example(case=_canonical_empty_case()) +@example(case=_canonical_unique_match_case()) +@example(case=_canonical_duplicate_contact_recovery_case()) +def test_p01_valid_plan_closure_and_conservation(case: _GeneratedCase) -> None: + """Valid and recoverable graphs produce closed plans that conserve directory facts. + + The law covers reference validity, complete removal of absent identities, and a + projected state with valid one-to-one reconciled bindings. It exists because those + relationships form a combinatorial state space that decision-table examples cannot + exhaust. + """ + reconciliation_input = case.reconciliation_input + plan = _generate_plan(reconciliation_input) + directory_provider_ids = {entry.provider_user_id for entry in reconciliation_input.directory_entries} + current_provider_ids = {identity.provider_user_id for identity in reconciliation_input.current_identities} + + assert Counter(upsert.entry.provider_user_id for upsert in plan.identity_upserts) == Counter( + entry.provider_user_id for entry in reconciliation_input.directory_entries + ) + assert {deletion.before.provider_user_id for deletion in plan.identity_deletions} == ( + current_provider_ids - directory_provider_ids + ) + + current_identity_ids = {identity.identity_id for identity in reconciliation_input.current_identities} + new_provider_ids = { + upsert.identity_ref.provider_user_id + for upsert in plan.identity_upserts + if isinstance(upsert.identity_ref, NewIMIdentityRef) + } + refs = [upsert.identity_ref for upsert in plan.identity_upserts] + refs.extend( + mutation.identity_ref if isinstance(mutation, CreateIMBinding) else mutation.next_identity_ref + for mutation in plan.binding_mutations + if isinstance(mutation, (CreateIMBinding, ReplaceIMBinding)) + ) + refs.extend(item.identity_ref for item in plan.sync_results if item.identity_ref is not None) + refs.extend(ref for warning in plan.warnings for ref in warning.identity_refs) + assert all( + (isinstance(ref, ExistingIMIdentityRef) and ref.identity_id in current_identity_ids) + or (isinstance(ref, NewIMIdentityRef) and ref.provider_user_id in new_provider_ids) + for ref in refs + ) + + identity_by_id = {identity.identity_id: identity for identity in reconciliation_input.current_identities} + absent_identity_ids = { + identity.identity_id + for identity in reconciliation_input.current_identities + if identity.provider_user_id not in directory_provider_ids + } + targeted_binding_counts = Counter( + binding_id + for mutation in plan.binding_mutations + if (binding_id := _binding_mutation_before_id(mutation)) is not None + ) + for binding in reconciliation_input.current_bindings: + if binding.identity_id in absent_identity_ids: + assert targeted_binding_counts[binding.binding_id] == 1 + else: + assert identity_by_id[binding.identity_id].provider_user_id in current_provider_ids + + projected = _project_plan(reconciliation_input, plan) + projected_identity_ids = {identity.identity_id for identity in projected.identities} + assert all(binding.identity_id in projected_identity_ids for binding in projected.bindings) + projected_binding_by_id = {binding.binding_id: binding for binding in projected.bindings} + projected_reconciled = [projected_binding_by_id[binding_id] for binding_id in projected.reconciled_binding_ids] + assert max(Counter(binding.identity_id for binding in projected_reconciled).values(), default=0) <= 1 + assert max(Counter(binding.contact_id for binding in projected_reconciled).values(), default=0) <= 1 + assert {identity.provider_user_id for identity in projected.identities} == directory_provider_ids + + +@settings(max_examples=100, deadline=None) +@given(case=_plan_cases()) +@example(case=_canonical_replacement_case()) +def test_p02_determinism_and_input_permutation_invariance(case: _GeneratedCase) -> None: + """Repeated planning and fact permutations preserve the same semantic plan. + + The law prevents hidden collection-order dependencies, which are easy to miss with + fixed examples and would make equivalent persisted states reconcile differently. + """ + reconciliation_input = case.reconciliation_input + first = SyncReconciler.generate_plan(reconciliation_input) + assert SyncReconciler.generate_plan(reconciliation_input) == first + + first_semantics = _semantic_result(reconciliation_input, first) + for permuted_input in _reversed_fact_inputs(reconciliation_input): + permuted_result = SyncReconciler.generate_plan(permuted_input) + assert _semantic_result(permuted_input, permuted_result) == first_semantics + + +@settings(max_examples=100, deadline=None) +@given(case=_single_violation_cases()) +@example(case=_canonical_duplicate_provider_case()) +def test_p02_blocker_semantics_are_permutation_invariant(case: _InvalidCase) -> None: + """Permuting an invalid graph preserves its blocker semantics. + + Structural corruption must be classified from relationships rather than input + order; generated violations guard that invariant beyond a canonical blocker case. + """ + reconciliation_input = case.reconciliation_input + first = SyncReconciler.generate_plan(reconciliation_input) + first_semantics = _semantic_result(reconciliation_input, first) + + for permuted_input in _reversed_fact_inputs(reconciliation_input): + permuted_result = SyncReconciler.generate_plan(permuted_input) + assert _semantic_result(permuted_input, permuted_result) == first_semantics + + +@settings(max_examples=100, deadline=None) +@given(case=_plan_cases()) +@example(case=_canonical_replacement_case()) +def test_p03_projected_plan_converges_on_the_second_run(case: _GeneratedCase) -> None: + """Applying a plan reaches a fixed point for binding and deletion decisions. + + A second run may refresh present identities but must not repeat structural writes or + change stable outcomes. This projection law protects recurring syncs from mutation + churn that isolated first-run examples cannot reveal. + """ + first_input = case.reconciliation_input + first_plan = _generate_plan(first_input) + second_input = _next_input(first_input, _project_plan(first_input, first_plan)) + second_plan = _generate_plan(second_input) + + assert second_plan.binding_mutations == () + assert second_plan.identity_deletions == () + assert {upsert.kind for upsert in second_plan.identity_upserts} <= {IMIdentityUpsertKind.REFRESH} + assert len(second_plan.identity_upserts) == len(first_input.directory_entries) + + for entry in first_input.directory_entries: + first_results = _results_for_directory_provider(first_plan, entry.provider_user_id) + second_results = _results_for_directory_provider(second_plan, entry.provider_user_id) + assert len(first_results) == 1 + assert len(second_results) == 1 + first_result = first_results[0] + second_result = second_results[0] + if first_result.result_type is IMSyncResultType.NOT_MATCHED: + assert (second_result.result_type, second_result.reason_code) == ( + IMSyncResultType.NOT_MATCHED, + first_result.reason_code, + ) + else: + assert second_result.result_type is IMSyncResultType.SKIPPED + + +@settings(max_examples=100, deadline=None) +@given(case=_existing_binding_cases()) +@example(case=_canonical_existing_binding_case()) +def test_p04_existing_binding_and_override_non_interference(case: _ExistingBindingCase) -> None: + """Existing reconciled bindings and manual overrides change only when their owner disappears. + + Profile changes, missing match candidates, and unrelated facts must not disturb + those bindings. This law generalizes preservation across background graphs where a + small set of examples could hide cross-record interference. + """ + baseline_plan = _generate_plan(case.reconciliation_input) + target_mutation_ids = { + binding_id + for mutation in baseline_plan.binding_mutations + if (binding_id := _binding_mutation_before_id(mutation)) is not None + } + assert case.binding.binding_id not in target_mutation_ids + assert case.override.binding_id not in target_mutation_ids + assert ( + _results_for_directory_provider(baseline_plan, case.provider_user_id)[0].result_type is IMSyncResultType.SKIPPED + ) + + override_only_provider_user_id = ProviderUserId(f"override-only-{case.provider_user_id}") + override_only_email = f"override-only-{case.provider_user_id}@example.com" + override_only_identity = _identity( + IMIdentityId(f"override-only-{case.identity.identity_id}"), + override_only_provider_user_id, + display_name="Override only", + email=override_only_email, + ) + override_only_contact = _contact( + ContactId(f"override-only-{case.contact.contact_id}"), + override_only_email, + ) + override_only_binding = CurrentIMBindingState( + binding_id=IMBindingId(f"override-only-{case.override.binding_id}"), + identity_id=override_only_identity.identity_id, + contact_id=override_only_contact.contact_id, + ) + override_only_input = _append_facts( + case.reconciliation_input, + entries=( + _entry( + override_only_provider_user_id, + display_name="Override only", + email=override_only_email, + ), + ), + identities=(override_only_identity,), + bindings=(override_only_binding,), + contacts=(override_only_contact,), + ) + override_only_plan = _generate_plan(override_only_input) + assert any( + isinstance(mutation, CreateIMBinding) + and _provider_for_ref(override_only_input, mutation.identity_ref) == override_only_provider_user_id + and mutation.contact_id == override_only_contact.contact_id + for mutation in override_only_plan.binding_mutations + ) + assert override_only_binding.binding_id not in { + binding_id + for mutation in override_only_plan.binding_mutations + if (binding_id := _binding_mutation_before_id(mutation)) is not None + } + + changed_entry = _entry( + case.provider_user_id, + display_name="Changed profile", + email=f"changed-{case.provider_user_id}@example.com", + ) + unrelated_entry = _entry( + ProviderUserId(f"unrelated-{case.provider_user_id}"), + display_name=None, + email=None, + ) + transformed_input = replace( + case.reconciliation_input, + directory_entries=tuple( + entry + for entry in case.reconciliation_input.directory_entries + if entry.provider_user_id != case.provider_user_id + ) + + (changed_entry, unrelated_entry), + contacts_for_email_matching=tuple( + contact + for contact in case.reconciliation_input.contacts_for_email_matching + if contact.contact_id != case.contact.contact_id + ), + ) + transformed_plan = _generate_plan(transformed_input) + transformed_target_ids = { + binding_id + for mutation in transformed_plan.binding_mutations + if (binding_id := _binding_mutation_before_id(mutation)) is not None + } + assert case.binding.binding_id not in transformed_target_ids + assert case.override.binding_id not in transformed_target_ids + transformed_result = _results_for_directory_provider(transformed_plan, case.provider_user_id)[0] + assert (transformed_result.result_type, transformed_result.contact_id) == ( + IMSyncResultType.SKIPPED, + case.binding.contact_id, + ) + + absent_input = replace( + case.reconciliation_input, + directory_entries=tuple( + entry + for entry in case.reconciliation_input.directory_entries + if entry.provider_user_id != case.provider_user_id + ), + ) + absent_plan = _generate_plan(absent_input) + absent_target_counts = Counter( + binding_id + for mutation in absent_plan.binding_mutations + if (binding_id := _binding_mutation_before_id(mutation)) is not None + ) + assert absent_target_counts[case.binding.binding_id] == 1 + assert absent_target_counts[case.override.binding_id] == 1 + assert case.identity.identity_id in {deletion.before.identity_id for deletion in absent_plan.identity_deletions} + + +@settings(max_examples=100, deadline=None) +@given(case=_plan_cases()) +@example(case=_canonical_unique_match_case()) +@example(case=_canonical_replacement_case()) +def test_p05_binding_mutations_satisfy_independent_safety_conditions(case: _GeneratedCase) -> None: + """Every create or replacement independently proves all email-match safety conditions. + + The target contact and provider must be uniquely admissible, email-equivalent, and + free of conflicting reconciled ownership. Checking every generated mutation avoids + an expected-output example masking an unsafe mutation elsewhere in the same plan. + """ + reconciliation_input = case.reconciliation_input + plan = _generate_plan(reconciliation_input) + contact_email_counts = Counter( + contact.normalized_email for contact in reconciliation_input.contacts_for_email_matching + ) + contacts_by_id = {contact.contact_id: contact for contact in reconciliation_input.contacts_for_email_matching} + normalized_email_by_provider = { + upsert.entry.provider_user_id: upsert.normalized_email for upsert in plan.identity_upserts + } + current_identity_by_id = {identity.identity_id: identity for identity in reconciliation_input.current_identities} + reconciled_bindings = tuple( + binding + for binding in reconciliation_input.current_bindings + if binding.binding_id in reconciliation_input.reconciled_binding_ids + ) + reconciled_binding_by_identity = {binding.identity_id: binding for binding in reconciled_bindings} + reconciled_binding_by_contact = {binding.contact_id: binding for binding in reconciled_bindings} + directory_provider_ids = {entry.provider_user_id for entry in reconciliation_input.directory_entries} + unbound_providers_by_email: dict[NormalizedEmail, list[ProviderUserId]] = defaultdict(list) + for entry in reconciliation_input.directory_entries: + current_identity = next( + ( + identity + for identity in reconciliation_input.current_identities + if identity.provider_user_id == entry.provider_user_id + ), + None, + ) + has_reconciled_binding = ( + current_identity is not None and current_identity.identity_id in reconciled_binding_by_identity + ) + normalized_email = normalized_email_by_provider[entry.provider_user_id] + if not has_reconciled_binding and normalized_email is not None: + unbound_providers_by_email[normalized_email].append(entry.provider_user_id) + + for mutation in plan.binding_mutations: + if isinstance(mutation, DeleteIMBinding): + continue + contact = mutation.contact_precondition + assert contacts_by_id[contact.contact_id] == contact + assert contact_email_counts[contact.normalized_email] == 1 + target_ref = mutation.identity_ref if isinstance(mutation, CreateIMBinding) else mutation.next_identity_ref + target_provider_user_id = _provider_for_ref(reconciliation_input, target_ref) + assert target_provider_user_id is not None + assert normalized_email_by_provider[target_provider_user_id] == contact.normalized_email + assert unbound_providers_by_email[contact.normalized_email] == [target_provider_user_id] + if isinstance(target_ref, ExistingIMIdentityRef): + assert target_ref.identity_id not in reconciled_binding_by_identity + + occupying_binding = reconciled_binding_by_contact.get(contact.contact_id) + if isinstance(mutation, CreateIMBinding): + assert occupying_binding is None + else: + assert occupying_binding == mutation.before + old_provider_user_id = current_identity_by_id[mutation.before.identity_id].provider_user_id + assert old_provider_user_id not in directory_provider_ids + assert target_provider_user_id in directory_provider_ids + + +def _matching_results( + plan: ReconciliationPlan, + *, + result_type: IMSyncResultType, + provider_user_id: ProviderUserId, + binding_id: IMBindingId | None, + contact_id: ContactId, +) -> tuple[PlannedSyncResult, ...]: + return tuple( + item + for item in plan.sync_results + if item.result_type is result_type + and item.provider_user_id == provider_user_id + and item.binding_id == binding_id + and item.contact_id == contact_id + ) + + +@settings(max_examples=100, deadline=None) +@given(case=_plan_cases()) +@example(case=_canonical_replacement_case()) +def test_p06_result_and_operation_key_consistency(case: _GeneratedCase) -> None: + """Plan results faithfully describe mutations and all operation keys remain unique and stable. + + This correspondence is the observable contract for execution and audit records. + Generated mixed-operation plans protect it from drift that single-operation examples + would not expose. + """ + reconciliation_input = case.reconciliation_input + plan = _generate_plan(reconciliation_input) + current_identity_by_id = {identity.identity_id: identity for identity in reconciliation_input.current_identities} + for mutation in plan.binding_mutations: + if isinstance(mutation, CreateIMBinding): + provider_user_id = _provider_for_ref(reconciliation_input, mutation.identity_ref) + assert provider_user_id is not None + assert ( + len( + _matching_results( + plan, + result_type=IMSyncResultType.ADDED, + provider_user_id=provider_user_id, + binding_id=None, + contact_id=mutation.contact_id, + ) + ) + == 1 + ) + elif isinstance(mutation, DeleteIMBinding): + provider_user_id = current_identity_by_id[mutation.before.identity_id].provider_user_id + assert ( + len( + _matching_results( + plan, + result_type=IMSyncResultType.REMOVED, + provider_user_id=provider_user_id, + binding_id=mutation.before.binding_id, + contact_id=mutation.before.contact_id, + ) + ) + == 1 + ) + else: + previous_provider_user_id = current_identity_by_id[mutation.before.identity_id].provider_user_id + next_provider_user_id = _provider_for_ref(reconciliation_input, mutation.next_identity_ref) + assert next_provider_user_id is not None + assert ( + len( + _matching_results( + plan, + result_type=IMSyncResultType.REMOVED, + provider_user_id=previous_provider_user_id, + binding_id=mutation.before.binding_id, + contact_id=mutation.before.contact_id, + ) + ) + == 1 + ) + assert ( + len( + _matching_results( + plan, + result_type=IMSyncResultType.ADDED, + provider_user_id=next_provider_user_id, + binding_id=None, + contact_id=mutation.before.contact_id, + ) + ) + == 1 + ) + + current_binding_identity_ids = {binding.identity_id for binding in reconciliation_input.current_bindings} + for deletion in plan.identity_deletions: + if deletion.before.identity_id not in current_binding_identity_ids: + assert not any( + item.result_type is IMSyncResultType.REMOVED + and item.provider_user_id == deletion.before.provider_user_id + for item in plan.sync_results + ) + + directory_provider_ids = {entry.provider_user_id for entry in reconciliation_input.directory_entries} + for binding in reconciliation_input.current_bindings: + if binding.binding_id not in reconciliation_input.reconciled_binding_ids: + continue + provider_user_id = current_identity_by_id[binding.identity_id].provider_user_id + if provider_user_id in directory_provider_ids: + assert ( + len( + _matching_results( + plan, + result_type=IMSyncResultType.SKIPPED, + provider_user_id=provider_user_id, + binding_id=binding.binding_id, + contact_id=binding.contact_id, + ) + ) + == 1 + ) + + mutation_keys = ( + *(upsert.operation_key for upsert in plan.identity_upserts), + *(mutation.operation_key for mutation in plan.binding_mutations), + *(deletion.operation_key for deletion in plan.identity_deletions), + ) + result_keys = tuple(item.operation_key for item in plan.sync_results) + assert len(mutation_keys) == len(set(mutation_keys)) + assert len(result_keys) == len(set(result_keys)) + + reversed_input = replace( + reconciliation_input, + directory_entries=tuple(reversed(reconciliation_input.directory_entries)), + current_identities=tuple(reversed(reconciliation_input.current_identities)), + current_bindings=tuple(reversed(reconciliation_input.current_bindings)), + contacts_for_email_matching=tuple(reversed(reconciliation_input.contacts_for_email_matching)), + ) + reversed_plan = _generate_plan(reversed_input) + assert _semantic_result(reversed_input, reversed_plan) == _semantic_result(reconciliation_input, plan) + + +@settings(max_examples=100, deadline=None) +@given(case=_single_violation_cases()) +@example(case=_canonical_duplicate_provider_case()) +def test_p07_single_violation_blocker_soundness_and_completeness(case: _InvalidCase) -> None: + """Each isolated structural violation blocks with exactly its corresponding code. + + The law establishes both blocker soundness and completeness across variants of each + invalid domain, rather than trusting one representative corruption example. + """ + result = SyncReconciler.generate_plan(case.reconciliation_input) + + assert isinstance(result, BlockedReconciliation) + assert {block.code for block in result.blockers} == {case.expected_code} + assert result.run == case.reconciliation_input.run + + +@settings(max_examples=100, deadline=None) +@given(case=_business_or_recovery_cases()) +@example(case=_canonical_missing_email_case()) +@example(case=_canonical_duplicate_contact_recovery_case()) +def test_p07_business_ambiguity_and_contact_recovery_do_not_block(case: _GeneratedCase) -> None: + """Business ambiguity and recoverable contact collisions still produce a plan. + + This negative-boundary law prevents conservative per-entry outcomes from being + promoted to run-level blockers as surrounding graph relationships vary. + """ + assert isinstance(SyncReconciler.generate_plan(case.reconciliation_input), ReconciliationPlan) diff --git a/api/tests/unit_tests/core/human_input_v2/test_import_boundaries.py b/api/tests/unit_tests/core/human_input_v2/test_import_boundaries.py new file mode 100644 index 00000000000..f1312e15c1e --- /dev/null +++ b/api/tests/unit_tests/core/human_input_v2/test_import_boundaries.py @@ -0,0 +1,88 @@ +"""Architecture tests for the infrastructure-free Human Input v2 domain.""" + +import ast +import os +import subprocess +import sys +from pathlib import Path + +from core.human_input_v2.entities import ContactId as LegacyContactId +from core.human_input_v2.shared import ContactId + +_DOMAIN_ROOT = Path(__file__).resolve().parents[4] / "core/human_input_v2" +_FORBIDDEN_PREFIXES = ( + "flask", + "controllers", + "models", + "sqlalchemy", + "core.model_runtime", + "core.plugin", + "dify_plugin", + "celery", + "tasks", +) +_RESOLVED_FORM_FORBIDDEN_PREFIXES = _FORBIDDEN_PREFIXES + ( + "core.workflow", + "core.human_input_v2.im_integration.adapters", + "core.human_input_v2.im_integration", + "slack_sdk", + "lark_oapi", + "botbuilder", +) + + +def test_domain_packages_have_no_transport_or_persistence_imports() -> None: + violations: list[str] = [] + domain_files = sorted((_DOMAIN_ROOT / "contact_directory").glob("*.py")) + domain_files.extend(sorted((_DOMAIN_ROOT / "im_integration").glob("*.py"))) + domain_files.extend(sorted((_DOMAIN_ROOT / "im_message_inbox").glob("*.py"))) + domain_files.append(_DOMAIN_ROOT / "resolved_form.py") + + for path in domain_files: + tree = ast.parse(path.read_text()) + for node in ast.walk(tree): + if isinstance(node, ast.Import): + imported_modules = [alias.name for alias in node.names] + elif isinstance(node, ast.ImportFrom) and node.module is not None: + imported_modules = [node.module] + else: + continue + for imported_module in imported_modules: + if imported_module.startswith(_FORBIDDEN_PREFIXES): + violations.append(f"{path.name}: {imported_module}") + + assert violations == [] + + +def test_resolved_form_package_import_does_not_load_workflow_or_provider_clients() -> None: + script = f""" +import sys +import core.human_input_v2 + +forbidden_prefixes = {_RESOLVED_FORM_FORBIDDEN_PREFIXES!r} +violations = sorted( + module_name + for module_name in sys.modules + if any( + module_name == prefix or module_name.startswith(f"{{prefix}}.") + for prefix in forbidden_prefixes + ) +) +if violations: + raise SystemExit(f"forbidden modules loaded: {{violations}}") +""" + completed = subprocess.run( + [sys.executable, "-c", script], + cwd=_DOMAIN_ROOT.parents[1], + check=False, + capture_output=True, + env={**os.environ, "PYTHONPATH": str(_DOMAIN_ROOT.parents[1])}, + text=True, + ) + + assert completed.returncode == 0, completed.stderr + + +def test_legacy_contact_id_remains_a_string_shaped_transport_identifier() -> None: + assert LegacyContactId("contact-1") == "contact-1" + assert ContactId("contact-1") == "contact-1" diff --git a/api/tests/unit_tests/core/human_input_v2/test_resolved_form.py b/api/tests/unit_tests/core/human_input_v2/test_resolved_form.py new file mode 100644 index 00000000000..1b1a5381d1c --- /dev/null +++ b/api/tests/unit_tests/core/human_input_v2/test_resolved_form.py @@ -0,0 +1,189 @@ +"""Behavior tests for channel-neutral resolved Human Input v2 forms.""" + +import json + +import pytest +from pydantic import BaseModel, ValidationError + +from core.human_input import ButtonStyle +from core.human_input_v2.resolved_form import ( + FileInput, + FileListInput, + MarkdownFragment, + ParagraphInput, + ResolvedForm, + SelectInput, + UserAction, +) +from graphon.file.enums import FileTransferMethod, FileType + + +@pytest.fixture +def form() -> ResolvedForm: + return ResolvedForm( + title="Review", + parts=( + MarkdownFragment(text="Please review\n"), + ParagraphInput(output_variable_name="reason", default_value="Ship it"), + SelectInput(output_variable_name="decision", options=("approve", "reject"), default_value=None), + FileInput( + output_variable_name="attachment", + allowed_file_types=(FileType.DOCUMENT, FileType.CUSTOM), + allowed_file_extensions=("pdf", "md"), + allowed_file_upload_methods=(FileTransferMethod.LOCAL_FILE, FileTransferMethod.REMOTE_URL), + ), + FileListInput( + output_variable_name="evidence", + allowed_file_types=(FileType.IMAGE,), + allowed_file_extensions=(), + allowed_file_upload_methods=(FileTransferMethod.LOCAL_FILE,), + number_limits=3, + ), + ), + actions=(UserAction(id="approve", title="Approve", button_style=ButtonStyle.PRIMARY),), + legacy_form_content="Please review {{#$output.reason#}}", + ) + + +def test_resolved_form_json_round_trip_preserves_all_parts_and_enums(form: ResolvedForm) -> None: + serialized = form.model_dump_json() + + assert json.loads(serialized) == { + "title": "Review", + "parts": [ + {"type": "markdown_fragment", "text": "Please review\n"}, + {"type": "paragraph_input", "output_variable_name": "reason", "default_value": "Ship it"}, + { + "type": "select_input", + "output_variable_name": "decision", + "options": ["approve", "reject"], + "default_value": None, + }, + { + "type": "file_input", + "output_variable_name": "attachment", + "allowed_file_types": ["document", "custom"], + "allowed_file_extensions": ["pdf", "md"], + "allowed_file_upload_methods": ["local_file", "remote_url"], + }, + { + "type": "file_list_input", + "output_variable_name": "evidence", + "allowed_file_types": ["image"], + "allowed_file_extensions": [], + "allowed_file_upload_methods": ["local_file"], + "number_limits": 3, + }, + ], + "actions": [{"id": "approve", "title": "Approve", "button_style": "primary"}], + "legacy_form_content": "Please review {{#$output.reason#}}", + } + restored = ResolvedForm.model_validate_json(serialized) + assert restored == form + assert restored.actions[0].button_style is ButtonStyle.PRIMARY + attachment = restored.parts[3] + assert isinstance(attachment, FileInput) + assert attachment.allowed_file_types[0] is FileType.DOCUMENT + assert attachment.allowed_file_upload_methods[0] is FileTransferMethod.LOCAL_FILE + assert ResolvedForm.model_validate(form.model_dump()) == form + + +def test_resolved_form_and_nested_values_are_frozen(form: ResolvedForm) -> None: + with pytest.raises(ValidationError, match="frozen_instance"): + form.title = "Changed" # pyrefly: ignore[read-only] + paragraph = form.parts[1] + assert isinstance(paragraph, ParagraphInput) + with pytest.raises(ValidationError, match="frozen_instance"): + paragraph.default_value = "Changed" # pyrefly: ignore[read-only] + with pytest.raises(ValidationError, match="frozen_instance"): + form.actions[0].title = "Changed" # pyrefly: ignore[read-only] + + +@pytest.mark.parametrize( + ("model", "values", "error_type"), + [ + (MarkdownFragment, {"text": 1}, "string_type"), + (ParagraphInput, {"output_variable_name": "reason", "default_value": 1}, "string_type"), + (SelectInput, {"output_variable_name": "choice", "options": ["yes"], "default_value": None}, "tuple_type"), + (SelectInput, {"output_variable_name": "choice", "options": (1,), "default_value": None}, "string_type"), + (UserAction, {"id": "approve", "title": "Approve", "button_style": "primary"}, "is_instance_of"), + (ResolvedForm, {"title": None, "parts": (), "actions": (), "legacy_form_content": ""}, "string_type"), + (ResolvedForm, {"title": "", "parts": [], "actions": (), "legacy_form_content": ""}, "tuple_type"), + (ResolvedForm, {"title": "", "parts": (), "actions": [], "legacy_form_content": ""}, "tuple_type"), + ], +) +def test_internal_construction_rejects_incorrect_types( + model: type[BaseModel], values: dict[str, object], error_type: str +) -> None: + with pytest.raises(ValidationError, match=error_type): + model.model_validate(values) + + +@pytest.mark.parametrize("field", ["allowed_file_types", "allowed_file_extensions", "allowed_file_upload_methods"]) +def test_file_constraints_reject_mutable_sequences(field: str, form: ResolvedForm) -> None: + values = form.parts[3].model_dump() + values[field] = [] + + with pytest.raises(ValidationError, match="tuple_type"): + FileInput.model_validate(values) + + +@pytest.mark.parametrize("number_limits", [True, "3", 1.5]) +def test_file_list_number_limit_requires_an_integer(number_limits: object, form: ResolvedForm) -> None: + values = form.parts[4].model_dump() + values["number_limits"] = number_limits + + with pytest.raises(ValidationError, match="int_type"): + FileListInput.model_validate(values) + + +def test_all_models_reject_extra_fields(form: ResolvedForm) -> None: + for model in (form, *form.parts, *form.actions): + values = model.model_dump() + values["unexpected"] = True + with pytest.raises(ValidationError, match="extra_forbidden"): + type(model).model_validate(values) + + +@pytest.mark.parametrize( + ("part", "error_type"), + [ + ({"type": "unknown", "text": "content"}, "union_tag_invalid"), + ({"text": "content"}, "union_tag_not_found"), + ({"type": "paragraph_input", "text": "content"}, "missing"), + ], +) +def test_json_deserialization_requires_matching_part_discriminator(part: dict[str, str], error_type: str) -> None: + serialized = json.dumps({"title": "", "parts": [part], "actions": [], "legacy_form_content": ""}) + + with pytest.raises(ValidationError, match=error_type): + ResolvedForm.model_validate_json(serialized) + + +def test_type_correct_values_are_preserved_without_business_validation() -> None: + action = UserAction(id="", title="", button_style=ButtonStyle.DEFAULT) + form = ResolvedForm( + title="", + parts=( + ParagraphInput(output_variable_name="", default_value=" Untrimmed "), + SelectInput(output_variable_name=" Choice ", options=("", "", " Yes "), default_value="unlisted"), + FileListInput( + output_variable_name="", + allowed_file_types=(), + allowed_file_extensions=(" .PDF ",), + allowed_file_upload_methods=(), + number_limits=0, + ), + ), + actions=(action, action), + legacy_form_content=" {{#$output.reason#}} ", + ) + + assert ResolvedForm.model_validate_json(form.model_dump_json()) == form + paragraph = form.parts[0] + assert isinstance(paragraph, ParagraphInput) + assert paragraph.default_value == " Untrimmed " + select = form.parts[1] + assert isinstance(select, SelectInput) + assert select.output_variable_name == " Choice " + assert form.legacy_form_content == " {{#$output.reason#}} " diff --git a/api/tests/unit_tests/core/human_input_v2/test_shared_values.py b/api/tests/unit_tests/core/human_input_v2/test_shared_values.py new file mode 100644 index 00000000000..29a8209c840 --- /dev/null +++ b/api/tests/unit_tests/core/human_input_v2/test_shared_values.py @@ -0,0 +1,82 @@ +"""Tests for values shared by Human Input v2 bounded contexts.""" + +from typing import NewType, Protocol + +import pytest + +from core.human_input_v2.shared import ( + AccountId, + ContactId, + DeploymentScope, + EmailProviderId, + IMBindingId, + IMIdentityId, + IMSyncResultId, + IMSyncRunId, + NormalizedEmail, + PlatformEntryId, + TenantId, + WorkspaceScope, +) + + +class _StringNewType(Protocol): + __supertype__: type[str] + + def __call__(self, value: str, /) -> str: ... + + +@pytest.mark.parametrize( + "identifier_type", + [ + AccountId, + ContactId, + EmailProviderId, + IMBindingId, + IMIdentityId, + IMSyncResultId, + IMSyncRunId, + PlatformEntryId, + TenantId, + ], +) +def test_typed_ids_are_direct_string_newtypes(identifier_type: _StringNewType) -> None: + assert type(identifier_type) is NewType + assert identifier_type.__supertype__ is str + assert identifier_type(" identifier ") == " identifier " + + +def test_typed_ids_are_distinct_static_types_with_string_runtime_values() -> None: + assert ContactId is not AccountId + assert ContactId("same") == ContactId("same") + assert ContactId("same") == AccountId("same") + + +@pytest.mark.parametrize( + ("raw", "normalized"), + [ + (" USER@Example.COM ", "user@example.com"), + ("StraSSE@EXAMPLE.COM", "strasse@example.com"), + ], +) +def test_normalized_email_canonicalizes_equality(raw: str, normalized: str) -> None: + assert NormalizedEmail(raw) == NormalizedEmail(normalized) + assert NormalizedEmail(raw).to_primitive() == normalized + + +@pytest.mark.parametrize("invalid_email", ["", "plain-text", "@example.com", "user@", "a b@example.com"]) +def test_normalized_email_rejects_invalid_values(invalid_email: str) -> None: + with pytest.raises(ValueError, match="valid email"): + NormalizedEmail(invalid_email) + + +def test_owner_scopes_are_explicit_and_immutable() -> None: + tenant_id = TenantId("workspace-1") + + assert DeploymentScope().to_primitive() == {"kind": "deployment"} + assert WorkspaceScope(id=tenant_id).to_primitive() == { + "kind": "workspace", + "id": "workspace-1", + } + with pytest.raises(AttributeError): + WorkspaceScope(id=tenant_id).id = TenantId("workspace-2") # type: ignore[misc] diff --git a/api/tests/unit_tests/core/repositories/test_human_input_repository.py b/api/tests/unit_tests/core/repositories/test_human_input_repository.py index c8573cb313e..cb202831f39 100644 --- a/api/tests/unit_tests/core/repositories/test_human_input_repository.py +++ b/api/tests/unit_tests/core/repositories/test_human_input_repository.py @@ -6,6 +6,7 @@ from datetime import datetime, timedelta from typing import Any import pytest +from pydantic import ValidationError from sqlalchemy import Engine, event, select from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.orm import Session, sessionmaker @@ -27,6 +28,12 @@ from core.workflow.human_input_adapter import ( EmailDeliveryMethod, EmailRecipients, ExternalRecipient, + InstantMessageChannelRecipient, + InstantMessageDeliveryConfig, + InstantMessageDeliveryMethod, + InstantMessageProvider, + InstantMessageRecipients, + InstantMessageUserRecipient, MemberRecipient, WebAppDeliveryMethod, ) @@ -49,6 +56,7 @@ from models.human_input import ( HumanInputDelivery, HumanInputForm, HumanInputFormRecipient, + InstantMessageRecipientPayload, RecipientType, StandaloneWebAppRecipientPayload, ) @@ -546,27 +554,6 @@ def _persist_recipient( class TestHumanInputRepositoryCases: - @staticmethod - @pytest.fixture - def repository_session(sqlite_engine: Engine, monkeypatch: pytest.MonkeyPatch) -> Iterator[Session]: - """Bind repository-owned sessions to an isolated SQLite database.""" - - tables = [ - HumanInputForm.__table__, - HumanInputDelivery.__table__, - HumanInputFormRecipient.__table__, - _human_input_repository_Account.__table__, - _human_input_repository_TenantAccountJoin.__table__, - ] - TypeBase.metadata.create_all(sqlite_engine, tables=tables) - repository_session_factory = sessionmaker(bind=sqlite_engine, expire_on_commit=False) - monkeypatch.setattr( - "core.repositories.human_input_repository.session_factory.create_session", - repository_session_factory, - ) - with repository_session_factory() as session: - yield session - @staticmethod def test_recipient_entity_token_raises_when_missing() -> None: recipient = HumanInputFormRecipient( @@ -1161,3 +1148,130 @@ class TestHumanInputRepositoryCases: repo = HumanInputFormSubmissionRepository() with pytest.raises(FormNotFoundError, match="form not found"): repo.mark_timeout(form_id="missing", timeout_status=HumanInputFormStatus.TIMEOUT) + + +def test_delivery_method_to_model_instant_message_creates_recipients( + repository_session: Session, monkeypatch: pytest.MonkeyPatch +) -> None: + repo = HumanInputFormRepositoryImpl(tenant_id="tenant") + monkeypatch.setattr("core.repositories.human_input_repository.uuidv7", lambda: "del-1") + method = InstantMessageDeliveryMethod( + config=InstantMessageDeliveryConfig( + provider=InstantMessageProvider.SLACK, + recipients=InstantMessageRecipients( + items=[ + InstantMessageChannelRecipient(channel_id="C123"), + InstantMessageUserRecipient(user_id="U123"), + ] + ), + message="Review {{#url#}}", + ) + ) + + result = repo._delivery_method_to_model(session=repository_session, form_id="form-1", delivery_method=method) + + assert result.delivery.id == "del-1" + assert result.delivery.delivery_method_type == method.type + assert result.delivery.channel_payload == method.model_dump_json() + assert [recipient.recipient_type for recipient in result.recipients] == [ + RecipientType.INSTANT_MESSAGE, + RecipientType.INSTANT_MESSAGE, + ] + channel_payload = InstantMessageRecipientPayload.model_validate_json(result.recipients[0].recipient_payload) + user_payload = InstantMessageRecipientPayload.model_validate_json(result.recipients[1].recipient_payload) + assert channel_payload.provider == InstantMessageProvider.SLACK + assert channel_payload.recipient_kind == "channel" + assert channel_payload.channel_id == "C123" + assert user_payload.recipient_kind == "user" + assert user_payload.user_id == "U123" + + +@pytest.mark.parametrize( + ("payload", "error_message"), + [ + ( + { + "TYPE": "instant_message", + "provider": "slack", + "recipient_kind": "channel", + "user_id": "U123", + }, + "channel recipients must include channel_id and must not include user_id", + ), + ( + { + "TYPE": "instant_message", + "provider": "slack", + "recipient_kind": "channel", + "channel_id": "C123", + "user_id": "U123", + }, + "channel recipients must include channel_id and must not include user_id", + ), + ( + { + "TYPE": "instant_message", + "provider": "slack", + "recipient_kind": "user", + "channel_id": "C123", + }, + "user recipients must include user_id and must not include channel_id", + ), + ( + { + "TYPE": "instant_message", + "provider": "slack", + "recipient_kind": "user", + "channel_id": "C123", + "user_id": "U123", + }, + "user recipients must include user_id and must not include channel_id", + ), + ], +) +def test_instant_message_recipient_payload_rejects_mismatched_targets( + payload: dict[str, str], + error_message: str, +) -> None: + with pytest.raises(ValidationError, match=error_message): + InstantMessageRecipientPayload.model_validate(payload) + + +def test_build_email_recipients_uses_all_members_when_whole_workspace( + repository_session: Session, monkeypatch: pytest.MonkeyPatch +) -> None: + repo = HumanInputFormRepositoryImpl(tenant_id="tenant") + monkeypatch.setattr( + repo, + "_query_all_workspace_members", + lambda *, session: [_WorkspaceMemberInfo(user_id="u", email="a@example.com")], + ) + monkeypatch.setattr(repo, "_create_email_recipients_from_resolved", lambda **_: ["ok"]) + recipients = repo._build_email_recipients( + session=repository_session, + form_id="f", + delivery_id="d", + recipients_config=EmailRecipients(include_bound_group=True, items=[ExternalRecipient(email="e@example.com")]), + ) + assert recipients == ["ok"] + + +@pytest.fixture +def repository_session(sqlite_engine: Engine, monkeypatch: pytest.MonkeyPatch) -> Iterator[Session]: + """Bind repository-owned sessions to an isolated SQLite database.""" + + tables = [ + HumanInputForm.__table__, + HumanInputDelivery.__table__, + HumanInputFormRecipient.__table__, + _human_input_repository_Account.__table__, + _human_input_repository_TenantAccountJoin.__table__, + ] + TypeBase.metadata.create_all(sqlite_engine, tables=tables) + repository_session_factory = sessionmaker(bind=sqlite_engine, expire_on_commit=False) + monkeypatch.setattr( + "core.repositories.human_input_repository.session_factory.create_session", + repository_session_factory, + ) + with repository_session_factory() as session: + yield session diff --git a/api/tests/unit_tests/core/repositories/test_sqlalchemy_workflow_execution_repository.py b/api/tests/unit_tests/core/repositories/test_sqlalchemy_workflow_execution_repository.py index 8f288337f0e..fb6102c2eb7 100644 --- a/api/tests/unit_tests/core/repositories/test_sqlalchemy_workflow_execution_repository.py +++ b/api/tests/unit_tests/core/repositories/test_sqlalchemy_workflow_execution_repository.py @@ -3,6 +3,7 @@ from datetime import UTC, datetime from uuid import uuid4 import pytest +from aliyun.log.util import Util from pytest_mock import MockerFixture from sqlalchemy.engine import Engine from sqlalchemy.orm import Session, sessionmaker @@ -501,6 +502,8 @@ class TestWorkflowControlPersistenceCases: monkeypatch.setattr(AliyunLogStore, "_initialized", False) monkeypatch.setenv("ALIYUN_SLS_ENDPOINT", "https://example.com") mocker.patch.object(AliyunLogStore, "_check_endpoint_connectivity") + # The SDK constructor probes the endpoint through a socket before any HTTP request. + mocker.patch.object(Util, "get_host_ip", return_value="127.0.0.1") store = AliyunLogStore() store._use_pg_protocol = True mocker.patch.object(store, "put_log") diff --git a/api/tests/unit_tests/core/workflow/nodes/human_input/test_callback.py b/api/tests/unit_tests/core/workflow/nodes/human_input/test_callback.py index 4ef27b8223d..4135b67e911 100644 --- a/api/tests/unit_tests/core/workflow/nodes/human_input/test_callback.py +++ b/api/tests/unit_tests/core/workflow/nodes/human_input/test_callback.py @@ -53,11 +53,30 @@ def repository( return HumanInputFormRepositoryImpl(tenant_id="tenant-1", app_id="app-1", workflow_execution_id="run-1") -def test_session_binding_identity_mapping() -> None: +@pytest.mark.parametrize( + ("node_version", "form_id", "session_id"), + [ + ("1", "form-1", "form-1"), + ("2", "form-2", "hitlv2:form-2"), + ], +) +def test_session_binding_preserves_form_version(node_version: str, form_id: str, session_id: str) -> None: binding = SessionBinding() - assert binding.issue_session_id_for_form(form_id="form-1") == "form-1" - assert binding.resolve_form_id_from_session_id(session_id="form-1") == "form-1" + assert binding.issue_session_id_for_form(node_version, form_id) == session_id + assert binding.resolve_form_id_from_session_id(session_id) == (node_version, form_id) + + +def test_session_binding_reads_existing_v1_identifiers() -> None: + assert SessionBinding().resolve_form_id_from_session_id("00000000-0000-0000-0000-000000000001") == ( + "1", + "00000000-0000-0000-0000-000000000001", + ) + + +def test_session_binding_rejects_unsupported_node_versions() -> None: + with pytest.raises(ValueError, match="Unsupported Human Input node version"): + SessionBinding().issue_session_id_for_form("3", "form-3") def test_dify_hitl_callback_creates_pause_requested_for_new_form( diff --git a/api/tests/unit_tests/core/workflow/nodes/human_input/test_entities.py b/api/tests/unit_tests/core/workflow/nodes/human_input/test_entities.py index 709aaaf1979..e31f460e7a9 100644 --- a/api/tests/unit_tests/core/workflow/nodes/human_input/test_entities.py +++ b/api/tests/unit_tests/core/workflow/nodes/human_input/test_entities.py @@ -127,8 +127,8 @@ from tests.helpers.unit.model_factories import make_account, make_message, make_ def test_session_binding_identity_mapping() -> None: binding = SessionBinding() - assert binding.issue_session_id_for_form(form_id="form-1") == "form-1" - assert binding.resolve_form_id_from_session_id(session_id="form-1") == "form-1" + assert binding.issue_session_id_for_form(node_version="1", form_id="form-1") == "form-1" + assert binding.resolve_form_id_from_session_id(session_id="form-1") == ("1", "form-1") def test_human_input_node_contracts_accept_legacy_json_payload() -> None: diff --git a/api/tests/unit_tests/core/workflow/nodes/human_input/test_pause_reason.py b/api/tests/unit_tests/core/workflow/nodes/human_input/test_pause_reason.py index f96064a15b4..9235a375775 100644 --- a/api/tests/unit_tests/core/workflow/nodes/human_input/test_pause_reason.py +++ b/api/tests/unit_tests/core/workflow/nodes/human_input/test_pause_reason.py @@ -507,5 +507,5 @@ def test_human_input_required_response_accepts_current_serialized_payload() -> N def test_session_binding_identity_mapping() -> None: binding = SessionBinding() - assert binding.issue_session_id_for_form(form_id="form-1") == "form-1" - assert binding.resolve_form_id_from_session_id(session_id="form-1") == "form-1" + assert binding.issue_session_id_for_form(node_version="1", form_id="form-1") == "form-1" + assert binding.resolve_form_id_from_session_id(session_id="form-1") == ("1", "form-1") diff --git a/api/tests/unit_tests/core/workflow/nodes/human_input_v2/test_form_compiler.py b/api/tests/unit_tests/core/workflow/nodes/human_input_v2/test_form_compiler.py new file mode 100644 index 00000000000..7eeee5a1fac --- /dev/null +++ b/api/tests/unit_tests/core/workflow/nodes/human_input_v2/test_form_compiler.py @@ -0,0 +1,231 @@ +"""Behavior tests for compiling Human Input v2 authoring data into a resolved form.""" + +import pytest +from pydantic import ValidationError + +from constants import DEFAULT_FILE_NUMBER_LIMITS +from core.human_input_v2.resolved_form import ( + FileInput, + FileListInput, + MarkdownFragment, + ParagraphInput, + SelectInput, + UserAction, +) +from core.workflow.nodes.human_input.entities import ( + FileInputConfig, + FileListInputConfig, + FormInputConfig, + ParagraphInputConfig, + SelectInputConfig, + StringListSource, + StringSource, + UserActionConfig, +) +from core.workflow.nodes.human_input.enums import ButtonStyle, ValueSourceType +from core.workflow.nodes.human_input_v2.entities import DebugModeConfig, HumanInputNodeData, MessageTemplateConfig +from core.workflow.nodes.human_input_v2.form_compiler import MissingResolvedInputError, compile_resolved_form +from graphon.file.enums import FileTransferMethod, FileType +from graphon.runtime import VariablePool +from graphon.variables import ArrayStringSegment, StringSegment + + +def _node_data( + *, + form_content: str, + inputs: list[FormInputConfig], + user_actions: list[UserActionConfig] | None = None, +) -> HumanInputNodeData: + return HumanInputNodeData( + title="Review", + recipients_spec=[], + message_template=MessageTemplateConfig(subject="Review", body="Review the request"), + debug_mode=DebugModeConfig(channels=[]), + form_content=form_content, + inputs=inputs, + user_actions=user_actions + or [UserActionConfig(id="approve", title="Approve", button_style=ButtonStyle.PRIMARY)], + ) + + +def test_compiler_preserves_markdown_and_input_source_order() -> None: + node_data = _node_data( + form_content="Before {{#$output.reason#}} between {{#$output.decision#}} after", + inputs=[ + ParagraphInputConfig(output_variable_name="reason"), + SelectInputConfig( + output_variable_name="decision", + option_source=StringListSource(type=ValueSourceType.CONSTANT, value=["approve", "reject"]), + ), + ], + ) + + resolved = compile_resolved_form(node_data, VariablePool.empty()) + + assert resolved.parts == ( + MarkdownFragment(text="Before "), + ParagraphInput(output_variable_name="reason", default_value=None), + MarkdownFragment(text=" between "), + SelectInput(output_variable_name="decision", options=("approve", "reject"), default_value=None), + MarkdownFragment(text=" after"), + ) + assert resolved.title == "Review" + assert resolved.actions == (UserAction(id="approve", title="Approve", button_style=ButtonStyle.PRIMARY),) + assert resolved.actions[0].button_style is ButtonStyle.PRIMARY + + +def test_compiler_keeps_adjacent_inputs_adjacent_and_preserves_whitespace_fragments() -> None: + node_data = _node_data( + form_content="{{#$output.first#}}{{#$output.second#}} \n {{#$output.third#}}", + inputs=[ + ParagraphInputConfig(output_variable_name="first"), + ParagraphInputConfig(output_variable_name="second"), + ParagraphInputConfig(output_variable_name="third"), + ], + ) + + resolved = compile_resolved_form(node_data, VariablePool.empty()) + + assert resolved.parts == ( + ParagraphInput(output_variable_name="first", default_value=None), + ParagraphInput(output_variable_name="second", default_value=None), + MarkdownFragment(text=" \n "), + ParagraphInput(output_variable_name="third", default_value=None), + ) + + +def test_compiler_rejects_missing_referenced_input_and_omits_unreferenced_input() -> None: + node_data = _node_data( + form_content="{{#$output.referenced#}} {{#$output.missing#}}", + inputs=[ + ParagraphInputConfig(output_variable_name="referenced"), + ParagraphInputConfig(output_variable_name="unused"), + ], + ) + + with pytest.raises(MissingResolvedInputError, match="missing"): + compile_resolved_form(node_data, VariablePool.empty()) + + resolved = compile_resolved_form( + _node_data( + form_content="{{#$output.referenced#}}", + inputs=[ + ParagraphInputConfig(output_variable_name="referenced"), + ParagraphInputConfig(output_variable_name="unused"), + ], + ), + VariablePool.empty(), + ) + assert resolved.parts == (ParagraphInput(output_variable_name="referenced", default_value=None),) + + +def test_compiler_resolves_legacy_content_paragraph_defaults_and_select_values() -> None: + variable_pool = VariablePool.empty() + variable_pool.add(("start", "request"), StringSegment(value="expense report")) + variable_pool.add(("start", "paragraph_default"), StringSegment(value="Looks good")) + variable_pool.add(("start", "options"), ArrayStringSegment(value=["approve", "reject"])) + node_data = _node_data( + form_content=( + "Review {{#start.request#}} / {{#start.request#}}: " + "{{#$output.constant_reason#}} {{#$output.variable_reason#}} {{#$output.decision#}}" + ), + inputs=[ + ParagraphInputConfig( + output_variable_name="constant_reason", + default=StringSource(type=ValueSourceType.CONSTANT, value="No concerns"), + ), + ParagraphInputConfig( + output_variable_name="variable_reason", + default=StringSource(type=ValueSourceType.VARIABLE, selector=("start", "paragraph_default")), + ), + SelectInputConfig( + output_variable_name="decision", + option_source=StringListSource(type=ValueSourceType.VARIABLE, selector=("start", "options")), + ), + ], + ) + + resolved = compile_resolved_form( + node_data, + variable_pool, + resolved_default_values={"decision": "approve"}, + ) + + assert resolved.legacy_form_content == ( + "Review expense report / expense report: " + "{{#$output.constant_reason#}} {{#$output.variable_reason#}} {{#$output.decision#}}" + ) + assert tuple(block for block in resolved.parts if not isinstance(block, MarkdownFragment)) == ( + ParagraphInput(output_variable_name="constant_reason", default_value="No concerns"), + ParagraphInput(output_variable_name="variable_reason", default_value="Looks good"), + SelectInput(output_variable_name="decision", options=("approve", "reject"), default_value="approve"), + ) + + +def test_compiler_freezes_file_constraints_and_effective_file_list_number_limits() -> None: + common_constraints = { + "allowed_file_types": [FileType.DOCUMENT, FileType.CUSTOM], + "allowed_file_extensions": ["pdf", "md"], + "allowed_file_upload_methods": [FileTransferMethod.LOCAL_FILE, FileTransferMethod.REMOTE_URL], + } + node_data = _node_data( + form_content="{{#$output.attachment#}}{{#$output.evidence#}}", + inputs=[ + FileInputConfig(output_variable_name="attachment", **common_constraints), + FileListInputConfig(output_variable_name="evidence", number_limits=0, **common_constraints), + ], + ) + + resolved = compile_resolved_form(node_data, VariablePool.empty()) + + assert resolved.parts == ( + FileInput( + output_variable_name="attachment", + allowed_file_types=(FileType.DOCUMENT, FileType.CUSTOM), + allowed_file_extensions=("pdf", "md"), + allowed_file_upload_methods=(FileTransferMethod.LOCAL_FILE, FileTransferMethod.REMOTE_URL), + ), + FileListInput( + output_variable_name="evidence", + allowed_file_types=(FileType.DOCUMENT, FileType.CUSTOM), + allowed_file_extensions=("pdf", "md"), + allowed_file_upload_methods=(FileTransferMethod.LOCAL_FILE, FileTransferMethod.REMOTE_URL), + number_limits=DEFAULT_FILE_NUMBER_LIMITS, + ), + ) + + +def test_node_data_and_compiler_preserve_repeated_output_slots_in_source_order() -> None: + node_data = _node_data( + form_content="{{#$output.reason#}} then {{#$output.reason#}}", + inputs=[ParagraphInputConfig(output_variable_name="reason")], + ) + + resolved = compile_resolved_form(node_data, VariablePool.empty()) + + assert node_data.output_variable_names() == ("reason", "reason") + assert resolved.parts == ( + ParagraphInput(output_variable_name="reason", default_value=None), + MarkdownFragment(text=" then "), + ParagraphInput(output_variable_name="reason", default_value=None), + ) + + +def test_node_data_reuses_input_and_action_identifier_validation() -> None: + with pytest.raises(ValidationError, match="duplicated output_variable_name 'reason'"): + _node_data( + form_content="{{#$output.reason#}}", + inputs=[ + ParagraphInputConfig(output_variable_name="reason"), + ParagraphInputConfig(output_variable_name="reason"), + ], + ) + with pytest.raises(ValidationError, match="duplicated user action id 'approve'"): + _node_data( + form_content="", + inputs=[], + user_actions=[ + UserActionConfig(id="approve", title="Approve"), + UserActionConfig(id="approve", title="Approve again"), + ], + ) diff --git a/api/tests/unit_tests/core/workflow/nodes/human_input_v2/test_migration.py b/api/tests/unit_tests/core/workflow/nodes/human_input_v2/test_migration.py new file mode 100644 index 00000000000..341340d8f8f --- /dev/null +++ b/api/tests/unit_tests/core/workflow/nodes/human_input_v2/test_migration.py @@ -0,0 +1,571 @@ +import pytest + +from core.human_input_v2.shared.values import NormalizedEmail +from core.workflow.nodes.human_input.entities import HumanInputNodeDataFull +from core.workflow.nodes.human_input_v2.migration import ( + MemberEmailSnapshot, + ResolvedMemberEmail, + convert_legacy_human_input_node_data, +) +from graphon.enums import BuiltinNodeTypes + +_EMAIL_METHOD_ID = "11111111-1111-4111-8111-111111111111" +_WEBAPP_METHOD_ID = "22222222-2222-4222-8222-222222222222" +_SECOND_EMAIL_METHOD_ID = "33333333-3333-4333-8333-333333333333" + + +def _member_email_snapshot(entries: dict[str, str] | None = None) -> MemberEmailSnapshot: + return MemberEmailSnapshot( + tuple( + ResolvedMemberEmail(member_id=member_id, email=NormalizedEmail(email)) + for member_id, email in (entries or {}).items() + ) + ) + + +@pytest.mark.parametrize( + ("default_type", "value", "normalized_value"), + [ + ("string", "fallback", "fallback"), + ("number", 7, 7), + ("number", 1.5, 1.5), + ("number", True, True), + ("number", "7", 7.0), + ("object", {"nested": [None, True, 7, 1.5, "value"]}, {"nested": [None, True, 7, 1.5, "value"]}), + ("object", '{"nested": [null, true, 7, 1.5, "value"]}', {"nested": [None, True, 7, 1.5, "value"]}), + ("array[number]", [1, 2.5, True], [1, 2.5, True]), + ("array[number]", "[1, 2.5, true]", [1, 2.5, True]), + ("array[string]", ["first", "second"], ["first", "second"]), + ("array[string]", '["first", "second"]', ["first", "second"]), + ("array[object]", [{"first": 1}, {"second": False}], [{"first": 1}, {"second": False}]), + ("array[object]", '[{"first": 1}, {"second": false}]', [{"first": 1}, {"second": False}]), + ( + "array[file]", + { + "nested": [None, True, 7, 1.5, "value", {"deep": [False]}], + }, + { + "nested": [None, True, 7, 1.5, "value", {"deep": [False]}], + }, + ), + ("array[file]", False, False), + ], +) +def test_canonical_default_value_matches_historical_normalization_and_converts_totally( + default_type: str, + value: object, + normalized_value: object, +) -> None: + raw_default = {"key": "fallback", "type": default_type, "value": value} + normalized_default = {"key": "fallback", "type": default_type, "value": normalized_value} + + node_data = HumanInputNodeDataFull.model_validate( + { + "title": "Approval", + "default_value": [raw_default], + "delivery_methods": [{"id": _WEBAPP_METHOD_ID, "type": "webapp", "config": {}}], + } + ) + conversion = convert_legacy_human_input_node_data(node_data, _member_email_snapshot()) + + assert node_data.model_dump(mode="json")["default_value"] == [normalized_default] + assert conversion.blockers == () + assert conversion.node_data is not None + assert conversion.node_data.model_dump(mode="json")["default_value"] == [normalized_default] + + +def test_complete_canonical_v1_shared_fields_convert_to_typed_v2() -> None: + legacy_node_data = HumanInputNodeDataFull.model_validate( + { + "title": "Manager approval", + "desc": "Preserve historical fields", + "version": "1", + "error_strategy": "fail-branch", + "default_value": [{"key": "reason", "type": "string", "value": "fallback"}], + "retry_config": {"retry_enabled": True, "max_retries": 2, "retry_interval": 1000}, + "delivery_methods": [{"id": _WEBAPP_METHOD_ID, "type": "webapp", "config": {}}], + "form_content": "Decision: {{#$output.reason#}}", + "inputs": [ + { + "type": "paragraph", + "output_variable_name": "reason", + "default": {"type": "constant", "value": "fallback"}, + } + ], + "user_actions": [{"id": "approve", "title": "Approve", "button_style": "primary"}], + "timeout": 2, + "timeout_unit": "day", + } + ) + + conversion = convert_legacy_human_input_node_data(legacy_node_data, _member_email_snapshot()) + + assert conversion.blockers == () + assert conversion.node_data is not None + assert conversion.node_data.model_dump(mode="json") == { + "type": "human-input", + "title": "Manager approval", + "desc": "Preserve historical fields", + "version": "2", + "error_strategy": "fail-branch", + "default_value": [{"key": "reason", "type": "string", "value": "fallback"}], + "retry_config": {"retry_enabled": True, "max_retries": 2, "retry_interval": 1000}, + "recipients_spec": [{"type": "initiator"}], + "message_template": { + "subject": "Action required", + "body": ( + "A request is waiting for your review. Please review the details and submit your decision to continue. " + "Review and respond: {{#url#}}" + ), + }, + "debug_mode": {"enabled": False, "channels": []}, + "form_content": "Decision: {{#$output.reason#}}", + "inputs": [ + { + "type": "paragraph", + "output_variable_name": "reason", + "default": {"type": "constant", "selector": [], "value": "fallback"}, + } + ], + "user_actions": [{"id": "approve", "title": "Approve", "button_style": "primary"}], + "timeout": 2, + "timeout_unit": "day", + } + + +def test_converter_preserves_historically_accepted_action_id_with_trailing_line_feed() -> None: + historical_action_id = f"{'a' * 19}\n" + legacy_node_data = HumanInputNodeDataFull.model_validate( + { + "title": "Manager approval", + "delivery_methods": [{"id": _WEBAPP_METHOD_ID, "type": "webapp", "config": {}}], + "user_actions": [{"id": historical_action_id, "title": "Approve"}], + } + ) + + conversion = convert_legacy_human_input_node_data(legacy_node_data, _member_email_snapshot()) + + assert conversion.blockers == () + assert conversion.node_data is not None + assert conversion.node_data.user_actions[0].id == historical_action_id + + +def test_converter_preserves_repeated_historical_output_slots() -> None: + legacy_node_data = HumanInputNodeDataFull.model_validate( + { + "title": "Approval", + "delivery_methods": [{"id": _WEBAPP_METHOD_ID, "type": "webapp", "config": {}}], + "form_content": "{{#$output.reason#}} / {{#$output.reason#}}", + "inputs": [{"type": "paragraph", "output_variable_name": "reason"}], + } + ) + + conversion = convert_legacy_human_input_node_data(legacy_node_data, _member_email_snapshot()) + + assert conversion.blockers == () + assert conversion.node_data is not None + assert conversion.node_data.form_content == "{{#$output.reason#}} / {{#$output.reason#}}" + assert conversion.node_data.output_variable_names() == ("reason", "reason") + + +def test_external_and_member_email_sources_become_normalized_onetime_email() -> None: + legacy_node_data = HumanInputNodeDataFull.model_validate( + { + "title": "Approval", + "delivery_methods": [ + { + "id": _EMAIL_METHOD_ID, + "type": "email", + "enabled": True, + "config": { + "recipients": { + "items": [ + {"type": "external", "email": " APPROVER@Example.COM "}, + {"type": "member", "reference_id": "member-1"}, + ] + }, + "subject": "Review request", + "body": "Please review {{#url#}}", + }, + } + ], + } + ) + + conversion = convert_legacy_human_input_node_data( + legacy_node_data, + _member_email_snapshot({"member-1": " MEMBER@Example.COM "}), + ) + + assert conversion.blockers == () + assert conversion.node_data is not None + assert [recipient.model_dump(mode="json") for recipient in conversion.node_data.recipients_spec] == [ + {"type": "onetime_email", "email": "approver@example.com"}, + {"type": "onetime_email", "email": "member@example.com"}, + ] + + +def test_member_user_id_has_safe_resolution_blocker_value() -> None: + legacy_node_data = HumanInputNodeDataFull.model_validate( + { + "title": "Approval", + "delivery_methods": [ + { + "id": _EMAIL_METHOD_ID, + "type": "email", + "config": { + "subject": "Review", + "body": "Please review", + "recipients": { + "items": [{"type": "member", "reference_id": "member-1"}], + }, + }, + } + ], + } + ) + + resolved = convert_legacy_human_input_node_data( + legacy_node_data, + _member_email_snapshot({"member-1": "member@example.com"}), + ) + unresolved = convert_legacy_human_input_node_data(legacy_node_data, _member_email_snapshot()) + + assert resolved.blockers == () + assert resolved.node_data is not None + assert resolved.node_data.recipients_spec[0].model_dump(mode="json") == { + "type": "onetime_email", + "email": "member@example.com", + } + assert [(blocker.code, blocker.method_id, blocker.value) for blocker in unresolved.blockers] == [ + ("unresolved-member", _EMAIL_METHOD_ID, "member-1"), + ("missing-recipients", None, None), + ] + + +def test_external_email_conversion_is_independent_of_unrelated_directory_state() -> None: + legacy_node_data = HumanInputNodeDataFull.model_validate( + { + "title": "Approval", + "delivery_methods": [ + { + "id": _EMAIL_METHOD_ID, + "type": "email", + "config": { + "recipients": { + "items": [{"type": "external", "email": "approver@example.com"}], + }, + "subject": "Review request", + "body": "Please review", + }, + } + ], + } + ) + + without_directory_entries = convert_legacy_human_input_node_data(legacy_node_data, _member_email_snapshot()) + with_unrelated_entries = convert_legacy_human_input_node_data( + legacy_node_data, + _member_email_snapshot({"unrelated-account": "approver@example.com"}), + ) + + assert without_directory_entries == with_unrelated_entries + + +def test_whole_workspace_marker_preserves_overlap_source_order_and_deduplicates() -> None: + legacy_node_data = HumanInputNodeDataFull.model_validate( + { + "title": "Approval", + "delivery_methods": [ + { + "id": _EMAIL_METHOD_ID, + "type": "email", + "config": { + "recipients": { + "include_bound_group": True, + "items": [{"type": "member", "reference_id": "member-1"}], + }, + "subject": "Review request", + "body": "Please review", + }, + }, + {"id": _WEBAPP_METHOD_ID, "type": "webapp", "config": {}}, + { + "id": _SECOND_EMAIL_METHOD_ID, + "type": "email", + "config": { + "recipients": {"include_bound_group": True}, + "subject": "Review request", + "body": "Please review", + }, + }, + ], + } + ) + + conversion = convert_legacy_human_input_node_data( + legacy_node_data, + _member_email_snapshot({"member-1": "member@example.com"}), + ) + + assert conversion.blockers == () + assert conversion.node_data is not None + assert [recipient.model_dump(mode="json") for recipient in conversion.node_data.recipients_spec] == [ + {"type": "onetime_email", "email": "member@example.com"}, + {"type": "all_workspace_contacts"}, + {"type": "initiator"}, + ] + + +def test_matching_email_templates_debug_mode_and_canonical_dedupe_are_deterministic() -> None: + legacy_node_data = HumanInputNodeDataFull.model_validate( + { + "title": "Approval", + "delivery_methods": [ + { + "id": _EMAIL_METHOD_ID, + "type": "email", + "config": { + "recipients": { + "items": [{"type": "external", "email": " First@Example.com "}], + }, + "subject": " Review request ", + "body": " Please review {{#url#}} ", + }, + }, + { + "id": _SECOND_EMAIL_METHOD_ID, + "type": "email", + "config": { + "recipients": { + "items": [ + {"type": "external", "email": "first@example.COM"}, + {"type": "external", "email": "second@example.com"}, + ], + }, + "subject": " Review request ", + "body": " Please review {{#url#}} ", + "debug_mode": True, + }, + }, + ], + } + ) + + conversion = convert_legacy_human_input_node_data(legacy_node_data, _member_email_snapshot()) + + assert conversion.blockers == () + assert conversion.node_data is not None + assert conversion.node_data.message_template.model_dump() == { + "subject": " Review request ", + "body": " Please review {{#url#}} ", + } + assert conversion.node_data.debug_mode.model_dump(mode="json") == { + "enabled": True, + "channels": ["email"], + } + assert [recipient.model_dump(mode="json") for recipient in conversion.node_data.recipients_spec] == [ + {"type": "onetime_email", "email": "first@example.com"}, + {"type": "onetime_email", "email": "second@example.com"}, + ] + + +def test_lossy_delivery_configuration_produces_stably_ordered_blockers() -> None: + legacy_node_data = HumanInputNodeDataFull.model_validate( + { + "title": "Approval", + "delivery_methods": [ + { + "id": _SECOND_EMAIL_METHOD_ID, + "type": "email", + "enabled": False, + "config": { + "subject": "Configured", + "body": "Configured", + "recipients": {"items": []}, + }, + }, + { + "id": _EMAIL_METHOD_ID, + "type": "email", + "enabled": True, + "config": { + "subject": " ", + "body": "7", + "recipients": { + "items": [ + {"type": "external", "email": "not-an-email"}, + {"type": "member", "reference_id": "missing-member"}, + ] + }, + }, + }, + ], + } + ) + + conversion = convert_legacy_human_input_node_data(legacy_node_data, _member_email_snapshot()) + + assert conversion.node_data is None + assert [(blocker.code, blocker.method_id, blocker.value) for blocker in conversion.blockers] == [ + ("configured-disabled-method", _SECOND_EMAIL_METHOD_ID, "email"), + ("invalid-email-configuration", _EMAIL_METHOD_ID, "subject"), + ("invalid-email", _EMAIL_METHOD_ID, "not-an-email"), + ("unresolved-member", _EMAIL_METHOD_ID, "missing-member"), + ("missing-recipients", None, None), + ] + + +def test_conversion_forces_untrusted_legacy_node_type_to_human_input() -> None: + legacy_node_data = HumanInputNodeDataFull.model_validate( + { + "title": "Approval", + "type": "code", + "delivery_methods": [{"id": _WEBAPP_METHOD_ID, "type": "webapp", "config": {}}], + } + ) + + conversion = convert_legacy_human_input_node_data(legacy_node_data, _member_email_snapshot()) + + assert conversion.blockers == () + assert conversion.node_data is not None + assert conversion.node_data.type == BuiltinNodeTypes.HUMAN_INPUT + assert conversion.node_data.model_dump(mode="json")["type"] == "human-input" + + +def test_conflicting_email_templates_block_after_recipient_preflight() -> None: + legacy_node_data = HumanInputNodeDataFull.model_validate( + { + "title": "Approval", + "delivery_methods": [ + { + "id": _EMAIL_METHOD_ID, + "type": "email", + "config": { + "recipients": {"items": [{"type": "external", "email": "first@example.com"}]}, + "subject": "First subject", + "body": "Shared body", + }, + }, + { + "id": _SECOND_EMAIL_METHOD_ID, + "type": "email", + "config": { + "recipients": {"items": [{"type": "external", "email": "invalid-email"}]}, + "subject": "Second subject", + "body": "Shared body", + }, + }, + ], + } + ) + + conversion = convert_legacy_human_input_node_data(legacy_node_data, _member_email_snapshot()) + + assert conversion.node_data is None + assert [(blocker.code, blocker.method_id) for blocker in conversion.blockers] == [ + ("invalid-email", _SECOND_EMAIL_METHOD_ID), + ("conflicting-email-templates", _SECOND_EMAIL_METHOD_ID), + ] + + +@pytest.mark.parametrize( + ("subject", "body", "enabled", "conflicting", "expected_blocker"), + [ + (" ", "Open {{#url#}}", True, False, "invalid-email-configuration"), + ("Review", " ", True, False, "invalid-email-configuration"), + ("Review", "Open {{#url#}}", False, False, "configured-disabled-method"), + ("Review", "Open {{#url#}}", True, True, "conflicting-email-templates"), + ], +) +def test_webapp_default_does_not_mask_email_blockers( + subject: str, body: str, enabled: bool, conflicting: bool, expected_blocker: str +) -> None: + legacy = HumanInputNodeDataFull.model_validate( + { + "delivery_methods": [ + {"type": "webapp"}, + { + "type": "email", + "enabled": enabled, + "config": { + "subject": subject, + "body": body, + "recipients": {"items": [{"type": "external", "email": "approver@example.com"}]}, + }, + }, + ] + + ( + [ + { + "type": "email", + "config": { + "subject": "Conflicting subject", + "body": "Open {{#url#}}", + "recipients": {"items": [{"type": "external", "email": "second@example.com"}]}, + }, + } + ] + if conflicting + else [] + ), + } + ) + + conversion = convert_legacy_human_input_node_data(legacy, MemberEmailSnapshot()) + + assert conversion.node_data is None + assert [blocker.code for blocker in conversion.blockers] == [expected_blocker] + + +def test_shared_fields_input_immutability_and_repeated_conversion_are_preserved() -> None: + legacy_node_data = HumanInputNodeDataFull.model_validate( + { + "title": "Manager approval", + "desc": "Preserve this description", + "form_content": "Decision: {{#$output.reason#}}", + "inputs": [{"type": "paragraph", "output_variable_name": "reason"}], + "user_actions": [{"id": "approve", "title": "Approve", "button_style": "primary"}], + "timeout": 2, + "timeout_unit": "day", + "delivery_methods": [{"id": _WEBAPP_METHOD_ID, "type": "webapp", "config": {}}], + } + ) + original_legacy_value = legacy_node_data.model_dump(mode="json") + + first = convert_legacy_human_input_node_data(legacy_node_data, _member_email_snapshot()) + second = convert_legacy_human_input_node_data(legacy_node_data, _member_email_snapshot()) + + assert first == second + assert legacy_node_data.model_dump(mode="json") == original_legacy_value + assert first.node_data is not None + assert first.node_data.model_dump(mode="json") == { + **{key: value for key, value in original_legacy_value.items() if key not in {"delivery_methods", "version"}}, + "type": "human-input", + "version": "2", + "recipients_spec": [{"type": "initiator"}], + "message_template": { + "subject": "Action required", + "body": ( + "A request is waiting for your review. Please review the details and submit your decision to continue. " + "Review and respond: {{#url#}}" + ), + }, + "debug_mode": {"enabled": False, "channels": []}, + } + + +def test_formal_v1_im_delivery_keeps_unsupported_delivery_blocker() -> None: + v1 = HumanInputNodeDataFull.model_validate( + { + "delivery_methods": [ + {"type": "webapp"}, + {"type": "im", "id": _EMAIL_METHOD_ID, "config": {"provider": "slack"}}, + ] + } + ) + conversion = convert_legacy_human_input_node_data(v1, MemberEmailSnapshot()) + assert conversion.node_data is None + assert [(blocker.code, blocker.method_id, blocker.value) for blocker in conversion.blockers] == [ + ("unsupported-delivery-method", _EMAIL_METHOD_ID, "im"), + ] diff --git a/api/tests/unit_tests/core/workflow/nodes/human_input_v2/test_node.py b/api/tests/unit_tests/core/workflow/nodes/human_input_v2/test_node.py new file mode 100644 index 00000000000..354c79ff003 --- /dev/null +++ b/api/tests/unit_tests/core/workflow/nodes/human_input_v2/test_node.py @@ -0,0 +1,509 @@ +from collections.abc import Iterator +from dataclasses import replace +from datetime import datetime +from unittest.mock import MagicMock, create_autospec, patch + +import pytest +from pydantic import SecretStr, TypeAdapter + +from core.app.apps.workflow_app_runner import WorkflowBasedAppRunner +from core.app.entities.queue_entities import ( + QueueHumanInputFormFilledEvent, + QueueHumanInputFormTimeoutEvent, + QueueNodeSucceededEvent, +) +from core.human_input import ButtonStyle +from core.human_input_v2.resolved_form import ( + FileInput, + FileListInput, + MarkdownFragment, + ParagraphInput, + ResolvedForm, + SelectInput, + UserAction, +) +from core.human_input_v2.shared.values import RecipientId, TenantId +from core.workflow.nodes.human_input.entities import ( + ParagraphInputConfig, + SelectInputConfig, + StringListSource, + StringSource, +) +from core.workflow.nodes.human_input.enums import HumanInputFormKind, HumanInputFormStatus, ValueSourceType +from core.workflow.nodes.human_input_v2.entities import ( + DynamicEmail, + HumanInputNodeData, + Initiator, + MessageTemplateConfig, +) +from core.workflow.nodes.human_input_v2.events import NodeRunHumanInputV2FormRequiredEvent +from core.workflow.nodes.human_input_v2.node import HumanInputNode +from core.workflow.nodes.human_input_v2.runtime import HumanInputDeliveryError, HumanInputRuntime, PreparedForm +from core.workflow.workflow_entry import WorkflowEntry +from graphon.engine_events import ( + EngineEvent, + NodeRunFailedEvent, + NodeRunHumanInputFormFilledEvent, + NodeRunHumanInputFormTimeoutEvent, + NodeRunPauseRequestedEvent, + NodeRunStartedEvent, + NodeRunSucceededEvent, +) +from graphon.entities import WorkflowStartReason +from graphon.entities.pause_reason import HitlRequired, PauseReason +from graphon.file import File, FileTransferMethod, FileType +from graphon.graph import Graph +from graphon.runtime import InitParams, RuntimeState, VariablePool +from graphon.variables.segments import ArrayFileSegment, FileSegment, NoneSegment, StringSegment +from repositories.human_input_v2.form_repository import Form, FormSubmission +from tests.unit_tests.core.app.apps.common.test_workflow_response_converter import ( + _workflow_response_converter_human_input_build_converter as _build_converter, +) + + +@pytest.fixture +def form() -> Form: + return Form( + id="form-1", + tenant_id=TenantId("tenant-1"), + app_id="app-1", + workflow_run_id="run-1", + node_execution_id="execution-1", + form_kind=HumanInputFormKind.RUNTIME, + status=HumanInputFormStatus.WAITING, + created_at=datetime(2025, 1, 1), + updated_at=datetime(2025, 1, 1), + expiration_time=datetime(2025, 1, 2), + global_timeout_deadline=datetime(2025, 1, 3), + submission=None, + resolved_form=ResolvedForm( + title="Frozen title", + legacy_form_content="Answer: {{#$output.answer#}}; files: {{#$output.files#}}", + parts=( + MarkdownFragment(text="Answer: "), + ParagraphInput(output_variable_name="answer", default_value="original"), + MarkdownFragment(text="; files: "), + FileListInput( + output_variable_name="files", + allowed_file_types=(), + allowed_file_extensions=(), + allowed_file_upload_methods=(), + number_limits=3, + ), + ), + actions=(UserAction(id="approve", title="Frozen approval", button_style=ButtonStyle.PRIMARY),), + ), + ) + + +def build_node(form: Form, *, initiator: bool = False, token: str | None = None) -> tuple[HumanInputNode, MagicMock]: + runtime = create_autospec(HumanInputRuntime, instance=True) + runtime.prepare_form.return_value = PreparedForm(form=form, form_token=SecretStr(token) if token else None) + node = HumanInputNode( + node_id="node-1", + data=HumanInputNodeData.model_validate( + { + "type": "human-input", + "version": "2", + "title": "Changed title", + "recipients_spec": [Initiator()] if initiator else [], + "message_template": {"subject": "Notice", "body": "[Request URL]"}, + "debug_mode": {"enabled": False, "channels": []}, + "form_content": "Changed form", + "user_actions": [], + } + ), + init_params=InitParams(workflow_id="wf-1", graph_config={}, run_context={}, call_depth=0), + runtime_state=RuntimeState(workflow_id="wf-1", variable_pool=VariablePool(), start_at=0), + human_input_runtime=runtime, + ) + node.bind_execution_id("execution-1") + return node, runtime + + +def engine_events(node: HumanInputNode) -> Iterator[EngineEvent]: + for event in node.run(): + assert isinstance(event, EngineEvent) + yield event + + +def submit(form: Form) -> Form: + return replace( + form, + status=HumanInputFormStatus.SUBMITTED, + submission=FormSubmission( + submitted_at=datetime(2025, 1, 1, 1), + submitted_by_recipient_id=RecipientId("recipient-1"), + submitted_by_delivery_id="delivery-1", + selected_action_id="approve", + raw_submission_inputs={"answer": "yes", "files": []}, + normalized_submission_data={"answer": StringSegment(value="yes"), "files": ArrayFileSegment(value=[])}, + ), + ) + + +@pytest.mark.parametrize("status", [HumanInputFormStatus.SUBMITTED, HumanInputFormStatus.TIMEOUT]) +def test_v1_event_filter_preserves_v2_completion_without_duplicate_events( + form: Form, status: HumanInputFormStatus +) -> None: + from core.repositories.human_input_repository import HumanInputFormSubmissionRepository + from core.workflow.nodes.human_input.boundary import HumanInputFormEventFilter + from graphon.engine.filter import EngineEventFilterContext, filter_engine_events + from graphon.graph import Graph + from graphon.runtime import ReadOnlyRuntimeStateWrapper + + form = submit(form) if status == HumanInputFormStatus.SUBMITTED else replace(form, status=status) + node, _ = build_node(form) + events = list( + filter_engine_events( + engine_events(node), + context=EngineEventFilterContext( + graph=Graph(root_node=node), + runtime_state=ReadOnlyRuntimeStateWrapper(node.runtime_state), + ), + filters=[HumanInputFormEventFilter(form_repository=HumanInputFormSubmissionRepository())], + ) + ) + + completion_type = ( + NodeRunHumanInputFormFilledEvent + if status == HumanInputFormStatus.SUBMITTED + else NodeRunHumanInputFormTimeoutEvent + ) + assert len(events) == 3 + assert isinstance(events[0], NodeRunStartedEvent) + assert isinstance(events[1], completion_type) + assert isinstance(events[2], NodeRunSucceededEvent) + + +def test_submitted_emits_filled_before_completion_from_frozen_values(form: Form) -> None: + form = submit(form) + node, runtime = build_node(form) + events = list(node.run()) + assert len(events) == 3 + assert isinstance(events[0], NodeRunStartedEvent) + assert isinstance(events[1], NodeRunHumanInputFormFilledEvent) + assert isinstance(events[2], NodeRunSucceededEvent) + filled = events[1] + assert isinstance(filled, NodeRunHumanInputFormFilledEvent) + assert (filled.id, filled.node_id, filled.node_title) == ("execution-1", "node-1", "Frozen title") + assert (filled.action_id, filled.action_text) == ("approve", "Frozen approval") + assert filled.rendered_content == "Answer: yes; files: [0 files]" + assert isinstance(filled.submitted_data["files"], ArrayFileSegment) + completed = events[2] + assert isinstance(completed, NodeRunSucceededEvent) + assert completed.node_run_result.edge_source_handle == "approve" + assert completed.node_run_result.outputs["__action_value"].text == "Frozen approval" + assert runtime.prepare_form.call_args.kwargs["node_execution_id"] == "execution-1" + + +def test_waiting_does_not_infer_timeout_from_clock(form: Form) -> None: + node, _ = build_node(form, initiator=True, token="initiator-token") + events = list(node.run()) + assert [type(event) for event in events] == [ + NodeRunStartedEvent, + NodeRunHumanInputV2FormRequiredEvent, + NodeRunPauseRequestedEvent, + ] + required = events[1] + assert isinstance(required, NodeRunHumanInputV2FormRequiredEvent) + assert required.prepared_form.form == form + assert required.prepared_form.form_token is not None + assert required.prepared_form.form_token.get_secret_value() == "initiator-token" + assert required.id == "execution-1" + assert required.node_id == "node-1" + assert required.node_version == "2" + assert isinstance(events[2], NodeRunPauseRequestedEvent) + assert isinstance(events[2].reason, HitlRequired) + assert events[2].reason.session_id == "hitlv2:form-1" + assert events[2].reason.node_title == "Frozen title" + + +def test_waiting_reason_obeys_the_graphon_wire_contract(form: Form) -> None: + node, _ = build_node(form, initiator=True, token="initiator-token") + pause = list(node.run())[-1] + assert isinstance(pause, NodeRunPauseRequestedEvent) + restored = TypeAdapter(PauseReason).validate_json(pause.reason.model_dump_json()) + assert restored.model_dump(mode="json") == { + "TYPE": "hitl_required", + "session_id": "hitlv2:form-1", + "node_id": "node-1", + "node_title": "Frozen title", + } + + +def test_persisted_timeout_emits_timeout_before_timeout_branch(form: Form) -> None: + # Even a future deadline cannot undo an already persisted timeout. + form = replace(form, status=HumanInputFormStatus.TIMEOUT, expiration_time=datetime(2099, 1, 1)) + node, _ = build_node(form) + events = list(node.run()) + assert len(events) == 3 + assert isinstance(events[0], NodeRunStartedEvent) + assert isinstance(events[1], NodeRunHumanInputFormTimeoutEvent) + assert isinstance(events[2], NodeRunSucceededEvent) + assert events[1].node_title == "Frozen title" + assert events[1].expiration_time == datetime(2099, 1, 1) + assert events[2].node_run_result.edge_source_handle == "__timeout" + assert events[2].node_run_result.outputs["__action_id"].text == "" + + +def test_global_expiration_aborts_without_a_node_branch(form: Form) -> None: + node, _ = build_node(replace(form, status=HumanInputFormStatus.EXPIRED)) + assert [type(event) for event in node.run()] == [NodeRunStartedEvent] + assert node.runtime_state.graph_execution.aborted + + +def test_all_delivery_failure_emits_failure_not_pause(form: Form) -> None: + node, runtime = build_node(form) + runtime.prepare_form.side_effect = HumanInputDeliveryError("All deliveries failed") + events = list(node.run()) + assert [type(event) for event in events] == [NodeRunStartedEvent, NodeRunFailedEvent] + assert isinstance(events[-1], NodeRunFailedEvent) + assert events[-1].error == "All deliveries failed" + + +def test_reentry_consumes_the_new_persisted_outcome(form: Form) -> None: + node, runtime = build_node(form) + runtime.prepare_form.side_effect = [PreparedForm(form, None), PreparedForm(submit(form), None)] + assert isinstance(list(node.run())[-1], NodeRunPauseRequestedEvent) + assert isinstance(list(node.run())[-1], NodeRunSucceededEvent) + + +@pytest.mark.parametrize("status", [HumanInputFormStatus.SUBMITTED, HumanInputFormStatus.TIMEOUT]) +def test_actual_graph_queue_response_path_preserves_order_and_fields(form: Form, status: HumanInputFormStatus) -> None: + form = submit(form) if status == HumanInputFormStatus.SUBMITTED else replace(form, status=status) + node, _ = build_node(form) + runner = create_autospec(WorkflowBasedAppRunner, instance=True) + entry = WorkflowEntry({}, Graph(root_node=node), 0, node.runtime_state) + for event in list(engine_events(node))[1:]: + WorkflowBasedAppRunner.handle_event(runner, entry, event) + queue_events = [call.args[0] for call in runner._publish_event.call_args_list] + assert isinstance(queue_events[1], QueueNodeSucceededEvent) + converter = _build_converter() + converter.workflow_start_to_stream_response( + task_id="task-1", workflow_run_id="run-1", workflow_id="wf-1", reason=WorkflowStartReason.INITIAL + ) + if status == HumanInputFormStatus.SUBMITTED: + assert isinstance(queue_events[0], QueueHumanInputFormFilledEvent) + assert queue_events[0].submitted_data is not None + assert isinstance(queue_events[0].submitted_data["files"], ArrayFileSegment) + response = converter.human_input_form_filled_to_stream_response(event=queue_events[0], task_id="task-1") + assert response.data.action_text == "Frozen approval" + assert response.data.rendered_content == "Answer: yes; files: [0 files]" + assert response.data.submitted_data == {"answer": "yes", "files": []} + else: + assert isinstance(queue_events[0], QueueHumanInputFormTimeoutEvent) + response = converter.human_input_form_timeout_to_stream_response(event=queue_events[0], task_id="task-1") + assert response.data.expiration_time == 1735776000 + assert response.data.node_title == "Frozen title" + assert response.data.form_id == form.id + completed_response = converter.workflow_node_finish_to_stream_response(event=queue_events[1], task_id="task-1") + assert completed_response is not None + assert completed_response.data.id == "execution-1" + + +@pytest.mark.parametrize("token", ["initiator-token", None]) +def test_dify_projection_serializes_the_runtime_token_without_a_graph_reason(form: Form, token: str | None) -> None: + from core.app.apps.common.workflow_response_converter import WorkflowResponseConverter + from core.app.entities.task_entities import HumanInputRequiredPauseReasonPayload + + prepared = PreparedForm(form, SecretStr(token) if token else None) + with patch( + "core.app.apps.common.workflow_response_converter.Session", side_effect=AssertionError("unexpected lookup") + ): + data = WorkflowResponseConverter.human_input_v2_required_data(prepared, node_id="node-1") + assert data.form_token == token + assert data.form_content == form.resolved_form.legacy_form_content + assert data.resolved_default_values == {"answer": "original"} + wire_fields = { + "form_version", + "form_id", + "node_id", + "node_title", + "form_content", + "inputs", + "actions", + "display_in_ui", + "form_token", + "approval_channels", + "resolved_default_values", + "expiration_time", + } + assert set(data.model_dump(mode="json")) == wire_fields + assert set(HumanInputRequiredPauseReasonPayload.from_response_data(data).model_dump(mode="json")) == { + "TYPE", + *wire_fields, + } + + +def test_file_segments_survive_node_graph_queue_and_response(form: Form) -> None: + attachment = File( + file_id="file-1", + file_type=FileType.DOCUMENT, + transfer_method=FileTransferMethod.REMOTE_URL, + remote_url="https://example.com/review.pdf", + filename="review.pdf", + extension=".pdf", + mime_type="application/pdf", + size=100, + ) + file_input = FileInput( + output_variable_name="attachment", + allowed_file_types=(FileType.DOCUMENT,), + allowed_file_extensions=(".pdf",), + allowed_file_upload_methods=(FileTransferMethod.REMOTE_URL,), + ) + form = submit(form) + assert form.submission is not None + values = dict(form.submission.normalized_submission_data) + values.update(attachment=FileSegment(value=attachment), files=ArrayFileSegment(value=[attachment])) + form = replace( + form, + resolved_form=form.resolved_form.model_copy( + update={"parts": form.resolved_form.parts + (MarkdownFragment(text="; attachment: "), file_input)} + ), + submission=replace(form.submission, normalized_submission_data=values), + ) + node, _ = build_node(form) + graph_events = list(node.run()) + assert isinstance(graph_events[1], NodeRunHumanInputFormFilledEvent) + assert isinstance(graph_events[2], NodeRunSucceededEvent) + assert graph_events[1].rendered_content == "Answer: yes; files: [1 files]; attachment: [file]" + assert isinstance(graph_events[2].node_run_result.outputs["attachment"], FileSegment) + runner = create_autospec(WorkflowBasedAppRunner, instance=True) + WorkflowBasedAppRunner.handle_event( + runner, WorkflowEntry({}, Graph(root_node=node), 0, node.runtime_state), graph_events[1] + ) + queue_event = runner._publish_event.call_args.args[0] + assert isinstance(queue_event.submitted_data["attachment"], FileSegment) + assert isinstance(queue_event.submitted_data["files"], ArrayFileSegment) + converter = _build_converter() + converter.workflow_start_to_stream_response( + task_id="task-1", workflow_run_id="run-1", workflow_id="wf-1", reason=WorkflowStartReason.INITIAL + ) + response = converter.human_input_form_filled_to_stream_response(event=queue_event, task_id="task-1") + assert response.data.submitted_data is not None + assert response.data.submitted_data["attachment"]["filename"] == "review.pdf" + assert response.data.submitted_data["files"][0]["filename"] == "review.pdf" + + +def test_filled_rendering_does_not_reinterpret_submission_as_a_template(form: Form) -> None: + form = submit(form) + assert form.submission is not None + values = dict(form.submission.normalized_submission_data) + values["answer"] = StringSegment(value="{{#$output.files#}}") + node, _ = build_node(replace(form, submission=replace(form.submission, normalized_submission_data=values))) + filled = list(node.run())[1] + assert isinstance(filled, NodeRunHumanInputFormFilledEvent) + assert filled.rendered_content == "Answer: {{#$output.files#}}; files: [0 files]" + + +def test_filled_rendering_skips_missing_and_null_inputs_and_preserves_select_text(form: Form) -> None: + form = submit(form) + assert form.submission is not None + form = replace( + form, + resolved_form=form.resolved_form.model_copy( + update={ + "parts": ( + MarkdownFragment(text="Before "), + ParagraphInput(output_variable_name="missing", default_value="unused"), + ParagraphInput(output_variable_name="null", default_value=None), + SelectInput(output_variable_name="choice", options=("Yes", "No"), default_value=None), + MarkdownFragment(text=" after"), + ) + } + ), + submission=replace( + form.submission, + normalized_submission_data={"null": NoneSegment(), "choice": StringSegment(value="Yes")}, + ), + ) + + node, _ = build_node(form) + events = list(node.run()) + + assert isinstance(events[1], NodeRunHumanInputFormFilledEvent) + assert isinstance(events[2], NodeRunSucceededEvent) + assert events[1].rendered_content == "Before Yes after" + assert events[2].node_run_result.outputs["__rendered_content"].text == "Before Yes after" + + +def test_waiting_projection_freezes_select_options_and_deduplicates_slots(form: Form) -> None: + from core.app.apps.common.workflow_response_converter import WorkflowResponseConverter + + option = SelectInput(output_variable_name="choice", options=("old-a", "old-b"), default_value="old-b") + form = replace(form, resolved_form=form.resolved_form.model_copy(update={"parts": (option, option)})) + prepared = PreparedForm(form, SecretStr("secret-token")) + data = WorkflowResponseConverter.human_input_v2_required_data(prepared, node_id="node-1") + assert len(data.inputs) == 1 + assert isinstance(data.inputs[0], SelectInputConfig) + assert data.inputs[0].option_source.value == ["old-a", "old-b"] + assert data.inputs[0].option_source.type == ValueSourceType.CONSTANT + assert data.resolved_default_values == {"choice": "old-b"} + assert "secret-token" not in repr(prepared) + + +def test_debug_dependencies_include_all_sources_and_complete_nested_paths(form: Form) -> None: + node, _ = build_node(form) + data = node.node_data.model_copy( + update={ + "form_content": "{{#upstream.data.name#}} {{#$output.answer#}}", + "message_template": MessageTemplateConfig( + subject="{{#upstream.subject#}}", body="{{#upstream.body#}} [Request URL]" + ), + "recipients_spec": [DynamicEmail(selector=("upstream", "data", "email"))], + "inputs": [ + ParagraphInputConfig( + output_variable_name="answer", + default=StringSource(type=ValueSourceType.VARIABLE, selector=("upstream", "data", "default")), + ), + SelectInputConfig( + output_variable_name="choice", + option_source=StringListSource(type=ValueSourceType.VARIABLE, selector=("upstream", "options")), + ), + ], + } + ) + mapping = HumanInputNode.extract_variable_selector_to_variable_mapping( + graph_config={}, config={"id": "node-1", "data": data} + ) + assert set(map(tuple, mapping.values())) == { + ("upstream", "data", "name"), + ("upstream", "subject"), + ("upstream", "body"), + ("upstream", "data", "email"), + ("upstream", "data", "default"), + ("upstream", "options"), + } + + +@pytest.mark.parametrize("status", [HumanInputFormStatus.SUBMITTED, HumanInputFormStatus.TIMEOUT]) +def test_node_emits_form_outcome_before_completion(form: Form, status: HumanInputFormStatus) -> None: + form = submit(form) if status == HumanInputFormStatus.SUBMITTED else replace(form, status=status) + node, _ = build_node(form) + events = list(node.run()) + expected = ( + NodeRunHumanInputFormFilledEvent + if status == HumanInputFormStatus.SUBMITTED + else NodeRunHumanInputFormTimeoutEvent + ) + assert isinstance(events[1], expected) + assert isinstance(events[2], NodeRunSucceededEvent) + + +def test_button_only_submission_completes_without_input_fields(form: Form) -> None: + form = submit(form) + assert form.submission is not None + form = replace( + form, + resolved_form=form.resolved_form.model_copy(update={"parts": (MarkdownFragment(text="Approve this"),)}), + submission=replace(form.submission, raw_submission_inputs={}, normalized_submission_data={}), + ) + node, _ = build_node(form) + events = list(node.run()) + assert isinstance(events[1], NodeRunHumanInputFormFilledEvent) + assert isinstance(events[2], NodeRunSucceededEvent) + assert events[1].submitted_data == {} + assert events[1].rendered_content == "Approve this" + assert events[2].node_run_result.edge_source_handle == "approve" diff --git a/api/tests/unit_tests/core/workflow/test_human_input_adapter.py b/api/tests/unit_tests/core/workflow/test_human_input_adapter.py index dd394327823..1e8ee1d5240 100644 --- a/api/tests/unit_tests/core/workflow/test_human_input_adapter.py +++ b/api/tests/unit_tests/core/workflow/test_human_input_adapter.py @@ -8,6 +8,9 @@ from core.workflow.human_input_adapter import ( EmailDeliveryConfig, EmailDeliveryMethod, EmailRecipients, + InstantMessageDeliveryMethod, + InstantMessageProvider, + InstantMessageRecipientType, WebAppDeliveryMethod, _WebAppDeliveryConfig, adapt_human_input_node_data_for_graph, @@ -175,6 +178,35 @@ def test_parse_human_input_delivery_methods_returns_empty_for_non_lists() -> Non assert parse_human_input_delivery_methods({"delivery_methods": None}) == [] +def test_parse_human_input_delivery_methods_accepts_instant_message_config() -> None: + methods = parse_human_input_delivery_methods( + { + "delivery_methods": [ + { + "type": DeliveryMethodType.IM, + "config": { + "provider": "slack", + "message": "Review {{#node.value#}}", + "recipients": { + "items": [ + {"type": "channel", "channel_id": "C123"}, + {"type": "user", "user_id": "U123"}, + ] + }, + }, + } + ] + } + ) + + assert len(methods) == 1 + method = methods[0] + assert isinstance(method, InstantMessageDeliveryMethod) + assert method.config.provider == InstantMessageProvider.SLACK + assert method.config.recipients.items[0].type == InstantMessageRecipientType.CHANNEL + assert method.config.recipients.items[1].type == InstantMessageRecipientType.USER + + def test_is_human_input_webapp_enabled_checks_enabled_delivery_methods() -> None: assert ( is_human_input_webapp_enabled( diff --git a/api/tests/unit_tests/dev/test_check_knowledge_fs_coverage.py b/api/tests/unit_tests/dev/test_check_knowledge_fs_coverage.py index 0ac7053c142..7d543f54516 100644 --- a/api/tests/unit_tests/dev/test_check_knowledge_fs_coverage.py +++ b/api/tests/unit_tests/dev/test_check_knowledge_fs_coverage.py @@ -10,6 +10,7 @@ from dev.check_knowledge_fs_coverage import ( CoverageGateError, CoverageReport, CoverageSummary, + collect_changed_glue_lines, is_core_coverage_path, load_glue_coverage_paths, parse_added_lines, @@ -267,6 +268,49 @@ def test_diff_base_resolution_is_explicit_and_fail_closed_for_a_single_commit(tm resolve_diff_base(tmp_path, "missing-base") +@pytest.mark.parametrize("configuration", ["diff.mnemonicPrefix", "diff.noprefix"]) +def test_changed_glue_collection_ignores_user_diff_prefix_configuration(tmp_path: Path, configuration: str) -> None: + subprocess.run(["git", "init", "--quiet"], cwd=tmp_path, check=True) + subprocess.run(["git", "config", configuration, "true"], cwd=tmp_path, check=True) + source = tmp_path / "api/app_factory.py" + source.parent.mkdir() + source.write_text("value = 1\n") + subprocess.run(["git", "add", "."], cwd=tmp_path, check=True) + subprocess.run( + [ + "git", + "-c", + "user.email=coverage@example.com", + "-c", + "user.name=Coverage Test", + "commit", + "--quiet", + "-m", + "fixture", + ], + cwd=tmp_path, + check=True, + ) + source.write_text("value = 2\n") + + changed_lines = collect_changed_glue_lines(tmp_path, "HEAD", glue_paths=("api/app_factory.py",)) + + assert changed_lines == {"api/app_factory.py": {1}} + report = coverage_report( + { + "api/app_factory.py": coverage_file( + covered_lines=0, + num_statements=1, + covered_branches=0, + num_branches=0, + missing_lines=[1], + ) + } + ) + with pytest.raises(CoverageGateError, match="changed-glue line coverage is 0.00%"): + validate_changed_glue_coverage(report, changed_lines=changed_lines, minimum=90) + + def coverage_report(files: dict[str, CoverageFile]) -> CoverageReport: return {"files": files} diff --git a/api/tests/unit_tests/dev/test_generate_swagger_specs.py b/api/tests/unit_tests/dev/test_generate_swagger_specs.py index 5f1b2456327..5d2ead032a7 100644 --- a/api/tests/unit_tests/dev/test_generate_swagger_specs.py +++ b/api/tests/unit_tests/dev/test_generate_swagger_specs.py @@ -174,6 +174,7 @@ def test_generate_specs_match_live_openapi_payloads(tmp_path: Path): assert json.loads(path.read_text(encoding="utf-8")) == live_payloads[path.name] +@pytest.mark.timeout(75) # Allow one cold, coverage-instrumented export with its own 60-second limit. def test_generate_specs_after_controllers_were_imported_with_swagger_disabled(tmp_path: Path): api_dir = Path(__file__).resolve().parents[3] script = """ @@ -210,6 +211,7 @@ assert [path.name for path in written_paths] == [ env=env, capture_output=True, text=True, + timeout=60, ) assert result.returncode == 0, result.stderr diff --git a/api/tests/unit_tests/extensions/logstore/repositories/test_logstore_api_workflow_node_execution_repository.py b/api/tests/unit_tests/extensions/logstore/repositories/test_logstore_api_workflow_node_execution_repository.py index 7f9359c79d7..5731dc8627d 100644 --- a/api/tests/unit_tests/extensions/logstore/repositories/test_logstore_api_workflow_node_execution_repository.py +++ b/api/tests/unit_tests/extensions/logstore/repositories/test_logstore_api_workflow_node_execution_repository.py @@ -18,8 +18,28 @@ from extensions.logstore.repositories.logstore_api_workflow_node_execution_repos from models.workflow import WorkflowNodeExecutionModel, WorkflowNodeExecutionTriggeredFrom +@pytest.fixture +def logstore_database() -> Generator[sqlite3.Connection]: + with closing(sqlite3.connect(":memory:")) as database: + database.row_factory = sqlite3.Row + database.execute( + "CREATE TABLE workflow_node_execution (" + "id TEXT, node_execution_id TEXT, tenant_id TEXT, app_id TEXT, workflow_id TEXT, " + "workflow_run_id TEXT, triggered_from_workflow_id TEXT, triggered_from_node_execution_id TEXT, " + 'triggered_from TEXT, node_id TEXT, node_type TEXT, title TEXT, "index" INTEGER, ' + "status TEXT, elapsed_time REAL, created_at INTEGER, finished_at INTEGER, execution_metadata TEXT, " + "log_version INTEGER, __time__ INTEGER, inputs TEXT, process_data TEXT, outputs TEXT)" + ) + yield database + + @pytest.fixture(params=["pg", "sdk"]) -def sql_repository(request: pytest.FixtureRequest, monkeypatch: pytest.MonkeyPatch, mocker: MockerFixture): +def sql_repository( + request: pytest.FixtureRequest, + monkeypatch: pytest.MonkeyPatch, + mocker: MockerFixture, + logstore_database: sqlite3.Connection, +): """Exercise the real protocol adapter while executing its SQL against local records.""" monkeypatch.setattr(AliyunLogStore, "_instance", None) monkeypatch.setattr(AliyunLogStore, "_initialized", False) @@ -30,24 +50,22 @@ def sql_repository(request: pytest.FixtureRequest, monkeypatch: pytest.MonkeyPat store._pg_client = AliyunLogStorePG("key", "secret", "localhost", "test") store.project_name = "test" store.log_enabled = False - with closing(sqlite3.connect(":memory:")) as database: - database.row_factory = sqlite3.Row - def execute(sql: str, *_args: object) -> list[dict[str, object]]: - return [dict(row) for row in database.execute(sql)] + def execute(sql: str, *_args: object) -> list[dict[str, object]]: + return [dict(row) for row in logstore_database.execute(sql)] - def get_logs(log_request: GetLogsRequest): - search, separator, sql = log_request.get_query().partition(" | ") - assert separator - assert search - assert log_request.get_from() == 0 - assert log_request.get_to() <= int(time.time()) - return GetLogsResponse({"meta": {"progress": "Complete"}, "data": execute(sql)}, {}) + def get_logs(log_request: GetLogsRequest): + search, separator, sql = log_request.get_query().partition(" | ") + assert separator + assert search + assert log_request.get_from() == 0 + assert log_request.get_to() <= int(time.time()) + return GetLogsResponse({"meta": {"progress": "Complete"}, "data": execute(sql)}, {}) - mocker.patch.object(store._pg_client, "execute_sql", side_effect=execute) - mocker.patch.object(store.client, "get_logs", side_effect=get_logs) - repository = LogstoreAPIWorkflowNodeExecutionRepository(session_maker=None) - yield repository, database + mocker.patch.object(store._pg_client, "execute_sql", side_effect=execute) + mocker.patch.object(store.client, "get_logs", side_effect=get_logs) + repository = LogstoreAPIWorkflowNodeExecutionRepository(session_maker=None) + return repository, logstore_database def test_sql_history_excludes_recursive_tool_executions_and_retains_legacy_rows(sql_repository) -> None: @@ -73,13 +91,9 @@ def test_sql_history_excludes_recursive_tool_executions_and_retains_legacy_rows( ("child", 3, WorkflowNodeExecutionTriggeredFrom.WORKFLOW_TOOL), ) ] - database.execute( - "CREATE TABLE workflow_node_execution (id TEXT, tenant_id TEXT, app_id TEXT, workflow_id TEXT, " - 'workflow_run_id TEXT, node_id TEXT, triggered_from TEXT, status TEXT, "index" INTEGER, ' - "created_at INTEGER, log_version INTEGER, __time__ INTEGER)" - ) insert = ( - "INSERT INTO workflow_node_execution VALUES " + "INSERT INTO workflow_node_execution (id, tenant_id, app_id, workflow_id, workflow_run_id, " + 'node_id, triggered_from, status, "index", created_at, log_version, __time__) VALUES ' "(:id, :tenant_id, :app_id, :workflow_id, :workflow_run_id, :node_id, :triggered_from, :status, " ":index, :created_at, :log_version, :__time__)" ) @@ -126,65 +140,63 @@ def test_sdk_history_searches_use_owner_scope() -> None: assert history_query["query"] == 'tenant_id: "tenant" and app_id: "app" and workflow_run_id: "run"' -def test_resumption_snapshots_read_latest_nodes_with_full_owner_scope() -> None: +def test_resumption_snapshots_read_latest_nodes_with_full_owner_scope(logstore_database: sqlite3.Connection) -> None: with patch("extensions.logstore.repositories.logstore_api_workflow_node_execution_repository.AliyunLogStore"): repository = LogstoreAPIWorkflowNodeExecutionRepository(session_maker=None) - with closing(sqlite3.connect(":memory:")) as database: - database.row_factory = sqlite3.Row - database.execute( - "CREATE TABLE workflow_node_execution (id TEXT, node_execution_id TEXT, tenant_id TEXT, app_id TEXT, " - "workflow_id TEXT, workflow_run_id TEXT, triggered_from TEXT, node_id TEXT, node_type TEXT, title TEXT, " - '"index" INTEGER, status TEXT, elapsed_time REAL, created_at INTEGER, finished_at INTEGER, ' - "execution_metadata TEXT, log_version INTEGER, __time__ INTEGER, " - "inputs TEXT, process_data TEXT, outputs TEXT)" - ) - base = { - "id": "row-id", - "node_execution_id": "engine-id", - "tenant_id": "tenant", - "app_id": "app", - "workflow_id": "workflow", - "workflow_run_id": "run", - "triggered_from": "workflow-run", - "node_id": "tool", - "node_type": "tool", - "title": "Approval", - "index": 2, - "status": "paused", - "elapsed_time": 3.5, - "created_at": 100, - "finished_at": 104, - "execution_metadata": '{"iteration_id":"iteration"}', - "log_version": 2, - "__time__": 1, - "inputs": '{"private":"input"}', - "process_data": '{"private":"trace"}', - "outputs": '{"private":"output"}', - } - rows = [base, {**base, "log_version": 1, "status": "running"}] - rows.extend( - {**base, "id": f"page-{index}", "index": index, "execution_metadata": "[]", "elapsed_time": None} - for index in range(3, 1003) - ) - for field in ("tenant_id", "app_id", "workflow_id", "workflow_run_id", "triggered_from"): - rows.append({**base, "id": f"other-{field}", field: "other", "index": 99}) - database.executemany( - "INSERT INTO workflow_node_execution VALUES (" + ", ".join(f":{key}" for key in base) + ")", rows - ) + database = logstore_database + database.row_factory = sqlite3.Row + base = { + "id": "row-id", + "node_execution_id": "engine-id", + "tenant_id": "tenant", + "app_id": "app", + "workflow_id": "workflow", + "workflow_run_id": "run", + "triggered_from": "workflow-run", + "node_id": "tool", + "node_type": "tool", + "title": "Approval", + "index": 2, + "status": "paused", + "elapsed_time": 3.5, + "created_at": 100, + "finished_at": 104, + "execution_metadata": '{"iteration_id":"iteration"}', + "log_version": 2, + "__time__": 1, + "inputs": '{"private":"input"}', + "process_data": '{"private":"trace"}', + "outputs": '{"private":"output"}', + } + rows = [base, {**base, "log_version": 1, "status": "running"}] + rows.extend( + {**base, "id": f"page-{index}", "index": index, "execution_metadata": "[]", "elapsed_time": None} + for index in range(3, 1003) + ) + for field in ("tenant_id", "app_id", "workflow_id", "workflow_run_id", "triggered_from"): + rows.append({**base, "id": f"other-{field}", field: "other", "index": 99}) + database.executemany( + 'INSERT INTO workflow_node_execution ("' + + '", "'.join(base) + + '") VALUES (' + + ", ".join(f":{key}" for key in base) + + ")", + rows, + ) - def execute_query(*, sql: str, **_kwargs: object) -> list[dict[str, object]]: - result = [dict(row) for row in database.execute(sql)] - assert all(row.keys().isdisjoint({"inputs", "process_data", "outputs"}) for row in result) - return result + def execute_query(*, sql: str, **_kwargs: object) -> list[dict[str, object]]: + result = [dict(row) for row in database.execute(sql)] + assert all(row.keys().isdisjoint({"inputs", "process_data", "outputs"}) for row in result) + return result - repository.logstore_client.execute_sql.side_effect = execute_query - snapshots = repository.get_execution_snapshots_by_workflow_run( - tenant_id="tenant", - app_id="app", - workflow_id="workflow", - triggered_from="workflow-run", - workflow_run_id="run", - ) + repository.logstore_client.execute_sql.side_effect = execute_query + snapshots = repository.get_execution_snapshots_by_workflow_run( + tenant_id="tenant", + app_id="app", + workflow_id="workflow", + triggered_from="workflow-run", + workflow_run_id="run", + ) assert len(snapshots) == 1001 snapshot = snapshots[0] assert (snapshot.execution_id, snapshot.title, snapshot.index, snapshot.status) == ( @@ -293,15 +305,10 @@ def test_workflow_tool_children_only_fetch_matching_latest_payloads( } rows = [{**defaults, **row} for row in rows] fetched_payload_ids: list[str] = [] - database.execute( - "CREATE TABLE workflow_node_execution (id TEXT, tenant_id TEXT, app_id TEXT, workflow_run_id TEXT, " - "node_execution_id TEXT, workflow_id TEXT, triggered_from_workflow_id TEXT, " - "triggered_from_node_execution_id TEXT, node_type TEXT, triggered_from TEXT, " - "process_data TEXT, outputs TEXT, " - 'status TEXT, "index" INTEGER, created_at INTEGER, log_version INTEGER, __time__ INTEGER)' - ) database.executemany( - "INSERT INTO workflow_node_execution VALUES " + "INSERT INTO workflow_node_execution (id, tenant_id, app_id, workflow_run_id, node_execution_id, " + "workflow_id, triggered_from_workflow_id, triggered_from_node_execution_id, node_type, triggered_from, " + 'process_data, outputs, status, "index", created_at, log_version, __time__) VALUES ' "(:id, :tenant_id, :app_id, :workflow_run_id, :node_execution_id, :workflow_id, " ":triggered_from_workflow_id, :triggered_from_node_execution_id, :node_type, :triggered_from, " ":process_data, :outputs, :status, :index, :created_at, :log_version, :__time__)", @@ -338,12 +345,9 @@ def test_load_full_process_data_returns_logstore_mapping() -> None: def test_get_execution_by_id_keeps_process_data_from_highest_failed_log_version(sql_repository) -> None: repository, database = sql_repository execution_id, tenant_id = "execution'1", 'tenant"1' - database.execute( - "CREATE TABLE workflow_node_execution " - "(id TEXT, tenant_id TEXT, log_version INTEGER, status TEXT, process_data TEXT, __time__ INTEGER)" - ) database.executemany( - "INSERT INTO workflow_node_execution VALUES (?, ?, ?, ?, ?, 1)", + "INSERT INTO workflow_node_execution (id, tenant_id, log_version, status, process_data, __time__) " + "VALUES (?, ?, ?, ?, ?, 1)", [ (execution_id, tenant_id, 1, "running", "{}"), (execution_id, tenant_id, 2, "failed", '{"workflow_agent_binding_id":"binding-1"}'), diff --git a/api/tests/unit_tests/extensions/logstore/repositories/test_logstore_workflow_node_execution_repository.py b/api/tests/unit_tests/extensions/logstore/repositories/test_logstore_workflow_node_execution_repository.py index a882af60039..165729482f4 100644 --- a/api/tests/unit_tests/extensions/logstore/repositories/test_logstore_workflow_node_execution_repository.py +++ b/api/tests/unit_tests/extensions/logstore/repositories/test_logstore_workflow_node_execution_repository.py @@ -22,6 +22,15 @@ def _make_account() -> Account: return account +@pytest.fixture +def logstore_schema(sqlite_engine: Engine) -> None: + with sqlite_engine.begin() as connection: + connection.exec_driver_sql( + "CREATE TABLE workflow_node_execution AS SELECT *, 1 AS log_version FROM workflow_node_executions WHERE 0" + ) + + +@pytest.mark.usefixtures("logstore_schema") @pytest.mark.parametrize("source_app", ["caller-app", "source-app"]) def test_workflow_tool_scope_preserves_logstore_and_source_scoped_synchronous_agent_rows( config_overrides: Callable[..., None], @@ -95,7 +104,7 @@ def test_workflow_tool_scope_preserves_logstore_and_source_scoped_synchronous_ag session.commit() with sqlite_engine.begin() as connection: connection.exec_driver_sql( - "CREATE TABLE workflow_node_execution AS SELECT *, 1 AS log_version FROM workflow_node_executions" + "INSERT INTO workflow_node_execution SELECT *, 1 AS log_version FROM workflow_node_executions" ) connection.exec_driver_sql("UPDATE workflow_node_execution SET triggered_from = NULL WHERE id = 'legacy-exec'") diff --git a/api/tests/unit_tests/extensions/test_ext_blueprints.py b/api/tests/unit_tests/extensions/test_ext_blueprints.py index 6694dd726ee..12840cb5d2b 100644 --- a/api/tests/unit_tests/extensions/test_ext_blueprints.py +++ b/api/tests/unit_tests/extensions/test_ext_blueprints.py @@ -297,33 +297,6 @@ def _build_app() -> DifyApp: class TestExtBlueprintsOpenapiCases: - @staticmethod - @pytest.fixture - def fresh_blueprints(monkeypatch: pytest.MonkeyPatch) -> dict[str, Blueprint]: - """Replace each production blueprint singleton with a fresh, unregistered copy. - - Mirrors the production `name` and `url_prefix` so the gate can still - be asserted via `app.blueprints[...]` and url_map prefix checks. The - openapi replacement gets a dummy `/_probe` rule so the test can - observe at least one `/openapi/v1/*` rule after registration. - """ - fresh: dict[str, Blueprint] = {} - for module_name in _BLUEPRINT_MODULES: - module = importlib.import_module(module_name) - original = module.bp - replacement = Blueprint( - original.name, - original.import_name, - url_prefix=original.url_prefix, - ) - if module_name == "controllers.openapi": - replacement.add_url_rule("/_probe", endpoint="_probe", view_func=_probe_view) - if module_name == "controllers.console": - replacement.add_url_rule("/_download", endpoint="_download", view_func=_download_probe_view) - monkeypatch.setattr(module, "bp", replacement) - fresh[module_name] = replacement - return fresh - @staticmethod def test_openapi_blueprint_registered_with_cors_when_enabled( monkeypatch: pytest.MonkeyPatch, @@ -389,3 +362,30 @@ class TestExtBlueprintsOpenapiCases: header.strip().lower() for header in response.headers["Access-Control-Expose-Headers"].split(",") } assert "content-disposition" in exposed_headers + + +@pytest.fixture +def fresh_blueprints(monkeypatch: pytest.MonkeyPatch) -> dict[str, Blueprint]: + """Replace each production blueprint singleton with a fresh, unregistered copy. + + Mirrors the production `name` and `url_prefix` so the gate can still + be asserted via `app.blueprints[...]` and url_map prefix checks. The + openapi replacement gets a dummy `/_probe` rule so the test can + observe at least one `/openapi/v1/*` rule after registration. + """ + fresh: dict[str, Blueprint] = {} + for module_name in _BLUEPRINT_MODULES: + module = importlib.import_module(module_name) + original = module.bp + replacement = Blueprint( + original.name, + original.import_name, + url_prefix=original.url_prefix, + ) + if module_name == "controllers.openapi": + replacement.add_url_rule("/_probe", endpoint="_probe", view_func=_probe_view) + if module_name == "controllers.console": + replacement.add_url_rule("/_download", endpoint="_download", view_func=_download_probe_view) + monkeypatch.setattr(module, "bp", replacement) + fresh[module_name] = replacement + return fresh diff --git a/api/tests/unit_tests/factories/test_file_url_factory.py b/api/tests/unit_tests/factories/test_file_url_factory.py new file mode 100644 index 00000000000..5e4780b7c99 --- /dev/null +++ b/api/tests/unit_tests/factories/test_file_url_factory.py @@ -0,0 +1,155 @@ +"""Behavior tests for refreshing file URLs in display content.""" + +from collections.abc import Callable +from typing import Literal +from urllib.parse import parse_qs, urlparse + +import pytest + +from core.app.workflow.file_runtime import create_dify_workflow_file_runtime +from core.tools.signature import sign_tool_file, sign_upload_file_preview_url +from factories.file_url_factory import re_sign_file_urls + + +@pytest.fixture(autouse=True) +def _signature_config(config_overrides: Callable[..., None]) -> None: + config_overrides( + SECRET_KEY="unit-secret", + FILES_URL="https://files.example.com", + INTERNAL_FILES_URL="https://internal.example.com", + FILES_ACCESS_TIMEOUT=120, + ) + + +@pytest.mark.parametrize("kind", ["upload", "image", "tool", "datasource"]) +@pytest.mark.parametrize("base_url", ["", "https://files.example.com", "https://files.example.com/proxy"]) +def test_refresh_expired_preview_urls_preserves_target_and_download_options( + kind: str, base_url: str, monkeypatch: pytest.MonkeyPatch, config_overrides: Callable[..., None] +) -> None: + config_overrides(FILES_URL=base_url, FILES_ACCESS_TIMEOUT=120) + monkeypatch.setattr("core.tools.signature.time.time", lambda: 1700000000) + runtime = create_dify_workflow_file_runtime() + file_id = "b4c3d2e1-0000-4000-8000-123456789abc" + match kind: + case "upload": + url = runtime.resolve_upload_file_url(upload_file_id=file_id, as_attachment=True) + case "image": + url = sign_upload_file_preview_url(file_id, ".png") + case _: + url = sign_tool_file(file_id, ".pdf") + if kind == "datasource": + url = url.replace("/files/tools/", "/files/datasources/") + url += "&label=Review%20file#page=2" + original = urlparse(url) + old_query = parse_qs(original.query) + preview_kind: Literal["image", "file"] = "image" if kind == "image" else "file" + monkeypatch.setattr("core.tools.signature.time.time", lambda: 1700000200) + assert not runtime.verify_preview_signature( + preview_kind=preview_kind, + file_id=file_id, + timestamp=old_query["timestamp"][0], + nonce=old_query["nonce"][0], + sign=old_query["sign"][0], + ) + + refreshed = re_sign_file_urls(url, preserve_url=True) + parsed = urlparse(refreshed) + query = parse_qs(parsed.query) + assert (parsed.scheme, parsed.netloc, parsed.path, parsed.fragment) == ( + original.scheme, + original.netloc, + original.path, + original.fragment, + ) + assert "&label=Review%20file#page=2" in refreshed + if kind == "upload": + assert query["as_attachment"] == ["true"] + assert query["timestamp"] == ["1700000200"] + assert query["nonce"] != old_query["nonce"] + for now, expected_valid in ((1700000200, True), (1700000320, True), (1700000321, False)): + monkeypatch.setattr("core.tools.signature.time.time", lambda now=now: now) + assert ( + runtime.verify_preview_signature( + preview_kind=preview_kind, + file_id=file_id, + timestamp=query["timestamp"][0], + nonce=query["nonce"][0], + sign=query["sign"][0], + ) + == expected_valid + ) + + +@pytest.mark.parametrize( + "change", + [ + "external_origin", + "lookalike_origin", + "other_file", + "unsigned", + "bad_signature", + "unicode_signature", + "duplicate", + ], +) +def test_refresh_preserves_external_unsigned_and_tampered_urls(change: str) -> None: + url = sign_tool_file("b4c3d2e1-0000-4000-8000-123456789abc", ".pdf") + match change: + case "external_origin": + url = url.replace("files.example.com", "external.example.com") + case "lookalike_origin": + url = url.replace("files.example.com", "files.example.com.evil.example") + case "other_file": + url = url.replace("b4c3d2e1", "a4c3d2e1") + case "unsigned": + url = url.partition("?")[0] + case "bad_signature": + url = url.replace("sign=", "sign=bad") + case "unicode_signature": + url = url.replace("sign=", "sign=文件") + case "duplicate": + url += "×tamp=1700000000" + assert re_sign_file_urls(url, preserve_url=True) == url + + +@pytest.mark.parametrize( + ("kind", "extension", "expected_path"), + [ + ("upload", "", "/files/file-1/file-preview"), + ("image", "", "/files/file-1/file-preview"), + ("tool", ".pdf", "/files/tools/file-1.pdf"), + ("tool", "", "/files/tools/file-1.bin"), + ("tool", ".long-extension", "/files/tools/file-1.bin"), + ], +) +def test_message_refresh_retains_external_host_and_legacy_preview_conversion( + kind: str, extension: str, expected_path: str, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr("core.tools.signature.time.time", lambda: 1700000000) + match kind: + case "upload": + old_url = create_dify_workflow_file_runtime().resolve_upload_file_url( + upload_file_id="file-1", for_external=False + ) + case "image": + old_url = sign_upload_file_preview_url("file-1", extension) + case _: + old_url = sign_tool_file("file-1", extension, for_external=False) + old_url += "&as_attachment=true" + monkeypatch.setattr("core.tools.signature.time.time", lambda: 1700000400) + + content = re_sign_file_urls(f"[Report]({old_url})") + + parsed = urlparse(content.removeprefix("[Report](").removesuffix(")")) + query = parse_qs(parsed.query) + assert parsed.netloc == "files.example.com" + assert parsed.path == expected_path + assert query["timestamp"] == ["1700000400"] + assert query["as_attachment"] == ["true"] + assert create_dify_workflow_file_runtime().verify_preview_signature( + preview_kind="file", + file_id="file-1", + timestamp=query["timestamp"][0], + nonce=query["nonce"][0], + sign=query["sign"][0], + ) diff --git a/api/tests/unit_tests/factories/test_human_input_v2_form_presentation.py b/api/tests/unit_tests/factories/test_human_input_v2_form_presentation.py new file mode 100644 index 00000000000..8774ab61d0d --- /dev/null +++ b/api/tests/unit_tests/factories/test_human_input_v2_form_presentation.py @@ -0,0 +1,194 @@ +"""Behavior tests for presenting and rendering Human Input v2 form values.""" + +from urllib.parse import parse_qsl, urlencode, urlsplit + +import pytest + +from core.human_input_v2.resolved_form import ( + FileInput, + FileListInput, + FormPart, + MarkdownFragment, + ParagraphInput, + ResolvedForm, + SelectInput, +) +from core.tools.signature import sign_tool_file +from core.workflow.nodes.human_input.entities import ParagraphInputConfig +from core.workflow.nodes.human_input.enums import ValueSourceType +from factories.human_input_v2_form_presentation import ( + build_form_presentation, + refresh_form_file_urls, + render_filled_content, +) +from graphon.file import File, FileTransferMethod, FileType +from graphon.variables.segments import ArrayFileSegment, FileSegment, NoneSegment, Segment, StringSegment + + +@pytest.fixture +def form() -> ResolvedForm: + return ResolvedForm(title="", parts=(), actions=(), legacy_form_content="Unused legacy template") + + +@pytest.fixture +def attachment() -> File: + return File( + file_id="file-1", + file_type=FileType.DOCUMENT, + transfer_method=FileTransferMethod.REMOTE_URL, + remote_url="https://example.com/review.pdf", + filename="review.pdf", + extension=".pdf", + mime_type="application/pdf", + size=100, + ) + + +@pytest.mark.parametrize( + "markup", ["[Report]({url})", "![Report]({url})", "<{url}>", "{url}", '[Report]: {url} "Title"'] +) +def test_file_refresh_keeps_markdown_and_both_snapshot_representations_consistent( + form: ResolvedForm, markup: str, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr("core.tools.signature.time.time", lambda: 1700000000) + url = sign_tool_file("b4c3d2e1-0000-4000-8000-123456789abc", ".pdf") + external = "https://example.com/report.pdf?download=true" + before = f"Review {markup.format(url=url)} / {markup.format(url=url)} / [External]({external}) " + after = "\nFrozen text." + note = ParagraphInput(output_variable_name="note", default_value=url) + snapshot = form.model_copy( + update={ + "parts": (MarkdownFragment(text=before), note, MarkdownFragment(text=after)), + "legacy_form_content": before + "{{#$output.note#}}" + after, + } + ) + monkeypatch.setattr("core.tools.signature.time.time", lambda: 1700000400) + monkeypatch.setattr("core.tools.signature.os.urandom", lambda _: b"\x01" * 16) + new_url = sign_tool_file("b4c3d2e1-0000-4000-8000-123456789abc", ".pdf") + parsed = urlsplit(new_url) + new_url = parsed._replace(query=urlencode(parse_qsl(parsed.query))).geturl() + + refreshed = refresh_form_file_urls(snapshot) + + first, refreshed_note, last = refreshed.parts + assert isinstance(first, MarkdownFragment) + assert isinstance(last, MarkdownFragment) + assert refreshed_note == note + assert last.text == after + assert refreshed.title == snapshot.title + assert refreshed.actions == snapshot.actions + assert refreshed.legacy_form_content == first.text + "{{#$output.note#}}" + after + assert first.text == before.replace(url, new_url) + assert snapshot.legacy_form_content == before + "{{#$output.note#}}" + after + + +@pytest.mark.parametrize( + ("default_value", "expected_value", "expected_resolved_defaults"), + [(None, "", {}), ("", "", {"answer": ""}), ("Preset answer", "Preset answer", {"answer": "Preset answer"})], +) +def test_paragraph_presentation_supplies_a_concrete_default_for_shared_form_controls( + form: ResolvedForm, + default_value: str | None, + expected_value: str, + expected_resolved_defaults: dict[str, str], +) -> None: + form = form.model_copy( + update={"parts": (ParagraphInput(output_variable_name="answer", default_value=default_value),)} + ) + + presentation = build_form_presentation(form) + + (paragraph,) = presentation.inputs + assert isinstance(paragraph, ParagraphInputConfig) + assert paragraph.default is not None + assert paragraph.default.type == ValueSourceType.CONSTANT + assert paragraph.default.value == expected_value + assert presentation.resolved_default_values == expected_resolved_defaults + + +def test_render_follows_source_order_without_reinterpreting_submitted_markers(form: ResolvedForm) -> None: + note = ParagraphInput(output_variable_name="note", default_value=None) + form = form.model_copy( + update={ + "parts": ( + MarkdownFragment(text="Literal {{#$output.choice#}}: "), + note, + MarkdownFragment(text=" / "), + SelectInput(output_variable_name="choice", options=("Yes", "No"), default_value=None), + MarkdownFragment(text=" / "), + note, + ParagraphInput(output_variable_name="empty", default_value="unused"), + ) + } + ) + + content = render_filled_content( + form, + { + "choice": StringSegment(value="Yes"), + "empty": StringSegment(value=""), + "note": StringSegment(value="{{#$output.choice#}}"), + }, + ) + + assert content == "Literal {{#$output.choice#}}: {{#$output.choice#}} / Yes / {{#$output.choice#}}" + + +@pytest.mark.parametrize( + "part", + [ + ParagraphInput(output_variable_name="answer", default_value="unused"), + SelectInput(output_variable_name="answer", options=("Yes", "No"), default_value="Yes"), + FileInput( + output_variable_name="answer", + allowed_file_types=(), + allowed_file_extensions=(), + allowed_file_upload_methods=(), + ), + FileListInput( + output_variable_name="answer", + allowed_file_types=(), + allowed_file_extensions=(), + allowed_file_upload_methods=(), + number_limits=3, + ), + ], + ids=["paragraph", "select", "file", "file-list"], +) +@pytest.mark.parametrize("inputs", [{}, {"answer": NoneSegment()}], ids=["missing", "null"]) +def test_render_omits_missing_and_null_values(form: ResolvedForm, part: FormPart, inputs: dict[str, Segment]) -> None: + form = form.model_copy(update={"parts": (MarkdownFragment(text="before"), part, MarkdownFragment(text="after"))}) + + assert render_filled_content(form, inputs) == "beforeafter" + + +@pytest.mark.parametrize(("count", "expected"), [(0, "[0 files]"), (1, "[1 files]"), (2, "[2 files]")]) +def test_render_uses_file_placeholders_and_list_counts( + form: ResolvedForm, attachment: File, count: int, expected: str +) -> None: + form = form.model_copy( + update={ + "parts": ( + FileInput( + output_variable_name="attachment", + allowed_file_types=(), + allowed_file_extensions=(), + allowed_file_upload_methods=(), + ), + MarkdownFragment(text=" / "), + FileListInput( + output_variable_name="files", + allowed_file_types=(), + allowed_file_extensions=(), + allowed_file_upload_methods=(), + number_limits=3, + ), + ) + } + ) + + content = render_filled_content( + form, {"attachment": FileSegment(value=attachment), "files": ArrayFileSegment(value=[attachment] * count)} + ) + + assert content == f"[file] / {expected}" diff --git a/api/tests/unit_tests/fields/test_timestamp.py b/api/tests/unit_tests/fields/test_timestamp.py new file mode 100644 index 00000000000..d08cbee40e3 --- /dev/null +++ b/api/tests/unit_tests/fields/test_timestamp.py @@ -0,0 +1,47 @@ +import time +from datetime import UTC, datetime, timedelta, timezone + +import pytest +from pydantic import TypeAdapter, ValidationError + +from fields.timestamp import Timestamp + + +def test_timestamp_preserves_datetime_until_json_serialization() -> None: + source = datetime(2026, 1, 12, 8, tzinfo=UTC) + adapter = TypeAdapter(Timestamp) + + timestamp = adapter.validate_python(source) + + assert timestamp is source + assert adapter.dump_python(timestamp, mode="json") == 1768204800 + assert adapter.json_schema(mode="serialization")["type"] == "integer" + + +@pytest.mark.parametrize("host_timezone", ["UTC", "Asia/Shanghai"]) +@pytest.mark.parametrize( + "source", + [ + datetime(2026, 8, 20, 8), + datetime(2026, 8, 20, 8, tzinfo=UTC), + datetime(2026, 8, 20, 17, tzinfo=timezone(timedelta(hours=9))), + ], +) +def test_timestamp_interprets_naive_values_as_utc_in_every_host_timezone( + source: datetime, host_timezone: str, monkeypatch: pytest.MonkeyPatch +) -> None: + adapter = TypeAdapter(Timestamp) + try: + with monkeypatch.context() as timezone_environment: + timezone_environment.setenv("TZ", host_timezone) + time.tzset() + + assert adapter.dump_python(adapter.validate_python(source), mode="json") == 1787212800 + finally: + time.tzset() + + +@pytest.mark.parametrize("value", [1768204800, 1768204800.5]) +def test_timestamp_rejects_preconverted_numeric_values(value: int | float) -> None: + with pytest.raises(ValidationError, match="Timestamp source must be a datetime"): + TypeAdapter(Timestamp).validate_python(value) diff --git a/api/tests/unit_tests/migrations/versions/test_2025_07_02_2332-1c9ba48be8e4_add_uuidv7_function_in_sql.py b/api/tests/unit_tests/migrations/versions/test_2025_07_02_2332-1c9ba48be8e4_add_uuidv7_function_in_sql.py deleted file mode 100644 index 44871b29bf5..00000000000 --- a/api/tests/unit_tests/migrations/versions/test_2025_07_02_2332-1c9ba48be8e4_add_uuidv7_function_in_sql.py +++ /dev/null @@ -1,111 +0,0 @@ -"""Tests for the uuidv7 SQL migration's PostgreSQL 18 compatibility guard. - -The migration file name is not a valid Python identifier (it starts with a date and -contains hyphens), so it is loaded directly from its path. The ``models`` import at the -top of the migration is stubbed because the migration never uses it during -``upgrade()``/``downgrade()`` and pulling in the real package would require a full app -context. -""" - -import importlib.util -import sys -import types -from pathlib import Path -from unittest import mock - -import pytest - -MIGRATION_PATH = ( - Path(__file__).resolve().parents[4] - / "migrations" - / "versions" - / "2025_07_02_2332-1c9ba48be8e4_add_uuidv7_function_in_sql.py" -) - - -def _load_migration(): - # The migration does `import models as models` but never references it, so a stub is - # enough and keeps the test free of any database/app configuration. - sys.modules.setdefault("models", types.ModuleType("models")) - spec = importlib.util.spec_from_file_location("uuidv7_pg18_migration_under_test", MIGRATION_PATH) - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - return module - - -def _make_bind(dialect_name): - bind = mock.MagicMock() - bind.dialect.name = dialect_name - return bind - - -def _executed_sql(fake_op): - return [str(call.args[0]) for call in fake_op.execute.call_args_list] - - -@pytest.fixture -def migration(): - return _load_migration() - - -def test_upgrade_creates_both_functions_when_native_uuidv7_absent(migration): - # PostgreSQL 13 to 17: no native pg_catalog.uuidv7(), so both functions are created. - # The DO block contains the CREATE FUNCTION guarded by IF NOT EXISTS, and - # uuidv7_boundary is created unconditionally. - bind = _make_bind("postgresql") - with mock.patch.object(migration, "op") as fake_op: - fake_op.get_bind.return_value = bind - migration.upgrade() - - sql = _executed_sql(fake_op) - assert any("CREATE FUNCTION public.uuidv7()" in stmt for stmt in sql) - assert any("CREATE FUNCTION public.uuidv7_boundary(timestamptz)" in stmt for stmt in sql) - - -def test_upgrade_skips_uuidv7_but_keeps_boundary_when_native_present(migration): - # PostgreSQL 18: native pg_catalog.uuidv7() exists, so the DO block must guard - # the CREATE FUNCTION with an IF NOT EXISTS check against pg_catalog. - # uuidv7_boundary is still missing and has to be created unconditionally. - bind = _make_bind("postgresql") - with mock.patch.object(migration, "op") as fake_op: - fake_op.get_bind.return_value = bind - migration.upgrade() - - sql = _executed_sql(fake_op) - # The DO block must contain the pg_catalog existence check. - do_block = next((stmt for stmt in sql if "DO $do$" in stmt), None) - assert do_block is not None - assert "pg_catalog" in do_block - assert "uuidv7" in do_block - assert "IF NOT EXISTS" in do_block - # uuidv7_boundary is always created (not guarded by the DO block). - assert any("CREATE FUNCTION public.uuidv7_boundary(timestamptz)" in stmt for stmt in sql) - - -def test_upgrade_is_noop_on_non_postgres(migration): - bind = _make_bind("sqlite") - with mock.patch.object(migration, "op") as fake_op: - fake_op.get_bind.return_value = bind - migration.upgrade() - - fake_op.execute.assert_not_called() - - -def test_downgrade_uses_if_exists_and_public_schema(migration): - bind = _make_bind("postgresql") - with mock.patch.object(migration, "op") as fake_op: - fake_op.get_bind.return_value = bind - migration.downgrade() - - sql = _executed_sql(fake_op) - assert "DROP FUNCTION IF EXISTS public.uuidv7()" in sql - assert "DROP FUNCTION IF EXISTS public.uuidv7_boundary(timestamptz)" in sql - - -def test_downgrade_is_noop_on_non_postgres(migration): - bind = _make_bind("sqlite") - with mock.patch.object(migration, "op") as fake_op: - fake_op.get_bind.return_value = bind - migration.downgrade() - - fake_op.execute.assert_not_called() diff --git a/api/tests/unit_tests/migrations/versions/test_2026_07_21_1200-a4e7c2f91b30_add_knowledge_fs_control_plane.py b/api/tests/unit_tests/migrations/versions/test_2026_07_21_1200-a4e7c2f91b30_add_knowledge_fs_control_plane.py deleted file mode 100644 index dc56d87db24..00000000000 --- a/api/tests/unit_tests/migrations/versions/test_2026_07_21_1200-a4e7c2f91b30_add_knowledge_fs_control_plane.py +++ /dev/null @@ -1,120 +0,0 @@ -from __future__ import annotations - -import importlib.util -from pathlib import Path -from types import ModuleType -from unittest.mock import patch - -import sqlalchemy as sa -from alembic.migration import MigrationContext -from alembic.operations import Operations - -from models.knowledge_fs import ( - AppKnowledgeFSSpaceJoin, - KnowledgeFSApiCredential, - KnowledgeFSAuthorizationRevision, - KnowledgeFSCapabilityIssuanceAudit, - KnowledgeFSControlSpace, - KnowledgeFSControlSpacePermission, - KnowledgeFSExternalAccessPolicy, - KnowledgeFSLifecycleOutbox, -) - -_MIGRATION_PATH = ( - Path(__file__).resolve().parents[4] - / "migrations/versions/2026_07_21_1200-a4e7c2f91b30_add_knowledge_fs_control_plane.py" -) - -_TABLES = { - "knowledge_fs_control_spaces", - "knowledge_fs_control_space_permissions", - "knowledge_fs_external_access_policies", - "knowledge_fs_api_credentials", - "app_knowledge_fs_space_joins", - "knowledge_fs_authorization_revisions", - "knowledge_fs_capability_issuance_audits", - "knowledge_fs_lifecycle_outbox", -} - -_FORBIDDEN_DEPENDENCIES = ("datasets", "documents", "dataset_permissions", "api_tokens") -_LATER_COLUMNS_BY_TABLE = {"knowledge_fs_control_spaces": {"icon_background"}} - -_MODELS_BY_TABLE = { - model.__tablename__: model - for model in ( - KnowledgeFSControlSpace, - KnowledgeFSControlSpacePermission, - KnowledgeFSExternalAccessPolicy, - KnowledgeFSApiCredential, - AppKnowledgeFSSpaceJoin, - KnowledgeFSAuthorizationRevision, - KnowledgeFSCapabilityIssuanceAudit, - KnowledgeFSLifecycleOutbox, - ) -} - - -def _load_migration_module() -> ModuleType: - spec = importlib.util.spec_from_file_location("knowledge_fs_control_plane", _MIGRATION_PATH) - if spec is None or spec.loader is None: - raise RuntimeError("failed to load migration module") - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - return module - - -def _run_migration_step(module: ModuleType, engine: sa.Engine, step_name: str) -> None: - with engine.begin() as connection: - context = MigrationContext.configure(connection) - operations = Operations(context) - with patch.object(module, "op", operations): - {"upgrade": module.upgrade, "downgrade": module.downgrade}[step_name]() - - -def test_upgrade_creates_only_independent_knowledge_fs_control_plane_tables() -> None: - engine = sa.create_engine("sqlite:///:memory:") - module = _load_migration_module() - - _run_migration_step(module, engine, "upgrade") - - inspector = sa.inspect(engine) - assert set(inspector.get_table_names()) >= _TABLES - for table_name in _TABLES: - migrated_columns = {column["name"] for column in inspector.get_columns(table_name)} - assert migrated_columns == ( - set(_MODELS_BY_TABLE[table_name].__table__.columns.keys()) - _LATER_COLUMNS_BY_TABLE.get(table_name, set()) - ) - dependency_text = " ".join( - [ - table_name, - *migrated_columns, - *( - referred_table - for fk in inspector.get_foreign_keys(table_name) - if (referred_table := fk.get("referred_table")) is not None - ), - ] - ).lower() - assert not any(forbidden in dependency_text for forbidden in _FORBIDDEN_DEPENDENCIES) - - workspace_fks = inspector.get_foreign_keys("knowledge_fs_control_spaces") - assert any( - fk.get("referred_table") == "tenants" - and fk.get("referred_columns") == ["id"] - and (fk.get("options") or {}).get("ondelete") == "RESTRICT" - for fk in workspace_fks - ) - control_space_indexes = { - index["name"]: tuple(index["column_names"]) for index in inspector.get_indexes("knowledge_fs_control_spaces") - } - assert control_space_indexes["kfs_control_space_state_updated_idx"] == ("state", "updated_at") - - -def test_migration_downgrade_is_symmetric() -> None: - engine = sa.create_engine("sqlite:///:memory:") - module = _load_migration_module() - - _run_migration_step(module, engine, "upgrade") - _run_migration_step(module, engine, "downgrade") - - assert not (_TABLES & set(sa.inspect(engine).get_table_names())) diff --git a/api/tests/unit_tests/migrations/versions/test_2026_07_21_1300-b7f2a9d41c60_add_knowledge_fs_cutover.py b/api/tests/unit_tests/migrations/versions/test_2026_07_21_1300-b7f2a9d41c60_add_knowledge_fs_cutover.py deleted file mode 100644 index c324d2d2017..00000000000 --- a/api/tests/unit_tests/migrations/versions/test_2026_07_21_1300-b7f2a9d41c60_add_knowledge_fs_cutover.py +++ /dev/null @@ -1,141 +0,0 @@ -from __future__ import annotations - -import importlib.util -from pathlib import Path -from types import ModuleType -from unittest.mock import patch - -import sqlalchemy as sa -from alembic.migration import MigrationContext -from alembic.operations import Operations - -from models.knowledge_fs_cutover import ( - KnowledgeFSMigrationIssue, - KnowledgeFSMigrationQuarantine, - KnowledgeFSShadowAuthorizationDiff, - KnowledgeFSWorkspaceCutoverLedger, -) - -_MIGRATION_PATH = ( - Path(__file__).resolve().parents[4] / "migrations/versions/2026_07_21_1300-b7f2a9d41c60_add_knowledge_fs_cutover.py" -) - -_MODELS = ( - KnowledgeFSWorkspaceCutoverLedger, - KnowledgeFSMigrationIssue, - KnowledgeFSMigrationQuarantine, - KnowledgeFSShadowAuthorizationDiff, -) -_TABLES = {model.__tablename__ for model in _MODELS} -_FORBIDDEN_DEPENDENCIES = ("datasets", "documents", "dataset_permissions", "api_tokens") -_LATER_QUARANTINE_COLUMNS = { - "resolved_by_operator", - "resolved_by_account_id", - "evidence", - "row_version", -} -_LATER_LEDGER_COLUMNS = { - "shadow_started_at", - "shadow_completed_at", - "shadow_evidence_digest", - "shadow_observation_count", - "shadow_window_started_at", - "shadow_window_ended_at", - "shadow_traffic_zero", - "shadow_traffic_zero_evidence", - "shadow_latest_observed_revision", - "shadow_producer", - "shadow_completed_by_operator", - "shadow_completed_by_account_id", - "remote_activation_id", - "remote_activation_revision", - "remote_activation_digest", - "remote_activation_control_space_id", - "remote_activation_activated_at", - "remote_activation_updated_at", - "remote_activation_acknowledged_at", - "remote_activation_applied", - "remote_activation_replayed", - "remote_freeze_id", - "remote_freeze_revision", - "remote_freeze_digest", - "remote_freeze_task_watermark", - "remote_freeze_control_space_id", - "remote_freeze_frozen_at", - "remote_freeze_updated_at", - "remote_freeze_acknowledged_at", - "remote_freeze_applied", - "remote_freeze_replayed", -} -_LATER_SHADOW_DIFF_COLUMNS = { - "current_evidence_digest", - "last_observed_at", - "row_version", -} - - -def _load_migration_module() -> ModuleType: - spec = importlib.util.spec_from_file_location("knowledge_fs_cutover", _MIGRATION_PATH) - if spec is None or spec.loader is None: - raise RuntimeError("failed to load migration module") - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - return module - - -def _run_migration_step(module: ModuleType, engine: sa.Engine, step_name: str) -> None: - with engine.begin() as connection: - context = MigrationContext.configure(connection) - operations = Operations(context) - with patch.object(module, "op", operations): - {"upgrade": module.upgrade, "downgrade": module.downgrade}[step_name]() - - -def test_upgrade_matches_models_and_has_only_cutover_ledger_dependencies() -> None: - engine = sa.create_engine("sqlite:///:memory:") - module = _load_migration_module() - - _run_migration_step(module, engine, "upgrade") - - inspector = sa.inspect(engine) - assert set(inspector.get_table_names()) == _TABLES - for model in _MODELS: - table_name = model.__tablename__ - migrated_columns = {column["name"] for column in inspector.get_columns(table_name)} - expected_columns = set(model.__table__.columns.keys()) - if model is KnowledgeFSMigrationQuarantine: - expected_columns -= _LATER_QUARANTINE_COLUMNS - elif model is KnowledgeFSWorkspaceCutoverLedger: - expected_columns -= _LATER_LEDGER_COLUMNS - elif model is KnowledgeFSShadowAuthorizationDiff: - expected_columns -= _LATER_SHADOW_DIFF_COLUMNS - assert migrated_columns == expected_columns - dependency_text = " ".join( - [ - table_name, - *migrated_columns, - *( - referred_table - for fk in inspector.get_foreign_keys(table_name) - if (referred_table := fk.get("referred_table")) is not None - ), - ] - ).lower() - assert not any(forbidden in dependency_text for forbidden in _FORBIDDEN_DEPENDENCIES) - assert { - fk["referred_table"] - for fk in inspector.get_foreign_keys(table_name) - if fk.get("referred_table") is not None - } <= {KnowledgeFSWorkspaceCutoverLedger.__tablename__} - - assert module.down_revision == "a4e7c2f91b30" - - -def test_cutover_migration_downgrade_is_symmetric() -> None: - engine = sa.create_engine("sqlite:///:memory:") - module = _load_migration_module() - - _run_migration_step(module, engine, "upgrade") - _run_migration_step(module, engine, "downgrade") - - assert not (_TABLES & set(sa.inspect(engine).get_table_names())) diff --git a/api/tests/unit_tests/migrations/versions/test_2026_07_21_1400-c8e31b7d52a4_add_knowledge_fs_cleanup_authorization.py b/api/tests/unit_tests/migrations/versions/test_2026_07_21_1400-c8e31b7d52a4_add_knowledge_fs_cleanup_authorization.py deleted file mode 100644 index 69016d547e5..00000000000 --- a/api/tests/unit_tests/migrations/versions/test_2026_07_21_1400-c8e31b7d52a4_add_knowledge_fs_cleanup_authorization.py +++ /dev/null @@ -1,157 +0,0 @@ -from __future__ import annotations - -import importlib.util -from pathlib import Path -from types import ModuleType -from unittest.mock import patch - -import sqlalchemy as sa -from alembic.migration import MigrationContext -from alembic.operations import Operations - -from models.knowledge_fs import KnowledgeFSCapabilityIssuanceReservation, KnowledgeFSControlSpace -from models.knowledge_fs_cleanup import KnowledgeFSCleanupAuthorization -from models.knowledge_fs_cutover import ( - KnowledgeFSMigrationQuarantine, - KnowledgeFSShadowAuthorizationDiff, - KnowledgeFSShadowAuthorizationObservation, - KnowledgeFSWorkspaceCutoverLedger, -) - -_MIGRATION_PATH = ( - Path(__file__).resolve().parents[4] - / "migrations/versions/2026_07_21_1400-c8e31b7d52a4_add_knowledge_fs_cleanup_authorization.py" -) -_CUTOVER_MIGRATION_PATH = ( - Path(__file__).resolve().parents[4] / "migrations/versions/2026_07_21_1300-b7f2a9d41c60_add_knowledge_fs_cutover.py" -) -_LATER_LEDGER_COLUMNS = { - "remote_freeze_id", - "remote_freeze_revision", - "remote_freeze_digest", - "remote_freeze_task_watermark", - "remote_freeze_control_space_id", - "remote_freeze_frozen_at", - "remote_freeze_updated_at", - "remote_freeze_acknowledged_at", - "remote_freeze_applied", - "remote_freeze_replayed", -} -_LATER_CLEANUP_COLUMNS = { - "completed_by_account_id", - "completed_at", - "completion_evidence", - "completed_ledger_cas_version", -} - - -def _load_migration_module(path: Path = _MIGRATION_PATH) -> ModuleType: - spec = importlib.util.spec_from_file_location(path.stem, path) - if spec is None or spec.loader is None: - raise RuntimeError("failed to load migration module") - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - return module - - -def _run_migration_step(module: ModuleType, engine: sa.Engine, step_name: str) -> None: - with engine.begin() as connection: - context = MigrationContext.configure(connection) - operations = Operations(context) - with patch.object(module, "op", operations): - {"upgrade": module.upgrade, "downgrade": module.downgrade}[step_name]() - - -def test_upgrade_matches_cleanup_authorization_model_and_only_references_cutover_ledger() -> None: - engine = sa.create_engine("sqlite:///:memory:") - cutover_module = _load_migration_module(_CUTOVER_MIGRATION_PATH) - module = _load_migration_module() - - _run_migration_step(cutover_module, engine, "upgrade") - _run_migration_step(module, engine, "upgrade") - - inspector = sa.inspect(engine) - table_name = KnowledgeFSCleanupAuthorization.__tablename__ - assert table_name in inspector.get_table_names() - assert {column["name"] for column in inspector.get_columns(table_name)} == ( - set(KnowledgeFSCleanupAuthorization.__table__.columns.keys()) - _LATER_CLEANUP_COLUMNS - ) - assert {foreign_key["referred_table"] for foreign_key in inspector.get_foreign_keys(table_name)} == { - KnowledgeFSWorkspaceCutoverLedger.__tablename__ - } - assert {foreign_key["options"].get("ondelete") for foreign_key in inspector.get_foreign_keys(table_name)} == {None} - reservation_table = KnowledgeFSCapabilityIssuanceReservation.__tablename__ - assert reservation_table in inspector.get_table_names() - assert {column["name"] for column in inspector.get_columns(reservation_table)} == set( - KnowledgeFSCapabilityIssuanceReservation.__table__.columns.keys() - ) - assert {foreign_key["referred_table"] for foreign_key in inspector.get_foreign_keys(reservation_table)} == { - KnowledgeFSControlSpace.__tablename__ - } - quarantine_table = KnowledgeFSMigrationQuarantine.__tablename__ - assert {column["name"] for column in inspector.get_columns(quarantine_table)} == set( - KnowledgeFSMigrationQuarantine.__table__.columns.keys() - ) - assert { - "kfs_migration_quarantine_resolution_fields_ck", - "kfs_migration_quarantine_version_ck", - } <= {constraint["name"] for constraint in inspector.get_check_constraints(quarantine_table)} - ledger_table = KnowledgeFSWorkspaceCutoverLedger.__tablename__ - assert {column["name"] for column in inspector.get_columns(ledger_table)} == ( - set(KnowledgeFSWorkspaceCutoverLedger.__table__.columns.keys()) - _LATER_LEDGER_COLUMNS - ) - assert { - "kfs_workspace_cutover_remote_activation_fields_ck", - "kfs_workspace_cutover_remote_activation_time_ck", - "kfs_workspace_cutover_shadow_completion_fields_ck", - "kfs_workspace_cutover_shadow_count_ck", - "kfs_workspace_cutover_shadow_window_ck", - } <= {constraint["name"] for constraint in inspector.get_check_constraints(ledger_table)} - diff_table = KnowledgeFSShadowAuthorizationDiff.__tablename__ - assert {column["name"] for column in inspector.get_columns(diff_table)} == set( - KnowledgeFSShadowAuthorizationDiff.__table__.columns.keys() - ) - observation_table = KnowledgeFSShadowAuthorizationObservation.__tablename__ - assert {column["name"] for column in inspector.get_columns(observation_table)} == set( - KnowledgeFSShadowAuthorizationObservation.__table__.columns.keys() - ) - assert {foreign_key["referred_table"] for foreign_key in inspector.get_foreign_keys(observation_table)} == { - KnowledgeFSWorkspaceCutoverLedger.__tablename__ - } - assert module.down_revision == "b7f2a9d41c60" - - -def test_cleanup_authorization_migration_downgrade_is_symmetric() -> None: - engine = sa.create_engine("sqlite:///:memory:") - cutover_module = _load_migration_module(_CUTOVER_MIGRATION_PATH) - module = _load_migration_module() - - _run_migration_step(cutover_module, engine, "upgrade") - _run_migration_step(module, engine, "upgrade") - _run_migration_step(module, engine, "downgrade") - - inspector = sa.inspect(engine) - assert KnowledgeFSCleanupAuthorization.__tablename__ not in inspector.get_table_names() - assert KnowledgeFSCapabilityIssuanceReservation.__tablename__ not in inspector.get_table_names() - assert KnowledgeFSShadowAuthorizationObservation.__tablename__ not in inspector.get_table_names() - quarantine_columns = { - column["name"] for column in inspector.get_columns(KnowledgeFSMigrationQuarantine.__tablename__) - } - assert ( - not { - "resolved_by_operator", - "resolved_by_account_id", - "evidence", - "row_version", - } - & quarantine_columns - ) - ledger_columns = { - column["name"] for column in inspector.get_columns(KnowledgeFSWorkspaceCutoverLedger.__tablename__) - } - assert not {column for column in ledger_columns if column.startswith("shadow_")} - assert not {column for column in ledger_columns if column.startswith("remote_activation_")} - diff_columns = { - column["name"] for column in inspector.get_columns(KnowledgeFSShadowAuthorizationDiff.__tablename__) - } - assert not {"current_evidence_digest", "last_observed_at", "row_version"} & diff_columns diff --git a/api/tests/unit_tests/migrations/versions/test_2026_07_21_1500-d4f6e8a1c305_add_knowledge_fs_remote_freeze_evidence.py b/api/tests/unit_tests/migrations/versions/test_2026_07_21_1500-d4f6e8a1c305_add_knowledge_fs_remote_freeze_evidence.py deleted file mode 100644 index b866cedd4b8..00000000000 --- a/api/tests/unit_tests/migrations/versions/test_2026_07_21_1500-d4f6e8a1c305_add_knowledge_fs_remote_freeze_evidence.py +++ /dev/null @@ -1,66 +0,0 @@ -from __future__ import annotations - -import importlib.util -from pathlib import Path -from types import ModuleType -from unittest.mock import patch - -import sqlalchemy as sa -from alembic.migration import MigrationContext -from alembic.operations import Operations - -from models.knowledge_fs_cutover import KnowledgeFSWorkspaceCutoverLedger - -_VERSIONS = Path(__file__).resolve().parents[4] / "migrations/versions" -_MIGRATION_PATHS = ( - _VERSIONS / "2026_07_21_1300-b7f2a9d41c60_add_knowledge_fs_cutover.py", - _VERSIONS / "2026_07_21_1400-c8e31b7d52a4_add_knowledge_fs_cleanup_authorization.py", - _VERSIONS / "2026_07_21_1500-d4f6e8a1c305_add_knowledge_fs_remote_freeze_evidence.py", -) - - -def _load(path: Path) -> ModuleType: - spec = importlib.util.spec_from_file_location(path.stem, path) - if spec is None or spec.loader is None: - raise RuntimeError("failed to load migration module") - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - return module - - -def _run(module: ModuleType, engine: sa.Engine, step: str) -> None: - with engine.begin() as connection: - operations = Operations(MigrationContext.configure(connection)) - with patch.object(module, "op", operations): - {"upgrade": module.upgrade, "downgrade": module.downgrade}[step]() - - -def test_remote_freeze_migration_matches_ledger_and_is_symmetric() -> None: - engine = sa.create_engine("sqlite:///:memory:") - cutover, cleanup, freeze = (_load(path) for path in _MIGRATION_PATHS) - _run(cutover, engine, "upgrade") - _run(cleanup, engine, "upgrade") - columns_before = { - column["name"] for column in sa.inspect(engine).get_columns(KnowledgeFSWorkspaceCutoverLedger.__tablename__) - } - - _run(freeze, engine, "upgrade") - - inspector = sa.inspect(engine) - assert {column["name"] for column in inspector.get_columns(KnowledgeFSWorkspaceCutoverLedger.__tablename__)} == set( - KnowledgeFSWorkspaceCutoverLedger.__table__.columns.keys() - ) - assert { - "kfs_workspace_cutover_remote_freeze_fields_ck", - "kfs_workspace_cutover_remote_freeze_time_ck", - } <= { - constraint["name"] - for constraint in inspector.get_check_constraints(KnowledgeFSWorkspaceCutoverLedger.__tablename__) - } - assert freeze.down_revision == "c8e31b7d52a4" - - _run(freeze, engine, "downgrade") - - assert { - column["name"] for column in sa.inspect(engine).get_columns(KnowledgeFSWorkspaceCutoverLedger.__tablename__) - } == columns_before diff --git a/api/tests/unit_tests/migrations/versions/test_2026_07_21_1600-e5a7c9b2d416_add_knowledge_fs_cleanup_completion.py b/api/tests/unit_tests/migrations/versions/test_2026_07_21_1600-e5a7c9b2d416_add_knowledge_fs_cleanup_completion.py deleted file mode 100644 index e26eac56463..00000000000 --- a/api/tests/unit_tests/migrations/versions/test_2026_07_21_1600-e5a7c9b2d416_add_knowledge_fs_cleanup_completion.py +++ /dev/null @@ -1,104 +0,0 @@ -from __future__ import annotations - -import importlib.util -from pathlib import Path -from types import ModuleType -from unittest.mock import patch - -import sqlalchemy as sa -from alembic.migration import MigrationContext -from alembic.operations import Operations - -from models.knowledge_fs_cleanup import KnowledgeFSCleanupAuthorization - -_VERSIONS = Path(__file__).resolve().parents[4] / "migrations/versions" -_MIGRATION_PATHS = ( - _VERSIONS / "2026_07_21_1300-b7f2a9d41c60_add_knowledge_fs_cutover.py", - _VERSIONS / "2026_07_21_1400-c8e31b7d52a4_add_knowledge_fs_cleanup_authorization.py", - _VERSIONS / "2026_07_21_1500-d4f6e8a1c305_add_knowledge_fs_remote_freeze_evidence.py", - _VERSIONS / "2026_07_21_1600-e5a7c9b2d416_add_knowledge_fs_cleanup_completion.py", -) -_COMPLETION_COLUMNS = { - "completed_by_account_id", - "completed_at", - "completion_evidence", - "completed_ledger_cas_version", -} - - -def _load(path: Path) -> ModuleType: - spec = importlib.util.spec_from_file_location(path.stem, path) - if spec is None or spec.loader is None: - raise RuntimeError("failed to load migration module") - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - return module - - -def _run(module: ModuleType, engine: sa.Engine, step: str) -> None: - with engine.begin() as connection: - operations = Operations(MigrationContext.configure(connection)) - with patch.object(module, "op", operations): - {"upgrade": module.upgrade, "downgrade": module.downgrade}[step]() - - -def test_cleanup_completion_migration_matches_model_and_is_symmetric() -> None: - engine = sa.create_engine("sqlite:///:memory:") - cutover, cleanup, freeze, completion = (_load(path) for path in _MIGRATION_PATHS) - _run(cutover, engine, "upgrade") - _run(cleanup, engine, "upgrade") - _run(freeze, engine, "upgrade") - table_name = KnowledgeFSCleanupAuthorization.__tablename__ - columns_before = {column["name"] for column in sa.inspect(engine).get_columns(table_name)} - - _run(completion, engine, "upgrade") - - inspector = sa.inspect(engine) - assert {column["name"] for column in inspector.get_columns(table_name)} == set( - KnowledgeFSCleanupAuthorization.__table__.columns.keys() - ) - assert { - "kfs_cleanup_authorization_completion_time_ck", - "kfs_cleanup_authorization_ledger_versions_ck", - "kfs_cleanup_authorization_status_fields_ck", - } <= {constraint["name"] for constraint in inspector.get_check_constraints(table_name)} - assert completion.down_revision == "d4f6e8a1c305" - - _run(completion, engine, "downgrade") - - assert {column["name"] for column in sa.inspect(engine).get_columns(table_name)} == columns_before - assert not (_COMPLETION_COLUMNS & columns_before) - - -def test_cleanup_completion_downgrade_keeps_completed_rows_as_started() -> None: - engine = sa.create_engine("sqlite:///:memory:") - modules = [_load(path) for path in _MIGRATION_PATHS] - for module in modules: - _run(module, engine, "upgrade") - with engine.begin() as connection: - connection.execute( - sa.text( - "INSERT INTO knowledge_fs_cleanup_authorizations (" - "id, tenant_id, ledger_id, request_id, plan_schema_version, plan_digest, targets," - " readiness_evidence, requested_by_account_id, requested_at, readiness_ledger_cas_version," - " status, approved_by_account_id, approved_at, approval_expires_at, approved_ledger_cas_version," - " started_by_account_id, started_at, started_ledger_cas_version, completed_by_account_id," - " completed_at, completion_evidence, completed_ledger_cas_version" - ") VALUES (" - "'auth-1', 'tenant-1', 'ledger-1', 'request-1', 'v1', 'sha256:plan', '[]', '{}', 'account-1'," - " '2026-07-21 10:00:00', 1, 'completed', 'account-2', '2026-07-21 11:00:00'," - " '2026-07-22 11:00:00', 2, 'account-1', '2026-07-21 12:00:00', 3, 'account-1'," - " '2026-07-21 13:00:00', '{\"deleted\": 1}', 4)" - ) - ) - - _run(modules[-1], engine, "downgrade") - - with engine.connect() as connection: - row = connection.execute( - sa.text( - "SELECT status, started_by_account_id, started_ledger_cas_version" - " FROM knowledge_fs_cleanup_authorizations WHERE id = 'auth-1'" - ) - ).one() - assert tuple(row) == ("started", "account-1", 3) diff --git a/api/tests/unit_tests/migrations/versions/test_2026_07_23_0203-f6e4c5686857_replace_agent_runtime_sessions_with_.py b/api/tests/unit_tests/migrations/versions/test_2026_07_23_0203-f6e4c5686857_replace_agent_runtime_sessions_with_.py deleted file mode 100644 index a62d3b31aed..00000000000 --- a/api/tests/unit_tests/migrations/versions/test_2026_07_23_0203-f6e4c5686857_replace_agent_runtime_sessions_with_.py +++ /dev/null @@ -1,144 +0,0 @@ -from __future__ import annotations - -import importlib.util -from collections.abc import Callable -from pathlib import Path -from typing import Protocol, cast - -import sqlalchemy as sa -from alembic.migration import MigrationContext -from alembic.operations import Operations - -_MIGRATION_PATH = ( - Path(__file__).resolve().parents[4] - / "migrations/versions/2026_07_23_0203-f6e4c5686857_replace_agent_runtime_sessions_with_.py" -) - - -class _MigrationModule(Protocol): - op: Operations - upgrade: Callable[[], None] - - -def _load_migration_module() -> _MigrationModule: - spec = importlib.util.spec_from_file_location("agent_workspace_migration", _MIGRATION_PATH) - if spec is None or spec.loader is None: - raise RuntimeError("failed to load Agent Workspace migration") - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - return cast(_MigrationModule, module) - - -def _create_pre_upgrade_schema(engine: sa.Engine) -> None: - metadata = sa.MetaData() - runtime_sessions = sa.Table( - "agent_runtime_sessions", - metadata, - sa.Column("id", sa.String(36), primary_key=True), - sa.Column("tenant_id", sa.String(36), nullable=False), - sa.Column("conversation_id", sa.String(36)), - sa.Column("workflow_run_id", sa.String(36)), - sa.Column("node_id", sa.String(255)), - sa.Column("binding_id", sa.String(36)), - sa.Column("agent_id", sa.String(36), nullable=False), - sa.Column("agent_config_snapshot_id", sa.String(36)), - sa.Column("home_snapshot_id", sa.String(36), nullable=True), - sa.Column("backend_run_id", sa.String(255)), - sa.Column("status", sa.String(32), nullable=False), - ) - sa.Index("agent_runtime_session_backend_run_idx", runtime_sessions.c.backend_run_id) - sa.Index( - "agent_runtime_session_conversation_lookup_idx", - runtime_sessions.c.tenant_id, - runtime_sessions.c.conversation_id, - runtime_sessions.c.status, - ) - sa.Index( - "agent_runtime_session_conversation_scope_unique", - runtime_sessions.c.tenant_id, - runtime_sessions.c.conversation_id, - runtime_sessions.c.agent_id, - runtime_sessions.c.agent_config_snapshot_id, - runtime_sessions.c.home_snapshot_id, - unique=True, - ) - sa.Index( - "agent_runtime_session_workflow_lookup_idx", - runtime_sessions.c.tenant_id, - runtime_sessions.c.workflow_run_id, - runtime_sessions.c.node_id, - runtime_sessions.c.status, - ) - sa.Index( - "agent_runtime_session_workflow_scope_unique", - runtime_sessions.c.tenant_id, - runtime_sessions.c.workflow_run_id, - runtime_sessions.c.node_id, - runtime_sessions.c.binding_id, - runtime_sessions.c.agent_id, - unique=True, - ) - sa.Table( - "agent_home_snapshots", - metadata, - sa.Column("id", sa.String(36), primary_key=True), - sa.Column("tenant_id", sa.String(36), nullable=False), - sa.Column("agent_id", sa.String(36), nullable=False), - sa.Column("snapshot_ref", sa.String(255), nullable=False), - ) - for table_name in ("conversations", "agent_config_drafts", "workflow_node_executions"): - sa.Table(table_name, metadata, sa.Column("id", sa.String(36), primary_key=True)) - metadata.create_all(engine) - - -def _run_upgrade(module: _MigrationModule, engine: sa.Engine) -> None: - with engine.begin() as connection: - operations = Operations(MigrationContext.configure(connection)) - original_op = module.op - module.op = operations - try: - module.upgrade() - finally: - module.op = original_op - - -def test_upgrade_replaces_runtime_sessions_with_workspace_schema() -> None: - engine = sa.create_engine("sqlite:///:memory:") - _create_pre_upgrade_schema(engine) - - _run_upgrade(_load_migration_module(), engine) - - inspector = sa.inspect(engine) - assert "agent_runtime_sessions" not in inspector.get_table_names() - binding_column_definitions = { - column["name"]: column for column in inspector.get_columns("agent_workspace_bindings") - } - binding_columns = set(binding_column_definitions) - assert { - "workspace_id", - "agent_id", - "base_home_snapshot_id", - "agent_config_version_id", - "agent_config_version_kind", - "backend_binding_ref", - "session_snapshot", - "status", - "retired_at", - "pending_form_id", - "pending_tool_call_id", - }.issubset(binding_columns) - assert "active_guard" not in binding_columns - assert binding_column_definitions["base_home_snapshot_id"]["nullable"] is True - binding_indexes = {index["name"] for index in inspector.get_indexes("agent_workspace_bindings")} - assert "agent_workspace_binding_agent_active_unique" not in binding_indexes - workspace_columns = {column["name"] for column in inspector.get_columns("agent_workspaces")} - assert {"owner_type", "owner_id", "owner_scope_key", "backend_workspace_ref", "status"}.issubset(workspace_columns) - workspace_indexes = {index["name"] for index in inspector.get_indexes("agent_workspaces")} - assert "agent_workspace_owner_active_unique" in workspace_indexes - home_columns = {column["name"] for column in inspector.get_columns("agent_home_snapshots")} - assert {"status", "retired_at"}.issubset(home_columns) - home_indexes = {index["name"] for index in inspector.get_indexes("agent_home_snapshots")} - assert "agent_home_snapshot_status_retired_idx" in home_indexes - for table_name in ("conversations", "agent_config_drafts", "workflow_node_executions"): - caller_columns = {column["name"] for column in inspector.get_columns(table_name)} - assert "agent_workspace_binding_id" in caller_columns diff --git a/api/tests/unit_tests/migrations/versions/test_2026_07_28_2331-e4708db55c1d_make_home_snapshot_references_nullable.py b/api/tests/unit_tests/migrations/versions/test_2026_07_28_2331-e4708db55c1d_make_home_snapshot_references_nullable.py deleted file mode 100644 index 971b3a771d1..00000000000 --- a/api/tests/unit_tests/migrations/versions/test_2026_07_28_2331-e4708db55c1d_make_home_snapshot_references_nullable.py +++ /dev/null @@ -1,181 +0,0 @@ -from __future__ import annotations - -import importlib.util -from collections.abc import Callable -from pathlib import Path -from typing import Protocol, cast - -import sqlalchemy as sa -from alembic.migration import MigrationContext -from alembic.operations import Operations - -_VERSIONS_DIR = Path(__file__).resolve().parents[4] / "migrations/versions" -_MIGRATION_PATHS = ( - _VERSIONS_DIR / "2026_07_21_2251-2f39536b3feb_add_agent_home_snapshot_ledger.py", - _VERSIONS_DIR / "2026_07_23_0203-f6e4c5686857_replace_agent_runtime_sessions_with_.py", - _VERSIONS_DIR / "2026_07_28_2331-e4708db55c1d_make_home_snapshot_references_nullable.py", -) - - -class _MigrationModule(Protocol): - op: Operations - upgrade: Callable[[], None] - - -def _load_migration(path: Path) -> _MigrationModule: - spec = importlib.util.spec_from_file_location(path.stem, path) - if spec is None or spec.loader is None: - raise RuntimeError(f"failed to load migration {path.name}") - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - return cast(_MigrationModule, module) - - -def _run_upgrade(module: _MigrationModule, engine: sa.Engine) -> None: - with engine.begin() as connection: - original_op = module.op - module.op = Operations(MigrationContext.configure(connection)) - try: - module.upgrade() - finally: - module.op = original_op - - -def _create_pre_home_snapshot_schema(engine: sa.Engine) -> None: - metadata = sa.MetaData() - drafts = sa.Table("agent_config_drafts", metadata, sa.Column("id", sa.String(36), primary_key=True)) - snapshots = sa.Table("agent_config_snapshots", metadata, sa.Column("id", sa.String(36), primary_key=True)) - conversations = sa.Table("conversations", metadata, sa.Column("id", sa.String(36), primary_key=True)) - executions = sa.Table("workflow_node_executions", metadata, sa.Column("id", sa.String(36), primary_key=True)) - runtime_sessions = sa.Table( - "agent_runtime_sessions", - metadata, - sa.Column("id", sa.String(36), primary_key=True), - sa.Column("tenant_id", sa.String(36), nullable=False), - sa.Column("conversation_id", sa.String(36)), - sa.Column("workflow_run_id", sa.String(36)), - sa.Column("node_id", sa.String(255)), - sa.Column("binding_id", sa.String(36)), - sa.Column("agent_id", sa.String(36), nullable=False), - sa.Column("agent_config_snapshot_id", sa.String(36)), - sa.Column("backend_run_id", sa.String(255)), - sa.Column("status", sa.String(32), nullable=False), - ) - sa.Index("agent_runtime_session_backend_run_idx", runtime_sessions.c.backend_run_id) - sa.Index( - "agent_runtime_session_conversation_lookup_idx", - runtime_sessions.c.tenant_id, - runtime_sessions.c.conversation_id, - runtime_sessions.c.status, - ) - sa.Index( - "agent_runtime_session_conversation_scope_unique", - runtime_sessions.c.tenant_id, - runtime_sessions.c.conversation_id, - runtime_sessions.c.agent_id, - runtime_sessions.c.agent_config_snapshot_id, - unique=True, - ) - sa.Index( - "agent_runtime_session_workflow_lookup_idx", - runtime_sessions.c.tenant_id, - runtime_sessions.c.workflow_run_id, - runtime_sessions.c.node_id, - runtime_sessions.c.status, - ) - sa.Index( - "agent_runtime_session_workflow_scope_unique", - runtime_sessions.c.tenant_id, - runtime_sessions.c.workflow_run_id, - runtime_sessions.c.node_id, - runtime_sessions.c.binding_id, - runtime_sessions.c.agent_id, - unique=True, - ) - metadata.create_all(engine) - with engine.begin() as connection: - connection.execute(drafts.insert().values(id="draft-1")) - connection.execute(snapshots.insert().values(id="snapshot-1")) - connection.execute(conversations.insert().values(id="conversation-1")) - connection.execute(executions.insert().values(id="execution-1")) - connection.execute( - runtime_sessions.insert().values( - id="runtime-1", - tenant_id="tenant-1", - conversation_id="conversation-1", - agent_id="agent-1", - agent_config_snapshot_id="snapshot-1", - status="active", - ) - ) - - -def _create_old_f6_schema(engine: sa.Engine) -> None: - metadata = sa.MetaData() - drafts = sa.Table( - "agent_config_drafts", - metadata, - sa.Column("id", sa.String(36), primary_key=True), - sa.Column("home_snapshot_id", sa.String(36), nullable=False), - ) - snapshots = sa.Table( - "agent_config_snapshots", - metadata, - sa.Column("id", sa.String(36), primary_key=True), - sa.Column("home_snapshot_id", sa.String(36), nullable=False), - ) - bindings = sa.Table( - "agent_workspace_bindings", - metadata, - sa.Column("id", sa.String(36), primary_key=True), - sa.Column("base_home_snapshot_id", sa.String(36), nullable=False), - ) - metadata.create_all(engine) - with engine.begin() as connection: - connection.execute(drafts.insert().values(id="draft-1", home_snapshot_id="home-draft")) - connection.execute(snapshots.insert().values(id="snapshot-1", home_snapshot_id="home-snapshot")) - connection.execute(bindings.insert().values(id="binding-1", base_home_snapshot_id="home-binding")) - - -def test_historical_rows_upgrade_through_nullable_home_snapshot_chain() -> None: - engine = sa.create_engine("sqlite:///:memory:") - _create_pre_home_snapshot_schema(engine) - - for path in _MIGRATION_PATHS: - _run_upgrade(_load_migration(path), engine) - - inspector = sa.inspect(engine) - for table_name, column_name in ( - ("agent_config_drafts", "home_snapshot_id"), - ("agent_config_snapshots", "home_snapshot_id"), - ("agent_workspace_bindings", "base_home_snapshot_id"), - ): - columns = {column["name"]: column for column in inspector.get_columns(table_name)} - assert columns[column_name]["nullable"] is True - - with engine.connect() as connection: - assert connection.scalar(sa.text("SELECT home_snapshot_id FROM agent_config_drafts")) is None - assert connection.scalar(sa.text("SELECT home_snapshot_id FROM agent_config_snapshots")) is None - - -def test_old_f6_schema_converges_to_nullable_without_changing_data() -> None: - engine = sa.create_engine("sqlite:///:memory:") - _create_old_f6_schema(engine) - - _run_upgrade(_load_migration(_MIGRATION_PATHS[-1]), engine) - - inspector = sa.inspect(engine) - for table_name, column_name in ( - ("agent_config_drafts", "home_snapshot_id"), - ("agent_config_snapshots", "home_snapshot_id"), - ("agent_workspace_bindings", "base_home_snapshot_id"), - ): - columns = {column["name"]: column for column in inspector.get_columns(table_name)} - assert columns[column_name]["nullable"] is True - - with engine.connect() as connection: - assert connection.scalar(sa.text("SELECT home_snapshot_id FROM agent_config_drafts")) == "home-draft" - assert connection.scalar(sa.text("SELECT home_snapshot_id FROM agent_config_snapshots")) == "home-snapshot" - assert ( - connection.scalar(sa.text("SELECT base_home_snapshot_id FROM agent_workspace_bindings")) == "home-binding" - ) diff --git a/api/tests/unit_tests/migrations/versions/test_2026_08_10_1200-7c1e9a4b2d60_add_knowledge_fs_staged_uploads.py b/api/tests/unit_tests/migrations/versions/test_2026_08_10_1200-7c1e9a4b2d60_add_knowledge_fs_staged_uploads.py deleted file mode 100644 index 745e3d65e68..00000000000 --- a/api/tests/unit_tests/migrations/versions/test_2026_08_10_1200-7c1e9a4b2d60_add_knowledge_fs_staged_uploads.py +++ /dev/null @@ -1,71 +0,0 @@ -from __future__ import annotations - -import importlib.util -from pathlib import Path -from types import ModuleType -from unittest.mock import patch - -import sqlalchemy as sa -from alembic.migration import MigrationContext -from alembic.operations import Operations - -from models.knowledge_fs import KnowledgeFSStagedUpload - -_MIGRATION_PATH = ( - Path(__file__).resolve().parents[4] - / "migrations/versions/2026_08_10_1200-7c1e9a4b2d60_add_knowledge_fs_staged_uploads.py" -) - - -def _load_migration() -> ModuleType: - spec = importlib.util.spec_from_file_location(_MIGRATION_PATH.stem, _MIGRATION_PATH) - if spec is None or spec.loader is None: - raise RuntimeError("failed to load KnowledgeFS staged-upload migration") - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - return module - - -def _run(module: ModuleType, engine: sa.Engine, step: str) -> None: - with engine.begin() as connection: - operations = Operations(MigrationContext.configure(connection)) - with patch.object(module, "op", operations): - {"upgrade": module.upgrade, "downgrade": module.downgrade}[step]() - - -def test_staged_upload_migration_matches_model() -> None: - engine = sa.create_engine("sqlite:///:memory:") - with engine.begin() as connection: - connection.exec_driver_sql("CREATE TABLE tenants (id VARCHAR(36) PRIMARY KEY)") - connection.exec_driver_sql("CREATE TABLE upload_files (id VARCHAR(36) PRIMARY KEY)") - connection.exec_driver_sql( - "CREATE TABLE knowledge_fs_control_spaces (" - "tenant_id VARCHAR(36) NOT NULL, id VARCHAR(36) NOT NULL, " - "PRIMARY KEY (id), UNIQUE (tenant_id, id))" - ) - migration = _load_migration() - - _run(migration, engine, "upgrade") - - table_name = KnowledgeFSStagedUpload.__tablename__ - inspector = sa.inspect(engine) - assert {column["name"] for column in inspector.get_columns(table_name)} == set( - KnowledgeFSStagedUpload.__table__.columns.keys() - ) - assert {index["name"] for index in inspector.get_indexes(table_name)} == { - "kfs_staged_upload_expiry_idx", - "kfs_staged_upload_owner_status_expiry_idx", - } - assert {constraint["name"] for constraint in inspector.get_check_constraints(table_name)} == { - "kfs_staged_upload_claimed_fields_ck", - "kfs_staged_upload_session_scope_ck", - "kfs_staged_upload_size_ck", - "kfs_staged_upload_status_ck", - "kfs_staged_upload_version_ck", - } - assert migration.revision == "7c1e9a4b2d60" - assert migration.down_revision == "e5a7c9b2d416" - - _run(migration, engine, "downgrade") - - assert table_name not in sa.inspect(engine).get_table_names() diff --git a/api/tests/unit_tests/migrations/versions/test_2026_08_13_1200-9d4e6f8a1b2c_add_knowledge_fs_space_tag_bindings.py b/api/tests/unit_tests/migrations/versions/test_2026_08_13_1200-9d4e6f8a1b2c_add_knowledge_fs_space_tag_bindings.py deleted file mode 100644 index f2b5c4ae582..00000000000 --- a/api/tests/unit_tests/migrations/versions/test_2026_08_13_1200-9d4e6f8a1b2c_add_knowledge_fs_space_tag_bindings.py +++ /dev/null @@ -1,60 +0,0 @@ -from __future__ import annotations - -import importlib.util -from pathlib import Path -from types import ModuleType -from unittest.mock import patch - -import sqlalchemy as sa -from alembic.migration import MigrationContext -from alembic.operations import Operations - -from models.knowledge_fs import KnowledgeFSSpaceTagBinding - -_MIGRATION_PATH = ( - Path(__file__).resolve().parents[4] - / "migrations/versions/2026_08_13_1200-9d4e6f8a1b2c_add_knowledge_fs_space_tag_bindings.py" -) - - -def _load_migration() -> ModuleType: - spec = importlib.util.spec_from_file_location(_MIGRATION_PATH.stem, _MIGRATION_PATH) - if spec is None or spec.loader is None: - raise RuntimeError("failed to load KnowledgeFS space-tag migration") - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - return module - - -def _run(module: ModuleType, engine: sa.Engine, step: str) -> None: - with engine.begin() as connection: - operations = Operations(MigrationContext.configure(connection)) - with patch.object(module, "op", operations): - {"upgrade": module.upgrade, "downgrade": module.downgrade}[step]() - - -def test_space_tag_binding_migration_matches_model() -> None: - engine = sa.create_engine("sqlite:///:memory:") - with engine.begin() as connection: - connection.exec_driver_sql("CREATE TABLE tags (id VARCHAR(36) PRIMARY KEY)") - connection.exec_driver_sql( - "CREATE TABLE knowledge_fs_control_spaces (" - "tenant_id VARCHAR(36) NOT NULL, id VARCHAR(36) NOT NULL, " - "PRIMARY KEY (id), UNIQUE (tenant_id, id))" - ) - migration = _load_migration() - - _run(migration, engine, "upgrade") - - table_name = KnowledgeFSSpaceTagBinding.__tablename__ - inspector = sa.inspect(engine) - assert {column["name"] for column in inspector.get_columns(table_name)} == set( - KnowledgeFSSpaceTagBinding.__table__.columns.keys() - ) - assert {index["name"] for index in inspector.get_indexes(table_name)} == {"kfs_space_tag_binding_tag_idx"} - assert migration.revision == "9d4e6f8a1b2c" - assert migration.down_revision == "7c1e9a4b2d60" - - _run(migration, engine, "downgrade") - - assert table_name not in sa.inspect(engine).get_table_names() diff --git a/api/tests/unit_tests/migrations/versions/test_2026_08_17_1200-4f8b2c7d9e10_add_knowledge_fs_icon_background.py b/api/tests/unit_tests/migrations/versions/test_2026_08_17_1200-4f8b2c7d9e10_add_knowledge_fs_icon_background.py deleted file mode 100644 index 06e76e6d8c7..00000000000 --- a/api/tests/unit_tests/migrations/versions/test_2026_08_17_1200-4f8b2c7d9e10_add_knowledge_fs_icon_background.py +++ /dev/null @@ -1,62 +0,0 @@ -from __future__ import annotations - -import importlib.util -from pathlib import Path -from types import ModuleType -from unittest.mock import patch - -import sqlalchemy as sa -from alembic.migration import MigrationContext -from alembic.operations import Operations - -from models.knowledge_fs import KnowledgeFSControlSpace - -_MIGRATION_PATH = ( - Path(__file__).resolve().parents[4] - / "migrations/versions/2026_08_17_1200-4f8b2c7d9e10_add_knowledge_fs_icon_background.py" -) - - -def _load_migration() -> ModuleType: - spec = importlib.util.spec_from_file_location(_MIGRATION_PATH.stem, _MIGRATION_PATH) - if spec is None or spec.loader is None: - raise RuntimeError("failed to load KnowledgeFS icon-background migration") - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - return module - - -def _run(module: ModuleType, engine: sa.Engine, step: str) -> None: - with engine.begin() as connection: - operations = Operations(MigrationContext.configure(connection)) - with patch.object(module, "op", operations): - {"upgrade": module.upgrade, "downgrade": module.downgrade}[step]() - - -def test_icon_background_migration_matches_model() -> None: - engine = sa.create_engine("sqlite:///:memory:") - with engine.begin() as connection: - connection.exec_driver_sql("CREATE TABLE knowledge_fs_control_spaces (id VARCHAR(36) PRIMARY KEY)") - migration = _load_migration() - - _run(migration, engine, "upgrade") - - columns = { - column["name"]: column for column in sa.inspect(engine).get_columns(KnowledgeFSControlSpace.__tablename__) - } - icon_background = columns["icon_background"] - assert icon_background["nullable"] is False - assert isinstance(icon_background["type"], sa.String) - assert isinstance(icon_background["type"], sa.String) - assert icon_background["type"].length == 7 - default = icon_background["default"] - assert default is not None - assert "F0F9FF" in default - assert migration.revision == "4f8b2c7d9e10" - assert migration.down_revision == "9d4e6f8a1b2c" - - _run(migration, engine, "downgrade") - - assert "icon_background" not in { - column["name"] for column in sa.inspect(engine).get_columns(KnowledgeFSControlSpace.__tablename__) - } diff --git a/api/tests/unit_tests/migrations/versions/test_2026_08_17_1200-f3a8c1d7e920_add_knowledge_fs_upgrade_jobs.py b/api/tests/unit_tests/migrations/versions/test_2026_08_17_1200-f3a8c1d7e920_add_knowledge_fs_upgrade_jobs.py deleted file mode 100644 index dc777da18c1..00000000000 --- a/api/tests/unit_tests/migrations/versions/test_2026_08_17_1200-f3a8c1d7e920_add_knowledge_fs_upgrade_jobs.py +++ /dev/null @@ -1,70 +0,0 @@ -from __future__ import annotations - -import importlib.util -from pathlib import Path -from types import ModuleType -from unittest.mock import patch - -import sqlalchemy as sa -from alembic.migration import MigrationContext -from alembic.operations import Operations - -from models.knowledge_fs import ( - KnowledgeFSUpgradeDocument, - KnowledgeFSUpgradeFileLease, - KnowledgeFSUpgradeJob, - KnowledgeFSUpgradeSource, -) - -_MIGRATION_PATH = ( - Path(__file__).resolve().parents[4] - / "migrations/versions/2026_08_17_1200-f3a8c1d7e920_add_knowledge_fs_upgrade_jobs.py" -) -_MODELS = ( - KnowledgeFSUpgradeJob, - KnowledgeFSUpgradeDocument, - KnowledgeFSUpgradeSource, - KnowledgeFSUpgradeFileLease, -) - - -def _load_migration_module() -> ModuleType: - spec = importlib.util.spec_from_file_location("knowledge_fs_upgrade_jobs", _MIGRATION_PATH) - if spec is None or spec.loader is None: - raise RuntimeError("failed to load migration module") - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - return module - - -def _run_step(module: ModuleType, engine: sa.Engine, step_name: str) -> None: - with engine.begin() as connection: - operations = Operations(MigrationContext.configure(connection)) - with patch.object(module, "op", operations): - {"upgrade": module.upgrade, "downgrade": module.downgrade}[step_name]() - - -def test_upgrade_schema_matches_upgrade_models_and_downgrades_cleanly() -> None: - engine = sa.create_engine("sqlite:///:memory:") - module = _load_migration_module() - - _run_step(module, engine, "upgrade") - - inspector = sa.inspect(engine) - assert set(inspector.get_table_names()) == {model.__tablename__ for model in _MODELS} - for model in _MODELS: - migrated_columns = {column["name"] for column in inspector.get_columns(model.__tablename__)} - # Availability checkpoints and reconciliation leases are introduced by later migrations. - expected_columns = set(model.__table__.columns.keys()) - if model is KnowledgeFSUpgradeDocument: - expected_columns.remove("availability_reconciled_at") - if model is KnowledgeFSUpgradeJob: - expected_columns -= { - "reconciliation_task_id", - "reconciliation_started_at", - "reconciliation_lease_expires_at", - } - assert migrated_columns == expected_columns - - _run_step(module, engine, "downgrade") - assert sa.inspect(engine).get_table_names() == [] diff --git a/api/tests/unit_tests/migrations/versions/test_2026_08_17_1740-89919253ca7a_remove_agent_drive.py b/api/tests/unit_tests/migrations/versions/test_2026_08_17_1740-89919253ca7a_remove_agent_drive.py deleted file mode 100644 index 27541389fea..00000000000 --- a/api/tests/unit_tests/migrations/versions/test_2026_08_17_1740-89919253ca7a_remove_agent_drive.py +++ /dev/null @@ -1,184 +0,0 @@ -from __future__ import annotations - -import importlib.util -import json -from io import StringIO -from pathlib import Path -from types import ModuleType - -import pytest -import sqlalchemy as sa -from alembic.migration import MigrationContext -from alembic.operations import Operations - -_MIGRATION_PATH = ( - Path(__file__).resolve().parents[4] / "migrations/versions/2026_08_17_1740-89919253ca7a_remove_agent_drive.py" -) - - -def _load_migration_module() -> ModuleType: - spec = importlib.util.spec_from_file_location("remove_agent_drive", _MIGRATION_PATH) - if spec is None or spec.loader is None: - raise RuntimeError("failed to load migration module") - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - return module - - -def _create_pre_upgrade_schema(engine: sa.Engine) -> None: - metadata = sa.MetaData() - sa.Table("agent_drive_files", metadata, sa.Column("id", sa.String(36), primary_key=True)) - for table_name in ("agent_config_snapshots", "agent_config_drafts"): - sa.Table( - table_name, - metadata, - sa.Column("id", sa.String(36), primary_key=True), - sa.Column("config_snapshot", sa.Text(), nullable=False), - ) - sa.Table( - "workflow_agent_node_bindings", - metadata, - sa.Column("id", sa.String(36), primary_key=True), - sa.Column("node_job_config", sa.Text(), nullable=False), - ) - metadata.create_all(engine) - - -def _run_migration_step(module: ModuleType, engine: sa.Engine, step_name: str) -> None: - migration_step = module.__dict__[step_name] - if not callable(migration_step): - raise TypeError(f"migration step {step_name!r} is not callable") - - with engine.begin() as connection: - operations = Operations(MigrationContext.configure(connection)) - original_op = module.__dict__["op"] - module.__dict__["op"] = operations - try: - migration_step() - finally: - module.__dict__["op"] = original_op - - -def test_upgrade_removes_agent_drive_schema_and_legacy_json_fields() -> None: - engine = sa.create_engine("sqlite:///:memory:") - _create_pre_upgrade_schema(engine) - soul = { - "files": {"skills": [{"name": "legacy"}]}, - "config_skills": [{"name": "current", "file_id": "tool-1"}], - "prompt": {"system_prompt": "hello"}, - } - node_job = { - "metadata": { - "file_refs": [ - {"id": "upload-1", "drive_key": "files/input.pdf"}, - {"id": "upload-2"}, - ] - }, - "declared_outputs": [ - { - "name": "report", - "type": "file", - "check": {"benchmark_file_ref": {"id": "upload-3", "drive_key": "files/reference.pdf"}}, - } - ], - } - with engine.begin() as connection: - for table_name in ("agent_config_snapshots", "agent_config_drafts"): - connection.execute( - sa.text(f"INSERT INTO {table_name} (id, config_snapshot) VALUES (:id, :value)"), - {"id": table_name, "value": json.dumps(soul)}, - ) - connection.execute( - sa.text("INSERT INTO workflow_agent_node_bindings (id, node_job_config) VALUES (:id, :value)"), - {"id": "binding-1", "value": json.dumps(node_job)}, - ) - - module = _load_migration_module() - _run_migration_step(module, engine, "upgrade") - - assert "agent_drive_files" not in sa.inspect(engine).get_table_names() - with engine.begin() as connection: - for table_name in ("agent_config_snapshots", "agent_config_drafts"): - stored = connection.execute(sa.text(f"SELECT config_snapshot FROM {table_name}")).scalar_one() - value = json.loads(stored) - assert "files" not in value - assert value["config_skills"] == soul["config_skills"] - assert value["prompt"] == soul["prompt"] - stored_node_job = connection.execute( - sa.text("SELECT node_job_config FROM workflow_agent_node_bindings") - ).scalar_one() - - migrated_node_job = json.loads(stored_node_job) - assert migrated_node_job["metadata"]["file_refs"] == [{"id": "upload-1"}, {"id": "upload-2"}] - assert migrated_node_job["declared_outputs"][0]["check"]["benchmark_file_ref"] == {"id": "upload-3"} - - _run_migration_step(module, engine, "downgrade") - inspector = sa.inspect(engine) - assert "agent_drive_files" in inspector.get_table_names() - assert { - "tenant_id", - "agent_id", - "key", - "file_kind", - "file_id", - "value_owned_by_drive", - "is_skill", - "skill_metadata", - }.issubset({column["name"] for column in inspector.get_columns("agent_drive_files")}) - assert "agent_drive_file_scope_key_unique" in { - constraint["name"] for constraint in inspector.get_unique_constraints("agent_drive_files") - } - assert "agent_drive_files_tenant_agent_is_skill_key_idx" in { - index["name"] for index in inspector.get_indexes("agent_drive_files") - } - - -def test_upgrade_supports_offline_sql_generation() -> None: - module = _load_migration_module() - output = StringIO() - migration_context = MigrationContext.configure( - dialect_name="postgresql", - opts={"as_sql": True, "output_buffer": output}, - ) - operations = Operations(migration_context) - migration_step = module.__dict__["upgrade"] - if not callable(migration_step): - raise TypeError("migration upgrade is not callable") - - original_op = module.__dict__["op"] - module.__dict__["op"] = operations - try: - migration_step() - finally: - module.__dict__["op"] = original_op - - generated_sql = output.getvalue() - assert "DROP TABLE agent_drive_files" in generated_sql - assert "SELECT id" not in generated_sql - - -@pytest.mark.parametrize( - ("table_name", "column_name"), - [ - pytest.param("agent_config_snapshots", "config_snapshot", id="config-snapshot"), - pytest.param("workflow_agent_node_bindings", "node_job_config", id="node-job-config"), - ], -) -def test_upgrade_rejects_invalid_json_without_rewriting(table_name: str, column_name: str) -> None: - engine = sa.create_engine("sqlite:///:memory:") - _create_pre_upgrade_schema(engine) - invalid_json = "not-json" - with engine.begin() as connection: - connection.execute( - sa.text(f"INSERT INTO {table_name} (id, {column_name}) VALUES (:id, :value)"), - {"id": "invalid-row", "value": invalid_json}, - ) - - module = _load_migration_module() - with pytest.raises(json.JSONDecodeError): - _run_migration_step(module, engine, "upgrade") - - with engine.begin() as connection: - stored = connection.execute(sa.text(f"SELECT {column_name} FROM {table_name}")).scalar_one() - assert stored == invalid_json - assert "agent_drive_files" in sa.inspect(engine).get_table_names() diff --git a/api/tests/unit_tests/migrations/versions/test_2026_08_20_0938-fbdfcf5f5a6e_clean_legacy_agent_soul_files.py b/api/tests/unit_tests/migrations/versions/test_2026_08_20_0938-fbdfcf5f5a6e_clean_legacy_agent_soul_files.py deleted file mode 100644 index afd0b2be6dc..00000000000 --- a/api/tests/unit_tests/migrations/versions/test_2026_08_20_0938-fbdfcf5f5a6e_clean_legacy_agent_soul_files.py +++ /dev/null @@ -1,125 +0,0 @@ -from __future__ import annotations - -import importlib.util -import json -from io import StringIO -from pathlib import Path -from types import ModuleType - -import pytest -import sqlalchemy as sa -from alembic.migration import MigrationContext -from alembic.operations import Operations -from pydantic import ValidationError - -from models.agent_config_entities import AgentSoulConfig - -_MIGRATION_PATH = ( - Path(__file__).resolve().parents[4] - / "migrations/versions/2026_08_20_0938-fbdfcf5f5a6e_clean_legacy_agent_soul_files.py" -) - - -def _load_migration_module() -> ModuleType: - spec = importlib.util.spec_from_file_location("clean_legacy_agent_soul_files", _MIGRATION_PATH) - if spec is None or spec.loader is None: - raise RuntimeError("failed to load migration module") - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - return module - - -def _run_upgrade(module: ModuleType, engine: sa.Engine) -> None: - with engine.begin() as connection: - operations = Operations(MigrationContext.configure(connection)) - original_op = module.__dict__["op"] - module.__dict__["op"] = operations - try: - module.__dict__["upgrade"]() - finally: - module.__dict__["op"] = original_op - - -def test_upgrade_makes_legacy_build_draft_valid_for_first_message() -> None: - engine = sa.create_engine("sqlite:///:memory:") - metadata = sa.MetaData() - for table_name in ("agent_config_snapshots", "agent_config_drafts"): - sa.Table( - table_name, - metadata, - sa.Column("id", sa.String(36), primary_key=True), - sa.Column("config_snapshot", sa.Text(), nullable=False), - ) - metadata.create_all(engine) - - legacy_soul: dict[str, object] = { - "files": {"files": [], "skills": []}, - "prompt": {"system_prompt": "Build mode"}, - } - with pytest.raises(ValidationError) as exc_info: - AgentSoulConfig.model_validate(legacy_soul) - assert exc_info.value.errors(include_url=False)[0]["loc"] == ("files",) - assert exc_info.value.errors(include_url=False)[0]["type"] == "extra_forbidden" - - with engine.begin() as connection: - for table_name in ("agent_config_snapshots", "agent_config_drafts"): - connection.execute( - sa.text(f"INSERT INTO {table_name} (id, config_snapshot) VALUES (:id, :config_snapshot)"), - {"id": table_name, "config_snapshot": json.dumps(legacy_soul)}, - ) - - _run_upgrade(_load_migration_module(), engine) - - with engine.begin() as connection: - stored_build_draft = connection.execute(sa.text("SELECT config_snapshot FROM agent_config_drafts")).scalar_one() - stored_snapshot = connection.execute(sa.text("SELECT config_snapshot FROM agent_config_snapshots")).scalar_one() - - for stored_soul in (stored_build_draft, stored_snapshot): - value = json.loads(stored_soul) - assert "files" not in value - assert AgentSoulConfig.model_validate(value).prompt.system_prompt == "Build mode" - - -@pytest.mark.parametrize( - ("dialect_name", "removal_expression", "presence_predicate", "other_dialect_expression"), - [ - ( - "postgresql", - "config_snapshot::jsonb - 'files'", - "config_snapshot::jsonb ? 'files'", - "JSON_REMOVE", - ), - ( - "mysql", - "JSON_REMOVE(config_snapshot, '$.files')", - "JSON_CONTAINS_PATH(config_snapshot, 'one', '$.files')", - "config_snapshot::jsonb", - ), - ], -) -def test_upgrade_emits_legacy_files_cleanup_in_offline_sql( - dialect_name: str, - removal_expression: str, - presence_predicate: str, - other_dialect_expression: str, -) -> None: - module = _load_migration_module() - output = StringIO() - migration_context = MigrationContext.configure( - dialect_name=dialect_name, - opts={"as_sql": True, "output_buffer": output}, - ) - operations = Operations(migration_context) - original_op = module.__dict__["op"] - module.__dict__["op"] = operations - try: - module.__dict__["upgrade"]() - finally: - module.__dict__["op"] = original_op - - generated_sql = output.getvalue() - assert "UPDATE agent_config_snapshots" in generated_sql - assert "UPDATE agent_config_drafts" in generated_sql - assert generated_sql.count(removal_expression) == 2 - assert generated_sql.count(presence_predicate) == 2 - assert other_dialect_expression not in generated_sql diff --git a/api/tests/unit_tests/migrations/versions/test_2026_08_21_1422-925e75620b69_simplify_agent_v2_output_contract.py b/api/tests/unit_tests/migrations/versions/test_2026_08_21_1422-925e75620b69_simplify_agent_v2_output_contract.py deleted file mode 100644 index 311deac1433..00000000000 --- a/api/tests/unit_tests/migrations/versions/test_2026_08_21_1422-925e75620b69_simplify_agent_v2_output_contract.py +++ /dev/null @@ -1,98 +0,0 @@ -from __future__ import annotations - -import importlib.util -import json -from pathlib import Path -from types import ModuleType - -import sqlalchemy as sa -from alembic.migration import MigrationContext -from alembic.operations import Operations - -from models.agent_config_entities import ( - WorkflowNodeJobConfig, - effective_declared_outputs, -) - -_MIGRATION_PATH = ( - Path(__file__).resolve().parents[4] - / "migrations/versions/2026_08_21_1422-925e75620b69_simplify_agent_v2_output_contract.py" -) - - -def _load_migration_module() -> ModuleType: - spec = importlib.util.spec_from_file_location("simplify_agent_v2_output_contract", _MIGRATION_PATH) - if spec is None or spec.loader is None: - raise RuntimeError("failed to load migration module") - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - return module - - -def test_upgrade_removes_legacy_preset_outputs_and_preserves_custom_output_configuration() -> None: - engine = sa.create_engine("sqlite:///:memory:") - metadata = sa.MetaData() - sa.Table( - "workflow_agent_node_bindings", - metadata, - sa.Column("id", sa.String(36), primary_key=True), - sa.Column("node_job_config", sa.Text(), nullable=False), - ) - metadata.create_all(engine) - custom_outputs = [ - { - "name": "summary", - "type": "string", - "required": True, - "description": "Keep every setting", - "failure_strategy": {"on_failure": "stop"}, - }, - { - "name": "attachments", - "type": "array", - "required": False, - "array_item": {"type": "file", "description": "Produced file"}, - }, - ] - node_job = { - "workflow_prompt": "Produce a result", - "declared_outputs": [ - {"name": "text", "type": "string"}, - custom_outputs[0], - {"name": "files", "type": "array", "array_item": {"type": "file"}}, - custom_outputs[1], - {"name": "json", "type": "object"}, - ], - } - with engine.begin() as connection: - connection.execute( - sa.text("INSERT INTO workflow_agent_node_bindings (id, node_job_config) VALUES (:id, :node_job_config)"), - {"id": "binding-1", "node_job_config": json.dumps(node_job)}, - ) - - module = _load_migration_module() - with engine.begin() as connection: - operations = Operations(MigrationContext.configure(connection)) - original_op = module.__dict__["op"] - module.__dict__["op"] = operations - try: - module.upgrade() - finally: - module.__dict__["op"] = original_op - - with engine.begin() as connection: - stored = connection.execute( - sa.text("SELECT node_job_config FROM workflow_agent_node_bindings WHERE id = :id"), - {"id": "binding-1"}, - ).scalar_one() - - migrated_node_job = json.loads(stored) - assert migrated_node_job["workflow_prompt"] == "Produce a result" - assert migrated_node_job["declared_outputs"] == custom_outputs - - node_job = WorkflowNodeJobConfig.model_validate(migrated_node_job) - assert [output.name for output in effective_declared_outputs(node_job.declared_outputs)] == [ - "text", - "summary", - "attachments", - ] diff --git a/api/tests/unit_tests/migrations/versions/test_2026_08_25_1200-9b7c6d5e4f3a_add_normalized_email_to_accounts.py b/api/tests/unit_tests/migrations/versions/test_2026_08_25_1200-9b7c6d5e4f3a_add_normalized_email_to_accounts.py deleted file mode 100644 index e4e352b7390..00000000000 --- a/api/tests/unit_tests/migrations/versions/test_2026_08_25_1200-9b7c6d5e4f3a_add_normalized_email_to_accounts.py +++ /dev/null @@ -1,98 +0,0 @@ -from __future__ import annotations - -import importlib.util -from io import StringIO -from pathlib import Path -from types import ModuleType - -import pytest -import sqlalchemy as sa -from alembic.migration import MigrationContext -from alembic.operations import Operations - -_MIGRATION_PATH = ( - Path(__file__).resolve().parents[4] - / "migrations/versions/2026_08_25_1200-9b7c6d5e4f3a_add_normalized_email_to_accounts.py" -) - - -def _load_migration_module() -> ModuleType: - spec = importlib.util.spec_from_file_location("add_normalized_email_to_accounts", _MIGRATION_PATH) - if spec is None or spec.loader is None: - raise RuntimeError("failed to load migration module") - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - return module - - -def test_upgrade_backfills_normalized_emails_without_enforcing_uniqueness() -> None: - engine = sa.create_engine("sqlite:///:memory:") - metadata = sa.MetaData() - sa.Table( - "accounts", - metadata, - sa.Column("id", sa.String(36), primary_key=True), - sa.Column("email", sa.String(255), nullable=False), - ) - metadata.create_all(engine) - - with engine.begin() as connection: - connection.execute( - sa.text("INSERT INTO accounts (id, email) VALUES (:id, :email)"), - [ - {"id": "account-1", "email": "User@Example.com"}, - {"id": "account-2", "email": "User@GoogleMail.com"}, - {"id": "account-3", "email": "u.ser+tag@gmail.com"}, - ], - ) - - module = _load_migration_module() - with engine.begin() as connection: - operations = Operations(MigrationContext.configure(connection)) - original_op = module.__dict__["op"] - module.__dict__["op"] = operations - try: - module.upgrade() - finally: - module.__dict__["op"] = original_op - - with engine.begin() as connection: - rows = connection.execute(sa.text("SELECT id, normalized_email FROM accounts ORDER BY id")).all() - connection.execute( - sa.text("INSERT INTO accounts (id, email, normalized_email) VALUES (:id, :email, :normalized_email)"), - { - "id": "account-4", - "email": "user@googlemail.com", - "normalized_email": "user@gmail.com", - }, - ) - - assert rows == [ - ("account-1", "user@example.com"), - ("account-2", "user@gmail.com"), - ("account-3", "user@gmail.com"), - ] - - -@pytest.mark.parametrize( - ("dialect_name", "dialect_marker"), - [("postgresql", "regexp_replace"), ("mysql", "SUBSTRING_INDEX"), ("sqlite", "instr")], -) -def test_backfill_emits_dialect_specific_offline_sql(dialect_name: str, dialect_marker: str) -> None: - module = _load_migration_module() - output = StringIO() - migration_context = MigrationContext.configure( - dialect_name=dialect_name, - opts={"as_sql": True, "output_buffer": output}, - ) - operations = Operations(migration_context) - original_op = module.__dict__["op"] - module.__dict__["op"] = operations - try: - module._backfill_normalized_emails() - finally: - module.__dict__["op"] = original_op - - generated_sql = output.getvalue() - assert "UPDATE accounts" in generated_sql - assert dialect_marker in generated_sql diff --git a/api/tests/unit_tests/migrations/versions/test_2026_08_27_1200-5578e028b2f2_migrate_legacy_model_types.py b/api/tests/unit_tests/migrations/versions/test_2026_08_27_1200-5578e028b2f2_migrate_legacy_model_types.py deleted file mode 100644 index d4997c6eb2a..00000000000 --- a/api/tests/unit_tests/migrations/versions/test_2026_08_27_1200-5578e028b2f2_migrate_legacy_model_types.py +++ /dev/null @@ -1,86 +0,0 @@ -from __future__ import annotations - -import importlib.util -from io import StringIO -from pathlib import Path -from types import ModuleType - -import pytest -from alembic.migration import MigrationContext -from alembic.operations import Operations - -_MIGRATION_PATH = ( - Path(__file__).resolve().parents[4] - / "migrations/versions/2026_08_27_1200-5578e028b2f2_migrate_legacy_model_types.py" -) - - -def _load_migration_module() -> ModuleType: - spec = importlib.util.spec_from_file_location("migrate_legacy_model_types", _MIGRATION_PATH) - if spec is None or spec.loader is None: - raise RuntimeError("failed to load migration module") - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - return module - - -@pytest.mark.parametrize( - ("dialect_name", "duplicate_delete", "reference_update", "temporary_drop"), - [ - ( - "postgresql", - "DELETE FROM provider_models AS loser USING provider_models AS winner", - "UPDATE provider_models AS model SET credential_id = merges.winner_id FROM", - "DROP TABLE tmp_5578e028b2f2_credential_merges", - ), - ( - "mysql", - "DELETE loser FROM provider_models AS loser INNER JOIN provider_models AS winner", - "UPDATE provider_models AS model INNER JOIN tmp_5578e028b2f2_credential_merges", - "DROP TEMPORARY TABLE tmp_5578e028b2f2_credential_merges", - ), - ], -) -def test_upgrade_emits_collision_safe_sql_for_supported_databases( - dialect_name: str, - duplicate_delete: str, - reference_update: str, - temporary_drop: str, -) -> None: - module = _load_migration_module() - output = StringIO() - migration_context = MigrationContext.configure( - dialect_name=dialect_name, - opts={"as_sql": True, "literal_binds": True, "output_buffer": output}, - ) - operations = Operations(migration_context) - original_op = module.__dict__["op"] - module.__dict__["op"] = operations - try: - module.__dict__["upgrade"]() - finally: - module.__dict__["op"] = original_op - - generated_sql = " ".join(output.getvalue().split()) - assert duplicate_delete in generated_sql - assert reference_update in generated_sql - assert temporary_drop in generated_sql - assert "ORDER BY updated_at DESC, id DESC" in generated_sql - assert "legacy.model_type IN ('text-generation', 'embeddings', 'reranking')" in generated_sql - assert "WHERE id <> winner_id AND legacy_count > 0" in generated_sql - - for table_name in ( - "provider_models", - "provider_model_credentials", - "tenant_default_models", - "provider_model_settings", - "load_balancing_model_configs", - ): - assert f"UPDATE {table_name} SET model_type = CASE {table_name}.model_type" in generated_sql - - for old_value, new_value in ( - ("text-generation", "llm"), - ("embeddings", "text-embedding"), - ("reranking", "rerank"), - ): - assert f"WHEN '{old_value}' THEN '{new_value}'" in generated_sql diff --git a/api/tests/unit_tests/migrations/versions/test_2026_09_03_1000-9a4e7d1c2b60_add_dataset_api_token_binding_resource_type.py b/api/tests/unit_tests/migrations/versions/test_2026_09_03_1000-9a4e7d1c2b60_add_dataset_api_token_binding_resource_type.py deleted file mode 100644 index edb9a4681c3..00000000000 --- a/api/tests/unit_tests/migrations/versions/test_2026_09_03_1000-9a4e7d1c2b60_add_dataset_api_token_binding_resource_type.py +++ /dev/null @@ -1,119 +0,0 @@ -from __future__ import annotations - -import importlib.util -from collections.abc import Iterator -from io import StringIO -from pathlib import Path -from types import ModuleType - -import pytest -import sqlalchemy as sa -from alembic.migration import MigrationContext -from alembic.operations import Operations - -_MIGRATION_PATH = ( - Path(__file__).resolve().parents[4] - / "migrations/versions/2026_09_03_1000-9a4e7d1c2b60_add_dataset_api_token_binding_resource_type.py" -) - - -def _load_migration() -> ModuleType: - spec = importlib.util.spec_from_file_location(_MIGRATION_PATH.stem, _MIGRATION_PATH) - if spec is None or spec.loader is None: - raise RuntimeError("failed to load dataset API token binding resource type migration") - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - return module - - -@pytest.fixture -def upgraded() -> Iterator[tuple[sa.Connection, ModuleType]]: - engine = sa.create_engine("sqlite:///:memory:") - metadata = sa.MetaData() - for table_name in ("api_tokens", "datasets", "knowledge_fs_control_spaces"): - sa.Table(table_name, metadata, sa.Column("id", sa.String(36), primary_key=True)) - sa.Table( - "dataset_api_token_bindings", - metadata, - sa.Column("id", sa.String(36), primary_key=True), - sa.Column("api_token_id", sa.String(36), sa.ForeignKey("api_tokens.id"), nullable=False), - sa.Column("dataset_id", sa.String(36), sa.ForeignKey("datasets.id"), nullable=False), - sa.UniqueConstraint("api_token_id", "dataset_id", name="dataset_api_token_binding_unique"), - ) - metadata.create_all(engine) - migration = _load_migration() - with engine.begin() as connection: - migration.__dict__["op"] = Operations(MigrationContext.configure(connection)) - migration.upgrade() - connection.execute(sa.text("INSERT INTO api_tokens (id) VALUES ('mixed-key'), ('space-key'), ('dataset-key')")) - connection.execute(sa.text("INSERT INTO datasets (id) VALUES ('dataset-1')")) - connection.execute(sa.text("INSERT INTO knowledge_fs_control_spaces (id) VALUES ('space-1')")) - connection.execute( - sa.text( - "INSERT INTO dataset_api_token_bindings (id, api_token_id, resource_type, dataset_id, control_space_id)" - " VALUES ('b1', 'mixed-key', 'dataset', 'dataset-1', NULL)," - " ('b2', 'mixed-key', 'knowledge_fs_space', NULL, 'space-1')," - " ('b3', 'dataset-key', 'dataset', 'dataset-1', NULL)" - ) - ) - yield connection, migration - engine.dispose() - - -def _bindings(connection: sa.Connection) -> set[tuple[str, str]]: - rows = connection.execute(sa.text("SELECT api_token_id, dataset_id FROM dataset_api_token_bindings")) - return {(row.api_token_id, row.dataset_id) for row in rows} - - -def test_downgrade_keeps_legacy_scope_for_keys_with_dataset_bindings( - upgraded: tuple[sa.Connection, ModuleType], -) -> None: - connection, migration = upgraded - - migration.downgrade() - - assert _bindings(connection) == {("mixed-key", "dataset-1"), ("dataset-key", "dataset-1")} - columns = {column["name"] for column in sa.inspect(connection).get_columns("dataset_api_token_bindings")} - assert columns == {"id", "api_token_id", "dataset_id"} - - -def test_downgrade_refuses_to_widen_knowledge_fs_only_keys(upgraded: tuple[sa.Connection, ModuleType]) -> None: - connection, migration = upgraded - connection.execute( - sa.text( - "INSERT INTO dataset_api_token_bindings (id, api_token_id, resource_type, dataset_id, control_space_id)" - " VALUES ('b4', 'space-key', 'knowledge_fs_space', NULL, 'space-1')" - ) - ) - - with pytest.raises(RuntimeError, match="1 dataset API key"): - migration.downgrade() - - assert connection.execute(sa.text("SELECT COUNT(*) FROM dataset_api_token_bindings")).scalar_one() == 4 - - -def _render_offline_downgrade(dialect_name: str) -> str: - migration = _load_migration() - output = StringIO() - context = MigrationContext.configure(dialect_name=dialect_name, opts={"as_sql": True, "output_buffer": output}) - migration.__dict__["op"] = Operations(context) - migration.downgrade() - return output.getvalue() - - -def test_offline_postgresql_downgrade_raises_before_dropping_space_bindings() -> None: - sql = _render_offline_downgrade("postgresql") - - guard = sql.index("DO $$ DECLARE knowledge_fs_only_keys bigint; BEGIN SELECT COUNT(DISTINCT api_token_id)") - assert sql.index("RAISE EXCEPTION 'Some dataset API key(s)", guard) < sql.index( - "DELETE FROM dataset_api_token_bindings" - ) - assert "%" not in sql - - -def test_offline_mysql_downgrade_warns_before_dropping_space_bindings() -> None: - sql = _render_offline_downgrade("mysql") - - assert sql.index("-- Before running this downgrade, confirm no dataset API key(s)") < sql.index( - "DELETE FROM dataset_api_token_bindings" - ) diff --git a/api/tests/unit_tests/migrations/versions/test_2026_09_10_1200-d8e4a6b1c902_expand_normalized_email_providers.py b/api/tests/unit_tests/migrations/versions/test_2026_09_10_1200-d8e4a6b1c902_expand_normalized_email_providers.py deleted file mode 100644 index 83feab7bcb2..00000000000 --- a/api/tests/unit_tests/migrations/versions/test_2026_09_10_1200-d8e4a6b1c902_expand_normalized_email_providers.py +++ /dev/null @@ -1,107 +0,0 @@ -from __future__ import annotations - -import importlib.util -from io import StringIO -from pathlib import Path -from types import ModuleType - -import pytest -import sqlalchemy as sa -from alembic.migration import MigrationContext -from alembic.operations import Operations - -from services.account_email import normalize_email - - -def _load_migration() -> ModuleType: - path = ( - Path(__file__).resolve().parents[4] - / "migrations/versions/2026_09_10_1200-d8e4a6b1c902_expand_normalized_email_providers.py" - ) - spec = importlib.util.spec_from_file_location("expand_normalized_email_providers", path) - assert spec is not None - assert spec.loader is not None - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - return module - - -def test_upgrade_matches_runtime_preserves_accounts_and_downgrade_restores_values() -> None: - domains = [ - "outlook.com", - "hotmail.com", - "live.com", - "msn.com", - "icloud.com", - "me.com", - "mac.com", - "protonmail.com", - "proton.me", - "pm.me", - "protonmail.ch", - "yahoo.com", - "yahoo.co.uk", - "mail.com", - "example.org", - "sub.proton.me", - "outlook.com.example.org", - ] - emails = [f"{local}@{domain.upper()}" for domain in domains for local in ("First.Last_Name-Test+tag+other", "User")] - emails += ["user+duplicate@outlook.com", "u.ser+tag@gmail.com", "User@GoogleMail.com"] - original_values = [ - normalize_email(email) if email.lower().endswith(("@gmail.com", "@googlemail.com")) else email.lower() - for email in emails - ] - engine = sa.create_engine("sqlite:///:memory:") - metadata = sa.MetaData() - accounts = sa.Table( - "accounts", - metadata, - sa.Column("id", sa.Integer, primary_key=True), - sa.Column("email", sa.String(255), nullable=False), - sa.Column("normalized_email", sa.String(255), nullable=True), - sa.Index("account_normalized_email_idx", "normalized_email"), - ) - metadata.create_all(engine) - module = _load_migration() - with engine.begin() as connection: - connection.execute( - accounts.insert(), - [ - {"id": index, "email": email, "normalized_email": original_values[index]} - for index, email in enumerate(emails) - ], - ) - module.__dict__["op"] = Operations(MigrationContext.configure(connection)) - module.upgrade() - rows = connection.execute(sa.select(accounts).order_by(accounts.c.id)).all() - assert [row.email for row in rows] == emails - assert [row.normalized_email for row in rows] == [normalize_email(email) for email in emails] - assert [row.normalized_email for row in rows].count("user@outlook.com") == 2 - module.upgrade() - assert connection.execute(sa.select(accounts).order_by(accounts.c.id)).all() == rows - module.downgrade() - assert ( - connection.scalars(sa.select(accounts.c.normalized_email).order_by(accounts.c.id)).all() == original_values - ) - engine.dispose() - - -@pytest.mark.parametrize("dialect", ["postgresql", "mysql", "mariadb", "sqlite"]) -@pytest.mark.parametrize("restore", [False, True]) -def test_migration_supports_offline_sql(dialect: str, restore: bool) -> None: - output = StringIO() - module = _load_migration() - module.__dict__["op"] = Operations( - MigrationContext.configure( - dialect_name=dialect, - opts={"as_sql": True, "output_buffer": output}, - ) - ) - module._backfill_normalized_emails(restore=restore) - sql = output.getvalue() - assert "UPDATE accounts SET normalized_email=" in sql - assert "WHERE" in sql - assert "protonmail.ch" in sql - assert "gmail.com" not in sql - assert "CASE" in sql if not restore else "CASE" not in sql diff --git a/api/tests/unit_tests/migrations/versions/test_2026_10_10_1200-c6f4a90d8b31_add_workflow_pause_task_id.py b/api/tests/unit_tests/migrations/versions/test_2026_10_10_1200-c6f4a90d8b31_add_workflow_pause_task_id.py deleted file mode 100644 index b8f50d65c4a..00000000000 --- a/api/tests/unit_tests/migrations/versions/test_2026_10_10_1200-c6f4a90d8b31_add_workflow_pause_task_id.py +++ /dev/null @@ -1,163 +0,0 @@ -from __future__ import annotations - -import importlib.util -from io import StringIO -from pathlib import Path -from types import ModuleType - -import pytest -import sqlalchemy as sa -from alembic import command -from alembic.config import Config -from alembic.migration import MigrationContext -from alembic.operations import Operations -from alembic.script import ScriptDirectory -from flask import Flask -from flask_migrate import Migrate -from flask_sqlalchemy import SQLAlchemy - -_MIGRATIONS_DIR = Path(__file__).resolve().parents[4] / "migrations" -_MIGRATION_PATH = _MIGRATIONS_DIR / "versions/2026_10_10_1200-c6f4a90d8b31_add_workflow_pause_task_id.py" - - -def _load_migration_module(path: Path) -> ModuleType: - spec = importlib.util.spec_from_file_location(path.stem, path) - if spec is None or spec.loader is None: - raise RuntimeError("failed to load migration module") - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - return module - - -def test_repository_migration_graph_has_one_head() -> None: - config = Config() - config.set_main_option("script_location", str(_MIGRATIONS_DIR)) - assert len(ScriptDirectory.from_config(config).get_heads()) == 1 - - -@pytest.mark.parametrize("dialect_name", ["postgresql", "mysql"]) -def test_index_uses_concurrent_ddl_outside_postgresql_transactions(dialect_name: str) -> None: - migration = _load_migration_module(_MIGRATION_PATH) - output = StringIO() - context = MigrationContext.configure( - dialect_name=dialect_name, - opts={"as_sql": True, "output_buffer": output}, - ) - migration.__dict__["op"] = Operations(context) - migration.upgrade() - migration.downgrade() - - statements = [statement.strip() for statement in output.getvalue().split(";") if statement.strip()] - assert not any(statement.startswith("UPDATE") for statement in statements) - assert not any( - "NOT NULL" in statement or "DEFAULT" in statement for statement in statements if "ADD COLUMN" in statement - ) - if dialect_name == "postgresql": - create = ( - "CREATE INDEX CONCURRENTLY workflow_run_task_id_idx ON workflow_runs (task_id) WHERE task_id IS NOT NULL" - ) - drop = "DROP INDEX CONCURRENTLY IF EXISTS workflow_run_task_id_idx" - index = statements.index(create) - assert statements[index - 2 : index + 2] == ["COMMIT", drop, create, "BEGIN"] - assert statements[index + 2 : index + 5] == ["COMMIT", drop, "BEGIN"] - else: - assert "CREATE INDEX workflow_run_task_id_idx ON workflow_runs (task_id)" in statements - assert "DROP INDEX workflow_run_task_id_idx ON workflow_runs" in statements - assert "CONCURRENTLY" not in output.getvalue() - assert "IF NOT EXISTS" not in output.getvalue() - - -def test_alembic_commits_retryable_columns_before_index_and_stamps_revision_afterward( - monkeypatch: pytest.MonkeyPatch, -) -> None: - output = StringIO() - config = Config(str(_MIGRATIONS_DIR / "alembic.ini"), output_buffer=output) - config.set_main_option("script_location", str(_MIGRATIONS_DIR)) - app = Flask(__name__) - app.config["SQLALCHEMY_DATABASE_URI"] = "postgresql://" - database = SQLAlchemy(app) - Migrate(app, database) - monkeypatch.setattr("logging.config.fileConfig", lambda _filename: None) - - with app.app_context(): - command.upgrade(config, "f1d3c5b7a902:c6f4a90d8b31", sql=True) - database.engine.dispose() - - sql = output.getvalue() - add_task_id = sql.index("ADD COLUMN IF NOT EXISTS task_id") - add_stop_requested_at = sql.index("ADD COLUMN IF NOT EXISTS stop_requested_at") - drop_index = sql.index("DROP INDEX CONCURRENTLY IF EXISTS workflow_run_task_id_idx") - create_index = sql.index("CREATE INDEX CONCURRENTLY workflow_run_task_id_idx") - stamp_revision = sql.index("UPDATE alembic_version SET version_num='c6f4a90d8b31'") - assert add_task_id < add_stop_requested_at < sql.index("COMMIT;") < drop_index < create_index < stamp_revision - assert sql.count("UPDATE alembic_version") == 1 - - -@pytest.mark.parametrize(("existing_column_count", "index_exists"), [(0, False), (1, False), (2, False), (2, True)]) -def test_sqlite_upgrade_keeps_historical_rows_nullable_and_downgrade_preserves_rows( - existing_column_count: int, index_exists: bool -) -> None: - engine = sa.create_engine("sqlite:///:memory:") - metadata = sa.MetaData() - runs = sa.Table( - "workflow_runs", - metadata, - sa.Column("id", sa.String(36), primary_key=True), - *[sa.Column("task_id", sa.String(36)), sa.Column("stop_requested_at", sa.DateTime())][:existing_column_count], - ) - if index_exists: - sa.Index("workflow_run_task_id_idx", runs.c.task_id) - metadata.create_all(engine) - migration = _load_migration_module(_MIGRATION_PATH) - - with engine.begin() as connection: - connection.execute(runs.insert().values(id="historical-run")) - if existing_column_count: - connection.execute(runs.update().values(task_id="historical-task")) - migration.__dict__["op"] = Operations(MigrationContext.configure(connection)) - migration.upgrade() - - columns = {column["name"]: column for column in sa.inspect(connection).get_columns("workflow_runs")} - assert columns["task_id"]["nullable"] is True - assert columns["stop_requested_at"]["nullable"] is True - assert columns["task_id"]["default"] is None - assert columns["stop_requested_at"]["default"] is None - assert connection.execute(sa.text("SELECT id, task_id, stop_requested_at FROM workflow_runs")).one() == ( - "historical-run", - "historical-task" if existing_column_count else None, - None, - ) - assert sa.inspect(connection).get_indexes("workflow_runs")[0]["name"] == "workflow_run_task_id_idx" - - migration.downgrade() - - assert [column["name"] for column in sa.inspect(connection).get_columns("workflow_runs")] == ["id"] - assert connection.scalar(sa.text("SELECT id FROM workflow_runs")) == "historical-run" - assert sa.inspect(connection).get_indexes("workflow_runs") == [] - - engine.dispose() - - -@pytest.mark.parametrize("remaining_column_count", [2, 1, 0]) -def test_sqlite_downgrade_resumes_after_index_or_columns_were_dropped(remaining_column_count: int) -> None: - engine = sa.create_engine("sqlite:///:memory:") - metadata = sa.MetaData() - runs = sa.Table( - "workflow_runs", - metadata, - sa.Column("id", sa.String(36), primary_key=True), - *[sa.Column("task_id", sa.String(36)), sa.Column("stop_requested_at", sa.DateTime())][:remaining_column_count], - ) - metadata.create_all(engine) - migration = _load_migration_module(_MIGRATION_PATH) - - with engine.begin() as connection: - connection.execute(runs.insert().values(id="historical-run")) - migration.__dict__["op"] = Operations(MigrationContext.configure(connection)) - migration.downgrade() - - assert [column["name"] for column in sa.inspect(connection).get_columns("workflow_runs")] == ["id"] - assert connection.scalar(sa.text("SELECT id FROM workflow_runs")) == "historical-run" - assert sa.inspect(connection).get_indexes("workflow_runs") == [] - - engine.dispose() diff --git a/api/tests/unit_tests/migrations/versions/test_2026_10_15_0330-a17c2980f3e6_add_application_agent_tracing.py b/api/tests/unit_tests/migrations/versions/test_2026_10_15_0330-a17c2980f3e6_add_application_agent_tracing.py deleted file mode 100644 index 1a53a363b9c..00000000000 --- a/api/tests/unit_tests/migrations/versions/test_2026_10_15_0330-a17c2980f3e6_add_application_agent_tracing.py +++ /dev/null @@ -1,69 +0,0 @@ -"""The migration can create and remove the three tables without business tables.""" - -import importlib.util -from pathlib import Path - -import pytest -import sqlalchemy as sa -from alembic.migration import MigrationContext -from alembic.operations import Operations -from alembic.script import ScriptDirectory - -from models.application_trace import ApplicationTraceBatch, ApplicationTraceInvocation, ApplicationTraceRun - - -@pytest.mark.parametrize( - ("starting_revision", "required_revisions"), - [ - ("f1d3c5b7a902", ("a17c2980f3e6", "c6f4a90d8b31", "f7e2c9a6d481")), - ("a17c2980f3e6", ("c6f4a90d8b31", "f7e2c9a6d481")), - ("c6f4a90d8b31", ("a17c2980f3e6", "f7e2c9a6d481")), - ], -) -def test_tracing_and_workflow_pause_upgrade_paths_join( - starting_revision: str, required_revisions: tuple[str, ...] -) -> None: - scripts = ScriptDirectory(str(Path(__file__).resolve().parents[4] / "migrations")) - merge = scripts.get_revision("f7e2c9a6d481") - assert merge is not None - assert isinstance(merge.down_revision, tuple) - assert set(merge.down_revision) == {"a17c2980f3e6", "c6f4a90d8b31"} - - # Use Alembic's upgrade planner so either already-applied branch still runs - # its missing sibling before the merge, without rewriting either migration. - steps = scripts._upgrade_revs(merge.revision, starting_revision) - assert {step.revision.revision for step in steps} == set(required_revisions) - assert steps[-1].revision.revision == merge.revision - - -def test_application_trace_migration_matches_models_and_roundtrips() -> None: - path = ( - Path(__file__).resolve().parents[4] - / "migrations/versions/2026_10_15_0330-a17c2980f3e6_add_application_agent_tracing.py" - ) - spec = importlib.util.spec_from_file_location("application_trace_migration", path) - assert spec is not None - assert spec.loader is not None - migration = importlib.util.module_from_spec(spec) - spec.loader.exec_module(migration) - assert migration.down_revision == "f1d3c5b7a902" - engine = sa.create_engine("sqlite://") - try: - with engine.begin() as connection: - with Operations.context(MigrationContext.configure(connection)): - migration.upgrade() - inspector = sa.inspect(connection) - for model in (ApplicationTraceRun, ApplicationTraceInvocation, ApplicationTraceBatch): - actual = {column["name"]: column for column in inspector.get_columns(model.__tablename__)} - assert set(actual) == set(model.__table__.columns.keys()) - for column in model.__table__.columns: - assert actual[column.name]["nullable"] == column.nullable - assert {index["name"] for index in inspector.get_indexes("application_trace_runs")} == { - "application_trace_delivery_idx", - "application_trace_retention_idx", - } - with Operations.context(MigrationContext.configure(connection)): - migration.downgrade() - assert sa.inspect(connection).get_table_names() == [] - finally: - engine.dispose() diff --git a/api/tests/unit_tests/migrations/versions/test_2026_10_21_1300-3edd6cd27f54_limit_active_knowledge_fs_upgrades_on_mysql.py b/api/tests/unit_tests/migrations/versions/test_2026_10_21_1300-3edd6cd27f54_limit_active_knowledge_fs_upgrades_on_mysql.py deleted file mode 100644 index ddc75fbeead..00000000000 --- a/api/tests/unit_tests/migrations/versions/test_2026_10_21_1300-3edd6cd27f54_limit_active_knowledge_fs_upgrades_on_mysql.py +++ /dev/null @@ -1,58 +0,0 @@ -from __future__ import annotations - -import importlib.util -from io import StringIO -from pathlib import Path -from types import ModuleType - -import pytest -from alembic.migration import MigrationContext -from alembic.operations import Operations - -_MIGRATION_PATH = ( - Path(__file__).resolve().parents[4] - / "migrations/versions/2026_10_21_1300-3edd6cd27f54_limit_active_knowledge_fs_upgrades_on_mysql.py" -) - - -def _render(dialect_name: str, step: str) -> str: - spec = importlib.util.spec_from_file_location(_MIGRATION_PATH.stem, _MIGRATION_PATH) - if spec is None or spec.loader is None: - raise RuntimeError("failed to load MySQL active upgrade uniqueness migration") - migration: ModuleType = importlib.util.module_from_spec(spec) - spec.loader.exec_module(migration) - output = StringIO() - context = MigrationContext.configure(dialect_name=dialect_name, opts={"as_sql": True, "output_buffer": output}) - migration.__dict__["op"] = Operations(context) - {"upgrade": migration.upgrade, "downgrade": migration.downgrade}[step]() - return output.getvalue() - - -def test_mysql_unique_index_only_covers_active_jobs() -> None: - sql = _render("mysql", "upgrade") - - drop = sql.index("DROP INDEX kfs_upgrade_job_active_dataset_uq ON knowledge_fs_upgrade_jobs") - add = sql.index( - "ADD COLUMN active_old_dataset_id CHAR(36) GENERATED ALWAYS AS " - "(CASE WHEN status IN ('queued', 'running') THEN old_dataset_id ELSE NULL END) STORED" - ) - create = sql.index( - "CREATE UNIQUE INDEX kfs_upgrade_job_active_dataset_uq ON knowledge_fs_upgrade_jobs " - "(tenant_id, active_old_dataset_id)" - ) - assert drop < add < create - - -def test_mysql_downgrade_restores_index_name_without_rejecting_history() -> None: - sql = _render("mysql", "downgrade") - - assert "DROP COLUMN active_old_dataset_id" in sql - assert ( - "CREATE INDEX kfs_upgrade_job_active_dataset_uq ON knowledge_fs_upgrade_jobs (tenant_id, old_dataset_id)" in sql - ) - assert "CREATE UNIQUE INDEX" not in sql - - -@pytest.mark.parametrize("step", ["upgrade", "downgrade"]) -def test_postgresql_keeps_its_partial_index(step: str) -> None: - assert _render("postgresql", step) == "" diff --git a/api/tests/unit_tests/migrations/versions/test_2026_10_21_1400-a8c2e6f4b901_add_upgrade_availability_checkpoint.py b/api/tests/unit_tests/migrations/versions/test_2026_10_21_1400-a8c2e6f4b901_add_upgrade_availability_checkpoint.py deleted file mode 100644 index 43f583c0783..00000000000 --- a/api/tests/unit_tests/migrations/versions/test_2026_10_21_1400-a8c2e6f4b901_add_upgrade_availability_checkpoint.py +++ /dev/null @@ -1,70 +0,0 @@ -from __future__ import annotations - -import importlib.util -from io import StringIO -from pathlib import Path -from types import ModuleType -from unittest.mock import patch - -import pytest -import sqlalchemy as sa -from alembic.migration import MigrationContext -from alembic.operations import Operations - -_PATH = ( - Path(__file__).resolve().parents[4] - / "migrations/versions/2026_10_21_1400-a8c2e6f4b901_add_upgrade_availability_checkpoint.py" -) - - -def _migration() -> ModuleType: - spec = importlib.util.spec_from_file_location("upgrade_availability_checkpoint", _PATH) - assert spec is not None - assert spec.loader is not None - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - return module - - -def test_migration_backfills_only_completed_reconciliations_and_downgrades() -> None: - engine = sa.create_engine("sqlite:///:memory:") - migration = _migration() - with engine.begin() as connection: - connection.execute( - sa.text("CREATE TABLE knowledge_fs_upgrade_documents (id TEXT PRIMARY KEY, state_reconciled_at DATETIME)") - ) - connection.execute( - sa.text( - "INSERT INTO knowledge_fs_upgrade_documents VALUES " - "('complete', '2026-10-21 12:00:00'), ('pending', NULL)" - ) - ) - with patch.object(migration, "op", Operations(MigrationContext.configure(connection))): - migration.upgrade() - rows = connection.execute( - sa.text( - "SELECT id, state_reconciled_at, availability_reconciled_at " - "FROM knowledge_fs_upgrade_documents ORDER BY id" - ) - ).all() - assert rows == [("complete", "2026-10-21 12:00:00", "2026-10-21 12:00:00"), ("pending", None, None)] - migration.downgrade() - assert [column["name"] for column in sa.inspect(connection).get_columns("knowledge_fs_upgrade_documents")] == [ - "id", - "state_reconciled_at", - ] - engine.dispose() - - -@pytest.mark.parametrize("dialect", ["postgresql", "mysql"]) -def test_migration_renders_for_supported_databases(dialect: str) -> None: - output = StringIO() - context = MigrationContext.configure(dialect_name=dialect, opts={"as_sql": True, "output_buffer": output}) - migration = _migration() - with patch.object(migration, "op", Operations(context)): - migration.upgrade() - migration.downgrade() - sql = output.getvalue() - assert "ADD COLUMN availability_reconciled_at" in sql - assert "WHERE state_reconciled_at IS NOT NULL" in sql - assert "DROP COLUMN availability_reconciled_at" in sql diff --git a/api/tests/unit_tests/migrations/versions/test_2026_10_21_1500-b9d3f7a5c102_add_upgrade_reconciliation_lease.py b/api/tests/unit_tests/migrations/versions/test_2026_10_21_1500-b9d3f7a5c102_add_upgrade_reconciliation_lease.py deleted file mode 100644 index 8c37b56c17f..00000000000 --- a/api/tests/unit_tests/migrations/versions/test_2026_10_21_1500-b9d3f7a5c102_add_upgrade_reconciliation_lease.py +++ /dev/null @@ -1,60 +0,0 @@ -from __future__ import annotations - -import importlib.util -from io import StringIO -from pathlib import Path -from types import ModuleType -from unittest.mock import patch - -import pytest -import sqlalchemy as sa -from alembic.migration import MigrationContext -from alembic.operations import Operations - -_PATH = ( - Path(__file__).resolve().parents[4] - / "migrations/versions/2026_10_21_1500-b9d3f7a5c102_add_upgrade_reconciliation_lease.py" -) - - -def _migration() -> ModuleType: - spec = importlib.util.spec_from_file_location("upgrade_reconciliation_lease", _PATH) - assert spec is not None - assert spec.loader is not None - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - return module - - -def test_existing_successful_jobs_remain_recoverable_after_migration() -> None: - engine = sa.create_engine("sqlite:///:memory:") - migration = _migration() - with engine.begin() as connection: - connection.execute(sa.text("CREATE TABLE knowledge_fs_upgrade_jobs (id TEXT PRIMARY KEY, status TEXT)")) - connection.execute(sa.text("INSERT INTO knowledge_fs_upgrade_jobs VALUES ('stranded', 'succeeded')")) - with patch.object(migration, "op", Operations(MigrationContext.configure(connection))): - migration.upgrade() - assert connection.execute(sa.text("SELECT * FROM knowledge_fs_upgrade_jobs")).one() == ( - "stranded", - "succeeded", - None, - None, - None, - ) - migration.downgrade() - assert connection.execute(sa.text("SELECT * FROM knowledge_fs_upgrade_jobs")).one() == ("stranded", "succeeded") - engine.dispose() - - -@pytest.mark.parametrize("dialect", ["postgresql", "mysql"]) -def test_reconciliation_lease_migration_renders_for_supported_databases(dialect: str) -> None: - output = StringIO() - context = MigrationContext.configure(dialect_name=dialect, opts={"as_sql": True, "output_buffer": output}) - migration = _migration() - with patch.object(migration, "op", Operations(context)): - migration.upgrade() - migration.downgrade() - sql = output.getvalue() - for column in ("reconciliation_task_id", "reconciliation_started_at", "reconciliation_lease_expires_at"): - assert f"ADD COLUMN {column}" in sql - assert f"DROP COLUMN {column}" in sql diff --git a/api/tests/unit_tests/models/test_agent.py b/api/tests/unit_tests/models/test_agent.py index d061c8d1874..6dff7dcb7bf 100644 --- a/api/tests/unit_tests/models/test_agent.py +++ b/api/tests/unit_tests/models/test_agent.py @@ -179,10 +179,11 @@ def test_agent_table_uses_db_unique_constraint_for_active_roster_names(): assert indexes["agent_tenant_scope_idx"] == ("tenant_id", "scope") -def test_active_roster_agent_name_unique_constraint_allows_archived_and_workflow_only_duplicates(): - engine = sa.create_engine("sqlite:///:memory:") +def test_active_roster_agent_name_unique_constraint_allows_archived_and_workflow_only_duplicates( + sqlite_engine: sa.Engine, +): + engine = sqlite_engine agent_table = cast(sa.Table, Agent.__table__) - agent_table.create(engine) insert_agent = agent_table.insert() with engine.begin() as conn: diff --git a/api/tests/unit_tests/models/test_human_input_im_identity.py b/api/tests/unit_tests/models/test_human_input_im_identity.py new file mode 100644 index 00000000000..27ea664fd0e --- /dev/null +++ b/api/tests/unit_tests/models/test_human_input_im_identity.py @@ -0,0 +1,34 @@ +import pytest +from pydantic import ValidationError +from sqlalchemy.dialects import sqlite + +from models.human_input_v2 import IMIdentityRawPayload +from models.types import FrozenPydanticModelColumn + + +def test_im_identity_raw_payload_accepts_only_a_strict_json_object_and_is_frozen() -> None: + raw_payload = IMIdentityRawPayload({"provider_user": {"id": "provider-user-1"}}) + + assert raw_payload.root == {"provider_user": {"id": "provider-user-1"}} + assert raw_payload.model_config["frozen"] is True + assert raw_payload.model_config["strict"] is True + assert raw_payload.model_config["validate_default"] is True + with pytest.raises(ValidationError): + IMIdentityRawPayload.model_validate([("provider_user", {"id": "provider-user-1"})]) + with pytest.raises(ValidationError): + raw_payload.root = {} + + +def test_im_identity_raw_payload_round_trips_through_its_persistence_column() -> None: + raw_payload = IMIdentityRawPayload( + { + "provider_user": {"id": "provider-user-1"}, + "roles": ["reviewer"], + } + ) + column_type = FrozenPydanticModelColumn(IMIdentityRawPayload) + + serialized = column_type.process_bind_param(raw_payload, sqlite.dialect()) + restored = column_type.process_result_value(serialized, sqlite.dialect()) + + assert restored == raw_payload diff --git a/api/tests/unit_tests/models/test_model.py b/api/tests/unit_tests/models/test_model.py index ec2ebcc1fa5..fb7180a41ee 100644 --- a/api/tests/unit_tests/models/test_model.py +++ b/api/tests/unit_tests/models/test_model.py @@ -20,7 +20,6 @@ from core.ops.entities.trace_entity import MessageTraceInfo from core.ops.ops_trace_manager import OpsTraceManager, TaskData from core.workflow.file_reference import build_file_reference from graphon.file import FILE_MODEL_IDENTITY, FileTransferMethod -from models import model as model_module from models.account import Account, Tenant from models.base import Base, TypeBase from models.dataset import DatasetCollectionBinding @@ -2260,8 +2259,7 @@ class TestModelCases: Patch file_helpers.get_signed_file_url to a deterministic stub. """ monkeypatch.setattr( - model_module.file_helpers, - "get_signed_file_url", + "graphon.file.helpers.get_signed_file_url", lambda file_id: f"https://signed.example/{file_id}", ) diff --git a/api/tests/unit_tests/models/test_pydantic_model_json.py b/api/tests/unit_tests/models/test_pydantic_model_json.py new file mode 100644 index 00000000000..8e0003188ee --- /dev/null +++ b/api/tests/unit_tests/models/test_pydantic_model_json.py @@ -0,0 +1,288 @@ +from collections.abc import Callable, Iterator +from enum import StrEnum +from typing import Annotated, Literal + +import pytest +import sqlalchemy as sa +from pydantic import BaseModel, ConfigDict, Field, TypeAdapter, ValidationError +from pydantic_core import PydanticSerializationError +from sqlalchemy import Engine, select +from sqlalchemy.dialects import mysql, postgresql, sqlite +from sqlalchemy.dialects.mysql import LONGTEXT +from sqlalchemy.orm import DeclarativeBase, Mapped, Session, mapped_column +from sqlalchemy.sql.sqltypes import TEXT + +from models.types import FrozenPydanticModelColumn + + +class _FrozenStrictPayload(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True, strict=True, validate_default=True) + + name: str + attributes: dict[str, str] = Field(default_factory=dict) + + +class _NamePayload(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True, strict=True, validate_default=True) + + name: str + + +class _OtherFrozenStrictPayload(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True, strict=True, validate_default=True) + + name: str + + +class _MutableStrictPayload(BaseModel): + model_config = ConfigDict(extra="forbid", strict=True, validate_default=True) + + name: str + + +class _FrozenNonStrictPayload(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True, validate_default=True) + + name: str + + +class _DeliveryChannel(StrEnum): + EMAIL = "email" + + +class _FrozenStrictEnumPayload(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True, strict=True, validate_default=True) + + channel: _DeliveryChannel + + +class _CatPayload(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True, strict=True, validate_default=True) + + kind: Literal["cat"] = "cat" + lives: int + + +class _DogPayload(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True, strict=True, validate_default=True) + + kind: Literal["dog"] = "dog" + breed: str + + +type _PetPayload = Annotated[_CatPayload | _DogPayload, Field(discriminator="kind")] + +_PAYLOAD_TYPE = FrozenPydanticModelColumn( + _FrozenStrictPayload, +) +_NAME_TYPE = FrozenPydanticModelColumn(_NamePayload) +_PET_TYPE = FrozenPydanticModelColumn( + TypeAdapter(_PetPayload), + model_types=(_CatPayload, _DogPayload), +) + + +class _Base(DeclarativeBase): + pass + + +class _PydanticJSONRecord(_Base): + __tablename__ = "unit_pydantic_json_records" + + id: Mapped[int] = mapped_column(sa.Integer, primary_key=True, autoincrement=True) + payload: Mapped[_FrozenStrictPayload | None] = mapped_column(_PAYLOAD_TYPE, nullable=True) + pet: Mapped[_PetPayload | None] = mapped_column(_PET_TYPE, nullable=True) + + +@pytest.fixture +def sqlite_pydantic_json_engine() -> Iterator[Engine]: + engine = sa.create_engine("sqlite:///:memory:") + _Base.metadata.create_all(engine) + try: + yield engine + finally: + _Base.metadata.drop_all(engine) + engine.dispose() + + +def test_frozen_pydantic_model_column_accepts_a_frozen_strict_model() -> None: + column_type = FrozenPydanticModelColumn(_FrozenStrictPayload) + + assert column_type.model_types == (_FrozenStrictPayload,) + + +def test_concrete_model_rejects_explicit_model_types() -> None: + # Exercise invalid arguments from an untyped runtime caller. + construct: Callable[..., FrozenPydanticModelColumn[_FrozenStrictPayload]] = FrozenPydanticModelColumn + with pytest.raises(TypeError, match="model_types must not be provided for a concrete model"): + construct( + _FrozenStrictPayload, + model_types=(_FrozenStrictPayload,), + ) + + +def test_type_adapter_requires_explicit_model_types() -> None: + # Exercise invalid arguments from an untyped runtime caller. + construct: Callable[..., FrozenPydanticModelColumn[_CatPayload | _DogPayload]] = FrozenPydanticModelColumn + with pytest.raises(TypeError, match="model_types is required when schema is a TypeAdapter"): + construct(TypeAdapter(_PetPayload)) + + +def test_constructor_rejects_a_model_that_is_not_frozen() -> None: + with pytest.raises(TypeError, match=r"_MutableStrictPayload.*frozen=True"): + FrozenPydanticModelColumn(_MutableStrictPayload) + + +def test_constructor_rejects_a_model_that_is_not_strict() -> None: + with pytest.raises(TypeError, match=r"_FrozenNonStrictPayload.*strict=True"): + FrozenPydanticModelColumn(_FrozenNonStrictPayload) + + +def test_bind_rejects_a_bare_dict() -> None: + with pytest.raises(TypeError, match=r"value.*_FrozenStrictPayload"): + _PAYLOAD_TYPE.process_bind_param({"name": "bare-dict"}, sqlite.dialect()) + + +def test_bind_rejects_a_json_string() -> None: + with pytest.raises(TypeError, match=r"value.*_FrozenStrictPayload"): + _PAYLOAD_TYPE.process_bind_param('{"name":"json-string"}', sqlite.dialect()) + + +def test_bind_rejects_an_unlisted_frozen_model() -> None: + with pytest.raises(TypeError, match=r"value.*_FrozenStrictPayload"): + _PAYLOAD_TYPE.process_bind_param(_OtherFrozenStrictPayload(name="other"), sqlite.dialect()) + + +def test_bind_returns_model_dump_json_output() -> None: + assert ( + _PAYLOAD_TYPE.process_bind_param( + _FrozenStrictPayload(name="serialized", attributes={"source": "model_dump_json"}), + sqlite.dialect(), + ) + == '{"name":"serialized","attributes":{"source":"model_dump_json"}}' + ) + + +def test_bind_processor_does_not_double_encode_model_json() -> None: + bind_processor = _NAME_TYPE.bind_processor(sqlite.dialect()) + + assert bind_processor is not None + assert bind_processor(_NamePayload(name="Alice")) == '{"name":"Alice"}' + + +def test_bind_treats_serialization_warnings_as_errors() -> None: + invalid_model = _FrozenStrictPayload.model_construct(name=42, attributes={}) + + with pytest.raises(PydanticSerializationError): + _PAYLOAD_TYPE.process_bind_param(invalid_model, sqlite.dialect()) + + +def test_validation_errors_are_not_swallowed() -> None: + with pytest.raises(ValidationError): + _PAYLOAD_TYPE.process_result_value('{"name":42}', sqlite.dialect()) + + +def test_result_restores_str_enum_from_strict_json_validation() -> None: + column_type = FrozenPydanticModelColumn(_FrozenStrictEnumPayload) + + restored = column_type.process_result_value('{"channel":"email"}', sqlite.dialect()) + + assert restored == _FrozenStrictEnumPayload(channel=_DeliveryChannel.EMAIL) + assert restored is not None + assert restored.channel is _DeliveryChannel.EMAIL + + +def test_uses_text_compatible_dialect_types() -> None: + assert isinstance(_NAME_TYPE.load_dialect_impl(postgresql.dialect()), TEXT) + assert isinstance(_NAME_TYPE.load_dialect_impl(sqlite.dialect()), TEXT) + assert isinstance(_NAME_TYPE.load_dialect_impl(mysql.dialect()), LONGTEXT) + + +def test_sqlite_uses_a_text_column(sqlite_pydantic_json_engine: Engine) -> None: + columns = sa.inspect(sqlite_pydantic_json_engine).get_columns(_PydanticJSONRecord.__tablename__) + payload_column = next(column for column in columns if column["name"] == "payload") + + assert isinstance(payload_column["type"], TEXT) + + +def test_sqlite_round_trips_a_concrete_model_and_discriminated_union( + sqlite_pydantic_json_engine: Engine, +) -> None: + with Session(sqlite_pydantic_json_engine) as session: + record = _PydanticJSONRecord( + payload=_FrozenStrictPayload(name="stored", attributes={"source": "sqlite"}), + pet=_CatPayload(lives=9), + ) + session.add(record) + session.commit() + record_id = record.id + + with Session(sqlite_pydantic_json_engine) as session: + restored = session.scalar(select(_PydanticJSONRecord).where(_PydanticJSONRecord.id == record_id)) + + assert restored is not None + assert restored.payload == _FrozenStrictPayload(name="stored", attributes={"source": "sqlite"}) + assert restored.pet == _CatPayload(lives=9) + assert isinstance(restored.pet, _CatPayload) + + +def test_sqlite_round_trips_nullable_values(sqlite_pydantic_json_engine: Engine) -> None: + with Session(sqlite_pydantic_json_engine) as session: + record = _PydanticJSONRecord(payload=None, pet=None) + session.add(record) + session.commit() + record_id = record.id + + with Session(sqlite_pydantic_json_engine) as session: + restored = session.scalar(select(_PydanticJSONRecord).where(_PydanticJSONRecord.id == record_id)) + + assert restored is not None + assert restored.payload is None + assert restored.pet is None + + +def test_replacing_the_whole_model_marks_the_attribute_dirty_and_persists( + sqlite_pydantic_json_engine: Engine, +) -> None: + with Session(sqlite_pydantic_json_engine) as session: + record = _PydanticJSONRecord(payload=_FrozenStrictPayload(name="before"), pet=None) + session.add(record) + session.commit() + record_id = record.id + + record.payload = _FrozenStrictPayload(name="after") + + assert session.is_modified(record) + session.commit() + + with Session(sqlite_pydantic_json_engine) as session: + restored = session.scalar(select(_PydanticJSONRecord).where(_PydanticJSONRecord.id == record_id)) + + assert restored is not None + assert restored.payload == _FrozenStrictPayload(name="after") + + +def test_frozen_models_remain_shallow_and_nested_mutation_is_not_tracked( + sqlite_pydantic_json_engine: Engine, +) -> None: + with Session(sqlite_pydantic_json_engine) as session: + record = _PydanticJSONRecord( + payload=_FrozenStrictPayload(name="stored", attributes={"key": "original"}), + pet=None, + ) + session.add(record) + session.commit() + record_id = record.id + + assert record.payload is not None + record.payload.attributes["key"] = "mutated" + + assert record.payload.attributes == {"key": "mutated"} + assert not session.is_modified(record, include_collections=True) + session.commit() + + with Session(sqlite_pydantic_json_engine) as session: + restored = session.scalar(select(_PydanticJSONRecord).where(_PydanticJSONRecord.id == record_id)) + + assert restored is not None + assert restored.payload == _FrozenStrictPayload(name="stored", attributes={"key": "original"}) diff --git a/api/tests/unit_tests/models/test_types.py b/api/tests/unit_tests/models/test_types.py index 6b3c3742a29..a47f5127681 100644 --- a/api/tests/unit_tests/models/test_types.py +++ b/api/tests/unit_tests/models/test_types.py @@ -18,6 +18,16 @@ class NotPydanticModel: pass +def test_json_model_column_is_deprecated() -> None: + message = ( + "JSONModelColumn is a legacy coercing TEXT-backed type. " + "Use FrozenPydanticModelColumn for new model-only persistence." + ) + + with pytest.warns(DeprecationWarning, match=message): + JSONModelColumn(JsonColumnSample) + + def test_json_model_column_serializes_supported_input_shapes() -> None: column = JSONModelColumn(JsonColumnSample) dialect = sqlite.dialect() diff --git a/api/tests/unit_tests/repositories/human_input_v2/email_channel/test_repository.py b/api/tests/unit_tests/repositories/human_input_v2/email_channel/test_repository.py new file mode 100644 index 00000000000..3ce3dfd68c7 --- /dev/null +++ b/api/tests/unit_tests/repositories/human_input_v2/email_channel/test_repository.py @@ -0,0 +1,265 @@ +"""Email persistence and credential contracts using caller-owned SQLite Sessions.""" + +import json +from dataclasses import replace +from datetime import datetime + +import pytest +import sqlalchemy as sa +from cryptography.fernet import Fernet +from sqlalchemy import event +from sqlalchemy.engine import Engine +from sqlalchemy.orm import Session, sessionmaker + +from core.helper import encrypter +from core.human_input_v2.shared import AccountId, EmailProviderId, NormalizedEmail, TenantId, WorkspaceScope +from models.account import Tenant +from repositories.human_input_v2.channel_credentials import ChannelEncryptedCredentials +from repositories.human_input_v2.email_channel import ( + CreateEmailConfigurationStatus, + DeleteEmailConfigurationStatus, + EmailChannelConfiguration, + UpdateEmailConfigurationStatus, +) +from repositories.human_input_v2.email_channel.entities import ResendCandidate, ResendCredentials +from repositories.human_input_v2.email_channel.mappers import ( + email_configuration_from_record, + email_configuration_to_record, +) +from repositories.human_input_v2.email_channel.repository import SQLAlchemyEmailChannelRepository +from services.human_input_v2.credential_update import ResendChannelUpdate, ResendCredentialsUpdate, Unchanged +from services.human_input_v2.email_channel_management_service import HumanInputEmailChannelManagementService +from services.human_input_v2.email_credential_codec import load_email_credentials, seal_email_credentials +from services.human_input_v2.errors import ProviderConfigurationUpdatedError + +_NOW = datetime(2026, 7, 28, 8) +_EARLIER = datetime(2026, 7, 28, 7) +_TENANT_ID = TenantId("00000000-0000-0000-0000-000000000001") +_SCOPE = WorkspaceScope(_TENANT_ID) + + +@pytest.fixture(autouse=True) +def credential_cipher(monkeypatch: pytest.MonkeyPatch) -> None: + cipher = Fernet(Fernet.generate_key()) + monkeypatch.setattr(encrypter, "encrypt_token", lambda _tenant, token: cipher.encrypt(token.encode()).decode()) + monkeypatch.setattr(encrypter, "decrypt_token", lambda _tenant, token: cipher.decrypt(token.encode()).decode()) + + +def _candidate(sender_name: str = "Sender") -> ResendCandidate: + return ResendCandidate( + NormalizedEmail("sender@example.com"), sender_name, ResendCredentials(api_key="private-api-key") + ) + + +def _configuration( + configuration_id: str = "00000000-0000-0000-0000-000000000010", + tenant_id: TenantId = _TENANT_ID, +) -> EmailChannelConfiguration: + return EmailChannelConfiguration( + id=EmailProviderId(configuration_id), + tenant_id=tenant_id, + sender_email=_candidate().sender_email, + sender_name=_candidate().sender_name, + encrypted_credentials=seal_email_credentials(tenant_id, _candidate().credentials), + configured_by_account_id=AccountId("00000000-0000-0000-0000-000000000020"), + created_at=_NOW, + updated_at=_NOW, + ) + + +@pytest.fixture +def sessions(sqlite_engine: Engine) -> sessionmaker[Session]: + session_maker = sessionmaker(sqlite_engine, expire_on_commit=False) + with session_maker.begin() as session: + tenant = Tenant(name="Workspace") + tenant.id = str(_TENANT_ID) + session.add(tenant) + return session_maker + + +class ProviderGateway: + def __init__(self) -> None: + self.tests: list[ResendCandidate] = [] + + def send_test(self, candidate: ResendCandidate) -> None: + self.tests.append(candidate) + + +def _service(sessions: sessionmaker[Session], gateway: ProviderGateway) -> HumanInputEmailChannelManagementService: + return HumanInputEmailChannelManagementService(gateway, session_factory=sessions) + + +def _assert_stored_credentials(sessions: sessionmaker[Session], candidate: ResendCandidate) -> None: + with sessions() as session: + stored = session.execute( + sa.text("SELECT encrypted_credentials, sender_email, sender_name FROM human_input_email_providers") + ).one() + envelope = json.loads(stored.encrypted_credentials) + assert candidate.credentials.api_key not in stored.encrypted_credentials + assert str(candidate.sender_email) not in stored.encrypted_credentials + assert set(envelope) == {"version", "ciphertext"} + assert envelope["version"] == 1 + assert stored.sender_email == str(candidate.sender_email) + assert stored.sender_name == candidate.sender_name + assert json.loads(encrypter.decrypt_token(str(_TENANT_ID), envelope["ciphertext"])) == { + "api_key": candidate.credentials.api_key, + } + + +def test_save_encrypts_the_complete_credentials_and_returns_safe_view(sessions: sessionmaker[Session]) -> None: + gateway = ProviderGateway() + service = _service(sessions, gateway) + + view = service.create(_SCOPE, None, _candidate()) + + _assert_stored_credentials(sessions, _candidate()) + assert service.get(_SCOPE, view.id) == view + assert view.sender_email == "sender@example.com" + assert view.sender_name == "Sender" + assert "private-api-key" not in repr(view) + assert gateway.tests == [] + + +@pytest.mark.parametrize("retain_key", [True, False]) +def test_saved_configuration_is_tested_without_persisting_edits_and_updated_with_cas( + sessions: sessionmaker[Session], retain_key: bool +) -> None: + gateway = ProviderGateway() + service = _service(sessions, gateway) + current = service.create(_SCOPE, None, _candidate()) + with sessions() as session: + saved_credentials = session.scalar(sa.text("SELECT encrypted_credentials FROM human_input_email_providers")) + edit = ResendChannelUpdate( + Unchanged.VALUE, + "Edited Sender", + ResendCredentialsUpdate(api_key=Unchanged.VALUE if retain_key else "replacement-api-key"), + ) + + service.test_saved(_SCOPE, current.id, current.revision, edit) + + expected = ResendCandidate( + NormalizedEmail("sender@example.com"), + "Edited Sender", + ResendCredentials(api_key="private-api-key" if retain_key else "replacement-api-key"), + ) + assert gateway.tests == [expected] + with sessions() as session: + stored = session.execute( + sa.text("SELECT encrypted_credentials, config_version FROM human_input_email_providers") + ).one() + assert stored.encrypted_credentials == saved_credentials + assert stored.config_version == 1 + + updated = service.update(_SCOPE, current.id, current.revision, None, edit) + + _assert_stored_credentials(sessions, expected) + assert updated.revision.config_version == 2 + assert service.get_current(_SCOPE) == updated + with pytest.raises(ProviderConfigurationUpdatedError): + service.update(_SCOPE, current.id, current.revision, None, edit) + _assert_stored_credentials(sessions, expected) + + +def test_create_load_conflict_and_cross_tenant_isolation(sessions: sessionmaker[Session]) -> None: + with sessions.begin() as session: + repository = SQLAlchemyEmailChannelRepository(session) + created = repository.create(_configuration()) + conflict = repository.create(_configuration("00000000-0000-0000-0000-000000000011")) + assert created.status is CreateEmailConfigurationStatus.CREATED + assert conflict.status is CreateEmailConfigurationStatus.CONFLICT + with sessions() as session: + repository = SQLAlchemyEmailChannelRepository(session) + assert repository.load(_TENANT_ID) == created.configuration + assert repository.load(TenantId("00000000-0000-0000-0000-000000000099")) is None + + +def test_email_configuration_mapper_round_trip_is_detached() -> None: + configuration = _configuration() + restored = email_configuration_from_record(email_configuration_to_record(configuration)) + assert restored == configuration + assert restored is not configuration + + +def test_update_uses_identity_and_revision_and_advances_numeric_version(sessions: sessionmaker[Session]) -> None: + with sessions.begin() as session: + current = SQLAlchemyEmailChannelRepository(session).create(_configuration()).configuration + assert current is not None + replacement = replace( + current, + sender_name="Updated", + encrypted_credentials=seal_email_credentials(_TENANT_ID, _candidate("Updated").credentials), + ) + with sessions.begin() as session: + result = SQLAlchemyEmailChannelRepository(session).update(replacement, expected=current.snapshot, now=_EARLIER) + with sessions.begin() as session: + repository = SQLAlchemyEmailChannelRepository(session) + stale = repository.update(current, expected=current.snapshot, now=_NOW) + assert result.status is UpdateEmailConfigurationStatus.UPDATED + assert result.configuration is not None + assert result.configuration.config_version == current.config_version + 1 + assert result.configuration.updated_at > current.updated_at + assert stale.status is UpdateEmailConfigurationStatus.STALE + assert repository.load(_TENANT_ID) == result.configuration + assert ( + load_email_credentials(result.configuration.tenant_id, result.configuration.encrypted_credentials) + == _candidate("Updated").credentials + ) + + +def test_delete_recreate_rejects_deleted_identity_snapshot(sessions: sessionmaker[Session]) -> None: + with sessions.begin() as session: + repository = SQLAlchemyEmailChannelRepository(session) + deleted = repository.create(_configuration()).configuration + assert deleted is not None + assert repository.delete(_TENANT_ID, expected=deleted.snapshot).status is DeleteEmailConfigurationStatus.DELETED + session.flush() + recreated = repository.create(_configuration("00000000-0000-0000-0000-000000000011")).configuration + assert recreated is not None + with sessions.begin() as session: + repository = SQLAlchemyEmailChannelRepository(session) + stale = repository.update(deleted, expected=deleted.snapshot, now=_NOW) + assert stale.status is UpdateEmailConfigurationStatus.STALE + assert repository.load(_TENANT_ID) == recreated + + +def test_delete_uses_identity_and_revision_snapshot(sessions: sessionmaker[Session]) -> None: + with sessions.begin() as session: + repository = SQLAlchemyEmailChannelRepository(session) + current = repository.create(_configuration()).configuration + assert current is not None + wrong_identity = replace(current.snapshot, configuration_id=EmailProviderId("email-other")) + stale_identity = repository.delete(_TENANT_ID, expected=wrong_identity) + stale_revision = repository.delete(_TENANT_ID, expected=replace(current.snapshot, config_version=2)) + assert stale_identity.status is DeleteEmailConfigurationStatus.STALE + assert stale_revision.status is DeleteEmailConfigurationStatus.STALE + assert repository.load(_TENANT_ID) == current + + +def test_failed_update_rolls_back_all_changes(sessions: sessionmaker[Session], sqlite_engine: Engine) -> None: + with sessions.begin() as session: + current = SQLAlchemyEmailChannelRepository(session).create(_configuration()).configuration + assert current is not None + + def fail_update( + _connection: object, + _cursor: object, + statement: str, + _parameters: object, + _context: object, + _executemany: object, + ) -> None: + if statement.lstrip().upper().startswith("UPDATE"): + raise RuntimeError("forced write failure") + + event.listen(sqlite_engine, "before_cursor_execute", fail_update) + try: + with pytest.raises(RuntimeError, match="forced write failure"), sessions.begin() as session: + SQLAlchemyEmailChannelRepository(session).update( + replace(current, encrypted_credentials=ChannelEncryptedCredentials(ciphertext="replacement")), + expected=current.snapshot, + now=_NOW, + ) + finally: + event.remove(sqlite_engine, "before_cursor_execute", fail_update) + with sessions() as session: + assert SQLAlchemyEmailChannelRepository(session).load(_TENANT_ID) == current diff --git a/api/tests/unit_tests/repositories/human_input_v2/im_integration/test_im_sync_queries.py b/api/tests/unit_tests/repositories/human_input_v2/im_integration/test_im_sync_queries.py new file mode 100644 index 00000000000..8ba8fe1ad4f --- /dev/null +++ b/api/tests/unit_tests/repositories/human_input_v2/im_integration/test_im_sync_queries.py @@ -0,0 +1,223 @@ +"""SQLite-backed read-query contracts for IM synchronization state.""" + +from __future__ import annotations + +from collections.abc import Generator +from datetime import datetime, timedelta + +import pytest +from sqlalchemy.engine import Engine +from sqlalchemy.orm import Session + +from core.human_input_v2.entities import IMIdentityBindingStatus, IMProvider, IMSyncResultType +from core.human_input_v2.im_integration import IMChannelRevision, IMSyncRun, SyncResultFact +from core.human_input_v2.shared import ContactId, IMIdentityId, IMSyncResultId, IMSyncRunId +from repositories.human_input_v2.channel_credentials import ChannelEncryptedCredentials +from repositories.human_input_v2.im_binding_repository import IMBindingAssignment +from repositories.human_input_v2.im_channel_repository import ( + IMChannel, + IMChannelId, + IMChannelStatus, + WebhookId, +) +from repositories.human_input_v2.im_identity_repository import IMIdentityObservation, OpaqueProviderPayload +from repositories.human_input_v2.im_integration.mappers import sync_result_to_record, sync_run_to_record +from repositories.human_input_v2.im_integration.repository import SQLAlchemyIMControlPlaneRepository +from repositories.human_input_v2.sqlalchemy_im_binding_repository import SQLAlchemyIMBindingRepository +from repositories.human_input_v2.sqlalchemy_im_identity_repository import SQLAlchemyIMIdentityRepository + +_NOW = datetime(2026, 8, 11, 8) +_CHANNEL_ID = IMChannelId("00000000-0000-0000-0000-000000000101") +_OTHER_CHANNEL_ID = IMChannelId("00000000-0000-0000-0000-000000000102") +_RUN_ID = IMSyncRunId("00000000-0000-0000-0000-000000000201") +_LATEST_RUN_ID = IMSyncRunId("00000000-0000-0000-0000-000000000202") +_IDENTITY_ALPHA = IMIdentityId("00000000-0000-0000-0000-000000000301") +_IDENTITY_EMAIL = IMIdentityId("00000000-0000-0000-0000-000000000302") +_IDENTITY_PROVIDER = IMIdentityId("00000000-0000-0000-0000-000000000303") +_SyncQueryContext = tuple[ + SQLAlchemyIMControlPlaneRepository, + SQLAlchemyIMIdentityRepository, + SQLAlchemyIMBindingRepository, +] + + +def _channel(channel_id: IMChannelId = _CHANNEL_ID) -> IMChannel: + return IMChannel( + id=channel_id, + created_at=_NOW, + updated_at=_NOW, + provider=IMProvider.FEISHU, + provider_tenant_id=f"provider-{channel_id}", + encrypted_credentials=ChannelEncryptedCredentials(ciphertext="opaque-ciphertext"), + app_identifier="app-1", + webhook_id=WebhookId("00000000000000000000000000000001"), + config_version=1, + status=IMChannelStatus.CONNECTED, + ) + + +def _run(run_id: IMSyncRunId, created_at: datetime) -> IMSyncRun: + return IMSyncRun.create( + sync_run_id=run_id, + channel_revision=IMChannelRevision(str(_CHANNEL_ID), 1), + provider=IMProvider.FEISHU, + started_by_account_id=None, + now=created_at, + ) + + +def _result( + result_id: str, + result_type: IMSyncResultType, + created_at: datetime, +) -> SyncResultFact: + return SyncResultFact( + id=IMSyncResultId(result_id), + channel_id=IMChannelId(str(_CHANNEL_ID)), + sync_run_id=_LATEST_RUN_ID, + operation_key=f"result:{result_id}", + result_type=result_type, + provider_user_id=None, + display_name=None, + email=None, + normalized_email=None, + contact_id=None, + identity_id=None, + binding_id=None, + removal_reason=None, + reason_code=None, + reason_message=None, + directory_entry_payload=None, + contact_snapshot=None, + identity_snapshot=None, + created_at=created_at, + updated_at=created_at, + ) + + +def _observation(provider_user_id: str, display_name: str | None, email: str | None) -> IMIdentityObservation: + return IMIdentityObservation( + provider_user_id=provider_user_id, + display_name=display_name, + email=email, + raw_payload=OpaqueProviderPayload({}), + sync_run_id=_RUN_ID, + observed_at=_NOW, + ) + + +@pytest.fixture +def sync_query_context( + sqlite_engine: Engine, +) -> Generator[_SyncQueryContext]: + with Session(sqlite_engine, expire_on_commit=False) as session: + identities = SQLAlchemyIMIdentityRepository(session, _CHANNEL_ID) + bindings = SQLAlchemyIMBindingRepository(session, _CHANNEL_ID) + identities.create(_IDENTITY_ALPHA, _observation("provider-alpha", "Alpha Reviewer", "alpha@example.com")) + identities.create(_IDENTITY_EMAIL, _observation("provider-email", "Beta Reviewer", "owner@needle.test")) + identities.create(_IDENTITY_PROVIDER, _observation("needle-provider-user", None, None)) + SQLAlchemyIMIdentityRepository(session, _OTHER_CHANNEL_ID).create( + IMIdentityId("00000000-0000-0000-0000-000000000399"), + _observation("needle-other-channel", "Needle Other", "needle@other.test"), + ) + bindings.create( + IMBindingAssignment( + ContactId("00000000-0000-0000-0000-000000000401"), + _IDENTITY_ALPHA, + _NOW, + ), + bound_by_account_id=None, + ) + session.add_all( + [ + sync_run_to_record(_run(_RUN_ID, _NOW)), + sync_run_to_record(_run(_LATEST_RUN_ID, _NOW + timedelta(hours=1))), + sync_result_to_record( + _result( + "00000000-0000-0000-0000-000000000501", + IMSyncResultType.ADDED, + _NOW + timedelta(minutes=2), + ) + ), + sync_result_to_record( + _result( + "00000000-0000-0000-0000-000000000502", + IMSyncResultType.ADDED, + _NOW + timedelta(minutes=1), + ) + ), + sync_result_to_record(_result("00000000-0000-0000-0000-000000000503", IMSyncResultType.SKIPPED, _NOW)), + ] + ) + session.commit() + yield SQLAlchemyIMControlPlaneRepository(session, _channel()), identities, bindings + + +def test_run_and_latest_run_queries_are_channel_bound_and_deterministic( + sync_query_context: _SyncQueryContext, +) -> None: + repository, _, _ = sync_query_context + + assert repository.load_sync_run(IMSyncRunId("00000000-0000-0000-0000-000000000999")) is None + assert repository.load_sync_run(_RUN_ID) == _run(_RUN_ID, _NOW) + assert repository.load_latest_sync_run() == _run(_LATEST_RUN_ID, _NOW + timedelta(hours=1)) + + +def test_result_query_pages_one_required_bucket_in_stable_order(sync_query_context: _SyncQueryContext) -> None: + repository, _, _ = sync_query_context + + first_page = repository.page_sync_results(_LATEST_RUN_ID, IMSyncResultType.ADDED, page=1, limit=1) + second_page = repository.page_sync_results(_LATEST_RUN_ID, IMSyncResultType.ADDED, page=2, limit=1) + + assert first_page.total == 2 + assert [item.id for item in first_page.items] == [IMSyncResultId("00000000-0000-0000-0000-000000000502")] + assert [item.id for item in second_page.items] == [IMSyncResultId("00000000-0000-0000-0000-000000000501")] + + +@pytest.mark.parametrize(("page", "limit"), [(0, 1), (1, 0), (1, 101)]) +def test_result_query_rejects_invalid_page_boundaries( + sync_query_context: _SyncQueryContext, + page: int, + limit: int, +) -> None: + repository, _, _ = sync_query_context + + with pytest.raises(ValueError, match="page must be positive|limit must be between 1 and 100"): + repository.page_sync_results(_LATEST_RUN_ID, IMSyncResultType.ADDED, page=page, limit=limit) + + +@pytest.mark.parametrize( + ("keyword", "expected_identity_id"), + [ + ("alpha reviewer", _IDENTITY_ALPHA), + ("NEEDLE.TEST", _IDENTITY_EMAIL), + ("needle-provider", _IDENTITY_PROVIDER), + ], +) +def test_identity_search_matches_supported_fields_and_reports_default_binding_status( + sync_query_context: _SyncQueryContext, + keyword: str, + expected_identity_id: IMIdentityId, +) -> None: + repository, identities, bindings = sync_query_context + + page = repository.search_identities(identities, bindings, keyword=keyword, page=1, limit=20) + + assert page.total == 1 + assert [item.id for item in page.items] == [expected_identity_id] + expected_status = ( + IMIdentityBindingStatus.BOUND if expected_identity_id == _IDENTITY_ALPHA else IMIdentityBindingStatus.UNBOUND + ) + assert page.items[0].binding_status is expected_status + assert not hasattr(page.items[0], "raw_payload") + + +def test_identity_search_pages_without_cross_channel_leak(sync_query_context: _SyncQueryContext) -> None: + repository, identities, bindings = sync_query_context + + first_page = repository.search_identities(identities, bindings, keyword=None, page=1, limit=2) + second_page = repository.search_identities(identities, bindings, keyword=" ", page=2, limit=2) + + assert first_page.total == 3 + assert [item.id for item in first_page.items] == [_IDENTITY_ALPHA, _IDENTITY_EMAIL] + assert [item.id for item in second_page.items] == [_IDENTITY_PROVIDER] diff --git a/api/tests/unit_tests/repositories/human_input_v2/im_integration/test_mappers.py b/api/tests/unit_tests/repositories/human_input_v2/im_integration/test_mappers.py new file mode 100644 index 00000000000..25a4f2b0dae --- /dev/null +++ b/api/tests/unit_tests/repositories/human_input_v2/im_integration/test_mappers.py @@ -0,0 +1,180 @@ +"""Bidirectional mapping tests for every IM Control Plane record.""" + +from datetime import datetime + +import pytest + +from core.human_input_v2.entities import ( + IMProvider, + IMSyncRemovalReason, + IMSyncResultType, +) +from core.human_input_v2.im_integration import ( + IMBindingChangeSnapshot, + IMChannelRevision, + IMIdentityChangeSnapshot, + IMReconciliationChange, + IMReconciliationOperation, + IMReconciliationSubjectKind, + IMSyncRun, + OpaqueProviderPayload, + SyncContactSnapshot, + SyncIdentitySnapshot, + SyncResultFact, +) +from core.human_input_v2.im_integration.adapters import ProviderUserId +from core.human_input_v2.shared import ( + AccountId, + ContactId, + IMBindingId, + IMIdentityId, + IMReconciliationChangeId, + IMSyncResultId, + IMSyncRunId, + NormalizedEmail, +) +from repositories.human_input_v2.im_channel_repository import IMChannelId +from repositories.human_input_v2.im_integration.mappers import ( + reconciliation_change_from_record, + reconciliation_change_to_record, + sync_result_from_record, + sync_result_to_record, + sync_run_from_record, + sync_run_to_record, +) + +_NOW = datetime(2026, 7, 25, 8) +_CHANNEL_ID = IMChannelId("channel-1") + + +def _run() -> IMSyncRun: + return IMSyncRun.create( + sync_run_id=IMSyncRunId("run-1"), + channel_revision=IMChannelRevision(str(_CHANNEL_ID), 1), + provider=IMProvider.FEISHU, + started_by_account_id=AccountId("account-1"), + now=_NOW, + ) + + +def _result() -> SyncResultFact: + return SyncResultFact( + id=IMSyncResultId("result-1"), + channel_id=_CHANNEL_ID, + sync_run_id=IMSyncRunId("run-1"), + operation_key="result:removed:1", + result_type=IMSyncResultType.REMOVED, + provider_user_id="provider-user-1", + display_name="Reviewer", + email="reviewer@example.com", + normalized_email=NormalizedEmail("reviewer@example.com"), + contact_id=ContactId("contact-1"), + identity_id=IMIdentityId("identity-1"), + binding_id=IMBindingId("binding-1"), + removal_reason=IMSyncRemovalReason.NOT_PRESENT_IN_DIRECTORY, + reason_code="removed", + reason_message="No longer present", + directory_entry_payload=OpaqueProviderPayload.from_mapping({"provider": "value"}), + contact_snapshot=SyncContactSnapshot( + contact_id=ContactId("contact-1"), + name="Reviewer", + email="reviewer@example.com", + avatar_file_id=None, + created_at=_NOW, + ), + identity_snapshot=SyncIdentitySnapshot( + identity_id=IMIdentityId("identity-1"), + provider=IMProvider.FEISHU, + provider_user_id="provider-user-1", + display_name="Reviewer", + email="reviewer@example.com", + ), + created_at=_NOW, + updated_at=_NOW, + ) + + +def test_sync_run_mapping_round_trips_captured_revision_and_counts() -> None: + run = _run() + + assert sync_run_from_record(sync_run_to_record(run)) == run + + +def test_sync_result_mapping_round_trips_all_structured_snapshots() -> None: + result = _result() + + record = sync_result_to_record(result) + + assert record.directory_entry_payload is not None + assert record.directory_entry_payload.root == {"provider": "value"} + assert sync_result_from_record(record) == result + + +@pytest.mark.parametrize( + "change", + [ + IMReconciliationChange( + id=IMReconciliationChangeId("change-identity"), + channel_id=_CHANNEL_ID, + sync_run_id=IMSyncRunId("run-1"), + operation_key="identity:update:1", + subject_kind=IMReconciliationSubjectKind.IDENTITY, + operation=IMReconciliationOperation.UPDATE, + reason_code="profile_changed", + identity_id=IMIdentityId("identity-1"), + binding_id=None, + contact_id=None, + before=IMIdentityChangeSnapshot( + identity_id=IMIdentityId("identity-1"), + provider=IMProvider.FEISHU, + provider_user_id=ProviderUserId("provider-user-1"), + display_name="Before", + email="reviewer@example.com", + normalized_email=NormalizedEmail("reviewer@example.com"), + last_seen_sync_run_id=None, + ), + after=IMIdentityChangeSnapshot( + identity_id=IMIdentityId("identity-1"), + provider=IMProvider.FEISHU, + provider_user_id=ProviderUserId("provider-user-1"), + display_name="After", + email="reviewer@example.com", + normalized_email=NormalizedEmail("reviewer@example.com"), + last_seen_sync_run_id=IMSyncRunId("run-1"), + ), + committed_at=_NOW, + ), + IMReconciliationChange( + id=IMReconciliationChangeId("change-binding"), + channel_id=_CHANNEL_ID, + sync_run_id=IMSyncRunId("run-1"), + operation_key="binding:replace:1", + subject_kind=IMReconciliationSubjectKind.BINDING, + operation=IMReconciliationOperation.REPLACE, + reason_code="binding_replaced", + identity_id=IMIdentityId("identity-2"), + binding_id=IMBindingId("binding-1"), + contact_id=ContactId("contact-1"), + before=IMBindingChangeSnapshot( + binding_id=IMBindingId("binding-1"), + identity_id=IMIdentityId("identity-1"), + contact_id=ContactId("contact-1"), + ), + after=IMBindingChangeSnapshot( + binding_id=IMBindingId("binding-1"), + identity_id=IMIdentityId("identity-2"), + contact_id=ContactId("contact-1"), + ), + committed_at=_NOW, + ), + ], +) +def test_reconciliation_change_mapping_round_trips_without_orm_leak( + change: IMReconciliationChange, +) -> None: + record = reconciliation_change_to_record(change) + + mapped = reconciliation_change_from_record(record) + + assert mapped == change + assert type(mapped) is IMReconciliationChange diff --git a/api/tests/unit_tests/repositories/human_input_v2/im_integration/test_repository.py b/api/tests/unit_tests/repositories/human_input_v2/im_integration/test_repository.py new file mode 100644 index 00000000000..82ada3ffc85 --- /dev/null +++ b/api/tests/unit_tests/repositories/human_input_v2/im_integration/test_repository.py @@ -0,0 +1,90 @@ +"""SQLite-backed historical sync persistence contracts.""" + +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import Engine, func, select +from sqlalchemy.orm import Session + +from core.human_input_v2.entities import IMProvider, IMSyncResultType +from core.human_input_v2.im_integration import IMChannelRevision, IMSyncRun, SyncResultFact +from core.human_input_v2.shared import IMSyncResultId, IMSyncRunId +from models.human_input_v2 import HumanInputIMSyncResult +from repositories.human_input_v2.channel_credentials import ChannelEncryptedCredentials +from repositories.human_input_v2.im_channel_repository import ( + IMChannel, + IMChannelId, + IMChannelStatus, + WebhookId, +) +from repositories.human_input_v2.im_integration.repository import SQLAlchemyIMControlPlaneRepository + +_NOW = datetime(2026, 8, 11, 8) +_CHANNEL_ID = IMChannelId("00000000-0000-0000-0000-000000000101") + + +def _channel() -> IMChannel: + return IMChannel( + id=_CHANNEL_ID, + created_at=_NOW, + updated_at=_NOW, + provider=IMProvider.FEISHU, + provider_tenant_id="provider-tenant-1", + encrypted_credentials=ChannelEncryptedCredentials(ciphertext="opaque-ciphertext"), + app_identifier="app-1", + webhook_id=WebhookId("00000000000000000000000000000001"), + config_version=1, + status=IMChannelStatus.CONNECTED, + ) + + +def _run() -> IMSyncRun: + return IMSyncRun.create( + sync_run_id=IMSyncRunId("00000000-0000-0000-0000-000000000201"), + channel_revision=IMChannelRevision(str(_CHANNEL_ID), 1), + provider=IMProvider.FEISHU, + started_by_account_id=None, + now=_NOW, + ) + + +def _result(result_id: str) -> SyncResultFact: + return SyncResultFact( + id=IMSyncResultId(result_id), + channel_id=IMChannelId(str(_CHANNEL_ID)), + sync_run_id=_run().id, + operation_key=f"diagnostic:{result_id}", + result_type=IMSyncResultType.FAILED, + provider_user_id=None, + display_name=None, + email=None, + normalized_email=None, + contact_id=None, + identity_id=None, + binding_id=None, + removal_reason=None, + reason_code="test_diagnostic", + reason_message="Operator-safe diagnostic", + directory_entry_payload=None, + contact_snapshot=None, + identity_snapshot=None, + created_at=_NOW, + updated_at=_NOW, + ) + + +def test_diagnostic_append_uses_the_supplied_session_transaction(sqlite_engine: Engine) -> None: + with Session(sqlite_engine) as session: + repository = SQLAlchemyIMControlPlaneRepository(session, _channel()) + repository.append_sync_results( + ( + _result("00000000-0000-0000-0000-000000000301"), + _result("00000000-0000-0000-0000-000000000302"), + ) + ) + assert session.scalar(select(func.count(HumanInputIMSyncResult.id))) == 2 + session.rollback() + + with Session(sqlite_engine) as session: + assert session.scalar(select(func.count(HumanInputIMSyncResult.id))) == 0 diff --git a/api/tests/unit_tests/repositories/human_input_v2/im_message_inbox/test_repository.py b/api/tests/unit_tests/repositories/human_input_v2/im_message_inbox/test_repository.py new file mode 100644 index 00000000000..0672c6a364d --- /dev/null +++ b/api/tests/unit_tests/repositories/human_input_v2/im_message_inbox/test_repository.py @@ -0,0 +1,248 @@ +"""SQLite-backed tests for durable IM callback persistence.""" + +from datetime import UTC, datetime + +import pytest +import sqlalchemy as sa +from sqlalchemy import event as sqlalchemy_event +from sqlalchemy.engine import Engine +from sqlalchemy.exc import IntegrityError, OperationalError +from sqlalchemy.orm import Session, sessionmaker + +from core.human_input_v2.entities import IMProvider +from core.human_input_v2.im_integration.adapters import AuthenticatedIMEvent, IMEventIngressKind +from core.human_input_v2.im_message_inbox import ( + AcceptanceKind, + IMInboxRecordId, + InboxEventValidationError, + InboxPersistenceError, +) +from models.human_input_v2 import IMMessageEvent +from repositories.human_input_v2.im_channel_repository import IMChannelId +from repositories.human_input_v2.im_message_inbox.repository import SQLAlchemyIMMessageInboxRepository +from services.human_input_v2.im_message_inbox.storage import IMInboxStorage + +_NOW = datetime(2026, 8, 2, 8, tzinfo=UTC) +_RECEIVED_AT = datetime(2026, 8, 2, 8) +_PAYLOAD = ' {"secret":"must-not-log"}\n' + + +def _event( + event_id: str | None = "event-1", + *, + provider: IMProvider = IMProvider.FEISHU, + tenant_id: str = "tenant-1", + event_type: str | None = "card.action", + ingress_kind: IMEventIngressKind = IMEventIngressKind.WEBHOOK, + payload: str = _PAYLOAD, +) -> AuthenticatedIMEvent: + return AuthenticatedIMEvent( + provider=provider, + provider_tenant_id=tenant_id, + event_id=event_id, + event_type=event_type, + occurred_at=None, + received_at=_RECEIVED_AT, + ingress_kind=ingress_kind, + payload=payload, + ) + + +def _repository(sqlite_engine: Engine) -> tuple[IMInboxStorage, sessionmaker[Session]]: + session_maker = sessionmaker(bind=sqlite_engine, expire_on_commit=False) + return IMInboxStorage(session_maker), session_maker + + +def test_unidentified_callbacks_create_independent_records(sqlite_engine: Engine) -> None: + repository, session_maker = _repository(sqlite_engine) + + first = repository.insert_or_resolve(IMChannelId("channel-1"), _event(None), now=_NOW) + second = repository.insert_or_resolve(IMChannelId("channel-1"), _event(None), now=_NOW) + + assert first.kind is AcceptanceKind.NEW + assert second.kind is AcceptanceKind.NEW + assert first.record_id != second.record_id + with session_maker() as session: + assert session.scalar(sa.select(sa.func.count(IMMessageEvent.id))) == 2 + + +def test_repository_callback_write_can_be_rolled_back_by_its_caller(sqlite_engine: Engine) -> None: + _, sessions = _repository(sqlite_engine) + with sessions() as session: + SQLAlchemyIMMessageInboxRepository(session).insert_or_resolve(IMChannelId("channel-1"), _event(), now=_NOW) + session.rollback() + with sessions() as session: + assert session.scalar(sa.select(sa.func.count(IMMessageEvent.id))) == 0 + + +@pytest.mark.parametrize("blank_event_id", ["", " \t\n", " " * 129]) +def test_blank_event_id_is_persisted_as_unidentified(sqlite_engine: Engine, blank_event_id: str) -> None: + repository, session_maker = _repository(sqlite_engine) + + repository.insert_or_resolve(IMChannelId("channel-1"), _event(blank_event_id), now=_NOW) + + with session_maker() as session: + stored = session.scalar(sa.select(IMMessageEvent)) + assert stored is not None + assert stored.event_id is None + + +@pytest.mark.parametrize("duplicate_tenant_id", ["tenant-1", "tenant-2"]) +def test_identified_duplicate_resolves_same_record_without_mutating_callback_facts( + sqlite_engine: Engine, duplicate_tenant_id: str +) -> None: + repository, session_maker = _repository(sqlite_engine) + first_event = _event(ingress_kind=IMEventIngressKind.WEBHOOK, payload='{"source":"webhook"}') + duplicate_event = _event( + tenant_id=duplicate_tenant_id, + ingress_kind=IMEventIngressKind.STREAM, + payload='{"source":"stream"}', + ) + + first = repository.insert_or_resolve(IMChannelId("channel-1"), first_event, now=_NOW) + duplicate = repository.insert_or_resolve(IMChannelId("channel-2"), duplicate_event, now=_NOW) + + assert first.kind is AcceptanceKind.NEW + assert duplicate.kind is AcceptanceKind.DUPLICATE + assert duplicate.record_id == first.record_id + with session_maker() as session: + stored = session.get_one(IMMessageEvent, str(first.record_id)) + assert stored.channel_id == "channel-1" + assert stored.tenant_id == first_event.provider_tenant_id + assert stored.ingress_kind is IMEventIngressKind.WEBHOOK + assert stored.payload == first_event.payload + + +def test_same_event_id_from_different_providers_creates_independent_records(sqlite_engine: Engine) -> None: + repository, _ = _repository(sqlite_engine) + + first = repository.insert_or_resolve(IMChannelId("channel-1"), _event(), now=_NOW) + second = repository.insert_or_resolve(IMChannelId("channel-2"), _event(provider=IMProvider.SLACK), now=_NOW) + + assert first.kind is AcceptanceKind.NEW + assert second.kind is AcceptanceKind.NEW + assert first.record_id != second.record_id + + +def test_database_rejects_same_provider_event_across_tenants(sqlite_engine: Engine) -> None: + repository, sessions = _repository(sqlite_engine) + accepted = repository.insert_or_resolve(IMChannelId("channel-1"), _event(), now=_NOW) + + with sessions() as session: + session.add( + IMMessageEvent( + channel_id="channel-2", + provider=IMProvider.FEISHU, + tenant_id="tenant-2", + event_id="event-1", + received_at=_RECEIVED_AT, + ingress_kind=IMEventIngressKind.WEBHOOK, + payload="{}", + ) + ) + with pytest.raises(IntegrityError): + session.flush() + + assert repository.get(accepted.record_id) is not None + with sessions() as session: + assert session.scalar(sa.select(sa.func.count(IMMessageEvent.id))) == 1 + + +def test_load_reconstructs_callback_facts(sqlite_engine: Engine) -> None: + repository, _ = _repository(sqlite_engine) + event = _event() + accepted = repository.insert_or_resolve(IMChannelId("channel-1"), event, now=_NOW) + + record = repository.get(accepted.record_id) + + assert record is not None + assert record.record_id == accepted.record_id + assert record.channel_id == IMChannelId("channel-1") + assert record.event == event + assert record.processed_at is None + + +def test_mark_processed_records_callback_fact_and_is_idempotent(sqlite_engine: Engine) -> None: + repository, session_maker = _repository(sqlite_engine) + accepted = repository.insert_or_resolve(IMChannelId("channel-1"), _event(), now=_NOW) + processed_at = _NOW.replace(minute=1) + + repository.mark_processed(accepted.record_id, processed_at=processed_at) + repository.mark_processed(accepted.record_id, processed_at=_NOW.replace(minute=2)) + + record = repository.get(accepted.record_id) + assert record is not None + assert record.processed_at == datetime(2026, 8, 2, 8, 1) + with session_maker() as session: + stored = session.get_one(IMMessageEvent, str(accepted.record_id)) + assert stored.processed_at == datetime(2026, 8, 2, 8, 1) + + +def test_mark_processed_rejects_missing_record(sqlite_engine: Engine) -> None: + repository, _ = _repository(sqlite_engine) + + with pytest.raises(InboxPersistenceError, match="does not exist"): + repository.mark_processed(IMInboxRecordId("missing"), processed_at=_NOW) + + +@pytest.mark.parametrize( + ("tenant_id", "event_id", "event_type", "field_name"), + [ + ("t" * 129, "event-1", "card.action", "provider tenant id"), + ("tenant-1", "e" * 129, "card.action", "provider event id"), + ("tenant-1", "event-1", "y" * 129, "provider event type"), + ], +) +def test_repository_rejects_oversized_callback_metadata( + sqlite_engine: Engine, + tenant_id: str, + event_id: str, + event_type: str, + field_name: str, +) -> None: + repository, session_maker = _repository(sqlite_engine) + + with pytest.raises(InboxEventValidationError, match=field_name): + repository.insert_or_resolve( + IMChannelId("channel-1"), + _event(event_id, tenant_id=tenant_id, event_type=event_type), + now=_NOW, + ) + + with session_maker() as session: + assert session.scalar(sa.select(sa.func.count(IMMessageEvent.id))) == 0 + + +def test_transaction_commit_failure_rolls_back_callback(sqlite_engine: Engine) -> None: + repository, session_maker = _repository(sqlite_engine) + + def fail_commit(_connection: sa.Connection) -> None: + raise OperationalError("COMMIT", {}, RuntimeError("credential must-not-escape")) + + sqlalchemy_event.listen(sqlite_engine, "commit", fail_commit) + try: + with pytest.raises(InboxPersistenceError) as error: + repository.insert_or_resolve(IMChannelId("channel-1"), _event(), now=_NOW) + finally: + sqlalchemy_event.remove(sqlite_engine, "commit", fail_commit) + + assert "must-not-escape" not in str(error.value) + with session_maker() as session: + assert session.scalar(sa.select(sa.func.count(IMMessageEvent.id))) == 0 + + +def test_request_ip_belongs_to_each_insert(sqlite_engine: Engine) -> None: + storage, _ = _repository(sqlite_engine) + first = storage.insert_or_resolve( + IMChannelId("channel-1"), _event("event-1"), now=_NOW, request_ip_address="192.0.2.20" + ) + without_ip = storage.insert_or_resolve(IMChannelId("channel-1"), _event("event-2"), now=_NOW) + third = storage.insert_or_resolve( + IMChannelId("channel-1"), _event("event-3"), now=_NOW, request_ip_address="192.0.2.21" + ) + storage.mark_processed(first.record_id, processed_at=_NOW) + + for accepted, expected_ip in ((first, "192.0.2.20"), (without_ip, None), (third, "192.0.2.21")): + record = storage.get(accepted.record_id) + assert record is not None + assert record.request_ip_address == expected_ip diff --git a/api/tests/unit_tests/repositories/human_input_v2/test_contact_contracts.py b/api/tests/unit_tests/repositories/human_input_v2/test_contact_contracts.py new file mode 100644 index 00000000000..a8821725953 --- /dev/null +++ b/api/tests/unit_tests/repositories/human_input_v2/test_contact_contracts.py @@ -0,0 +1,97 @@ +"""Domain contracts for current Human Input v2 Contact values.""" + +from dataclasses import FrozenInstanceError, fields +from datetime import datetime + +import pytest + +from core.human_input_v2.shared import ContactId +from repositories.human_input_v2.contact import ( + CandidateId, + Contact, + ContactQuery, + ContactType, + ExternalContact, + OrganizationCandidate, + Page, +) + +_NOW = datetime(2026, 8, 30, 8) +_CONTACT_ID = ContactId("00000000-0000-0000-0000-000000000001") + + +def test_contact_type_has_only_current_contact_classifications() -> None: + assert [(contact_type.name, contact_type.value) for contact_type in ContactType] == [ + ("WORKSPACE", "workspace"), + ("PLATFORM", "platform"), + ("EXTERNAL", "external"), + ] + + +def test_candidate_id_is_the_contact_id_type_and_keeps_the_candidate_identity() -> None: + candidate_id = CandidateId(_CONTACT_ID) + candidate = OrganizationCandidate( + id=candidate_id, + name="Reviewer", + email="reviewer@example.com", + avatar_file_id=None, + created_at=_NOW, + ) + + assert CandidateId is ContactId + assert candidate.id is candidate_id + assert ContactId(candidate.id) == _CONTACT_ID + + +def test_contact_values_are_frozen_slotted_values_with_stable_shapes() -> None: + contact = Contact( + id=_CONTACT_ID, + type=ContactType.WORKSPACE, + name="Reviewer", + email="Reviewer@Example.com", + avatar_file_id="avatar-1", + created_at=_NOW, + ) + external = ExternalContact( + id=_CONTACT_ID, + name="External Reviewer", + email="External@Example.com", + avatar_file_id=None, + created_at=_NOW, + ) + query = ContactQuery(keyword="reviewer", contact_type=ContactType.EXTERNAL) + page = Page(items=(contact,), page=2, limit=10) + + assert tuple(field.name for field in fields(Contact)) == ( + "id", + "type", + "name", + "email", + "avatar_file_id", + "created_at", + ) + assert tuple(field.name for field in fields(ExternalContact)) == ( + "id", + "name", + "email", + "avatar_file_id", + "created_at", + ) + assert tuple(field.name for field in fields(ContactQuery)) == ("keyword", "contact_type") + assert tuple(field.name for field in fields(Page)) == ("items", "page", "limit") + assert query == ContactQuery(keyword="reviewer", contact_type=ContactType.EXTERNAL) + assert page == Page(items=(contact,), page=2, limit=10) + assert isinstance(page.items, tuple) + assert not hasattr(contact, "__dict__") + assert not hasattr(external, "__dict__") + assert not hasattr(query, "__dict__") + assert not hasattr(page, "__dict__") + + for value, attribute, replacement in ( + (contact, "name", "Changed"), + (external, "email", "changed@example.com"), + (query, "keyword", "changed"), + (page, "page", 3), + ): + with pytest.raises(FrozenInstanceError): + setattr(value, attribute, replacement) diff --git a/api/tests/unit_tests/repositories/human_input_v2/test_contact_repository.py b/api/tests/unit_tests/repositories/human_input_v2/test_contact_repository.py new file mode 100644 index 00000000000..c85bf578d66 --- /dev/null +++ b/api/tests/unit_tests/repositories/human_input_v2/test_contact_repository.py @@ -0,0 +1,614 @@ +"""SQLite contracts for the current Contact repository.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime, timedelta + +import pytest +from sqlalchemy import Engine, event +from sqlalchemy.orm import Session, sessionmaker + +from core.human_input_v2.shared import AccountId, ContactId, TenantId +from models.account import Account, AccountStatus, Tenant, TenantAccountJoin, TenantAccountRole +from models.human_input_v2 import ( + ContactSubjectType, + HumanInputContactIdentity, + HumanInputExternalContactProfile, + HumanInputPlatformContactWorkspaceEntry, +) +from repositories.human_input_v2.contact import ( + ContactError, + ContactErrorCode, + ContactQuery, + ContactType, + ExternalContact, +) +from repositories.human_input_v2.sqlalchemy_contact_repository import ( + SQLAlchemyContactIMBindingRepository, + SQLAlchemyContactRepository, +) + +_NOW = datetime(2026, 8, 30, 8) +_TENANT_A = TenantId("00000000-0000-0000-0000-000000000101") +_TENANT_B = TenantId("00000000-0000-0000-0000-000000000102") +_ADMIN_ACCOUNT = AccountId("00000000-0000-0000-0000-000000000199") +_MISSING_CONTACT = ContactId("00000000-0000-0000-0000-000000000999") + + +@dataclass(frozen=True, slots=True) +class _ContactIds: + workspace_shared: ContactId + platform_beta: ContactId + external_shared: ContactId + workspace_delta: ContactId + platform_echo: ContactId + external_foxtrot: ContactId + inactive: ContactId + unavailable: ContactId + foreign_platform: ContactId + foreign_external: ContactId + + @property + def visible(self) -> tuple[ContactId, ...]: + return ( + self.workspace_shared, + self.platform_beta, + self.external_shared, + self.workspace_delta, + self.platform_echo, + self.external_foxtrot, + ) + + +@dataclass(frozen=True, slots=True) +class _RepositoryContext: + engine: Engine + sessions: sessionmaker[Session] + contact_ids: _ContactIds + candidate_ids: tuple[ContactId, ...] + + +def _tenant(tenant_id: TenantId, name: str) -> Tenant: + tenant = Tenant(name=name) + tenant.id = str(tenant_id) + return tenant + + +def _account( + account_id: AccountId, + *, + name: str, + email: str, + created_at: datetime, + status: AccountStatus = AccountStatus.ACTIVE, +) -> Account: + account = Account(name=name, email=email, status=status) + account.id = str(account_id) + account.created_at = created_at + account.updated_at = created_at + return account + + +def _account_identity(account_id: AccountId, contact_id: ContactId, created_at: datetime) -> HumanInputContactIdentity: + identity = HumanInputContactIdentity( + subject_type=ContactSubjectType.ACCOUNT, + account_id=str(account_id), + ) + identity.id = str(contact_id) + identity.created_at = created_at + identity.updated_at = created_at + return identity + + +def _membership(tenant_id: TenantId, account_id: AccountId) -> TenantAccountJoin: + return TenantAccountJoin( + tenant_id=str(tenant_id), + account_id=str(account_id), + role=TenantAccountRole.NORMAL, + ) + + +def _platform_entry( + tenant_id: TenantId, + contact_id: ContactId, + *, + created_at: datetime, +) -> HumanInputPlatformContactWorkspaceEntry: + entry = HumanInputPlatformContactWorkspaceEntry( + tenant_id=str(tenant_id), + contact_id=str(contact_id), + added_by_account_id=str(_ADMIN_ACCOUNT), + ) + entry.created_at = created_at + entry.updated_at = created_at + return entry + + +def _external_records( + tenant_id: TenantId, + contact_id: ContactId, + *, + name: str, + email: str, + created_at: datetime, +) -> tuple[HumanInputContactIdentity, HumanInputExternalContactProfile]: + identity = HumanInputContactIdentity(subject_type=ContactSubjectType.EXTERNAL) + identity.id = str(contact_id) + identity.created_at = created_at + identity.updated_at = created_at + profile = HumanInputExternalContactProfile( + contact_id=str(contact_id), + tenant_id=str(tenant_id), + name=name, + normalized_name=name.casefold(), + email=email, + normalized_email=email.strip().casefold(), + avatar_file_id=None, + ) + profile.created_at = created_at + profile.updated_at = created_at + return identity, profile + + +@pytest.fixture +def contact_repository_context(sqlite_engine: Engine) -> _RepositoryContext: + ids = _ContactIds( + workspace_shared=ContactId("00000000-0000-0000-0000-000000000301"), + platform_beta=ContactId("00000000-0000-0000-0000-000000000302"), + external_shared=ContactId("00000000-0000-0000-0000-000000000303"), + workspace_delta=ContactId("00000000-0000-0000-0000-000000000304"), + platform_echo=ContactId("00000000-0000-0000-0000-000000000305"), + external_foxtrot=ContactId("00000000-0000-0000-0000-000000000306"), + inactive=ContactId("00000000-0000-0000-0000-000000000307"), + unavailable=ContactId("00000000-0000-0000-0000-000000000308"), + foreign_platform=ContactId("00000000-0000-0000-0000-000000000309"), + foreign_external=ContactId("00000000-0000-0000-0000-000000000310"), + ) + account_specs = ( + ( + AccountId("00000000-0000-0000-0000-000000000201"), + ids.workspace_shared, + "Alpha Shared", + "Shared@Example.com", + AccountStatus.ACTIVE, + ), + ( + AccountId("00000000-0000-0000-0000-000000000202"), + ids.platform_beta, + "Beta Platform", + "beta@example.com", + AccountStatus.ACTIVE, + ), + ( + AccountId("00000000-0000-0000-0000-000000000204"), + ids.workspace_delta, + "Delta Workspace", + "delta@example.com", + AccountStatus.ACTIVE, + ), + ( + AccountId("00000000-0000-0000-0000-000000000205"), + ids.platform_echo, + "Echo Shared", + "echo@example.com", + AccountStatus.ACTIVE, + ), + ( + AccountId("00000000-0000-0000-0000-000000000207"), + ids.inactive, + "Inactive Account", + "inactive@example.com", + AccountStatus.BANNED, + ), + ( + AccountId("00000000-0000-0000-0000-000000000208"), + ids.unavailable, + "Unavailable Candidate", + "unavailable@example.com", + AccountStatus.ACTIVE, + ), + ( + AccountId("00000000-0000-0000-0000-000000000209"), + ids.foreign_platform, + "Foreign Platform", + "foreign-platform@example.com", + AccountStatus.ACTIVE, + ), + ) + account_rows: list[object] = [ + _tenant(_TENANT_A, "Tenant A"), + _tenant(_TENANT_B, "Tenant B"), + _account( + _ADMIN_ACCOUNT, + name="Admin", + email="admin@example.com", + created_at=_NOW - timedelta(minutes=1), + ), + ] + candidate_ids: list[ContactId] = [] + for position, (account_id, contact_id, name, email, status) in enumerate(account_specs, start=1): + created_at = _NOW + timedelta(minutes=position) + account_rows.extend( + ( + _account(account_id, name=name, email=email, status=status, created_at=created_at), + _account_identity(account_id, contact_id, created_at), + ) + ) + if status is AccountStatus.ACTIVE: + candidate_ids.append(contact_id) + + external_shared = _external_records( + _TENANT_A, + ids.external_shared, + name="Charlie Shared", + email="shared@example.com", + created_at=_NOW + timedelta(minutes=3), + ) + external_foxtrot = _external_records( + _TENANT_A, + ids.external_foxtrot, + name="Foxtrot External", + email="foxtrot@example.com", + created_at=_NOW + timedelta(minutes=6), + ) + foreign_external = _external_records( + _TENANT_B, + ids.foreign_external, + name="Foreign Shared", + email="shared@example.com", + created_at=_NOW + timedelta(minutes=10), + ) + account_rows.extend( + ( + _membership(_TENANT_A, AccountId("00000000-0000-0000-0000-000000000201")), + _platform_entry(_TENANT_A, ids.workspace_shared, created_at=_NOW + timedelta(minutes=1)), + _platform_entry(_TENANT_A, ids.platform_beta, created_at=_NOW + timedelta(minutes=2)), + _membership(_TENANT_A, AccountId("00000000-0000-0000-0000-000000000204")), + _platform_entry(_TENANT_A, ids.platform_echo, created_at=_NOW + timedelta(minutes=5)), + _membership(_TENANT_A, AccountId("00000000-0000-0000-0000-000000000207")), + _platform_entry(_TENANT_B, ids.foreign_platform, created_at=_NOW + timedelta(minutes=9)), + *external_shared, + *external_foxtrot, + *foreign_external, + ) + ) + sessions = sessionmaker(bind=sqlite_engine, expire_on_commit=False) + with sessions.begin() as session: + session.add_all(account_rows) + + return _RepositoryContext(sqlite_engine, sessions, ids, tuple(candidate_ids)) + + +@pytest.mark.parametrize( + ("query", "expected_attribute"), + [ + (ContactQuery(), "visible"), + (ContactQuery(keyword=" SHARED "), "shared"), + (ContactQuery(contact_type=ContactType.WORKSPACE), "workspace"), + (ContactQuery(contact_type=ContactType.PLATFORM), "platform"), + (ContactQuery(contact_type=ContactType.EXTERNAL), "external"), + ], +) +def test_count_and_list_apply_identical_filters( + contact_repository_context: _RepositoryContext, + query: ContactQuery, + expected_attribute: str, +) -> None: + ids = contact_repository_context.contact_ids + expected_by_attribute = { + "visible": set(ids.visible), + "shared": {ids.workspace_shared, ids.external_shared, ids.platform_echo}, + "workspace": {ids.workspace_shared, ids.workspace_delta}, + "platform": {ids.platform_beta, ids.platform_echo}, + "external": {ids.external_shared, ids.external_foxtrot}, + } + with contact_repository_context.sessions() as session: + repository = SQLAlchemyContactRepository(session) + contact_page = repository.list_contact(_TENANT_A, page=1, limit=100, query=query) + count = repository.count_contact(_TENANT_A, query) + + expected_ids = expected_by_attribute[expected_attribute] + assert count == len(expected_ids) + assert {contact.id for contact in contact_page.items} == expected_ids + + +def test_filtering_precedes_pagination(contact_repository_context: _RepositoryContext) -> None: + ids = contact_repository_context.contact_ids + query = ContactQuery(keyword="shared") + + with contact_repository_context.sessions() as session: + repository = SQLAlchemyContactRepository(session) + second_match = repository.list_contact(_TENANT_A, page=2, limit=1, query=query) + + assert tuple(contact.id for contact in second_match.items) == (ids.external_shared,) + + +@pytest.mark.parametrize( + ("page", "expected_slice"), + [ + (1, slice(0, 2)), + (2, slice(2, 4)), + (3, slice(4, 6)), + (4, slice(6, 8)), + ], + ids=("first", "middle", "final", "out-of-range"), +) +def test_list_contact_page_boundaries( + contact_repository_context: _RepositoryContext, + page: int, + expected_slice: slice, +) -> None: + expected_ids = contact_repository_context.contact_ids.visible[expected_slice] + + with contact_repository_context.sessions() as session: + contact_page = SQLAlchemyContactRepository(session).list_contact(_TENANT_A, page=page, limit=2) + + assert tuple(contact.id for contact in contact_page.items) == expected_ids + assert contact_page.page == page + assert contact_page.limit == 2 + + +@pytest.mark.parametrize(("page", "limit"), [(0, 1), (-1, 1), (1, 0), (1, -1)]) +def test_list_contact_rejects_non_positive_page_boundaries( + contact_repository_context: _RepositoryContext, + page: int, + limit: int, +) -> None: + with contact_repository_context.sessions() as session: + repository = SQLAlchemyContactRepository(session) + with pytest.raises(ValueError, match="must be positive"): + repository.list_contact(_TENANT_A, page=page, limit=limit) + + +def test_public_count_and_list_calls_use_bounded_database_queries_and_indexed_owner_lookups( + contact_repository_context: _RepositoryContext, +) -> None: + captured: list[tuple[str, tuple[object, ...]]] = [] + + def capture_statement( + _connection: object, + _cursor: object, + statement: str, + parameters: tuple[object, ...], + _context: object, + _executemany: bool, + ) -> None: + captured.append((statement, parameters)) + + event.listen(contact_repository_context.engine, "before_cursor_execute", capture_statement) + try: + with contact_repository_context.sessions() as session: + repository = SQLAlchemyContactRepository(session) + repository.count_contact(_TENANT_A, ContactQuery(keyword="shared")) + repository.list_contact(_TENANT_A, page=2, limit=1, query=ContactQuery(keyword="shared")) + finally: + event.remove(contact_repository_context.engine, "before_cursor_execute", capture_statement) + + assert len(captured) == 2 + list_statement, list_parameters = captured[1] + assert "LIMIT" in list_statement.upper() + assert "OFFSET" in list_statement.upper() + with contact_repository_context.engine.connect() as connection: + plan_rows = connection.exec_driver_sql( + f"EXPLAIN QUERY PLAN {list_statement}", + list_parameters, + ).all() + + plan = "\n".join(str(row[-1]) for row in plan_rows) + assert "tenant_account_joins" in plan + assert "human_input_platform_contact_workspace_entries" in plan + assert "human_input_external_contact_profiles" in plan + assert "USING" in plan + + +def test_detail_batch_and_availability_are_tenant_scoped_and_order_agnostic( + contact_repository_context: _RepositoryContext, +) -> None: + ids = contact_repository_context.contact_ids + requested_ids = ( + ids.workspace_shared, + ids.platform_beta, + ids.workspace_shared, + ids.inactive, + ids.unavailable, + ids.foreign_platform, + ids.foreign_external, + _MISSING_CONTACT, + ) + + with contact_repository_context.sessions() as session: + repository = SQLAlchemyContactRepository(session) + assert repository.get_contacts_by_id(_TENANT_A, ids.workspace_shared) is not None + for unavailable_id in ( + ids.inactive, + ids.unavailable, + ids.foreign_platform, + ids.foreign_external, + _MISSING_CONTACT, + ): + assert repository.get_contacts_by_id(_TENANT_A, unavailable_id) is None + contacts = repository.get_contacts_by_ids(_TENANT_A, requested_ids) + availability = repository.available(_TENANT_A, requested_ids) + foreign_contacts = repository.get_contacts_by_ids( + _TENANT_B, + (ids.foreign_platform, ids.foreign_external), + ) + foreign_availability = repository.available( + _TENANT_B, + (ids.foreign_platform, ids.foreign_external), + ) + + returned_ids = {contact.id for contact in contacts} + assert returned_ids == {ids.workspace_shared, ids.platform_beta} + assert len(contacts) == len(returned_ids) + assert availability == { + ids.workspace_shared: True, + ids.platform_beta: True, + ids.inactive: False, + ids.unavailable: False, + ids.foreign_platform: False, + ids.foreign_external: False, + _MISSING_CONTACT: False, + } + assert {contact.id for contact in foreign_contacts} == {ids.foreign_platform, ids.foreign_external} + assert foreign_availability == {ids.foreign_platform: True, ids.foreign_external: True} + + +def test_account_and_external_contacts_with_one_normalized_email_both_match( + contact_repository_context: _RepositoryContext, +) -> None: + ids = contact_repository_context.contact_ids + + with contact_repository_context.sessions() as session: + contacts = SQLAlchemyContactRepository(session).query_contacts_by_email( + _TENANT_A, + (" SHARED@example.COM ", "shared@example.com"), + ) + + assert {contact.id for contact in contacts} == {ids.workspace_shared, ids.external_shared} + assert len(contacts) == 2 + + +def test_membership_takes_precedence_over_platform_visibility( + contact_repository_context: _RepositoryContext, +) -> None: + contact_id = contact_repository_context.contact_ids.workspace_shared + + with contact_repository_context.sessions() as session: + contact = SQLAlchemyContactRepository(session).get_contacts_by_id(_TENANT_A, contact_id) + + assert contact is not None + assert contact.id == contact_id + assert contact.type is ContactType.WORKSPACE + + +def test_organization_candidate_count_list_keyword_and_pagination_share_one_candidate_set( + contact_repository_context: _RepositoryContext, +) -> None: + expected_ids = set(contact_repository_context.candidate_ids) + + with contact_repository_context.sessions() as session: + repository = SQLAlchemyContactRepository(session) + first_page = repository.list_organization_candidates(page=1, limit=2) + middle_page = repository.list_organization_candidates(page=2, limit=2) + final_page = repository.list_organization_candidates(page=3, limit=2) + out_of_range = repository.list_organization_candidates(page=4, limit=2) + count = repository.count_organization_candidates() + keyword_page = repository.list_organization_candidates(page=1, limit=10, keyword="UNAVAILABLE") + keyword_count = repository.count_organization_candidates(keyword=" unavailable ") + + paged_ids = {candidate.id for candidate in (*first_page, *middle_page, *final_page)} + assert count == len(expected_ids) + assert paged_ids == expected_ids + assert out_of_range == () + assert keyword_count == len(keyword_page) == 1 + assert {candidate.id for candidate in keyword_page} == {contact_repository_context.contact_ids.unavailable} + + +def test_candidate_contact_id_round_trips_through_platform_entry_creation( + contact_repository_context: _RepositoryContext, +) -> None: + expected_id = contact_repository_context.contact_ids.unavailable + + with contact_repository_context.sessions.begin() as session: + repository = SQLAlchemyContactRepository(session) + candidates = repository.list_organization_candidates(page=1, limit=10, keyword="unavailable") + assert len(candidates) == 1 + candidate = candidates[0] + repository.create_platform_entry(_TENANT_A, candidate.id, _ADMIN_ACCOUNT) + created_contact = repository.get_contacts_by_id(_TENANT_A, candidate.id) + + assert candidate.id == expected_id + assert created_contact is not None + assert created_contact.id == candidate.id + assert created_contact.type is ContactType.PLATFORM + + +@pytest.mark.parametrize(("page", "limit"), [(0, 1), (-1, 1), (1, 0), (1, -1)]) +def test_organization_candidate_list_rejects_non_positive_page_boundaries( + contact_repository_context: _RepositoryContext, + page: int, + limit: int, +) -> None: + with contact_repository_context.sessions() as session: + repository = SQLAlchemyContactRepository(session) + with pytest.raises(ValueError, match="must be positive"): + repository.list_organization_candidates(page=page, limit=limit) + + +def test_account_contact_provisioning_is_idempotent_and_compensatable( + contact_repository_context: _RepositoryContext, +) -> None: + missing_account_id = AccountId("00000000-0000-0000-0000-000000000998") + with contact_repository_context.sessions.begin() as session: + repository = SQLAlchemyContactRepository(session) + with pytest.raises(ContactError) as missing: + repository.provision_account_backed_contact(missing_account_id) + assert missing.value.code is ContactErrorCode.ACCOUNT_NOT_FOUND + + contact_id = repository.provision_account_backed_contact(_ADMIN_ACCOUNT) + assert repository.provision_account_backed_contact(_ADMIN_ACCOUNT) == contact_id + repository.delete_account_identity_after_failed_creation(_ADMIN_ACCOUNT) + assert repository.get_contacts_by_id(_TENANT_A, contact_id) is None + + +def test_external_contact_lifecycle_is_tenant_owned_and_conflict_safe( + contact_repository_context: _RepositoryContext, +) -> None: + contact_id = ContactId("00000000-0000-0000-0000-000000000399") + original = ExternalContact(contact_id, "New External", "new@example.com", None, _NOW) + updated = ExternalContact(contact_id, "Updated External", "updated@example.com", None, _NOW) + + with contact_repository_context.sessions.begin() as session: + repository = SQLAlchemyContactRepository(session) + assert repository.query_contacts_by_email(_TENANT_A, ()) == () + assert repository.get_contacts_by_ids(_TENANT_A, ()) == () + created = repository.save_external_contact(_TENANT_A, original) + assert (created.id, created.name, created.email) == (contact_id, "New External", "new@example.com") + stored = repository.save_external_contact(_TENANT_A, updated) + assert (stored.id, stored.name, stored.email) == (contact_id, "Updated External", "updated@example.com") + repository.delete_external_contact(_TENANT_A, updated) + with pytest.raises(ContactError) as missing: + repository.delete_external_contact(_TENANT_A, updated) + assert missing.value.code is ContactErrorCode.NOT_FOUND + + invalid_owner = ExternalContact( + contact_repository_context.contact_ids.workspace_shared, + "Invalid", + "invalid@example.com", + None, + _NOW, + ) + with pytest.raises(ContactError) as invalid: + repository.save_external_contact(_TENANT_A, invalid_owner) + assert invalid.value.code is ContactErrorCode.INVALID_OWNER + + duplicate_id = ContactId("00000000-0000-0000-0000-000000000398") + duplicate = ExternalContact(duplicate_id, "Duplicate", "foxtrot@example.com", None, _NOW) + with contact_repository_context.sessions() as session: + with pytest.raises(ContactError) as conflict: + SQLAlchemyContactRepository(session).save_external_contact(_TENANT_A, duplicate) + assert conflict.value.code is ContactErrorCode.CONFLICT + + +def test_platform_entry_deletion_and_missing_candidate_are_explicit( + contact_repository_context: _RepositoryContext, +) -> None: + contact_id = contact_repository_context.contact_ids.platform_beta + with contact_repository_context.sessions.begin() as session: + repository = SQLAlchemyContactRepository(session) + repository.delete_platform_entry(_TENANT_A, contact_id) + assert repository.get_contacts_by_id(_TENANT_A, contact_id) is None + with pytest.raises(ContactError) as missing: + repository.create_platform_entry(_TENANT_A, _MISSING_CONTACT, _ADMIN_ACCOUNT) + assert missing.value.code is ContactErrorCode.NOT_FOUND + + +def test_contact_binding_projection_short_circuits_without_a_channel_or_contacts( + contact_repository_context: _RepositoryContext, +) -> None: + with contact_repository_context.sessions() as session: + repository = SQLAlchemyContactIMBindingRepository(session, None) + assert repository.get_im_bindings(_TENANT_A, ()) == () + assert repository.get_im_bindings(_TENANT_A, (contact_repository_context.contact_ids.workspace_shared,)) == () diff --git a/api/tests/unit_tests/repositories/human_input_v2/test_form_repository.py b/api/tests/unit_tests/repositories/human_input_v2/test_form_repository.py new file mode 100644 index 00000000000..38157c242bc --- /dev/null +++ b/api/tests/unit_tests/repositories/human_input_v2/test_form_repository.py @@ -0,0 +1,505 @@ +"""Behavioral contracts for owner-bound Human Input v2 forms.""" + +from __future__ import annotations + +from concurrent.futures import ThreadPoolExecutor +from dataclasses import replace +from datetime import datetime, timedelta +from sqlite3 import Connection as SQLiteConnection +from threading import Barrier +from uuid import uuid4 + +import pytest +import sqlalchemy as sa +from pydantic import ValidationError +from sqlalchemy import event +from sqlalchemy.engine import Connection, Engine +from sqlalchemy.exc import NoResultFound +from sqlalchemy.orm import Session +from sqlalchemy.pool import ConnectionPoolEntry + +from core.human_input_v2.resolved_form import ResolvedForm +from core.human_input_v2.shared.values import RecipientId, TenantId +from core.workflow.nodes.human_input.enums import HumanInputFormKind, HumanInputFormStatus +from graphon.file.enums import FileTransferMethod, FileType +from graphon.file.models import File +from graphon.variables.segments import ArrayFileSegment, FileSegment, StringSegment +from libs.datetime_utils import naive_utc_now +from models.human_input_v2 import HumanInputForm +from repositories.human_input_v2.form_repository import Form, FormCreateParams, FormExpirationRun, FormSubmissionFailure +from repositories.human_input_v2.sqlalchemy_form_repository import ( + SQLAlchemyFormExpirationRepository, + SQLAlchemyFormRepository, +) + +_TENANT = TenantId("00000000-0000-0000-0000-000000000001") +_APP = "00000000-0000-0000-0000-000000000002" +_RECIPIENT = RecipientId("00000000-0000-0000-0000-000000000003") + + +@pytest.fixture +def form_engine(sqlite_engine: Engine) -> Engine: + engine = sqlite_engine + + # Explicit BEGIN keeps caller rollback effective for every repository write. + @event.listens_for(engine, "connect") + def configure_transaction(connection: SQLiteConnection, _record: ConnectionPoolEntry) -> None: + connection.isolation_level = None + + @event.listens_for(engine, "begin") + def begin_transaction(connection: Connection) -> None: + connection.exec_driver_sql("BEGIN") + + return engine + + +def _params( + *, + expiration_time: datetime | None = None, + global_timeout_deadline: datetime | None = None, +) -> FormCreateParams: + now = naive_utc_now() + return FormCreateParams( + workflow_run_id=str(uuid4()), + node_execution_id=str(uuid4()), + resolved_form=ResolvedForm(title="Approval", parts=(), actions=(), legacy_form_content="Review this."), + expiration_time=expiration_time or now + timedelta(hours=1), + global_timeout_deadline=global_timeout_deadline or now + timedelta(hours=2), + ) + + +def _create(engine: Engine, params: FormCreateParams | None = None) -> Form: + with Session(engine) as session, session.begin(): + return SQLAlchemyFormRepository(session, _TENANT, _APP).create_form(params or _params()) + + +def _submit( + repo: SQLAlchemyFormRepository, + form_id: str, + recipient: RecipientId = _RECIPIENT, + delivery_id: str = "00000000-0000-0000-0000-000000000004", +) -> Form | FormSubmissionFailure: + return repo.submit_form( + form_id, + submitted_by_recipient_id=recipient, + submitted_by_delivery_id=delivery_id, + selected_action_id="approve", + raw_submission_inputs={"answer": " original "}, + normalized_submission_data={"answer": StringSegment(value="original")}, + ) + + +def test_create_and_read_frozen_waiting_form(form_engine: Engine) -> None: + params = _params() + form = _create(form_engine, params) + assert isinstance(form, Form) + assert form.status == HumanInputFormStatus.WAITING + assert form.submission is None + assert form.resolved_form == params.resolved_form + assert form.expiration_time == params.expiration_time + with Session(form_engine) as session: + repo = SQLAlchemyFormRepository(session, _TENANT, _APP) + assert repo.get_form_by_id(form.id) == form + assert params.workflow_run_id is not None + assert params.node_execution_id is not None + assert repo.get_form(params.workflow_run_id, params.node_execution_id) == form + + +def test_runtime_creation_reuses_frozen_form_and_delivery_tests_are_distinct(form_engine: Engine) -> None: + params = _params() + form = _create(form_engine, params) + assert _create(form_engine, replace(params, expiration_time=naive_utc_now())) == form + test_params = replace( + params, form_kind=HumanInputFormKind.DELIVERY_TEST, workflow_run_id=None, node_execution_id=None + ) + assert _create(form_engine, test_params).id != _create(form_engine, test_params).id + + +def test_get_forms_by_ids_filters_requested_ids_and_run(form_engine: Engine) -> None: + params = _params() + assert params.workflow_run_id is not None + first = _create(form_engine, params) + second = _create(form_engine, replace(params, node_execution_id=str(uuid4()))) + _create(form_engine, replace(params, node_execution_id=str(uuid4()))) + other_run_form = _create(form_engine) + + with Session(form_engine) as session: + forms = SQLAlchemyFormRepository(session, _TENANT, _APP).get_forms_by_ids( + workflow_run_id=params.workflow_run_id, + form_ids=[first.id, second.id, first.id, other_run_form.id, str(uuid4())], + ) + + assert len(forms) == 2 + assert {form.id: form for form in forms} == {first.id: first, second.id: second} + + +def test_get_forms_by_ids_with_empty_ids_returns_no_forms(form_engine: Engine) -> None: + form = _create(form_engine) + assert form.workflow_run_id is not None + with Session(form_engine) as session: + assert ( + SQLAlchemyFormRepository(session, _TENANT, _APP).get_forms_by_ids( + workflow_run_id=form.workflow_run_id, form_ids=[] + ) + == () + ) + + +@pytest.mark.parametrize( + ("other_tenant", "other_app"), + [(TenantId(str(uuid4())), _APP), (_TENANT, str(uuid4()))], + ids=["other-tenant", "other-app"], +) +def test_all_operations_are_owner_scoped(form_engine: Engine, other_tenant: TenantId, other_app: str) -> None: + params = _params(expiration_time=naive_utc_now() - timedelta(hours=1)) + form = _create(form_engine, params) + with Session(form_engine) as session, session.begin(): + repo = SQLAlchemyFormRepository(session, other_tenant, other_app) + assert repo.get_form_by_id(form.id) is None + assert params.workflow_run_id is not None + assert params.node_execution_id is not None + assert repo.get_form(params.workflow_run_id, params.node_execution_id) is None + assert repo.get_forms_by_ids(workflow_run_id=params.workflow_run_id, form_ids=[form.id]) == () + assert _submit(repo, form.id) == FormSubmissionFailure.NOT_FOUND + assert repo.expire_form(form.id) is None + assert repo.timeout_form(form.id) is None + assert repo.list_expired_forms(now=naive_utc_now(), limit=10) == () + assert repo.list_waiting_forms_for_run(params.workflow_run_id).forms == () + # Execution identity is global; an owner mismatch must neither create a + # duplicate nor expose the existing owner's form. + with pytest.raises(NoResultFound): + repo.create_form(params) + assert session.scalar(sa.select(sa.func.count()).select_from(HumanInputForm)) == 1 + + +def test_submission_round_trips_raw_inputs_and_concrete_segments(form_engine: Engine) -> None: + form = _create(form_engine) + file = File(file_type=FileType.DOCUMENT, transfer_method=FileTransferMethod.LOCAL_FILE, reference=str(uuid4())) + inputs = { + "answer": StringSegment(value="normalized"), + "file": FileSegment(value=file), + "files": ArrayFileSegment(value=[file]), + "empty_files": ArrayFileSegment(value=[]), + } + with Session(form_engine) as session, session.begin(): + submitted = SQLAlchemyFormRepository(session, _TENANT, _APP).submit_form( + form.id, + submitted_by_recipient_id=_RECIPIENT, + submitted_by_delivery_id="00000000-0000-0000-0000-000000000004", + selected_action_id="approve", + raw_submission_inputs={"answer": " raw ", "extra": {"enabled": True}}, + normalized_submission_data=inputs, + ) + assert isinstance(submitted, Form) + assert submitted.status == HumanInputFormStatus.SUBMITTED + with Session(form_engine) as session: + loaded = SQLAlchemyFormRepository(session, _TENANT, _APP).get_form_by_id(form.id) + assert loaded == submitted + assert loaded is not None + assert loaded.submission is not None + assert loaded.submission.raw_submission_inputs == {"answer": " raw ", "extra": {"enabled": True}} + assert loaded.submission.normalized_submission_data == inputs + assert isinstance(loaded.submission.normalized_submission_data["file"], FileSegment) + assert isinstance(loaded.submission.normalized_submission_data["empty_files"], ArrayFileSegment) + + +@pytest.mark.parametrize( + ("form_delta", "global_delta", "failure", "status"), + [ + (-1, 1, FormSubmissionFailure.FORM_EXPIRED, HumanInputFormStatus.TIMEOUT), + (-2, -1, FormSubmissionFailure.GLOBAL_TIMEOUT, HumanInputFormStatus.EXPIRED), + (1, -1, FormSubmissionFailure.GLOBAL_TIMEOUT, HumanInputFormStatus.EXPIRED), + ], +) +def test_overdue_submission_is_rejected_and_expiration_is_explicit( + form_engine: Engine, + form_delta: int, + global_delta: int, + failure: FormSubmissionFailure, + status: HumanInputFormStatus, +) -> None: + now = naive_utc_now() + form = _create( + form_engine, + _params( + expiration_time=now + timedelta(hours=form_delta), + global_timeout_deadline=now + timedelta(hours=global_delta), + ), + ) + with Session(form_engine) as session, session.begin(): + repo = SQLAlchemyFormRepository(session, _TENANT, _APP) + assert _submit(repo, form.id) == failure + assert repo.get_form_by_id(form.id) == form + expired = repo.timeout_form(form.id) if status == HumanInputFormStatus.TIMEOUT else repo.expire_form(form.id) + assert expired is not None + assert expired.status == status + assert expired.submission is None + assert repo.expire_form(form.id) == expired + assert repo.timeout_form(form.id) == expired + assert _submit(repo, form.id) == failure + + +def test_global_expiration_does_not_settle_node_timeout(form_engine: Engine) -> None: + form = _create(form_engine, _params(expiration_time=naive_utc_now() - timedelta(hours=1))) + with Session(form_engine) as session, session.begin(): + repo = SQLAlchemyFormRepository(session, _TENANT, _APP) + assert repo.expire_form(form.id) == form + + +def test_node_timeout_leaves_global_expiration_to_its_own_transition(form_engine: Engine) -> None: + now = naive_utc_now() + form = _create( + form_engine, + _params(expiration_time=now - timedelta(hours=1), global_timeout_deadline=now - timedelta(minutes=1)), + ) + with Session(form_engine) as session, session.begin(): + repo = SQLAlchemyFormRepository(session, _TENANT, _APP) + assert repo.timeout_form(form.id) == form + expired = repo.expire_form(form.id) + assert expired is not None + assert expired.status == HumanInputFormStatus.EXPIRED + + +def test_terminal_outcome_does_not_change_when_global_deadline_passes(form_engine: Engine) -> None: + form = _create(form_engine, _params(expiration_time=naive_utc_now() - timedelta(hours=1))) + with Session(form_engine) as session, session.begin(): + repo = SQLAlchemyFormRepository(session, _TENANT, _APP) + expired = repo.timeout_form(form.id) + assert expired is not None + assert expired.status == HumanInputFormStatus.TIMEOUT + session.execute( + sa.update(HumanInputForm) + .where(HumanInputForm.id == form.id) + .values( + global_timeout_deadline=naive_utc_now() - timedelta(hours=1), + ) + ) + still_expired = repo.expire_form(form.id) + assert still_expired is not None + assert still_expired.status == HumanInputFormStatus.TIMEOUT + assert repo.timeout_form(form.id) == still_expired + assert _submit(repo, form.id) == FormSubmissionFailure.FORM_EXPIRED + + +def test_scan_paginates_only_waiting_runtime_forms(form_engine: Engine) -> None: + now = naive_utc_now() + due = _params(expiration_time=now - timedelta(hours=1)) + forms = [_create(form_engine, replace(due, node_execution_id=str(uuid4()))) for _ in range(3)] + _create(form_engine) + _create( + form_engine, + replace(due, form_kind=HumanInputFormKind.DELIVERY_TEST, workflow_run_id=None, node_execution_id=None), + ) + with Session(form_engine) as session, session.begin(): + repo = SQLAlchemyFormRepository(session, _TENANT, _APP) + repo.timeout_form(forms[0].id) + expected = sorted(forms[1:], key=lambda form: form.id) + first = repo.list_expired_forms(now=now, limit=1) + assert first == (expected[0],) + assert repo.list_expired_forms(now=now, limit=1, after_form_id=first[0].id) == (expected[1],) + assert repo.list_expired_forms(now=now, limit=1, after_form_id=expected[1].id) == () + + +def test_caller_rollback_undoes_creation_and_submission(form_engine: Engine) -> None: + with Session(form_engine) as session: + repo = SQLAlchemyFormRepository(session, _TENANT, _APP) + form = repo.create_form(_params()) + session.rollback() + assert repo.get_form_by_id(form.id) is None + form = _create(form_engine) + with Session(form_engine) as session: + repo = SQLAlchemyFormRepository(session, _TENANT, _APP) + assert isinstance(_submit(repo, form.id), Form) + session.rollback() + assert repo.get_form_by_id(form.id) == form + + +@pytest.mark.parametrize( + ("field", "value"), + [ + ("submitted_at", datetime(2026, 1, 1)), + ("raw_submission_inputs", "not json"), + ("status", HumanInputFormStatus.SUBMITTED), + ], +) +def test_malformed_stored_submission_is_not_treated_as_waiting(form_engine: Engine, field: str, value: object) -> None: + form = _create(form_engine) + with Session(form_engine) as session, session.begin(): + session.execute(sa.update(HumanInputForm).where(HumanInputForm.id == form.id).values({field: value})) + with Session(form_engine) as session: + with pytest.raises(ValidationError): + SQLAlchemyFormRepository(session, _TENANT, _APP).get_form_by_id(form.id) + + +def test_concurrent_runtime_creation_returns_one_form(form_engine: Engine) -> None: + params = _params() + barrier = Barrier(2) + + def create() -> Form: + with Session(form_engine) as session, session.begin(): + barrier.wait(timeout=10) + return SQLAlchemyFormRepository(session, _TENANT, _APP).create_form(params) + + with ThreadPoolExecutor(max_workers=2) as executor: + futures = [executor.submit(create) for _ in range(2)] + forms = [future.result(timeout=15) for future in futures] + assert forms[0] == forms[1] + with Session(form_engine) as session: + assert session.scalar(sa.select(sa.func.count()).select_from(HumanInputForm)) == 1 + + +def test_concurrent_submissions_have_one_winner(form_engine: Engine) -> None: + form = _create(form_engine) + barrier = Barrier(2) + + def submit(recipient: RecipientId) -> Form | FormSubmissionFailure: + with Session(form_engine) as session, session.begin(): + barrier.wait(timeout=10) + return _submit(SQLAlchemyFormRepository(session, _TENANT, _APP), form.id, recipient, str(uuid4())) + + with ThreadPoolExecutor(max_workers=2) as executor: + futures = [executor.submit(submit, RecipientId(str(uuid4()))) for _ in range(2)] + results = [future.result(timeout=15) for future in futures] + winners = [result for result in results if isinstance(result, Form)] + assert len(winners) == 1 + assert results.count(FormSubmissionFailure.ALREADY_SUBMITTED) == 1 + with Session(form_engine) as session: + assert SQLAlchemyFormRepository(session, _TENANT, _APP).get_form_by_id(form.id) == winners[0] + + +@pytest.mark.parametrize( + ("global_deadline_reached", "failure", "status"), + [ + (False, FormSubmissionFailure.FORM_EXPIRED, HumanInputFormStatus.TIMEOUT), + (True, FormSubmissionFailure.GLOBAL_TIMEOUT, HumanInputFormStatus.EXPIRED), + ], +) +def test_exact_deadline_rejects_submission( + form_engine: Engine, + monkeypatch: pytest.MonkeyPatch, + global_deadline_reached: bool, + failure: FormSubmissionFailure, + status: HumanInputFormStatus, +) -> None: + now = datetime(2026, 9, 9, 12) + monkeypatch.setattr(SQLAlchemyFormRepository, "_database_now", lambda _self: now) + form = _create( + form_engine, + _params( + expiration_time=now, + global_timeout_deadline=now if global_deadline_reached else now + timedelta(hours=1), + ), + ) + with Session(form_engine) as session, session.begin(): + repo = SQLAlchemyFormRepository(session, _TENANT, _APP) + assert _submit(repo, form.id) == failure + expired = repo.expire_form(form.id) if global_deadline_reached else repo.timeout_form(form.id) + assert expired is not None + assert expired.status == status + with Session(form_engine) as session: + assert SQLAlchemyFormRepository(session, _TENANT, _APP).get_form_by_id(form.id) == expired + + +def test_submission_just_before_deadline_survives_later_expiration( + form_engine: Engine, + monkeypatch: pytest.MonkeyPatch, +) -> None: + now = datetime(2026, 9, 9, 12) + monkeypatch.setattr(SQLAlchemyFormRepository, "_database_now", lambda _self: now) + deadline = now + timedelta(microseconds=1) + params = _params(expiration_time=deadline, global_timeout_deadline=deadline) + form = _create(form_engine, params) + with Session(form_engine) as session, session.begin(): + repo = SQLAlchemyFormRepository(session, _TENANT, _APP) + assert repo.expire_form(form.id) == form + assert repo.timeout_form(form.id) == form + submitted = _submit(repo, form.id) + assert isinstance(submitted, Form) + assert submitted.submission is not None + assert submitted.submission.submitted_at == now + monkeypatch.setattr(SQLAlchemyFormRepository, "_database_now", lambda _self: now + timedelta(days=1)) + with Session(form_engine) as session, session.begin(): + repo = SQLAlchemyFormRepository(session, _TENANT, _APP) + assert repo.expire_form(form.id) == submitted + assert repo.timeout_form(form.id) == submitted + assert _submit(repo, form.id) == FormSubmissionFailure.ALREADY_SUBMITTED + assert repo.create_form(params) == submitted + assert repo.list_expired_forms(now=now + timedelta(days=1), limit=10) == () + + +@pytest.mark.parametrize("limit", [0, -1]) +def test_scan_rejects_nonpositive_limit(form_engine: Engine, limit: int) -> None: + with Session(form_engine) as session: + with pytest.raises(ValueError, match="limit must be positive"): + SQLAlchemyFormRepository(session, _TENANT, _APP).list_expired_forms(now=naive_utc_now(), limit=limit) + with pytest.raises(ValueError, match="limit must be positive"): + SQLAlchemyFormExpirationRepository(session).list_expired_runs(now=naive_utc_now(), limit=limit) + + +def test_expiration_discovery_is_bounded_across_owners_and_only_returns_runs_with_due_waiting_forms( + form_engine: Engine, +) -> None: + now = naive_utc_now() + node_due = _params(expiration_time=now) + first = _create(form_engine, node_due) + with Session(form_engine) as session, session.begin(): + other_owner = SQLAlchemyFormRepository(session, TenantId(str(uuid4())), str(uuid4())) + second = other_owner.create_form(_params(global_timeout_deadline=now)) + _create(form_engine) + _create( + form_engine, + replace(node_due, form_kind=HumanInputFormKind.DELIVERY_TEST, workflow_run_id=None, node_execution_id=None), + ) + timed_out = _create(form_engine, _params(expiration_time=now - timedelta(hours=1))) + with Session(form_engine) as session, session.begin(): + SQLAlchemyFormRepository(session, _TENANT, _APP).timeout_form(timed_out.id) + assert first.workflow_run_id is not None + assert second.workflow_run_id is not None + expected = tuple( + sorted( + ( + FormExpirationRun(first.tenant_id, first.app_id, first.workflow_run_id, first.global_timeout_deadline), + FormExpirationRun( + second.tenant_id, second.app_id, second.workflow_run_id, second.global_timeout_deadline + ), + ), + key=lambda run: run.workflow_run_id, + ) + ) + with Session(form_engine) as session: + repository = SQLAlchemyFormExpirationRepository(session) + assert repository.list_expired_runs(now=now, limit=1) == expected[:1] + assert repository.list_expired_runs(now=now, limit=10) == expected + + +def test_expiration_discovery_includes_global_deadline_outside_both_candidate_limits(form_engine: Engine) -> None: + now = naive_utc_now() + run_id = "00000000-0000-0000-0000-000000000001" + _create(form_engine, replace(_params(expiration_time=now - timedelta(minutes=2)), workflow_run_id=run_id)) + global_deadline = now - timedelta(minutes=1) + _create(form_engine, replace(_params(global_timeout_deadline=global_deadline), workflow_run_id=run_id)) + _create( + form_engine, + replace( + _params(global_timeout_deadline=now - timedelta(minutes=3)), + workflow_run_id="00000000-0000-0000-0000-000000000002", + ), + ) + with Session(form_engine) as session: + runs = SQLAlchemyFormExpirationRepository(session).list_expired_runs(now=now, limit=1) + assert runs == (FormExpirationRun(_TENANT, _APP, run_id, global_deadline),) + + +def test_waiting_form_batch_is_complete_and_scoped_to_one_run(form_engine: Engine) -> None: + params = _params() + first = _create(form_engine, params) + second = _create(form_engine, replace(params, node_execution_id=str(uuid4()))) + _create(form_engine) + timed_out = _create(form_engine, replace(params, node_execution_id=str(uuid4()), expiration_time=naive_utc_now())) + assert params.workflow_run_id is not None + with Session(form_engine) as session, session.begin(): + repository = SQLAlchemyFormRepository(session, _TENANT, _APP) + repository.timeout_form(timed_out.id) + batch = repository.list_waiting_forms_for_run(params.workflow_run_id) + assert batch.forms == tuple(sorted((first, second), key=lambda form: form.id)) + assert batch.now.tzinfo is None diff --git a/api/tests/unit_tests/repositories/human_input_v2/test_im_binding_repository.py b/api/tests/unit_tests/repositories/human_input_v2/test_im_binding_repository.py new file mode 100644 index 00000000000..6636fc55c7c --- /dev/null +++ b/api/tests/unit_tests/repositories/human_input_v2/test_im_binding_repository.py @@ -0,0 +1,443 @@ +"""Behavioral contracts for Channel-bound current IM Binding persistence.""" + +from __future__ import annotations + +from dataclasses import fields +from datetime import datetime +from functools import partial + +import pytest +import sqlalchemy as sa +from sqlalchemy import event +from sqlalchemy.engine import Engine, ExecutionContext +from sqlalchemy.orm import Session +from sqlalchemy.sql.dml import Delete, Insert, Update + +from core.human_input_v2.shared import AccountId, ContactId, IMBindingId, IMIdentityId, IMSyncRunId, TenantId +from models.human_input_v2 import HumanInputIMBinding, HumanInputIMBindingWorkspaceOverride +from repositories.human_input_v2.im_binding_repository import ( + IMBinding, + IMBindingAssignment, + IMBindingConflictError, + IMBindingIdentityNotFoundError, + IMBindingKind, + IMBindingRepositoryError, +) +from repositories.human_input_v2.im_channel_repository import IMChannelId +from repositories.human_input_v2.im_identity_repository import IMIdentityObservation, OpaqueProviderPayload +from repositories.human_input_v2.sqlalchemy_im_binding_repository import SQLAlchemyIMBindingRepository +from repositories.human_input_v2.sqlalchemy_im_identity_repository import SQLAlchemyIMIdentityRepository + +_NOW = datetime(2026, 9, 1, 8) +_LATER = datetime(2026, 9, 1, 9) +_CHANNEL_ONE = IMChannelId("00000000-0000-0000-0000-000000000101") +_CHANNEL_TWO = IMChannelId("00000000-0000-0000-0000-000000000102") +_TENANT_ONE = TenantId("00000000-0000-0000-0000-000000000201") +_TENANT_TWO = TenantId("00000000-0000-0000-0000-000000000202") +_CONTACT_ONE = ContactId("00000000-0000-0000-0000-000000000301") +_CONTACT_TWO = ContactId("00000000-0000-0000-0000-000000000302") +_CONTACT_THREE = ContactId("00000000-0000-0000-0000-000000000303") +_IDENTITY_ONE = IMIdentityId("00000000-0000-0000-0000-000000000401") +_IDENTITY_TWO = IMIdentityId("00000000-0000-0000-0000-000000000402") +_IDENTITY_THREE = IMIdentityId("00000000-0000-0000-0000-000000000403") +_RUN_ID = IMSyncRunId("00000000-0000-0000-0000-000000000501") +_ACCOUNT_ONE = AccountId("00000000-0000-0000-0000-000000000601") +_ACCOUNT_TWO = AccountId("00000000-0000-0000-0000-000000000602") + + +def _seed_identities(engine: Engine, channel_id: IMChannelId, *identity_ids: IMIdentityId) -> None: + with Session(engine) as session, session.begin(): + repository = SQLAlchemyIMIdentityRepository(session, channel_id) + for index, identity_id in enumerate(identity_ids, start=1): + repository.create( + identity_id, + IMIdentityObservation( + provider_user_id=f"provider-user-{channel_id}-{index}", + display_name=f"User {index}", + email=f"user-{index}@example.com", + raw_payload=OpaqueProviderPayload({"index": index}), + sync_run_id=_RUN_ID, + observed_at=_NOW, + ), + ) + + +def _assignment( + contact_id: ContactId, + identity_id: IMIdentityId, + assigned_at: datetime = _NOW, +) -> IMBindingAssignment: + return IMBindingAssignment(contact_id, identity_id, assigned_at) + + +def test_binding_values_are_owner_free_and_expected_errors_share_a_direct_root() -> None: + binding = IMBinding(IMBindingId("binding-1"), IMBindingKind.DEFAULT, _CONTACT_ONE, _IDENTITY_ONE, _NOW, _NOW) + + assert tuple(field.name for field in fields(IMBinding)) == ( + "id", + "kind", + "contact_id", + "identity_id", + "created_at", + "updated_at", + ) + assert tuple(field.name for field in fields(IMBindingAssignment)) == ( + "contact_id", + "identity_id", + "assigned_at", + ) + assert IMBindingRepositoryError.__bases__ == (Exception,) + assert isinstance(hash(binding), int) + + +def test_default_create_is_idempotent_and_get_list_are_channel_scoped(sqlite_engine: Engine) -> None: + _seed_identities(sqlite_engine, _CHANNEL_ONE, _IDENTITY_ONE) + _seed_identities(sqlite_engine, _CHANNEL_TWO, _IDENTITY_TWO) + + with Session(sqlite_engine, expire_on_commit=False) as session: + first = SQLAlchemyIMBindingRepository(session, _CHANNEL_ONE) + created = first.create(_assignment(_CONTACT_ONE, _IDENTITY_ONE), bound_by_account_id=_ACCOUNT_ONE) + retried = first.create(_assignment(_CONTACT_ONE, _IDENTITY_ONE, _LATER), bound_by_account_id=_ACCOUNT_TWO) + + assert retried == created + assert created.kind is IMBindingKind.DEFAULT + assert first.get(created.id) == created + assert first.list_all() == (created,) + assert SQLAlchemyIMBindingRepository(session, _CHANNEL_TWO).get(created.id) is None + record = session.get_one(HumanInputIMBinding, str(created.id)) + assert record.channel_id == str(_CHANNEL_ONE) + assert record.bound_by_account_id == str(_ACCOUNT_ONE) + assert not hasattr(created, "channel_id") + assert not hasattr(created, "bound_by_account_id") + + +@pytest.mark.parametrize( + ("second_contact", "second_identity"), + [(_CONTACT_ONE, _IDENTITY_TWO), (_CONTACT_TWO, _IDENTITY_ONE)], +) +def test_default_create_rejects_endpoint_conflicts( + sqlite_engine: Engine, + second_contact: ContactId, + second_identity: IMIdentityId, +) -> None: + _seed_identities(sqlite_engine, _CHANNEL_ONE, _IDENTITY_ONE, _IDENTITY_TWO) + with Session(sqlite_engine) as session: + repository = SQLAlchemyIMBindingRepository(session, _CHANNEL_ONE) + repository.create(_assignment(_CONTACT_ONE, _IDENTITY_ONE), bound_by_account_id=None) + with pytest.raises(IMBindingConflictError): + repository.create(_assignment(second_contact, second_identity), bound_by_account_id=None) + + +@pytest.mark.parametrize("operation", ["create", "replace", "override"]) +def test_writes_reject_missing_and_cross_channel_identities(sqlite_engine: Engine, operation: str) -> None: + _seed_identities(sqlite_engine, _CHANNEL_TWO, _IDENTITY_ONE) + with Session(sqlite_engine) as session: + repository = SQLAlchemyIMBindingRepository(session, _CHANNEL_ONE) + if operation == "create": + mutation = partial( + repository.create, + _assignment(_CONTACT_ONE, _IDENTITY_ONE), + bound_by_account_id=None, + ) + elif operation == "replace": + mutation = partial( + repository.replace, + _assignment(_CONTACT_ONE, _IDENTITY_ONE), + bound_by_account_id=None, + ) + else: + mutation = partial( + repository.set_workspace_override, + _TENANT_ONE, + _assignment(_CONTACT_ONE, _IDENTITY_ONE), + bound_by_account_id=None, + ) + with pytest.raises(IMBindingIdentityNotFoundError): + mutation() + assert session.scalar(sa.select(sa.func.count(HumanInputIMBinding.id))) == 0 + assert session.scalar(sa.select(sa.func.count(HumanInputIMBindingWorkspaceOverride.id))) == 0 + + +def test_replace_creates_reassigns_and_preserves_identical_bindings_within_its_channel(sqlite_engine: Engine) -> None: + _seed_identities(sqlite_engine, _CHANNEL_ONE, _IDENTITY_ONE, _IDENTITY_TWO) + _seed_identities(sqlite_engine, _CHANNEL_TWO, _IDENTITY_THREE) + with Session(sqlite_engine, expire_on_commit=False) as session: + repository = SQLAlchemyIMBindingRepository(session, _CHANNEL_ONE) + created = repository.replace(_assignment(_CONTACT_ONE, _IDENTITY_ONE), bound_by_account_id=_ACCOUNT_ONE) + other_repository = SQLAlchemyIMBindingRepository(session, _CHANNEL_TWO) + foreign = other_repository.replace(_assignment(_CONTACT_ONE, _IDENTITY_THREE), bound_by_account_id=None) + assert ( + repository.replace(_assignment(_CONTACT_ONE, _IDENTITY_ONE, _LATER), bound_by_account_id=_ACCOUNT_TWO) + == created + ) + assert session.get_one(HumanInputIMBinding, str(created.id)).bound_by_account_id == str(_ACCOUNT_ONE) + + replaced = repository.replace( + _assignment(_CONTACT_ONE, _IDENTITY_TWO, _LATER), + bound_by_account_id=_ACCOUNT_TWO, + ) + assert other_repository.get(foreign.id) == foreign + assert replaced is not None + assert replaced.id != created.id + assert replaced == IMBinding( + replaced.id, + IMBindingKind.DEFAULT, + _CONTACT_ONE, + _IDENTITY_TWO, + _LATER, + _LATER, + ) + assert session.get(HumanInputIMBinding, str(created.id)) is None + assert session.get_one(HumanInputIMBinding, str(replaced.id)).bound_by_account_id == str(_ACCOUNT_TWO) + + repository.delete(created.id, expected_identity_id=_IDENTITY_ONE) + assert repository.get(replaced.id) == replaced + SQLAlchemyIMBindingRepository(session, _CHANNEL_TWO).delete( + replaced.id, + expected_identity_id=_IDENTITY_TWO, + ) + assert repository.get(replaced.id) == replaced + repository.delete(replaced.id, expected_identity_id=_IDENTITY_ONE) + assert repository.get(replaced.id) == replaced + assert repository.delete(replaced.id, expected_identity_id=_IDENTITY_TWO) is None + assert repository.delete(replaced.id, expected_identity_id=_IDENTITY_TWO) is None + assert repository.get(replaced.id) is None + + +@pytest.mark.parametrize("target_already_bound", [False, True]) +def test_replace_transfers_identity_and_preserves_other_channels_and_workspace_overrides( + sqlite_engine: Engine, target_already_bound: bool +) -> None: + _seed_identities(sqlite_engine, _CHANNEL_ONE, _IDENTITY_ONE, _IDENTITY_TWO) + _seed_identities(sqlite_engine, _CHANNEL_TWO, _IDENTITY_THREE) + with Session(sqlite_engine, expire_on_commit=False) as session, session.begin(): + repository = SQLAlchemyIMBindingRepository(session, _CHANNEL_ONE) + source = repository.create(_assignment(_CONTACT_ONE, _IDENTITY_ONE), bound_by_account_id=_ACCOUNT_ONE) + target = ( + repository.create(_assignment(_CONTACT_TWO, _IDENTITY_TWO), bound_by_account_id=_ACCOUNT_ONE) + if target_already_bound + else None + ) + foreign_repository = SQLAlchemyIMBindingRepository(session, _CHANNEL_TWO) + foreign = foreign_repository.create( + _assignment(_CONTACT_TWO, _IDENTITY_THREE), bound_by_account_id=_ACCOUNT_ONE + ) + overrides = tuple( + ( + tenant_id, + repository.set_workspace_override( + tenant_id, _assignment(contact_id, identity_id), bound_by_account_id=_ACCOUNT_ONE + ), + ) + for tenant_id, contact_id, identity_id in ( + (_TENANT_ONE, _CONTACT_ONE, _IDENTITY_ONE), + (_TENANT_ONE, _CONTACT_TWO, _IDENTITY_TWO), + (_TENANT_TWO, _CONTACT_ONE, _IDENTITY_ONE), + ) + ) + + transferred = repository.replace( + _assignment(_CONTACT_TWO, _IDENTITY_ONE, _LATER), bound_by_account_id=_ACCOUNT_TWO + ) + + assert transferred.id != source.id + if target is not None: + assert transferred.id != target.id + assert transferred == IMBinding( + transferred.id, IMBindingKind.DEFAULT, _CONTACT_TWO, _IDENTITY_ONE, _LATER, _LATER + ) + assert repository.list_all() == (transferred,) + assert repository.get(source.id) is None + assert session.get_one(HumanInputIMBinding, str(transferred.id)).bound_by_account_id == str(_ACCOUNT_TWO) + assert foreign_repository.get(foreign.id) == foreign + for tenant_id, override in overrides: + assert repository.get_effective(tenant_id, override.contact_id) == override + assert session.get_one(HumanInputIMBindingWorkspaceOverride, str(override.id)).bound_by_account_id == str( + _ACCOUNT_ONE + ) + assert ( + repository.replace(_assignment(_CONTACT_TWO, _IDENTITY_ONE), bound_by_account_id=_ACCOUNT_ONE) + == transferred + ) + assert session.get_one(HumanInputIMBinding, str(transferred.id)).bound_by_account_id == str(_ACCOUNT_TWO) + + +def test_workspace_override_replacement_is_isolated_by_channel_and_tenant( + sqlite_engine: Engine, +) -> None: + _seed_identities(sqlite_engine, _CHANNEL_ONE, _IDENTITY_ONE, _IDENTITY_TWO) + _seed_identities(sqlite_engine, _CHANNEL_TWO, _IDENTITY_THREE) + with Session(sqlite_engine, expire_on_commit=False) as session: + repository = SQLAlchemyIMBindingRepository(session, _CHANNEL_ONE) + default = repository.create(_assignment(_CONTACT_ONE, _IDENTITY_ONE), bound_by_account_id=None) + first_override = repository.set_workspace_override( + _TENANT_ONE, + _assignment(_CONTACT_TWO, _IDENTITY_ONE), + bound_by_account_id=_ACCOUNT_ONE, + ) + other_workspace = repository.set_workspace_override( + _TENANT_TWO, + _assignment(_CONTACT_TWO, _IDENTITY_ONE), + bound_by_account_id=_ACCOUNT_TWO, + ) + other_channel_repository = SQLAlchemyIMBindingRepository(session, _CHANNEL_TWO) + other_channel = other_channel_repository.set_workspace_override( + _TENANT_ONE, + _assignment(_CONTACT_TWO, _IDENTITY_THREE), + bound_by_account_id=_ACCOUNT_ONE, + ) + replaced_override = repository.set_workspace_override( + _TENANT_ONE, + _assignment(_CONTACT_TWO, _IDENTITY_TWO, _LATER), + bound_by_account_id=_ACCOUNT_TWO, + ) + + assert default.identity_id == first_override.identity_id == other_workspace.identity_id + assert first_override.kind is IMBindingKind.WORKSPACE_OVERRIDE + assert other_workspace.kind is IMBindingKind.WORKSPACE_OVERRIDE + assert replaced_override.id != first_override.id + assert replaced_override.created_at == replaced_override.updated_at == _LATER + assert replaced_override.identity_id == _IDENTITY_TWO + assert session.get(HumanInputIMBindingWorkspaceOverride, str(first_override.id)) is None + stored = session.get_one(HumanInputIMBindingWorkspaceOverride, str(replaced_override.id)) + assert stored.bound_by_account_id == str(_ACCOUNT_TWO) + assert repository.get_effective(_TENANT_TWO, _CONTACT_TWO) == other_workspace + assert other_channel_repository.get_effective(_TENANT_ONE, _CONTACT_TWO) == other_channel + assert repository.get(default.id) == default + + repository.reset_workspace_override(_TENANT_ONE, _CONTACT_TWO) + assert repository.get_effective(_TENANT_ONE, _CONTACT_TWO) is None + assert repository.get_effective(_TENANT_TWO, _CONTACT_TWO) == other_workspace + assert other_channel_repository.get_effective(_TENANT_ONE, _CONTACT_TWO) == other_channel + + +@pytest.mark.parametrize("kind", list(IMBindingKind)) +def test_bindings_are_immutable_and_round_trip_assignments_never_reuse_ids( + sqlite_engine: Engine, kind: IMBindingKind +) -> None: + _seed_identities(sqlite_engine, _CHANNEL_ONE, _IDENTITY_ONE, _IDENTITY_TWO) + binding_tables = {HumanInputIMBinding.__tablename__, HumanInputIMBindingWorkspaceOverride.__tablename__} + writes: list[Insert | Delete | Update] = [] + + def capture_binding_write( + _connection: object, + _cursor: object, + _statement: str, + _parameters: object, + context: ExecutionContext, + _executemany: bool, + ) -> None: + if context.compiled is None: + return + statement = context.compiled.statement + if ( + isinstance(statement, Insert | Delete | Update) + and isinstance(statement.table, sa.Table) + and statement.table.name in binding_tables + ): + writes.append(statement) + + event.listen(sqlite_engine, "before_cursor_execute", capture_binding_write) + try: + with Session(sqlite_engine) as session, session.begin(): + repository = SQLAlchemyIMBindingRepository(session, _CHANNEL_ONE) + if kind is IMBindingKind.DEFAULT: + assign = repository.replace + model = HumanInputIMBinding + else: + assign = partial(repository.set_workspace_override, _TENANT_ONE) + model = HumanInputIMBindingWorkspaceOverride + + original = assign(_assignment(_CONTACT_ONE, _IDENTITY_ONE), bound_by_account_id=_ACCOUNT_ONE) + writes.clear() + retried = assign(_assignment(_CONTACT_ONE, _IDENTITY_ONE, _LATER), bound_by_account_id=_ACCOUNT_TWO) + session.flush() + assert retried == original + assert session.get_one(model, str(original.id)).bound_by_account_id == str(_ACCOUNT_ONE) + assert writes == [] + + replacement = assign(_assignment(_CONTACT_ONE, _IDENTITY_TWO, _LATER), bound_by_account_id=_ACCOUNT_TWO) + restored = assign(_assignment(_CONTACT_ONE, _IDENTITY_ONE, _LATER), bound_by_account_id=None) + assert len({original.id, replacement.id, restored.id}) == 3 + assert session.get(model, str(original.id)) is None + assert session.get(model, str(replacement.id)) is None + assert session.get_one(model, str(restored.id)).bound_by_account_id is None + + if kind is IMBindingKind.DEFAULT: + repository.delete(restored.id, expected_identity_id=_IDENTITY_ONE) + else: + repository.reset_workspace_override(_TENANT_ONE, _CONTACT_ONE) + assert repository.get_effective(_TENANT_ONE, _CONTACT_ONE) is None + assert any(isinstance(statement, Insert) for statement in writes) + assert any(isinstance(statement, Delete) for statement in writes) + assert not any(isinstance(statement, Update) for statement in writes) + finally: + event.remove(sqlite_engine, "before_cursor_execute", capture_binding_write) + + +def test_workspace_override_conflicts_only_within_the_target_workspace(sqlite_engine: Engine) -> None: + _seed_identities(sqlite_engine, _CHANNEL_ONE, _IDENTITY_ONE) + with Session(sqlite_engine) as session: + repository = SQLAlchemyIMBindingRepository(session, _CHANNEL_ONE) + repository.set_workspace_override( + _TENANT_ONE, + _assignment(_CONTACT_ONE, _IDENTITY_ONE), + bound_by_account_id=None, + ) + with pytest.raises(IMBindingConflictError): + repository.set_workspace_override( + _TENANT_ONE, + _assignment(_CONTACT_TWO, _IDENTITY_ONE), + bound_by_account_id=None, + ) + second_workspace = repository.set_workspace_override( + _TENANT_TWO, + _assignment(_CONTACT_TWO, _IDENTITY_ONE), + bound_by_account_id=None, + ) + assert second_workspace.contact_id == _CONTACT_TWO + + +def test_effective_reads_apply_override_first_precedence_and_preserve_request_order(sqlite_engine: Engine) -> None: + _seed_identities(sqlite_engine, _CHANNEL_ONE, _IDENTITY_ONE, _IDENTITY_TWO, _IDENTITY_THREE) + with Session(sqlite_engine, expire_on_commit=False) as session: + repository = SQLAlchemyIMBindingRepository(session, _CHANNEL_ONE) + first_default = repository.create(_assignment(_CONTACT_ONE, _IDENTITY_ONE), bound_by_account_id=None) + second_default = repository.create(_assignment(_CONTACT_TWO, _IDENTITY_TWO), bound_by_account_id=None) + override = repository.set_workspace_override( + _TENANT_ONE, + _assignment(_CONTACT_ONE, _IDENTITY_THREE), + bound_by_account_id=None, + ) + + assert repository.get_effective(_TENANT_ONE, _CONTACT_ONE) == override + assert repository.get_effective(_TENANT_TWO, _CONTACT_ONE) == first_default + assert repository.get_effective(_TENANT_ONE, _CONTACT_THREE) is None + assert repository.get_effective_many( + _TENANT_ONE, + (_CONTACT_TWO, _CONTACT_ONE, _CONTACT_TWO, _CONTACT_THREE), + ) == (second_default, override) + + reset = repository.reset_workspace_override(_TENANT_ONE, _CONTACT_ONE) + assert reset == override + assert repository.get_effective(_TENANT_ONE, _CONTACT_ONE) == first_default + assert repository.reset_workspace_override(_TENANT_ONE, _CONTACT_ONE) is None + + +def test_caller_rollback_removes_the_complete_flushed_write_set(sqlite_engine: Engine) -> None: + _seed_identities(sqlite_engine, _CHANNEL_ONE, _IDENTITY_ONE, _IDENTITY_TWO) + with Session(sqlite_engine) as session: + repository = SQLAlchemyIMBindingRepository(session, _CHANNEL_ONE) + repository.create(_assignment(_CONTACT_ONE, _IDENTITY_ONE), bound_by_account_id=None) + repository.set_workspace_override( + _TENANT_ONE, + _assignment(_CONTACT_TWO, _IDENTITY_TWO), + bound_by_account_id=None, + ) + assert session.scalar(sa.select(sa.func.count(HumanInputIMBinding.id))) == 1 + assert session.scalar(sa.select(sa.func.count(HumanInputIMBindingWorkspaceOverride.id))) == 1 + session.rollback() + + with Session(sqlite_engine) as session: + assert session.scalar(sa.select(sa.func.count(HumanInputIMBinding.id))) == 0 + assert session.scalar(sa.select(sa.func.count(HumanInputIMBindingWorkspaceOverride.id))) == 0 diff --git a/api/tests/unit_tests/repositories/human_input_v2/test_im_channel_repository_contract.py b/api/tests/unit_tests/repositories/human_input_v2/test_im_channel_repository_contract.py new file mode 100644 index 00000000000..5e7c04f0a54 --- /dev/null +++ b/api/tests/unit_tests/repositories/human_input_v2/test_im_channel_repository_contract.py @@ -0,0 +1,540 @@ +"""Behavioral contracts for owner-bound IM Channel persistence.""" + +from __future__ import annotations + +import inspect +from dataclasses import FrozenInstanceError, fields, replace +from datetime import datetime +from typing import Literal + +import pytest +import sqlalchemy as sa +from sqlalchemy import event +from sqlalchemy.engine import Engine +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +from core.human_input_v2.entities import EmailProviderType, IMProvider +from core.human_input_v2.shared import AccountId, TenantId +from models.human_input_v2 import HumanInputEmailProvider, HumanInputIMChannel +from repositories.human_input_v2.channel_credentials import ChannelEncryptedCredentials +from repositories.human_input_v2.im_channel_repository import ( + IMChannel, + IMChannelAlreadyConfiguredError, + IMChannelId, + IMChannelReader, + IMChannelStatus, + IMChannelWriter, + StaleIMChannelWriteError, + WebhookId, +) +from repositories.human_input_v2.sqlalchemy_im_channel_repository import ( + DeploymentIMChannelReader, + DeploymentIMChannelWriter, + WorkspaceIMChannelReader, + WorkspaceIMChannelWriter, +) + +_NOW = datetime(2026, 8, 31, 8) +_LATER = datetime(2026, 8, 31, 9) +_WORKSPACE_ONE = TenantId("00000000-0000-0000-0000-000000000101") +_WORKSPACE_TWO = TenantId("00000000-0000-0000-0000-000000000102") +_ACCOUNT_ONE = AccountId("00000000-0000-0000-0000-000000000201") +_ACCOUNT_TWO = AccountId("00000000-0000-0000-0000-000000000202") +_OwnerKind = Literal["workspace", "deployment"] + + +@pytest.fixture +def channel_engine(sqlite_engine: Engine) -> Engine: + return sqlite_engine + + +def _channel( + index: int, + *, + version: int = 1, + webhook_index: int | None = None, + provider: IMProvider = IMProvider.FEISHU, + app_identifier: str | None = None, + status: IMChannelStatus = IMChannelStatus.CONNECTED, + status_reason: str | None = None, + created_at: datetime = _NOW, + updated_at: datetime = _NOW, +) -> IMChannel: + return IMChannel( + id=IMChannelId(f"00000000-0000-0000-0000-{index:012d}"), + created_at=created_at, + updated_at=updated_at, + provider=provider, + provider_tenant_id=f"provider-tenant-{index}", + encrypted_credentials=ChannelEncryptedCredentials(ciphertext=f"opaque-ciphertext-{index}"), + app_identifier=app_identifier or f"app-{index}", + webhook_id=WebhookId(f"{webhook_index if webhook_index is not None else index:032x}"), + config_version=version, + status=status, + status_reason=status_reason, + ) + + +def _reader( + session: Session, + owner_kind: _OwnerKind, + tenant_id: TenantId = _WORKSPACE_ONE, +) -> IMChannelReader: + if owner_kind == "workspace": + return WorkspaceIMChannelReader(session, tenant_id) + return DeploymentIMChannelReader(session) + + +def _writer( + session: Session, + owner_kind: _OwnerKind, + tenant_id: TenantId = _WORKSPACE_ONE, + account_id: AccountId = _ACCOUNT_ONE, +) -> IMChannelWriter: + if owner_kind == "workspace": + return WorkspaceIMChannelWriter(session, tenant_id, account_id) + return DeploymentIMChannelWriter(session) + + +def _commit_channel( + engine: Engine, + owner_kind: _OwnerKind, + channel: IMChannel, + *, + tenant_id: TenantId = _WORKSPACE_ONE, + account_id: AccountId = _ACCOUNT_ONE, +) -> None: + with Session(engine, expire_on_commit=False) as session, session.begin(): + _writer(session, owner_kind, tenant_id, account_id).create(channel) + + +def _pending_email_configuration() -> HumanInputEmailProvider: + return HumanInputEmailProvider( + provider=EmailProviderType.RESEND, + sender_email="pending@example.com", + sender_name="Pending", + encrypted_credentials=ChannelEncryptedCredentials(ciphertext="opaque-email-key"), + tenant_id="00000000-0000-0000-0000-000000000999", + ) + + +def _public_methods(protocol: type[object]) -> set[str]: + return {name for name, value in protocol.__dict__.items() if not name.startswith("_") and inspect.isfunction(value)} + + +def test_repository_value_is_immutable_owner_free_and_credential_safe() -> None: + channel = _channel(1) + + assert tuple(field.name for field in fields(IMChannel)) == ( + "id", + "created_at", + "updated_at", + "provider", + "provider_tenant_id", + "encrypted_credentials", + "app_identifier", + "webhook_id", + "config_version", + "status", + "status_reason", + ) + assert "opaque-ciphertext-1" not in repr(channel) + assert {status.value for status in IMChannelStatus} == { + "connected", + "invalid_credentials", + "connection_failure", + } + assert IMChannelAlreadyConfiguredError.__bases__ == (Exception,) + assert StaleIMChannelWriteError.__bases__ == (Exception,) + frozen_attribute = "app_identifier" + with pytest.raises(FrozenInstanceError): + setattr(channel, frozen_attribute, "mutated") + + +def test_protocols_and_constructors_expose_only_the_frozen_persistence_contract() -> None: + assert _public_methods(IMChannelReader) == {"get"} + assert _public_methods(IMChannelWriter) == {"create", "update", "replace", "delete"} + assert tuple(inspect.signature(IMChannelReader.get).parameters) == ("self",) + assert tuple(inspect.signature(IMChannelWriter.create).parameters) == ("self", "channel") + assert tuple(inspect.signature(IMChannelWriter.update).parameters) == ( + "self", + "channel", + "expected_config_version", + ) + assert tuple(inspect.signature(IMChannelWriter.replace).parameters) == ( + "self", + "current_channel_id", + "expected_config_version", + "replacement", + ) + assert tuple(inspect.signature(IMChannelWriter.delete).parameters) == ( + "self", + "channel_id", + "expected_config_version", + ) + assert tuple(inspect.signature(WorkspaceIMChannelReader).parameters) == ("session", "tenant_id") + assert tuple(inspect.signature(WorkspaceIMChannelWriter).parameters) == ( + "session", + "tenant_id", + "configured_by_account_id", + ) + assert tuple(inspect.signature(DeploymentIMChannelReader).parameters) == ("session",) + assert tuple(inspect.signature(DeploymentIMChannelWriter).parameters) == ("session",) + + +@pytest.mark.parametrize("owner_kind", ["workspace", "deployment"]) +def test_create_maps_every_channel_value_and_caller_rollback_removes_it( + channel_engine: Engine, + owner_kind: _OwnerKind, +) -> None: + channel = _channel( + 1, + provider=IMProvider.SLACK, + status=IMChannelStatus.CONNECTION_FAILURE, + status_reason="safe failure", + ) + with Session(channel_engine, expire_on_commit=False) as session: + created = _writer(session, owner_kind).create(channel) + record = session.get_one(HumanInputIMChannel, str(channel.id)) + + assert created == channel + assert created.encrypted_credentials is channel.encrypted_credentials + assert _reader(session, owner_kind).get() == channel + assert record.owner_key == (f"workspace:{_WORKSPACE_ONE}" if owner_kind == "workspace" else "deployment") + assert record.configured_by_account_id == (str(_ACCOUNT_ONE) if owner_kind == "workspace" else None) + assert record.provider_tenant_id == channel.provider_tenant_id + assert record.encrypted_credentials == channel.encrypted_credentials + assert record.webhook_id == channel.webhook_id + assert record.status is channel.status + assert record.status_reason == channel.status_reason + session.rollback() + + with Session(channel_engine) as verification_session: + assert _reader(verification_session, owner_kind).get() is None + + +def test_readers_return_only_their_current_owner_slot(channel_engine: Engine) -> None: + workspace_one = _channel(1) + workspace_two = _channel(2) + deployment = _channel(3) + with Session(channel_engine, expire_on_commit=False) as session, session.begin(): + _writer(session, "workspace", _WORKSPACE_ONE, _ACCOUNT_ONE).create(workspace_one) + _writer(session, "workspace", _WORKSPACE_TWO, _ACCOUNT_TWO).create(workspace_two) + _writer(session, "deployment").create(deployment) + + with Session(channel_engine) as session: + assert _reader(session, "workspace", _WORKSPACE_ONE).get() == workspace_one + assert _reader(session, "workspace", _WORKSPACE_TWO).get() == workspace_two + assert _reader(session, "workspace", TenantId("00000000-0000-0000-0000-000000000103")).get() is None + assert _reader(session, "deployment").get() == deployment + + +@pytest.mark.parametrize("owner_kind", ["workspace", "deployment"]) +def test_update_uses_id_and_scalar_version_cas_and_is_rollback_safe( + channel_engine: Engine, + owner_kind: _OwnerKind, +) -> None: + current = _channel(1) + updated = replace( + current, + updated_at=_LATER, + provider_tenant_id="provider-tenant-updated", + encrypted_credentials=ChannelEncryptedCredentials(ciphertext="opaque-rotated-ciphertext"), + app_identifier="app-updated", + webhook_id=WebhookId("f" * 32), + config_version=2, + status=IMChannelStatus.INVALID_CREDENTIALS, + status_reason="credentials rejected", + ) + _commit_channel(channel_engine, owner_kind, current) + + with Session(channel_engine, expire_on_commit=False) as session: + assert _writer(session, owner_kind).update(updated, expected_config_version=1) == updated + assert _reader(session, owner_kind).get() == updated + session.rollback() + + with Session(channel_engine) as session: + assert _reader(session, owner_kind).get() == current + + with Session(channel_engine, expire_on_commit=False) as session, session.begin(): + _writer(session, owner_kind).update(updated, expected_config_version=1) + with Session(channel_engine) as session: + record = session.get_one(HumanInputIMChannel, str(current.id)) + assert _reader(session, owner_kind).get() == updated + assert record.owner_key == (f"workspace:{_WORKSPACE_ONE}" if owner_kind == "workspace" else "deployment") + assert record.configured_by_account_id == (str(_ACCOUNT_ONE) if owner_kind == "workspace" else None) + + +def test_update_rejects_invalid_next_version_before_mutation_sql(channel_engine: Engine) -> None: + current = _channel(1) + _commit_channel(channel_engine, "workspace", current) + statements: list[str] = [] + + def capture(_connection, _cursor, statement: str, _parameters, _context, _executemany) -> None: + statements.append(statement) + + event.listen(channel_engine, "before_cursor_execute", capture) + try: + with Session(channel_engine) as session: + with pytest.raises(ValueError, match="increment by one"): + _writer(session, "workspace").update(current, expected_config_version=1) + finally: + event.remove(channel_engine, "before_cursor_execute", capture) + + assert not any(statement.lstrip().upper().startswith("UPDATE") for statement in statements) + with Session(channel_engine) as session: + assert _reader(session, "workspace").get() == current + + +@pytest.mark.parametrize( + ("candidate", "expected_version"), + [ + (_channel(2, version=2), 1), + (_channel(1, version=3), 2), + ], + ids=["wrong-id", "stale-version"], +) +def test_update_reports_wrong_id_and_stale_version_without_mutation( + channel_engine: Engine, + candidate: IMChannel, + expected_version: int, +) -> None: + current = _channel(1) + _commit_channel(channel_engine, "workspace", current) + + with Session(channel_engine) as session: + with pytest.raises(StaleIMChannelWriteError): + _writer(session, "workspace").update(candidate, expected_config_version=expected_version) + + with Session(channel_engine) as session: + assert _reader(session, "workspace").get() == current + + +@pytest.mark.parametrize("owner_kind", ["workspace", "deployment"]) +def test_replace_prevents_aba_and_caller_rollback_restores_current_row( + channel_engine: Engine, + owner_kind: _OwnerKind, +) -> None: + current = _channel(1) + replacement = _channel(2) + _commit_channel(channel_engine, owner_kind, current) + + with Session(channel_engine, expire_on_commit=False) as session: + assert _writer(session, owner_kind).replace(current.id, 1, replacement) == replacement + assert _reader(session, owner_kind).get() == replacement + session.rollback() + with Session(channel_engine) as session: + assert _reader(session, owner_kind).get() == current + + with Session(channel_engine, expire_on_commit=False) as session, session.begin(): + _writer(session, owner_kind).replace(current.id, 1, replacement) + with Session(channel_engine) as session: + writer = _writer(session, owner_kind) + record = session.get_one(HumanInputIMChannel, str(replacement.id)) + assert record.configured_by_account_id == (str(_ACCOUNT_ONE) if owner_kind == "workspace" else None) + with pytest.raises(StaleIMChannelWriteError): + writer.update(replace(current, config_version=2), expected_config_version=1) + with pytest.raises(StaleIMChannelWriteError): + writer.delete(current.id, expected_config_version=1) + assert _reader(session, owner_kind).get() == replacement + + +@pytest.mark.parametrize( + "replacement", + [_channel(1), _channel(2, version=2)], + ids=["same-id", "noninitial-version"], +) +def test_replace_rejects_invalid_replacement_before_mutation_sql( + channel_engine: Engine, + replacement: IMChannel, +) -> None: + current = _channel(1) + _commit_channel(channel_engine, "workspace", current) + statements: list[str] = [] + + def capture(_connection, _cursor, statement: str, _parameters, _context, _executemany) -> None: + statements.append(statement) + + event.listen(channel_engine, "before_cursor_execute", capture) + try: + with Session(channel_engine) as session: + with pytest.raises( + ValueError, + match=( + "replacement IM Channel must use a different ID|initial IM Channel configuration version must be 1" + ), + ): + _writer(session, "workspace").replace(current.id, 1, replacement) + finally: + event.remove(channel_engine, "before_cursor_execute", capture) + + assert not any(statement.lstrip().upper().startswith(("DELETE", "INSERT")) for statement in statements) + + +@pytest.mark.parametrize("owner_kind", ["workspace", "deployment"]) +def test_delete_is_cas_guarded_rollback_safe_and_releases_committed_owner_slot( + channel_engine: Engine, + owner_kind: _OwnerKind, +) -> None: + current = _channel(1) + replacement = _channel(2) + _commit_channel(channel_engine, owner_kind, current) + + with Session(channel_engine) as session: + with pytest.raises(StaleIMChannelWriteError): + _writer(session, owner_kind).delete(current.id, expected_config_version=2) + assert _reader(session, owner_kind).get() == current + _writer(session, owner_kind).delete(current.id, expected_config_version=1) + assert _reader(session, owner_kind).get() is None + session.rollback() + with Session(channel_engine) as session: + assert _reader(session, owner_kind).get() == current + + with Session(channel_engine) as session, session.begin(): + _writer(session, owner_kind).delete(current.id, expected_config_version=1) + with Session(channel_engine) as session, session.begin(): + _writer(session, owner_kind).create(replacement) + with Session(channel_engine) as session: + assert _reader(session, owner_kind).get() == replacement + + +def test_cross_owner_ids_are_stale_and_never_mutate_foreign_rows(channel_engine: Engine) -> None: + workspace_one = _channel(1) + workspace_two = _channel(2) + deployment = _channel(3) + with Session(channel_engine, expire_on_commit=False) as session, session.begin(): + _writer(session, "workspace", _WORKSPACE_ONE, _ACCOUNT_ONE).create(workspace_one) + _writer(session, "workspace", _WORKSPACE_TWO, _ACCOUNT_TWO).create(workspace_two) + _writer(session, "deployment").create(deployment) + + with Session(channel_engine) as session: + writer = _writer(session, "workspace", _WORKSPACE_TWO, _ACCOUNT_TWO) + with pytest.raises(StaleIMChannelWriteError): + writer.update(replace(workspace_one, config_version=2), expected_config_version=1) + with pytest.raises(StaleIMChannelWriteError): + writer.replace(workspace_one.id, 1, _channel(4)) + with pytest.raises(StaleIMChannelWriteError): + writer.delete(workspace_one.id, 1) + + with Session(channel_engine) as session: + assert _reader(session, "workspace", _WORKSPACE_ONE).get() == workspace_one + assert _reader(session, "workspace", _WORKSPACE_TWO).get() == workspace_two + assert _reader(session, "deployment").get() == deployment + + +def test_create_classifies_only_owner_slot_collision_as_already_configured(channel_engine: Engine) -> None: + current = _channel(1) + _commit_channel(channel_engine, "workspace", current) + + with Session(channel_engine) as session: + with pytest.raises(IMChannelAlreadyConfiguredError): + _writer(session, "workspace").create(_channel(2)) + + with Session(channel_engine) as session: + with pytest.raises(IntegrityError) as captured: + _writer(session, "workspace", _WORKSPACE_TWO, _ACCOUNT_TWO).create(_channel(3, webhook_index=1)) + assert not isinstance(captured.value, IMChannelAlreadyConfiguredError) + + +@pytest.mark.parametrize("config_version", [0, 2]) +def test_create_rejects_noninitial_version_before_insert_sql( + channel_engine: Engine, + config_version: int, +) -> None: + statements: list[str] = [] + + def capture(_connection, _cursor, statement: str, _parameters, _context, _executemany) -> None: + statements.append(statement) + + event.listen(channel_engine, "before_cursor_execute", capture) + try: + with Session(channel_engine) as session: + with pytest.raises(ValueError, match="version must be 1"): + _writer(session, "workspace").create(_channel(1, version=config_version)) + finally: + event.remove(channel_engine, "before_cursor_execute", capture) + + assert not any(statement.lstrip().upper().startswith("INSERT") for statement in statements) + + +def test_replacement_insert_failure_propagates_and_caller_rollback_restores_old_row( + channel_engine: Engine, +) -> None: + current = _channel(1) + foreign = _channel(2) + with Session(channel_engine, expire_on_commit=False) as session, session.begin(): + _writer(session, "workspace", _WORKSPACE_ONE, _ACCOUNT_ONE).create(current) + _writer(session, "workspace", _WORKSPACE_TWO, _ACCOUNT_TWO).create(foreign) + + with Session(channel_engine) as session: + with pytest.raises(IntegrityError): + _writer(session, "workspace", _WORKSPACE_ONE, _ACCOUNT_ONE).replace( + current.id, + 1, + _channel(3, webhook_index=2), + ) + session.rollback() + + with Session(channel_engine) as session: + assert _reader(session, "workspace", _WORKSPACE_ONE).get() == current + assert _reader(session, "workspace", _WORKSPACE_TWO).get() == foreign + + +@pytest.mark.parametrize("operation", ["get", "create", "update", "replace", "delete"]) +def test_operations_do_not_flush_unrelated_pending_session_state( + channel_engine: Engine, + operation: str, +) -> None: + current = _channel(1) + if operation != "create": + _commit_channel(channel_engine, "workspace", current) + + with Session(channel_engine, expire_on_commit=False) as session: + pending = _pending_email_configuration() + session.add(pending) + if operation == "get": + assert _reader(session, "workspace").get() == current + elif operation == "create": + _writer(session, "workspace").create(current) + elif operation == "update": + _writer(session, "workspace").update(replace(current, config_version=2), 1) + elif operation == "replace": + _writer(session, "workspace").replace(current.id, 1, _channel(2)) + else: + _writer(session, "workspace").delete(current.id, 1) + + state = sa.inspect(pending) + assert state.pending + assert not state.persistent + session.rollback() + + with Session(channel_engine) as session: + assert session.scalar(sa.select(sa.func.count(HumanInputEmailProvider.id))) == 0 + + +def test_repository_sql_accesses_only_the_im_channel_table(channel_engine: Engine) -> None: + current = _channel(1) + updated = replace(current, config_version=2, updated_at=_LATER) + replacement = _channel(2) + _commit_channel(channel_engine, "workspace", current) + statements: list[str] = [] + + def capture(_connection, _cursor, statement: str, _parameters, _context, _executemany) -> None: + statements.append(statement) + + event.listen(channel_engine, "before_cursor_execute", capture) + try: + with Session(channel_engine, expire_on_commit=False) as session: + reader = _reader(session, "workspace") + writer = _writer(session, "workspace") + assert reader.get() == current + writer.update(updated, 1) + writer.replace(current.id, 2, replacement) + writer.delete(replacement.id, 1) + session.rollback() + finally: + event.remove(channel_engine, "before_cursor_execute", capture) + + assert statements + assert all("human_input_im_channels" in statement.lower() for statement in statements) diff --git a/api/tests/unit_tests/repositories/human_input_v2/test_im_identity_repository.py b/api/tests/unit_tests/repositories/human_input_v2/test_im_identity_repository.py new file mode 100644 index 00000000000..ff1cb2b88ca --- /dev/null +++ b/api/tests/unit_tests/repositories/human_input_v2/test_im_identity_repository.py @@ -0,0 +1,278 @@ +"""Behavioral contracts for Channel-bound current IM Identity persistence.""" + +from __future__ import annotations + +from dataclasses import replace +from datetime import datetime + +import pytest +import sqlalchemy as sa +from pydantic import ValidationError +from sqlalchemy.engine import Engine +from sqlalchemy.orm import Session + +from core.human_input_v2.shared import IMIdentityId, IMSyncRunId +from models.human_input_v2 import ( + HumanInputIMBinding, + HumanInputIMBindingWorkspaceOverride, + HumanInputIMIdentity, +) +from repositories.human_input_v2.im_channel_repository import IMChannelId +from repositories.human_input_v2.im_identity_repository import ( + IMIdentity, + IMIdentityAlreadyExistsError, + IMIdentityInUseError, + IMIdentityNotFoundError, + IMIdentityObservation, + IMIdentityRepositoryError, + OpaqueProviderPayload, +) +from repositories.human_input_v2.sqlalchemy_im_identity_repository import SQLAlchemyIMIdentityRepository + +_NOW = datetime(2026, 9, 1, 8) +_LATER = datetime(2026, 9, 1, 9) +_CHANNEL_ONE = IMChannelId("00000000-0000-0000-0000-000000000101") +_CHANNEL_TWO = IMChannelId("00000000-0000-0000-0000-000000000102") +_IDENTITY_ONE = IMIdentityId("00000000-0000-0000-0000-000000000201") +_IDENTITY_TWO = IMIdentityId("00000000-0000-0000-0000-000000000202") +_RUN_ONE = IMSyncRunId("00000000-0000-0000-0000-000000000301") +_RUN_TWO = IMSyncRunId("00000000-0000-0000-0000-000000000302") + + +def _observation( + provider_user_id: str, + *, + display_name: str | None = " Alice Example ", + email: str | None = " Alice@Example.COM ", + sync_run_id: IMSyncRunId = _RUN_ONE, + observed_at: datetime = _NOW, +) -> IMIdentityObservation: + return IMIdentityObservation( + provider_user_id=provider_user_id, + display_name=display_name, + email=email, + raw_payload=OpaqueProviderPayload({"provider_user_id": provider_user_id, "active": True}), + sync_run_id=sync_run_id, + observed_at=observed_at, + ) + + +def _create_committed_identity( + engine: Engine, + channel_id: IMChannelId, + identity_id: IMIdentityId, + provider_user_id: str, +) -> IMIdentity: + with Session(engine, expire_on_commit=False) as session, session.begin(): + return SQLAlchemyIMIdentityRepository(session, channel_id).create( + identity_id, + _observation(provider_user_id), + ) + + +def test_avatar_reference_is_persisted_and_can_be_cleared(sqlite_engine: Engine) -> None: + avatar_id = "00000000-0000-0000-0000-000000000601" + with Session(sqlite_engine) as session, session.begin(): + repository = SQLAlchemyIMIdentityRepository(session, _CHANNEL_ONE) + created = repository.create(_IDENTITY_ONE, replace(_observation("provider-user"), avatar_file_id=avatar_id)) + assert created.avatar_file_id == avatar_id + with Session(sqlite_engine) as session, session.begin(): + repository = SQLAlchemyIMIdentityRepository(session, _CHANNEL_ONE) + restored = repository.get(_IDENTITY_ONE) + assert restored is not None + assert restored.avatar_file_id == avatar_id + assert repository.update(_IDENTITY_ONE, _observation("provider-user")).avatar_file_id is None + with Session(sqlite_engine) as session: + assert session.get_one(HumanInputIMIdentity, str(_IDENTITY_ONE)).avatar_file_id is None + + +def test_identity_value_and_payload_are_owner_free_immutable_contracts() -> None: + identity = IMIdentity( + id=_IDENTITY_ONE, + provider_user_id="provider-user-1", + display_name="Alice", + email="alice@example.com", + last_seen_sync_run_id=_RUN_ONE, + last_seen_at=_NOW, + created_at=_NOW, + updated_at=_NOW, + ) + + assert IMIdentityRepositoryError.__bases__ == (Exception,) + assert OpaqueProviderPayload.model_config == { + "frozen": True, + "strict": True, + "validate_default": True, + } + assert isinstance(hash(identity), int) + with pytest.raises(ValidationError): + OpaqueProviderPayload.model_validate(["not", "an", "object"]) + + +def test_create_canonicalizes_query_facts_and_maps_only_safe_current_values(sqlite_engine: Engine) -> None: + with Session(sqlite_engine, expire_on_commit=False) as session: + repository = SQLAlchemyIMIdentityRepository(session, _CHANNEL_ONE) + created = repository.create(_IDENTITY_ONE, _observation(" Provider-User-1 ")) + record = session.get_one(HumanInputIMIdentity, str(_IDENTITY_ONE)) + + assert created == IMIdentity( + id=_IDENTITY_ONE, + provider_user_id="Provider-User-1", + display_name="Alice Example", + email="Alice@Example.COM", + last_seen_sync_run_id=_RUN_ONE, + last_seen_at=_NOW, + created_at=_NOW, + updated_at=_NOW, + ) + assert record.channel_id == str(_CHANNEL_ONE) + assert record.normalized_name == "alice example" + assert record.normalized_email == "alice@example.com" + assert record.raw_payload.root == {"provider_user_id": " Provider-User-1 ", "active": True} + assert not hasattr(created, "channel_id") + assert not hasattr(created, "raw_payload") + + +def test_blank_optional_profile_facts_are_persisted_as_null_pairs(sqlite_engine: Engine) -> None: + with Session(sqlite_engine) as session: + repository = SQLAlchemyIMIdentityRepository(session, _CHANNEL_ONE) + repository.create( + _IDENTITY_ONE, + _observation("provider-user-1", display_name=" \t ", email="\n"), + ) + record = session.get_one(HumanInputIMIdentity, str(_IDENTITY_ONE)) + + assert (record.display_name, record.normalized_name) == (None, None) + assert (record.email, record.normalized_email) == (None, None) + + +def test_get_many_returns_only_requested_current_channel_identities(sqlite_engine: Engine) -> None: + first = _create_committed_identity(sqlite_engine, _CHANNEL_ONE, _IDENTITY_ONE, "user-1") + second = _create_committed_identity(sqlite_engine, _CHANNEL_ONE, _IDENTITY_TWO, "user-2") + foreign_id = IMIdentityId("00000000-0000-0000-0000-000000000203") + unrequested_id = IMIdentityId("00000000-0000-0000-0000-000000000204") + missing_id = IMIdentityId("00000000-0000-0000-0000-000000000299") + _create_committed_identity(sqlite_engine, _CHANNEL_TWO, foreign_id, "foreign-user") + _create_committed_identity(sqlite_engine, _CHANNEL_ONE, unrequested_id, "unrequested-user") + + with Session(sqlite_engine) as session: + repository = SQLAlchemyIMIdentityRepository(session, _CHANNEL_ONE) + assert repository.get_many(()) == () + assert repository.get_many((missing_id, foreign_id)) == () + identities = repository.get_many((_IDENTITY_TWO, foreign_id, _IDENTITY_ONE, _IDENTITY_ONE, missing_id)) + + assert len(identities) == 2 + assert {identity.id: identity for identity in identities} == {_IDENTITY_ONE: first, _IDENTITY_TWO: second} + + +def test_reads_search_and_update_are_channel_scoped(sqlite_engine: Engine) -> None: + _create_committed_identity(sqlite_engine, _CHANNEL_ONE, _IDENTITY_ONE, "shared-user") + _create_committed_identity(sqlite_engine, _CHANNEL_TWO, _IDENTITY_TWO, "shared-user") + + with Session(sqlite_engine, expire_on_commit=False) as session: + first = SQLAlchemyIMIdentityRepository(session, _CHANNEL_ONE) + second = SQLAlchemyIMIdentityRepository(session, _CHANNEL_TWO) + + assert first.get(_IDENTITY_TWO) is None + assert first.get_by_provider_user_id(" shared-user ") == first.get(_IDENTITY_ONE) + assert first.list_all() == (first.get(_IDENTITY_ONE),) + assert first.search(keyword="ALICE", page=1, limit=20).items == (first.get(_IDENTITY_ONE),) + assert first.search(keyword="shared-USER", page=1, limit=20).total == 1 + assert second.search(keyword="alice@example.com", page=1, limit=20).total == 1 + + with pytest.raises(IMIdentityNotFoundError): + first.update(_IDENTITY_TWO, _observation("shared-user", observed_at=_LATER)) + + updated = first.update( + _IDENTITY_ONE, + _observation( + "ignored-new-provider-id", + display_name=" Updated Name ", + email=" UPDATED@EXAMPLE.COM ", + sync_run_id=_RUN_TWO, + observed_at=_LATER, + ), + ) + assert updated.provider_user_id == "shared-user" + assert updated.display_name == "Updated Name" + assert updated.email == "UPDATED@EXAMPLE.COM" + assert updated.last_seen_sync_run_id == _RUN_TWO + assert updated.created_at == _NOW + assert updated.updated_at == _LATER + + +@pytest.mark.parametrize(("page", "limit"), [(0, 1), (-1, 1), (1, 0), (1, 101)]) +def test_search_rejects_invalid_page_boundaries(sqlite_engine: Engine, page: int, limit: int) -> None: + with Session(sqlite_engine) as session: + with pytest.raises(ValueError, match="page must be positive|limit must be between 1 and 100"): + SQLAlchemyIMIdentityRepository(session, _CHANNEL_ONE).search(page=page, limit=limit) + + +def test_duplicate_provider_user_is_local_to_one_channel(sqlite_engine: Engine) -> None: + _create_committed_identity(sqlite_engine, _CHANNEL_ONE, _IDENTITY_ONE, "shared-user") + _create_committed_identity(sqlite_engine, _CHANNEL_TWO, _IDENTITY_TWO, "shared-user") + + with Session(sqlite_engine) as session: + with pytest.raises(IMIdentityAlreadyExistsError): + SQLAlchemyIMIdentityRepository(session, _CHANNEL_ONE).create( + IMIdentityId("00000000-0000-0000-0000-000000000203"), + _observation(" shared-user "), + ) + session.rollback() + + with Session(sqlite_engine) as session: + rows = session.scalars(sa.select(HumanInputIMIdentity).order_by(HumanInputIMIdentity.channel_id)).all() + assert [(row.channel_id, row.provider_user_id) for row in rows] == [ + (str(_CHANNEL_ONE), "shared-user"), + (str(_CHANNEL_TWO), "shared-user"), + ] + + +@pytest.mark.parametrize("binding_kind", ["default", "workspace_override"]) +def test_delete_rejects_an_identity_referenced_by_either_binding_table( + sqlite_engine: Engine, + binding_kind: str, +) -> None: + _create_committed_identity(sqlite_engine, _CHANNEL_ONE, _IDENTITY_ONE, "provider-user-1") + with Session(sqlite_engine) as session, session.begin(): + if binding_kind == "default": + binding = HumanInputIMBinding( + channel_id=str(_CHANNEL_ONE), + contact_id="00000000-0000-0000-0000-000000000401", + im_identity_id=str(_IDENTITY_ONE), + ) + else: + binding = HumanInputIMBindingWorkspaceOverride( + channel_id=str(_CHANNEL_ONE), + tenant_id="00000000-0000-0000-0000-000000000501", + contact_id="00000000-0000-0000-0000-000000000401", + im_identity_id=str(_IDENTITY_ONE), + ) + binding.id = "00000000-0000-0000-0000-000000000601" + session.add(binding) + + with Session(sqlite_engine) as session: + with pytest.raises(IMIdentityInUseError): + SQLAlchemyIMIdentityRepository(session, _CHANNEL_ONE).delete(_IDENTITY_ONE) + assert session.get(HumanInputIMIdentity, str(_IDENTITY_ONE)) is not None + + +def test_delete_is_idempotent_channel_scoped_and_caller_rollback_safe(sqlite_engine: Engine) -> None: + _create_committed_identity(sqlite_engine, _CHANNEL_TWO, _IDENTITY_ONE, "foreign-user") + with Session(sqlite_engine) as session: + repository = SQLAlchemyIMIdentityRepository(session, _CHANNEL_ONE) + assert repository.delete(_IDENTITY_ONE) is None + assert repository.delete(IMIdentityId("00000000-0000-0000-0000-000000000999")) is None + session.rollback() + + with Session(sqlite_engine) as session: + assert session.get(HumanInputIMIdentity, str(_IDENTITY_ONE)) is not None + + with Session(sqlite_engine) as session: + repository = SQLAlchemyIMIdentityRepository(session, _CHANNEL_ONE) + repository.create(_IDENTITY_TWO, _observation("rollback-user")) + assert repository.get(_IDENTITY_TWO) is not None + session.rollback() + + with Session(sqlite_engine) as session: + assert session.get(HumanInputIMIdentity, str(_IDENTITY_TWO)) is None diff --git a/api/tests/unit_tests/repositories/human_input_v2/test_recipient_delivery_repositories.py b/api/tests/unit_tests/repositories/human_input_v2/test_recipient_delivery_repositories.py new file mode 100644 index 00000000000..583377eede4 --- /dev/null +++ b/api/tests/unit_tests/repositories/human_input_v2/test_recipient_delivery_repositories.py @@ -0,0 +1,805 @@ +"""Persistence acceptance contracts for recipients, deliveries, and send history.""" + +from __future__ import annotations + +from concurrent.futures import ThreadPoolExecutor +from dataclasses import replace +from sqlite3 import Connection as SQLiteConnection +from threading import Barrier, Event +from uuid import uuid4 + +import pytest +import sqlalchemy as sa +from pydantic import JsonValue, ValidationError +from sqlalchemy import event +from sqlalchemy.engine import Connection, Engine +from sqlalchemy.exc import IntegrityError, MultipleResultsFound, OperationalError +from sqlalchemy.orm import Session +from sqlalchemy.pool import ConnectionPoolEntry + +from core.human_input_v2.entities import IMProvider +from core.human_input_v2.im_integration.adapters.message_locator import MessageLocator +from core.human_input_v2.shared.values import ContactId, IMBindingId, NormalizedEmail, RecipientId, TenantId +from core.workflow.nodes.human_input_v2.entities import ( + AllWorkspaceContacts, + Contact, + DynamicEmail, + Initiator, + OnetimeEmail, +) +from models.human_input_v2 import ( + HumanInputDelivery, + HumanInputDeliveryAttempt, + HumanInputRecipient, + RecipientSnapshot, + RecipientSources, + RecipientSubjectType, +) +from repositories.human_input_v2.delivery_attempt_repository import DeliveryAttemptCreateParams, DeliveryStatus +from repositories.human_input_v2.delivery_repository import ( + DeliveryCreateParams, + EmailTargetSnapshot, + IMMessageType, + IMUserTargetSnapshot, + InitiatorSnapshot, + SubmissionAuthType, + TargetSnapshot, +) +from repositories.human_input_v2.recipient_repository import ( + ContactRecipientSubject, + EmailRecipientSubject, + EndUserRecipientSubject, + Recipient, + RecipientCreateParams, +) +from repositories.human_input_v2.sqlalchemy_delivery_attempt_repository import SQLAlchemyDeliveryAttemptRepository +from repositories.human_input_v2.sqlalchemy_delivery_repository import SQLAlchemyDeliveryRepository +from repositories.human_input_v2.sqlalchemy_recipient_repository import SQLAlchemyRecipientRepository + +_TENANT = TenantId("00000000-0000-0000-0000-000000000001") +_FORM = "00000000-0000-0000-0000-000000000002" +_CONTACT = ContactId("00000000-0000-0000-0000-000000000003") +_BINDING = IMBindingId("00000000-0000-0000-0000-000000000004") +_HASH = "a" * 64 + + +@pytest.mark.parametrize("message_type", ["card", "link"]) +def test_im_snapshot_preserves_the_chosen_message_type(message_type: str) -> None: + snapshot = IMUserTargetSnapshot.model_validate( + { + "im_provider": IMProvider.SLACK, + "im_tenant_id": "team", + "im_provider_user_id": "user", + "im_binding_id": _BINDING, + "message_type": message_type, + } + ) + assert snapshot.model_dump(mode="json")["message_type"] == message_type + + +@pytest.mark.parametrize("binding_fields", [{}, {"im_binding_id": None}]) +def test_im_snapshot_requires_binding_id(binding_fields: dict[str, JsonValue]) -> None: + with pytest.raises(ValidationError, match="im_binding_id"): + IMUserTargetSnapshot.model_validate( + { + "im_provider": IMProvider.SLACK, + "im_tenant_id": "team", + "im_provider_user_id": "user", + "message_type": "card", + **binding_fields, + } + ) + + +@pytest.mark.parametrize("message_fields", [{}, {"message_type": None}]) +def test_im_snapshot_requires_message_type(message_fields: dict[str, JsonValue]) -> None: + with pytest.raises(ValidationError, match="message_type"): + IMUserTargetSnapshot.model_validate( + { + "im_provider": IMProvider.SLACK, + "im_tenant_id": "team", + "im_provider_user_id": "user", + "im_binding_id": _BINDING, + **message_fields, + } + ) + + +def test_initiator_snapshot_preserves_protected_token_without_exposing_it_in_repr() -> None: + snapshot = InitiatorSnapshot.model_validate({"protected_form_token": "encrypted-token"}) + assert snapshot.model_dump(mode="json")["protected_form_token"] == "encrypted-token" + assert "encrypted-token" not in repr(snapshot) + + +@pytest.mark.parametrize("token_fields", [{}, {"protected_form_token": None}]) +def test_initiator_snapshot_requires_protected_token(token_fields: dict[str, JsonValue]) -> None: + with pytest.raises(ValidationError, match="protected_form_token"): + InitiatorSnapshot.model_validate(token_fields) + + +@pytest.fixture +def delivery_engine(sqlite_engine: Engine) -> Engine: + engine = sqlite_engine + + # Use real outer transactions, including before the first SAVEPOINT. + @event.listens_for(engine, "connect") + def configure_transaction(connection: SQLiteConnection, _record: ConnectionPoolEntry) -> None: + connection.isolation_level = None + + @event.listens_for(engine, "begin") + def begin_transaction(connection: Connection) -> None: + connection.exec_driver_sql("BEGIN") + + return engine + + +def _recipient_params() -> RecipientCreateParams: + return RecipientCreateParams( + subject=ContactRecipientSubject(_CONTACT), + sources=(Contact(contact_id=_CONTACT), AllWorkspaceContacts()), + name="Original Contact", + ) + + +def _delivery_params(recipient_id: RecipientId) -> DeliveryCreateParams: + return DeliveryCreateParams( + recipient_id=recipient_id, + token_hash=_HASH, + auth_type=SubmissionAuthType.EMAIL_OTP, + target_snapshot=EmailTargetSnapshot(email_address="shared@example.com"), + ) + + +def _im_params(recipient_id: RecipientId, message_type: IMMessageType) -> DeliveryCreateParams: + return DeliveryCreateParams( + recipient_id=recipient_id, + token_hash=_HASH, + auth_type=SubmissionAuthType.IM, + target_snapshot=IMUserTargetSnapshot( + im_provider=IMProvider.SLACK, + im_tenant_id="team", + im_provider_user_id="user", + im_binding_id=_BINDING, + message_type=message_type, + ), + ) + + +@pytest.mark.parametrize("message_type", [IMMessageType.CARD, IMMessageType.LINK]) +def test_im_locator_round_trips_independently_of_attempts_and_cannot_be_replaced( + delivery_engine: Engine, message_type: IMMessageType +) -> None: + recipient = _create_recipient(delivery_engine) + locator = MessageLocator("opaque-accepted-message") + with Session(delivery_engine) as session, session.begin(): + repo = SQLAlchemyDeliveryRepository(session) + delivery = repo.create_delivery(tenant_id=_TENANT, form_id=_FORM, params=_im_params(recipient.id, message_type)) + assert isinstance(delivery.target_snapshot, IMUserTargetSnapshot) + assert delivery.target_snapshot.message_locator is None + assert repo.list_deliveries(tenant_id=_TENANT, form_id=_FORM) == (delivery,) + accepted = repo.set_im_message_locator( + tenant_id=_TENANT, form_id=_FORM, delivery_id=delivery.id, message_locator=locator + ) + assert accepted is not None + assert isinstance(accepted.target_snapshot, IMUserTargetSnapshot) + assert accepted.target_snapshot.message_locator == locator + assert accepted.target_snapshot.im_provider_user_id == "user" + assert accepted.target_snapshot.im_binding_id == _BINDING + assert accepted.target_snapshot.im_tenant_id == "team" + assert ( + repo.set_im_message_locator( + tenant_id=_TENANT, form_id=_FORM, delivery_id=delivery.id, message_locator=locator + ) + == accepted + ) + with pytest.raises(ValueError, match="different message locator"): + repo.set_im_message_locator( + tenant_id=_TENANT, + form_id=_FORM, + delivery_id=delivery.id, + message_locator=MessageLocator("another-card"), + ) + SQLAlchemyDeliveryAttemptRepository(session, _TENANT, _FORM).record_attempt(delivery.id, _attempt_params()) + with Session(delivery_engine) as session: + repo = SQLAlchemyDeliveryRepository(session) + reloaded = repo.get_delivery_by_token_hash(_HASH) + assert reloaded is not None + assert isinstance(reloaded.target_snapshot, IMUserTargetSnapshot) + assert reloaded.target_snapshot.message_type == message_type + assert reloaded.target_snapshot.im_binding_id == _BINDING + assert reloaded.target_snapshot.message_locator == locator + assert repo.get_delivery(tenant_id=_TENANT, form_id=_FORM, delivery_id=delivery.id) == reloaded + assert repo.list_deliveries(tenant_id=_TENANT, form_id=_FORM) == (reloaded,) + + +@pytest.mark.parametrize("wrong_owner", ["tenant", "form", "delivery"]) +def test_card_locator_assignment_preserves_owner_scope(delivery_engine: Engine, wrong_owner: str) -> None: + recipient = _create_recipient(delivery_engine) + with Session(delivery_engine) as session, session.begin(): + repo = SQLAlchemyDeliveryRepository(session) + delivery = repo.create_delivery( + tenant_id=_TENANT, form_id=_FORM, params=_im_params(recipient.id, IMMessageType.CARD) + ) + assert ( + repo.set_im_message_locator( + tenant_id=TenantId(str(uuid4())) if wrong_owner == "tenant" else _TENANT, + form_id=str(uuid4()) if wrong_owner == "form" else _FORM, + delivery_id=str(uuid4()) if wrong_owner == "delivery" else delivery.id, + message_locator=MessageLocator("opaque-card"), + ) + is None + ) + reloaded = repo.get_delivery_by_token_hash(_HASH) + assert reloaded is not None + assert isinstance(reloaded.target_snapshot, IMUserTargetSnapshot) + assert reloaded.target_snapshot.message_locator is None + + +@pytest.mark.parametrize("target", ["email", "initiator"]) +def test_non_im_deliveries_reject_message_locator_assignment(delivery_engine: Engine, target: str) -> None: + recipient = _create_recipient(delivery_engine) + params = _delivery_params(recipient.id) + if target == "initiator": + params = replace(params, target_snapshot=InitiatorSnapshot(protected_form_token="protected")) + with Session(delivery_engine) as session, session.begin(): + repo = SQLAlchemyDeliveryRepository(session) + delivery = repo.create_delivery(tenant_id=_TENANT, form_id=_FORM, params=params) + with pytest.raises(ValueError, match="Only an IM delivery"): + repo.set_im_message_locator( + tenant_id=_TENANT, form_id=_FORM, delivery_id=delivery.id, message_locator=MessageLocator("opaque-card") + ) + + +def test_card_identity_and_send_result_follow_the_callers_rollback(delivery_engine: Engine) -> None: + recipient = _create_recipient(delivery_engine) + with Session(delivery_engine) as session, session.begin(): + delivery = SQLAlchemyDeliveryRepository(session).create_delivery( + tenant_id=_TENANT, form_id=_FORM, params=_im_params(recipient.id, IMMessageType.CARD) + ) + with Session(delivery_engine) as session: + SQLAlchemyDeliveryRepository(session).set_im_message_locator( + tenant_id=_TENANT, + form_id=_FORM, + delivery_id=delivery.id, + message_locator=MessageLocator("accepted-but-rolled-back"), + ) + SQLAlchemyDeliveryAttemptRepository(session, _TENANT, _FORM).record_attempt( + delivery.id, DeliveryAttemptCreateParams(DeliveryStatus.SUCCEEDED, None, {"accepted": True}) + ) + session.rollback() + with Session(delivery_engine) as session: + record = session.get(HumanInputDelivery, delivery.id) + assert record is not None + assert isinstance(record.target_snapshot, IMUserTargetSnapshot) + assert record.target_snapshot.message_locator is None + assert session.scalar(sa.select(HumanInputDeliveryAttempt.id)) is None + + +def _attempt_params() -> DeliveryAttemptCreateParams: + return DeliveryAttemptCreateParams( + status=DeliveryStatus.FAILED, + error_message="Provider timed out", + response={"code": 504, "retryable": True, "metadata": {"request_id": "first"}, "receipt": None}, + ) + + +def _create_recipient(engine: Engine, params: RecipientCreateParams | None = None) -> Recipient: + with Session(engine) as session, session.begin(): + return SQLAlchemyRecipientRepository(session, _TENANT, _FORM).create_recipients( + [params or _recipient_params()] + )[0] + + +def test_recipients_round_trip_all_subjects_and_provenance_without_resolution(delivery_engine: Engine) -> None: + email_sources = ( + DynamicEmail(selector=["upstream", "emails"]), + OnetimeEmail(email="shared@example.com"), + Initiator(), + ) + params = [ + _recipient_params(), + RecipientCreateParams(EmailRecipientSubject(NormalizedEmail("shared@example.com")), email_sources, None), + RecipientCreateParams(EndUserRecipientSubject(_CONTACT), (Initiator(),), None), + replace(_recipient_params(), subject=ContactRecipientSubject(ContactId(str(uuid4())))), + ] + with Session(delivery_engine) as session, session.begin(): + repo = SQLAlchemyRecipientRepository(session, _TENANT, _FORM) + recipients = repo.create_recipients(params) + assert [item.subject for item in recipients] == [item.subject for item in params] + assert len({item.id for item in recipients}) == len(params) + assert repo.create_recipients([]) == () + with Session(delivery_engine) as session: + repo = SQLAlchemyRecipientRepository(session, _TENANT, _FORM) + assert repo.list_recipients() == tuple(sorted(recipients, key=lambda item: item.id)) + for recipient, param in zip(recipients, params, strict=True): + assert repo.get_recipient(recipient.id) == recipient + assert recipient.sources == param.sources + assert recipient.name == param.name + assert recipient.created_at.tzinfo is None + assert recipient.updated_at == recipient.created_at + assert repo.get_recipient(RecipientId(str(uuid4()))) is None + + +def test_retries_preserve_existing_ids_names_sources_and_timestamps(delivery_engine: Engine) -> None: + original = _create_recipient(delivery_engine) + historical_id = RecipientId(str(uuid4())) + with delivery_engine.begin() as connection: + connection.execute( + sa.update(HumanInputRecipient) + .where(HumanInputRecipient.id == original.id) + .values({HumanInputRecipient.id: historical_id, HumanInputRecipient.updated_at: original.updated_at}) + ) + historical = replace(original, id=historical_id) + extra = replace(_recipient_params(), subject=EndUserRecipientSubject(str(uuid4())), name=None) + with Session(delivery_engine) as session, session.begin(): + repo = SQLAlchemyRecipientRepository(session, _TENANT, _FORM) + retry = replace(_recipient_params(), sources=(Initiator(),), name="Changed Contact") + created = repo.create_recipients([extra, retry]) + assert created[1] == historical + assert repo.create_recipients([retry]) == (historical,) + assert len(repo.list_recipients()) == 2 + + +def test_caller_retries_concurrent_recipient_initialization(delivery_engine: Engine) -> None: + barrier = Barrier(2) + committed = Event() + second = replace(_recipient_params(), subject=EndUserRecipientSubject(str(uuid4())), name=None) + + def create(reverse: bool) -> tuple[Recipient, ...]: + params = [replace(_recipient_params(), name=f"Writer {reverse}"), second] + if reverse: + params.reverse() + barrier.wait(timeout=5) + try: + with Session(delivery_engine) as session, session.begin(): + recipients = SQLAlchemyRecipientRepository(session, _TENANT, _FORM).create_recipients(params) + except (IntegrityError, OperationalError) as error: + # SQLite may reject a concurrent read-to-write upgrade before the + # unique constraint is checked. Retry only after the winner commits. + if isinstance(error, OperationalError) and "database is locked" not in str(error): + raise + assert committed.wait(timeout=10) + with Session(delivery_engine) as session, session.begin(): + return SQLAlchemyRecipientRepository(session, _TENANT, _FORM).create_recipients(params) + committed.set() + return recipients + + with ThreadPoolExecutor(max_workers=2) as executor: + first = executor.submit(create, False) + second_result = executor.submit(create, True) + forward = first.result(timeout=15) + backward = second_result.result(timeout=15) + assert forward == tuple(reversed(backward)) + with Session(delivery_engine) as session: + assert len(SQLAlchemyRecipientRepository(session, _TENANT, _FORM).list_recipients()) == 2 + + +@pytest.mark.parametrize("tenant", [_TENANT, TenantId("00000000-0000-0000-0000-000000000004")]) +def test_database_rejects_duplicate_subjects_in_the_same_form(delivery_engine: Engine, tenant: TenantId) -> None: + original = _create_recipient(delivery_engine) + with Session(delivery_engine) as session: + session.add( + HumanInputRecipient( + tenant_id=tenant, + form_id=_FORM, + subject_type=RecipientSubjectType.CONTACT, + subject_value=_CONTACT, + sources=RecipientSources(root=[]), + snapshot=RecipientSnapshot(name="Duplicate"), + ) + ) + with pytest.raises(IntegrityError): + session.flush() + session.rollback() + with Session(delivery_engine) as session: + assert SQLAlchemyRecipientRepository(session, _TENANT, _FORM).list_recipients() == (original,) + + +def test_same_subject_can_participate_in_different_forms(delivery_engine: Engine) -> None: + original = _create_recipient(delivery_engine) + with Session(delivery_engine) as session, session.begin(): + other = SQLAlchemyRecipientRepository(session, _TENANT, str(uuid4())).create_recipients([_recipient_params()])[ + 0 + ] + assert other.id != original.id + assert other.subject == original.subject + + +def test_failed_batch_is_rolled_back_by_the_caller_transaction(delivery_engine: Engine) -> None: + with delivery_engine.begin() as connection: + connection.exec_driver_sql( + "CREATE TRIGGER reject_recipient BEFORE INSERT ON hitlv2_recipients " + "WHEN NEW.subject_value = 'reject' BEGIN SELECT RAISE(ABORT, 'recipient rejected'); END" + ) + with Session(delivery_engine) as session: + repo = SQLAlchemyRecipientRepository(session, _TENANT, _FORM) + repo.create_recipients([_recipient_params()]) + params = [ + RecipientCreateParams(ContactRecipientSubject(ContactId("accepted")), (), "Accepted"), + RecipientCreateParams(EndUserRecipientSubject("reject"), (), None), + ] + with pytest.raises(IntegrityError, match="recipient rejected"): + repo.create_recipients(params) + assert not session.is_active + session.rollback() + with Session(delivery_engine) as session: + assert SQLAlchemyRecipientRepository(session, _TENANT, _FORM).list_recipients() == () + + +@pytest.mark.parametrize( + ("target", "authentication"), + [ + (EmailTargetSnapshot(email_address="shared@example.com"), SubmissionAuthType.EMAIL_OTP), + (InitiatorSnapshot(protected_form_token="encrypted-token"), SubmissionAuthType.CONSOLE), + (InitiatorSnapshot(protected_form_token="encrypted-token"), SubmissionAuthType.WEB_APP), + *[ + ( + IMUserTargetSnapshot( + im_provider=provider, + im_tenant_id="provider-tenant", + im_provider_user_id="provider-user", + im_binding_id=_BINDING, + message_type=IMMessageType.CARD, + ), + SubmissionAuthType.IM, + ) + for provider in IMProvider + ], + ], +) +def test_delivery_round_trips_frozen_access_data( + delivery_engine: Engine, target: TargetSnapshot, authentication: SubmissionAuthType +) -> None: + recipient = _create_recipient(delivery_engine) + params = replace(_delivery_params(recipient.id), target_snapshot=target, auth_type=authentication) + with Session(delivery_engine) as session, session.begin(): + delivery = SQLAlchemyDeliveryRepository(session).create_delivery( + tenant_id=_TENANT, form_id=_FORM, params=params + ) + assert delivery is not None + assert delivery.recipient_id == recipient.id + assert delivery.target_snapshot == target + assert delivery.auth_type == authentication + assert delivery.token_hash == _HASH + assert delivery.created_at.tzinfo is None + assert delivery.updated_at == delivery.created_at + with Session(delivery_engine) as session: + repo = SQLAlchemyDeliveryRepository(session) + assert repo.get_delivery_by_token_hash(_HASH) == delivery + assert repo.get_delivery_by_token_hash(_HASH.upper()) is None + assert repo.get_delivery(tenant_id=_TENANT, form_id=_FORM, delivery_id=delivery.id) == delivery + assert repo.get_delivery(tenant_id=_TENANT, form_id=_FORM, delivery_id=str(uuid4())) is None + assert repo.list_deliveries(tenant_id=_TENANT, form_id=_FORM) == (delivery,) + assert ( + session.scalar(sa.select(HumanInputDelivery.target_type).where(HumanInputDelivery.id == delivery.id)) + == target.type + ) + + +def test_deliveries_keep_distinct_recipients_sharing_an_endpoint_and_reject_ambiguous_hashes( + delivery_engine: Engine, +) -> None: + recipient = _create_recipient(delivery_engine) + other = _create_recipient( + delivery_engine, replace(_recipient_params(), subject=ContactRecipientSubject(ContactId(str(uuid4())))) + ) + with Session(delivery_engine) as session, session.begin(): + repo = SQLAlchemyDeliveryRepository(session) + first = repo.create_delivery(tenant_id=_TENANT, form_id=_FORM, params=_delivery_params(recipient.id)) + second = repo.create_delivery(tenant_id=_TENANT, form_id=_FORM, params=_delivery_params(other.id)) + third = repo.create_delivery( + tenant_id=_TENANT, form_id=_FORM, params=replace(_delivery_params(recipient.id), token_hash="b" * 64) + ) + assert first is not None + assert second is not None + assert third is not None + assert len({first.id, second.id, third.id}) == 3 + assert repo.list_deliveries(tenant_id=_TENANT, form_id=_FORM) == tuple( + sorted((first, second, third), key=lambda delivery: delivery.id) + ) + persisted_recipients = ( + session.execute(sa.select(HumanInputDelivery.id, HumanInputDelivery.recipient_id)).tuples().all() + ) + assert dict(persisted_recipients) == { + first.id: recipient.id, + second.id: other.id, + third.id: recipient.id, + } + assert repo.get_delivery_by_token_hash("b" * 64) == third + with pytest.raises(MultipleResultsFound): + repo.get_delivery_by_token_hash(_HASH) + + +@pytest.mark.parametrize( + ("tenant", "form"), + [ + (TenantId("00000000-0000-0000-0000-000000000004"), _FORM), + (_TENANT, "00000000-0000-0000-0000-000000000005"), + ], +) +def test_owner_scoped_writes_and_global_token_lookup(delivery_engine: Engine, tenant: TenantId, form: str) -> None: + recipient = _create_recipient(delivery_engine) + with Session(delivery_engine) as session, session.begin(): + delivery = SQLAlchemyDeliveryRepository(session).create_delivery( + tenant_id=_TENANT, form_id=_FORM, params=_delivery_params(recipient.id) + ) + assert delivery is not None + attempt = SQLAlchemyDeliveryAttemptRepository(session, _TENANT, _FORM).record_attempt( + delivery.id, _attempt_params() + ) + assert attempt is not None + recipients = SQLAlchemyRecipientRepository(session, tenant, form) + deliveries = SQLAlchemyDeliveryRepository(session) + attempts = SQLAlchemyDeliveryAttemptRepository(session, tenant, form) + assert recipients.list_recipients() == () + assert recipients.get_recipient(recipient.id) is None + assert deliveries.get_delivery_by_token_hash(_HASH) == delivery + assert deliveries.get_delivery(tenant_id=tenant, form_id=form, delivery_id=delivery.id) is None + assert deliveries.list_deliveries(tenant_id=tenant, form_id=form) == () + assert attempts.record_attempt(delivery.id, _attempt_params()) is None + assert session.scalars(sa.select(HumanInputDeliveryAttempt.id)).all() == [attempt.id] + other_form = str(uuid4()) + other = SQLAlchemyRecipientRepository(session, tenant, other_form).create_recipients([_recipient_params()])[0] + assert other.id != recipient.id + own_delivery = deliveries.create_delivery( + tenant_id=tenant, + form_id=other_form, + params=replace(_delivery_params(other.id), token_hash="b" * 64), + ) + assert own_delivery is not None + assert own_delivery.tenant_id == tenant + assert own_delivery.form_id == other_form + assert deliveries.get_delivery_by_token_hash("b" * 64) == own_delivery + assert deliveries.get_delivery_by_token_hash(_HASH) == delivery + assert deliveries.list_deliveries(tenant_id=_TENANT, form_id=_FORM) == (delivery,) + assert deliveries.list_deliveries(tenant_id=tenant, form_id=other_form) == (own_delivery,) + assert ( + deliveries.create_delivery(tenant_id=tenant, form_id=other_form, params=_delivery_params(other.id)) + is not None + ) + with pytest.raises(MultipleResultsFound): + deliveries.get_delivery_by_token_hash(_HASH) + with Session(delivery_engine) as session: + assert SQLAlchemyDeliveryRepository(session).get_delivery_by_token_hash("b" * 64) == own_delivery + + +def test_delivery_creation_trusts_caller_supplied_recipient(delivery_engine: Engine) -> None: + recipient_id = RecipientId(str(uuid4())) + with Session(delivery_engine) as session, session.begin(): + deliveries = SQLAlchemyDeliveryRepository(session) + delivery = deliveries.create_delivery(tenant_id=_TENANT, form_id=_FORM, params=_delivery_params(recipient_id)) + assert delivery is not None + assert delivery.recipient_id == recipient_id + assert delivery.tenant_id == _TENANT + assert delivery.form_id == _FORM + with Session(delivery_engine) as session: + assert SQLAlchemyDeliveryRepository(session).get_delivery_by_token_hash(_HASH) == delivery + + +def test_missing_delivery_does_not_create_attempt(delivery_engine: Engine) -> None: + with Session(delivery_engine) as session, session.begin(): + attempts = SQLAlchemyDeliveryAttemptRepository(session, _TENANT, _FORM) + assert attempts.record_attempt(str(uuid4()), _attempt_params()) is None + assert session.scalar(sa.select(HumanInputDeliveryAttempt.id)) is None + + +def test_retry_and_resend_append_history_without_changing_delivery(delivery_engine: Engine) -> None: + recipient = _create_recipient(delivery_engine) + with Session(delivery_engine) as session, session.begin(): + deliveries = SQLAlchemyDeliveryRepository(session) + delivery = deliveries.create_delivery(tenant_id=_TENANT, form_id=_FORM, params=_delivery_params(recipient.id)) + assert delivery is not None + attempts = SQLAlchemyDeliveryAttemptRepository(session, _TENANT, _FORM) + first = attempts.record_attempt(delivery.id, _attempt_params()) + success = DeliveryAttemptCreateParams(DeliveryStatus.SUCCEEDED, None, {"receipt": "provider-accepted"}) + retry = attempts.record_attempt(delivery.id, success) + resend = attempts.record_attempt(delivery.id, success) + assert first is not None + assert retry is not None + assert resend is not None + assert len({first.id, retry.id, resend.id}) == 3 + assert first.response == _attempt_params().response + assert first.error_message == _attempt_params().error_message + assert first.status == DeliveryStatus.FAILED + assert retry.status == DeliveryStatus.SUCCEEDED + assert retry.error_message is None + assert first.created_at.tzinfo is None + assert first.updated_at == first.created_at + assert deliveries.get_delivery_by_token_hash(_HASH) == delivery + with Session(delivery_engine) as session: + history = {record.id: record for record in session.scalars(sa.select(HumanInputDeliveryAttempt))} + assert set(history) == {first.id, retry.id, resend.id} + for expected in (first, retry, resend): + record = history[expected.id] + assert (record.tenant_id, record.form_id, record.delivery_id) == (_TENANT, _FORM, delivery.id) + assert record.status == expected.status + assert record.error_message == expected.error_message + assert record.response.root == expected.response + assert record.created_at == expected.created_at + assert record.updated_at == expected.updated_at + + +def test_caller_can_roll_back_all_three_repositories_atomically(delivery_engine: Engine) -> None: + with Session(delivery_engine) as session: + recipient = SQLAlchemyRecipientRepository(session, _TENANT, _FORM).create_recipients([_recipient_params()])[0] + delivery = SQLAlchemyDeliveryRepository(session).create_delivery( + tenant_id=_TENANT, form_id=_FORM, params=_delivery_params(recipient.id) + ) + assert delivery is not None + assert ( + SQLAlchemyDeliveryAttemptRepository(session, _TENANT, _FORM).record_attempt(delivery.id, _attempt_params()) + is not None + ) + session.rollback() + with Session(delivery_engine) as session: + assert SQLAlchemyRecipientRepository(session, _TENANT, _FORM).list_recipients() == () + assert session.scalar(sa.select(HumanInputDelivery.id)) is None + assert session.scalar(sa.select(HumanInputDeliveryAttempt.id)) is None + + +def test_repository_operations_do_not_flush_unrelated_pending_caller_work(delivery_engine: Engine) -> None: + with Session(delivery_engine) as session: + pending = HumanInputRecipient( + tenant_id=_TENANT, + form_id=_FORM, + subject_type=RecipientSubjectType.END_USER, + subject_value="pending", + sources=RecipientSources(root=[]), + snapshot=RecipientSnapshot(), + ) + session.add(pending) + recipients = SQLAlchemyRecipientRepository(session, _TENANT, _FORM) + recipient = recipients.create_recipients([_recipient_params()])[0] + deliveries = SQLAlchemyDeliveryRepository(session) + delivery = deliveries.create_delivery(tenant_id=_TENANT, form_id=_FORM, params=_delivery_params(recipient.id)) + assert delivery is not None + attempts = SQLAlchemyDeliveryAttemptRepository(session, _TENANT, _FORM) + assert attempts.record_attempt(delivery.id, _attempt_params()) is not None + assert recipients.list_recipients() == (recipient,) + assert deliveries.get_delivery_by_token_hash(_HASH) == delivery + assert ( + session.scalar( + sa.select(sa.func.count()).select_from(HumanInputDeliveryAttempt).execution_options(autoflush=False) + ) + == 1 + ) + assert pending in session.new + session.rollback() + + +def test_mutating_supplied_or_returned_json_does_not_rewrite_persisted_history(delivery_engine: Engine) -> None: + source = DynamicEmail(selector=["node", "email"]) + params = replace(_recipient_params(), sources=(source,)) + with Session(delivery_engine) as session, session.begin(): + recipients = SQLAlchemyRecipientRepository(session, _TENANT, _FORM) + recipient = recipients.create_recipients([params])[0] + source.selector = ["changed", "selector"] + assert recipient.sources == (DynamicEmail(selector=["node", "email"]),) + delivery = SQLAlchemyDeliveryRepository(session).create_delivery( + tenant_id=_TENANT, form_id=_FORM, params=_delivery_params(recipient.id) + ) + assert delivery is not None + attempts = SQLAlchemyDeliveryAttemptRepository(session, _TENANT, _FORM) + metadata: dict[str, JsonValue] = {"request_id": "original"} + response: dict[str, JsonValue] = {"metadata": metadata} + attempt = attempts.record_attempt( + delivery.id, DeliveryAttemptCreateParams(DeliveryStatus.SUCCEEDED, None, response) + ) + assert attempt is not None + metadata["request_id"] = "mutated" + assert attempt.response == {"metadata": {"request_id": "original"}} + returned_source = recipient.sources[0] + assert isinstance(returned_source, DynamicEmail) + returned_source.selector = ["changed", "again"] + reloaded = recipients.get_recipient(recipient.id) + assert reloaded is not None + assert reloaded.sources == (DynamicEmail(selector=["node", "email"]),) + with Session(delivery_engine) as session: + persisted_attempt = session.get(HumanInputDeliveryAttempt, attempt.id) + assert persisted_attempt is not None + assert persisted_attempt.response.root == {"metadata": {"request_id": "original"}} + + +@pytest.mark.parametrize( + ("table", "column", "payload"), + [ + ("hitlv2_recipients", "sources", '[{"type":"unknown"}]'), + ("hitlv2_recipients", "snapshot", '{"name":42}'), + ("hitlv2_deliveries", "target_snapshot", '{"type":"email","email_address":"invalid"}'), + ("hitlv2_delivery_attempts", "response", "[]"), + ], +) +def test_malformed_persisted_json_is_rejected_at_the_storage_boundary( + delivery_engine: Engine, table: str, column: str, payload: str +) -> None: + recipient = _create_recipient(delivery_engine) + with Session(delivery_engine) as session, session.begin(): + delivery = SQLAlchemyDeliveryRepository(session).create_delivery( + tenant_id=_TENANT, form_id=_FORM, params=_delivery_params(recipient.id) + ) + assert delivery is not None + attempt = SQLAlchemyDeliveryAttemptRepository(session, _TENANT, _FORM).record_attempt( + delivery.id, _attempt_params() + ) + assert attempt is not None + with delivery_engine.begin() as connection: + connection.execute(sa.text(f"UPDATE {table} SET {column} = :payload"), {"payload": payload}) + with Session(delivery_engine) as session: + if table == "hitlv2_recipients": + with pytest.raises(ValidationError): + SQLAlchemyRecipientRepository(session, _TENANT, _FORM).get_recipient(recipient.id) + elif table == "hitlv2_deliveries": + with pytest.raises(ValidationError): + SQLAlchemyDeliveryRepository(session).get_delivery_by_token_hash(_HASH) + else: + with pytest.raises(ValidationError): + session.get(HumanInputDeliveryAttempt, attempt.id) + + +def test_initiator_delivery_lookup_is_scoped_to_form_and_tenant(delivery_engine: Engine) -> None: + recipient = _create_recipient(delivery_engine) + initiator = _create_recipient( + delivery_engine, replace(_recipient_params(), subject=EndUserRecipientSubject(str(uuid4()))) + ) + with Session(delivery_engine) as session, session.begin(): + repo = SQLAlchemyDeliveryRepository(session) + assert repo.get_initiator_delivery(tenant_id=_TENANT, form_id=_FORM) is None + repo.create_delivery(tenant_id=_TENANT, form_id=_FORM, params=_delivery_params(recipient.id)) + repo.create_delivery( + tenant_id=_TENANT, + form_id=_FORM, + params=replace( + _delivery_params(recipient.id), + target_snapshot=IMUserTargetSnapshot( + im_provider=IMProvider.SLACK, + im_tenant_id="team", + im_provider_user_id="user", + im_binding_id=_BINDING, + message_type=IMMessageType.CARD, + ), + auth_type=SubmissionAuthType.IM, + ), + ) + assert repo.get_initiator_delivery(tenant_id=_TENANT, form_id=_FORM) is None + delivery = repo.create_delivery( + tenant_id=_TENANT, + form_id=_FORM, + params=replace( + _delivery_params(initiator.id), + target_snapshot=InitiatorSnapshot(protected_form_token="encrypted-initiator-token"), + auth_type=SubmissionAuthType.WEB_APP, + ), + ) + with Session(delivery_engine) as session: + repo = SQLAlchemyDeliveryRepository(session) + assert repo.get_initiator_delivery(tenant_id=_TENANT, form_id=_FORM) == delivery + assert repo.get_initiator_delivery(tenant_id=TenantId(str(uuid4())), form_id=_FORM) is None + assert repo.get_initiator_delivery(tenant_id=_TENANT, form_id=str(uuid4())) is None + + +@pytest.mark.parametrize("same_recipient", [False, True]) +def test_initiator_delivery_lookup_rejects_multiple_entries_for_the_form( + delivery_engine: Engine, same_recipient: bool +) -> None: + recipient = _create_recipient(delivery_engine) + other = ( + recipient + if same_recipient + else _create_recipient( + delivery_engine, replace(_recipient_params(), subject=EndUserRecipientSubject(str(uuid4()))) + ) + ) + with Session(delivery_engine) as session, session.begin(): + repo = SQLAlchemyDeliveryRepository(session) + for candidate in (recipient, other): + repo.create_delivery( + tenant_id=_TENANT, + form_id=_FORM, + params=replace( + _delivery_params(candidate.id), + target_snapshot=InitiatorSnapshot(protected_form_token="encrypted-token"), + auth_type=SubmissionAuthType.CONSOLE, + ), + ) + with Session(delivery_engine) as session, pytest.raises(MultipleResultsFound): + SQLAlchemyDeliveryRepository(session).get_initiator_delivery(tenant_id=_TENANT, form_id=_FORM) diff --git a/api/tests/unit_tests/repositories/human_input_v2/test_sqlalchemy_form_repository.py b/api/tests/unit_tests/repositories/human_input_v2/test_sqlalchemy_form_repository.py new file mode 100644 index 00000000000..2475096365d --- /dev/null +++ b/api/tests/unit_tests/repositories/human_input_v2/test_sqlalchemy_form_repository.py @@ -0,0 +1,99 @@ +"""Accepted submissions require and preserve their authenticated delivery.""" + +from datetime import timedelta +from uuid import uuid4 + +import pytest +import sqlalchemy as sa +from pydantic import ValidationError +from sqlalchemy.orm import Session, sessionmaker + +from core.human_input_v2.resolved_form import ResolvedForm +from core.human_input_v2.shared.values import RecipientId, TenantId +from core.workflow.nodes.human_input.enums import HumanInputFormKind +from libs.datetime_utils import naive_utc_now +from models.human_input_v2 import HumanInputForm +from repositories.human_input_v2.form_repository import Form, FormCreateParams, FormSubmissionFailure +from repositories.human_input_v2.sqlalchemy_form_repository import SQLAlchemyFormRepository + + +@pytest.fixture +def form(sqlite_session_factory: sessionmaker[Session]) -> Form: + now = naive_utc_now() + with sqlite_session_factory.begin() as session: + return SQLAlchemyFormRepository(session, TenantId(str(uuid4())), str(uuid4())).create_form( + FormCreateParams( + workflow_run_id=None, + node_execution_id=None, + form_kind=HumanInputFormKind.DELIVERY_TEST, + resolved_form=ResolvedForm(title="Approval", parts=(), actions=(), legacy_form_content="Review"), + expiration_time=now + timedelta(hours=1), + global_timeout_deadline=now + timedelta(hours=2), + ) + ) + + +def test_submission_preserves_delivery_on_read_and_rejected_write( + sqlite_session_factory: sessionmaker[Session], form: Form +) -> None: + recipient_id = RecipientId(str(uuid4())) + delivery_id = str(uuid4()) + with sqlite_session_factory.begin() as session: + repository = SQLAlchemyFormRepository(session, form.tenant_id, form.app_id) + accepted = repository.submit_form( + form.id, + submitted_by_recipient_id=recipient_id, + submitted_by_delivery_id=delivery_id, + selected_action_id="approve", + raw_submission_inputs={"note": "Accepted"}, + normalized_submission_data={}, + ) + assert isinstance(accepted, Form) + + with sqlite_session_factory.begin() as session: + repository = SQLAlchemyFormRepository(session, form.tenant_id, form.app_id) + loaded = repository.get_form_by_id(form.id) + assert loaded is not None + assert loaded.submission is not None + assert loaded.submission.submitted_by_delivery_id == delivery_id + assert loaded.submission.submitted_by_recipient_id == recipient_id + assert loaded.submission.raw_submission_inputs == {"note": "Accepted"} + assert ( + repository.submit_form( + form.id, + submitted_by_recipient_id=recipient_id, + submitted_by_delivery_id=str(uuid4()), + selected_action_id="reject", + raw_submission_inputs={}, + normalized_submission_data={}, + ) + == FormSubmissionFailure.ALREADY_SUBMITTED + ) + assert repository.get_form_by_id(form.id) == loaded + + +def test_submitted_form_requires_submission_delivery(sqlite_session_factory: sessionmaker[Session], form: Form) -> None: + with sqlite_session_factory.begin() as session: + SQLAlchemyFormRepository(session, form.tenant_id, form.app_id).submit_form( + form.id, + submitted_by_recipient_id=RecipientId(str(uuid4())), + submitted_by_delivery_id=str(uuid4()), + selected_action_id="approve", + raw_submission_inputs={}, + normalized_submission_data={}, + ) + session.get_one(HumanInputForm, form.id).submitted_by_delivery_id = None + + with sqlite_session_factory() as session, pytest.raises(ValidationError, match="complete submission"): + SQLAlchemyFormRepository(session, form.tenant_id, form.app_id).get_form_by_id(form.id) + + +def test_waiting_form_cannot_contain_submission_delivery( + sqlite_session_factory: sessionmaker[Session], form: Form +) -> None: + with sqlite_session_factory.begin() as session: + session.execute( + sa.update(HumanInputForm).where(HumanInputForm.id == form.id).values(submitted_by_delivery_id=str(uuid4())) + ) + with sqlite_session_factory() as session, pytest.raises(ValidationError): + SQLAlchemyFormRepository(session, form.tenant_id, form.app_id).get_form_by_id(form.id) diff --git a/api/tests/unit_tests/repositories/test_sqlalchemy_api_workflow_run_repository.py b/api/tests/unit_tests/repositories/test_sqlalchemy_api_workflow_run_repository.py index b89ebad768a..d2aa5596929 100644 --- a/api/tests/unit_tests/repositories/test_sqlalchemy_api_workflow_run_repository.py +++ b/api/tests/unit_tests/repositories/test_sqlalchemy_api_workflow_run_repository.py @@ -27,10 +27,123 @@ from repositories.sqlalchemy_api_workflow_run_repository import ( WorkflowRunPauseRecord, WorkflowTaskOwnerMismatchError, _build_human_input_required_reason, - _PrivateWorkflowPauseEntity, ) +@pytest.fixture +def workflow_run(sqlite_session_factory: sessionmaker[Session]) -> WorkflowRun: + run = WorkflowRun( + id=str(uuid4()), + tenant_id=str(uuid4()), + app_id=str(uuid4()), + workflow_id=str(uuid4()), + type=WorkflowType.WORKFLOW, + triggered_from=WorkflowRunTriggeredFrom.APP_RUN, + version="draft", + graph="{}", + inputs="{}", + status=WorkflowExecutionStatus.PAUSED, + created_by_role=CreatorUserRole.ACCOUNT, + created_by=str(uuid4()), + ) + with sqlite_session_factory.begin() as session: + session.add(run) + return run + + +@pytest.mark.parametrize("owner_scope", ["matching", "other_tenant", "other_app", "missing_run"]) +def test_locked_workflow_run_read_requires_complete_owner_scope( + sqlite_session_factory: sessionmaker[Session], workflow_run: WorkflowRun, owner_scope: str +) -> None: + repository = DifyAPISQLAlchemyWorkflowRunRepository(sqlite_session_factory) + with sqlite_session_factory.begin() as session: + run = repository.get_workflow_run_by_id_for_update( + session, + tenant_id=str(uuid4()) if owner_scope == "other_tenant" else workflow_run.tenant_id, + app_id=str(uuid4()) if owner_scope == "other_app" else workflow_run.app_id, + run_id=str(uuid4()) if owner_scope == "missing_run" else workflow_run.id, + ) + if owner_scope != "matching": + assert run is None + return + assert run is not None + assert run.id == workflow_run.id + assert run.status == WorkflowExecutionStatus.PAUSED + + +def test_locked_workflow_run_changes_follow_the_callers_rollback( + sqlite_session_factory: sessionmaker[Session], workflow_run: WorkflowRun +) -> None: + repository = DifyAPISQLAlchemyWorkflowRunRepository(sqlite_session_factory) + with sqlite_session_factory() as session: + session.begin() + run = repository.get_workflow_run_by_id_for_update( + session, + tenant_id=workflow_run.tenant_id, + app_id=workflow_run.app_id, + run_id=workflow_run.id, + ) + assert run is not None + run.status = WorkflowExecutionStatus.STOPPED + session.flush() + session.rollback() + + with sqlite_session_factory() as session: + persisted_run = session.get(WorkflowRun, workflow_run.id) + assert persisted_run is not None + assert persisted_run.status == WorkflowExecutionStatus.PAUSED + + +@pytest.mark.parametrize( + ("pause_state", "owner_scope", "expected"), + [ + ("active", "matching", True), + ("resumed", "matching", False), + ("missing", "matching", False), + ("active", "other_tenant", False), + ("active", "other_app", False), + ("active", "missing_run", False), + ], +) +def test_active_workflow_pause_requires_unresumed_pause_in_complete_owner_scope( + sqlite_session_factory: sessionmaker[Session], + workflow_run: WorkflowRun, + pause_state: str, + owner_scope: str, + expected: bool, +) -> None: + repository = DifyAPISQLAlchemyWorkflowRunRepository(sqlite_session_factory) + with sqlite_session_factory() as session: + if pause_state != "missing": + session.add( + WorkflowPause( + workflow_id=workflow_run.workflow_id, + workflow_run_id=workflow_run.id, + state_object_key="checkpoint.json", + resumed_at=datetime(2024, 1, 1) if pause_state == "resumed" else None, + ) + ) + session.flush() + assert ( + repository.has_active_workflow_pause( + session, + tenant_id=str(uuid4()) if owner_scope == "other_tenant" else workflow_run.tenant_id, + app_id=str(uuid4()) if owner_scope == "other_app" else workflow_run.app_id, + run_id=str(uuid4()) if owner_scope == "missing_run" else workflow_run.id, + ) + is expected + ) + session.rollback() + + with sqlite_session_factory() as session: + assert not repository.has_active_workflow_pause( + session, + tenant_id=workflow_run.tenant_id, + app_id=workflow_run.app_id, + run_id=workflow_run.id, + ) + + def _build_form_model() -> HumanInputForm: expiration_time = datetime(2024, 1, 1, tzinfo=UTC) definition = FormDefinition( @@ -143,33 +256,18 @@ def test_workflow_pause_reason_to_entity_restores_graphon_hitl_reason() -> None: assert reason.node_id == "node-1" -def test_private_workflow_pause_entity_preserves_list_shaped_pause_reasons() -> None: - pause_reasons = [ +@pytest.mark.parametrize( + "reason", + [ + HitlRequired(session_id="hitlv2:form-2", node_id="node-2", node_title="Approval"), HumanInputRequired( - form_id="form-1", - form_content="content", - inputs=[], - actions=[], - node_id="node-1", - node_title="Ask Name", - ) - ] - pause_model = WorkflowPause( - workflow_id="workflow-1", - workflow_run_id="run-1", - state_object_key="pause-state", - ) - pause_model.id = "pause-1" - entity = _PrivateWorkflowPauseEntity( - pause_model=pause_model, - reason_models=[], - pause_reasons=pause_reasons, - ) - - result = entity.get_pause_reasons() - - assert isinstance(result, list) - assert result == pause_reasons + form_version="2", form_id="form-2", form_content="Approve", node_id="node-2", node_title="Approval" + ), + ], +) +def test_legacy_pause_reason_rejects_v2_references(reason) -> None: + with pytest.raises(ValueError, match="only accepts HITL v1"): + WorkflowPauseReason.from_entity(pause_id="pause-2", pause_reason=reason) def _message(*, message_id: str, app_id: str, workflow_run_id: str, conversation_id: str) -> Message: @@ -244,21 +342,21 @@ def test_get_pause_record_scopes_the_workflow_run_to_the_workspace( sqlite_session.commit() repository = DifyAPISQLAlchemyWorkflowRunRepository(session_maker=sqlite_session_factory) - assert repository.get_pause_record(workspace_id="tenant-2", workflow_run_id="run-1") is None + assert repository.get_pause_record(sqlite_session, workspace_id="tenant-2", workflow_run_id="run-1") is None assert repository.get_pause_record( + sqlite_session, workspace_id="tenant-1", workflow_run_id="run-1", ) == WorkflowRunPauseRecord( status=WorkflowExecutionStatus.SUCCEEDED, - paused_at=None, - reasons=(), - form_tokens={}, + pause=None, ) -def test_get_pause_record_loads_reasons_and_tokens_in_one_repository_call( +def test_get_pause_record_returns_run_bound_checkpoint( sqlite_session: Session, sqlite_session_factory: sessionmaker[Session], + monkeypatch: pytest.MonkeyPatch, ) -> None: workflow_run = _workflow_run( run_id="run-1", @@ -271,32 +369,20 @@ def test_get_pause_record_loads_reasons_and_tokens_in_one_repository_call( state_object_key="pause-state", ) pause.id = "pause-1" - reason = WorkflowPauseReason( - pause_id=pause.id, - type_=PauseReasonType.HITL_REQUIRED, - form_id="form-1", - node_id="node-1", - ) - recipient = HumanInputFormRecipient( - form_id="form-1", - delivery_id="delivery-1", - recipient_type=RecipientType.CONSOLE, - recipient_payload="{}", - access_token="form-token", - ) - sqlite_session.add_all([workflow_run, pause, reason, recipient]) + sqlite_session.add_all([workflow_run, pause]) sqlite_session.commit() repository = DifyAPISQLAlchemyWorkflowRunRepository(session_maker=sqlite_session_factory) - result = repository.get_pause_record(workspace_id="tenant-1", workflow_run_id="run-1") + monkeypatch.setattr("repositories.sqlalchemy_api_workflow_run_repository.storage.load", lambda _key: b"checkpoint") + result = repository.get_pause_record(sqlite_session, workspace_id="tenant-1", workflow_run_id="run-1") assert result is not None assert result.status == WorkflowExecutionStatus.PAUSED - assert result.paused_at == pause.created_at - assert len(result.reasons) == 1 - assert isinstance(result.reasons[0], HumanInputRequired) - assert result.reasons[0].form_id == "form-1" - assert result.form_tokens == {"form-1": "form-token"} + assert result.pause is not None + assert result.pause.id == pause.id + assert result.pause.workflow_execution_id == workflow_run.id + assert result.pause.paused_at == pause.created_at + assert result.pause.get_state() == b"checkpoint" def test_delete_pause_model_deletes_record_when_state_object_delete_fails( diff --git a/api/tests/unit_tests/repositories/test_sqlalchemy_knowledge_fs_capability_issuance_auditor.py b/api/tests/unit_tests/repositories/test_sqlalchemy_knowledge_fs_capability_issuance_auditor.py index ba0de6f0f27..a08c6de9431 100644 --- a/api/tests/unit_tests/repositories/test_sqlalchemy_knowledge_fs_capability_issuance_auditor.py +++ b/api/tests/unit_tests/repositories/test_sqlalchemy_knowledge_fs_capability_issuance_auditor.py @@ -2,7 +2,8 @@ from __future__ import annotations from datetime import UTC, datetime, timedelta -from sqlalchemy import create_engine, select +from sqlalchemy import select +from sqlalchemy.engine import Engine from sqlalchemy.orm import Session, sessionmaker from models.knowledge_fs import ( @@ -25,15 +26,8 @@ from services.knowledge_fs_capability import ( ) -def test_auditor_commits_sanitized_event_before_returning() -> None: - engine = create_engine("sqlite:///:memory:") - KnowledgeFSControlSpace.metadata.create_all( - engine, - tables=[ - KnowledgeFSControlSpace.metadata.tables[KnowledgeFSControlSpace.__tablename__], - KnowledgeFSCapabilityIssuanceAudit.metadata.tables[KnowledgeFSCapabilityIssuanceAudit.__tablename__], - ], - ) +def test_auditor_commits_sanitized_event_before_returning(sqlite_engine: Engine) -> None: + engine = sqlite_engine session_maker = sessionmaker(bind=engine, expire_on_commit=False) with session_maker.begin() as session: control_space = KnowledgeFSControlSpace( @@ -105,15 +99,8 @@ def test_auditor_commits_sanitized_event_before_returning() -> None: assert "raw-jti" not in str(audit.claims_summary) -def test_auditor_persists_namespace_issuance_without_a_control_space_foreign_key() -> None: - engine = create_engine("sqlite:///:memory:") - KnowledgeFSControlSpace.metadata.create_all( - engine, - tables=[ - KnowledgeFSControlSpace.metadata.tables[KnowledgeFSControlSpace.__tablename__], - KnowledgeFSCapabilityIssuanceAudit.metadata.tables[KnowledgeFSCapabilityIssuanceAudit.__tablename__], - ], - ) +def test_auditor_persists_namespace_issuance_without_a_control_space_foreign_key(sqlite_engine: Engine) -> None: + engine = sqlite_engine session_maker = sessionmaker(bind=engine, expire_on_commit=False) auditor = SQLAlchemyKnowledgeFSCapabilityIssuanceAuditor(session_maker) issued_at = datetime(2026, 9, 4, tzinfo=UTC) @@ -153,17 +140,8 @@ def test_auditor_persists_namespace_issuance_without_a_control_space_foreign_key assert audit.claims_summary["control_space_id"] == "namespace:11111111-1111-4111-8111-111111111111" -def test_terminal_reservation_cleanup_never_removes_an_active_fence() -> None: - engine = create_engine("sqlite:///:memory:") - KnowledgeFSControlSpace.metadata.create_all( - engine, - tables=[ - KnowledgeFSControlSpace.metadata.tables[KnowledgeFSControlSpace.__tablename__], - KnowledgeFSCapabilityIssuanceReservation.metadata.tables[ - KnowledgeFSCapabilityIssuanceReservation.__tablename__ - ], - ], - ) +def test_terminal_reservation_cleanup_never_removes_an_active_fence(sqlite_engine: Engine) -> None: + engine = sqlite_engine session_maker = sessionmaker(bind=engine, expire_on_commit=False) with session_maker.begin() as session: control_space = KnowledgeFSControlSpace( diff --git a/api/tests/unit_tests/repositories/workspace/test_workspace_repository.py b/api/tests/unit_tests/repositories/workspace/test_workspace_repository.py index e7b13046906..6e2e856f801 100644 --- a/api/tests/unit_tests/repositories/workspace/test_workspace_repository.py +++ b/api/tests/unit_tests/repositories/workspace/test_workspace_repository.py @@ -1,5 +1,6 @@ from collections.abc import Mapping, Sequence from datetime import datetime, timedelta +from functools import partial from unittest.mock import Mock, create_autospec import pytest @@ -8,10 +9,12 @@ from sqlalchemy.orm import Session, sessionmaker from machinery.context import RequestContext from models.account import Account, AccountStatus, Tenant, TenantAccountJoin, TenantAccountRole, TenantStatus +from models.human_input_v2 import ContactSubjectType, HumanInputContactIdentity from repositories.workspace.workspace_repository import WorkspaceRepository from services.errors.workspace import WorkspaceNotFoundError, WorkspaceNotLinkedError from services.workspace.contracts import ( EffectiveCreditPool, + WorkspaceCreation, WorkspaceCustomConfig, WorkspaceFeatures, WorkspaceMemberRecord, @@ -311,6 +314,37 @@ class TestWorkspaceRepositoryCases: session.commit() return WorkspaceRepository(sqlite_session_factory) + @staticmethod + def test_provision_existing_account_creates_and_reuses_contact_identity( + sqlite_session_factory: sessionmaker[Session], + ) -> None: + with sqlite_session_factory.begin() as session: + session.add(make_account("owner", status=AccountStatus.ACTIVE, created_at=datetime(2026, 1, 1))) + + repository = WorkspaceRepository(sqlite_session_factory) + contact_ids: list[str] = [] + for workspace_id in ("first", "second"): + prepare = partial(WorkspaceCreation, workspace_id, "Workspace", "public-key", 10, 0, {}) + + created = repository.provision(prepare, owner_id="owner") + + assert created is not None + assert created.id == workspace_id + with sqlite_session_factory() as session: + membership = session.scalar( + select(TenantAccountJoin).where( + TenantAccountJoin.tenant_id == workspace_id, + TenantAccountJoin.account_id == "owner", + ) + ) + assert membership is not None + assert membership.role == TenantAccountRole.OWNER + identity = session.scalars(select(HumanInputContactIdentity)).one() + assert identity.subject_type == ContactSubjectType.ACCOUNT + assert identity.account_id == "owner" + contact_ids.append(identity.id) + assert contact_ids[0] == contact_ids[1] + @staticmethod def test_names_by_ids_returns_only_existing_requested_workspaces(seeded: WorkspaceRepository) -> None: assert seeded.names_by_ids(["w1", "w3", "missing", "w1"]) == {"w1": "First", "w3": "Archived"} diff --git a/api/tests/unit_tests/services/account/test_login_adapters.py b/api/tests/unit_tests/services/account/test_login_adapters.py index 59cfbfb47c6..ae836bdedfe 100644 --- a/api/tests/unit_tests/services/account/test_login_adapters.py +++ b/api/tests/unit_tests/services/account/test_login_adapters.py @@ -15,8 +15,11 @@ from sqlalchemy.orm import Session, sessionmaker from extensions.ext_redis import RedisClientWrapper from libs.helper import RateLimiter from models.account import Account, AccountStatus, Tenant, TenantAccountJoin, TenantAccountRole +from models.human_input_v2 import ContactSubjectType, HumanInputContactIdentity from repositories.account.repository import SQLAlchemyAccountRepository from repositories.account_activation_repository import SQLAlchemyAccountActivationRepository +from repositories.human_input_v2.contact import ContactError, ContactErrorCode +from repositories.human_input_v2.sqlalchemy_contact_repository import SQLAlchemyContactRepository from repositories.workspace.workspace_repository import WorkspaceRepository from services import account_errors from services.account import login_adapters as adapters @@ -41,6 +44,7 @@ from services.entities.account_login_entities import ( RefreshAccountStatus, ) from services.turnstile_service import TurnstileChallengeRejectedError, TurnstileUpstreamError +from services.workspace import gateways from tests.helpers.unit.account_domain import AccountDomain from tests.unit_tests.services.account.test_login_service import Dependencies @@ -328,6 +332,12 @@ def test_workspace_provisioning_persists_owner_workspace( assert membership is not None assert membership.role == TenantAccountRole.OWNER assert session.get(Tenant, membership.tenant_id) is not None + assert ( + session.scalar( + select(HumanInputContactIdentity.id).where(HumanInputContactIdentity.account_id == "account-1") + ) + is not None + ) def test_workspace_provisioning_rejects_missing_account( @@ -421,7 +431,7 @@ def test_turnstile_gateway_maps_provider_failures( assert (record.exc_info is not None) is has_exception_info -def test_provisioning_persists_account_and_workspace_atomically( +def test_provisioning_persists_account_contact_and_workspace_atomically( account_domain: AccountDomain, sqlite_session_factory: sessionmaker[Session], ) -> None: @@ -442,6 +452,66 @@ def test_provisioning_persists_account_and_workspace_atomically( assert account.normalized_email == "user@example.com" assert account.timezone == "UTC" assert membership is not None + contact = session.scalars( + select(HumanInputContactIdentity).where(HumanInputContactIdentity.account_id == account_id) + ).one() + assert contact.subject_type == ContactSubjectType.ACCOUNT + + +def test_provisioning_rolls_back_when_contact_creation_fails( + account_domain: AccountDomain, + sqlite_session_factory: sessionmaker[Session], + monkeypatch: pytest.MonkeyPatch, +) -> None: + gateway = account_domain.provisioning + + def fail_contact(*_args: object, **_kwargs: object) -> None: + raise ContactError(ContactErrorCode.CONFLICT, "Contact creation failed") + + monkeypatch.setattr(SQLAlchemyContactRepository, "provision_account_backed_contact", fail_contact) + + with pytest.raises(ContactError, match="Contact creation failed"): + gateway.create_with_owner_workspace( + email="user@example.com", + name="User", + interface_language="en-US", + timezone="UTC", + ip_address="127.0.0.1", + ) + + with sqlite_session_factory() as session: + assert session.scalar(select(Account)) is None + assert session.scalar(select(HumanInputContactIdentity)) is None + assert session.scalar(select(Tenant)) is None + assert session.scalar(select(TenantAccountJoin)) is None + + +def test_provisioning_rolls_back_account_and_contact_when_workspace_creation_fails( + account_domain: AccountDomain, + sqlite_session_factory: sessionmaker[Session], + monkeypatch: pytest.MonkeyPatch, +) -> None: + gateway = account_domain.provisioning + + def fail_key_pair(*_args: object, **_kwargs: object) -> None: + raise ValueError("Key generation failed") + + monkeypatch.setattr(gateways, "generate_key_pair", fail_key_pair) + + with pytest.raises(ValueError, match="Key generation failed"): + gateway.create_with_owner_workspace( + email="user@example.com", + name="User", + interface_language="en-US", + timezone="UTC", + ip_address="127.0.0.1", + ) + + with sqlite_session_factory() as session: + assert session.scalar(select(Account)) is None + assert session.scalar(select(HumanInputContactIdentity)) is None + assert session.scalar(select(Tenant)) is None + assert session.scalar(select(TenantAccountJoin)) is None def test_provisioning_rejects_equivalent_normalized_email( diff --git a/api/tests/unit_tests/services/account/test_service.py b/api/tests/unit_tests/services/account/test_service.py index d334f253d42..769b1efc128 100644 --- a/api/tests/unit_tests/services/account/test_service.py +++ b/api/tests/unit_tests/services/account/test_service.py @@ -9,7 +9,10 @@ from sqlalchemy import select from sqlalchemy.orm import Session, sessionmaker from models.account import Account, AccountStatus, Tenant, TenantAccountJoin, TenantAccountRole, TenantStatus +from models.human_input_v2 import ContactSubjectType, HumanInputContactIdentity from models.model import DifySetup +from repositories.human_input_v2.contact import ContactError, ContactErrorCode +from repositories.human_input_v2.sqlalchemy_contact_repository import SQLAlchemyContactRepository from repositories.installation_state_repository import InstallationStateRepository from services.account.contracts import SetupInput from services.account.service import AccountSetupProvisioner @@ -39,6 +42,10 @@ def test_creation_hashes_password_and_keeps_registration_ip( assert stored.normalized_email == "new@example.com" assert stored.password != "Valid123!" assert stored.last_login_ip == "203.0.113.1" + contact = sqlite_session.scalars( + select(HumanInputContactIdentity).where(HumanInputContactIdentity.account_id == account.id) + ).one() + assert contact.subject_type == ContactSubjectType.ACCOUNT assert account.status == "active" assert account.initialized_at is None assert account_domain.accounts.authenticate("New@Example.com", "Valid123!").id == account.id @@ -46,6 +53,21 @@ def test_creation_hashes_password_and_keeps_registration_ip( account_domain.accounts.authenticate(account.email, "wrong") +def test_creation_rolls_back_account_when_contact_creation_fails( + account_domain: AccountDomain, + sqlite_session: Session, + monkeypatch: pytest.MonkeyPatch, +) -> None: + def fail_contact(*_args: object, **_kwargs: object) -> None: + raise ContactError(ContactErrorCode.CONFLICT, "Contact creation failed") + + monkeypatch.setattr(SQLAlchemyContactRepository, "provision_account_backed_contact", fail_contact) + with pytest.raises(ContactError, match="Contact creation failed"): + account_domain.accounts.create_account("new@example.com", "New", "en-US") + assert sqlite_session.scalar(select(Account.id)) is None + assert sqlite_session.scalar(select(HumanInputContactIdentity.id)) is None + + @pytest.mark.parametrize( ("setting", "value", "error"), [ @@ -102,6 +124,10 @@ def test_registration_initializes_and_creates_owner_membership( assert memberships[0].account_id == account.id assert memberships[0].role == TenantAccountRole.OWNER assert not memberships[0].current + contact = sqlite_session.scalars( + select(HumanInputContactIdentity).where(HumanInputContactIdentity.account_id == account.id) + ).one() + assert contact.subject_type == ContactSubjectType.ACCOUNT account_domain.policy.try_join_default_workspace.assert_called_once_with(account.id) @@ -189,6 +215,7 @@ def test_setup_cleans_partial_state_on_failure( assert sqlite_session.scalar(select(Account.id)) is None assert sqlite_session.scalar(select(Tenant.id)) is None assert sqlite_session.scalar(select(DifySetup.version)) is None + assert sqlite_session.scalar(select(HumanInputContactIdentity.id)) is None def test_setup_persists_initialized_owner_before_telemetry( diff --git a/api/tests/unit_tests/services/human_input_v2/im_message_inbox/test_processor.py b/api/tests/unit_tests/services/human_input_v2/im_message_inbox/test_processor.py new file mode 100644 index 00000000000..cd2615965b2 --- /dev/null +++ b/api/tests/unit_tests/services/human_input_v2/im_message_inbox/test_processor.py @@ -0,0 +1,95 @@ +"""Tests for processing one durable IM callback.""" + +from datetime import UTC, datetime + +import pytest +from sqlalchemy.engine import Engine +from sqlalchemy.orm import sessionmaker + +from core.human_input_v2.entities import IMProvider +from core.human_input_v2.im_integration.adapters import AuthenticatedIMEvent, IMEventIngressKind +from core.human_input_v2.im_message_inbox import IMInboxRecord, IMInboxRecordId +from repositories.human_input_v2.im_channel_repository import IMChannelId +from services.human_input_v2.im_message_inbox.processor import IMInboxProcessor +from services.human_input_v2.im_message_inbox.storage import IMInboxStorage + +_NOW = datetime(2026, 8, 2, 8, tzinfo=UTC) + + +class _Consumer: + records: list[IMInboxRecord] + + def __init__(self, *, failure: Exception | None = None) -> None: + self.records = [] + self._failure = failure + + def consume(self, record: IMInboxRecord) -> None: + self.records.append(record) + if self._failure is not None: + raise self._failure + + +def _event() -> AuthenticatedIMEvent: + return AuthenticatedIMEvent( + provider=IMProvider.FEISHU, + provider_tenant_id="tenant-1", + event_id="event-1", + event_type="card.action", + occurred_at=None, + received_at=datetime(2026, 8, 2, 8), + ingress_kind=IMEventIngressKind.WEBHOOK, + payload='{"secret":"must-not-log"}', + ) + + +def _context( + sqlite_engine: Engine, *, failure: Exception | None = None +) -> tuple[IMInboxProcessor, IMInboxStorage, _Consumer]: + repository = IMInboxStorage(sessionmaker(bind=sqlite_engine, expire_on_commit=False)) + consumer = _Consumer(failure=failure) + processor = IMInboxProcessor(repository=repository, consumer=consumer, clock=lambda: _NOW) + return processor, repository, consumer + + +def test_processor_loads_callback_and_marks_it_processed(sqlite_engine: Engine) -> None: + processor, repository, consumer = _context(sqlite_engine) + accepted = repository.insert_or_resolve(IMChannelId("channel-1"), _event(), now=_NOW) + + processor.process(accepted.record_id) + + assert len(consumer.records) == 1 + assert consumer.records[0].event == _event() + stored = repository.get(accepted.record_id) + assert stored is not None + assert stored.processed_at == datetime(2026, 8, 2, 8) + + +def test_processor_skips_already_processed_callback(sqlite_engine: Engine) -> None: + processor, repository, consumer = _context(sqlite_engine) + accepted = repository.insert_or_resolve(IMChannelId("channel-1"), _event(), now=_NOW) + repository.mark_processed(accepted.record_id, processed_at=_NOW) + + processor.process(accepted.record_id) + + assert consumer.records == [] + + +def test_processor_leaves_callback_unprocessed_when_consumer_raises(sqlite_engine: Engine) -> None: + processor, repository, consumer = _context(sqlite_engine, failure=RuntimeError("must-not-log")) + accepted = repository.insert_or_resolve(IMChannelId("channel-1"), _event(), now=_NOW) + + with pytest.raises(RuntimeError, match="must-not-log"): + processor.process(accepted.record_id) + + assert len(consumer.records) == 1 + stored = repository.get(accepted.record_id) + assert stored is not None + assert stored.processed_at is None + + +def test_processor_treats_missing_callback_as_idempotent_noop(sqlite_engine: Engine) -> None: + processor, _, consumer = _context(sqlite_engine) + + processor.process(IMInboxRecordId("missing")) + + assert consumer.records == [] diff --git a/api/tests/unit_tests/services/human_input_v2/im_message_inbox/test_provider_composition.py b/api/tests/unit_tests/services/human_input_v2/im_message_inbox/test_provider_composition.py new file mode 100644 index 00000000000..5adbcb85af3 --- /dev/null +++ b/api/tests/unit_tests/services/human_input_v2/im_message_inbox/test_provider_composition.py @@ -0,0 +1,327 @@ +"""SQLite acceptance tests from the public Slack receiver boundary to the inbox.""" + +from __future__ import annotations + +import json +from collections.abc import Callable +from datetime import UTC, datetime + +import pytest +import sqlalchemy as sa +from slack_sdk.signature import SignatureVerifier +from slack_sdk.socket_mode.request import SocketModeRequest +from slack_sdk.socket_mode.response import SocketModeResponse +from sqlalchemy import event as sqlalchemy_event +from sqlalchemy.engine import Engine +from sqlalchemy.exc import OperationalError +from sqlalchemy.orm import Session, sessionmaker + +from core.human_input_v2.entities import IMProvider +from core.human_input_v2.im_integration.adapters import ( + AuthenticatedIMEvent, + EventAcceptance, + SlackCredentials, + WebhookRequest, +) +from core.human_input_v2.im_integration.adapters import slack as slack_adapter_module +from core.human_input_v2.im_integration.adapters.slack import SlackIMProviderAdapter +from core.human_input_v2.im_message_inbox import IMInboxRecordId +from models.human_input_v2 import IMMessageEvent +from repositories.human_input_v2.im_channel_repository import IMChannelId +from services.human_input_v2.im_message_inbox.sink import IMMessageInboxSink +from services.human_input_v2.im_message_inbox.storage import IMInboxStorage +from services.human_input_v2.im_message_inbox.telemetry import NoopIMInboxMetrics + +_SIGNING_SECRET = "sanitized-signing-material" +_PROVIDER_TENANT_ID = "sanitized-team" +_CHANNEL_ID = IMChannelId("00000000-0000-0000-0000-000000000001") +_RECEIVED_AT = datetime(2026, 8, 6, 8) +_NOW = _RECEIVED_AT.replace(tzinfo=UTC) + + +class _FixedClock: + def now(self) -> datetime: + return _NOW + + +class _TaskPublisher: + record_ids: list[IMInboxRecordId] + observer: Callable[[IMInboxRecordId], None] | None + + def __init__(self) -> None: + self.record_ids = [] + self.observer = None + + def apply_async(self, args: tuple[str, ...], *, retry: bool) -> object: + assert retry is False + record_id = IMInboxRecordId(args[0]) + self.record_ids.append(record_id) + if self.observer is not None: + self.observer(record_id) + return object() + + +@pytest.fixture(autouse=True) +def task_publisher(monkeypatch: pytest.MonkeyPatch) -> _TaskPublisher: + publisher = _TaskPublisher() + monkeypatch.setattr( + "services.human_input_v2.im_message_inbox.sink.process_im_message_inbox_record.apply_async", + publisher.apply_async, + ) + return publisher + + +class _DelegatingConsumer: + _sink: IMMessageInboxSink + events: list[AuthenticatedIMEvent] + + def __init__(self, sink: IMMessageInboxSink) -> None: + self._sink = sink + self.events = [] + + def accept(self, event: AuthenticatedIMEvent, *, request_ip_address: str | None = None) -> EventAcceptance: + self.events.append(event) + return self._sink.accept(event, request_ip_address=request_ip_address) + + +def _credentials() -> SlackCredentials: + return SlackCredentials( + provider=IMProvider.SLACK, + client_id="sanitized-client-id", + client_secret="sanitized-client-secret", + signing_secret=_SIGNING_SECRET, + bot_token="xoxb-sanitized-placeholder", + app_token="xapp-sanitized-placeholder", + ) + + +def _event_body(event_id: str) -> bytes: + return json.dumps( + { + "type": "event_callback", + "team_id": _PROVIDER_TENANT_ID, + "event_id": event_id, + "event_time": int(_NOW.timestamp()), + "event": {"type": "message", "text": "Sanitized text"}, + }, + separators=(",", ":"), + ).encode() + + +def _signed_request(body: bytes, *, valid_signature: bool = True) -> WebhookRequest: + timestamp = str(int(_RECEIVED_AT.replace(tzinfo=UTC).timestamp())) + signature = SignatureVerifier(_SIGNING_SECRET).generate_signature(timestamp=timestamp, body=body) + assert signature is not None + if not valid_signature: + signature = "v0=invalid" + return WebhookRequest( + method="POST", + headers=( + ("X-Slack-Request-Timestamp", timestamp), + ("X-Slack-Signature", signature), + ("Content-Type", "application/json"), + ), + body=body, + received_at=_RECEIVED_AT, + ) + + +def _build_sink(sqlite_engine: Engine) -> tuple[IMMessageInboxSink, sessionmaker[Session]]: + session_maker = sessionmaker(bind=sqlite_engine, expire_on_commit=False) + sink = IMMessageInboxSink( + channel_id=_CHANNEL_ID, + expected_provider=IMProvider.SLACK, + expected_provider_tenant_id=_PROVIDER_TENANT_ID, + repository=IMInboxStorage(session_maker), + clock=_FixedClock().now, + metrics=NoopIMInboxMetrics(), + ) + return sink, session_maker + + +def test_slack_webhook_commits_only_authenticated_business_events_before_success( + sqlite_engine: Engine, + task_publisher: _TaskPublisher, +) -> None: + sink, session_maker = _build_sink(sqlite_engine) + + def observe_commit(record_id: IMInboxRecordId) -> None: + with session_maker() as session: + assert session.get(IMMessageEvent, str(record_id)) is not None + + task_publisher.observer = observe_commit + consumer = _DelegatingConsumer(sink) + handler = SlackIMProviderAdapter(_credentials()).create_webhook_handler(consumer) + event_body = _event_body("sanitized-webhook-event") + challenge_body = json.dumps( + {"type": "url_verification", "challenge": "sanitized-challenge"}, + separators=(",", ":"), + ).encode() + + unauthenticated_response = handler.handle(_signed_request(event_body, valid_signature=False)) + challenge_response = handler.handle(_signed_request(challenge_body)) + assert consumer.events == [] + with session_maker() as session: + assert session.scalar(sa.select(sa.func.count(IMMessageEvent.id))) == 0 + + accepted_response = handler.handle(_signed_request(event_body)) + + assert unauthenticated_response.status_code == 401 + assert challenge_response.status_code == 200 + assert json.loads(challenge_response.body) == {"challenge": "sanitized-challenge"} + assert accepted_response.status_code == 200 + assert len(consumer.events) == 1 + assert len(task_publisher.record_ids) == 1 + with session_maker() as session: + stored = session.get_one(IMMessageEvent, str(task_publisher.record_ids[0])) + assert stored.channel_id == str(_CHANNEL_ID) + assert stored.provider is IMProvider.SLACK + assert stored.tenant_id == _PROVIDER_TENANT_ID + assert stored.event_id == "sanitized-webhook-event" + assert json.loads(stored.payload) == json.loads(event_body) + + +def test_slack_webhook_maps_inbox_commit_failure_to_retryable_response(sqlite_engine: Engine) -> None: + sink, session_maker = _build_sink(sqlite_engine) + handler = SlackIMProviderAdapter(_credentials()).create_webhook_handler(sink) + + def fail_commit(_connection: sa.Connection) -> None: + raise OperationalError("COMMIT", {}, RuntimeError("injected database failure")) + + sqlalchemy_event.listen(sqlite_engine, "commit", fail_commit, once=True) + + response = handler.handle(_signed_request(_event_body("sanitized-failed-event"))) + + assert response.status_code == 503 + with session_maker() as session: + assert session.scalar(sa.select(sa.func.count(IMMessageEvent.id))) == 0 + + +def test_slack_stream_connection_acks_only_the_event_committed_by_the_inbox( + sqlite_engine: Engine, + monkeypatch: pytest.MonkeyPatch, +) -> None: + sink, session_maker = _build_sink(sqlite_engine) + failed_request = SocketModeRequest( + type="events_api", + envelope_id="sanitized-failed-envelope", + payload=json.loads(_event_body("sanitized-failed-stream-event")), + ) + accepted_request = SocketModeRequest( + type="events_api", + envelope_id="sanitized-accepted-envelope", + payload=json.loads(_event_body("sanitized-accepted-stream-event")), + ) + + class _SocketTransport: + instance: _SocketTransport | None = None + + def __init__(self, **kwargs: object) -> None: + del kwargs + self.socket_mode_request_listeners: list[Callable[[object, SocketModeRequest], None]] = [] + self.responses: list[SocketModeResponse] = [] + self.closed = False + self.__class__.instance = self + + def connect(self) -> None: + listener = self.socket_mode_request_listeners[0] + listener(self, failed_request) + listener(self, accepted_request) + + def send_socket_mode_response(self, response: SocketModeResponse) -> None: + with session_maker() as session: + stored = session.scalar(sa.select(IMMessageEvent)) + assert stored is not None + assert stored.event_id == "sanitized-accepted-stream-event" + self.responses.append(response) + + def close(self) -> None: + self.closed = True + + fail_next_commit = True + + def fail_first_commit(_connection: sa.Connection) -> None: + nonlocal fail_next_commit + if fail_next_commit: + fail_next_commit = False + raise OperationalError("COMMIT", {}, RuntimeError("injected database failure")) + + monkeypatch.setattr(slack_adapter_module, "SocketModeClient", _SocketTransport) + sqlalchemy_event.listen(sqlite_engine, "commit", fail_first_commit) + stream = SlackIMProviderAdapter(_credentials()).create_stream_handler(sink) + assert stream is not None + try: + stream.start() + finally: + sqlalchemy_event.remove(sqlite_engine, "commit", fail_first_commit) + + transport = _SocketTransport.instance + assert transport is not None + assert [response.to_dict() for response in transport.responses] == [{"envelope_id": "sanitized-accepted-envelope"}] + with session_maker() as session: + assert session.scalar(sa.select(sa.func.count(IMMessageEvent.id))) == 1 + + stream.stop() + + assert transport.closed is True + + +def test_slack_webhook_and_stream_share_identified_callback_deduplication( + sqlite_engine: Engine, + monkeypatch: pytest.MonkeyPatch, +) -> None: + sink, session_maker = _build_sink(sqlite_engine) + consumer = _DelegatingConsumer(sink) + adapter = SlackIMProviderAdapter(_credentials()) + event_body = _event_body("sanitized-shared-event") + webhook_response = adapter.create_webhook_handler(consumer).handle(_signed_request(event_body)) + stream_request = SocketModeRequest( + type="events_api", + envelope_id="sanitized-shared-envelope", + payload=json.loads(event_body), + ) + + class _SocketTransport: + instance: _SocketTransport | None = None + + def __init__(self, **kwargs: object) -> None: + del kwargs + self.socket_mode_request_listeners: list[Callable[[object, SocketModeRequest], None]] = [] + self.responses: list[SocketModeResponse] = [] + self.closed = False + self.__class__.instance = self + + def connect(self) -> None: + self.socket_mode_request_listeners[0](self, stream_request) + + def send_socket_mode_response(self, response: SocketModeResponse) -> None: + self.responses.append(response) + + def close(self) -> None: + self.closed = True + + monkeypatch.setattr(slack_adapter_module, "SocketModeClient", _SocketTransport) + stream = adapter.create_stream_handler(consumer) + assert stream is not None + stream.start() + + transport = _SocketTransport.instance + assert transport is not None + assert webhook_response.status_code == 200 + assert [response.to_dict() for response in transport.responses] == [{"envelope_id": "sanitized-shared-envelope"}] + assert len(consumer.events) == 2 + with session_maker() as session: + records = list(session.scalars(sa.select(IMMessageEvent))) + assert len(records) == 1 + assert records[0].channel_id == str(_CHANNEL_ID) + assert records[0].processed_at is None + assert consumer.events[0].ingress_kind.value == "webhook" + assert consumer.events[1].ingress_kind.value == "stream" + assert json.loads(consumer.events[1].payload) == stream_request.to_dict() + assert records[0].ingress_kind is consumer.events[0].ingress_kind + assert records[0].payload == consumer.events[0].payload + assert json.loads(records[0].payload) == json.loads(event_body) + + stream.stop() + + assert transport.closed is True diff --git a/api/tests/unit_tests/services/human_input_v2/im_message_inbox/test_sink.py b/api/tests/unit_tests/services/human_input_v2/im_message_inbox/test_sink.py new file mode 100644 index 00000000000..dc3d1ce7668 --- /dev/null +++ b/api/tests/unit_tests/services/human_input_v2/im_message_inbox/test_sink.py @@ -0,0 +1,294 @@ +"""SQLite-backed tests for durable callback acceptance and task publication.""" + +from __future__ import annotations + +from collections.abc import Callable +from datetime import UTC, datetime + +import pytest +import sqlalchemy as sa +from sqlalchemy import event as sqlalchemy_event +from sqlalchemy.engine import Engine +from sqlalchemy.exc import OperationalError +from sqlalchemy.orm import Session, sessionmaker + +from core.human_input_v2.entities import IMProvider +from core.human_input_v2.im_integration.adapters import AuthenticatedIMEvent, EventAcceptance, IMEventIngressKind +from core.human_input_v2.im_message_inbox import IMInboxRecordId, InboxEventValidationError +from models.human_input_v2 import IMMessageEvent +from repositories.human_input_v2.im_channel_repository import IMChannelId +from services.human_input_v2.im_message_inbox.sink import IMMessageInboxSink +from services.human_input_v2.im_message_inbox.storage import IMInboxStorage +from services.human_input_v2.im_message_inbox.telemetry import IMInboxMetricKind + +_NOW = datetime(2026, 8, 2, 8, tzinfo=UTC) +_PAYLOAD = ' {"secret":"must-not-log"}\n' + + +class _TaskPublisher: + record_ids: list[IMInboxRecordId] + fail: bool + observer: Callable[[IMInboxRecordId], None] | None + + def __init__(self) -> None: + self.record_ids = [] + self.fail = False + self.observer = None + + def apply_async(self, args: tuple[str, ...], *, retry: bool) -> object: + assert retry is False + record_id = IMInboxRecordId(args[0]) + self.record_ids.append(record_id) + if self.observer is not None: + self.observer(record_id) + if self.fail: + raise RuntimeError("broker credential must-not-log") + return object() + + +class _RecordingMetrics: + events: list[tuple[IMInboxMetricKind, IMProvider | None, str | None]] + + def __init__(self) -> None: + self.events = [] + + def record( + self, + kind: IMInboxMetricKind, + *, + provider: IMProvider | None, + outcome: str | None = None, + ) -> None: + self.events.append((kind, provider, outcome)) + + +@pytest.fixture(autouse=True) +def task_publisher(monkeypatch: pytest.MonkeyPatch) -> _TaskPublisher: + publisher = _TaskPublisher() + monkeypatch.setattr( + "services.human_input_v2.im_message_inbox.sink.process_im_message_inbox_record.apply_async", + publisher.apply_async, + ) + return publisher + + +def _event( + *, + provider: IMProvider = IMProvider.FEISHU, + tenant_id: str = "tenant-1", + event_id: str | None = "event-1", + event_type: str | None = None, +) -> AuthenticatedIMEvent: + return AuthenticatedIMEvent( + provider=provider, + provider_tenant_id=tenant_id, + event_id=event_id, + event_type=event_type, + occurred_at=None, + received_at=datetime(2026, 8, 2, 8), + ingress_kind=IMEventIngressKind.WEBHOOK, + payload=_PAYLOAD, + ) + + +def _sink(sqlite_engine: Engine) -> tuple[IMMessageInboxSink, sessionmaker[Session], _RecordingMetrics]: + session_maker = sessionmaker(bind=sqlite_engine, expire_on_commit=False) + metrics = _RecordingMetrics() + return ( + IMMessageInboxSink( + channel_id=IMChannelId("channel-1"), + expected_provider=IMProvider.FEISHU, + expected_provider_tenant_id="tenant-1", + repository=IMInboxStorage(session_maker), + clock=lambda: _NOW, + metrics=metrics, + ), + session_maker, + metrics, + ) + + +def test_sink_commits_before_enqueuing_record_id(sqlite_engine: Engine, task_publisher: _TaskPublisher) -> None: + sink, session_maker, metrics = _sink(sqlite_engine) + + def observe_commit(record_id: IMInboxRecordId) -> None: + with session_maker() as session: + assert session.get(IMMessageEvent, str(record_id)) is not None + + task_publisher.observer = observe_commit + + acceptance = sink.accept(_event()) + + assert acceptance is EventAcceptance.ACCEPTED + assert metrics.events == [(IMInboxMetricKind.ACCEPTANCE, IMProvider.FEISHU, "new")] + assert len(task_publisher.record_ids) == 1 + + +def test_identified_redelivery_reenqueues_same_record_without_mutating_facts( + sqlite_engine: Engine, task_publisher: _TaskPublisher +) -> None: + sink, session_maker, metrics = _sink(sqlite_engine) + assert sink.accept(_event()) is EventAcceptance.ACCEPTED + with session_maker.begin() as session: + stored = session.scalar(sa.select(IMMessageEvent)) + assert stored is not None + stored.processed_at = datetime(2026, 8, 2, 8, 1) + + duplicate = sink.accept(_event()) + + assert duplicate is EventAcceptance.ACCEPTED + assert metrics.events[-2:] == [ + (IMInboxMetricKind.ACCEPTANCE, IMProvider.FEISHU, "duplicate"), + (IMInboxMetricKind.DUPLICATE, IMProvider.FEISHU, None), + ] + assert len(task_publisher.record_ids) == 2 + assert task_publisher.record_ids[0] == task_publisher.record_ids[1] + with session_maker() as session: + records = list(session.scalars(sa.select(IMMessageEvent))) + assert len(records) == 1 + assert records[0].processed_at == datetime(2026, 8, 2, 8, 1) + + +def test_enqueue_failure_rejects_identified_callback_and_redelivery_retries_same_record( + sqlite_engine: Engine, + task_publisher: _TaskPublisher, + caplog: pytest.LogCaptureFixture, +) -> None: + sink, session_maker, metrics = _sink(sqlite_engine) + task_publisher.fail = True + + first_acceptance = sink.accept(_event()) + task_publisher.fail = False + second_acceptance = sink.accept(_event()) + + assert first_acceptance is EventAcceptance.NOT_ACCEPTED + assert second_acceptance is EventAcceptance.ACCEPTED + assert task_publisher.record_ids[0] == task_publisher.record_ids[1] + assert metrics.events == [ + (IMInboxMetricKind.ACCEPTANCE, IMProvider.FEISHU, "new"), + (IMInboxMetricKind.DISPATCH_FAILURE, IMProvider.FEISHU, "broker_unavailable"), + (IMInboxMetricKind.ACCEPTANCE, IMProvider.FEISHU, "duplicate"), + (IMInboxMetricKind.DUPLICATE, IMProvider.FEISHU, None), + ] + assert "must-not-log" not in caplog.text + with session_maker() as session: + assert session.scalar(sa.select(sa.func.count(IMMessageEvent.id))) == 1 + + +def test_enqueue_failure_for_unidentified_callback_creates_new_record_on_redelivery( + sqlite_engine: Engine, + task_publisher: _TaskPublisher, +) -> None: + sink, session_maker, _ = _sink(sqlite_engine) + task_publisher.fail = True + + first_acceptance = sink.accept(_event(event_id=None)) + task_publisher.fail = False + second_acceptance = sink.accept(_event(event_id=None)) + + assert first_acceptance is EventAcceptance.NOT_ACCEPTED + assert second_acceptance is EventAcceptance.ACCEPTED + assert task_publisher.record_ids[0] != task_publisher.record_ids[1] + with session_maker() as session: + assert session.scalar(sa.select(sa.func.count(IMMessageEvent.id))) == 2 + + +def test_sink_rejects_conflicting_bound_provider_identity_without_insert(sqlite_engine: Engine) -> None: + sink, session_maker, metrics = _sink(sqlite_engine) + + provider_conflict = sink.accept(_event(provider=IMProvider.SLACK)) + tenant_conflict = sink.accept(_event(tenant_id="tenant-2")) + + assert provider_conflict is EventAcceptance.NOT_ACCEPTED + assert tenant_conflict is EventAcceptance.NOT_ACCEPTED + assert metrics.events == [ + (IMInboxMetricKind.ACCEPTANCE_FAILURE, IMProvider.SLACK, "identity_conflict"), + (IMInboxMetricKind.ACCEPTANCE_FAILURE, IMProvider.FEISHU, "identity_conflict"), + ] + with session_maker() as session: + assert session.scalar(sa.select(sa.func.count(IMMessageEvent.id))) == 0 + + +def test_sink_preserves_provider_metadata_and_payload(sqlite_engine: Engine) -> None: + maximum_tenant_id = "t" * 128 + maximum_event_id = "e" * 128 + maximum_event_type = "y" * 128 + session_maker = sessionmaker(bind=sqlite_engine, expire_on_commit=False) + sink = IMMessageInboxSink( + channel_id=IMChannelId("channel-1"), + expected_provider=IMProvider.FEISHU, + expected_provider_tenant_id=maximum_tenant_id, + repository=IMInboxStorage(session_maker), + clock=lambda: _NOW, + metrics=_RecordingMetrics(), + ) + + acceptance = sink.accept( + _event(tenant_id=maximum_tenant_id, event_id=maximum_event_id, event_type=maximum_event_type) + ) + + assert acceptance is EventAcceptance.ACCEPTED + with session_maker() as session: + stored = session.scalar(sa.select(IMMessageEvent)) + assert stored is not None + assert stored.tenant_id == maximum_tenant_id + assert stored.event_id == maximum_event_id + assert stored.provider_event_type == maximum_event_type + assert stored.payload == _PAYLOAD + + +def test_sink_canonicalizes_blank_event_id_before_validation(sqlite_engine: Engine) -> None: + sink, session_maker, _ = _sink(sqlite_engine) + + acceptance = sink.accept(_event(event_id=" " * 129)) + + assert acceptance is EventAcceptance.ACCEPTED + with session_maker() as session: + stored = session.scalar(sa.select(IMMessageEvent)) + assert stored is not None + assert stored.event_id is None + + +def test_sink_rejects_oversized_bound_provider_identity(sqlite_engine: Engine) -> None: + session_maker = sessionmaker(bind=sqlite_engine, expire_on_commit=False) + + with pytest.raises(InboxEventValidationError, match="provider tenant id"): + IMMessageInboxSink( + channel_id=IMChannelId("channel-1"), + expected_provider=IMProvider.FEISHU, + expected_provider_tenant_id="t" * 129, + repository=IMInboxStorage(session_maker), + clock=lambda: _NOW, + metrics=_RecordingMetrics(), + ) + + +def test_sink_returns_not_accepted_when_database_commit_fails( + sqlite_engine: Engine, caplog: pytest.LogCaptureFixture +) -> None: + sink, session_maker, metrics = _sink(sqlite_engine) + + def fail_commit(_connection: sa.Connection) -> None: + raise OperationalError("COMMIT", {}, RuntimeError("credential must-not-log")) + + sqlalchemy_event.listen(sqlite_engine, "commit", fail_commit, once=True) + + acceptance = sink.accept(_event()) + + assert acceptance is EventAcceptance.NOT_ACCEPTED + assert metrics.events == [(IMInboxMetricKind.ACCEPTANCE_FAILURE, IMProvider.FEISHU, "persistence_failure")] + assert "must-not-log" not in caplog.text + with session_maker() as session: + assert session.scalar(sa.select(sa.func.count(IMMessageEvent.id))) == 0 + + +def test_request_ip_is_forwarded_for_each_accepted_event(sqlite_engine: Engine) -> None: + sink, sessions, _ = _sink(sqlite_engine) + assert sink.accept(_event(event_id="event-1"), request_ip_address="192.0.2.22") is EventAcceptance.ACCEPTED + assert sink.accept(_event(event_id="event-2")) is EventAcceptance.ACCEPTED + with sessions() as session: + records = session.scalars(sa.select(IMMessageEvent)).all() + assert {record.event_id: record.request_ip_address for record in records} == { + "event-1": "192.0.2.22", + "event-2": None, + } diff --git a/api/tests/unit_tests/services/human_input_v2/im_message_inbox/test_telemetry.py b/api/tests/unit_tests/services/human_input_v2/im_message_inbox/test_telemetry.py new file mode 100644 index 00000000000..ba1f50d6b3e --- /dev/null +++ b/api/tests/unit_tests/services/human_input_v2/im_message_inbox/test_telemetry.py @@ -0,0 +1,48 @@ +"""Tests for payload-free IM callback metrics.""" + +from __future__ import annotations + +from collections.abc import Mapping + +import opentelemetry.metrics +import pytest + +from configs import dify_config +from core.human_input_v2.entities import IMProvider +from services.human_input_v2.im_message_inbox.telemetry import IMInboxMetricKind, OpenTelemetryIMInboxMetrics + + +class _RecordingCounter: + calls: list[tuple[int, Mapping[str, str]]] + + def __init__(self) -> None: + self.calls = [] + + def add(self, amount: int, attributes: Mapping[str, str]) -> None: + self.calls.append((amount, attributes)) + + +class _RecordingMeter: + def __init__(self) -> None: + self.counter = _RecordingCounter() + + def create_counter(self, name: str, *, description: str, unit: str) -> _RecordingCounter: + assert name == "im_message_inbox_events_total" + del description, unit + return self.counter + + +def test_callback_metrics_use_low_cardinality_dimensions(monkeypatch: pytest.MonkeyPatch) -> None: + meter = _RecordingMeter() + + def get_meter(_name: str, *, version: str) -> _RecordingMeter: + del version + return meter + + monkeypatch.setattr(dify_config, "ENABLE_OTEL", True) + monkeypatch.setattr(opentelemetry.metrics, "get_meter", get_meter) + metrics = OpenTelemetryIMInboxMetrics() + + metrics.record(IMInboxMetricKind.ACCEPTANCE, provider=IMProvider.FEISHU, outcome="new") + + assert meter.counter.calls == [(1, {"kind": "acceptance", "provider": "feishu", "outcome": "new"})] diff --git a/api/tests/unit_tests/services/human_input_v2/test_composition.py b/api/tests/unit_tests/services/human_input_v2/test_composition.py new file mode 100644 index 00000000000..7a0fc30eb47 --- /dev/null +++ b/api/tests/unit_tests/services/human_input_v2/test_composition.py @@ -0,0 +1,273 @@ +from collections.abc import Callable +from dataclasses import dataclass +from typing import Literal +from uuid import uuid4 + +import pytest +from cryptography.fernet import Fernet +from sqlalchemy import select +from sqlalchemy.orm import Session, sessionmaker + +from core.app.entities.app_invoke_entities import InvokeFrom, UserFrom, build_dify_run_context +from core.db.session_factory import session_factory +from core.helper import encrypter +from core.human_input_v2.shared.values import AccountId, TenantId +from core.workflow.node_factory import DifyNodeFactory +from core.workflow.nodes.human_input.enums import HumanInputFormStatus +from core.workflow.nodes.human_input_v2.events import NodeRunHumanInputV2FormRequiredEvent +from core.workflow.system_variables import SystemVariableKey, system_variable_selector +from enums import DeploymentEdition +from graphon.engine import Engine +from graphon.engine.command import InMemoryChannel +from graphon.engine_events import GraphRunPausedEvent, GraphRunSucceededEvent, NodeRunSucceededEvent +from graphon.enums import WorkflowExecutionStatus +from graphon.graph import Graph +from graphon.runtime import InitParams, RuntimeState, VariablePool +from models import CreatorUserRole +from models.account import Account, AccountStatus, Tenant, TenantAccountJoin, TenantAccountRole +from models.enums import WorkflowRunTriggeredFrom +from models.human_input_v2 import ContactSubjectType, HumanInputContactIdentity, HumanInputForm +from models.workflow import WorkflowPause, WorkflowRun, WorkflowType +from services.human_input_v2.submission_service import HumanInputSubmissionService +from tasks.app_generate.workflow_execute_task import resume_app_execution +from tasks.human_input_im_card_tasks import update_human_input_im_card_task + + +@dataclass(frozen=True) +class _ContainerWorkflow: + tenant_id: TenantId + app_id: str + run_id: str + account_id: AccountId + + +@pytest.fixture +def container_workflow( + sqlite_session_factory: sessionmaker[Session], + monkeypatch: pytest.MonkeyPatch, + config_overrides: Callable[..., None], +) -> _ContainerWorkflow: + workflow = _ContainerWorkflow(TenantId(str(uuid4())), str(uuid4()), str(uuid4()), AccountId(str(uuid4()))) + cipher = Fernet(Fernet.generate_key()) + monkeypatch.setattr(encrypter, "encrypt_token", lambda _tenant, token: cipher.encrypt(token.encode()).decode()) + monkeypatch.setattr(encrypter, "decrypt_token", lambda _tenant, token: cipher.decrypt(token.encode()).decode()) + monkeypatch.setattr(update_human_input_im_card_task, "apply_async", lambda **_kwargs: None) + config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.CLOUD) + with sqlite_session_factory.begin() as session: + tenant = Tenant(name="Workspace") + tenant.id = workflow.tenant_id + account = Account(name="Approver", email="approver@example.com", status=AccountStatus.ACTIVE) + account.id = workflow.account_id + session.add_all( + [ + tenant, + account, + TenantAccountJoin( + tenant_id=workflow.tenant_id, account_id=workflow.account_id, role=TenantAccountRole.NORMAL + ), + HumanInputContactIdentity(subject_type=ContactSubjectType.ACCOUNT, account_id=workflow.account_id), + WorkflowRun( + id=workflow.run_id, + tenant_id=workflow.tenant_id, + app_id=workflow.app_id, + workflow_id="workflow", + type=WorkflowType.WORKFLOW, + triggered_from=WorkflowRunTriggeredFrom.APP_RUN, + version="1", + status=WorkflowExecutionStatus.RUNNING, + created_by_role=CreatorUserRole.ACCOUNT, + created_by=workflow.account_id, + ), + ] + ) + return workflow + + +def _container_graph_config(kind: Literal["iteration", "parallel", "loop"]) -> dict[str, object]: + is_loop = kind == "loop" + container_type = "loop" if is_loop else "iteration" + context_variable = "answer" if is_loop else "item" + container: dict[str, object] = {"type": container_type, "title": "Review items", "start_node_id": "child-start"} + if is_loop: + container.update( + loop_count=2, + break_conditions=[], + logical_operator="and", + loop_variables=[{"label": "answer", "var_type": "string", "value_type": "constant", "value": "initial"}], + ) + else: + container.update( + iterator_selector=["start", "items"], + output_selector=["approval", "answer"], + is_parallel=kind == "parallel", + parallel_nums=2, + ) + nodes = [ + {"id": "start", "data": {"type": "start", "title": "Start", "variables": list[object]()}}, + {"id": "container", "data": container}, + { + "id": "child-start", + "parentId": "container", + "data": {"type": f"{container_type}-start", "title": "Container start"}, + }, + { + "id": "approval", + "parentId": "container", + "data": { + "type": "human-input", + "version": "2", + "title": "Approval", + "recipients_spec": [{"type": "initiator"}], + "message_template": {"subject": "Review", "body": "Open {{#url#}}"}, + "debug_mode": {"enabled": False, "channels": list[str]()}, + "form_content": "Review {{#container." + context_variable + "#}}: {{#$output.answer#}}", + "inputs": [{"type": "paragraph", "output_variable_name": "answer"}], + "user_actions": [{"id": "approve", "title": "Approve"}], + }, + }, + { + "id": "end", + "data": { + "type": "end", + "title": "End", + "outputs": [{"variable": "result", "value_selector": ["container", "answer" if is_loop else "output"]}], + }, + }, + ] + edges = [ + {"source": "start", "target": "container"}, + {"source": "container", "target": "end"}, + {"source": "child-start", "target": "approval"}, + ] + if is_loop: + nodes.append( + { + "id": "save-answer", + "parentId": "container", + "data": { + "type": "assigner", + "version": "2", + "title": "Save answer for next round", + "items": [ + { + "variable_selector": ["container", "answer"], + "input_type": "variable", + "operation": "over-write", + "value": ["approval", "answer"], + } + ], + }, + } + ) + edges.append({"source": "approval", "target": "save-answer", "sourceHandle": "approve"}) + return {"nodes": nodes, "edges": edges} + + +@pytest.mark.parametrize("kind", ["iteration", "parallel", "loop"]) +@pytest.mark.parametrize("container_debug", [False, True], ids=["workflow-run", "container-debug"]) +def test_container_approvals_resume_from_checkpoints_with_independent_forms( + container_workflow: _ContainerWorkflow, + sqlite_session_factory: sessionmaker[Session], + monkeypatch: pytest.MonkeyPatch, + kind: Literal["iteration", "parallel", "loop"], + container_debug: bool, +) -> None: + workflow = container_workflow + monkeypatch.setattr(session_factory, "create_session", sqlite_session_factory) + enqueue_calls: list[dict[str, object]] = [] + + def enqueue(*, kwargs: dict[str, object]) -> None: + enqueue_calls.append(kwargs) + + monkeypatch.setattr(resume_app_execution, "apply_async", enqueue) + graph_config = _container_graph_config(kind) + params = InitParams( + workflow_id="workflow", + graph_config=graph_config, + run_context=build_dify_run_context( + tenant_id=workflow.tenant_id, + app_id=workflow.app_id, + user_id=workflow.account_id, + user_from=UserFrom.ACCOUNT, + invoke_from=InvokeFrom.DEBUGGER if container_debug else InvokeFrom.EXPLORE, + ), + call_depth=0, + ) + pool = VariablePool() + pool.add(system_variable_selector(SystemVariableKey.WORKFLOW_EXECUTION_ID), workflow.run_id) + pool.add(["start", "items"], ["first", "second"]) + state = RuntimeState(workflow_id="workflow", variable_pool=pool, start_at=0) + submissions = HumanInputSubmissionService(session_factory=sqlite_session_factory) + accepted_form_ids: set[str] = set() + completed_answers: list[str] = [] + pending_tokens: dict[str, str] = {} + for _ in range(3): + graph = Graph.init( + graph_config=graph_config, + node_factory=DifyNodeFactory(params, state), + root_node_id="container" if container_debug else "start", + skip_validation=container_debug, + ) + events = list(Engine(graph=graph, runtime_state=state, command_channel=InMemoryChannel(), workers=2).run()) + for event in events: + if isinstance(event, NodeRunHumanInputV2FormRequiredEvent): + prepared = event.prepared_form + assert prepared.form_token is not None + pending_tokens[prepared.form.id] = prepared.form_token.get_secret_value() + if isinstance(event, NodeRunSucceededEvent) and event.node_id == "approval": + completed_answers.append(event.node_run_result.outputs["answer"].value) + if isinstance(events[-1], GraphRunSucceededEvent): + break + assert isinstance(events[-1], GraphRunPausedEvent) + checkpoint = state.dumps() + with sqlite_session_factory.begin() as session: + run = session.get(WorkflowRun, workflow.run_id) + assert run is not None + run.status = WorkflowExecutionStatus.PAUSED + if session.scalar(select(WorkflowPause).where(WorkflowPause.workflow_run_id == workflow.run_id)) is None: + session.add( + WorkflowPause( + workflow_id="workflow", workflow_run_id=workflow.run_id, state_object_key="checkpoint" + ) + ) + waiting = list( + session.scalars( + select(HumanInputForm).where( + HumanInputForm.workflow_run_id == workflow.run_id, + HumanInputForm.status == HumanInputFormStatus.WAITING, + ) + ) + ) + assert waiting + form = waiting[0] + content = form.resolved_form.legacy_form_content + answer = { + "Review first: {{#$output.answer#}}": "approved-first", + "Review second: {{#$output.answer#}}": "approved-second", + "Review initial: {{#$output.answer#}}": "approved-initial", + "Review approved-initial: {{#$output.answer#}}": "approved-approved-initial", + }[content] + form_id = form.id + assert form_id not in accepted_form_ids + submissions.submit_console_form( + account_id=workflow.account_id, + form_token=pending_tokens[form_id], + action="approve", + inputs={"answer": answer}, + ) + accepted_form_ids.add(form_id) + state = RuntimeState.from_snapshot(checkpoint) + assert isinstance(events[-1], GraphRunSucceededEvent) + expected_answers = ( + ["approved-initial", "approved-approved-initial"] if kind == "loop" else ["approved-first", "approved-second"] + ) + assert sorted(completed_answers) == sorted(expected_answers) + assert state.outputs["result"] == ( + "approved-approved-initial" if kind == "loop" else ["approved-first", "approved-second"] + ) + with sqlite_session_factory() as session: + forms = list(session.scalars(select(HumanInputForm).where(HumanInputForm.workflow_run_id == workflow.run_id))) + assert len(forms) == 2 + assert len({form.node_execution_id for form in forms}) == 2 + assert all(form.status == HumanInputFormStatus.SUBMITTED for form in forms) + assert len(enqueue_calls) == 2 diff --git a/api/tests/unit_tests/services/human_input_v2/test_contact_capability_boundaries.py b/api/tests/unit_tests/services/human_input_v2/test_contact_capability_boundaries.py new file mode 100644 index 00000000000..778d6b30e64 --- /dev/null +++ b/api/tests/unit_tests/services/human_input_v2/test_contact_capability_boundaries.py @@ -0,0 +1,220 @@ +"""Behavioral contracts for core and Enterprise Contact capability ownership.""" + +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from datetime import datetime +from typing import get_type_hints + +from core.human_input_v2.shared import AccountId, ContactId, TenantId +from repositories.human_input_v2.contact import ( + CandidateId, + Contact, + ContactIMBindingRepository, + ContactQuery, + ContactRepository, + ContactType, + EnterpriseContactRepository, + ExternalContact, + IMBinding, + OrganizationCandidate, + Page, +) +from services.enterprise.human_input_contact_service import EnterpriseContactManagementService +from services.human_input_v2.contact_service import ContactManagementService + +_NOW = datetime(2026, 8, 30, 8) +_TENANT_ID = TenantId("00000000-0000-0000-0000-000000000101") +_ACCOUNT_ID = AccountId("00000000-0000-0000-0000-000000000201") +_CONTACT_ID = ContactId("00000000-0000-0000-0000-000000000301") +_DEFAULT_CONTACT_QUERY = ContactQuery() + + +class _TenantContactPort: + def __init__(self, contacts: Sequence[Contact]) -> None: + self._contacts = tuple(contacts) + self.tenant_calls: list[TenantId] = [] + + def count_contact(self, tenant_id: TenantId, query: ContactQuery = _DEFAULT_CONTACT_QUERY) -> int: + self.tenant_calls.append(tenant_id) + return len(self._matching_contacts(query)) + + def list_contact( + self, + tenant_id: TenantId, + page: int, + limit: int, + query: ContactQuery = _DEFAULT_CONTACT_QUERY, + ) -> Page[Contact]: + self.tenant_calls.append(tenant_id) + contacts = self._matching_contacts(query) + offset = (page - 1) * limit + return Page(contacts[offset : offset + limit], page, limit) + + def get_contacts_by_id(self, tenant_id: TenantId, contact_id: ContactId) -> Contact | None: + self.tenant_calls.append(tenant_id) + return next((contact for contact in self._contacts if contact.id == contact_id), None) + + def get_contact_by_account_id(self, tenant_id: TenantId, account_id: AccountId) -> Contact | None: + raise AssertionError(f"Account identity lookup is outside this contract: {tenant_id}, {account_id}") + + def get_contacts_by_ids(self, tenant_id: TenantId, contact_ids: Sequence[ContactId]) -> Sequence[Contact]: + self.tenant_calls.append(tenant_id) + requested_ids = set(contact_ids) + return tuple(contact for contact in self._contacts if contact.id in requested_ids) + + def available(self, tenant_id: TenantId, contact_ids: Sequence[ContactId]) -> Mapping[ContactId, bool]: + self.tenant_calls.append(tenant_id) + available_ids = {contact.id for contact in self._contacts} + return {contact_id: contact_id in available_ids for contact_id in dict.fromkeys(contact_ids)} + + def save_external_contact(self, tenant_id: TenantId, external_contact: ExternalContact) -> Contact: + self.tenant_calls.append(tenant_id) + return Contact( + id=external_contact.id, + type=ContactType.EXTERNAL, + name=external_contact.name, + email=external_contact.email, + avatar_file_id=external_contact.avatar_file_id, + created_at=external_contact.created_at, + ) + + def delete_external_contact(self, tenant_id: TenantId, external_contact: ExternalContact) -> None: + del external_contact + self.tenant_calls.append(tenant_id) + + def provision_account_backed_contact(self, account_id: AccountId) -> ContactId: + del account_id + return _CONTACT_ID + + def query_contacts_by_email(self, tenant_id: TenantId, emails: Sequence[str]) -> Sequence[Contact]: + self.tenant_calls.append(tenant_id) + requested_emails = {email.casefold() for email in emails} + return tuple( + contact + for contact in self._contacts + if contact.email is not None and contact.email.casefold() in requested_emails + ) + + def _matching_contacts(self, query: ContactQuery) -> tuple[Contact, ...]: + keyword = query.keyword.strip().casefold() + return tuple( + contact + for contact in self._contacts + if (query.contact_type is None or contact.type is query.contact_type) + and (not keyword or keyword in contact.name.casefold() or keyword in (contact.email or "").casefold()) + ) + + +class _BindingReadPort: + def __init__(self) -> None: + self.calls: list[tuple[TenantId, tuple[ContactId, ...]]] = [] + + def get_im_bindings(self, tenant_id: TenantId, contact_ids: Sequence[ContactId]) -> Sequence[IMBinding]: + self.calls.append((tenant_id, tuple(contact_ids))) + return () + + +class _EnterpriseContactPort: + def __init__(self, candidate: OrganizationCandidate) -> None: + self.candidate = candidate + self.created: list[tuple[TenantId, CandidateId, AccountId]] = [] + self.deleted: list[tuple[TenantId, ContactId]] = [] + + def list_organization_candidates( + self, + page: int, + limit: int, + keyword: str = "", + ) -> Sequence[OrganizationCandidate]: + if page != 1 or limit < 1 or keyword.casefold() not in self.candidate.name.casefold(): + return () + return (self.candidate,) + + def count_organization_candidates(self, keyword: str = "") -> int: + return int(keyword.casefold() in self.candidate.name.casefold()) + + def create_platform_entry( + self, + tenant_id: TenantId, + candidate_id: CandidateId, + added_by_account_id: AccountId, + ) -> None: + self.created.append((tenant_id, candidate_id, added_by_account_id)) + + def delete_platform_entry(self, tenant_id: TenantId, contact_id: ContactId) -> None: + self.deleted.append((tenant_id, contact_id)) + + +def _platform_contact() -> Contact: + return Contact( + id=_CONTACT_ID, + type=ContactType.PLATFORM, + name="Platform Reviewer", + email="reviewer@example.com", + avatar_file_id=None, + created_at=_NOW, + ) + + +def test_public_service_constructors_expose_the_core_and_enterprise_ports_separately() -> None: + core_hints = get_type_hints(ContactManagementService.__init__) + enterprise_hints = get_type_hints(EnterpriseContactManagementService.__init__) + + assert core_hints["contact_repository"] is ContactRepository + assert core_hints["binding_repository"] is ContactIMBindingRepository + assert EnterpriseContactRepository not in core_hints.values() + assert enterprise_hints["contacts"] is ContactRepository + assert enterprise_hints["enterprise_contacts"] is EnterpriseContactRepository + + +def test_tenant_scoped_core_service_needs_only_contact_and_binding_read_ports() -> None: + contact = _platform_contact() + contact_port = _TenantContactPort((contact,)) + binding_port = _BindingReadPort() + service = ContactManagementService(contact_port, binding_port, None) + + contact_page, contacts_with_bindings = service.list_contacts( + _TENANT_ID, + page=1, + limit=10, + query=ContactQuery(keyword="reviewer"), + ) + selected = service.get_contact(_TENANT_ID, contact.id) + options = service.get_contact_options(_TENANT_ID, (contact.id,)) + + assert contact_page.items == (contact,) + assert tuple(item.contact for item in contacts_with_bindings) == (contact,) + assert selected is not None + assert selected.contact == contact + assert tuple(options) == (contact,) + assert contact_port.tenant_calls == [_TENANT_ID, _TENANT_ID, _TENANT_ID] + assert binding_port.calls == [(_TENANT_ID, (contact.id,)), (_TENANT_ID, (contact.id,))] + + +def test_candidate_id_round_trips_unchanged_through_the_enterprise_service_port() -> None: + candidate_id = CandidateId(_CONTACT_ID) + candidate = OrganizationCandidate( + id=candidate_id, + name="Platform Reviewer", + email="reviewer@example.com", + avatar_file_id=None, + created_at=_NOW, + ) + tenant_port = _TenantContactPort((_platform_contact(),)) + enterprise_port = _EnterpriseContactPort(candidate) + query_service = ContactManagementService(tenant_port, _BindingReadPort(), None) + service = EnterpriseContactManagementService(tenant_port, enterprise_port, query_service) + + listed = service.list_organization_candidates(page=1, limit=10, keyword="reviewer") + count = service.count_organization_candidates("reviewer") + added = service.add_platform_contacts(_TENANT_ID, (listed[0].id,), _ACCOUNT_ID) + removed = service.remove_contacts(_TENANT_ID, (listed[0].id,)) + + assert count == len(listed) == 1 + assert listed[0].id is candidate_id + assert enterprise_port.created == [(_TENANT_ID, candidate_id, _ACCOUNT_ID)] + assert enterprise_port.created[0][1] is candidate_id + assert tuple(item.contact.id for item in added) == (candidate_id,) + assert enterprise_port.deleted == [(_TENANT_ID, candidate_id)] + assert removed == (candidate_id,) diff --git a/api/tests/unit_tests/services/human_input_v2/test_contact_im_binding_service.py b/api/tests/unit_tests/services/human_input_v2/test_contact_im_binding_service.py new file mode 100644 index 00000000000..b7d14a629dc --- /dev/null +++ b/api/tests/unit_tests/services/human_input_v2/test_contact_im_binding_service.py @@ -0,0 +1,794 @@ +"""SQLite behavior tests for Channel-bound manual IM Binding commands.""" + +from __future__ import annotations + +from collections.abc import Sequence +from dataclasses import dataclass, replace +from datetime import datetime +from functools import partial +from importlib import import_module +from inspect import unwrap +from uuid import UUID + +import pytest +import sqlalchemy as sa +from flask import Flask +from flask.typing import ResponseReturnValue +from sqlalchemy import event +from sqlalchemy.engine import Engine +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session, sessionmaker + +from controllers.console.human_input_v2 import contact as human_input +from controllers.console.human_input_v2.contact import WorkspaceContactIMBindingsApi +from core.human_input_v2.entities import HumanInputContactType, IMBindingScope, IMProvider +from core.human_input_v2.im_integration import IMBindingCommandError, IMBindingCommandErrorCode +from core.human_input_v2.shared import ( + AccountId, + ContactId, + DirectoryScope, + IMBindingId, + IMIdentityId, + IMSyncRunId, + TenantId, + WorkspaceScope, +) +from models.account import Account, AccountStatus +from models.human_input_v2 import ( + ContactSubjectType, + HumanInputContactIdentity, + HumanInputIMBinding, + HumanInputIMBindingWorkspaceOverride, + HumanInputPlatformContactWorkspaceEntry, +) +from repositories.human_input_v2.channel_credentials import ChannelEncryptedCredentials +from repositories.human_input_v2.contact import ExternalContact, IMBinding +from repositories.human_input_v2.im_binding_repository import IMBindingAssignment +from repositories.human_input_v2.im_channel_repository import ( + IMChannel, + IMChannelId, + IMChannelStatus, + WebhookId, +) +from repositories.human_input_v2.im_identity_repository import IMIdentityObservation, OpaqueProviderPayload +from repositories.human_input_v2.sqlalchemy_contact_repository import ( + SQLAlchemyContactIMBindingRepository, + SQLAlchemyContactRepository, +) +from repositories.human_input_v2.sqlalchemy_im_binding_repository import SQLAlchemyIMBindingRepository +from repositories.human_input_v2.sqlalchemy_im_identity_repository import SQLAlchemyIMIdentityRepository +from services.human_input_v2.contact_service import ContactManagementService +from services.human_input_v2.im_contact_sync.binding_service import ContactIMBindingService +from services.human_input_v2.im_contact_sync.composition import build_im_contact_sync_application + +_NOW = datetime(2026, 8, 11, 8) +_TENANT_ID = TenantId("00000000-0000-0000-0000-000000000101") +_CONTACT_ID = ContactId("00000000-0000-0000-0000-000000000201") +_OTHER_CONTACT_ID = ContactId("00000000-0000-0000-0000-000000000202") +_CHANNEL_ID = IMChannelId("00000000-0000-0000-0000-000000000301") +_OTHER_CHANNEL_ID = IMChannelId("00000000-0000-0000-0000-000000000302") +_IDENTITY_ID = IMIdentityId("00000000-0000-0000-0000-000000000401") +_OTHER_IDENTITY_ID = IMIdentityId("00000000-0000-0000-0000-000000000402") +_ACCOUNT_ID = AccountId("00000000-0000-0000-0000-000000000501") +_OTHER_ACCOUNT_ID = AccountId("00000000-0000-0000-0000-000000000502") +_OWNER_SCOPE = WorkspaceScope(id=_TENANT_ID) + + +def _channel(channel_id: IMChannelId = _CHANNEL_ID) -> IMChannel: + return IMChannel( + id=channel_id, + created_at=_NOW, + updated_at=_NOW, + provider=IMProvider.FEISHU, + provider_tenant_id="provider-tenant-1", + encrypted_credentials=ChannelEncryptedCredentials(ciphertext="opaque-ciphertext"), + app_identifier="app-1", + webhook_id=WebhookId("00000000000000000000000000000001"), + config_version=1, + status=IMChannelStatus.CONNECTED, + ) + + +def _observation(provider_user_id: str) -> IMIdentityObservation: + return IMIdentityObservation( + provider_user_id=provider_user_id, + display_name="Reviewer", + email="reviewer@example.com", + raw_payload=OpaqueProviderPayload({}), + sync_run_id=IMSyncRunId("00000000-0000-0000-0000-000000000601"), + observed_at=_NOW, + ) + + +@dataclass(frozen=True) +class _BindingContext: + sessions: sessionmaker[Session] + service: ContactIMBindingService + + +@pytest.fixture +def binding_context(sqlite_engine: Engine) -> _BindingContext: + sessions = sessionmaker(bind=sqlite_engine, expire_on_commit=False) + account = Account(name="Reviewer", email="reviewer@example.com", status=AccountStatus.ACTIVE) + account.id = str(_ACCOUNT_ID) + contact_identity = HumanInputContactIdentity( + subject_type=ContactSubjectType.ACCOUNT, + account_id=account.id, + ) + contact_identity.id = str(_CONTACT_ID) + contact_identity.created_at = _NOW + contact_identity.updated_at = _NOW + platform_entry = HumanInputPlatformContactWorkspaceEntry( + tenant_id=str(_TENANT_ID), + contact_id=str(_CONTACT_ID), + added_by_account_id=str(_ACCOUNT_ID), + ) + platform_entry.id = "00000000-0000-0000-0000-000000000701" + with sessions.begin() as session: + session.add_all([account, contact_identity, platform_entry]) + SQLAlchemyIMIdentityRepository(session, _CHANNEL_ID).create(_IDENTITY_ID, _observation("provider-user-1")) + SQLAlchemyIMIdentityRepository(session, _CHANNEL_ID).create( + _OTHER_IDENTITY_ID, + _observation("provider-user-2"), + ) + SQLAlchemyIMIdentityRepository(session, _OTHER_CHANNEL_ID).create( + IMIdentityId("00000000-0000-0000-0000-000000000499"), + _observation("foreign-provider-user"), + ) + + def resolve_channel(_session: Session, owner_scope: DirectoryScope) -> IMChannel: + assert owner_scope == _OWNER_SCOPE + return _channel() + + return _BindingContext( + sessions=sessions, + service=ContactIMBindingService(sessions, resolve_channel, clock=lambda: _NOW), + ) + + +def test_default_create_and_delete_preserve_contact_projection_and_actor_metadata( + binding_context: _BindingContext, +) -> None: + contact = binding_context.service.set_organization_binding( + organization_scope=_OWNER_SCOPE, + tenant_id=_TENANT_ID, + contact_id=_CONTACT_ID, + identity_id=_IDENTITY_ID, + bound_by_account_id=_ACCOUNT_ID, + ) + + assert contact.id == _CONTACT_ID + assert contact.type is HumanInputContactType.PLATFORM + assert contact.name == "Reviewer" + assert contact.email == "reviewer@example.com" + assert len(contact.im_bindings) == 1 + binding = contact.im_bindings[0] + assert binding.scope is IMBindingScope.ORGANIZATION + assert binding.contact_id == _CONTACT_ID + assert binding.identity_id == _IDENTITY_ID + assert binding.provider is IMProvider.FEISHU + with binding_context.sessions() as session: + record = session.get_one(HumanInputIMBinding, str(binding.id)) + assert record.channel_id == str(_CHANNEL_ID) + assert record.bound_by_account_id == str(_ACCOUNT_ID) + + binding_context.service.delete_organization_binding( + organization_scope=_OWNER_SCOPE, + contact_id=_CONTACT_ID, + ) + with binding_context.sessions() as session: + assert session.scalar(sa.select(sa.func.count(HumanInputIMBinding.id))) == 0 + + +def test_delete_current_default_preserves_overrides_other_contacts_and_other_channels( + binding_context: _BindingContext, +) -> None: + with binding_context.sessions.begin() as session: + repository = SQLAlchemyIMBindingRepository(session, _CHANNEL_ID) + repository.create(IMBindingAssignment(_CONTACT_ID, _IDENTITY_ID, _NOW), bound_by_account_id=_ACCOUNT_ID) + override = repository.set_workspace_override( + _TENANT_ID, + IMBindingAssignment(_CONTACT_ID, _IDENTITY_ID, _NOW), + bound_by_account_id=_ACCOUNT_ID, + ) + current = repository.replace( + IMBindingAssignment(_CONTACT_ID, _OTHER_IDENTITY_ID, _NOW), + bound_by_account_id=_ACCOUNT_ID, + ) + other_contact = repository.create( + IMBindingAssignment(_OTHER_CONTACT_ID, _IDENTITY_ID, _NOW), + bound_by_account_id=_ACCOUNT_ID, + ) + other_channel = SQLAlchemyIMBindingRepository(session, _OTHER_CHANNEL_ID).create( + IMBindingAssignment(_CONTACT_ID, IMIdentityId("00000000-0000-0000-0000-000000000499"), _NOW), + bound_by_account_id=_ACCOUNT_ID, + ) + + binding_context.service.delete_organization_binding( + organization_scope=_OWNER_SCOPE, + contact_id=_CONTACT_ID, + ) + + with binding_context.sessions() as session: + assert session.get(HumanInputIMBinding, str(current.id)) is None + assert session.get_one(HumanInputIMBinding, str(other_contact.id)).im_identity_id == str(_IDENTITY_ID) + assert session.get_one(HumanInputIMBinding, str(other_channel.id)).channel_id == str(_OTHER_CHANNEL_ID) + assert session.get_one(HumanInputIMBindingWorkspaceOverride, str(override.id)).im_identity_id == str( + _IDENTITY_ID + ) + + +def test_contact_details_follow_effective_binding_and_reset(binding_context: _BindingContext) -> None: + binding_context.service.set_organization_binding( + organization_scope=_OWNER_SCOPE, + tenant_id=_TENANT_ID, + contact_id=_CONTACT_ID, + identity_id=_IDENTITY_ID, + bound_by_account_id=_ACCOUNT_ID, + ) + override = binding_context.service.set_workspace_override( + organization_scope=_OWNER_SCOPE, + tenant_id=_TENANT_ID, + contact_id=_CONTACT_ID, + identity_id=_OTHER_IDENTITY_ID, + bound_by_account_id=_ACCOUNT_ID, + ) + with binding_context.sessions.begin() as session: + SQLAlchemyIMIdentityRepository(session, _CHANNEL_ID).update( + _OTHER_IDENTITY_ID, + replace(_observation("provider-user-2"), display_name="Workspace Reviewer", email="override@example.com"), + ) + + with binding_context.sessions() as session: + service = ContactManagementService( + SQLAlchemyContactRepository(session), + SQLAlchemyContactIMBindingRepository(session, _channel()), + SQLAlchemyIMIdentityRepository(session, _CHANNEL_ID), + ) + details = service.get_contact_details(_TENANT_ID, _CONTACT_ID) + assert details is not None + assert details.contact.contact.email == "reviewer@example.com" + assert len(details.im_binding_details) == 1 + binding = details.im_binding_details[0] + assert binding.binding.id == override.im_bindings[0].id + assert binding.binding.scope is IMBindingScope.WORKSPACE + assert binding.identity.id == _OTHER_IDENTITY_ID + assert binding.identity.provider_user_id == "provider-user-2" + assert binding.identity.display_name == "Workspace Reviewer" + assert binding.identity.email == "override@example.com" + assert details.contact.im_bindings == (binding.binding,) + + binding_context.service.reset_workspace_override( + organization_scope=_OWNER_SCOPE, tenant_id=_TENANT_ID, contact_id=_CONTACT_ID + ) + with binding_context.sessions() as session: + service = ContactManagementService( + SQLAlchemyContactRepository(session), + SQLAlchemyContactIMBindingRepository(session, _channel()), + SQLAlchemyIMIdentityRepository(session, _CHANNEL_ID), + ) + details = service.get_contact_details(_TENANT_ID, _CONTACT_ID) + assert details is not None + binding = details.im_binding_details[0] + assert binding.binding.scope is IMBindingScope.ORGANIZATION + assert binding.identity.id == _IDENTITY_ID + assert binding.identity.email == "reviewer@example.com" + + +def test_contact_details_read_identities_in_one_batch( + binding_context: _BindingContext, monkeypatch: pytest.MonkeyPatch +) -> None: + bindings = ( + IMBinding(IMBindingId("binding-1"), IMBindingScope.ORGANIZATION, _CONTACT_ID, _IDENTITY_ID, IMProvider.FEISHU), + IMBinding( + IMBindingId("binding-2"), IMBindingScope.WORKSPACE, _CONTACT_ID, _OTHER_IDENTITY_ID, IMProvider.FEISHU + ), + ) + + def read_bindings(tenant_id: TenantId, contact_ids: Sequence[ContactId]) -> Sequence[IMBinding]: + return bindings if tenant_id == _TENANT_ID and _CONTACT_ID in contact_ids else () + + statements: list[str] = [] + + def capture_statement( + _connection: object, + _cursor: object, + statement: str, + _parameters: object, + _context: object, + _executemany: bool, + ) -> None: + statements.append(statement) + + with binding_context.sessions() as session: + binding_repository = SQLAlchemyContactIMBindingRepository(session, _channel()) + monkeypatch.setattr(binding_repository, "get_im_bindings", read_bindings) + service = ContactManagementService( + SQLAlchemyContactRepository(session), + binding_repository, + SQLAlchemyIMIdentityRepository(session, _CHANNEL_ID), + ) + connection = session.connection() + event.listen(connection, "before_cursor_execute", capture_statement) + try: + details = service.get_contact_details(_TENANT_ID, _CONTACT_ID) + finally: + event.remove(connection, "before_cursor_execute", capture_statement) + + assert details is not None + assert tuple(detail.identity.id for detail in details.im_binding_details) == (_IDENTITY_ID, _OTHER_IDENTITY_ID) + # One contact query and one identity query, regardless of the number of bindings. + assert len(statements) == 2 + + +def test_contact_details_are_workspace_and_channel_scoped(binding_context: _BindingContext) -> None: + binding_context.service.set_organization_binding( + organization_scope=_OWNER_SCOPE, + tenant_id=_TENANT_ID, + contact_id=_CONTACT_ID, + identity_id=_IDENTITY_ID, + bound_by_account_id=_ACCOUNT_ID, + ) + with binding_context.sessions() as session: + service = ContactManagementService( + SQLAlchemyContactRepository(session), + SQLAlchemyContactIMBindingRepository(session, _channel()), + SQLAlchemyIMIdentityRepository(session, _CHANNEL_ID), + ) + assert service.get_contact_details(TenantId("00000000-0000-0000-0000-000000000999"), _CONTACT_ID) is None + assert service.get_contact_details(_TENANT_ID, _OTHER_CONTACT_ID) is None + for channel in (None, _channel(_OTHER_CHANNEL_ID)): + service = ContactManagementService( + SQLAlchemyContactRepository(session), + SQLAlchemyContactIMBindingRepository(session, channel), + SQLAlchemyIMIdentityRepository(session, channel.id) if channel is not None else None, + ) + details = service.get_contact_details(_TENANT_ID, _CONTACT_ID) + assert details is not None + assert details.im_binding_details == () + assert details.contact.im_bindings == () + + +def test_contact_details_allow_missing_identity_profile_and_unbound_contacts(binding_context: _BindingContext) -> None: + with binding_context.sessions.begin() as session: + contacts = SQLAlchemyContactRepository(session) + contacts.save_external_contact( + _TENANT_ID, ExternalContact(_OTHER_CONTACT_ID, "External", "external@example.com", None, _NOW) + ) + service = ContactManagementService( + contacts, + SQLAlchemyContactIMBindingRepository(session, _channel()), + SQLAlchemyIMIdentityRepository(session, _CHANNEL_ID), + ) + for contact_id in (_CONTACT_ID, _OTHER_CONTACT_ID): + details = service.get_contact_details(_TENANT_ID, contact_id) + assert details is not None + assert details.im_binding_details == () + SQLAlchemyIMIdentityRepository(session, _CHANNEL_ID).update( + _IDENTITY_ID, replace(_observation("provider-user-1"), display_name=None, email=None) + ) + binding_context.service.set_organization_binding( + organization_scope=_OWNER_SCOPE, + tenant_id=_TENANT_ID, + contact_id=_CONTACT_ID, + identity_id=_IDENTITY_ID, + bound_by_account_id=_ACCOUNT_ID, + ) + with binding_context.sessions() as session: + service = ContactManagementService( + SQLAlchemyContactRepository(session), + SQLAlchemyContactIMBindingRepository(session, _channel()), + SQLAlchemyIMIdentityRepository(session, _CHANNEL_ID), + ) + details = service.get_contact_details(_TENANT_ID, _CONTACT_ID) + assert details is not None + identity = details.im_binding_details[0].identity + assert identity.provider_user_id == "provider-user-1" + assert identity.display_name is None + assert identity.email is None + + +def test_workspace_override_reuses_one_service_across_mutations_and_reset_falls_back_to_default( + binding_context: _BindingContext, +) -> None: + default_view = binding_context.service.set_organization_binding( + organization_scope=_OWNER_SCOPE, + tenant_id=_TENANT_ID, + contact_id=_CONTACT_ID, + identity_id=_IDENTITY_ID, + bound_by_account_id=_ACCOUNT_ID, + ) + default_binding = default_view.im_bindings[0] + override_view = binding_context.service.set_workspace_override( + organization_scope=_OWNER_SCOPE, + tenant_id=_TENANT_ID, + contact_id=_CONTACT_ID, + identity_id=_OTHER_IDENTITY_ID, + bound_by_account_id=_ACCOUNT_ID, + ) + retried_view = binding_context.service.set_workspace_override( + organization_scope=_OWNER_SCOPE, + tenant_id=_TENANT_ID, + contact_id=_CONTACT_ID, + identity_id=_OTHER_IDENTITY_ID, + bound_by_account_id=_OTHER_ACCOUNT_ID, + ) + + override_binding = override_view.im_bindings[0] + assert override_binding.scope is IMBindingScope.WORKSPACE + assert override_binding.identity_id == _OTHER_IDENTITY_ID + assert retried_view.im_bindings[0].id == override_binding.id + with binding_context.sessions() as session: + override_record = session.get_one(HumanInputIMBindingWorkspaceOverride, str(override_binding.id)) + assert override_record.bound_by_account_id == str(_ACCOUNT_ID) + + reset_view = binding_context.service.reset_workspace_override( + organization_scope=_OWNER_SCOPE, + tenant_id=_TENANT_ID, + contact_id=_CONTACT_ID, + ) + assert reset_view.im_bindings == (default_binding,) + with binding_context.sessions() as session: + assert session.scalar(sa.select(sa.func.count(HumanInputIMBinding.id))) == 1 + assert session.scalar(sa.select(sa.func.count(HumanInputIMBindingWorkspaceOverride.id))) == 0 + + +@pytest.mark.parametrize("with_override", [False, True]) +def test_put_replaces_default_binding_and_retries_are_idempotent( + binding_context: _BindingContext, + app: Flask, + monkeypatch: pytest.MonkeyPatch, + with_override: bool, +) -> None: + monkeypatch.setattr( + import_module("controllers.console.human_input_v2.contact"), + "build_im_contact_sync_application", + lambda: replace( + build_im_contact_sync_application(session_maker=binding_context.sessions), + binding_service=binding_context.service, + ), + ) + with binding_context.sessions() as session: + account = session.get_one(Account, str(_ACCOUNT_ID)) + + def put(identity_id: IMIdentityId) -> object: + with app.test_request_context(method="PUT", json={"identity_id": str(identity_id)}): + return unwrap(WorkspaceContactIMBindingsApi.put)( + WorkspaceContactIMBindingsApi(), str(_TENANT_ID), account, str(_CONTACT_ID) + ) + + first = put(_IDENTITY_ID) + assert isinstance(first, dict) + original_id = first["contact"]["im_bindings"][0]["id"] + override = None + if with_override: + override = binding_context.service.set_workspace_override( + organization_scope=_OWNER_SCOPE, + tenant_id=_TENANT_ID, + contact_id=_CONTACT_ID, + identity_id=_IDENTITY_ID, + bound_by_account_id=_OTHER_ACCOUNT_ID, + ).im_bindings[0] + + replacement = put(_OTHER_IDENTITY_ID) + assert isinstance(replacement, dict) + with binding_context.sessions() as session: + assert session.get(HumanInputIMBinding, original_id) is None + record = session.scalars(sa.select(HumanInputIMBinding)).one() + assert record.im_identity_id == str(_OTHER_IDENTITY_ID) + assert record.bound_by_account_id == str(_ACCOUNT_ID) + new_id = record.id + if override is not None: + stored_override = session.get_one(HumanInputIMBindingWorkspaceOverride, str(override.id)) + assert stored_override.im_identity_id == str(_IDENTITY_ID) + assert stored_override.bound_by_account_id == str(_OTHER_ACCOUNT_ID) + assert replacement["contact"]["im_bindings"][0]["id"] == str(override.id) + else: + assert replacement["contact"]["im_bindings"][0]["id"] == new_id + + assert put(_OTHER_IDENTITY_ID) == replacement + with binding_context.sessions() as session: + assert session.scalars(sa.select(HumanInputIMBinding.id)).one() == new_id + + if with_override: + reset = binding_context.service.reset_workspace_override( + organization_scope=_OWNER_SCOPE, tenant_id=_TENANT_ID, contact_id=_CONTACT_ID + ) + assert str(reset.im_bindings[0].id) == new_id + assert reset.im_bindings[0].identity_id == _OTHER_IDENTITY_ID + + +@pytest.mark.parametrize("target_already_bound", [False, True]) +def test_put_transfers_identity_from_another_contact( + binding_context: _BindingContext, + app: Flask, + monkeypatch: pytest.MonkeyPatch, + target_already_bound: bool, +) -> None: + with binding_context.sessions.begin() as session: + SQLAlchemyContactRepository(session).save_external_contact( + _TENANT_ID, ExternalContact(_OTHER_CONTACT_ID, "External", "external@example.com", None, _NOW) + ) + source = binding_context.service.set_organization_binding( + organization_scope=_OWNER_SCOPE, + tenant_id=_TENANT_ID, + contact_id=_OTHER_CONTACT_ID, + identity_id=_OTHER_IDENTITY_ID, + bound_by_account_id=_OTHER_ACCOUNT_ID, + ).im_bindings[0] + target = None + if target_already_bound: + target = binding_context.service.set_organization_binding( + organization_scope=_OWNER_SCOPE, + tenant_id=_TENANT_ID, + contact_id=_CONTACT_ID, + identity_id=_IDENTITY_ID, + bound_by_account_id=_OTHER_ACCOUNT_ID, + ).im_bindings[0] + monkeypatch.setattr( + human_input, + "build_im_contact_sync_application", + lambda: replace( + build_im_contact_sync_application(session_maker=binding_context.sessions), + binding_service=binding_context.service, + ), + ) + with binding_context.sessions() as session: + account = session.get_one(Account, str(_ACCOUNT_ID)) + + def put() -> object: + with app.test_request_context(method="PUT", json={"identity_id": str(_OTHER_IDENTITY_ID)}): + return unwrap(WorkspaceContactIMBindingsApi.put)( + WorkspaceContactIMBindingsApi(), str(_TENANT_ID), account, str(_CONTACT_ID) + ) + + response = put() + assert isinstance(response, dict) + binding_id = response["contact"]["im_bindings"][0]["id"] + assert response["contact"]["id"] == str(_CONTACT_ID) + assert binding_id != str(source.id) + if target is not None: + assert binding_id != str(target.id) + assert put() == response + with binding_context.sessions() as session: + record = session.scalars(sa.select(HumanInputIMBinding)).one() + assert record.id == binding_id + assert record.contact_id == str(_CONTACT_ID) + assert record.im_identity_id == str(_OTHER_IDENTITY_ID) + assert record.bound_by_account_id == str(_ACCOUNT_ID) + assert SQLAlchemyIMBindingRepository(session, _CHANNEL_ID).get_effective(_TENANT_ID, _OTHER_CONTACT_ID) is None + + +@pytest.mark.parametrize(("occupied", "workspace_override"), [(False, False), (False, True), (True, True)]) +def test_failed_replacement_preserves_original_binding( + binding_context: _BindingContext, occupied: bool, workspace_override: bool +) -> None: + assign = ( + binding_context.service.set_workspace_override + if workspace_override + else binding_context.service.set_organization_binding + ) + original = assign( + organization_scope=_OWNER_SCOPE, + tenant_id=_TENANT_ID, + contact_id=_CONTACT_ID, + identity_id=_IDENTITY_ID, + bound_by_account_id=_ACCOUNT_ID, + ).im_bindings[0] + next_identity_id = IMIdentityId("00000000-0000-0000-0000-000000000499") + expected_error = IMBindingCommandErrorCode.IDENTITY_NOT_FOUND + if occupied: + with binding_context.sessions.begin() as session: + repository = SQLAlchemyIMBindingRepository(session, _CHANNEL_ID) + create = partial(repository.set_workspace_override, _TENANT_ID) if workspace_override else repository.create + create( + IMBindingAssignment(_OTHER_CONTACT_ID, _OTHER_IDENTITY_ID, _NOW), + bound_by_account_id=None, + ) + next_identity_id = _OTHER_IDENTITY_ID + expected_error = IMBindingCommandErrorCode.BINDING_CONFLICT + + with pytest.raises(IMBindingCommandError) as error_info: + assign( + organization_scope=_OWNER_SCOPE, + tenant_id=_TENANT_ID, + contact_id=_CONTACT_ID, + identity_id=next_identity_id, + bound_by_account_id=_OTHER_ACCOUNT_ID, + ) + assert error_info.value.code is expected_error + with binding_context.sessions() as session: + model = HumanInputIMBindingWorkspaceOverride if workspace_override else HumanInputIMBinding + record = session.get_one(model, str(original.id)) + assert record.im_identity_id == str(_IDENTITY_ID) + assert record.bound_by_account_id == str(_ACCOUNT_ID) + + +@pytest.mark.parametrize(("occupied", "workspace_override"), [(False, False), (True, False), (False, True)]) +def test_replacement_insert_failure_rolls_back_deleted_bindings( + binding_context: _BindingContext, occupied: bool, workspace_override: bool +) -> None: + assign = ( + binding_context.service.set_workspace_override + if workspace_override + else binding_context.service.set_organization_binding + ) + model = HumanInputIMBindingWorkspaceOverride if workspace_override else HumanInputIMBinding + original = assign( + organization_scope=_OWNER_SCOPE, + tenant_id=_TENANT_ID, + contact_id=_CONTACT_ID, + identity_id=_IDENTITY_ID, + bound_by_account_id=_ACCOUNT_ID, + ).im_bindings[0] + source = None + if occupied: + with binding_context.sessions.begin() as session: + source = SQLAlchemyIMBindingRepository(session, _CHANNEL_ID).create( + IMBindingAssignment(_OTHER_CONTACT_ID, _OTHER_IDENTITY_ID, _NOW), + bound_by_account_id=_OTHER_ACCOUNT_ID, + ) + + def reject_binding_insert(_mapper: object, _connection: object, _target: object) -> None: + raise IntegrityError("INSERT", {}, Exception("binding insert unavailable")) + + event.listen(model, "before_insert", reject_binding_insert) + try: + with pytest.raises(IntegrityError, match="binding insert unavailable"): + assign( + organization_scope=_OWNER_SCOPE, + tenant_id=_TENANT_ID, + contact_id=_CONTACT_ID, + identity_id=_OTHER_IDENTITY_ID, + bound_by_account_id=_OTHER_ACCOUNT_ID, + ) + finally: + event.remove(model, "before_insert", reject_binding_insert) + + with binding_context.sessions() as session: + records = session.scalars(sa.select(model)).all() + assert {record.id for record in records} == ( + {str(original.id), str(source.id)} if source is not None else {str(original.id)} + ) + if source is not None: + source_record = session.get_one(model, str(source.id)) + assert source_record.contact_id == str(_OTHER_CONTACT_ID) + assert source_record.im_identity_id == str(_OTHER_IDENTITY_ID) + assert source_record.bound_by_account_id == str(_OTHER_ACCOUNT_ID) + record = session.get_one(model, str(original.id)) + assert record.im_identity_id == str(_IDENTITY_ID) + assert record.bound_by_account_id == str(_ACCOUNT_ID) + + +def test_missing_or_foreign_identity_maps_to_stable_error_without_partial_binding( + binding_context: _BindingContext, +) -> None: + with pytest.raises(IMBindingCommandError) as error_info: + binding_context.service.set_organization_binding( + organization_scope=_OWNER_SCOPE, + tenant_id=_TENANT_ID, + contact_id=_CONTACT_ID, + identity_id=IMIdentityId("00000000-0000-0000-0000-000000000499"), + bound_by_account_id=None, + ) + + assert error_info.value.code is IMBindingCommandErrorCode.IDENTITY_NOT_FOUND + with binding_context.sessions() as session: + assert session.scalar(sa.select(sa.func.count(HumanInputIMBinding.id))) == 0 + + +@pytest.mark.parametrize("workspace_override", [False, True]) +def test_contact_boundary_and_missing_default_binding_errors_are_stable( + binding_context: _BindingContext, workspace_override: bool +) -> None: + with pytest.raises(IMBindingCommandError) as missing_contact: + binding_context.service.set_workspace_override( + organization_scope=_OWNER_SCOPE, + tenant_id=_TENANT_ID, + contact_id=_OTHER_CONTACT_ID, + identity_id=_IDENTITY_ID, + bound_by_account_id=None, + ) + assert missing_contact.value.code is IMBindingCommandErrorCode.CONTACT_NOT_FOUND + + override = None + if workspace_override: + override = binding_context.service.set_workspace_override( + organization_scope=_OWNER_SCOPE, + tenant_id=_TENANT_ID, + contact_id=_CONTACT_ID, + identity_id=_IDENTITY_ID, + bound_by_account_id=_ACCOUNT_ID, + ).im_bindings[0] + with pytest.raises(IMBindingCommandError) as missing_binding: + binding_context.service.delete_organization_binding( + organization_scope=_OWNER_SCOPE, + contact_id=_CONTACT_ID, + ) + assert missing_binding.value.code is IMBindingCommandErrorCode.BINDING_NOT_FOUND + if override is not None: + with binding_context.sessions() as session: + assert session.get_one(HumanInputIMBindingWorkspaceOverride, str(override.id)).im_identity_id == str( + _IDENTITY_ID + ) + + +def test_missing_channel_maps_to_not_configured_before_mutation(binding_context: _BindingContext) -> None: + service = ContactIMBindingService(binding_context.sessions, lambda _session, _scope: None) + + with pytest.raises(IMBindingCommandError) as error_info: + service.set_organization_binding( + organization_scope=_OWNER_SCOPE, + tenant_id=_TENANT_ID, + contact_id=_CONTACT_ID, + identity_id=_IDENTITY_ID, + bound_by_account_id=None, + ) + + assert error_info.value.code is IMBindingCommandErrorCode.INTEGRATION_NOT_CONFIGURED + + +@pytest.mark.parametrize( + ("contact_id", "expected_status"), + [(_CONTACT_ID, 200), (_OTHER_CONTACT_ID, 404)], +) +@pytest.mark.parametrize("workspace_override", [False, True]) +def test_delete_binding_through_uuid_route( + binding_context: _BindingContext, + monkeypatch: pytest.MonkeyPatch, + contact_id: ContactId, + expected_status: int, + workspace_override: bool, +) -> None: + contact = binding_context.service.set_organization_binding( + organization_scope=_OWNER_SCOPE, + tenant_id=_TENANT_ID, + contact_id=_CONTACT_ID, + identity_id=_IDENTITY_ID, + bound_by_account_id=_ACCOUNT_ID, + ) + binding = contact.im_bindings[0] + override = None + if workspace_override: + override = binding_context.service.set_workspace_override( + organization_scope=_OWNER_SCOPE, + tenant_id=_TENANT_ID, + contact_id=_CONTACT_ID, + identity_id=_OTHER_IDENTITY_ID, + bound_by_account_id=_ACCOUNT_ID, + ).im_bindings[0] + monkeypatch.setattr( + human_input, + "build_im_contact_sync_application", + lambda: replace( + build_im_contact_sync_application(session_maker=binding_context.sessions), + binding_service=binding_context.service, + ), + ) + app = Flask(__name__) + + @app.delete("/workspaces/current/human-input/v2/contacts//im-bindings") + def delete_binding(contact_id: UUID) -> ResponseReturnValue: + # Isolate authentication while retaining Flask's path conversion and the real binding service. + return unwrap(human_input.WorkspaceContactIMBindingsApi.delete)( + human_input.WorkspaceContactIMBindingsApi(), str(_TENANT_ID), contact_id + ) + + response = app.test_client().delete( + f"/workspaces/current/human-input/v2/contacts/{contact_id}/im-bindings", + ) + + assert response.status_code == expected_status + response_body = response.get_json() + assert response_body is not None + with binding_context.sessions() as session: + if override is not None: + remaining_override = session.get_one(HumanInputIMBindingWorkspaceOverride, str(override.id)) + assert remaining_override.im_identity_id == str(_OTHER_IDENTITY_ID) + remaining = session.get(HumanInputIMBinding, str(binding.id)) + if expected_status == 200: + assert response_body == {} + assert remaining is None + else: + assert response_body["code"] == "im_binding_not_found" + assert remaining is not None diff --git a/api/tests/unit_tests/services/human_input_v2/test_email_credential_codec.py b/api/tests/unit_tests/services/human_input_v2/test_email_credential_codec.py new file mode 100644 index 00000000000..a7bc71d64ae --- /dev/null +++ b/api/tests/unit_tests/services/human_input_v2/test_email_credential_codec.py @@ -0,0 +1,86 @@ +"""Complete credential decoding rejects malformed data without exposing secrets.""" + +import json +from dataclasses import replace +from datetime import datetime +from traceback import format_exception + +import pytest +from cryptography.fernet import Fernet, InvalidToken +from pydantic import ValidationError + +from core.helper import encrypter +from core.human_input_v2.shared import EmailProviderId, NormalizedEmail, TenantId +from repositories.human_input_v2.channel_credentials import ChannelEncryptedCredentials +from repositories.human_input_v2.email_channel.entities import ( + EmailChannelConfiguration, + ResendCredentials, +) +from services.human_input_v2.email_credential_codec import load_email_credentials, seal_email_credentials + +_TENANT = TenantId("workspace-1") + + +@pytest.fixture +def configuration(monkeypatch: pytest.MonkeyPatch) -> EmailChannelConfiguration: + ciphers = {"workspace-1": Fernet(Fernet.generate_key()), "workspace-2": Fernet(Fernet.generate_key())} + monkeypatch.setattr( + encrypter, "encrypt_token", lambda tenant, plaintext: ciphers[tenant].encrypt(plaintext.encode()).decode() + ) + monkeypatch.setattr( + encrypter, "decrypt_token", lambda tenant, ciphertext: ciphers[tenant].decrypt(ciphertext.encode()).decode() + ) + now = datetime(2026, 10, 23) + return EmailChannelConfiguration( + id=EmailProviderId("email-1"), + tenant_id=_TENANT, + sender_email=NormalizedEmail("sender@example.com"), + sender_name="Sender", + encrypted_credentials=seal_email_credentials(_TENANT, ResendCredentials(api_key="private-api-key")), + configured_by_account_id=None, + created_at=now, + updated_at=now, + ) + + +def test_credentials_are_bound_to_the_workspace(configuration: EmailChannelConfiguration) -> None: + assert load_email_credentials(configuration.tenant_id, configuration.encrypted_credentials) == ResendCredentials( + api_key="private-api-key" + ) + with pytest.raises(InvalidToken): + load_email_credentials(TenantId("workspace-2"), configuration.encrypted_credentials) + + +@pytest.mark.parametrize("api_key", ["", " \t ", "arbitrary-key:+/=_", " key with spaces "]) +def test_credentials_preserve_the_opaque_api_key(configuration: EmailChannelConfiguration, api_key: str) -> None: + credentials = ResendCredentials(api_key=api_key) + + envelope = seal_email_credentials(configuration.tenant_id, credentials) + + assert load_email_credentials(configuration.tenant_id, envelope).api_key == api_key + + +@pytest.mark.parametrize( + "plaintext", + [ + "not-json-private-api-key", + json.dumps({}), + json.dumps({"api_key": 123}), + json.dumps({"api_key": None}), + json.dumps({"api_key": "private-api-key", "provider": "resend"}), + json.dumps({"api_key": "private-api-key", "sender_email": "sender@example.com"}), + json.dumps({"api_key": "private-api-key", "sender_name": "Sender"}), + ], +) +def test_invalid_credentials_raise_validation_errors(configuration: EmailChannelConfiguration, plaintext: str) -> None: + broken = replace( + configuration, + encrypted_credentials=ChannelEncryptedCredentials(ciphertext=encrypter.encrypt_token(str(_TENANT), plaintext)), + ) + + with pytest.raises(ValidationError) as captured: + load_email_credentials(broken.tenant_id, broken.encrypted_credentials) + + assert "private-api-key" not in "".join(format_exception(captured.value)) + assert "private-api-key" not in repr(broken) + assert "private-api-key" not in repr(broken.encrypted_credentials) diff --git a/api/tests/unit_tests/services/human_input_v2/test_email_otp_service.py b/api/tests/unit_tests/services/human_input_v2/test_email_otp_service.py new file mode 100644 index 00000000000..714b13183e2 --- /dev/null +++ b/api/tests/unit_tests/services/human_input_v2/test_email_otp_service.py @@ -0,0 +1,666 @@ +"""OTP authorization and audit transactions against actual SQLite persistence.""" + +import json +from dataclasses import dataclass, field +from datetime import timedelta +from hashlib import sha256 +from sqlite3 import Connection as SQLiteConnection +from unittest.mock import Mock, create_autospec +from uuid import uuid4 + +import pytest +import sqlalchemy as sa +from redis import Redis +from sqlalchemy import event +from sqlalchemy.engine import Connection +from sqlalchemy.orm import Session, sessionmaker +from sqlalchemy.pool import ConnectionPoolEntry + +from core.human_input_v2.resolved_form import ResolvedForm +from core.human_input_v2.shared.values import ContactId, NormalizedEmail, TenantId +from core.workflow.nodes.human_input.enums import HumanInputFormKind, HumanInputFormStatus +from core.workflow.nodes.human_input_v2.entities import DynamicEmail, OnetimeEmail +from extensions.ext_redis import RedisClientWrapper +from libs.datetime_utils import naive_utc_now +from libs.helper import RateLimiter +from models.account import Account, Tenant +from models.human_input_v2 import ( + HumanInputAuditEvent, + HumanInputContactIdentity, + HumanInputDelivery, + HumanInputExternalContactProfile, + HumanInputForm, + OtpSendEvent, + OtpVerificationEvent, +) +from models.model import App, AppMode +from repositories.human_input_v2.contact import ExternalContact +from repositories.human_input_v2.delivery_repository import ( + Delivery, + DeliveryCreateParams, + EmailTargetSnapshot, + SubmissionAuthType, +) +from repositories.human_input_v2.form_repository import Form, FormCreateParams, FormSubmissionFailure +from repositories.human_input_v2.recipient_repository import ( + ContactRecipientSubject, + EmailRecipientSubject, + RecipientCreateParams, +) +from repositories.human_input_v2.sqlalchemy_contact_repository import SQLAlchemyContactRepository +from repositories.human_input_v2.sqlalchemy_delivery_repository import SQLAlchemyDeliveryRepository +from repositories.human_input_v2.sqlalchemy_form_repository import SQLAlchemyFormRepository +from repositories.human_input_v2.sqlalchemy_recipient_repository import SQLAlchemyRecipientRepository +from services.human_input_v2.email_otp_service import ( + EmailOtpError, + EmailOtpService, + OtpFailure, + OtpMail, + VerificationStatus, + VerifiedEmail, +) + + +@dataclass +class Context: + sessions: sessionmaker[Session] + service: EmailOtpService + redis: Mock + limiter: Mock + delivery: Delivery + form: Form + token: str + sent_emails: list[tuple[str, str]] = field(default_factory=list) + mail_failure: EmailOtpError | None = None + + def send(self, address: str, code: str) -> None: + self.sent_emails.append((address, code)) + if self.mail_failure is not None: + raise self.mail_failure + + +@pytest.fixture(params=["email", "dynamic_email", "external_contact"]) +def context(request: pytest.FixtureRequest, sqlite_engine: sa.Engine, monkeypatch: pytest.MonkeyPatch) -> Context: + engine = sqlite_engine + # Explicit BEGIN keeps repository savepoints inside the caller's transaction. + + @event.listens_for(engine, "connect") + def configure(connection: SQLiteConnection, _record: ConnectionPoolEntry) -> None: + connection.isolation_level = None + + @event.listens_for(engine, "begin") + def begin(connection: Connection) -> None: + connection.exec_driver_sql("BEGIN") + + sessions = sessionmaker(engine, expire_on_commit=False) + tenant, app_id, token = TenantId(str(uuid4())), str(uuid4()), "form-secret" + now = naive_utc_now() + with sessions.begin() as session: + tenant_record = Tenant(name="OTP workspace") + tenant_record.id = tenant + session.add(tenant_record) + session.add( + App(id=app_id, tenant_id=tenant, name="Approval", mode=AppMode.WORKFLOW, enable_site=True, enable_api=True) + ) + session.flush() + form = SQLAlchemyFormRepository(session, tenant, app_id).create_form( + FormCreateParams( + workflow_run_id=None, + node_execution_id=None, + form_kind=HumanInputFormKind.DELIVERY_TEST, + resolved_form=ResolvedForm(title="Approval", parts=(), actions=(), legacy_form_content="Review"), + expiration_time=now + timedelta(hours=1), + global_timeout_deadline=now + timedelta(hours=2), + ) + ) + if request.param == "external_contact": + contact = SQLAlchemyContactRepository(session).save_external_contact( + tenant, + ExternalContact( + id=ContactId(str(uuid4())), + name="Approver", + email="approver@example.com", + avatar_file_id=None, + created_at=now, + ), + ) + subject = ContactRecipientSubject(contact.id) + else: + subject = EmailRecipientSubject(NormalizedEmail("approver@example.com")) + recipient = SQLAlchemyRecipientRepository(session, tenant, form.id).create_recipients( + [ + RecipientCreateParams( + subject=subject, + sources=( + (DynamicEmail(selector=["input", "email"]),) + if request.param == "dynamic_email" + else (OnetimeEmail(email="approver@example.com"),) + ), + name=None, + ) + ] + )[0] + delivery = SQLAlchemyDeliveryRepository(session).create_delivery( + tenant_id=tenant, + form_id=form.id, + params=DeliveryCreateParams( + recipient_id=recipient.id, + token_hash=sha256(token.encode()).hexdigest(), + auth_type=SubmissionAuthType.EMAIL_OTP, + target_snapshot=EmailTargetSnapshot(email_address="approver@example.com"), + ), + ) + redis = create_autospec(RedisClientWrapper, instance=True) + payload = {"type": "email", "email": "approver@example.com", "otp": "123456"} + if isinstance(subject, ContactRecipientSubject): + payload.update(type="external_contact", contact_id=subject.contact_id) + redis.get.return_value = json.dumps(payload).encode() + redis.get.side_effect = lambda key: b"0" if key.endswith(":failed_attempts") else redis.get.return_value + redis.ttl.return_value = 600 + redis.incr_if_exists.return_value = 1 + pipeline = Redis().pipeline() + monkeypatch.setattr(pipeline, "execute", lambda: []) + redis.pipeline.return_value = pipeline + limiter = create_autospec(RateLimiter, instance=True) + limiter.acquire.return_value = 0 + limiter.retry_after.return_value = 60 + sent_emails: list[tuple[str, str]] = [] + + def send(address: str, code: str) -> None: + result.send(address, code) + + service = EmailOtpService( + session_factory=sessions, + redis=redis, + limiter=limiter, + mail=OtpMail(available=lambda: True, send=send), + ) + result = Context(sessions, service, redis, limiter, delivery, form, token, sent_emails) + return result + + +def test_send_uses_frozen_email_and_six_digit_code_without_tokens(context: Context) -> None: + response = context.service.request_access(context.token) + address, code = context.sent_emails[-1] + assert address == "approver@example.com" + assert len(code) == 6 + assert code.isascii() + assert code.isdigit() + assert len(response.challenge_token) >= 43 + assert response.challenge_token not in repr(response) + assert response.expires_in_seconds == 600 + assert response.resend_after_seconds == 60 + + +@pytest.mark.parametrize( + ("status", "reason"), + [ + (VerificationStatus.INVALID, OtpFailure.INVALID), + (VerificationStatus.STALE, OtpFailure.STALE), + (VerificationStatus.EXPIRED, OtpFailure.CHALLENGE_EXPIRED), + ], +) +def test_rejected_proof_persists_failure_audit( + context: Context, status: VerificationStatus, reason: OtpFailure +) -> None: + if status == VerificationStatus.EXPIRED: + context.redis.get.return_value = None + elif status == VerificationStatus.STALE: + context.redis.incr_if_exists.return_value = 5 + with pytest.raises(EmailOtpError) as error: + context.service.verify( + context.token, challenge_token="challenge-secret", otp_code="000000", ip_address="127.0.0.1" + ) + assert error.value.reason == reason + with context.sessions() as session: + audit = session.scalar(sa.select(HumanInputAuditEvent)) + assert audit is not None + assert isinstance(audit.payload, OtpVerificationEvent) + assert audit.payload.succeeded is False + assert audit.payload.reason == reason.value + assert audit.payload.recipient_id == context.delivery.recipient_id + assert "challenge-secret" not in audit.payload.model_dump_json() + assert "000000" not in audit.payload.model_dump_json() + + +def test_missing_proof_is_audited_without_spending_guesses(context: Context) -> None: + with pytest.raises(EmailOtpError) as error: + context.service.verify(context.token, challenge_token=None, otp_code=None, ip_address=None) + assert error.value.reason == OtpFailure.INVALID + context.redis.incr_if_exists.assert_not_called() + with context.sessions() as session: + assert session.scalar(sa.select(sa.func.count()).select_from(HumanInputAuditEvent)) == 1 + + +def test_rate_limit_does_not_send_email(context: Context) -> None: + context.limiter.acquire.return_value = 32 + with pytest.raises(EmailOtpError) as error: + context.service.request_access(context.token) + assert error.value.reason == OtpFailure.RATE_LIMIT + assert error.value.retry_after == 32 + assert not context.sent_emails + + +def test_send_failure_removes_only_its_challenge(context: Context) -> None: + context.mail_failure = EmailOtpError(OtpFailure.DELIVERY) + with pytest.raises(EmailOtpError) as error: + context.service.request_access(context.token) + assert error.value.reason == OtpFailure.DELIVERY + assert error.value.retry_after == 60 + + +def test_storage_failure_does_not_send(context: Context, monkeypatch: pytest.MonkeyPatch) -> None: + from redis.exceptions import ConnectionError + + def fail_execute() -> None: + raise ConnectionError("secret-command") + + monkeypatch.setattr(context.redis.pipeline.return_value, "execute", fail_execute) + with pytest.raises(EmailOtpError) as error: + context.service.request_access(context.token) + assert error.value.reason == OtpFailure.UNAVAILABLE + assert "secret-command" not in str(error.value) + assert not context.sent_emails + + +@pytest.mark.parametrize("status", [HumanInputFormStatus.SUBMITTED, HumanInputFormStatus.EXPIRED]) +def test_terminal_form_cannot_issue_or_verify_proof(context: Context, status: HumanInputFormStatus) -> None: + with context.sessions.begin() as session: + session.execute(sa.update(HumanInputForm).values(status=status)) + with pytest.raises(EmailOtpError): + context.service.request_access(context.token) + with pytest.raises(EmailOtpError): + context.service.verify(context.token, challenge_token="proof", otp_code="123456", ip_address=None) + assert not context.sent_emails + context.redis.incr_if_exists.assert_not_called() + + +def test_wrong_auth_type_does_not_issue_otp(context: Context) -> None: + with context.sessions.begin() as session: + session.execute(sa.update(HumanInputDelivery).values(auth_type=SubmissionAuthType.CONSOLE)) + with pytest.raises(EmailOtpError) as error: + context.service.request_access(context.token) + assert error.value.reason == OtpFailure.NOT_FOUND + assert not context.sent_emails + + +def test_submission_and_success_audit_commit_or_rollback_together(context: Context) -> None: + verified = context.service.verify(context.token, challenge_token="proof", otp_code="123456", ip_address=None) + + def submit(session: Session) -> Form | FormSubmissionFailure: + return SQLAlchemyFormRepository(session, context.form.tenant_id, context.form.app_id).submit_form( + context.form.id, + submitted_by_recipient_id=context.delivery.recipient_id, + submitted_by_delivery_id=context.delivery.id, + selected_action_id="approve", + raw_submission_inputs={}, + normalized_submission_data={}, + ) + + with context.sessions() as session: + assert isinstance(submit(session), Form) + verified.add_submission_audit(session, ip_address=None) + session.flush() + session.rollback() + with context.sessions() as session: + assert session.scalar(sa.select(HumanInputForm.status)) == HumanInputFormStatus.WAITING + assert session.scalar(sa.select(sa.func.count()).select_from(HumanInputAuditEvent)) == 0 + # A new request can still verify after rollback; proof is not consumed. + verified = context.service.verify(context.token, challenge_token="proof", otp_code="123456", ip_address=None) + with context.sessions.begin() as session: + assert isinstance(submit(session), Form) + verified.add_submission_audit(session, ip_address=None) + context.service.cleanup(context.delivery.id, "proof") + with context.sessions() as session: + assert submit(session) == FormSubmissionFailure.ALREADY_SUBMITTED + audit = session.scalars(sa.select(HumanInputAuditEvent)).one() + assert isinstance(audit.payload, OtpVerificationEvent) + assert audit.payload.succeeded is True + + +def test_current_contact_email_change_invalidates_only_contact_proof(context: Context) -> None: + with context.sessions.begin() as session: + profile = session.scalar(sa.select(HumanInputExternalContactProfile)) + if profile is None: + # Direct email is independent of later directory membership. + session.add(Account(name="Same email", email="approver@example.com")) + external = False + else: + profile.email = "changed@example.com" + external = True + if external: + with pytest.raises(EmailOtpError) as error: + context.service.verify(context.token, challenge_token="proof", otp_code="123456", ip_address=None) + assert error.value.reason == OtpFailure.STALE + with context.sessions() as session: + audit = session.scalars(sa.select(HumanInputAuditEvent)).one() + assert isinstance(audit.payload, OtpVerificationEvent) + assert audit.payload.reason == OtpFailure.STALE.value + with pytest.raises(EmailOtpError) as error: + context.service.request_access(context.token) + assert error.value.reason == OtpFailure.NOT_FOUND + else: + verified = context.service.verify(context.token, challenge_token="proof", otp_code="123456", ip_address=None) + assert verified.subject.type == "email" + assert verified.subject.email == "approver@example.com" + + +def test_recreated_contact_with_same_email_does_not_restore_old_delivery(context: Context) -> None: + with context.sessions.begin() as session: + profile = session.scalar(sa.select(HumanInputExternalContactProfile)) + if profile is not None: + session.delete(profile) + session.execute(sa.delete(HumanInputContactIdentity)) + session.flush() + SQLAlchemyContactRepository(session).save_external_contact( + context.form.tenant_id, + ExternalContact( + id=ContactId(str(uuid4())), + name="Replacement", + email="approver@example.com", + avatar_file_id=None, + created_at=naive_utc_now(), + ), + ) + if profile is not None: + with pytest.raises(EmailOtpError) as error: + context.service.request_access(context.token) + assert error.value.reason == OtpFailure.NOT_FOUND + else: + context.service.request_access(context.token) + assert context.sent_emails[-1][0] == "approver@example.com" + + +def test_form_deadline_is_checked_independently_of_proof(context: Context) -> None: + with context.sessions.begin() as session: + session.execute(sa.update(HumanInputForm).values(expiration_time=naive_utc_now() - timedelta(seconds=1))) + with pytest.raises(EmailOtpError) as error: + context.service.verify(context.token, challenge_token="proof", otp_code="123456", ip_address=None) + assert error.value.reason == OtpFailure.EXPIRED + context.redis.incr_if_exists.assert_not_called() + + +def test_cleanup_failure_does_not_undo_submission_or_log_secrets( + context: Context, caplog: pytest.LogCaptureFixture +) -> None: + from redis.exceptions import ConnectionError + + context.redis.delete.side_effect = ConnectionError("proof-secret otp-secret") + context.service.cleanup(context.delivery.id, "proof-secret") + assert "cleanup failed" in caplog.text + assert "proof-secret" not in caplog.text + assert "otp-secret" not in caplog.text + + +def test_audit_failure_does_not_repeat_verification_or_restore_budget(context: Context) -> None: + from sqlalchemy.exc import OperationalError + + def fail_audit_write(session: Session, _flush_context: object, _instances: object) -> None: + if any(isinstance(record, HumanInputAuditEvent) for record in session.new): + raise OperationalError("INSERT", {}, OSError("audit storage unavailable")) + + event.listen(context.sessions, "before_flush", fail_audit_write) + try: + with pytest.raises(OperationalError): + context.service.verify(context.token, challenge_token="proof", otp_code="000000", ip_address=None) + finally: + event.remove(context.sessions, "before_flush", fail_audit_write) + context.redis.incr_if_exists.assert_called_once() + + +def test_field_validation_failure_does_not_write_success_audit(context: Context) -> None: + context.service.verify(context.token, challenge_token="proof", otp_code="123456", ip_address=None) + # The caller rejects its form input and therefore never accepts a submission. + with context.sessions() as session: + assert session.scalar(sa.select(sa.func.count()).select_from(HumanInputAuditEvent)) == 0 + context.service.verify(context.token, challenge_token="proof", otp_code="123456", ip_address=None) + + +def test_deployment_mail_content_and_provider_failure_redaction( + context: Context, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + monkeypatch.setattr("services.human_input_v2.email_otp_service.secrets.randbelow", lambda _bound: 1234) + from extensions.ext_mail import Mail + from services.human_input_v2 import email_otp_composition as composition + + mail = create_autospec(Mail, instance=True) + mail.is_inited.return_value = True + mail.send.side_effect = TimeoutError("provider echoed otp-secret") + monkeypatch.setattr(composition, "mail", mail) + monkeypatch.setattr(composition.session_factory, "create_session", context.sessions) + monkeypatch.setattr(composition, "redis_client", context.redis) + monkeypatch.setattr(composition, "RateLimiter", lambda **_kwargs: context.limiter) + with pytest.raises(EmailOtpError) as error: + composition.build_email_otp_service().request_access(context.token) + assert error.value.reason == OtpFailure.DELIVERY + sent = mail.send.call_args.kwargs + assert "001234" in sent["html"] + assert "otp-secret" not in caplog.text + assert "001234" not in caplog.text + assert context.token not in sent["html"] + + +def test_unconfigured_deployment_mail_does_not_spend_sending_budget(context: Context) -> None: + service = EmailOtpService( + session_factory=context.sessions, + redis=context.redis, + limiter=context.limiter, + mail=OtpMail(available=lambda: False, send=context.send), + ) + with pytest.raises(EmailOtpError) as error: + service.request_access(context.token) + assert error.value.reason == OtpFailure.UNAVAILABLE + context.limiter.acquire.assert_not_called() + assert not context.sent_emails + + +def test_two_verified_challenges_accept_one_submission_and_success_audit(context: Context) -> None: + from concurrent.futures import ThreadPoolExecutor + from threading import Barrier + + proofs = [ + context.service.verify(context.token, challenge_token=token, otp_code="123456", ip_address=None) + for token in ("first", "second") + ] + barrier = Barrier(2) + + def accept(verified: VerifiedEmail) -> Form | FormSubmissionFailure: + barrier.wait() + with context.sessions.begin() as session: + result = SQLAlchemyFormRepository(session, context.form.tenant_id, context.form.app_id).submit_form( + context.form.id, + submitted_by_recipient_id=context.delivery.recipient_id, + submitted_by_delivery_id=context.delivery.id, + selected_action_id="approve", + raw_submission_inputs={}, + normalized_submission_data={}, + ) + if isinstance(result, Form): + verified.add_submission_audit(session, ip_address=None) + return result + + with ThreadPoolExecutor(max_workers=2) as executor: + results = list(executor.map(accept, proofs)) + assert sum(isinstance(result, Form) for result in results) == 1 + assert results.count(FormSubmissionFailure.ALREADY_SUBMITTED) == 1 + with context.sessions() as session: + assert session.scalar(sa.select(sa.func.count()).select_from(HumanInputAuditEvent)) == 1 + + +def test_mail_is_sent_after_authorization_session_closes(context: Context) -> None: + opened = [] + + def sessions() -> Session: + session = context.sessions() + opened.append(session) + return session + + def send(_email: str, _code: str) -> None: + assert opened + assert all(not session.in_transaction() for session in opened) + + service = EmailOtpService( + session_factory=sessions, + redis=context.redis, + limiter=context.limiter, + mail=OtpMail(available=lambda: True, send=send), + ) + service.request_access(context.token) + + +@pytest.mark.parametrize("owner", ["delivery", "app"]) +def test_cross_tenant_owner_chain_is_rejected(context: Context, owner: str) -> None: + with context.sessions.begin() as session: + if owner == "delivery": + session.execute(sa.update(HumanInputDelivery).values(tenant_id=str(uuid4()))) + else: + session.execute(sa.update(App).values(tenant_id=str(uuid4()))) + with pytest.raises(EmailOtpError) as error: + context.service.request_access(context.token) + assert error.value.reason == OtpFailure.NOT_FOUND + assert not context.sent_emails + + +def test_no_access_token_is_returned_if_challenge_expires_during_send(context: Context) -> None: + context.redis.ttl.return_value = 0 + with pytest.raises(EmailOtpError) as error: + context.service.request_access(context.token) + assert error.value.reason == OtpFailure.CHALLENGE_EXPIRED + + +def test_redis_verification_outage_is_audited_without_logging_its_command( + context: Context, caplog: pytest.LogCaptureFixture +) -> None: + from redis.exceptions import ConnectionError + + context.redis.get.side_effect = ConnectionError("secret-command otp-secret") + with pytest.raises(EmailOtpError) as error: + context.service.verify(context.token, challenge_token="proof-secret", otp_code="001234", ip_address=None) + assert error.value.reason == OtpFailure.UNAVAILABLE + with context.sessions() as session: + audit = session.scalar(sa.select(HumanInputAuditEvent)) + assert audit is not None + assert audit is not None + assert isinstance(audit.payload, OtpVerificationEvent) + assert audit.payload.succeeded is False + assert audit.payload.reason == OtpFailure.UNAVAILABLE.value + assert "secret-command" not in caplog.text + assert "proof-secret" not in caplog.text + assert "001234" not in caplog.text + + +def test_deleted_tenant_email_channel_does_not_block_deployment_otp(context: Context) -> None: + from core.human_input_v2.entities import EmailProviderType + from models.human_input_v2 import HumanInputEmailProvider + from repositories.human_input_v2.channel_credentials import ChannelEncryptedCredentials + + with context.sessions.begin() as session: + channel = HumanInputEmailProvider( + tenant_id=context.form.tenant_id, + provider=EmailProviderType.RESEND, + sender_email="old-sender@example.com", + encrypted_credentials=ChannelEncryptedCredentials(ciphertext="unused-ciphertext"), + ) + session.add(channel) + session.flush() + session.delete(channel) + response = context.service.request_access(context.token) + assert response.expires_in_seconds == 600 + assert context.sent_emails[-1][0] == "approver@example.com" + + +def test_binding_mismatch_does_not_consume_guesses(context: Context) -> None: + payload = json.loads(context.redis.get.return_value) + payload["email"] = "another@example.com" + context.redis.get.return_value = json.dumps(payload) + with pytest.raises(EmailOtpError) as error: + context.service.verify(context.token, challenge_token="proof", otp_code="123456", ip_address=None) + assert error.value.reason == OtpFailure.STALE + context.redis.incr_if_exists.assert_not_called() + + +def test_challenge_removed_before_counter_update_is_expired(context: Context) -> None: + context.redis.incr_if_exists.return_value = None + with pytest.raises(EmailOtpError) as error: + context.service.verify(context.token, challenge_token="proof", otp_code="000000", ip_address=None) + assert error.value.reason == OtpFailure.CHALLENGE_EXPIRED + + +def test_corrupt_challenge_is_unavailable_without_disclosing_payload(context: Context) -> None: + context.redis.get.return_value = b'{"type":"invalid-secret-type","otp":"otp-secret"}' + with pytest.raises(EmailOtpError) as error: + context.service.verify(context.token, challenge_token="proof", otp_code="123456", ip_address=None) + assert error.value.reason == OtpFailure.UNAVAILABLE + assert "otp-secret" not in str(error.value) + assert error.value.__context__ is None + context.redis.incr_if_exists.assert_not_called() + + +@pytest.mark.parametrize("counter", [None, b"5", b"6"]) +def test_missing_or_exhausted_counter_rejects_correct_otp(context: Context, counter: bytes | None) -> None: + context.redis.get.side_effect = lambda key: ( + counter if key.endswith(":failed_attempts") else context.redis.get.return_value + ) + with pytest.raises(EmailOtpError) as error: + context.service.verify(context.token, challenge_token="proof", otp_code="123456", ip_address=None) + assert error.value.reason == OtpFailure.CHALLENGE_EXPIRED + context.redis.incr_if_exists.assert_not_called() + + +@pytest.mark.parametrize("ip_address", ["192.0.2.12", None]) +def test_successful_send_persists_otp_send_audit(context: Context, ip_address: str | None) -> None: + response = context.service.request_access(context.token, ip_address=ip_address) + with context.sessions() as session: + audit = session.scalars(sa.select(HumanInputAuditEvent)).one() + assert audit.tenant_id == context.form.tenant_id + assert audit.form_id == context.form.id + assert audit.created_at is not None + assert audit.payload == OtpSendEvent( + recipient_id=context.delivery.recipient_id, + recipient_email="approver@example.com", + sender_ip_address=ip_address, + ) + stored = audit.payload.model_dump_json() + assert context.token not in stored + assert response.challenge_token not in stored + assert context.sent_emails + assert context.sent_emails[-1][1] not in stored + + +@pytest.mark.parametrize("failure", ["rate_limit", "unavailable", "storage", "send"]) +def test_unsent_otp_does_not_record_a_send(context: Context, failure: str, monkeypatch: pytest.MonkeyPatch) -> None: + from redis.exceptions import RedisError + + service = context.service + match failure: + case "rate_limit": + context.limiter.acquire.return_value = 30 + case "unavailable": + service = EmailOtpService( + session_factory=context.sessions, + redis=context.redis, + limiter=context.limiter, + mail=OtpMail(available=lambda: False, send=context.send), + ) + case "storage": + + def fail_execute() -> None: + raise RedisError() + + monkeypatch.setattr(context.redis.pipeline.return_value, "execute", fail_execute) + case "send": + context.mail_failure = EmailOtpError(OtpFailure.DELIVERY) + with pytest.raises(EmailOtpError): + service.request_access(context.token) + with context.sessions() as session: + assert session.scalar(sa.select(sa.func.count()).select_from(HumanInputAuditEvent)) == 0 + + +def test_sent_otp_is_audited_even_when_challenge_expires_during_send(context: Context) -> None: + context.redis.ttl.return_value = 0 + with pytest.raises(EmailOtpError) as error: + context.service.request_access(context.token) + assert error.value.reason == OtpFailure.CHALLENGE_EXPIRED + with context.sessions() as session: + audit = session.scalars(sa.select(HumanInputAuditEvent)).one() + assert isinstance(audit.payload, OtpSendEvent) diff --git a/api/tests/unit_tests/services/human_input_v2/test_file_upload_service.py b/api/tests/unit_tests/services/human_input_v2/test_file_upload_service.py new file mode 100644 index 00000000000..de7083cc67c --- /dev/null +++ b/api/tests/unit_tests/services/human_input_v2/test_file_upload_service.py @@ -0,0 +1,327 @@ +"""V2 upload admission, tenant/creator isolation, and persisted file restoration.""" + +import hashlib +import io +from dataclasses import dataclass +from datetime import timedelta +from unittest.mock import create_autospec +from uuid import uuid4 + +import httpx +import jwt +import pytest +import sqlalchemy as sa +from flask import Flask +from sqlalchemy.orm import Session, sessionmaker + +from configs import dify_config +from controllers.web import human_input_form_access_request as token_controller +from controllers.web import human_input_v2_file_upload as controller +from core.human_input import ButtonStyle +from core.human_input_v2.resolved_form import FileInput, ResolvedForm, UserAction +from core.human_input_v2.shared.values import AccountId, NormalizedEmail, RecipientId, TenantId +from core.workflow.nodes.human_input_v2.entities import OnetimeEmail +from extensions.ext_storage import storage +from graphon.enums import WorkflowExecutionStatus +from graphon.file.enums import FileTransferMethod, FileType +from graphon.variables.segments import FileSegment +from libs.datetime_utils import naive_utc_now +from libs.external_api import ExternalApi +from models.account import TenantAccountJoin, TenantAccountRole +from models.enums import CreatorUserRole, WorkflowRunTriggeredFrom +from models.human_input_v2 import HumanInputAuditEvent, HumanInputDelivery, HumanInputForm +from models.model import UploadFile +from models.workflow import WorkflowPause, WorkflowRun, WorkflowType +from repositories.human_input_v2.delivery_repository import ( + DeliveryCreateParams, + EmailTargetSnapshot, + SubmissionAuthType, +) +from repositories.human_input_v2.form_repository import FormCreateParams +from repositories.human_input_v2.recipient_repository import ( + EmailRecipientSubject, + RecipientCreateParams, +) +from repositories.human_input_v2.sqlalchemy_delivery_repository import SQLAlchemyDeliveryRepository +from repositories.human_input_v2.sqlalchemy_form_repository import SQLAlchemyFormRepository +from repositories.human_input_v2.sqlalchemy_recipient_repository import SQLAlchemyRecipientRepository +from services.file_service import FileService +from services.human_input_file_upload_service import HumanInputUploadToken, InvalidUploadTokenError +from services.human_input_v2.file_upload_service import V2_UPLOAD_TOKEN_PREFIX, HumanInputV2FileUploadService +from services.human_input_v2.submission_service import ( + HumanInputFormError, + HumanInputFormErrorCode, + HumanInputSubmissionService, +) +from services.remote_file_service import RemoteFileService +from tests.helpers.unit.model_factories import make_account, make_app, make_end_user, make_tenant + + +@dataclass +class Approval: + sessions: sessionmaker[Session] + service: HumanInputV2FileUploadService + tenant_id: TenantId + app_id: str + account_id: AccountId + end_user_id: str + form_id: str + delivery_id: str + token: str + owner_role: CreatorUserRole + + def issue(self) -> HumanInputUploadToken: + return self.service.issue_public_upload_token(self.token) + + +@pytest.fixture(params=[CreatorUserRole.ACCOUNT, CreatorUserRole.END_USER]) +def approval( + sqlite_session_factory: sessionmaker[Session], monkeypatch: pytest.MonkeyPatch, request: pytest.FixtureRequest +) -> Approval: + monkeypatch.setattr(dify_config, "SECRET_KEY", "v2-upload-test-key-long-enough-for-hs256") + monkeypatch.setattr(storage, "save", lambda *_args: None) + tenant_id, app_id, account_id, end_user_id, run_id = [str(uuid4()) for _ in range(5)] + owner_role = request.param + assert isinstance(owner_role, CreatorUserRole) + now = naive_utc_now() + with sqlite_session_factory.begin() as session: + session.add_all( + [ + make_tenant(tenant_id=tenant_id, name="Uploads"), + make_account(account_id=account_id, name="Initiator", email="initiator@example.com"), + TenantAccountJoin(tenant_id=tenant_id, account_id=account_id, role=TenantAccountRole.NORMAL), + make_app(app_id=app_id, tenant_id=tenant_id, name="Approval", created_by=account_id), + make_end_user(end_user_id=end_user_id, tenant_id=tenant_id, app_id=app_id, session_id="original"), + WorkflowRun( + id=run_id, + tenant_id=tenant_id, + app_id=app_id, + workflow_id=str(uuid4()), + type=WorkflowType.WORKFLOW, + triggered_from=WorkflowRunTriggeredFrom.APP_RUN, + version="1", + status=WorkflowExecutionStatus.PAUSED, + created_by_role=owner_role, + created_by=end_user_id if owner_role == CreatorUserRole.END_USER else account_id, + ), + ] + ) + session.flush() + form = SQLAlchemyFormRepository(session, TenantId(tenant_id), app_id).create_form( + FormCreateParams( + workflow_run_id=run_id, + node_execution_id=str(uuid4()), + expiration_time=now + timedelta(minutes=10), + global_timeout_deadline=now + timedelta(minutes=5), + resolved_form=ResolvedForm( + title="Upload", + legacy_form_content="{{#$output.file#}}", + parts=( + FileInput( + output_variable_name="file", + allowed_file_types=(FileType.DOCUMENT,), + allowed_file_extensions=(".txt",), + allowed_file_upload_methods=(FileTransferMethod.LOCAL_FILE, FileTransferMethod.REMOTE_URL), + ), + ), + actions=(UserAction(id="approve", title="Approve", button_style=ButtonStyle.DEFAULT),), + ), + ) + ) + subject = EmailRecipientSubject(NormalizedEmail("approver@example.com")) + auth_type = SubmissionAuthType.EMAIL_OTP + target = EmailTargetSnapshot(email_address="approver@example.com") + recipient = SQLAlchemyRecipientRepository(session, TenantId(tenant_id), form.id).create_recipients( + [ + RecipientCreateParams( + subject=subject, + sources=(OnetimeEmail(email="approver@example.com"),), + name="Approver", + ) + ] + )[0] + token = "v2-delivery-" + str(uuid4()) + delivery = SQLAlchemyDeliveryRepository(session).create_delivery( + tenant_id=TenantId(tenant_id), + form_id=form.id, + params=DeliveryCreateParams( + recipient_id=recipient.id, + auth_type=auth_type, + target_snapshot=target, + token_hash=hashlib.sha256(token.encode()).hexdigest(), + ), + ) + files = FileService(sqlite_session_factory) + service = HumanInputV2FileUploadService( + session_factory=sqlite_session_factory, files=files, remote_files=RemoteFileService(files=files) + ) + monkeypatch.setattr(controller, "build_file_upload_service", lambda: service) + monkeypatch.setattr(token_controller, "build_file_upload_service", lambda: service) + return Approval( + sqlite_session_factory, + service, + TenantId(tenant_id), + app_id, + AccountId(account_id), + end_user_id, + form.id, + delivery.id, + token, + owner_role, + ) + + +def test_uploads_local_and_remote_files_as_the_original_initiator( + approval: Approval, monkeypatch: pytest.MonkeyPatch +) -> None: + grant = approval.issue() + with approval.sessions() as session: + form = session.get_one(HumanInputForm, approval.form_id) + assert grant.expires_at == form.global_timeout_deadline + app = Flask(__name__) + ExternalApi(app).add_namespace(controller.web_ns) + client = app.test_client() + headers = {"Authorization": "Bearer " + grant.upload_token} + local = client.post("/human-input/v2/files", headers=headers, data={"file": (io.BytesIO(b"review"), "review.txt")}) + assert local.status_code == 201, local.json + assert local.json is not None + assert local.json["name"] == "review.txt" + assert local.json["url"] + + def fetch(method: str, url: str, **_kwargs: object) -> httpx.Response: + return httpx.Response( + 200, + headers={"Content-Type": "text/plain", "Content-Length": "6"}, + content=b"review" if method == "GET" else b"", + request=httpx.Request(method, url), + ) + + monkeypatch.setattr("core.file.remote_fetcher.make_request", fetch) + remote = client.post( + "/human-input/v2/files/remote", headers=headers, json={"url": "https://example.com/remote.txt"} + ) + assert remote.status_code == 201, remote.json + assert remote.json is not None + with approval.sessions() as session: + files = list(session.scalars(sa.select(UploadFile))) + assert {file.id for file in files} == {local.json["id"], remote.json["id"]} + for file in files: + assert file.tenant_id == approval.tenant_id + assert file.created_by == ( + approval.end_user_id if approval.owner_role == CreatorUserRole.END_USER else approval.account_id + ) + assert file.created_by_role == ( + CreatorUserRole.END_USER if approval.owner_role == CreatorUserRole.END_USER else CreatorUserRole.ACCOUNT + ) + + +@pytest.mark.parametrize("change", ["submitted", "node_deadline", "global_deadline", "recipient_revoked"]) +def test_rechecks_form_and_delivery_before_using_a_grant(approval: Approval, change: str) -> None: + grant = approval.issue() + with approval.sessions.begin() as session: + form = session.get_one(HumanInputForm, approval.form_id) + if change == "submitted": + delivery = session.get_one(HumanInputDelivery, approval.delivery_id) + SQLAlchemyFormRepository(session, approval.tenant_id, approval.app_id).submit_form( + form.id, + submitted_by_recipient_id=RecipientId(delivery.recipient_id), + submitted_by_delivery_id=delivery.id, + selected_action_id="approve", + raw_submission_inputs={}, + normalized_submission_data={}, + ) + elif change == "node_deadline": + form.expiration_time = naive_utc_now() - timedelta(seconds=1) + elif change == "global_deadline": + form.global_timeout_deadline = naive_utc_now() - timedelta(seconds=1) + else: + delivery = session.get_one(HumanInputDelivery, approval.delivery_id) + session.delete(delivery) + with pytest.raises(HumanInputFormError) as caught: + approval.service.validate_upload_token(grant.upload_token) + assert ( + caught.value.code + == { + "submitted": HumanInputFormErrorCode.ALREADY_SUBMITTED, + "node_deadline": HumanInputFormErrorCode.EXPIRED, + "global_deadline": HumanInputFormErrorCode.EXPIRED, + "recipient_revoked": HumanInputFormErrorCode.NOT_FOUND, + }[change] + ) + + +def test_rejects_tampered_expired_or_legacy_grants(approval: Approval) -> None: + grant = approval.issue() + claims = jwt.decode(grant.upload_token.removeprefix(V2_UPLOAD_TOKEN_PREFIX), options={"verify_signature": False}) + claims["exp"] = 1 + expired = V2_UPLOAD_TOKEN_PREFIX + jwt.encode(claims, dify_config.SECRET_KEY, algorithm="HS256") + claims["exp"] = 1900000000 + forged = V2_UPLOAD_TOKEN_PREFIX + jwt.encode(claims, "a-different-signing-key-with-32-bytes", algorithm="HS256") + for token in [expired, forged, "hitl_upload_legacy", "hitl_v2_upload_malformed"]: + with pytest.raises(InvalidUploadTokenError): + approval.service.validate_upload_token(token) + + +@pytest.mark.parametrize("auth_type", [SubmissionAuthType.CONSOLE, SubmissionAuthType.WEB_APP, SubmissionAuthType.IM]) +def test_public_upload_rejects_other_delivery_authentication(approval: Approval, auth_type: SubmissionAuthType) -> None: + with approval.sessions.begin() as session: + delivery = session.get_one(HumanInputDelivery, approval.delivery_id) + assert delivery is not None + delivery.auth_type = auth_type + with pytest.raises(HumanInputFormError) as caught: + approval.issue() + assert caught.value.code == HumanInputFormErrorCode.NOT_FOUND + + +def test_public_uploaded_file_can_be_submitted_and_restored( + approval: Approval, monkeypatch: pytest.MonkeyPatch +) -> None: + grant = approval.issue() + context = approval.service.validate_upload_token(grant.upload_token) + uploaded = approval.service.upload_local_file( + context=context, filename="review.txt", content=b"review", mimetype="text/plain" + ) + with approval.sessions.begin() as session: + form = session.get_one(HumanInputForm, approval.form_id) + run = session.get_one(WorkflowRun, form.workflow_run_id) + session.add(WorkflowPause(workflow_id=run.workflow_id, workflow_run_id=run.id, state_object_key="checkpoint")) + monkeypatch.setattr( + "tasks.app_generate.workflow_execute_task.resume_app_execution.apply_async", lambda **_kwargs: None + ) + service = HumanInputSubmissionService(session_factory=approval.sessions) + from services.human_input_v2.email_otp_service import EmailOtpService, EmailSubject, VerifiedEmail + + with approval.sessions() as session: + delivery = SQLAlchemyDeliveryRepository(session).get_delivery_by_token_hash( + hashlib.sha256(approval.token.encode()).hexdigest() + ) + assert delivery is not None + email_otp = create_autospec(EmailOtpService, instance=True) + email_otp.verify.return_value = VerifiedEmail( + delivery, EmailSubject(email="approver@example.com"), approval.app_id, "verified-challenge" + ) + service.submit_public_form( + email_otp_service=email_otp, + challenge_token="verified-challenge", + otp_code="123456", + ip_address=None, + form_token=approval.token, + action="approve", + inputs={"file": {"type": "document", "transfer_method": "local_file", "upload_file_id": uploaded.id}}, + ) + with approval.sessions() as session: + restored = SQLAlchemyFormRepository(session, approval.tenant_id, approval.app_id).get_form_by_id( + approval.form_id + ) + assert restored is not None + assert restored.submission is not None + segment = restored.submission.normalized_submission_data["file"] + assert isinstance(segment, FileSegment) + assert segment.value.related_id == uploaded.id + + +def test_upload_authorization_does_not_record_a_form_view(approval: Approval) -> None: + grant = approval.issue() + approval.service.validate_upload_token(grant.upload_token) + with approval.sessions() as session: + assert session.scalar(sa.select(sa.func.count()).select_from(HumanInputAuditEvent)) == 0 diff --git a/api/tests/unit_tests/services/human_input_v2/test_form_delivery_runtime.py b/api/tests/unit_tests/services/human_input_v2/test_form_delivery_runtime.py new file mode 100644 index 00000000000..9541eaa6de1 --- /dev/null +++ b/api/tests/unit_tests/services/human_input_v2/test_form_delivery_runtime.py @@ -0,0 +1,4199 @@ +from __future__ import annotations + +from collections.abc import Callable +from concurrent.futures import ThreadPoolExecutor +from dataclasses import dataclass, field, replace +from datetime import timedelta +from sqlite3 import Connection as SQLiteConnection +from typing import Protocol +from unittest.mock import Mock, create_autospec +from uuid import uuid4 + +import pytest +import sqlalchemy as sa +from celery import Task +from flask.testing import FlaskClient +from pydantic import JsonValue +from sqlalchemy import event +from sqlalchemy.engine import Connection +from sqlalchemy.exc import IntegrityError, SQLAlchemyError +from sqlalchemy.orm import Session, sessionmaker +from sqlalchemy.pool import ConnectionPoolEntry +from werkzeug.test import TestResponse + +from core.human_input_v2.entities import EmailProviderType, IMProvider +from core.human_input_v2.im_integration.adapters.entities import MessageAccepted, StaticCardIntent +from core.human_input_v2.im_integration.adapters.message_locator import MessageLocator +from core.human_input_v2.shared.values import AccountId, ContactId, NormalizedEmail, RecipientId, TenantId +from core.workflow.nodes.human_input.entities import HumanInputNodeDataFull +from core.workflow.nodes.human_input.enums import HumanInputFormStatus +from core.workflow.nodes.human_input_v2.entities import ( + Contact, + DynamicEmail, + HumanInputNodeData, + Initiator, + OnetimeEmail, +) +from core.workflow.nodes.human_input_v2.migration import MemberEmailSnapshot, convert_legacy_human_input_node_data +from core.workflow.nodes.human_input_v2.runtime import HumanInputDeliveryError, PreparedForm +from enums import DeploymentEdition +from graphon.enums import WorkflowExecutionStatus +from graphon.runtime import VariablePool +from libs.datetime_utils import naive_utc_now +from models import CreatorUserRole +from models.account import Account, AccountStatus, Tenant, TenantAccountJoin, TenantAccountRole +from models.enums import EndUserType, WorkflowRunTriggeredFrom +from models.human_input_v2 import ( + ContactSubjectType, + FormViewEvent, + HumanInputAuditEvent, + HumanInputContactIdentity, + HumanInputDelivery, + HumanInputDeliveryAttempt, + HumanInputEmailProvider, + HumanInputExternalContactProfile, + HumanInputForm, + HumanInputIMBinding, + HumanInputIMBindingWorkspaceOverride, + HumanInputIMChannel, + HumanInputIMIdentity, + HumanInputPlatformContactWorkspaceEntry, + HumanInputRecipient, + OtpVerificationEvent, + RecipientSubjectType, +) +from models.model import EndUser +from models.workflow import WorkflowPause, WorkflowPauseReason, WorkflowRun, WorkflowType +from repositories.human_input_v2.delivery_repository import IMUserTargetSnapshot, SubmissionAuthType +from repositories.human_input_v2.email_channel.entities import ResendCandidate, ResendCredentials +from repositories.human_input_v2.form_repository import Form +from repositories.human_input_v2.recipient_repository import ContactRecipientSubject, EmailRecipientSubject +from repositories.human_input_v2.sqlalchemy_form_repository import SQLAlchemyFormRepository +from repositories.sqlalchemy_api_workflow_node_execution_repository import ( + DifyAPISQLAlchemyWorkflowNodeExecutionRepository, +) +from services.human_input_v2.email_otp_service import AccessChallenge +from services.human_input_v2.form_service import ( + AccountInitiator, + CreatedForm, + FormExecutionContext, + HumanInputFormService, +) + +_TENANT = TenantId("00000000-0000-0000-0000-000000000001") +_APP = "00000000-0000-0000-0000-000000000002" +_RUN = "00000000-0000-0000-0000-000000000003" +_ACCOUNT = AccountId("00000000-0000-0000-0000-000000000004") +_CONTACT = ContactId("00000000-0000-0000-0000-000000000005") +_EXECUTION = "00000000-0000-0000-0000-000000000006" + + +CreatedIMCards = tuple[str, str, set[str], Mock, list[dict[str, str]], list[dict[str, object]]] + + +@dataclass(frozen=True) +class SentEmail: + candidate: ResendCandidate + to: str + subject: str + html: str + + +class EmailAcceptance(Protocol): + def __call__(self, candidate: ResendCandidate, *, to: str, subject: str, html: str) -> None: ... + + +@dataclass +class EmailOutbox: + on_send: EmailAcceptance + messages: list[SentEmail] = field(default_factory=list) + failure: Exception | None = None + + def send_message(self, candidate: ResendCandidate, *, to: str, subject: str, html: str) -> None: + self.messages.append(SentEmail(candidate, to, subject, html)) + if self.failure is not None: + raise self.failure + self.on_send(candidate, to=to, subject=subject, html=html) + + +def _pause_state(form_id: str, *, node_version: str = "2") -> str: + from core.app.app_config.entities import WorkflowUIBasedAppConfig + from core.app.entities.app_invoke_entities import InvokeFrom, UserFrom, WorkflowAppGenerateEntity + from core.app.layers.pause_state_persist_layer import WorkflowResumptionContext, _WorkflowGenerateEntityWrapper + from core.workflow.nodes.human_input.session_binding import default_session_binding + from graphon.entities.pause_reason import HitlRequired + from graphon.runtime import RuntimeState + from models.model import AppMode + + runtime = RuntimeState(workflow_id="workflow", variable_pool=VariablePool(), start_at=0) + runtime.graph_execution.pause( + HitlRequired( + session_id=default_session_binding.issue_session_id_for_form(node_version, form_id), + node_id="approval", + node_title="Frozen", + ) + ) + return WorkflowResumptionContext( + generate_entity=_WorkflowGenerateEntityWrapper( + entity=WorkflowAppGenerateEntity( + task_id="task-1", + inputs={}, + files=[], + user_id=str(_ACCOUNT), + user_from=UserFrom.ACCOUNT, + stream=True, + invoke_from=InvokeFrom.DEBUGGER, + workflow_execution_id=_RUN, + app_config=WorkflowUIBasedAppConfig( + tenant_id=str(_TENANT), + app_id=_APP, + workflow_id="workflow", + app_mode=AppMode.WORKFLOW, + ), + ) + ), + serialized_graph_runtime_state=runtime.dumps(), + ).dumps() + + +@dataclass +class Context: + sessions: sessionmaker[Session] + service: HumanInputFormService + execution: FormExecutionContext + + def prepare(self, data: HumanInputNodeData, pool: VariablePool | None = None) -> CreatedForm | PreparedForm: + return self.service.prepare_form( + node_execution_id=_EXECUTION, node_data=data, variable_pool=pool or VariablePool() + ) + + def handle_timeouts(self) -> None: + from services.human_input_v2.form_service import handle_form_timeouts + + handle_form_timeouts(session_factory=self.sessions, tenant_id=_TENANT, app_id=_APP, workflow_run_id=_RUN) + + +def _node(*recipients: Contact | DynamicEmail | Initiator | OnetimeEmail) -> HumanInputNodeData: + return HumanInputNodeData.model_validate( + { + "recipients_spec": list(recipients), + "message_template": {"subject": "Review", "body": "Open {{#url#}}"}, + "debug_mode": {"enabled": False, "channels": []}, + "title": "Frozen", + "form_content": "Please approve", + "user_actions": [{"id": "approve", "title": "Approve"}], + } + ) + + +@pytest.mark.parametrize("with_empty_disabled_email", [False, True]) +def test_migrated_webapp_node_creates_waiting_form_with_initiator( + context: Context, with_empty_disabled_email: bool +) -> None: + legacy = HumanInputNodeDataFull.model_validate( + { + "title": "Migrated approval", + "delivery_methods": [{"type": "webapp"}] + + ( + [ + { + "type": "email", + "enabled": False, + "config": {"recipients": {"items": []}, "subject": "", "body": ""}, + } + ] + if with_empty_disabled_email + else [] + ), + "form_content": "Please approve", + "user_actions": [{"id": "approve", "title": "Approve"}], + } + ) + conversion = convert_legacy_human_input_node_data(legacy, MemberEmailSnapshot()) + assert conversion.blockers == () + assert conversion.node_data is not None + assert conversion.node_data.recipients_spec == [Initiator()] + + created = context.prepare(conversion.node_data) + + assert isinstance(created, CreatedForm) + assert created.form.status == HumanInputFormStatus.WAITING + assert created.form.resolved_form.legacy_form_content == "Please approve" + assert created.message_template.subject == "Action required" + assert created.message_template.body == ( + "A request is waiting for your review. Please review the details and submit your decision to continue. " + "Review and respond: {{#url#}}" + ) + assert len(created.recipients) == 1 + assert created.recipients[0].subject == ContactRecipientSubject(_CONTACT) + + +@pytest.mark.usefixtures("providers") +@pytest.mark.parametrize("selected", [None, False, True]) +def test_factory_runs_v2_and_reuses_persisted_approval( + context: Context, monkeypatch: pytest.MonkeyPatch, selected: bool | None +) -> None: + from core.app.entities.app_invoke_entities import InvokeFrom, UserFrom, build_dify_run_context + from core.db.session_factory import session_factory + from core.workflow.node_factory import DifyNodeFactory + from core.workflow.nodes.human_input_v2.events import NodeRunHumanInputV2FormRequiredEvent + from core.workflow.system_variables import SystemVariableKey, system_variable_selector + from graphon.engine_events import NodeRunPauseRequestedEvent + from graphon.runtime import InitParams, RuntimeState + + monkeypatch.setattr(session_factory, "create_session", context.sessions) + pool = VariablePool() + pool.add(system_variable_selector(SystemVariableKey.WORKFLOW_EXECUTION_ID), _RUN) + factory = DifyNodeFactory( + InitParams( + workflow_id="workflow", + graph_config={"nodes": [], "edges": []}, + run_context=build_dify_run_context( + tenant_id=_TENANT, + app_id=_APP, + user_id=_ACCOUNT, + user_from=UserFrom.ACCOUNT, + invoke_from=InvokeFrom.DEBUGGER, + ), + call_depth=0, + ), + RuntimeState(workflow_id="workflow", variable_pool=pool, start_at=0), + ) + node_data = _node(Initiator()).model_dump(mode="json") + if selected is not None: + node_data["selected"] = selected + node = factory.create_node({"id": "approval", "data": node_data}) + node.bind_execution_id(_EXECUTION) + first_events = list(node.run()) + second_events = list(node.run()) + first = [event for event in first_events if isinstance(event, NodeRunPauseRequestedEvent)] + second = [event for event in second_events if isinstance(event, NodeRunPauseRequestedEvent)] + assert len(first) == len(second) == 1 + assert first[0].reason == second[0].reason + with context.sessions() as session: + assert session.scalar(sa.select(sa.func.count()).select_from(HumanInputForm)) == 1 + assert session.scalar(sa.select(sa.func.count()).select_from(HumanInputDelivery)) == 2 + required = next(event for event in first_events if isinstance(event, NodeRunHumanInputV2FormRequiredEvent)) + revisited = next(event for event in second_events if isinstance(event, NodeRunHumanInputV2FormRequiredEvent)) + assert required.prepared_form.form.resolved_form.legacy_form_content == "Please approve" + from graphon.entities.pause_reason import HitlRequired + + assert isinstance(first[0].reason, HitlRequired) + assert first[0].reason.session_id == f"hitlv2:{required.prepared_form.form.id}" + assert required.prepared_form.form_token == revisited.prepared_form.form_token + + +@pytest.fixture +def context(sqlite_engine: sa.Engine, monkeypatch: pytest.MonkeyPatch) -> Context: + from tasks.human_input_im_card_tasks import update_human_input_im_card_task + + monkeypatch.setattr(update_human_input_im_card_task, "apply_async", lambda **_kwargs: None) + engine = sqlite_engine + + @event.listens_for(engine, "connect") + def setup_connection(connection: SQLiteConnection, _record: ConnectionPoolEntry) -> None: + connection.isolation_level = None + + @event.listens_for(engine, "begin") + def begin_transaction(connection: Connection) -> None: + # SQLite cannot upgrade competing read transactions to writers. Reserve + # its writer at begin so concurrent calls can wait for serialized commits. + connection.exec_driver_sql("BEGIN IMMEDIATE") + + sessions = sessionmaker(engine, expire_on_commit=False) + monkeypatch.setattr("core.db.session_factory.session_factory.get_session_maker", lambda: sessions) + with sessions.begin() as session: + account = Account(name="Initiator", email="member@example.com", status=AccountStatus.ACTIVE) + account.id = _ACCOUNT + tenant = Tenant(name="Workspace") + tenant.id = _TENANT + identity = HumanInputContactIdentity(subject_type=ContactSubjectType.ACCOUNT, account_id=_ACCOUNT) + identity.id = _CONTACT + session.add_all( + [ + account, + tenant, + identity, + TenantAccountJoin(tenant_id=_TENANT, account_id=_ACCOUNT, role=TenantAccountRole.NORMAL), + WorkflowRun( + id=_RUN, + tenant_id=_TENANT, + app_id=_APP, + workflow_id=str(uuid4()), + type=WorkflowType.WORKFLOW, + triggered_from=WorkflowRunTriggeredFrom.APP_RUN, + version="1", + status=WorkflowExecutionStatus.RUNNING, + created_by_role=CreatorUserRole.ACCOUNT, + created_by=_ACCOUNT, + ), + ] + ) + execution = FormExecutionContext( + tenant_id=_TENANT, + app_id=_APP, + workflow_run_id=_RUN, + global_timeout_deadline=naive_utc_now() + timedelta(days=3), + initiator=AccountInitiator(_ACCOUNT), + debugging_account_id=None, + ) + return Context(sessions, HumanInputFormService(session_factory=sessions, context=execution), execution) + + +@pytest.fixture +def timeout_publications(context: Context, monkeypatch: pytest.MonkeyPatch) -> list[dict[str, str]]: + from tasks import human_input_timeout_tasks + + with context.sessions() as session: + engine = session.get_bind() + monkeypatch.setattr(type(human_input_timeout_tasks.db), "engine", property(lambda _: engine)) + monkeypatch.setattr("core.db.session_factory.session_factory.create_session", context.sessions) + publications: list[dict[str, str]] = [] + + def publish(*, kwargs: dict[str, str]) -> None: + publications.append(kwargs) + + monkeypatch.setattr(human_input_timeout_tasks.handle_human_input_v2_timeout, "apply_async", publish) + return publications + + +@pytest.fixture +def dispatch_timeouts(timeout_publications: list[dict[str, str]]) -> Callable[[int], None]: + from tasks import human_input_timeout_tasks + + def dispatch(limit: int = 100) -> None: + timeout_publications.clear() + human_input_timeout_tasks.check_and_handle_human_input_timeouts(limit=limit) + for publication in timeout_publications: + human_input_timeout_tasks.handle_human_input_v2_timeout(**publication) + + return dispatch + + +def test_initialize_and_revisit_freezes_form_and_recipient_sources(context: Context) -> None: + created = context.prepare( + _node(Contact(contact_id=_CONTACT), Initiator(), OnetimeEmail(email="MEMBER@example.com")) + ) + assert isinstance(created, CreatedForm) + assert len(created.recipients) == 2 + contact = next(r for r in created.recipients if isinstance(r.subject, ContactRecipientSubject)) + assert len(contact.sources) == 2 + direct = next(r for r in created.recipients if isinstance(r.subject, EmailRecipientSubject)) + assert isinstance(direct.subject, EmailRecipientSubject) + assert direct.subject.email.value == "member@example.com" + changed = _node(OnetimeEmail(email="changed@example.com")).model_copy(update={"title": "Changed"}) + revisited = context.prepare(changed) + assert isinstance(revisited, PreparedForm) + assert revisited.form == created.form + with context.sessions() as session: + assert session.scalar(sa.select(sa.func.count()).select_from(HumanInputRecipient)) == 2 + assert session.scalar(sa.select(sa.func.count()).select_from(HumanInputDelivery)) == 0 + + +def test_invalid_dynamic_email_does_not_discard_another_recipient( + context: Context, caplog: pytest.LogCaptureFixture +) -> None: + pool = VariablePool() + pool.add(["upstream", "email"], ["wrong@example.com"]) + result = context.prepare( + _node(DynamicEmail(selector=["upstream", "email"]), OnetimeEmail(email="ok@example.com")), pool + ) + assert isinstance(result, CreatedForm) + assert len(result.recipients) == 1 + assert "unsupported_type" in caplog.text + + +def test_no_resolved_recipient_rolls_back_form(context: Context) -> None: + with pytest.raises(HumanInputDeliveryError): + context.prepare(_node(DynamicEmail(selector=["upstream", "absent"]))) + with context.sessions() as session: + assert session.scalar(sa.select(sa.func.count()).select_from(HumanInputForm)) == 0 + + +@pytest.fixture +def rejecting_recipient_context(context: Context) -> Context: + with context.sessions.begin() as session: + session.execute( + sa.text( + "CREATE TRIGGER reject_recipient BEFORE INSERT ON hitlv2_recipients " + "WHEN NEW.subject_value = 'reject@example.com' " + "BEGIN SELECT RAISE(ABORT, 'rejected recipient'); END" + ) + ) + return context + + +def test_recipient_write_failure_rolls_back_whole_initialization(rejecting_recipient_context: Context) -> None: + context = rejecting_recipient_context + with pytest.raises(IntegrityError): + context.prepare(_node(OnetimeEmail(email="ok@example.com"), OnetimeEmail(email="reject@example.com"))) + with context.sessions() as session: + assert session.scalar(sa.select(sa.func.count()).select_from(HumanInputForm)) == 0 + assert session.scalar(sa.select(sa.func.count()).select_from(HumanInputRecipient)) == 0 + + +def test_concurrent_entries_have_one_initial_delivery_owner(context: Context) -> None: + node = _node(OnetimeEmail(email="recipient@example.com")) + with ThreadPoolExecutor(max_workers=2) as executor: + results = list(executor.map(lambda _: context.prepare(node), range(2))) + assert sum(isinstance(result, CreatedForm) for result in results) == 1 + assert {result.form.id for result in results} == {results[0].form.id} + with context.sessions() as session: + assert session.scalar(sa.select(sa.func.count()).select_from(HumanInputRecipient)) == 1 + + +@pytest.fixture +def providers(context: Context, monkeypatch: pytest.MonkeyPatch) -> tuple[Mock, EmailOutbox]: + from cryptography.fernet import Fernet + + from configs import dify_config + from core.helper import encrypter + from core.human_input_v2.im_integration.adapters import factory + from core.human_input_v2.im_integration.adapters.credentials import SlackCredentials + from core.human_input_v2.im_integration.adapters.entities import CardAssessment, MessageAccepted + from core.human_input_v2.im_integration.adapters.message_locator import MessageLocator + from core.human_input_v2.im_integration.adapters.protocols import IMProviderAdapter + from enums import DeploymentEdition + from services.human_input_v2.im_credential_codec import IMCredentialCodec + from services.human_input_v2.resend_channel import ResendProviderGateway + + monkeypatch.setattr(dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.CLOUD) + monkeypatch.setattr(dify_config, "APP_WEB_URL", "https://app.example.com") + monkeypatch.setattr(dify_config, "CONSOLE_WEB_URL", "https://console.example.com") + cipher = Fernet(Fernet.generate_key()) + monkeypatch.setattr(encrypter, "encrypt_token", lambda _tenant, token: cipher.encrypt(token.encode()).decode()) + monkeypatch.setattr(encrypter, "decrypt_token", lambda _tenant, token: cipher.decrypt(token.encode()).decode()) + from services.human_input_v2.email_credential_codec import seal_email_credentials + + credentials = seal_email_credentials(_TENANT, ResendCredentials(api_key="email-key")) + with context.sessions.begin() as session: + session.add( + HumanInputEmailProvider( + tenant_id=_TENANT, + provider=EmailProviderType.RESEND, + sender_email="approvals@example.com", + sender_name="Approvals", + encrypted_credentials=credentials, + ) + ) + active: set[object] = set() + engine = context.sessions.kw["bind"] + event.listen(engine, "begin", lambda conn: active.add(conn)) + event.listen(engine, "commit", lambda conn: active.discard(conn)) + event.listen(engine, "rollback", lambda conn: active.discard(conn)) + + def accepted(*_args: object, **_kwargs: object) -> MessageAccepted: + assert not active, "Provider I/O must not hold a DB transaction" + with context.sessions() as session: + assert session.scalars(sa.select(sa.func.count()).select_from(HumanInputDelivery)).one() > 0 + return MessageAccepted(MessageLocator("provider-message")) + + adapter = create_autospec(IMProviderAdapter, instance=True) + adapter.dynamic_card_messaging.assess.return_value = CardAssessment(True) + adapter.dynamic_card_messaging.send_card.side_effect = accepted + adapter.messaging.send_text.side_effect = accepted + monkeypatch.setattr(factory, "build_im_provider_adapter", lambda _credentials: adapter) + monkeypatch.setattr( + IMCredentialCodec, + "load", + lambda *_args: SlackCredentials( + provider=IMProvider.SLACK, + client_id="client", + client_secret="secret", + signing_secret="sign", + bot_token="xoxb-test", + ), + ) + + def email_accepted(candidate: ResendCandidate, *, to: str, subject: str, html: str) -> None: + del candidate, to, subject, html + accepted() + + outbox = EmailOutbox(email_accepted) + monkeypatch.setattr(ResendProviderGateway, "send_message", outbox.send_message) + return adapter, outbox + + +def _add_im(context: Context) -> None: + from libs.datetime_utils import naive_utc_now + from models.human_input_v2 import IMIdentityRawPayload + from repositories.human_input_v2.channel_credentials import ChannelEncryptedCredentials + from repositories.human_input_v2.im_channel_repository import IMChannel, IMChannelId, IMChannelStatus, WebhookId + from repositories.human_input_v2.sqlalchemy_im_channel_repository import WorkspaceIMChannelWriter + + now = naive_utc_now() + channel_id = IMChannelId(str(uuid4())) + identity_id = str(uuid4()) + with context.sessions.begin() as session: + WorkspaceIMChannelWriter(session, _TENANT, _ACCOUNT).create( + IMChannel( + id=channel_id, + created_at=now, + updated_at=now, + provider=IMProvider.SLACK, + provider_tenant_id="provider-team", + encrypted_credentials=ChannelEncryptedCredentials(ciphertext="protected"), + app_identifier="app", + webhook_id=WebhookId(str(uuid4())), + config_version=1, + status=IMChannelStatus.CONNECTED, + ) + ) + identity = HumanInputIMIdentity( + channel_id=channel_id, + provider_user_id="im-user", + raw_payload=IMIdentityRawPayload({}), + last_seen_sync_run_id=str(uuid4()), + last_seen_at=now, + ) + identity.id = identity_id + session.add_all( + [identity, HumanInputIMBinding(channel_id=channel_id, contact_id=_CONTACT, im_identity_id=identity_id)] + ) + + +@pytest.mark.parametrize("include_contact", [False, True]) +@pytest.mark.parametrize("with_im", [False, True]) +def test_initiator_keeps_other_notification_channels( + context: Context, providers: tuple[Mock, EmailOutbox], include_contact: bool, with_im: bool +) -> None: + from repositories.human_input_v2.delivery_attempt_repository import DeliveryStatus + from services.human_input_v2.composition import build_human_input_delivery_service + + if with_im: + _add_im(context) + recipients = [Initiator(), Contact(contact_id=_CONTACT)] if include_contact else [Initiator()] + created = context.prepare(_node(*recipients)) + assert isinstance(created, CreatedForm) + assert len(created.recipients) == 1 + + result = build_human_input_delivery_service(tenant_id=_TENANT, session_factory=context.sessions).deliver( + form=created.form, + recipient=created.recipients[0], + message_template=created.message_template, + ) + + assert result.form_token is not None + assert len(result.attempts) == (2 if with_im else 1) + assert all(attempt.status == DeliveryStatus.SUCCEEDED for attempt in result.attempts) + adapter, email_send = providers + assert len(email_send.messages) == 1 + if with_im: + adapter.dynamic_card_messaging.send_card.assert_called_once() + else: + adapter.dynamic_card_messaging.send_card.assert_not_called() + + +def test_initiator_token_is_encrypted_and_recovered_without_another_delivery( + context: Context, providers: tuple[Mock, EmailOutbox] +) -> None: + import hashlib + + from repositories.human_input_v2.delivery_repository import InitiatorSnapshot + from services.human_input_v2.composition import build_human_input_delivery_service + + created = context.prepare(_node(Initiator())) + assert isinstance(created, CreatedForm) + result = build_human_input_delivery_service(tenant_id=_TENANT, session_factory=context.sessions).deliver( + form=created.form, + recipient=created.recipients[0], + message_template=created.message_template, + ) + assert result.form_token is not None + token = result.form_token.get_secret_value() + assert len(result.attempts) == 1 + with context.sessions() as session: + delivery = next( + item + for item in session.scalars(sa.select(HumanInputDelivery)) + if isinstance(item.target_snapshot, InitiatorSnapshot) + ) + from repositories.human_input_v2.delivery_repository import InitiatorSnapshot + + assert isinstance(delivery.target_snapshot, InitiatorSnapshot) + assert delivery.target_snapshot.protected_form_token != token + assert delivery.token_hash == hashlib.sha256(token.encode()).hexdigest() + assert token not in repr(delivery.target_snapshot) + revisited = context.prepare(_node(Initiator())) + assert isinstance(revisited, PreparedForm) + assert revisited.form_token == result.form_token + with context.sessions() as session: + assert session.scalar(sa.select(sa.func.count()).select_from(HumanInputDelivery)) == 2 + assert session.scalar(sa.select(sa.func.count()).select_from(HumanInputDeliveryAttempt)) == 1 + assert len(providers[1].messages) == 1 + + +@pytest.mark.parametrize("supports_card", [True, False]) +@pytest.mark.parametrize("workspace_override", [False, True]) +def test_im_assessment_controls_message_form_and_auth_without_losing_email( + context: Context, providers: tuple[Mock, EmailOutbox], supports_card: bool, workspace_override: bool +) -> None: + from core.human_input_v2.im_integration.adapters.entities import CardAssessment + from repositories.human_input_v2.delivery_repository import IMUserTargetSnapshot, SubmissionAuthType + from services.human_input_v2.composition import build_human_input_delivery_service + + _add_im(context) + with context.sessions.begin() as session: + binding = session.scalars(sa.select(HumanInputIMBinding)).one() + expected_binding_id = binding.id + if workspace_override: + override = HumanInputIMBindingWorkspaceOverride( + channel_id=binding.channel_id, + tenant_id=_TENANT, + contact_id=binding.contact_id, + im_identity_id=binding.im_identity_id, + ) + session.add(override) + expected_binding_id = override.id + adapter, email_send = providers + adapter.dynamic_card_messaging.assess.return_value = CardAssessment( + supports_card, None if supports_card else "file input" + ) + created = context.prepare(_node(Contact(contact_id=_CONTACT))) + assert isinstance(created, CreatedForm) + result = build_human_input_delivery_service(tenant_id=_TENANT, session_factory=context.sessions).deliver( + form=created.form, + recipient=created.recipients[0], + message_template=created.message_template, + ) + assert len(result.attempts) == 2 + assert result.form_token is None + with context.sessions() as session: + deliveries = list(session.scalars(sa.select(HumanInputDelivery))) + im_delivery = next(d for d in deliveries if isinstance(d.target_snapshot, IMUserTargetSnapshot)) + assert isinstance(im_delivery.target_snapshot, IMUserTargetSnapshot) + assert im_delivery.target_snapshot.im_binding_id == expected_binding_id + assert im_delivery.target_snapshot.message_type == ("card" if supports_card else "link") + assert im_delivery.auth_type == (SubmissionAuthType.IM if supports_card else SubmissionAuthType.CONSOLE) + assert im_delivery.target_snapshot.message_locator == "provider-message" + assert session.scalar(sa.select(sa.func.count()).select_from(HumanInputDeliveryAttempt)) == 2 + assert adapter.dynamic_card_messaging.send_card.call_count == int(supports_card) + assert adapter.messaging.send_text.call_count == int(not supports_card) + assert len(email_send.messages) == 1 + adapter.close.assert_called_once() + + +def test_im_link_subject_stays_literal_like_email_while_body_renders_markdown( + context: Context, providers: tuple[Mock, EmailOutbox] +) -> None: + from core.human_input_v2.im_integration.adapters.entities import CardAssessment + from services.human_input_v2.composition import build_human_input_delivery_service + + _add_im(context) + adapter, email_send = providers + adapter.dynamic_card_messaging.assess.return_value = CardAssessment(False, "file input") + node = _node(Contact(contact_id=_CONTACT)) + node.message_template.subject = "### Review {{#start.name#}}" + node.message_template.body = "# H1\n\n[Approval]({{#url#}})" + pool = VariablePool() + pool.add(["start", "name"], "**Alice**") + created = context.prepare(node, pool) + assert isinstance(created, CreatedForm) + + build_human_input_delivery_service(tenant_id=_TENANT, session_factory=context.sessions).deliver( + form=created.form, + recipient=created.recipients[0], + message_template=created.message_template, + ) + + message = adapter.messaging.send_text.call_args.args[1] + assert adapter.messaging.send_text.call_args.kwargs["subject"] == "### Review **Alice**" + assert message.startswith("# H1\n\n[Approval](https://console.example.com/human-input/") + assert email_send.messages[-1].subject == "### Review **Alice**" + assert "

H1

" in email_send.messages[-1].html + + +@pytest.mark.parametrize("contact_kind", ["workspace", "organization"]) +@pytest.mark.parametrize("app_web_url", ["https://app.example.com", ""]) +def test_account_contact_notifications_use_console_form_page( + context: Context, + providers: tuple[Mock, EmailOutbox], + monkeypatch: pytest.MonkeyPatch, + contact_kind: str, + app_web_url: str, +) -> None: + from configs import dify_config + from core.human_input_v2.im_integration.adapters.entities import CardAssessment + from services.human_input_v2.composition import build_human_input_delivery_service + + monkeypatch.setattr(dify_config, "APP_WEB_URL", app_web_url) + monkeypatch.setattr(dify_config, "CONSOLE_WEB_URL", "https://console.example.com/console/") + if contact_kind == "organization": + with context.sessions.begin() as session: + session.execute( + sa.delete(TenantAccountJoin).where( + TenantAccountJoin.tenant_id == _TENANT, TenantAccountJoin.account_id == _ACCOUNT + ) + ) + session.add( + HumanInputPlatformContactWorkspaceEntry( + tenant_id=_TENANT, contact_id=_CONTACT, added_by_account_id=_ACCOUNT + ) + ) + _add_im(context) + adapter, email_send = providers + adapter.dynamic_card_messaging.assess.return_value = CardAssessment(False, "file input") + created = context.prepare(_node(Contact(contact_id=_CONTACT))) + assert isinstance(created, CreatedForm) + + build_human_input_delivery_service(tenant_id=_TENANT, session_factory=context.sessions).deliver( + form=created.form, + recipient=created.recipients[0], + message_template=created.message_template, + ) + + assert "https://console.example.com/console/human-input/" in email_send.messages[-1].html + assert "https://console.example.com/console/human-input/" in adapter.messaging.send_text.call_args.args[1] + + +@pytest.mark.usefixtures("providers") +def test_account_contact_notification_requires_console_web_url( + context: Context, monkeypatch: pytest.MonkeyPatch +) -> None: + from configs import dify_config + from services.human_input_v2.composition import build_human_input_delivery_service + + monkeypatch.setattr(dify_config, "CONSOLE_WEB_URL", "") + created = context.prepare(_node(Contact(contact_id=_CONTACT))) + assert isinstance(created, CreatedForm) + + with pytest.raises(ValueError, match="CONSOLE_WEB_URL"): + build_human_input_delivery_service(tenant_id=_TENANT, session_factory=context.sessions).deliver( + form=created.form, + recipient=created.recipients[0], + message_template=created.message_template, + ) + + +def test_email_failure_is_recorded_without_discarding_im_success( + context: Context, providers: tuple[Mock, EmailOutbox] +) -> None: + from repositories.human_input_v2.delivery_attempt_repository import DeliveryStatus + from repositories.human_input_v2.email_channel.ports import EmailProviderOperationError + from services.human_input_v2.composition import build_human_input_delivery_service + + _add_im(context) + providers[1].failure = EmailProviderOperationError("provider_timeout") + created = context.prepare(_node(Contact(contact_id=_CONTACT))) + assert isinstance(created, CreatedForm) + result = build_human_input_delivery_service(tenant_id=_TENANT, session_factory=context.sessions).deliver( + form=created.form, + recipient=created.recipients[0], + message_template=created.message_template, + ) + assert {attempt.status for attempt in result.attempts} == {DeliveryStatus.SUCCEEDED, DeliveryStatus.FAILED} + + +def test_runtime_delivers_independently_to_recipients_sharing_email( + context: Context, providers: tuple[Mock, EmailOutbox] +) -> None: + from services.human_input_v2.composition import build_human_input_delivery_service + from services.human_input_v2.runtime import DifyHumanInputRuntime + + runtime = DifyHumanInputRuntime( + form_service=context.service, + delivery_service=build_human_input_delivery_service(tenant_id=_TENANT, session_factory=context.sessions), + ) + node = _node(Contact(contact_id=_CONTACT), OnetimeEmail(email="member@example.com")) + result = runtime.prepare_form(node_execution_id=_EXECUTION, node_data=node, variable_pool=VariablePool()) + assert result.form.status == HumanInputFormStatus.WAITING + assert result.form_token is None + assert len(providers[1].messages) == 2 + runtime.prepare_form(node_execution_id=_EXECUTION, node_data=node, variable_pool=VariablePool()) + assert len(providers[1].messages) == 2 + + +def test_all_provider_failures_fail_node_but_do_not_rollback_committed_form( + context: Context, providers: tuple[Mock, EmailOutbox] +) -> None: + from repositories.human_input_v2.email_channel.ports import EmailProviderOperationError + from services.human_input_v2.composition import build_human_input_delivery_service + from services.human_input_v2.runtime import DifyHumanInputRuntime + + providers[1].failure = EmailProviderOperationError("provider_timeout") + runtime = DifyHumanInputRuntime( + form_service=context.service, + delivery_service=build_human_input_delivery_service(tenant_id=_TENANT, session_factory=context.sessions), + ) + with pytest.raises(HumanInputDeliveryError): + runtime.prepare_form( + node_execution_id=_EXECUTION, + node_data=_node(OnetimeEmail(email="person@example.com")), + variable_pool=VariablePool(), + ) + with context.sessions() as session: + assert session.scalar(sa.select(sa.func.count()).select_from(HumanInputForm)) == 1 + assert session.scalar(sa.select(sa.func.count()).select_from(HumanInputDeliveryAttempt)) == 1 + + +@pytest.mark.parametrize("include_initiator", [False, True]) +def test_submission_during_delivery_is_rejected_until_workflow_pauses( + context: Context, providers: tuple[Mock, EmailOutbox], monkeypatch: pytest.MonkeyPatch, include_initiator: bool +) -> None: + import re + + from services.human_input_v2.composition import build_human_input_delivery_service + from services.human_input_v2.runtime import DifyHumanInputRuntime + from services.human_input_v2.submission_service import ( + HumanInputFormError, + HumanInputFormErrorCode, + HumanInputSubmissionService, + ) + from tasks.app_generate.workflow_execute_task import resume_app_execution + + provider_accept = providers[1].on_send + enqueue_calls: list[dict[str, object]] = [] + + def enqueue(*, kwargs: dict[str, object]) -> None: + enqueue_calls.append(kwargs) + + monkeypatch.setattr(resume_app_execution, "apply_async", enqueue) + submission_service = HumanInputSubmissionService(session_factory=context.sessions) + + def submit_on_acceptance(candidate: ResendCandidate, *, to: str, subject: str, html: str) -> None: + provider_accept(candidate, to=to, subject=subject, html=html) + if to != "member@example.com": + return + approval_link = re.search(r"/human-input/([A-Za-z0-9_-]+)", html) + assert approval_link is not None + with pytest.raises(HumanInputFormError) as exc: + submission_service.submit_console_form( + account_id=_ACCOUNT, form_token=approval_link.group(1), action="approve", inputs={} + ) + assert exc.value.code == HumanInputFormErrorCode.WORKFLOW_NOT_ACTIVE + + providers[1].on_send = submit_on_acceptance + runtime = DifyHumanInputRuntime( + form_service=context.service, + delivery_service=build_human_input_delivery_service(tenant_id=_TENANT, session_factory=context.sessions), + ) + result = runtime.prepare_form( + node_execution_id=_EXECUTION, + node_data=_node( + Initiator() if include_initiator else Contact(contact_id=_CONTACT), + OnetimeEmail(email="second@example.com"), + ), + variable_pool=VariablePool(), + ) + assert result.form.status == HumanInputFormStatus.WAITING + assert result.form.submission is None + assert (result.form_token is not None) == include_initiator + assert [message.to for message in providers[1].messages] == [ + "member@example.com", + "second@example.com", + ] + with context.sessions() as session: + assert session.get_one(HumanInputForm, result.form.id).status == HumanInputFormStatus.WAITING + assert not enqueue_calls + + +@pytest.mark.parametrize("during_delivery", [False, True]) +@pytest.mark.parametrize( + "state_change", ["submitted", "timeout", "expired", "form_deadline", "global_deadline", "workflow_stopped"] +) +def test_delivery_is_independent_of_form_and_workflow_state( + context: Context, providers: tuple[Mock, EmailOutbox], during_delivery: bool, state_change: str +) -> None: + from repositories.human_input_v2.delivery_attempt_repository import DeliveryStatus + from services.human_input_v2.composition import build_human_input_delivery_service + + _add_im(context) + created = context.prepare(_node(Initiator())) + assert isinstance(created, CreatedForm) + recipient = created.recipients[0] + + def change_state() -> None: + with context.sessions.begin() as session: + record = session.get_one(HumanInputForm, created.form.id) + match state_change: + case "submitted": + SQLAlchemyFormRepository(session, _TENANT, _APP).submit_form( + created.form.id, + submitted_by_recipient_id=RecipientId(recipient.id), + submitted_by_delivery_id=str(uuid4()), + selected_action_id="approve", + raw_submission_inputs={}, + normalized_submission_data={}, + ) + case "timeout": + record.status = HumanInputFormStatus.TIMEOUT + case "expired": + record.status = HumanInputFormStatus.EXPIRED + case "form_deadline": + record.expiration_time = naive_utc_now() - timedelta(seconds=1) + case "global_deadline": + record.global_timeout_deadline = naive_utc_now() - timedelta(seconds=1) + case "workflow_stopped": + session.get_one(WorkflowRun, _RUN).status = WorkflowExecutionStatus.STOPPED + + adapter, email_send = providers + if during_delivery: + provider_accept = adapter.dynamic_card_messaging.send_card.side_effect + + def accept_and_change_state(*args: object, **kwargs: object) -> MessageAccepted: + accepted = provider_accept(*args, **kwargs) + change_state() + return accepted + + adapter.dynamic_card_messaging.send_card.side_effect = accept_and_change_state + else: + change_state() + + result = build_human_input_delivery_service(tenant_id=_TENANT, session_factory=context.sessions).deliver( + form=created.form, recipient=recipient, message_template=created.message_template + ) + + assert result.form_token is not None + assert [attempt.status for attempt in result.attempts] == [DeliveryStatus.SUCCEEDED, DeliveryStatus.SUCCEEDED] + adapter.dynamic_card_messaging.send_card.assert_called_once() + assert len(email_send.messages) == 1 + with context.sessions() as session: + assert session.scalar(sa.select(sa.func.count()).select_from(HumanInputDelivery)) == 3 + assert session.scalar(sa.select(sa.func.count()).select_from(HumanInputDeliveryAttempt)) == 2 + + +def test_debug_mode_uses_only_editor_and_selected_test_channels( + context: Context, providers: tuple[Mock, EmailOutbox] +) -> None: + from core.human_input_v2.entities import DebugChannel + from core.workflow.nodes.human_input_v2.entities import DebugModeConfig + from services.human_input_v2.composition import build_human_input_delivery_service + from services.human_input_v2.runtime import DifyHumanInputRuntime + + _add_im(context) + editor_service = HumanInputFormService( + session_factory=context.sessions, context=replace(context.execution, debugging_account_id=_ACCOUNT) + ) + runtime = DifyHumanInputRuntime( + form_service=editor_service, + delivery_service=build_human_input_delivery_service(tenant_id=_TENANT, session_factory=context.sessions), + ) + node = _node(OnetimeEmail(email="production@example.com")).model_copy( + update={ + "debug_mode": DebugModeConfig(enabled=True, channels=[DebugChannel.EMAIL]), + } + ) + result = runtime.prepare_form(node_execution_id=_EXECUTION, node_data=node, variable_pool=VariablePool()) + assert result.form_token is None + assert providers[1].messages[-1].to == "member@example.com" + providers[0].dynamic_card_messaging.send_card.assert_not_called() + with context.sessions() as session: + recipient = session.scalars(sa.select(HumanInputRecipient)).one() + assert recipient.subject_value == _CONTACT + + +def test_production_invocation_does_not_activate_node_debug_mode(context: Context) -> None: + from core.human_input_v2.entities import DebugChannel + from core.workflow.nodes.human_input_v2.entities import DebugModeConfig + + node = _node(OnetimeEmail(email="production@example.com")).model_copy( + update={ + "debug_mode": DebugModeConfig(enabled=True, channels=[DebugChannel.EMAIL]), + } + ) + result = context.prepare(node) + assert isinstance(result, CreatedForm) + assert result.debug_channels is None + assert result.recipients[0].subject == EmailRecipientSubject(NormalizedEmail("production@example.com")) + + +def test_timeout_scan_fans_out_one_task_for_a_run_without_settling_its_forms( + context: Context, timeout_publications: list[dict[str, str]] +) -> None: + from tasks import human_input_timeout_tasks + + forms = [ + context.service.prepare_form( + node_execution_id=str(uuid4()), + node_data=_node(OnetimeEmail(email="person@example.com")), + variable_pool=VariablePool(), + ).form + for _ in range(2) + ] + with context.sessions.begin() as session: + for form in forms: + session.get_one(HumanInputForm, form.id).expiration_time = naive_utc_now() - timedelta(minutes=1) + human_input_timeout_tasks.check_and_handle_human_input_timeouts() + + with context.sessions() as session: + assert all(session.get_one(HumanInputForm, form.id).status == HumanInputFormStatus.WAITING for form in forms) + assert len(timeout_publications) == 1 + publication = timeout_publications[-1] + assert publication == {"tenant_id": _TENANT, "app_id": _APP, "workflow_run_id": _RUN} + + +@pytest.mark.parametrize("outcome", ["submitted", "extended", "globally_expired"]) +def test_timeout_task_reloads_changes_made_while_it_was_queued( + context: Context, monkeypatch: pytest.MonkeyPatch, timeout_publications: list[dict[str, str]], outcome: str +) -> None: + from tasks import human_input_timeout_tasks + + created = context.prepare(_node(OnetimeEmail(email="person@example.com"))) + assert isinstance(created, CreatedForm) + with context.sessions.begin() as session: + session.get_one(HumanInputForm, created.form.id).expiration_time = naive_utc_now() - timedelta(minutes=1) + human_input_timeout_tasks.check_and_handle_human_input_timeouts() + assert len(timeout_publications) == 1 + with context.sessions.begin() as session: + form = session.get_one(HumanInputForm, created.form.id) + if outcome == "submitted": + form.status = HumanInputFormStatus.SUBMITTED + form.submitted_at = naive_utc_now() + form.submitted_by_recipient_id = created.recipients[0].id + form.selected_action_id = "approve" + form.raw_submission_inputs = "{}" + form.normalized_submission_data = "{}" + elif outcome == "extended": + form.expiration_time = naive_utc_now() + timedelta(hours=1) + else: + form.global_timeout_deadline = naive_utc_now() - timedelta(seconds=1) + resume_calls: list[str] = [] + + def resume(run_id: str) -> None: + resume_calls.append(run_id) + + monkeypatch.setattr("services.human_input_v2.form_service._enqueue_resume", resume) + + human_input_timeout_tasks.handle_human_input_v2_timeout(**timeout_publications[-1]) + + expected_status = { + "submitted": HumanInputFormStatus.SUBMITTED, + "extended": HumanInputFormStatus.WAITING, + "globally_expired": HumanInputFormStatus.EXPIRED, + }[outcome] + with context.sessions() as session: + assert session.get_one(HumanInputForm, created.form.id).status == expected_status + assert session.get_one(WorkflowRun, _RUN).status == ( + WorkflowExecutionStatus.STOPPED if outcome == "globally_expired" else WorkflowExecutionStatus.RUNNING + ) + assert not resume_calls + + +def test_repeated_timeout_task_requests_only_one_resume(context: Context, monkeypatch: pytest.MonkeyPatch) -> None: + from tasks.human_input_timeout_tasks import handle_human_input_v2_timeout + + created = context.prepare(_node(OnetimeEmail(email="person@example.com"))) + with context.sessions.begin() as session: + session.get_one(HumanInputForm, created.form.id).expiration_time = naive_utc_now() - timedelta(minutes=1) + resume_calls: list[str] = [] + + def resume(run_id: str) -> None: + resume_calls.append(run_id) + + monkeypatch.setattr("services.human_input_v2.form_service._enqueue_resume", resume) + + handle_human_input_v2_timeout(tenant_id=_TENANT, app_id=_APP, workflow_run_id=_RUN) + handle_human_input_v2_timeout(tenant_id=_TENANT, app_id=_APP, workflow_run_id=_RUN) + + with context.sessions() as session: + assert session.get_one(HumanInputForm, created.form.id).status == HumanInputFormStatus.TIMEOUT + assert resume_calls == [_RUN] + + +@pytest.mark.parametrize("mismatched_field", ["tenant_id", "app_id", "workflow_run_id"]) +def test_timeout_task_does_not_handle_a_run_outside_its_owner_scope( + context: Context, monkeypatch: pytest.MonkeyPatch, mismatched_field: str +) -> None: + from tasks.human_input_timeout_tasks import handle_human_input_v2_timeout + + created = context.prepare(_node(OnetimeEmail(email="person@example.com"))) + with context.sessions.begin() as session: + session.get_one(HumanInputForm, created.form.id).expiration_time = naive_utc_now() - timedelta(minutes=1) + resume_calls: list[str] = [] + + def resume(run_id: str) -> None: + resume_calls.append(run_id) + + monkeypatch.setattr("services.human_input_v2.form_service._enqueue_resume", resume) + task_arguments = {"tenant_id": _TENANT, "app_id": _APP, "workflow_run_id": _RUN} + task_arguments[mismatched_field] = str(uuid4()) + + handle_human_input_v2_timeout( + tenant_id=task_arguments["tenant_id"], + app_id=task_arguments["app_id"], + workflow_run_id=task_arguments["workflow_run_id"], + ) + + with context.sessions() as session: + assert session.get_one(HumanInputForm, created.form.id).status == HumanInputFormStatus.WAITING + assert session.get_one(WorkflowRun, _RUN).status == WorkflowExecutionStatus.RUNNING + assert not resume_calls + + +@pytest.mark.parametrize("limit", [1, 100]) +def test_scheduled_timeouts_commit_all_due_forms_before_one_resume( + context: Context, monkeypatch: pytest.MonkeyPatch, limit: int, dispatch_timeouts: Callable[..., None] +) -> None: + from core.db.session_factory import session_factory + from services.human_input_v2 import form_service + + forms = [ + context.service.prepare_form( + node_execution_id=str(uuid4()), + node_data=_node(OnetimeEmail(email="person@example.com")), + variable_pool=VariablePool(), + ).form + for _ in range(2) + ] + with context.sessions.begin() as session: + for form in forms: + session.get_one(HumanInputForm, form.id).expiration_time = naive_utc_now() - timedelta(minutes=1) + + def enqueue(run_id: str) -> None: + assert run_id == _RUN + with context.sessions() as session: + assert all( + session.get_one(HumanInputForm, form.id).status == HumanInputFormStatus.TIMEOUT for form in forms + ) + + resume_calls: list[str] = [] + original_enqueue = enqueue + + def enqueue_resume(run_id: str) -> None: + resume_calls.append(run_id) + original_enqueue(run_id) + + monkeypatch.setattr(session_factory, "create_session", context.sessions) + monkeypatch.setattr(form_service, "_enqueue_resume", enqueue_resume) + dispatch_timeouts(limit=limit) + assert resume_calls == [_RUN] + + +@pytest.mark.parametrize("limit", [1, 100]) +def test_scheduled_global_expiration_wins_over_an_earlier_node_timeout( + context: Context, monkeypatch: pytest.MonkeyPatch, limit: int, dispatch_timeouts: Callable[..., None] +) -> None: + from core.db.session_factory import session_factory + from services.human_input_v2 import form_service + + node_form = context.prepare(_node(OnetimeEmail(email="person@example.com"))).form + global_form = context.service.prepare_form( + node_execution_id=str(uuid4()), + node_data=_node(OnetimeEmail(email="person@example.com")), + variable_pool=VariablePool(), + ).form + with context.sessions.begin() as session: + session.get_one(HumanInputForm, node_form.id).expiration_time = naive_utc_now() - timedelta(minutes=2) + session.get_one(HumanInputForm, global_form.id).global_timeout_deadline = naive_utc_now() - timedelta(minutes=1) + + enqueue_calls: list[str] = [] + + def enqueue(run_id: str) -> None: + enqueue_calls.append(run_id) + + monkeypatch.setattr(session_factory, "create_session", context.sessions) + monkeypatch.setattr(form_service, "_enqueue_resume", enqueue) + dispatch_timeouts(limit=limit) + with context.sessions() as session: + assert session.get_one(WorkflowRun, _RUN).status == WorkflowExecutionStatus.STOPPED + assert session.get_one(HumanInputForm, global_form.id).status == HumanInputFormStatus.EXPIRED + assert not enqueue_calls + + +def test_node_timeout_batch_rolls_back_all_forms_if_a_later_write_fails( + context: Context, monkeypatch: pytest.MonkeyPatch +) -> None: + from sqlalchemy.exc import SQLAlchemyError + + from services.human_input_v2 import form_service + + forms = [ + context.service.prepare_form( + node_execution_id=str(uuid4()), + node_data=_node(OnetimeEmail(email="person@example.com")), + variable_pool=VariablePool(), + ).form + for _ in range(2) + ] + with context.sessions.begin() as session: + for form in forms: + session.get_one(HumanInputForm, form.id).expiration_time = naive_utc_now() - timedelta(minutes=1) + fail_id = max(form.id for form in forms) + timeout_form = SQLAlchemyFormRepository.timeout_form + + def fail_later_write(repository: SQLAlchemyFormRepository, form_id: str) -> Form | None: + result = timeout_form(repository, form_id) + if form_id == fail_id: + raise SQLAlchemyError("Write failed") + return result + + enqueue_calls: list[str] = [] + + def enqueue(run_id: str) -> None: + enqueue_calls.append(run_id) + + monkeypatch.setattr(SQLAlchemyFormRepository, "timeout_form", fail_later_write) + monkeypatch.setattr(form_service, "_enqueue_resume", enqueue) + with pytest.raises(SQLAlchemyError, match="Write failed"): + context.handle_timeouts() + with context.sessions() as session: + assert all(session.get_one(HumanInputForm, form.id).status == HumanInputFormStatus.WAITING for form in forms) + assert not enqueue_calls + + +@pytest.mark.parametrize("status", [WorkflowExecutionStatus.STOPPED, WorkflowExecutionStatus.SUCCEEDED]) +def test_node_timeout_never_resumes_a_terminal_run( + context: Context, monkeypatch: pytest.MonkeyPatch, status: WorkflowExecutionStatus +) -> None: + from services.human_input_v2 import form_service + + form = context.prepare(_node(OnetimeEmail(email="person@example.com"))).form + with context.sessions.begin() as session: + session.get_one(HumanInputForm, form.id).expiration_time = naive_utc_now() - timedelta(minutes=1) + session.get_one(WorkflowRun, _RUN).status = status + enqueue_calls: list[str] = [] + + def enqueue(run_id: str) -> None: + enqueue_calls.append(run_id) + + monkeypatch.setattr(form_service, "_enqueue_resume", enqueue) + context.handle_timeouts() + with context.sessions() as session: + assert session.get_one(WorkflowRun, _RUN).status == status + assert session.get_one(HumanInputForm, form.id).status == HumanInputFormStatus.TIMEOUT + assert not enqueue_calls + + +def test_timeout_publication_failure_preserves_committed_forms_without_rehandling( + context: Context, monkeypatch: pytest.MonkeyPatch +) -> None: + from services.human_input_v2 import form_service + + result = context.prepare(_node(OnetimeEmail(email="person@example.com"))) + with context.sessions.begin() as session: + form = session.get_one(HumanInputForm, result.form.id) + assert form is not None + form.expiration_time = naive_utc_now() - timedelta(seconds=1) + publications = [] + + def enqueue(run_id: str) -> None: + with context.sessions() as session: + assert session.get_one(HumanInputForm, result.form.id).status == HumanInputFormStatus.TIMEOUT + publications.append(run_id) + if len(publications) == 1: + raise ConnectionError("queue unavailable") + + monkeypatch.setattr(form_service, "_enqueue_resume", enqueue) + with pytest.raises(ConnectionError): + context.handle_timeouts() + context.handle_timeouts() + assert publications == [_RUN] + + +def test_global_expiration_stops_run_without_requesting_node_resume( + context: Context, monkeypatch: pytest.MonkeyPatch +) -> None: + from services.human_input_v2 import form_service + + result = context.prepare(_node(OnetimeEmail(email="person@example.com"))) + with context.sessions.begin() as session: + form = session.get_one(HumanInputForm, result.form.id) + assert form is not None + form.expiration_time = naive_utc_now() - timedelta(seconds=1) + form.global_timeout_deadline = naive_utc_now() - timedelta(seconds=1) + run = session.get_one(WorkflowRun, _RUN) + run.status = WorkflowExecutionStatus.PAUSED + pause = WorkflowPause(workflow_id=run.workflow_id, workflow_run_id=_RUN, state_object_key="pause") + session.add(pause) + enqueue_calls: list[str] = [] + + def enqueue(run_id: str) -> None: + enqueue_calls.append(run_id) + + monkeypatch.setattr(form_service, "_enqueue_resume", enqueue) + context.handle_timeouts() + with context.sessions() as session: + assert session.get_one(HumanInputForm, result.form.id).status == HumanInputFormStatus.EXPIRED + run = session.get_one(WorkflowRun, _RUN) + assert run.status == WorkflowExecutionStatus.STOPPED + assert run.finished_at is not None + assert session.get_one(WorkflowPause, pause.id).resumed_at == run.finished_at + assert not enqueue_calls + + +def test_timeout_handling_uses_persisted_global_deadlines(context: Context, monkeypatch: pytest.MonkeyPatch) -> None: + from services.human_input_v2 import form_service + + created = context.prepare(_node(OnetimeEmail(email="person@example.com"))) + with context.sessions.begin() as session: + record = session.get_one(HumanInputForm, created.form.id) + record.expiration_time = naive_utc_now() - timedelta(minutes=2) + record.global_timeout_deadline = naive_utc_now() - timedelta(minutes=1) + enqueue_calls: list[str] = [] + + def enqueue(run_id: str) -> None: + enqueue_calls.append(run_id) + + monkeypatch.setattr(form_service, "_enqueue_resume", enqueue) + + context.handle_timeouts() + with context.sessions() as session: + assert session.get_one(WorkflowRun, _RUN).status == WorkflowExecutionStatus.STOPPED + assert session.get_one(HumanInputForm, created.form.id).status == HumanInputFormStatus.EXPIRED + assert not enqueue_calls + + +def test_timeout_handling_preserves_forms_that_are_not_due(context: Context, monkeypatch: pytest.MonkeyPatch) -> None: + from services.human_input_v2 import form_service + + created = context.prepare(_node(OnetimeEmail(email="person@example.com"))) + enqueue_calls: list[str] = [] + + def enqueue(run_id: str) -> None: + enqueue_calls.append(run_id) + + monkeypatch.setattr(form_service, "_enqueue_resume", enqueue) + + context.handle_timeouts() + with context.sessions() as session: + assert session.get_one(WorkflowRun, _RUN).status == WorkflowExecutionStatus.RUNNING + assert session.get_one(HumanInputForm, created.form.id).status == HumanInputFormStatus.WAITING + assert not enqueue_calls + + +@pytest.mark.parametrize(("node_timed_out", "globally_expired"), [(True, False), (False, True), (True, True)]) +def test_scheduled_expiration_uses_v2_deadlines( + context: Context, + monkeypatch: pytest.MonkeyPatch, + node_timed_out: bool, + globally_expired: bool, + dispatch_timeouts: Callable[..., None], +) -> None: + from core.db.session_factory import session_factory + from services.human_input_v2 import form_service + + created = context.prepare(_node(OnetimeEmail(email="person@example.com"))) + with context.sessions.begin() as session: + record = session.get_one(HumanInputForm, created.form.id) + if node_timed_out: + record.expiration_time = naive_utc_now() - timedelta(seconds=1) + if globally_expired: + record.global_timeout_deadline = naive_utc_now() - timedelta(seconds=1) + monkeypatch.setattr(session_factory, "create_session", context.sessions) + enqueue_calls: list[str] = [] + + def enqueue(run_id: str) -> None: + enqueue_calls.append(run_id) + + monkeypatch.setattr(form_service, "_enqueue_resume", enqueue) + dispatch_timeouts() + with context.sessions() as session: + record = session.get_one(HumanInputForm, created.form.id) + assert record.status == (HumanInputFormStatus.EXPIRED if globally_expired else HumanInputFormStatus.TIMEOUT) + if globally_expired: + assert not enqueue_calls + else: + assert enqueue_calls == [_RUN] + + +def test_scheduled_timeout_does_not_rescan_terminal_forms( + context: Context, monkeypatch: pytest.MonkeyPatch, dispatch_timeouts: Callable[..., None] +) -> None: + from kombu.exceptions import OperationalError + + from core.db.session_factory import session_factory + from services.human_input_v2 import form_service + + created = context.prepare(_node(OnetimeEmail(email="person@example.com"))) + with context.sessions.begin() as session: + record = session.get_one(HumanInputForm, created.form.id) + record.expiration_time = naive_utc_now() - timedelta(seconds=1) + run = session.get_one(WorkflowRun, _RUN) + run.status = WorkflowExecutionStatus.PAUSED + pause = WorkflowPause(workflow_id=run.workflow_id, workflow_run_id=_RUN, state_object_key="pause") + session.add(pause) + + def read_state(_key: str) -> bytes: + return _pause_state(created.form.id).encode() + + monkeypatch.setattr("tasks.human_input_timeout_tasks.storage.load", read_state) + monkeypatch.setattr(session_factory, "create_session", context.sessions) + resume_calls: list[str] = [] + + def enqueue(run_id: str) -> None: + resume_calls.append(run_id) + if len(resume_calls) == 1: + raise OperationalError("broker unavailable") + + monkeypatch.setattr(form_service, "_enqueue_resume", enqueue) + with pytest.raises(OperationalError, match="broker unavailable"): + dispatch_timeouts() + dispatch_timeouts() + assert resume_calls == [_RUN] + with context.sessions() as session: + assert session.get_one(HumanInputForm, created.form.id).status == HumanInputFormStatus.TIMEOUT + + +@pytest.mark.usefixtures("providers") +def test_terminal_form_does_not_return_its_initiator_token(context: Context, monkeypatch: pytest.MonkeyPatch) -> None: + from core.helper import encrypter + from services.human_input_v2.composition import build_human_input_delivery_service + + created = context.prepare(_node(Initiator())) + assert isinstance(created, CreatedForm) + build_human_input_delivery_service(tenant_id=_TENANT, session_factory=context.sessions).deliver( + form=created.form, + recipient=created.recipients[0], + message_template=created.message_template, + ) + with context.sessions.begin() as session: + form = session.get_one(HumanInputForm, created.form.id) + form.status = HumanInputFormStatus.TIMEOUT + + def decrypt(*_args: object) -> str: + raise AssertionError("Terminal token must not be recovered") + + monkeypatch.setattr(encrypter, "decrypt_token", decrypt) + result = context.prepare(_node(Initiator())) + assert isinstance(result, PreparedForm) + assert result.form_token is None + + +def test_delivery_rejects_recipient_from_another_form(context: Context, providers: tuple[Mock, EmailOutbox]) -> None: + from services.human_input_v2.composition import build_human_input_delivery_service + + created = context.prepare(_node(OnetimeEmail(email="person@example.com"))) + assert isinstance(created, CreatedForm) + from core.human_input_v2.shared.values import RecipientId + + foreign = replace(created.recipients[0], id=RecipientId(str(uuid4()))) + with pytest.raises(ValueError, match="Recipient does not belong to this form"): + build_human_input_delivery_service(tenant_id=_TENANT, session_factory=context.sessions).deliver( + form=created.form, + recipient=foreign, + message_template=created.message_template, + ) + assert not providers[1].messages + + +def test_reentry_reads_initialization_without_sending_notifications( + context: Context, providers: tuple[Mock, EmailOutbox] +) -> None: + from services.human_input_v2.composition import build_human_input_delivery_service + from services.human_input_v2.runtime import DifyHumanInputRuntime + + node = _node(OnetimeEmail(email="person@example.com")) + created = context.prepare(node) + runtime = DifyHumanInputRuntime( + form_service=context.service, + delivery_service=build_human_input_delivery_service(tenant_id=_TENANT, session_factory=context.sessions), + ) + prepared = runtime.prepare_form(node_execution_id=_EXECUTION, node_data=node, variable_pool=VariablePool()) + assert prepared.form.id == created.form.id + assert not providers[1].messages + + +def test_reentry_does_not_retry_failed_notifications(context: Context, providers: tuple[Mock, EmailOutbox]) -> None: + from repositories.human_input_v2.email_channel.ports import EmailProviderOperationError + from services.human_input_v2.composition import build_human_input_delivery_service + from services.human_input_v2.runtime import DifyHumanInputRuntime + + sends = [] + + def send(candidate: ResendCandidate, *, to: str, subject: str, html: str) -> None: + del candidate + sends.append((to, subject, html)) + if len(sends) == 2: + raise EmailProviderOperationError("provider_timeout") + + providers[1].on_send = send + node = _node(OnetimeEmail(email="first@example.com"), OnetimeEmail(email="second@example.com")) + runtime = DifyHumanInputRuntime( + form_service=context.service, + delivery_service=build_human_input_delivery_service(tenant_id=_TENANT, session_factory=context.sessions), + ) + runtime.prepare_form(node_execution_id=_EXECUTION, node_data=node, variable_pool=VariablePool()) + changed = _node(OnetimeEmail(email="other@example.com")) + changed.message_template.subject = "Changed" + runtime.prepare_form(node_execution_id=_EXECUTION, node_data=changed, variable_pool=VariablePool()) + assert len(sends) == 2 + runtime.prepare_form(node_execution_id=_EXECUTION, node_data=changed, variable_pool=VariablePool()) + assert len(sends) == 2 + with context.sessions() as session: + assert session.scalar(sa.select(sa.func.count()).select_from(HumanInputDelivery)) == 2 + assert session.scalar(sa.select(sa.func.count()).select_from(HumanInputDeliveryAttempt)) == 2 + + +@pytest.mark.usefixtures("providers") +@pytest.mark.parametrize("recipient", [Initiator(), OnetimeEmail(email="person@example.com")]) +def test_pause_persistence_and_reconnect_preserve_v2_form_and_initiator_only_token( + context: Context, monkeypatch: pytest.MonkeyPatch, recipient: Initiator | OnetimeEmail +) -> None: + from core.app.entities.queue_entities import QueueWorkflowPausedEvent + from core.app.entities.task_entities import ( + HumanInputRequiredPauseReasonPayload, + HumanInputRequiredResponse, + WorkflowPauseStreamResponse, + ) + from core.db.session_factory import session_factory + from core.workflow.nodes.human_input.entities import FormDefinition + from core.workflow.nodes.human_input_v2.presentation import build_human_input_v2_pause_reason + from graphon.entities import WorkflowStartReason + from graphon.runtime import RuntimeState + from models.human_input import HumanInputForm as LegacyForm + from repositories.sqlalchemy_api_workflow_run_repository import DifyAPISQLAlchemyWorkflowRunRepository + from services import workflow_event_snapshot_service + from services.human_input_v2.composition import build_human_input_delivery_service + from services.human_input_v2.runtime import DifyHumanInputRuntime + from tests.unit_tests.core.app.apps.common.test_workflow_response_converter import ( + _workflow_response_converter_human_input_build_converter as _build_converter, + ) + + monkeypatch.setattr("repositories.sqlalchemy_api_workflow_run_repository.storage.save", lambda *_args: None) + prepared = DifyHumanInputRuntime( + form_service=context.service, + delivery_service=build_human_input_delivery_service(tenant_id=_TENANT, session_factory=context.sessions), + ).prepare_form(node_execution_id=_EXECUTION, node_data=_node(recipient), variable_pool=VariablePool()) + legacy_definition = FormDefinition( + form_content="Legacy content", rendered_content="Legacy content", expiration_time=prepared.form.expiration_time + ) + with context.sessions.begin() as session: + legacy_form = LegacyForm( + tenant_id=_TENANT, + app_id=_APP, + workflow_run_id=_RUN, + node_id="legacy-node", + form_definition=legacy_definition.model_dump_json(), + rendered_content="Legacy content", + expiration_time=prepared.form.expiration_time, + ) + legacy_form.id = prepared.form.id + session.add(legacy_form) + reason = build_human_input_v2_pause_reason( + prepared.form.resolved_form, form_id=prepared.form.id, node_id="approval" + ) + repository = DifyAPISQLAlchemyWorkflowRunRepository(context.sessions) + state = _pause_state(prepared.form.id) + monkeypatch.setattr("repositories.sqlalchemy_api_workflow_run_repository.storage.load", lambda _key: state.encode()) + repository.create_workflow_pause( + workflow_run_id=_RUN, state_owner_user_id=_ACCOUNT, state=state, pause_reasons=[reason] + ) + pause = repository.get_workflow_pause(_RUN) + assert pause is not None + with context.sessions() as session: + run = session.get_one(WorkflowRun, _RUN) + assert run is not None + events = workflow_event_snapshot_service._build_paused_snapshot_events( + workflow_run=run, + node_snapshots=[], + node_executions={}, + task_id="task", + message_context=None, + pause_entity=pause, + resumption_context=None, + session_maker=context.sessions, + ) + required = next(event["data"] for event in events if event["event"] == "human_input_required") + paused = next(event["data"] for event in events if event["event"] == "workflow_paused") + expected_token = None + if isinstance(recipient, Initiator): + assert prepared.form_token is not None + expected_token = prepared.form_token.get_secret_value() + assert required["form_version"] == "2" + assert required["form_token"] == expected_token + assert required["display_in_ui"] == isinstance(recipient, Initiator) + assert required["form_content"] == "Please approve" + assert paused["reasons"][0]["form_token"] == expected_token + assert paused["reasons"][0]["form_version"] == "2" + + def reject_live_reload() -> Session: + raise AssertionError("Unexpected live form reload") + + monkeypatch.setattr(session_factory, "create_session", reject_live_reload) + converter = _build_converter() + converter.workflow_start_to_stream_response( + task_id="task", workflow_run_id=_RUN, workflow_id=run.workflow_id, reason=WorkflowStartReason.INITIAL + ) + live = converter.workflow_pause_to_stream_response( + event=QueueWorkflowPausedEvent( + reasons=[reason], outputs={}, paused_nodes=["approval"], human_input_v2_forms={prepared.form.id: prepared} + ), + task_id="task", + graph_runtime_state=RuntimeState(workflow_id="workflow", variable_pool=VariablePool(), start_at=0), + ) + live_required = next(event.data for event in live if isinstance(event, HumanInputRequiredResponse)) + live_paused = next(event.data for event in live if isinstance(event, WorkflowPauseStreamResponse)) + assert live_required.model_dump(mode="json") == required + assert live_paused.model_dump(mode="json")["reasons"] == paused["reasons"] + blocking = HumanInputRequiredPauseReasonPayload.from_response_data(live_required).model_dump(mode="json") + assert blocking == {"TYPE": "human_input_required", **required} + + # Pause details and generic execution resume also work without the legacy table. + from inspect import unwrap + + from flask import Flask + + from controllers.console.app import workflow_run as controller + from machinery.context import RequestContext + from services.workflow_run_service import WorkflowRunService + + service = WorkflowRunService( + workflow_runs=repository, + node_executions=DifyAPISQLAlchemyWorkflowNodeExecutionRepository(context.sessions), + session_factory=context.sessions, + ) + from configs import dify_config + from extensions.ext_application_services import build_application_services + from extensions.ext_redis import redis_client + + application = build_application_services( + database_client=context.sessions, + deployment_edition=dify_config.DEPLOYMENT_EDITION, + initialization_password="", + redis=redis_client, + ) + monkeypatch.setattr(controller, "application_services", lambda: replace(application, workflow_runs=service)) + request = RequestContext(request_id="request", trace_id="trace", account_id=_ACCOUNT, active_workspace_id=_TENANT) + with Flask(__name__).test_request_context(): + resource = controller.ConsoleWorkflowPauseDetailsApi() + details, status = unwrap(resource.get)(resource, request, workflow_run_id=_RUN) + assert status == 200 + assert details["paused_nodes"][0]["node_id"] == "approval" + assert details["paused_nodes"][0]["pause_type"]["form_id"] == prepared.form.id + url = details["paused_nodes"][0]["pause_type"]["backstage_input_url"] + assert url == (f"https://app.example.com/form/{expected_token}" if expected_token else None) + repository.resume_workflow_pause(_RUN, pause) + with context.sessions() as session: + assert session.get_one(WorkflowRun, _RUN).status == WorkflowExecutionStatus.RUNNING + + +def test_pause_snapshot_resolves_mixed_versions_in_checkpoint_order( + context: Context, monkeypatch: pytest.MonkeyPatch +) -> None: + from core.app.layers.pause_state_persist_layer import WorkflowResumptionContext + from core.workflow.nodes.human_input.entities import FormDefinition + from core.workflow.nodes.human_input.pause_reason import HumanInputRequired + from core.workflow.nodes.human_input_v2.presentation import build_human_input_v2_pause_reason + from graphon.entities.pause_reason import HitlRequired, SchedulingPause + from graphon.runtime import RuntimeState + from models.human_input import HumanInputForm as LegacyForm + from repositories.sqlalchemy_api_workflow_run_repository import DifyAPISQLAlchemyWorkflowRunRepository + from services.workflow_pause_service import load_workflow_pause_snapshot + + created = context.prepare(_node(OnetimeEmail(email="person@example.com"))) + legacy_id = str(uuid4()) + with context.sessions.begin() as session: + legacy = LegacyForm( + tenant_id=_TENANT, + app_id=_APP, + workflow_run_id=_RUN, + node_id="legacy-node", + form_definition=FormDefinition( + form_content="Legacy approval", + rendered_content="Frozen legacy content", + node_title="Legacy title", + expiration_time=created.form.expiration_time, + default_values={"note": "Legacy default"}, + ).model_dump_json(), + rendered_content="Frozen legacy content", + expiration_time=created.form.expiration_time, + ) + legacy.id = legacy_id + session.add(legacy) + session.add( + WorkflowPauseReason.from_entity( + pause_id=str(uuid4()), + pause_reason=HumanInputRequired( + form_id=str(uuid4()), + node_id="unrelated", + node_title="Other pause", + form_content="", + ), + ) + ) + resumption = WorkflowResumptionContext.loads(_pause_state(created.form.id)) + runtime = RuntimeState.from_snapshot(resumption.serialized_graph_runtime_state) + runtime.graph_execution.pause(HitlRequired(session_id=legacy_id, node_id="legacy-node", node_title="Legacy title")) + scheduling = SchedulingPause(message="Scheduled wait") + runtime.graph_execution.pause(scheduling) + resumption = resumption.model_copy(update={"serialized_graph_runtime_state": runtime.dumps()}) + state = resumption.dumps() + monkeypatch.setattr("repositories.sqlalchemy_api_workflow_run_repository.storage.save", lambda *_args: None) + monkeypatch.setattr("repositories.sqlalchemy_api_workflow_run_repository.storage.load", lambda _key: state.encode()) + repository = DifyAPISQLAlchemyWorkflowRunRepository(context.sessions) + repository.create_workflow_pause( + workflow_run_id=_RUN, + state_owner_user_id=_ACCOUNT, + state=state, + pause_reasons=[ + build_human_input_v2_pause_reason(created.form.resolved_form, form_id=created.form.id, node_id="approval"), + HumanInputRequired(form_id=legacy_id, node_id="legacy-node", node_title="Legacy title", form_content=""), + ], + ) + pause = repository.get_workflow_pause(_RUN) + assert pause is not None + snapshot = load_workflow_pause_snapshot(pause, session_factory=context.sessions) + assert len(snapshot.reasons) == 3 + v2, v1, scheduled = snapshot.reasons + assert isinstance(v2, HumanInputRequired) + assert v2.form_version == "2" + assert v2.form_content == "Please approve" + assert isinstance(v1, HumanInputRequired) + assert v1.form_version == "1" + assert v1.form_content == "Frozen legacy content" + assert v1.node_title == "Legacy title" + assert v1.resolved_default_values == {"note": "Legacy default"} + assert scheduled == scheduling + assert set(snapshot.v2_forms) == {created.form.id} + + +@pytest.mark.parametrize("initiator", [False, True]) +@pytest.mark.usefixtures("providers") +def test_console_pause_details_follow_checkpoint_with_mixed_form_versions( + context: Context, monkeypatch: pytest.MonkeyPatch, initiator: bool +) -> None: + from core.app.layers.pause_state_persist_layer import WorkflowResumptionContext + from core.workflow.nodes.human_input.entities import FormDefinition + from core.workflow.nodes.human_input.pause_reason import HumanInputRequired + from graphon.entities.pause_reason import HitlRequired + from graphon.runtime import RuntimeState + from machinery.context import RequestContext + from models.human_input import HumanInputForm as LegacyForm + from models.human_input import HumanInputFormRecipient, RecipientType + from repositories.sqlalchemy_api_workflow_run_repository import DifyAPISQLAlchemyWorkflowRunRepository + from services.human_input_v2.composition import build_human_input_delivery_service + from services.workflow_run_service import WorkflowRunPausedNode, WorkflowRunService + + created = context.prepare(_node(Initiator() if initiator else OnetimeEmail(email="person@example.com"))) + assert isinstance(created, CreatedForm) + token = None + if initiator: + delivered = build_human_input_delivery_service(tenant_id=_TENANT, session_factory=context.sessions).deliver( + form=created.form, recipient=created.recipients[0], message_template=created.message_template + ) + assert delivered.form_token is not None + token = delivered.form_token.get_secret_value() + + legacy_id = str(uuid4()) + with context.sessions.begin() as session: + session.add( + LegacyForm( + id=legacy_id, + tenant_id=_TENANT, + app_id=_APP, + workflow_run_id=_RUN, + node_id="legacy-node", + form_definition=FormDefinition( + form_content="Legacy approval", + rendered_content="Frozen legacy content", + node_title="Legacy title", + expiration_time=created.form.expiration_time, + ).model_dump_json(), + rendered_content="Frozen legacy content", + expiration_time=created.form.expiration_time, + ) + ) + session.add( + HumanInputFormRecipient( + form_id=legacy_id, + delivery_id=str(uuid4()), + recipient_type=RecipientType.CONSOLE, + recipient_payload="{}", + access_token="legacy-token", + ) + ) + + resumption = WorkflowResumptionContext.loads(_pause_state(created.form.id)) + runtime = RuntimeState.from_snapshot(resumption.serialized_graph_runtime_state) + runtime.graph_execution.pause(HitlRequired(session_id=legacy_id, node_id="legacy-node", node_title="Legacy title")) + state = resumption.model_copy(update={"serialized_graph_runtime_state": runtime.dumps()}).dumps() + monkeypatch.setattr("repositories.sqlalchemy_api_workflow_run_repository.storage.save", lambda *_args: None) + monkeypatch.setattr("repositories.sqlalchemy_api_workflow_run_repository.storage.load", lambda _key: state.encode()) + repository = DifyAPISQLAlchemyWorkflowRunRepository(context.sessions) + pause = repository.create_workflow_pause( + workflow_run_id=_RUN, + state_owner_user_id=_ACCOUNT, + state=state, + pause_reasons=[ + HumanInputRequired(form_id=legacy_id, node_id="legacy-node", node_title="Legacy title", form_content=""), + HumanInputRequired( + form_id=str(uuid4()), node_id="stale-node", node_title="Stale approval", form_content="" + ), + ], + ) + service = WorkflowRunService( + workflow_runs=repository, + node_executions=DifyAPISQLAlchemyWorkflowNodeExecutionRepository(context.sessions), + session_factory=context.sessions, + ) + request = RequestContext(request_id="request", trace_id="trace", account_id=_ACCOUNT, active_workspace_id=_TENANT) + + result = service.get_pause_details(request, workflow_run_id=_RUN) + + assert result is not None + assert result.paused_at == pause.paused_at + assert result.paused_nodes == ( + WorkflowRunPausedNode( + node_id="approval", node_title="Frozen", form_id=created.form.id, form_version="2", form_token=token + ), + WorkflowRunPausedNode( + node_id="legacy-node", + node_title="Legacy title", + form_id=legacy_id, + form_version="1", + form_token="legacy-token", + ), + ) + other_workspace = request._replace(active_workspace_id=str(uuid4())) + assert service.get_pause_details(other_workspace, workflow_run_id=_RUN) is None + + +@pytest.mark.parametrize("checkpoint", ["invalid", "missing_form"]) +def test_console_pause_details_do_not_fall_back_when_checkpoint_is_invalid( + context: Context, monkeypatch: pytest.MonkeyPatch, checkpoint: str +) -> None: + from machinery.context import RequestContext + from repositories.sqlalchemy_api_workflow_run_repository import DifyAPISQLAlchemyWorkflowRunRepository + from services.workflow_run_service import WorkflowRunService + + state = "invalid checkpoint" if checkpoint == "invalid" else _pause_state(str(uuid4())) + monkeypatch.setattr("repositories.sqlalchemy_api_workflow_run_repository.storage.save", lambda *_args: None) + monkeypatch.setattr("repositories.sqlalchemy_api_workflow_run_repository.storage.load", lambda _key: state.encode()) + repository = DifyAPISQLAlchemyWorkflowRunRepository(context.sessions) + repository.create_workflow_pause(workflow_run_id=_RUN, state_owner_user_id=_ACCOUNT, state=state, pause_reasons=[]) + service = WorkflowRunService( + workflow_runs=repository, + node_executions=DifyAPISQLAlchemyWorkflowNodeExecutionRepository(context.sessions), + session_factory=context.sessions, + ) + request = RequestContext(request_id="request", trace_id="trace", account_id=_ACCOUNT, active_workspace_id=_TENANT) + + with pytest.raises(ValueError, match="Invalid JSON|Human Input v2 form is missing from its paused run"): + service.get_pause_details(request, workflow_run_id=_RUN) + + +def test_missing_v1_pause_form_does_not_fall_back_to_v2(context: Context, monkeypatch: pytest.MonkeyPatch) -> None: + from core.workflow.nodes.human_input.pause_reason import HumanInputRequired + from repositories.sqlalchemy_api_workflow_run_repository import DifyAPISQLAlchemyWorkflowRunRepository + + created = context.prepare(_node(OnetimeEmail(email="person@example.com"))) + monkeypatch.setattr("repositories.sqlalchemy_api_workflow_run_repository.storage.save", lambda *_args: None) + repository = DifyAPISQLAlchemyWorkflowRunRepository(context.sessions) + repository.create_workflow_pause( + workflow_run_id=_RUN, + state_owner_user_id=_ACCOUNT, + state="{}", + pause_reasons=[ + HumanInputRequired( + form_id=created.form.id, + form_content="", + node_id="legacy-node", + node_title="Legacy", + ) + ], + ) + pause = repository.get_workflow_pause(_RUN) + assert pause is not None + with context.sessions() as session: + reason = repository.get_legacy_pause_reasons(session, pause.id)[0] + assert isinstance(reason, HumanInputRequired) + assert reason.form_version == "1" + assert reason.form_content == "" + + +@pytest.mark.usefixtures("providers") +def test_original_web_app_initiator_uses_web_auth_and_recovers_its_token( + context: Context, caplog: pytest.LogCaptureFixture +) -> None: + from repositories.human_input_v2.delivery_repository import SubmissionAuthType + from services.human_input_v2.composition import build_human_input_delivery_service + from services.human_input_v2.form_service import EndUserInitiator + from services.human_input_v2.runtime import DifyHumanInputRuntime + + end_user_id = str(uuid4()) + with context.sessions.begin() as session: + session.add( + EndUser( + id=end_user_id, tenant_id=_TENANT, app_id=_APP, type=EndUserType.BROWSER, session_id="original-session" + ) + ) + forms = HumanInputFormService( + session_factory=context.sessions, context=replace(context.execution, initiator=EndUserInitiator(end_user_id)) + ) + runtime = DifyHumanInputRuntime( + form_service=forms, + delivery_service=build_human_input_delivery_service(tenant_id=_TENANT, session_factory=context.sessions), + ) + prepared = runtime.prepare_form( + node_execution_id=_EXECUTION, node_data=_node(Initiator()), variable_pool=VariablePool() + ) + assert prepared.form_token is not None + assert "recipient_no_available_channel" not in caplog.text + revisited = runtime.prepare_form( + node_execution_id=_EXECUTION, node_data=_node(Initiator()), variable_pool=VariablePool() + ) + assert revisited.form_token == prepared.form_token + with context.sessions() as session: + delivery = session.scalars(sa.select(HumanInputDelivery)).one() + assert delivery.auth_type == SubmissionAuthType.WEB_APP + + +def test_cloud_without_workspace_email_configuration_does_not_use_default_mail( + context: Context, monkeypatch: pytest.MonkeyPatch +) -> None: + from configs import dify_config + from enums import DeploymentEdition + from extensions.ext_mail import mail + from services.human_input_v2.composition import build_human_input_delivery_service + + monkeypatch.setattr(dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.CLOUD) + sent_mail: list[dict[str, str]] = [] + + def send(*, to: str, subject: str, html: str) -> None: + sent_mail.append({"to": to, "subject": subject, "html": html}) + + monkeypatch.setattr(mail, "send", send) + monkeypatch.setattr(mail, "is_inited", lambda: True) + created = context.prepare(_node(OnetimeEmail(email="person@example.com"))) + assert isinstance(created, CreatedForm) + result = build_human_input_delivery_service(tenant_id=_TENANT, session_factory=context.sessions).deliver( + form=created.form, + recipient=created.recipients[0], + message_template=created.message_template, + ) + assert result.attempts == () + assert result.form_token is None + assert not sent_mail + + +def test_card_send_failure_does_not_change_saved_auth_or_send_an_im_link( + context: Context, providers: tuple[Mock, EmailOutbox] +) -> None: + from core.human_input_v2.im_integration.adapters.entities import MessageSendingError + from repositories.human_input_v2.delivery_attempt_repository import DeliveryStatus + from repositories.human_input_v2.delivery_repository import IMUserTargetSnapshot, SubmissionAuthType + from services.human_input_v2.composition import build_human_input_delivery_service + + _add_im(context) + adapter, _email_send = providers + adapter.dynamic_card_messaging.send_card.side_effect = None + adapter.dynamic_card_messaging.send_card.return_value = MessageSendingError("Not accepted") + created = context.prepare(_node(Contact(contact_id=_CONTACT))) + assert isinstance(created, CreatedForm) + result = build_human_input_delivery_service(tenant_id=_TENANT, session_factory=context.sessions).deliver( + form=created.form, + recipient=created.recipients[0], + message_template=created.message_template, + ) + assert {attempt.status for attempt in result.attempts} == {DeliveryStatus.SUCCEEDED, DeliveryStatus.FAILED} + adapter.messaging.send_text.assert_not_called() + with context.sessions() as session: + delivery = next( + d + for d in session.scalars(sa.select(HumanInputDelivery)) + if isinstance(d.target_snapshot, IMUserTargetSnapshot) + ) + assert delivery.auth_type == SubmissionAuthType.IM + assert isinstance(delivery.target_snapshot, IMUserTargetSnapshot) + assert delivery.target_snapshot.message_type == "card" + assert delivery.target_snapshot.message_locator is None + + +@pytest.fixture +def console_approval(context: Context) -> tuple[str, str]: + import hashlib + + from repositories.human_input_v2.delivery_repository import ( + DeliveryCreateParams, + InitiatorSnapshot, + SubmissionAuthType, + ) + from repositories.human_input_v2.sqlalchemy_delivery_repository import SQLAlchemyDeliveryRepository + + node_data = _node(Initiator()).model_dump(mode="json") + node_data.update( + form_content="Review: {{#$output.note#}}", + inputs=[{"type": "paragraph", "output_variable_name": "note"}], + ) + node = HumanInputNodeData.model_validate(node_data) + created = context.prepare(node) + assert isinstance(created, CreatedForm) + token = "console-approval-test-token" + with context.sessions.begin() as session: + SQLAlchemyDeliveryRepository(session).create_delivery( + tenant_id=_TENANT, + form_id=created.form.id, + params=DeliveryCreateParams( + recipient_id=created.recipients[0].id, + token_hash=hashlib.sha256(token.encode()).hexdigest(), + auth_type=SubmissionAuthType.CONSOLE, + target_snapshot=InitiatorSnapshot(protected_form_token="encrypted-token"), + ), + ) + run = session.get_one(WorkflowRun, _RUN) + assert run is not None + run.status = WorkflowExecutionStatus.PAUSED + session.add( + WorkflowPause(workflow_id=run.workflow_id, workflow_run_id=_RUN, state_object_key="approval-checkpoint") + ) + return created.form.id, token + + +def test_submission_fans_out_each_created_im_card_after_commit( + context: Context, console_approval: tuple[str, str], monkeypatch: pytest.MonkeyPatch +) -> None: + from core.human_input_v2.im_integration.adapters.message_locator import MessageLocator + from core.human_input_v2.shared.values import IMBindingId + from repositories.human_input_v2.delivery_attempt_repository import DeliveryAttemptCreateParams, DeliveryStatus + from repositories.human_input_v2.delivery_repository import ( + DeliveryCreateParams, + IMMessageType, + IMUserTargetSnapshot, + SubmissionAuthType, + ) + from repositories.human_input_v2.sqlalchemy_delivery_attempt_repository import SQLAlchemyDeliveryAttemptRepository + from repositories.human_input_v2.sqlalchemy_delivery_repository import SQLAlchemyDeliveryRepository + from services.human_input_v2.submission_service import HumanInputSubmissionService + from tasks.app_generate.workflow_execute_task import resume_app_execution + from tasks.human_input_im_card_tasks import update_human_input_im_card_task + + form_id, token = console_approval + expected_delivery_ids = set() + with context.sessions.begin() as session: + recipient = session.scalars(sa.select(HumanInputRecipient)).one() + for owner, delivery_form, message_type, status in ( + (_TENANT, form_id, IMMessageType.CARD, DeliveryStatus.SUCCEEDED), + (_TENANT, form_id, IMMessageType.CARD, DeliveryStatus.SUCCEEDED), + (_TENANT, form_id, IMMessageType.CARD, DeliveryStatus.FAILED), + (_TENANT, form_id, IMMessageType.LINK, DeliveryStatus.SUCCEEDED), + (TenantId(str(uuid4())), form_id, IMMessageType.CARD, DeliveryStatus.SUCCEEDED), + (_TENANT, str(uuid4()), IMMessageType.CARD, DeliveryStatus.SUCCEEDED), + ): + delivery = SQLAlchemyDeliveryRepository(session).create_delivery( + tenant_id=owner, + form_id=delivery_form, + params=DeliveryCreateParams( + recipient_id=RecipientId(recipient.id), + token_hash=uuid4().hex, + auth_type=SubmissionAuthType.IM, + target_snapshot=IMUserTargetSnapshot( + im_provider=IMProvider.SLACK, + im_tenant_id="provider-team", + im_provider_user_id="im-user", + im_binding_id=IMBindingId(str(uuid4())), + message_type=message_type, + ), + ), + ) + SQLAlchemyDeliveryAttemptRepository(session, owner, delivery_form).record_attempt( + delivery.id, + DeliveryAttemptCreateParams(status, None, {"accepted": True, "message_locator": delivery.id}), + ) + if status == DeliveryStatus.SUCCEEDED: + SQLAlchemyDeliveryRepository(session).set_im_message_locator( + tenant_id=owner, + form_id=delivery_form, + delivery_id=delivery.id, + message_locator=MessageLocator(delivery.id), + ) + if (owner, delivery_form, message_type, status) == ( + _TENANT, + form_id, + IMMessageType.CARD, + DeliveryStatus.SUCCEEDED, + ): + expected_delivery_ids.add(delivery.id) + # Multiple accepted attempts still fan out once per delivery. + SQLAlchemyDeliveryAttemptRepository(session, owner, delivery_form).record_attempt( + delivery.id, + DeliveryAttemptCreateParams(status, None, {"accepted": True, "message_locator": delivery.id}), + ) + SQLAlchemyDeliveryAttemptRepository(session, owner, delivery_form).record_attempt( + delivery.id, DeliveryAttemptCreateParams(DeliveryStatus.FAILED, "Later resend failed", {}) + ) + + published = [] + + def publish(task: Task[..., None], *, kwargs: dict[str, object]) -> None: + with context.sessions() as session: + assert session.get_one(HumanInputForm, form_id).status == HumanInputFormStatus.SUBMITTED + published.append((task.name, kwargs)) + + monkeypatch.setattr(resume_app_execution, "apply_async", lambda **kwargs: publish(resume_app_execution, **kwargs)) + monkeypatch.setattr( + update_human_input_im_card_task, + "apply_async", + lambda **kwargs: publish(update_human_input_im_card_task, **kwargs), + ) + HumanInputSubmissionService(session_factory=context.sessions).submit_console_form( + account_id=_ACCOUNT, form_token=token, action="approve", inputs={"note": "Approved"} + ) + card_updates = [kwargs for name, kwargs in published if "delivery_id" in kwargs] + assert {update["delivery_id"] for update in card_updates} == expected_delivery_ids + assert len(card_updates) == len(expected_delivery_ids) + assert all(update["tenant_id"] == _TENANT and update["form_id"] == form_id for update in card_updates) + + +@pytest.fixture +def created_im_cards( + context: Context, + console_approval: tuple[str, str], + providers: tuple[Mock, EmailOutbox], + monkeypatch: pytest.MonkeyPatch, +) -> CreatedIMCards: + from core.human_input_v2.im_integration.adapters.entities import MessageAccepted + from core.human_input_v2.im_integration.adapters.message_locator import MessageLocator + from models.model import App, AppMode + from repositories.human_input_v2.delivery_repository import IMUserTargetSnapshot + from repositories.human_input_v2.sqlalchemy_recipient_repository import SQLAlchemyRecipientRepository + from services.human_input_v2.delivery_service import HumanInputDeliveryService, ResolvedMessageTemplate + from tasks.app_generate.workflow_execute_task import resume_app_execution + from tasks.human_input_im_card_tasks import update_human_input_im_card_task + + form_id, token = console_approval + with context.sessions.begin() as session: + session.add( + App( + id=_APP, tenant_id=_TENANT, name="IM approval", mode=AppMode.WORKFLOW, enable_api=True, enable_site=True + ) + ) + _add_im(context) + adapter, _ = providers + adapter.dynamic_card_messaging.send_card.side_effect = [ + MessageAccepted(MessageLocator("opaque-card-one")), + MessageAccepted(MessageLocator("opaque-card-two")), + ] + adapter.dynamic_card_messaging.replace_with_static.return_value = None + with context.sessions() as session: + form = SQLAlchemyFormRepository(session, _TENANT, _APP).get_form_by_id(form_id) + assert form is not None + recipient = SQLAlchemyRecipientRepository(session, _TENANT, form_id).list_recipients()[0] + for _ in range(2): + HumanInputDeliveryService(session_factory=context.sessions, email_client=None).deliver( + form=form, + recipient=recipient, + message_template=ResolvedMessageTemplate(subject="Review", body="Open {{#url#}}"), + ) + with context.sessions() as session: + deliveries = { + delivery.id + for delivery in session.scalars(sa.select(HumanInputDelivery)) + if isinstance(delivery.target_snapshot, IMUserTargetSnapshot) + } + published: list[dict[str, str]] = [] + resumed: list[dict[str, object]] = [] + + def publish_card(*, kwargs: dict[str, str]) -> None: + published.append(kwargs) + + def publish_resume(*, kwargs: dict[str, object]) -> None: + resumed.append(kwargs) + + monkeypatch.setattr(update_human_input_im_card_task, "apply_async", publish_card) + monkeypatch.setattr(resume_app_execution, "apply_async", publish_resume) + return form_id, token, deliveries, adapter, published, resumed + + +def test_im_card_tasks_replace_exact_cards_with_committed_submission( + context: Context, created_im_cards: CreatedIMCards +) -> None: + from services.human_input_v2.submission_service import HumanInputSubmissionService + from tasks.human_input_im_card_tasks import update_human_input_im_card_task + + form_id, token, deliveries, adapter, published, _ = created_im_cards + HumanInputSubmissionService(session_factory=context.sessions).submit_console_form( + account_id=_ACCOUNT, form_token=token, action="approve", inputs={"note": "Accepted {{#$output.note#}}"} + ) + assert {call["delivery_id"] for call in published} == deliveries + adapter.dynamic_card_messaging.replace_with_static.assert_not_called() + active: set[object] = set() + engine = context.sessions.kw["bind"] + event.listen(engine, "begin", lambda conn: active.add(conn)) + event.listen(engine, "commit", lambda conn: active.discard(conn)) + event.listen(engine, "rollback", lambda conn: active.discard(conn)) + replacements = [] + + def replace_card(locator: MessageLocator, intent: StaticCardIntent) -> None: + assert not active, "Card updates must not hold a DB transaction" + replacements.append((locator, intent)) + + adapter.dynamic_card_messaging.replace_with_static.side_effect = replace_card + # Bindings may change after delivery; replacement still targets the saved card. + with context.sessions.begin() as session: + session.execute(sa.delete(HumanInputIMBinding)) + for call in published: + update_human_input_im_card_task.run(**call) + assert {locator for locator, _ in replacements} == {"opaque-card-one", "opaque-card-two"} + assert len(replacements) == 2 + assert all( + intent == StaticCardIntent("Frozen\n\nReview: Accepted {{#$output.note#}}\n\nSubmitted: Approve") + for _, intent in replacements + ) + # Redelivery renders the identical committed state, without another submission. + update_human_input_im_card_task.run(**published[0]) + assert replacements[2] == replacements[0] + + +def test_im_card_updates_render_typed_inputs_and_skip_missing_values( + context: Context, created_im_cards: CreatedIMCards +) -> None: + from core.human_input_v2.resolved_form import ( + FileInput, + FileListInput, + MarkdownFragment, + ParagraphInput, + SelectInput, + ) + from core.human_input_v2.shared.values import RecipientId + from graphon.file import File, FileTransferMethod, FileType + from graphon.variables.segments import ArrayFileSegment, FileSegment, NoneSegment, StringSegment + from repositories.human_input_v2.sqlalchemy_form_repository import SQLAlchemyFormRepository + from services.human_input_v2.im_card_service import HumanInputIMCardService + + form_id, _, deliveries, adapter, _, _ = created_im_cards + attachment = File( + file_id="file-1", + file_type=FileType.DOCUMENT, + transfer_method=FileTransferMethod.REMOTE_URL, + remote_url="https://example.com/review.pdf", + filename="review.pdf", + extension=".pdf", + mime_type="application/pdf", + size=100, + ) + file_input = FileInput( + output_variable_name="attachment", + allowed_file_types=(FileType.DOCUMENT,), + allowed_file_extensions=(".pdf",), + allowed_file_upload_methods=(FileTransferMethod.REMOTE_URL,), + ) + file_list = FileListInput( + output_variable_name="files", + allowed_file_types=file_input.allowed_file_types, + allowed_file_extensions=file_input.allowed_file_extensions, + allowed_file_upload_methods=file_input.allowed_file_upload_methods, + number_limits=3, + ) + with context.sessions.begin() as session: + record = session.get_one(HumanInputForm, form_id) + assert record is not None + record.resolved_form = record.resolved_form.model_copy( + update={ + "parts": record.resolved_form.parts + + ( + MarkdownFragment(text="; choice: "), + SelectInput(output_variable_name="choice", options=("Yes", "No"), default_value=None), + MarkdownFragment(text="; attachment: "), + file_input, + MarkdownFragment(text="; files: "), + file_list, + MarkdownFragment(text="; empty files: "), + file_list.model_copy(update={"output_variable_name": "empty_files"}), + MarkdownFragment(text="; omitted: "), + ParagraphInput(output_variable_name="missing", default_value=None), + file_input.model_copy(update={"output_variable_name": "null_file"}), + ) + } + ) + recipient = session.scalars(sa.select(HumanInputRecipient)).one() + delivery = session.scalars(sa.select(HumanInputDelivery)).first() + assert delivery is not None + SQLAlchemyFormRepository(session, _TENANT, _APP).submit_form( + form_id, + submitted_by_recipient_id=RecipientId(recipient.id), + submitted_by_delivery_id=delivery.id, + selected_action_id="approve", + raw_submission_inputs={}, + normalized_submission_data={ + "note": StringSegment(value="Accepted {{#$output.choice#}}"), + "choice": StringSegment(value="Yes"), + "attachment": FileSegment(value=attachment), + "files": ArrayFileSegment(value=[attachment, attachment]), + "empty_files": ArrayFileSegment(value=[]), + "null_file": NoneSegment(), + }, + ) + + service = HumanInputIMCardService(session_factory=context.sessions) + for delivery_id in deliveries: + service.update_card(tenant_id=_TENANT, form_id=form_id, delivery_id=delivery_id) + + replacements = adapter.dynamic_card_messaging.replace_with_static.call_args_list + assert len(replacements) == len(deliveries) + expected = StaticCardIntent( + "Frozen\n\nReview: Accepted {{#$output.choice#}}; choice: Yes; attachment: [file]; " + "files: [2 files]; empty files: [0 files]; omitted: \n\nSubmitted: Approve" + ) + assert all(call.args[1] == expected for call in replacements) + + +def test_im_card_updates_do_not_depend_on_attempt_history(context: Context, created_im_cards: CreatedIMCards) -> None: + import json + + from services.human_input_v2.submission_service import HumanInputSubmissionService + from tasks.human_input_im_card_tasks import update_human_input_im_card_task + + _, token, deliveries, adapter, published, _ = created_im_cards + with context.sessions.begin() as session: + snapshots = session.scalars( + sa.select(sa.type_coerce(HumanInputDelivery.target_snapshot, sa.Text)).where( + HumanInputDelivery.id.in_(deliveries) + ) + ).all() + assert {json.loads(snapshot).get("message_locator") for snapshot in snapshots} == { + "opaque-card-one", + "opaque-card-two", + } + attempts = session.scalars( + sa.select(HumanInputDeliveryAttempt).where(HumanInputDeliveryAttempt.delivery_id.in_(deliveries)) + ).all() + assert {attempt.response.root["message_locator"] for attempt in attempts} == { + "opaque-card-one", + "opaque-card-two", + } + session.execute( + sa.delete(HumanInputDeliveryAttempt).where(HumanInputDeliveryAttempt.delivery_id.in_(deliveries)) + ) + + HumanInputSubmissionService(session_factory=context.sessions).submit_console_form( + account_id=_ACCOUNT, form_token=token, action="approve", inputs={"note": "Accepted"} + ) + assert {call["delivery_id"] for call in published} == deliveries + for call in published: + update_human_input_im_card_task.run(**call) + calls = adapter.dynamic_card_messaging.replace_with_static.call_args_list + assert {call.args[0] for call in calls} == {"opaque-card-one", "opaque-card-two"} + + +@pytest.mark.usefixtures("providers") +def test_attempt_storage_failure_rolls_back_the_accepted_card_identity( + context: Context, monkeypatch: pytest.MonkeyPatch +) -> None: + from repositories.human_input_v2.delivery_repository import IMUserTargetSnapshot + from repositories.human_input_v2.sqlalchemy_delivery_attempt_repository import SQLAlchemyDeliveryAttemptRepository + from services.human_input_v2.delivery_service import HumanInputDeliveryService, ResolvedMessageTemplate + + _add_im(context) + created = context.prepare(_node(Initiator())) + assert isinstance(created, CreatedForm) + + def reject_attempt(*_args: object, **_kwargs: object) -> None: + raise SQLAlchemyError("Attempt storage failed") + + monkeypatch.setattr(SQLAlchemyDeliveryAttemptRepository, "record_attempt", reject_attempt) + with pytest.raises(SQLAlchemyError, match="Attempt storage failed"): + HumanInputDeliveryService(session_factory=context.sessions, email_client=None).deliver( + form=created.form, + recipient=created.recipients[0], + message_template=ResolvedMessageTemplate(subject="Review", body="Open {{#url#}}"), + ) + with context.sessions() as session: + cards = [ + record + for record in session.scalars(sa.select(HumanInputDelivery)) + if isinstance(record.target_snapshot, IMUserTargetSnapshot) + ] + assert len(cards) == 1 + assert isinstance(cards[0].target_snapshot, IMUserTargetSnapshot) + assert cards[0].target_snapshot.message_locator is None + assert session.scalar(sa.select(sa.func.count()).select_from(HumanInputDeliveryAttempt)) == 0 + + +def test_im_card_update_uses_delivery_identity_when_attempt_history_conflicts( + context: Context, created_im_cards: CreatedIMCards +) -> None: + from repositories.human_input_v2.delivery_attempt_repository import DeliveryAttemptCreateParams, DeliveryStatus + from repositories.human_input_v2.sqlalchemy_delivery_attempt_repository import SQLAlchemyDeliveryAttemptRepository + from services.human_input_v2.im_card_service import HumanInputIMCardService + from services.human_input_v2.submission_service import HumanInputSubmissionService + + form_id, token, deliveries, adapter, _, _ = created_im_cards + HumanInputSubmissionService(session_factory=context.sessions).submit_console_form( + account_id=_ACCOUNT, form_token=token, action="approve", inputs={"note": "Accepted"} + ) + delivery_id = next(iter(deliveries)) + with context.sessions.begin() as session: + expected_target = session.get_one(HumanInputDelivery, delivery_id).target_snapshot + assert isinstance(expected_target, IMUserTargetSnapshot) + expected_locator = expected_target.message_locator + SQLAlchemyDeliveryAttemptRepository(session, _TENANT, form_id).record_attempt( + delivery_id, + DeliveryAttemptCreateParams( + DeliveryStatus.SUCCEEDED, None, {"accepted": True, "message_locator": "conflicting-card"} + ), + ) + + updater = HumanInputIMCardService(session_factory=context.sessions) + updater.update_card(tenant_id=_TENANT, form_id=form_id, delivery_id=delivery_id) + assert adapter.dynamic_card_messaging.replace_with_static.call_args.args[0] == expected_locator + + +@pytest.mark.parametrize( + "scenario", + [ + "waiting", + "tenant", + "app_owner", + "form", + "delivery", + "provider", + "provider_tenant", + "channel", + "email", + "im_link", + "unaccepted", + ], +) +def test_im_card_update_ignores_unrelated_or_unavailable_cards( + context: Context, created_im_cards: CreatedIMCards, scenario: str +) -> None: + from repositories.human_input_v2.delivery_repository import ( + DeliveryTargetType, + EmailTargetSnapshot, + IMMessageType, + ) + from services.human_input_v2.submission_service import HumanInputSubmissionService + from tasks.human_input_im_card_tasks import update_human_input_im_card_task + + form_id, token, deliveries, adapter, _, _ = created_im_cards + delivery_id = next(iter(deliveries)) + if scenario != "waiting": + HumanInputSubmissionService(session_factory=context.sessions).submit_console_form( + account_id=_ACCOUNT, form_token=token, action="approve", inputs={"note": "Accepted"} + ) + with context.sessions.begin() as session: + if scenario == "channel": + session.execute(sa.delete(HumanInputIMChannel)) + elif scenario in ("provider", "provider_tenant"): + channel = session.scalars(sa.select(HumanInputIMChannel)).one() + if scenario == "provider": + channel.provider = IMProvider.FEISHU + else: + channel.provider_tenant_id = "another-provider-tenant" + elif scenario == "app_owner": + from models.model import App + + session.get_one(App, _APP).tenant_id = str(uuid4()) + elif scenario in ("email", "im_link", "unaccepted"): + delivery = session.get_one(HumanInputDelivery, delivery_id) + if scenario == "email": + delivery.target_type = DeliveryTargetType.EMAIL + delivery.target_snapshot = EmailTargetSnapshot(email_address="member@example.com") + else: + target = delivery.target_snapshot + assert isinstance(target, IMUserTargetSnapshot) + delivery.target_snapshot = target.model_copy( + update={ + "message_locator": None if scenario == "unaccepted" else target.message_locator, + "message_type": IMMessageType.LINK if scenario == "im_link" else IMMessageType.CARD, + } + ) + update_human_input_im_card_task.run( + tenant_id=str(uuid4()) if scenario == "tenant" else _TENANT, + form_id=str(uuid4()) if scenario == "form" else form_id, + delivery_id=str(uuid4()) if scenario == "delivery" else delivery_id, + ) + adapter.dynamic_card_messaging.replace_with_static.assert_not_called() + + +def test_card_publication_failure_keeps_other_deliveries_and_workflow_resume( + context: Context, + created_im_cards: CreatedIMCards, + caplog: pytest.LogCaptureFixture, + monkeypatch: pytest.MonkeyPatch, +) -> None: + from kombu.exceptions import OperationalError + + from services.human_input_v2.submission_service import HumanInputSubmissionService + + form_id, token, deliveries, _, published, resumed = created_im_cards + from tasks.human_input_im_card_tasks import update_human_input_im_card_task + + def publish_card(*, kwargs: dict[str, str]) -> None: + published.append(kwargs) + if len(published) == 1: + raise OperationalError("broker unavailable") + + monkeypatch.setattr(update_human_input_im_card_task, "apply_async", publish_card) + HumanInputSubmissionService(session_factory=context.sessions).submit_console_form( + account_id=_ACCOUNT, form_token=token, action="approve", inputs={"note": "Accepted"} + ) + assert len(published) == len(deliveries) + assert resumed == [{"payload": {"workflow_run_id": _RUN}}] + with context.sessions() as session: + assert session.get_one(HumanInputForm, form_id).status == HumanInputFormStatus.SUBMITTED + assert "Failed to enqueue Human Input IM card update" in caplog.text + + +@pytest.mark.parametrize("failure_kind", ["unknown", "stale_reference", "invalid_reference", "unsupported"]) +def test_im_card_update_failure_is_an_independent_follow_up( + context: Context, created_im_cards: CreatedIMCards, failure_kind: str +) -> None: + from core.human_input_v2.im_integration.adapters.entities import ReplacementError, ReplacementErrorKind + from services.human_input_v2.im_card_service import HumanInputIMCardService, IMCardUpdateRetryableError + from services.human_input_v2.submission_service import HumanInputSubmissionService + from tasks.human_input_im_card_tasks import update_human_input_im_card_task + + form_id, token, deliveries, adapter, _, resumed = created_im_cards + HumanInputSubmissionService(session_factory=context.sessions).submit_console_form( + account_id=_ACCOUNT, form_token=token, action="approve", inputs={"note": "Accepted"} + ) + if failure_kind == "unsupported": + adapter.dynamic_card_messaging = None + else: + adapter.dynamic_card_messaging.replace_with_static.return_value = ReplacementError( + ReplacementErrorKind(failure_kind), "Provider replacement failed" + ) + task_kwargs = {"tenant_id": _TENANT, "form_id": form_id, "delivery_id": next(iter(deliveries))} + # Exercise the retry signal directly; Celery owns backoff at the task boundary. + updater = HumanInputIMCardService(session_factory=context.sessions) + if failure_kind == "unknown": + with pytest.raises(IMCardUpdateRetryableError): + updater.update_card( + tenant_id=TenantId(task_kwargs["tenant_id"]), + form_id=task_kwargs["form_id"], + delivery_id=task_kwargs["delivery_id"], + ) + else: + update_human_input_im_card_task.run( + tenant_id=task_kwargs["tenant_id"], form_id=task_kwargs["form_id"], delivery_id=task_kwargs["delivery_id"] + ) + assert len(resumed) == 1 + with context.sessions() as session: + assert session.get_one(HumanInputForm, form_id).status == HumanInputFormStatus.SUBMITTED + + +def test_im_card_task_retries_unknown_outcome_with_the_same_static_content( + context: Context, created_im_cards: CreatedIMCards +) -> None: + from core.human_input_v2.im_integration.adapters.entities import ReplacementError, ReplacementErrorKind + from services.human_input_v2.submission_service import HumanInputSubmissionService + from tasks.human_input_im_card_tasks import update_human_input_im_card_task + + form_id, token, deliveries, adapter, _, _ = created_im_cards + HumanInputSubmissionService(session_factory=context.sessions).submit_console_form( + account_id=_ACCOUNT, form_token=token, action="approve", inputs={"note": "Accepted"} + ) + adapter.dynamic_card_messaging.replace_with_static.side_effect = [ + ReplacementError(ReplacementErrorKind.UNKNOWN, "Provider temporarily unavailable"), + None, + ] + result = update_human_input_im_card_task.apply( + kwargs={"tenant_id": _TENANT, "form_id": form_id, "delivery_id": next(iter(deliveries))} + ) + assert result.successful() + calls = adapter.dynamic_card_messaging.replace_with_static.call_args_list + assert len(calls) == 2 + assert calls[0] == calls[1] + + +def test_im_callback_redelivery_republishes_cards_after_workflow_resume( + context: Context, created_im_cards: CreatedIMCards +) -> None: + from repositories.human_input_v2.im_channel_repository import IMChannelId + from services.human_input_v2.submission_service import HumanInputSubmissionService, IMFormActor + + form_id, _, deliveries, adapter, published, resumed = created_im_cards + with context.sessions() as session: + channel = session.scalars(sa.select(HumanInputIMChannel)).one() + actor = IMFormActor(IMChannelId(channel.id), IMProvider.SLACK, "provider-team", "im-user") + token = adapter.dynamic_card_messaging.send_card.call_args_list[0].args[2] + service = HumanInputSubmissionService(session_factory=context.sessions) + service.submit_im_form(actor=actor, form_token=token, action="approve", inputs={"note": "Accepted"}) + with context.sessions.begin() as session: + original_submitted_at = session.get_one(HumanInputForm, form_id).submitted_at + session.get_one(WorkflowRun, _RUN).status = WorkflowExecutionStatus.SUCCEEDED + published.clear() + resumed.clear() + service.submit_im_form(actor=actor, form_token=token, action="approve", inputs={"note": "Accepted"}) + assert {call["delivery_id"] for call in published} == deliveries + assert not resumed + with context.sessions() as session: + assert session.get_one(HumanInputForm, form_id).submitted_at == original_submitted_at + + +def test_console_approve_http_commits_before_resuming( + context: Context, console_approval: tuple[str, str], monkeypatch: pytest.MonkeyPatch +) -> None: + from inspect import unwrap + + from flask import Flask + from flask_restx import Resource + + from controllers.console import console_ns + from core.db.session_factory import session_factory + from libs.external_api import ExternalApi + from tasks.app_generate.workflow_execute_task import resume_app_execution + + form_id, token = console_approval + monkeypatch.setattr(session_factory, "create_session", context.sessions) + published = [] + + def publish(*, kwargs: dict[str, dict[str, str]]) -> None: + with context.sessions() as session: + record = session.get_one(HumanInputForm, form_id) + assert record is not None + assert record.status == HumanInputFormStatus.SUBMITTED + assert record.selected_action_id == "approve" + published.append(kwargs) + + monkeypatch.setattr(resume_app_execution, "apply_async", publish) + app = Flask(__name__) + api = ExternalApi(app) + # Exercise the registered transport and real service, isolating only Console + # login admission and the async broker from the approval behavior. + resource = next( + resource for resource, urls, _, _ in console_ns.resources if "/form/human-input/v2/" in urls + ) + handler = unwrap(resource.post) + with context.sessions() as session: + account = session.get_one(Account, _ACCOUNT) + + class ApprovalEndpoint(Resource): + def post(self, form_token: str) -> object: + return handler(resource(), current_user=account, form_token=form_token) + + api.add_resource(ApprovalEndpoint, "/form/human-input/v2/") + client = app.test_client() + response = client.post(f"/form/human-input/v2/{token}", json={"inputs": {"note": "Approved"}, "action": "approve"}) + assert response.status_code == 200 + duplicate = client.post(f"/form/human-input/v2/{token}", json={"inputs": {"note": "Changed"}, "action": "approve"}) + assert duplicate.status_code == 409 + assert duplicate.json is not None + assert duplicate.json["code"] == "already_submitted" + unknown = client.post("/form/human-input/v2/unknown", json={"inputs": {"note": "Changed"}, "action": "approve"}) + assert unknown.status_code == 404 + assert published == [{"payload": {"workflow_run_id": _RUN}}] + + +@pytest.mark.parametrize( + ("action", "inputs"), + [ + ("reject", {"note": "Approved"}), + ("approve", {}), + ("approve", {"note": 123}), + ("approve", {"note": "Approved", "unexpected": "value"}), + ], +) +def test_console_approval_rejects_invalid_submission( + context: Context, + console_approval: tuple[str, str], + monkeypatch: pytest.MonkeyPatch, + action: str, + inputs: dict[str, JsonValue], +) -> None: + from services.human_input_v2.submission_service import ( + HumanInputFormError, + HumanInputFormErrorCode, + HumanInputSubmissionService, + ) + from tasks.app_generate.workflow_execute_task import resume_app_execution + + form_id, token = console_approval + enqueue_calls: list[dict[str, object]] = [] + + def enqueue(*, kwargs: dict[str, object]) -> None: + enqueue_calls.append(kwargs) + + monkeypatch.setattr(resume_app_execution, "apply_async", enqueue) + with pytest.raises(HumanInputFormError) as exc: + HumanInputSubmissionService(session_factory=context.sessions).submit_console_form( + account_id=_ACCOUNT, form_token=token, action=action, inputs=inputs + ) + assert exc.value.code == HumanInputFormErrorCode.INVALID_INPUT + with context.sessions() as session: + assert session.get_one(HumanInputForm, form_id).status == HumanInputFormStatus.WAITING + assert not enqueue_calls + + +@pytest.mark.parametrize( + "scenario", ["unknown_token", "wrong_account", "wrong_surface", "wrong_form", "wrong_tenant", "removed_contact"] +) +def test_console_approval_requires_the_delivery_recipient( + context: Context, console_approval: tuple[str, str], monkeypatch: pytest.MonkeyPatch, scenario: str +) -> None: + from services.human_input_v2.submission_service import ( + HumanInputFormError, + HumanInputFormErrorCode, + HumanInputSubmissionService, + ) + from tasks.app_generate.workflow_execute_task import resume_app_execution + + form_id, token = console_approval + account_id = _ACCOUNT + with context.sessions.begin() as session: + delivery = session.scalar(sa.select(HumanInputDelivery)) + assert delivery is not None + match scenario: + case "unknown_token": + token = "unknown-token" + case "wrong_account": + account_id = AccountId(str(uuid4())) + case "wrong_surface": + delivery.auth_type = SubmissionAuthType.EMAIL_OTP + case "wrong_form": + delivery.recipient_id = str(uuid4()) + case "wrong_tenant": + delivery.tenant_id = str(uuid4()) + case "removed_contact": + session.execute(sa.delete(TenantAccountJoin).where(TenantAccountJoin.account_id == _ACCOUNT)) + enqueue_calls: list[dict[str, object]] = [] + + def enqueue(*, kwargs: dict[str, object]) -> None: + enqueue_calls.append(kwargs) + + monkeypatch.setattr(resume_app_execution, "apply_async", enqueue) + with pytest.raises(HumanInputFormError) as exc: + HumanInputSubmissionService(session_factory=context.sessions).submit_console_form( + account_id=account_id, form_token=token, action="approve", inputs={"note": "Approved"} + ) + assert exc.value.code == HumanInputFormErrorCode.NOT_FOUND + with context.sessions() as session: + assert session.get_one(HumanInputForm, form_id).status == HumanInputFormStatus.WAITING + assert not enqueue_calls + + +@pytest.mark.parametrize("deadline", ["expiration_time", "global_timeout_deadline"]) +def test_console_approval_rejects_expiration( + context: Context, console_approval: tuple[str, str], monkeypatch: pytest.MonkeyPatch, deadline: str +) -> None: + from services.human_input_v2.submission_service import ( + HumanInputFormError, + HumanInputFormErrorCode, + HumanInputSubmissionService, + ) + from tasks.app_generate.workflow_execute_task import resume_app_execution + + form_id, token = console_approval + with context.sessions.begin() as session: + session.execute( + sa.update(HumanInputForm) + .where(HumanInputForm.id == form_id) + .values({deadline: naive_utc_now() - timedelta(seconds=1)}) + ) + enqueue_calls: list[dict[str, object]] = [] + + def enqueue(*, kwargs: dict[str, object]) -> None: + enqueue_calls.append(kwargs) + + monkeypatch.setattr(resume_app_execution, "apply_async", enqueue) + with pytest.raises(HumanInputFormError) as exc: + HumanInputSubmissionService(session_factory=context.sessions).submit_console_form( + account_id=_ACCOUNT, form_token=token, action="approve", inputs={"note": "Approved"} + ) + assert exc.value.code == HumanInputFormErrorCode.EXPIRED + assert not enqueue_calls + + +def test_console_approval_preserves_first_submission( + context: Context, console_approval: tuple[str, str], monkeypatch: pytest.MonkeyPatch +) -> None: + from graphon.variables.segments import StringSegment + from services.human_input_v2.submission_service import ( + HumanInputFormError, + HumanInputFormErrorCode, + HumanInputSubmissionService, + ) + from tasks.app_generate.workflow_execute_task import resume_app_execution + + form_id, token = console_approval + enqueue_calls: list[dict[str, object]] = [] + + def enqueue(*, kwargs: dict[str, object]) -> None: + enqueue_calls.append(kwargs) + + monkeypatch.setattr(resume_app_execution, "apply_async", enqueue) + service = HumanInputSubmissionService(session_factory=context.sessions) + service.submit_console_form(account_id=_ACCOUNT, form_token=token, action="approve", inputs={"note": " First "}) + with pytest.raises(HumanInputFormError) as exc: + service.submit_console_form(account_id=_ACCOUNT, form_token=token, action="approve", inputs={"note": "Second"}) + assert exc.value.code == HumanInputFormErrorCode.ALREADY_SUBMITTED + with context.sessions() as session: + form = SQLAlchemyFormRepository(session, _TENANT, _APP).get_form_by_id(form_id) + assert form is not None + assert form.submission is not None + assert form.submission.raw_submission_inputs == {"note": " First "} + assert form.submission.normalized_submission_data == {"note": StringSegment(value=" First ")} + assert enqueue_calls == [{"payload": {"workflow_run_id": _RUN}}] + + +@pytest.mark.parametrize( + "status", + [ + WorkflowExecutionStatus.RUNNING, + WorkflowExecutionStatus.STOPPED, + WorkflowExecutionStatus.FAILED, + WorkflowExecutionStatus.SUCCEEDED, + ], +) +def test_console_approval_requires_paused_workflow( + context: Context, + console_approval: tuple[str, str], + monkeypatch: pytest.MonkeyPatch, + status: WorkflowExecutionStatus, +) -> None: + from services.human_input_v2.submission_service import ( + HumanInputFormError, + HumanInputFormErrorCode, + HumanInputSubmissionService, + ) + from tasks.app_generate.workflow_execute_task import resume_app_execution + + form_id, token = console_approval + with context.sessions.begin() as session: + session.get_one(WorkflowRun, _RUN).status = status + enqueue_calls: list[dict[str, object]] = [] + + def enqueue(*, kwargs: dict[str, object]) -> None: + enqueue_calls.append(kwargs) + + monkeypatch.setattr(resume_app_execution, "apply_async", enqueue) + with pytest.raises(HumanInputFormError) as exc: + HumanInputSubmissionService(session_factory=context.sessions).submit_console_form( + account_id=_ACCOUNT, form_token=token, action="approve", inputs={"note": "Approved"} + ) + assert exc.value.code == HumanInputFormErrorCode.WORKFLOW_NOT_ACTIVE + with context.sessions() as session: + assert session.get_one(HumanInputForm, form_id).status == HumanInputFormStatus.WAITING + assert not enqueue_calls + + +@pytest.mark.parametrize("pause_state", ["missing", "resumed"]) +def test_console_approval_requires_committed_active_pause( + context: Context, console_approval: tuple[str, str], monkeypatch: pytest.MonkeyPatch, pause_state: str +) -> None: + from services.human_input_v2.submission_service import ( + HumanInputFormError, + HumanInputFormErrorCode, + HumanInputSubmissionService, + ) + from tasks.app_generate.workflow_execute_task import resume_app_execution + + form_id, token = console_approval + with context.sessions.begin() as session: + pause = session.scalar(sa.select(WorkflowPause).where(WorkflowPause.workflow_run_id == _RUN)) + assert pause is not None + if pause_state == "missing": + session.delete(pause) + else: + pause.resumed_at = naive_utc_now() + enqueue_calls: list[dict[str, object]] = [] + + def enqueue(*, kwargs: dict[str, object]) -> None: + enqueue_calls.append(kwargs) + + monkeypatch.setattr(resume_app_execution, "apply_async", enqueue) + service = HumanInputSubmissionService(session_factory=context.sessions) + with pytest.raises(HumanInputFormError) as exc: + service.submit_console_form( + account_id=_ACCOUNT, form_token=token, action="approve", inputs={"note": "Approved"} + ) + assert exc.value.code == HumanInputFormErrorCode.WORKFLOW_NOT_ACTIVE + with context.sessions() as session: + assert session.get_one(HumanInputForm, form_id).status == HumanInputFormStatus.WAITING + assert not enqueue_calls + + with context.sessions.begin() as session: + previous_pause = session.scalar(sa.select(WorkflowPause).where(WorkflowPause.workflow_run_id == _RUN)) + if previous_pause is not None: + session.delete(previous_pause) + session.flush() + run = session.get_one(WorkflowRun, _RUN) + assert run is not None + session.add( + WorkflowPause(workflow_id=run.workflow_id, workflow_run_id=_RUN, state_object_key="ready-checkpoint") + ) + service.submit_console_form(account_id=_ACCOUNT, form_token=token, action="approve", inputs={"note": "Approved"}) + with context.sessions() as session: + assert session.get_one(HumanInputForm, form_id).status == HumanInputFormStatus.SUBMITTED + assert enqueue_calls == [{"payload": {"workflow_run_id": _RUN}}] + + +@pytest.mark.parametrize("choice", ["Yes", "Unknown", 1]) +def test_console_approval_uses_frozen_select_options( + context: Context, console_approval: tuple[str, str], monkeypatch: pytest.MonkeyPatch, choice: str | int +) -> None: + from core.human_input_v2.resolved_form import SelectInput + from services.human_input_v2.submission_service import ( + HumanInputFormError, + HumanInputFormErrorCode, + HumanInputSubmissionService, + ) + from tasks.app_generate.workflow_execute_task import resume_app_execution + + form_id, token = console_approval + with context.sessions.begin() as session: + record = session.get_one(HumanInputForm, form_id) + record.resolved_form = record.resolved_form.model_copy( + update={"parts": (SelectInput(output_variable_name="note", options=("Yes", "No"), default_value=None),)} + ) + monkeypatch.setattr(resume_app_execution, "apply_async", lambda **_kwargs: None) + service = HumanInputSubmissionService(session_factory=context.sessions) + if choice == "Yes": + service.submit_console_form(account_id=_ACCOUNT, form_token=token, action="approve", inputs={"note": choice}) + with context.sessions() as session: + assert session.get_one(HumanInputForm, form_id).status == HumanInputFormStatus.SUBMITTED + else: + with pytest.raises(HumanInputFormError) as exc: + service.submit_console_form( + account_id=_ACCOUNT, form_token=token, action="approve", inputs={"note": choice} + ) + assert exc.value.code == HumanInputFormErrorCode.INVALID_INPUT + + +@pytest.mark.parametrize("scenario", ["single", "list", "empty_list", "foreign_file", "over_limit", "invalid_mapping"]) +def test_console_approval_validates_files_and_persists_typed_segments( + context: Context, console_approval: tuple[str, str], monkeypatch: pytest.MonkeyPatch, scenario: str +) -> None: + from core.db.session_factory import session_factory + from core.human_input_v2.resolved_form import FileInput, FileListInput + from extensions.storage.storage_type import StorageType + from graphon.file.enums import FileTransferMethod, FileType + from graphon.variables.segments import ArrayFileSegment, FileSegment + from models.model import UploadFile + from services.human_input_v2.submission_service import ( + HumanInputFormError, + HumanInputFormErrorCode, + HumanInputSubmissionService, + ) + from tasks.app_generate.workflow_execute_task import resume_app_execution + + form_id, token = console_approval + with context.sessions.begin() as session: + upload = UploadFile( + tenant_id=str(uuid4()) if scenario == "foreign_file" else _TENANT, + storage_type=StorageType.LOCAL, + key="approval-test.txt", + name="approval-test.txt", + size=20, + extension="txt", + mime_type="text/plain", + created_by_role=CreatorUserRole.ACCOUNT, + created_by=_ACCOUNT, + created_at=naive_utc_now(), + used=False, + ) + session.add(upload) + file_id = upload.id + constraints = { + "output_variable_name": "note", + "allowed_file_types": (FileType.DOCUMENT,), + "allowed_file_extensions": (), + "allowed_file_upload_methods": (FileTransferMethod.LOCAL_FILE,), + } + part = ( + FileListInput.model_validate({**constraints, "number_limits": 1}) + if scenario in ("list", "empty_list", "over_limit", "invalid_mapping") + else FileInput.model_validate(constraints) + ) + record = session.get_one(HumanInputForm, form_id) + record.resolved_form = record.resolved_form.model_copy(update={"parts": (part,)}) + mapping: dict[str, JsonValue] = {"transfer_method": "local_file", "upload_file_id": file_id, "type": "document"} + submitted: JsonValue = mapping + match scenario: + case "list": + submitted = [mapping] + case "empty_list": + submitted = [] + case "over_limit": + submitted = [mapping, mapping] + case "invalid_mapping": + submitted = [{}] + enqueue_calls: list[dict[str, object]] = [] + + def enqueue(*, kwargs: dict[str, object]) -> None: + enqueue_calls.append(kwargs) + + monkeypatch.setattr(resume_app_execution, "apply_async", enqueue) + monkeypatch.setattr(session_factory, "create_session", context.sessions) + service = HumanInputSubmissionService(session_factory=context.sessions) + if scenario in ("foreign_file", "over_limit", "invalid_mapping"): + with pytest.raises(HumanInputFormError) as exc: + service.submit_console_form( + account_id=_ACCOUNT, form_token=token, action="approve", inputs={"note": submitted} + ) + assert exc.value.code == HumanInputFormErrorCode.INVALID_INPUT + assert not enqueue_calls + return + service.submit_console_form(account_id=_ACCOUNT, form_token=token, action="approve", inputs={"note": submitted}) + with context.sessions() as session: + form = SQLAlchemyFormRepository(session, _TENANT, _APP).get_form_by_id(form_id) + assert form is not None + assert form.submission is not None + segment = form.submission.normalized_submission_data["note"] + assert form.submission.raw_submission_inputs == {"note": submitted} + if scenario == "single": + assert isinstance(segment, FileSegment) + assert segment.value.filename == "approval-test.txt" + else: + assert isinstance(segment, ArrayFileSegment) + assert len(segment.value) == (0 if scenario == "empty_list" else 1) + + +@pytest.mark.parametrize( + ("recipient_outcomes", "include_initiator", "expect_failure"), + [ + ((False, False), False, True), + ((False, True), False, False), + ((True, False), False, False), + ((True, True), False, False), + ((False,), True, False), + ], + ids=["all-failed", "first-failed", "last-failed", "all-succeeded", "initiator-with-email-failed"], +) +def test_initial_delivery_results_control_graph_failure_and_approval( + context: Context, + providers: tuple[Mock, EmailOutbox], + monkeypatch: pytest.MonkeyPatch, + recipient_outcomes: tuple[bool, ...], + include_initiator: bool, + expect_failure: bool, +) -> None: + from core.app.entities.app_invoke_entities import InvokeFrom, UserFrom, build_dify_run_context + from core.db.session_factory import session_factory + from core.workflow.node_factory import DifyNodeFactory + from core.workflow.system_variables import SystemVariableKey, system_variable_selector + from graphon.engine import Engine + from graphon.engine.command import InMemoryChannel + from graphon.engine_events import ( + GraphRunFailedEvent, + GraphRunPausedEvent, + GraphRunSucceededEvent, + NodeRunFailedEvent, + NodeRunStartedEvent, + ) + from graphon.graph import Graph + from graphon.nodes.end.end_node import EndNode + from graphon.nodes.end.entities import EndNodeData + from graphon.nodes.start import StartNode + from graphon.nodes.start.entities import StartNodeData + from graphon.runtime import InitParams, RuntimeState + from repositories.human_input_v2.delivery_attempt_repository import DeliveryStatus + from repositories.human_input_v2.email_channel.ports import EmailProviderOperationError + + monkeypatch.setattr(session_factory, "create_session", context.sessions) + provider_accept = providers[1].on_send + outcomes = iter(recipient_outcomes) + + def send(candidate: ResendCandidate, *, to: str, subject: str, html: str) -> None: + if not next(outcomes): + raise EmailProviderOperationError("provider_timeout") + return provider_accept(candidate, to=to, subject=subject, html=html) + + providers[1].on_send = send + pool = VariablePool() + pool.add(system_variable_selector(SystemVariableKey.WORKFLOW_EXECUTION_ID), _RUN) + state = RuntimeState(workflow_id="workflow", variable_pool=pool, start_at=0) + params = InitParams( + workflow_id="workflow", + graph_config={"nodes": [], "edges": []}, + run_context=build_dify_run_context( + tenant_id=_TENANT, + app_id=_APP, + user_id=_ACCOUNT, + user_from=UserFrom.ACCOUNT, + invoke_from=InvokeFrom.DEBUGGER, + ), + call_depth=0, + ) + recipients: list[Initiator | OnetimeEmail] = [ + OnetimeEmail(email=f"recipient{index}@example.com") for index in range(len(recipient_outcomes)) + ] + if include_initiator: + recipients[0] = Initiator() + approval = DifyNodeFactory(params, state).create_node( + {"id": "approval", "data": _node(*recipients).model_dump(mode="json")} + ) + start = StartNode( + node_id="start", + data=StartNodeData(title="Start", variables=[]), + init_params=params, + runtime_state=state, + ) + end = EndNode( + node_id="end", + data=EndNodeData(title="End", outputs=[]), + init_params=params, + runtime_state=state, + ) + graph = ( + Graph.new() + .add_root(start) + .add_node(approval, from_node_id="start") + .add_node(end, from_node_id="approval", source_handle="approve") + .build() + ) + engine = Engine( + graph=graph, + runtime_state=state, + command_channel=InMemoryChannel(), + ) + events = [] + if expect_failure: + # GraphEngine emits the failure event and then raises to its caller. + with pytest.raises(RuntimeError, match="No one can approve this step"): + events.extend(engine.run()) + else: + events.extend(engine.run()) + starts = [event.node_id for event in events if isinstance(event, NodeRunStartedEvent)] + assert starts == ["start", "approval"] + assert len(providers[1].messages) == len(recipient_outcomes) + assert not any(isinstance(event, GraphRunSucceededEvent) for event in events) + if expect_failure: + failures = [event for event in events if isinstance(event, NodeRunFailedEvent)] + assert len(failures) == 1 + assert failures[0].node_id == "approval" + assert failures[0].error == "No one can approve this step." + assert isinstance(events[-1], GraphRunFailedEvent) + assert not any(isinstance(event, GraphRunPausedEvent) for event in events) + else: + assert not any(isinstance(event, (NodeRunFailedEvent, GraphRunFailedEvent)) for event in events) + assert isinstance(events[-1], GraphRunPausedEvent) + with context.sessions() as session: + assert session.scalar(sa.select(sa.func.count()).select_from(HumanInputForm)) == 1 + attempts = list(session.scalars(sa.select(HumanInputDeliveryAttempt))) + assert len(attempts) == len(recipient_outcomes) + assert sum(attempt.status == DeliveryStatus.SUCCEEDED for attempt in attempts) == sum(recipient_outcomes) + assert sum(attempt.status == DeliveryStatus.FAILED for attempt in attempts) == len(recipient_outcomes) - sum( + recipient_outcomes + ) + + if not expect_failure: + from repositories.human_input_v2.delivery_repository import InitiatorSnapshot + from repositories.human_input_v2.recipient_repository import RecipientId + from repositories.human_input_v2.sqlalchemy_form_repository import SQLAlchemyFormRepository + + # Supply an accepted submission through the real persistence contract. + # Authentication and HTTP submission are covered by the existing suite. + with context.sessions.begin() as session: + successful_ids = set( + session.scalars( + sa.select(HumanInputDeliveryAttempt.delivery_id).where( + HumanInputDeliveryAttempt.status == DeliveryStatus.SUCCEEDED + ) + ) + ) + deliveries = list(session.scalars(sa.select(HumanInputDelivery))) + eligible = next( + delivery + for delivery in deliveries + if delivery.id in successful_ids or isinstance(delivery.target_snapshot, InitiatorSnapshot) + ) + SQLAlchemyFormRepository(session, _TENANT, _APP).submit_form( + eligible.form_id, + submitted_by_recipient_id=RecipientId(eligible.recipient_id), + submitted_by_delivery_id=eligible.id, + selected_action_id="approve", + raw_submission_inputs={}, + normalized_submission_data={}, + ) + resumed = list( + Engine( + graph=graph, + runtime_state=state, + command_channel=InMemoryChannel(), + ).run() + ) + assert isinstance(resumed[-1], GraphRunSucceededEvent) + assert [event.node_id for event in resumed if isinstance(event, NodeRunStartedEvent)] == ["approval", "end"] + assert len(providers[1].messages) == len(recipient_outcomes) + + +@pytest.mark.parametrize("recipient_kind", ["external_contact", "dynamic_email", "onetime_email"]) +def test_public_email_links_use_the_v2_form_page( + context: Context, providers: tuple[Mock, EmailOutbox], recipient_kind: str +) -> None: + from repositories.human_input_v2.contact import ExternalContact + from repositories.human_input_v2.sqlalchemy_contact_repository import SQLAlchemyContactRepository + from services.human_input_v2.composition import build_human_input_delivery_service + + email = "approver@example.com" + pool = VariablePool() + if recipient_kind == "external_contact": + contact_id = ContactId(str(uuid4())) + with context.sessions.begin() as session: + SQLAlchemyContactRepository(session).save_external_contact( + _TENANT, + ExternalContact( + id=contact_id, + name="Approver", + email=email, + avatar_file_id=None, + created_at=naive_utc_now(), + ), + ) + recipient = Contact(contact_id=contact_id) + elif recipient_kind == "dynamic_email": + pool.add(["upstream", "email"], email) + recipient = DynamicEmail(selector=["upstream", "email"]) + else: + recipient = OnetimeEmail(email=email) + + created = context.prepare(_node(recipient), pool) + assert isinstance(created, CreatedForm) + build_human_input_delivery_service(tenant_id=_TENANT, session_factory=context.sessions).deliver( + form=created.form, + recipient=created.recipients[0], + message_template=created.message_template, + ) + + email_send = providers[1] + assert email_send.messages[-1].to == email + assert "https://app.example.com/form-v2/" in email_send.messages[-1].html + + +def test_delivery_uses_one_injected_email_client_for_multiple_recipients( + context: Context, monkeypatch: pytest.MonkeyPatch +) -> None: + from configs import dify_config + from repositories.human_input_v2.delivery_attempt_repository import DeliveryStatus + from services.human_input_v2.delivery_service import HumanInputDeliveryService + from services.human_input_v2.email_client import EmailClient + + monkeypatch.setattr(dify_config, "APP_WEB_URL", "https://app.example.com") + client = create_autospec(EmailClient, instance=True) + created = context.prepare(_node(OnetimeEmail(email="first@example.com"), OnetimeEmail(email="second@example.com"))) + assert isinstance(created, CreatedForm) + service = HumanInputDeliveryService(session_factory=context.sessions, email_client=client) + for recipient in created.recipients: + result = service.deliver(form=created.form, recipient=recipient, message_template=created.message_template) + assert [attempt.status for attempt in result.attempts] == [DeliveryStatus.SUCCEEDED] + assert [call.kwargs["to"] for call in client.send.call_args_list] == ["first@example.com", "second@example.com"] + with context.sessions() as session: + assert session.scalar(sa.select(sa.func.count()).select_from(HumanInputDelivery)) == 2 + assert session.scalar(sa.select(sa.func.count()).select_from(HumanInputDeliveryAttempt)) == 2 + + +@pytest.mark.parametrize("edition", [DeploymentEdition.COMMUNITY, DeploymentEdition.ENTERPRISE]) +@pytest.mark.parametrize("configured", [False, True]) +def test_self_hosted_email_delivery_uses_configured_deployment_client( + context: Context, monkeypatch: pytest.MonkeyPatch, edition: DeploymentEdition, configured: bool +) -> None: + from configs import dify_config + from extensions.ext_mail import mail + from repositories.human_input_v2.delivery_attempt_repository import DeliveryStatus + from services.human_input_v2.composition import build_human_input_delivery_service + + monkeypatch.setattr(dify_config, "DEPLOYMENT_EDITION", edition) + monkeypatch.setattr(dify_config, "APP_WEB_URL", "https://app.example.com") + monkeypatch.setattr(mail, "is_inited", lambda: configured) + sent_mail: list[dict[str, str]] = [] + + def send(*, to: str, subject: str, html: str) -> None: + sent_mail.append({"to": to, "subject": subject, "html": html}) + + monkeypatch.setattr(mail, "send", send) + created = context.prepare(_node(OnetimeEmail(email="recipient@example.com"))) + assert isinstance(created, CreatedForm) + service = build_human_input_delivery_service(tenant_id=_TENANT, session_factory=context.sessions) + result = service.deliver( + form=created.form, recipient=created.recipients[0], message_template=created.message_template + ) + if configured: + assert [attempt.status for attempt in result.attempts] == [DeliveryStatus.SUCCEEDED] + assert len(sent_mail) == 1 + assert sent_mail[-1]["to"] == "recipient@example.com" + assert sent_mail[-1]["subject"] == "Review" + assert "https://app.example.com/form-v2/" in sent_mail[-1]["html"] + else: + assert result.attempts == () + assert not sent_mail + with context.sessions() as session: + assert session.scalar(sa.select(sa.func.count()).select_from(HumanInputDelivery)) == 0 + + +@pytest.fixture +def web_app_approval( + context: Context, monkeypatch: pytest.MonkeyPatch +) -> tuple[FlaskClient, str, str, dict[str, str], str]: + import hashlib + + from flask import Flask + + from configs import dify_config + from controllers.web import web_ns + from core.db.session_factory import session_factory + from libs.external_api import ExternalApi + from libs.passport import PassportService + from models.model import App, AppMode, CustomizeTokenStrategy, Site + from models.workflow import Workflow + from repositories.human_input_v2.delivery_repository import ( + DeliveryCreateParams, + InitiatorSnapshot, + SubmissionAuthType, + ) + from repositories.human_input_v2.sqlalchemy_delivery_repository import SQLAlchemyDeliveryRepository + from services.human_input_v2.form_service import EndUserInitiator + from services.system_feature_service import SystemFeatureService + + end_user_id = str(uuid4()) + with context.sessions.begin() as session: + session.add_all( + [ + App( + id=_APP, + tenant_id=_TENANT, + name="Web approval", + mode=AppMode.WORKFLOW, + workflow_id="published-approval-workflow", + enable_site=True, + enable_api=True, + ), + Workflow( + id="published-approval-workflow", + tenant_id=_TENANT, + app_id=_APP, + type=WorkflowType.WORKFLOW, + version="published", + graph="{}", + _features="{}", + created_by=str(_ACCOUNT), + ), + Site( + app_id=_APP, + code="approval-app", + title="Web approval", + default_language="en-US", + customize_token_strategy=CustomizeTokenStrategy.NOT_ALLOW, + ), + EndUser( + id=end_user_id, + tenant_id=_TENANT, + app_id=_APP, + type=EndUserType.BROWSER, + session_id="original-session", + ), + ] + ) + forms = HumanInputFormService( + session_factory=context.sessions, context=replace(context.execution, initiator=EndUserInitiator(end_user_id)) + ) + node_data = _node(Initiator()).model_dump(mode="json") + node_data.update( + form_content="Review: {{#$output.note#}}", + inputs=[{"type": "paragraph", "output_variable_name": "note"}], + ) + created = forms.prepare_form( + node_execution_id=_EXECUTION, + node_data=HumanInputNodeData.model_validate(node_data), + variable_pool=VariablePool(), + ) + assert isinstance(created, CreatedForm) + token = "web-app-approval-test-token" + with context.sessions.begin() as session: + SQLAlchemyDeliveryRepository(session).create_delivery( + tenant_id=_TENANT, + form_id=created.form.id, + params=DeliveryCreateParams( + recipient_id=created.recipients[0].id, + token_hash=hashlib.sha256(token.encode()).hexdigest(), + auth_type=SubmissionAuthType.WEB_APP, + target_snapshot=InitiatorSnapshot(protected_form_token="encrypted-token"), + ), + ) + run = session.get_one(WorkflowRun, _RUN) + assert run is not None + run.status = WorkflowExecutionStatus.PAUSED + session.add(WorkflowPause(workflow_id=run.workflow_id, workflow_run_id=_RUN, state_object_key="web-checkpoint")) + monkeypatch.setattr(session_factory, "create_session", context.sessions) + monkeypatch.setattr(SystemFeatureService, "is_webapp_auth_enabled", staticmethod(lambda: False)) + monkeypatch.setattr(dify_config, "SECRET_KEY", "web-approval-test-signing-key-at-least-32-bytes") + app = Flask(__name__) + ExternalApi(app).add_namespace(web_ns) + passport = PassportService().issue({"app_id": _APP, "app_code": "approval-app", "end_user_id": end_user_id}) + headers = {"X-App-Code": "approval-app", "X-App-Passport": passport} + return app.test_client(), created.form.id, token, headers, end_user_id + + +@pytest.mark.parametrize(("remote_address", "expected_ip"), [("192.0.2.13", "192.0.2.13"), (None, None), ("", None)]) +def test_web_app_approval_reads_frozen_form( + context: Context, + web_app_approval: tuple[FlaskClient, str, str, dict[str, str], str], + remote_address: str | None, + expected_ip: str | None, +) -> None: + client, form_id, token, headers, _ = web_app_approval + response = client.get( + f"/form/human-input/v2/{token}", headers=headers, environ_overrides={"REMOTE_ADDR": remote_address} + ) + assert response.status_code == 200, response.json + assert response.json is not None + assert response.json["resolved_form"]["legacy_form_content"] == "Review: {{#$output.note#}}" + assert response.json is not None + assert response.json["resolved_form"]["parts"][1] == { + "type": "paragraph_input", + "output_variable_name": "note", + "default_value": None, + } + assert response.json is not None + assert response.json["resolved_form"]["actions"][0]["id"] == "approve" + assert response.json is not None + assert isinstance(response.json["expiration_time"], int) + with context.sessions() as session: + audit = session.scalars(sa.select(HumanInputAuditEvent)).one() + assert audit.tenant_id == _TENANT + assert audit.form_id == form_id + assert audit.created_at is not None + assert isinstance(audit.payload, FormViewEvent) + assert audit.payload.viewer_ip_address == expected_ip + + +def test_web_app_approval_commits_before_resuming_and_preserves_first_submission( + context: Context, + web_app_approval: tuple[FlaskClient, str, str, dict[str, str], str], + monkeypatch: pytest.MonkeyPatch, +) -> None: + import json + + from tasks.app_generate.workflow_execute_task import resume_app_execution + + client, form_id, token, headers, _ = web_app_approval + published = [] + + def publish(*, kwargs: dict[str, dict[str, str]]) -> None: + with context.sessions() as session: + form = session.get_one(HumanInputForm, form_id) + assert form is not None + assert form.status == HumanInputFormStatus.SUBMITTED + assert form.raw_submission_inputs is not None + assert json.loads(form.raw_submission_inputs) == {"note": " First "} + published.append(kwargs) + + monkeypatch.setattr(resume_app_execution, "apply_async", publish) + response = client.post( + f"/form/human-input/v2/{token}", headers=headers, json={"action": "approve", "inputs": {"note": " First "}} + ) + assert response.status_code == 200 + assert response.json == {} + duplicate = client.post( + f"/form/human-input/v2/{token}", headers=headers, json={"action": "approve", "inputs": {"note": "Second"}} + ) + assert duplicate.status_code == 409 + assert duplicate.json is not None + assert duplicate.json["code"] == "already_submitted" + reread = client.get(f"/form/human-input/v2/{token}", headers=headers) + assert reread.status_code == 409 + assert reread.json is not None + assert reread.json["code"] == "already_submitted" + assert published == [{"payload": {"workflow_run_id": _RUN}}] + + +@pytest.mark.parametrize("method", ["get", "post"]) +@pytest.mark.parametrize("passport", [None, "invalid", "expired"]) +def test_web_app_approval_requires_passport( + context: Context, + web_app_approval: tuple[FlaskClient, str, str, dict[str, str], str], + monkeypatch: pytest.MonkeyPatch, + method: str, + passport: str | None, +) -> None: + from libs.passport import PassportService + from tasks.app_generate.workflow_execute_task import resume_app_execution + + client, form_id, token, headers, end_user_id = web_app_approval + if passport is None: + headers.pop("X-App-Passport") + elif passport == "expired": + headers["X-App-Passport"] = PassportService().issue( + {"app_id": _APP, "app_code": "approval-app", "end_user_id": end_user_id, "exp": 1} + ) + else: + headers["X-App-Passport"] = passport + enqueue_calls: list[dict[str, object]] = [] + + def enqueue(*, kwargs: dict[str, object]) -> None: + enqueue_calls.append(kwargs) + + monkeypatch.setattr(resume_app_execution, "apply_async", enqueue) + response = client.open( + f"/form/human-input/v2/{token}", + method=method.upper(), + headers=headers, + json={"action": "approve", "inputs": {"note": "Approved"}}, + ) + assert response.status_code == 401 + with context.sessions() as session: + assert session.get_one(HumanInputForm, form_id).status == HumanInputFormStatus.WAITING + assert not enqueue_calls + + +@pytest.mark.parametrize( + "scenario", + [ + "unknown_token", + "other_user", + "other_app", + "other_tenant", + "console", + "email_otp", + "im", + "wrong_recipient", + "wrong_owner", + "form_other_app", + "form_other_tenant", + "recipient_other_form", + "contact_recipient", + ], +) +def test_web_app_approval_requires_original_app_and_recipient( + context: Context, + web_app_approval: tuple[FlaskClient, str, str, dict[str, str], str], + monkeypatch: pytest.MonkeyPatch, + scenario: str, +) -> None: + from libs.passport import PassportService + from tasks.app_generate.workflow_execute_task import resume_app_execution + + client, form_id, token, headers, end_user_id = web_app_approval + with context.sessions.begin() as session: + delivery = session.scalar(sa.select(HumanInputDelivery)) + assert delivery is not None + end_user = session.get_one(EndUser, end_user_id) + match scenario: + case "unknown_token": + token = "legacy-or-unknown-token" + case "other_user": + end_user_id = str(uuid4()) + session.add( + EndUser( + id=end_user_id, + tenant_id=_TENANT, + app_id=_APP, + type=EndUserType.BROWSER, + session_id="other-session", + ) + ) + case "other_app": + end_user.app_id = str(uuid4()) + case "other_tenant": + end_user.tenant_id = str(uuid4()) + case "console" | "email_otp" | "im": + delivery.auth_type = SubmissionAuthType(scenario) + case "wrong_recipient": + delivery.recipient_id = str(uuid4()) + case "wrong_owner": + delivery.tenant_id = str(uuid4()) + case "form_other_app": + session.get_one(HumanInputForm, form_id).app_id = str(uuid4()) + case "form_other_tenant": + session.get_one(HumanInputForm, form_id).tenant_id = str(uuid4()) + case "recipient_other_form": + session.get_one(HumanInputRecipient, delivery.recipient_id).form_id = str(uuid4()) + case "contact_recipient": + recipient = session.get_one(HumanInputRecipient, delivery.recipient_id) + recipient.subject_type = RecipientSubjectType.CONTACT + recipient.subject_value = _CONTACT + headers["X-App-Passport"] = PassportService().issue( + {"app_id": _APP, "app_code": "approval-app", "end_user_id": end_user_id} + ) + enqueue_calls: list[dict[str, object]] = [] + + def enqueue(*, kwargs: dict[str, object]) -> None: + enqueue_calls.append(kwargs) + + monkeypatch.setattr(resume_app_execution, "apply_async", enqueue) + for method in ("GET", "POST"): + response = client.open( + f"/form/human-input/v2/{token}", + method=method, + headers=headers, + json={"action": "approve", "inputs": {"note": "Approved"}}, + ) + assert response.status_code == 404 + with context.sessions() as session: + assert session.get_one(HumanInputForm, form_id).status == HumanInputFormStatus.WAITING + assert not enqueue_calls + + +@pytest.mark.parametrize("deadline", ["expiration_time", "global_timeout_deadline"]) +def test_web_app_approval_rejects_expiration( + context: Context, + web_app_approval: tuple[FlaskClient, str, str, dict[str, str], str], + monkeypatch: pytest.MonkeyPatch, + deadline: str, +) -> None: + from tasks.app_generate.workflow_execute_task import resume_app_execution + + client, form_id, token, headers, _ = web_app_approval + with context.sessions.begin() as session: + session.execute( + sa.update(HumanInputForm) + .where(HumanInputForm.id == form_id) + .values({deadline: naive_utc_now() - timedelta(seconds=1)}) + ) + enqueue_calls: list[dict[str, object]] = [] + + def enqueue(*, kwargs: dict[str, object]) -> None: + enqueue_calls.append(kwargs) + + monkeypatch.setattr(resume_app_execution, "apply_async", enqueue) + for method in ("GET", "POST"): + response = client.open( + f"/form/human-input/v2/{token}", + method=method, + headers=headers, + json={"action": "approve", "inputs": {"note": "Approved"}}, + ) + assert response.status_code == 410 + assert response.json is not None + assert response.json["code"] == "expired" + assert not enqueue_calls + + +@pytest.mark.parametrize("scenario", ["running", "missing_pause", "resumed_pause"]) +def test_web_app_approval_requires_active_pause( + context: Context, + web_app_approval: tuple[FlaskClient, str, str, dict[str, str], str], + monkeypatch: pytest.MonkeyPatch, + scenario: str, +) -> None: + from tasks.app_generate.workflow_execute_task import resume_app_execution + + client, form_id, token, headers, _ = web_app_approval + with context.sessions.begin() as session: + pause = session.scalar(sa.select(WorkflowPause)) + assert pause is not None + if scenario == "running": + session.get_one(WorkflowRun, _RUN).status = WorkflowExecutionStatus.RUNNING + elif scenario == "missing_pause": + session.delete(pause) + else: + pause.resumed_at = naive_utc_now() + enqueue_calls: list[dict[str, object]] = [] + + def enqueue(*, kwargs: dict[str, object]) -> None: + enqueue_calls.append(kwargs) + + monkeypatch.setattr(resume_app_execution, "apply_async", enqueue) + response = client.post( + f"/form/human-input/v2/{token}", headers=headers, json={"action": "approve", "inputs": {"note": "Approved"}} + ) + assert response.status_code == 409 + assert response.json is not None + assert response.json["code"] == "workflow_not_active" + with context.sessions() as session: + assert session.get_one(HumanInputForm, form_id).status == HumanInputFormStatus.WAITING + assert not enqueue_calls + + +@pytest.mark.parametrize( + ("payload", "status"), + [ + ({"action": "reject", "inputs": {"note": "Approved"}}, 400), + ({"action": "approve", "inputs": {}}, 400), + ({"action": "approve", "inputs": {"note": 123}}, 400), + ({"action": "approve", "inputs": {"note": "Approved"}, "end_user_id": "another-user"}, 422), + ({"action": "approve", "inputs": {"note": "Approved"}, "user": "another-user"}, 422), + ( + { + "action": "approve", + "inputs": {"note": "Approved"}, + "otp_code": "123456", + "challenge_token": "email-proof", + }, + 422, + ), + ], +) +def test_web_app_approval_rejects_invalid_payload( + context: Context, + web_app_approval: tuple[FlaskClient, str, str, dict[str, str], str], + monkeypatch: pytest.MonkeyPatch, + payload: dict[str, JsonValue], + status: int, +) -> None: + from tasks.app_generate.workflow_execute_task import resume_app_execution + + client, form_id, token, headers, _ = web_app_approval + enqueue_calls: list[dict[str, object]] = [] + + def enqueue(*, kwargs: dict[str, object]) -> None: + enqueue_calls.append(kwargs) + + monkeypatch.setattr(resume_app_execution, "apply_async", enqueue) + response = client.post(f"/form/human-input/v2/{token}", headers=headers, json=payload) + assert response.status_code == status + with context.sessions() as session: + assert session.get_one(HumanInputForm, form_id).status == HumanInputFormStatus.WAITING + assert not enqueue_calls + + +@dataclass +class PublicFormApproval: + client: FlaskClient + form_id: str + token: str + challenge: AccessChallenge + otp_code: str + redis: Mock + + def submit( + self, *, action: str = "approve", inputs: dict[str, JsonValue] | None = None, proof: bool = True + ) -> TestResponse: + body = {"action": action, "inputs": {"note": "Approved"} if inputs is None else inputs} + if proof: + body.update(challenge_token=self.challenge.challenge_token, otp_code=self.otp_code) + return self.client.post(f"/api/form/human-input/v2/public/{self.token}", json=body) + + +@pytest.fixture(params=["email", "external_contact"]) +def public_form_approval( + context: Context, monkeypatch: pytest.MonkeyPatch, request: pytest.FixtureRequest +) -> PublicFormApproval: + import hashlib + + from flask import Flask + from redis import Redis + from redis.client import Pipeline + + from controllers.web import human_input_form_access_request as controller + from extensions.ext_redis import RedisClientWrapper + from extensions.redis_names import serialize_redis_name + from libs.external_api import ExternalApi + from libs.helper import RateLimiter + from models.model import App, AppMode + from repositories.human_input_v2.contact import ExternalContact + from repositories.human_input_v2.delivery_repository import ( + DeliveryCreateParams, + EmailTargetSnapshot, + SubmissionAuthType, + ) + from repositories.human_input_v2.sqlalchemy_contact_repository import SQLAlchemyContactRepository + from repositories.human_input_v2.sqlalchemy_delivery_repository import SQLAlchemyDeliveryRepository + from services.human_input_v2.email_otp_service import EmailOtpService, OtpMail + + with context.sessions.begin() as session: + session.add( + App(id=_APP, tenant_id=_TENANT, name="Email form", mode=AppMode.WORKFLOW, enable_api=True, enable_site=True) + ) + contact_id = ContactId(str(uuid4())) + if request.param == "external_contact": + SQLAlchemyContactRepository(session).save_external_contact( + _TENANT, + ExternalContact( + id=contact_id, + name="Approver", + email="approver@example.com", + avatar_file_id=None, + created_at=naive_utc_now(), + ), + ) + recipient = ( + Contact(contact_id=contact_id) + if request.param == "external_contact" + else OnetimeEmail(email="approver@example.com") + ) + node_data = _node(recipient).model_dump(mode="json") + node_data.update( + form_content="Review: {{#$output.note#}}", inputs=[{"type": "paragraph", "output_variable_name": "note"}] + ) + created = context.prepare(HumanInputNodeData.model_validate(node_data)) + assert isinstance(created, CreatedForm) + token = "public-form-test-token" + with context.sessions.begin() as session: + SQLAlchemyDeliveryRepository(session).create_delivery( + tenant_id=_TENANT, + form_id=created.form.id, + params=DeliveryCreateParams( + recipient_id=created.recipients[0].id, + token_hash=hashlib.sha256(token.encode()).hexdigest(), + auth_type=SubmissionAuthType.EMAIL_OTP, + target_snapshot=EmailTargetSnapshot(email_address="approver@example.com"), + ), + ) + run = session.get_one(WorkflowRun, _RUN) + run.status = WorkflowExecutionStatus.PAUSED + session.add( + WorkflowPause(workflow_id=run.workflow_id, workflow_run_id=_RUN, state_object_key="public-checkpoint") + ) + cache: dict[str, bytes] = {} + redis = create_autospec(RedisClientWrapper, instance=True) + pipeline = Redis().pipeline() + + def store_challenge(key: str, value: str | int, **_kwargs: object) -> Pipeline[bytes]: + cache[key] = str(value).encode() + return pipeline + + monkeypatch.setattr(pipeline, "set", store_challenge) + monkeypatch.setattr(pipeline, "execute", lambda: []) + redis.pipeline.return_value = pipeline + redis.get.side_effect = lambda key: cache.get(serialize_redis_name(key)) + redis.ttl.side_effect = lambda key: 600 if serialize_redis_name(key) in cache else -2 + + def increment(key: str) -> int | None: + physical = serialize_redis_name(key) + if physical not in cache: + return None + count = int(cache[physical]) + 1 + cache[physical] = str(count).encode() + return count + + def delete(*keys: str) -> None: + for key in keys: + cache.pop(serialize_redis_name(key), None) + + redis.incr_if_exists.side_effect = increment + redis.delete.side_effect = delete + limiter = create_autospec(RateLimiter, instance=True) + limiter.acquire.return_value = 0 + limiter.retry_after.return_value = 60 + sent_codes: list[tuple[str, str]] = [] + + def send(address: str, code: str) -> None: + sent_codes.append((address, code)) + + otp_service = EmailOtpService( + session_factory=context.sessions, redis=redis, limiter=limiter, mail=OtpMail(available=lambda: True, send=send) + ) + challenge = otp_service.request_access(token) + monkeypatch.setattr(controller, "build_email_otp_service", lambda: otp_service) + app = Flask(__name__) + ExternalApi(app).add_namespace(controller.web_ns, path="/api") + return PublicFormApproval(app.test_client(), created.form.id, token, challenge, sent_codes[-1][1], redis) + + +def test_public_form_submission_commits_form_and_audit_before_resuming( + context: Context, public_form_approval: PublicFormApproval, monkeypatch: pytest.MonkeyPatch +) -> None: + import json + + from models.human_input_v2 import HumanInputAuditEvent, OtpVerificationEvent + from tasks.app_generate.workflow_execute_task import resume_app_execution + + approval = public_form_approval + published = [] + + def publish(*, kwargs: dict[str, dict[str, str]]) -> None: + with context.sessions() as session: + form = session.get_one(HumanInputForm, approval.form_id) + assert form.status == HumanInputFormStatus.SUBMITTED + assert form.raw_submission_inputs is not None + assert json.loads(form.raw_submission_inputs) == {"note": " First "} + (audit,) = [ + event + for event in session.scalars(sa.select(HumanInputAuditEvent)) + if isinstance(event.payload, OtpVerificationEvent) + ] + assert isinstance(audit.payload, OtpVerificationEvent) + assert audit.payload.succeeded + assert audit.payload.recipient_id == form.submitted_by_recipient_id + assert audit.payload.recipient_email == "approver@example.com" + assert audit.payload.submitter_ip_address == "127.0.0.1" + stored_proof = audit.payload.model_dump_json() + assert approval.otp_code not in stored_proof + assert approval.challenge.challenge_token not in stored_proof + assert approval.redis.delete.called + published.append(kwargs) + + monkeypatch.setattr(resume_app_execution, "apply_async", publish) + accepted = approval.submit(inputs={"note": " First "}) + assert accepted.status_code == 200, accepted.json + assert accepted.json == {} + duplicate = approval.submit(inputs={"note": "Second"}) + assert duplicate.status_code == 412 + assert duplicate.json is not None + assert duplicate.json["code"] == "human_input_form_submitted" + assert published == [{"payload": {"workflow_run_id": _RUN}}] + + +def test_reading_public_definition_does_not_authorize_or_consume_submission_proof( + context: Context, public_form_approval: PublicFormApproval, monkeypatch: pytest.MonkeyPatch +) -> None: + from tasks.app_generate.workflow_execute_task import resume_app_execution + + approval = public_form_approval + enqueue_calls: list[dict[str, object]] = [] + + def enqueue(*, kwargs: dict[str, object]) -> None: + enqueue_calls.append(kwargs) + + monkeypatch.setattr(resume_app_execution, "apply_async", enqueue) + response = approval.client.get(f"/api/form/human-input/v2/public/{approval.token}") + assert response.status_code == 200 + approval.redis.get.assert_not_called() + approval.redis.delete.assert_not_called() + assert approval.submit(proof=False).status_code == 400 + with context.sessions() as session: + assert session.get_one(HumanInputForm, approval.form_id).status == HumanInputFormStatus.WAITING + assert not enqueue_calls + assert approval.submit().status_code == 200 + + +@pytest.mark.parametrize("scenario", ["missing", "wrong_code", "unknown_challenge"]) +def test_public_form_submission_requires_proof( + context: Context, public_form_approval: PublicFormApproval, monkeypatch: pytest.MonkeyPatch, scenario: str +) -> None: + from models.human_input_v2 import HumanInputAuditEvent, OtpVerificationEvent + from tasks.app_generate.workflow_execute_task import resume_app_execution + + approval = public_form_approval + if scenario == "wrong_code": + approval.otp_code = "wrong-code" + elif scenario == "unknown_challenge": + approval.challenge = approval.challenge.model_copy(update={"challenge_token": "unknown-challenge"}) + enqueue_calls: list[dict[str, object]] = [] + + def enqueue(*, kwargs: dict[str, object]) -> None: + enqueue_calls.append(kwargs) + + monkeypatch.setattr(resume_app_execution, "apply_async", enqueue) + response = approval.submit(proof=scenario != "missing") + assert response.status_code == 400 + assert response.json is not None + assert response.json["code"] == ( + "human_input_challenge_expired" if scenario == "unknown_challenge" else "human_input_invalid_otp" + ) + with context.sessions() as session: + assert session.get_one(HumanInputForm, approval.form_id).status == HumanInputFormStatus.WAITING + assert not any( + event.payload.succeeded + for event in session.scalars(sa.select(HumanInputAuditEvent)) + if isinstance(event.payload, OtpVerificationEvent) + ) + approval.redis.delete.assert_not_called() + assert not enqueue_calls + + +@pytest.mark.parametrize( + ("action", "inputs"), [("reject", {"note": "Approved"}), ("approve", {}), ("approve", {"note": 123})] +) +def test_public_form_invalid_input_keeps_proof_for_retry( + context: Context, + public_form_approval: PublicFormApproval, + monkeypatch: pytest.MonkeyPatch, + action: str, + inputs: dict[str, JsonValue], +) -> None: + from models.human_input_v2 import HumanInputAuditEvent + from tasks.app_generate.workflow_execute_task import resume_app_execution + + approval = public_form_approval + enqueue_calls: list[dict[str, object]] = [] + + def enqueue(*, kwargs: dict[str, object]) -> None: + enqueue_calls.append(kwargs) + + monkeypatch.setattr(resume_app_execution, "apply_async", enqueue) + rejected = approval.submit(action=action, inputs=inputs) + assert rejected.status_code == 400 + with context.sessions() as session: + assert session.get_one(HumanInputForm, approval.form_id).status == HumanInputFormStatus.WAITING + assert not any( + isinstance(event.payload, OtpVerificationEvent) + for event in session.scalars(sa.select(HumanInputAuditEvent)) + ) + approval.redis.delete.assert_not_called() + assert not enqueue_calls + assert approval.submit().status_code == 200 + + +@pytest.mark.parametrize("deadline", ["expiration_time", "global_timeout_deadline"]) +def test_public_form_submission_rejects_expiration( + context: Context, public_form_approval: PublicFormApproval, monkeypatch: pytest.MonkeyPatch, deadline: str +) -> None: + from tasks.app_generate.workflow_execute_task import resume_app_execution + + approval = public_form_approval + with context.sessions.begin() as session: + session.execute( + sa.update(HumanInputForm) + .where(HumanInputForm.id == approval.form_id) + .values({deadline: naive_utc_now() - timedelta(seconds=1)}) + ) + enqueue_calls: list[dict[str, object]] = [] + + def enqueue(*, kwargs: dict[str, object]) -> None: + enqueue_calls.append(kwargs) + + monkeypatch.setattr(resume_app_execution, "apply_async", enqueue) + response = approval.submit() + assert response.status_code == 412 + assert response.json is not None + assert response.json["code"] == "human_input_form_expired" + approval.redis.delete.assert_not_called() + assert not enqueue_calls + + +@pytest.mark.parametrize("scenario", ["running", "missing_pause", "resumed_pause"]) +def test_public_form_submission_requires_active_pause( + context: Context, public_form_approval: PublicFormApproval, monkeypatch: pytest.MonkeyPatch, scenario: str +) -> None: + from tasks.app_generate.workflow_execute_task import resume_app_execution + + with context.sessions.begin() as session: + pause = session.scalar(sa.select(WorkflowPause)) + assert pause is not None + if scenario == "running": + session.get_one(WorkflowRun, _RUN).status = WorkflowExecutionStatus.RUNNING + elif scenario == "missing_pause": + session.delete(pause) + else: + pause.resumed_at = naive_utc_now() + enqueue_calls: list[dict[str, object]] = [] + + def enqueue(*, kwargs: dict[str, object]) -> None: + enqueue_calls.append(kwargs) + + monkeypatch.setattr(resume_app_execution, "apply_async", enqueue) + response = public_form_approval.submit() + assert response.status_code == 409 + assert response.json is not None + assert response.json["code"] == "workflow_not_active" + public_form_approval.redis.delete.assert_not_called() + assert not enqueue_calls + + +def test_public_form_audit_failure_rolls_back_and_keeps_proof( + context: Context, public_form_approval: PublicFormApproval, monkeypatch: pytest.MonkeyPatch +) -> None: + from models.human_input_v2 import HumanInputAuditEvent + from tasks.app_generate.workflow_execute_task import resume_app_execution + + approval = public_form_approval + with context.sessions() as session: + engine = session.get_bind() + + def fail_audit( + _connection: object, _cursor: object, statement: str, _parameters: object, _execution: object, _many: bool + ) -> None: + if statement.startswith("INSERT INTO hitlv2_audit_events"): + raise OSError("Audit storage unavailable") + + enqueue_calls: list[dict[str, object]] = [] + + def enqueue(*, kwargs: dict[str, object]) -> None: + enqueue_calls.append(kwargs) + + monkeypatch.setattr(resume_app_execution, "apply_async", enqueue) + event.listen(engine, "before_cursor_execute", fail_audit) + try: + assert approval.submit().status_code == 500 + finally: + event.remove(engine, "before_cursor_execute", fail_audit) + with context.sessions() as session: + assert session.get_one(HumanInputForm, approval.form_id).status == HumanInputFormStatus.WAITING + assert not any( + isinstance(event.payload, OtpVerificationEvent) + for event in session.scalars(sa.select(HumanInputAuditEvent)) + ) + approval.redis.delete.assert_not_called() + assert not enqueue_calls + assert approval.submit().status_code == 200 + + +@pytest.mark.parametrize("scenario", ["contact_email", "recipient_removed", "delivery_auth", "form_owner"]) +def test_public_form_submission_rechecks_current_authorization( + context: Context, public_form_approval: PublicFormApproval, monkeypatch: pytest.MonkeyPatch, scenario: str +) -> None: + from models.human_input_v2 import HumanInputAuditEvent + from tasks.app_generate.workflow_execute_task import resume_app_execution + + approval = public_form_approval + read = approval.redis.get.side_effect + changed = False + + def revoke_on_read(key: str) -> bytes | None: + nonlocal changed + stored = read(key) + if not changed: + changed = True + with context.sessions.begin() as session: + delivery = session.scalar(sa.select(HumanInputDelivery)) + assert delivery is not None + recipient = session.get_one(HumanInputRecipient, delivery.recipient_id) + match scenario: + case "contact_email": + if recipient.subject_type == "contact": + session.scalars( + sa.select(HumanInputExternalContactProfile) + ).one().email = "changed@example.com" + else: + recipient.subject_value = "changed@example.com" + case "recipient_removed": + session.delete(recipient) + case "delivery_auth": + delivery.auth_type = SubmissionAuthType.CONSOLE + case "form_owner": + session.get_one(HumanInputForm, approval.form_id).app_id = str(uuid4()) + return stored + + approval.redis.get.side_effect = revoke_on_read + enqueue_calls: list[dict[str, object]] = [] + + def enqueue(*, kwargs: dict[str, object]) -> None: + enqueue_calls.append(kwargs) + + monkeypatch.setattr(resume_app_execution, "apply_async", enqueue) + response = approval.submit() + assert response.status_code == (400 if scenario == "contact_email" else 404) + with context.sessions() as session: + assert session.get_one(HumanInputForm, approval.form_id).status == HumanInputFormStatus.WAITING + assert not any( + isinstance(event.payload, OtpVerificationEvent) + for event in session.scalars(sa.select(HumanInputAuditEvent)) + ) + approval.redis.delete.assert_not_called() + assert not enqueue_calls + + +@pytest.mark.parametrize("denial", [None, "recipient", "expired", "token"]) +@pytest.mark.parametrize(("remote_address", "expected_ip"), [("192.0.2.14", "192.0.2.14"), (None, None), ("", None)]) +def test_console_form_read_audits_only_successful_views( + context: Context, + console_approval: tuple[str, str], + denial: str | None, + remote_address: str | None, + expected_ip: str | None, +) -> None: + from functools import partial + from inspect import unwrap + + from flask import Flask + + from controllers.console.human_input_v2.form import ConsoleHumanInputV2FormApi + + form_id, token = console_approval + with context.sessions.begin() as session: + account = session.get_one(Account, _ACCOUNT) + if denial == "recipient": + session.execute(sa.delete(TenantAccountJoin)) + elif denial == "expired": + session.get_one(HumanInputForm, form_id).expiration_time = naive_utc_now() - timedelta(seconds=1) + elif denial == "token": + token = "unknown" + app = Flask(__name__) + app.add_url_rule( + "/form/", + endpoint="view", + view_func=partial(unwrap(ConsoleHumanInputV2FormApi.get), ConsoleHumanInputV2FormApi(), current_user=account), + ) + response = app.test_client().get(f"/form/{token}", environ_overrides={"REMOTE_ADDR": remote_address}) + assert response.status_code == (200 if denial is None else 410 if denial == "expired" else 404) + with context.sessions() as session: + audits = list(session.scalars(sa.select(HumanInputAuditEvent))) + if denial is not None: + assert audits == [] + return + assert len(audits) == 1 + audit = audits[0] + assert audit.tenant_id == _TENANT + assert audit.form_id == form_id + assert audit.created_at is not None + assert isinstance(audit.payload, FormViewEvent) + assert audit.payload.viewer_ip_address == expected_ip + + +@pytest.mark.parametrize(("remote_address", "expected_ip"), [("192.0.2.16", "192.0.2.16"), (None, None), ("", None)]) +def test_otp_access_request_audits_the_http_request_ip( + context: Context, public_form_approval: PublicFormApproval, remote_address: str | None, expected_ip: str | None +) -> None: + from models.human_input_v2 import OtpSendEvent + + approval = public_form_approval + response = approval.client.post( + f"/api/form/human-input/v2/public/{approval.token}/access-request", + environ_overrides={"REMOTE_ADDR": remote_address}, + ) + assert response.status_code == 200 + with context.sessions() as session: + sends = [ + event + for event in session.scalars(sa.select(HumanInputAuditEvent)) + if isinstance(event.payload, OtpSendEvent) + ] + assert len(sends) == 2 + assert all(event.tenant_id == _TENANT and event.form_id == approval.form_id for event in sends) + assert {event.payload.sender_ip_address for event in sends if isinstance(event.payload, OtpSendEvent)} == { + None, + expected_ip, + } diff --git a/api/tests/unit_tests/services/human_input_v2/test_im_channel_reconciliation_service.py b/api/tests/unit_tests/services/human_input_v2/test_im_channel_reconciliation_service.py new file mode 100644 index 00000000000..359567aaad6 --- /dev/null +++ b/api/tests/unit_tests/services/human_input_v2/test_im_channel_reconciliation_service.py @@ -0,0 +1,771 @@ +"""Database-backed behavior tests for Channel-bound reconciliation orchestration.""" + +from __future__ import annotations + +from dataclasses import dataclass, replace +from datetime import datetime +from uuid import UUID + +import pytest +import sqlalchemy as sa +from pytest_mock import MockerFixture +from sqlalchemy.engine import Engine +from sqlalchemy.orm import Session, sessionmaker + +from core.human_input_v2.entities import IMProvider, IMSyncResultType, IMSyncRunStatus +from core.human_input_v2.im_integration.adapters import ( + CredentialTestFailure, + CredentialTestFailureKind, + Directory, + DirectoryEntry, + DirectoryReadFailure, + IMCardEventDecoder, + IMDynamicCardMessaging, + IMEventConsumer, + IMEventStream, + IMMessaging, + IMWebhookHandler, + ProviderUserId, +) +from core.human_input_v2.im_integration.adapters.entities import Avatar +from core.human_input_v2.shared import AccountId, ContactId, IMSyncRunId, TenantId +from extensions.ext_storage import storage +from models.account import Account, TenantAccountJoin, TenantAccountRole +from models.enums import CreatorUserRole +from models.human_input_v2 import ( + HumanInputIMBinding, + HumanInputIMChannel, + HumanInputIMIdentity, + HumanInputIMReconciliationChange, + HumanInputIMSyncResult, + HumanInputIMSyncRun, + IMBindingReconciliationSnapshot, +) +from models.model import UploadFile +from repositories.human_input_v2.channel_credentials import ChannelEncryptedCredentials +from repositories.human_input_v2.contact import Contact, ContactType +from repositories.human_input_v2.im_channel_repository import ( + IMChannel, + IMChannelId, + IMChannelStatus, + WebhookId, +) +from repositories.human_input_v2.im_integration.repository import SQLAlchemyIMControlPlaneRepository +from repositories.human_input_v2.sqlalchemy_im_channel_repository import WorkspaceIMChannelWriter +from services.human_input_v2.im_contact_sync import coordinator as coordinator_module +from services.human_input_v2.im_contact_sync.coordinator import IMChannelReconciliationService, IMSyncRetryableError +from services.human_input_v2.im_contact_sync.service import IMSyncRunNotFoundError + +_NOW = datetime(2026, 8, 11, 8) +_TENANT_ID = TenantId("00000000-0000-0000-0000-000000000101") +_ACCOUNT_ID = AccountId("00000000-0000-0000-0000-000000000201") +_CHANNEL_ID = IMChannelId("00000000-0000-0000-0000-000000000301") +_RUN_ID = IMSyncRunId("00000000-0000-0000-0000-000000000401") +_SECOND_RUN_ID = IMSyncRunId("00000000-0000-0000-0000-000000000402") +_CONTACT_ID = ContactId("00000000-0000-0000-0000-000000000501") + + +def _channel( + *, + channel_id: IMChannelId = _CHANNEL_ID, + config_version: int = 1, + provider: IMProvider = IMProvider.FEISHU, +) -> IMChannel: + return IMChannel( + id=channel_id, + created_at=_NOW, + updated_at=_NOW, + provider=provider, + provider_tenant_id="provider-tenant-1", + encrypted_credentials=ChannelEncryptedCredentials(ciphertext="opaque-ciphertext"), + app_identifier="app-1", + webhook_id=WebhookId("00000000000000000000000000000001"), + config_version=config_version, + status=IMChannelStatus.CONNECTED, + ) + + +class _ContactReader: + def list_contacts(self, page: int, limit: int) -> tuple[Contact, ...]: + assert (page, limit) == (1, 500) + return ( + Contact( + id=_CONTACT_ID, + type=ContactType.WORKSPACE, + name="Reviewer", + email="reviewer@example.com", + avatar_file_id=None, + created_at=_NOW, + ), + ) + + def get_contact(self, contact_id: ContactId) -> Contact | None: + if contact_id != _CONTACT_ID: + return None + return self.list_contacts(1, 500)[0] + + +@dataclass(frozen=True) +class _ContactListReader: + contacts: tuple[Contact, ...] + + def list_contacts(self, page: int, limit: int) -> tuple[Contact, ...]: + return self.contacts[(page - 1) * limit : page * limit] + + def get_contact(self, contact_id: ContactId) -> Contact | None: + return next((contact for contact in self.contacts if contact.id == contact_id), None) + + +class _DirectoryCapability: + def __init__(self, result: Directory | DirectoryReadFailure | Exception) -> None: + self._result = result + + def read_directory(self) -> Directory | DirectoryReadFailure: + if isinstance(self._result, Exception): + raise self._result + return self._result + + +class _Adapter: + provider = IMProvider.FEISHU + + def __init__(self, result: Directory | DirectoryReadFailure | Exception) -> None: + self.directory = _DirectoryCapability(result) + self.close_calls = 0 + self.close_error: Exception | None = None + + @classmethod + def card_event_decoder(cls) -> IMCardEventDecoder | None: + return None + + def test_credentials(self) -> CredentialTestFailure: + return CredentialTestFailure(CredentialTestFailureKind.UNKNOWN, "not exercised") + + @property + def messaging(self) -> IMMessaging: + raise AssertionError("messaging is outside reconciliation") + + @property + def dynamic_card_messaging(self) -> IMDynamicCardMessaging | None: + return None + + def create_webhook_handler(self, consumer: IMEventConsumer) -> IMWebhookHandler | None: + del consumer + return None + + def create_stream_handler(self, consumer: IMEventConsumer) -> IMEventStream | None: + del consumer + return None + + def close(self) -> None: + self.close_calls += 1 + if self.close_error is not None: + raise self.close_error + + +@dataclass(frozen=True) +class _ReconciliationContext: + sessions: sessionmaker[Session] + channel: IMChannel + + def service(self, adapter: _Adapter) -> IMChannelReconciliationService: + return IMChannelReconciliationService( + self.sessions, + self.channel, + lambda _channel: adapter, + lambda _session: _ContactReader(), + tenant_id=_TENANT_ID, + clock=lambda: _NOW, + ) + + +@pytest.fixture +def reconciliation_context(sqlite_engine: Engine) -> _ReconciliationContext: + sessions = sessionmaker(bind=sqlite_engine, expire_on_commit=False) + channel = _channel() + with sessions.begin() as session: + WorkspaceIMChannelWriter(session, _TENANT_ID, _ACCOUNT_ID).create(channel) + decision = SQLAlchemyIMControlPlaneRepository(session, channel).create_or_get_active_run( + sync_run_id=_RUN_ID, + started_by_account_id=_ACCOUNT_ID, + now=_NOW, + ) + assert decision.run is not None + return _ReconciliationContext(sessions, channel) + + +def _successful_directory() -> Directory: + return Directory( + ( + DirectoryEntry( + ProviderUserId("provider-user-1"), + "Reviewer", + "reviewer@example.com", + ), + ) + ) + + +def _create_active_run( + sessions: sessionmaker[Session], + channel: IMChannel, + sync_run_id: IMSyncRunId, +) -> None: + with sessions.begin() as session: + decision = SQLAlchemyIMControlPlaneRepository(session, channel).create_or_get_active_run( + sync_run_id=sync_run_id, + started_by_account_id=_ACCOUNT_ID, + now=_NOW, + ) + assert decision.run is not None + + +@pytest.mark.parametrize("has_initiator", [True, False]) +def test_workspace_sync_stores_avatar_with_initiator_or_owner( + reconciliation_context: _ReconciliationContext, + mocker: MockerFixture, + has_initiator: bool, +) -> None: + save = mocker.patch.object(storage, "save") + with reconciliation_context.sessions.begin() as session: + owner = Account(name="Owner", email="owner@example.com") + other_owner = Account(name="Other owner", email="other-owner@example.com") + session.add_all([owner, other_owner]) + session.add_all( + [ + TenantAccountJoin(tenant_id=str(_TENANT_ID), account_id=owner.id, role=TenantAccountRole.OWNER), + TenantAccountJoin( + tenant_id="00000000-0000-0000-0000-000000000999", + account_id=other_owner.id, + role=TenantAccountRole.OWNER, + ), + ] + ) + if not has_initiator: + session.get_one(HumanInputIMSyncRun, str(_RUN_ID)).started_by_account_id = None + expected_creator = str(_ACCOUNT_ID) if has_initiator else owner.id + entry = replace(_successful_directory().entries[0], avatar=Avatar(mime_type="image/png", data=b"first avatar")) + service = reconciliation_context.service(_Adapter(Directory((entry,)))) + + assert service.reconcile(_RUN_ID).status is IMSyncRunStatus.SUCCEEDED + + with reconciliation_context.sessions() as session: + identity = session.scalars(sa.select(HumanInputIMIdentity)).one() + assert identity.avatar_file_id is not None + first_avatar_id = identity.avatar_file_id + uploaded = session.get_one(UploadFile, first_avatar_id) + assert uploaded.tenant_id == str(_TENANT_ID) + assert uploaded.created_by == expected_creator + assert uploaded.created_by_role is CreatorUserRole.ACCOUNT + assert uploaded.mime_type == "image/png" + assert uploaded.size == len(b"first avatar") + assert uploaded.used is True + assert uploaded.used_by == expected_creator + save.assert_called_once_with(uploaded.key, b"first avatar") + + assert service.reconcile(_RUN_ID).status is IMSyncRunStatus.SUCCEEDED + assert save.call_count == 1 + + _create_active_run(reconciliation_context.sessions, reconciliation_context.channel, _SECOND_RUN_ID) + replacement = replace(entry, avatar=Avatar(mime_type="image/jpeg", data=b"new avatar")) + assert ( + reconciliation_context.service(_Adapter(Directory((replacement,)))).reconcile(_SECOND_RUN_ID).status + is IMSyncRunStatus.SUCCEEDED + ) + with reconciliation_context.sessions() as session: + identity = session.scalars(sa.select(HumanInputIMIdentity)).one() + assert identity.avatar_file_id is not None + assert identity.avatar_file_id != first_avatar_id + assert session.get_one(UploadFile, identity.avatar_file_id).mime_type == "image/jpeg" + + third_run_id = IMSyncRunId("00000000-0000-0000-0000-000000000403") + _create_active_run(reconciliation_context.sessions, reconciliation_context.channel, third_run_id) + assert ( + reconciliation_context.service(_Adapter(_successful_directory())).reconcile(third_run_id).status + is IMSyncRunStatus.SUCCEEDED + ) + with reconciliation_context.sessions() as session: + assert session.scalars(sa.select(HumanInputIMIdentity)).one().avatar_file_id is None + assert session.scalar(sa.select(sa.func.count(UploadFile.id))) == 2 + + +def test_missing_workspace_owner_fails_before_storing_avatars( + reconciliation_context: _ReconciliationContext, + mocker: MockerFixture, +) -> None: + save = mocker.patch.object(storage, "save") + with reconciliation_context.sessions.begin() as session: + session.get_one(HumanInputIMSyncRun, str(_RUN_ID)).started_by_account_id = None + entry = replace(_successful_directory().entries[0], avatar=Avatar(mime_type="image/png", data=b"avatar")) + + run = reconciliation_context.service(_Adapter(Directory((entry,)))).reconcile(_RUN_ID) + + assert run.status is IMSyncRunStatus.FAILED + save.assert_not_called() + with reconciliation_context.sessions() as session: + assert session.scalar(sa.select(sa.func.count(HumanInputIMIdentity.id))) == 0 + + +def test_avatar_storage_failure_does_not_write_identity_or_file_metadata( + reconciliation_context: _ReconciliationContext, + mocker: MockerFixture, +) -> None: + mocker.patch.object(storage, "save", side_effect=OSError("Storage unavailable")) + entry = replace(_successful_directory().entries[0], avatar=Avatar(mime_type="image/png", data=b"avatar")) + + run = reconciliation_context.service(_Adapter(Directory((entry,)))).reconcile(_RUN_ID) + + assert run.status is IMSyncRunStatus.FAILED + with reconciliation_context.sessions() as session: + assert session.scalar(sa.select(sa.func.count(HumanInputIMIdentity.id))) == 0 + assert session.scalar(sa.select(sa.func.count(UploadFile.id))) == 0 + + +def test_success_atomically_persists_current_state_history_results_and_terminal_run( + reconciliation_context: _ReconciliationContext, +) -> None: + adapter = _Adapter(_successful_directory()) + + run = reconciliation_context.service(adapter).reconcile(_RUN_ID) + + assert run.status is IMSyncRunStatus.SUCCEEDED + assert (run.added_count, run.not_matched_count, run.failed_count) == (1, 0, 0) + assert adapter.close_calls == 1 + with reconciliation_context.sessions() as session: + identity = session.scalar(sa.select(HumanInputIMIdentity)) + binding = session.scalar(sa.select(HumanInputIMBinding)) + results = session.scalars(sa.select(HumanInputIMSyncResult)).all() + changes = session.scalars(sa.select(HumanInputIMReconciliationChange)).all() + assert identity is not None + assert identity.channel_id == str(_CHANNEL_ID) + assert binding is not None + assert binding.channel_id == str(_CHANNEL_ID) + assert binding.im_identity_id == identity.id + assert binding.contact_id == str(_CONTACT_ID) + assert [result.result_type for result in results] == [IMSyncResultType.ADDED] + assert len(changes) == 2 + assert {change.subject_kind.value for change in changes} == {"identity", "binding"} + + +@pytest.mark.parametrize( + "directory_result", + [DirectoryReadFailure("provider unavailable"), ConnectionError("provider exploded")], +) +def test_directory_failure_persists_safe_terminal_diagnostic_and_always_closes_adapter( + reconciliation_context: _ReconciliationContext, + directory_result: DirectoryReadFailure | Exception, +) -> None: + adapter = _Adapter(directory_result) + adapter.close_error = RuntimeError("close failed") + + run = reconciliation_context.service(adapter).reconcile(_RUN_ID) + + assert run.status is IMSyncRunStatus.FAILED + assert run.error_code == "directory_read_failed" + assert run.failed_count == 1 + assert adapter.close_calls == 1 + with reconciliation_context.sessions() as session: + result = session.scalar(sa.select(HumanInputIMSyncResult)) + assert result is not None + assert result.result_type is IMSyncResultType.FAILED + assert result.reason_code == "directory_read_failed" + assert result.directory_entry_payload is None + assert session.scalar(sa.select(sa.func.count(HumanInputIMIdentity.id))) == 0 + + +@pytest.mark.parametrize("with_avatar", [False, True]) +def test_apply_failure_rolls_back_current_mutations_before_persisting_failure( + reconciliation_context: _ReconciliationContext, + monkeypatch: pytest.MonkeyPatch, + mocker: MockerFixture, + with_avatar: bool, +) -> None: + mocker.patch.object(storage, "save") + entry = _successful_directory().entries[0] + if with_avatar: + entry = replace(entry, avatar=Avatar(mime_type="image/png", data=b"avatar")) + adapter = _Adapter(Directory((entry,))) + + def fail_change_mapping(_change: object) -> None: + raise RuntimeError("change storage unavailable") + + monkeypatch.setattr(coordinator_module, "reconciliation_change_to_record", fail_change_mapping) + run = reconciliation_context.service(adapter).reconcile(_RUN_ID) + + assert run.status is IMSyncRunStatus.FAILED + assert run.error_code == "unexpected_apply_failure" + with reconciliation_context.sessions() as session: + assert session.scalar(sa.select(sa.func.count(HumanInputIMIdentity.id))) == 0 + assert session.scalar(sa.select(sa.func.count(HumanInputIMBinding.id))) == 0 + assert session.scalar(sa.select(sa.func.count(UploadFile.id))) == 0 + assert session.scalar(sa.select(sa.func.count(HumanInputIMReconciliationChange.id))) == 0 + diagnostic = session.scalar(sa.select(HumanInputIMSyncResult)) + assert diagnostic is not None + assert diagnostic.reason_code == "unexpected_apply_failure" + + +@pytest.mark.parametrize( + ("changed_field", "changed_value"), + [("config_version", 2), ("provider", IMProvider.SLACK)], +) +def test_apply_revalidates_current_channel_revision_and_provider( + reconciliation_context: _ReconciliationContext, + changed_field: str, + changed_value: int | IMProvider, +) -> None: + with reconciliation_context.sessions.begin() as session: + record = session.get_one(HumanInputIMChannel, str(_CHANNEL_ID)) + if changed_field == "config_version": + assert isinstance(changed_value, int) + record.config_version = changed_value + else: + assert isinstance(changed_value, IMProvider) + record.provider = changed_value + adapter = _Adapter(_successful_directory()) + + run = reconciliation_context.service(adapter).reconcile(_RUN_ID) + + assert run.status is IMSyncRunStatus.FAILED + assert run.error_code == "stale_revision" + assert adapter.close_calls == 1 + with reconciliation_context.sessions() as session: + assert session.scalar(sa.select(sa.func.count(HumanInputIMIdentity.id))) == 0 + assert session.scalar(sa.select(sa.func.count(HumanInputIMBinding.id))) == 0 + + +def test_foreign_channel_run_is_rejected_before_provider_or_current_state_access( + reconciliation_context: _ReconciliationContext, +) -> None: + foreign_channel = replace( + reconciliation_context.channel, + id=IMChannelId("00000000-0000-0000-0000-000000000399"), + webhook_id=WebhookId("00000000000000000000000000000002"), + ) + adapter = _Adapter(_successful_directory()) + service = IMChannelReconciliationService( + reconciliation_context.sessions, + foreign_channel, + lambda _channel: adapter, + lambda _session: _ContactReader(), + clock=lambda: _NOW, + ) + + with pytest.raises(IMSyncRunNotFoundError): + service.reconcile(_RUN_ID) + + assert adapter.close_calls == 0 + + +def test_duplicate_terminal_delivery_is_idempotent_without_provider_reentry( + reconciliation_context: _ReconciliationContext, +) -> None: + adapters: list[_Adapter] = [] + + def build_adapter(_channel: IMChannel) -> _Adapter: + adapter = _Adapter(_successful_directory()) + adapters.append(adapter) + return adapter + + service = IMChannelReconciliationService( + reconciliation_context.sessions, + reconciliation_context.channel, + build_adapter, + lambda _session: _ContactReader(), + clock=lambda: _NOW, + ) + + first = service.reconcile(_RUN_ID) + duplicate = service.reconcile(_RUN_ID) + + assert duplicate == first + assert len(adapters) == 1 + assert adapters[0].close_calls == 1 + with reconciliation_context.sessions() as session: + assert session.scalar(sa.select(sa.func.count(HumanInputIMSyncRun.id))) == 1 + assert session.scalar(sa.select(sa.func.count(HumanInputIMSyncResult.id))) == 1 + assert session.scalar(sa.select(sa.func.count(HumanInputIMReconciliationChange.id))) == 2 + + +def test_next_run_replaces_an_absent_identity_binding_and_deletes_the_old_identity( + reconciliation_context: _ReconciliationContext, +) -> None: + service = reconciliation_context.service(_Adapter(_successful_directory())) + assert service.reconcile(_RUN_ID).status is IMSyncRunStatus.SUCCEEDED + with reconciliation_context.sessions() as session: + original_binding_id = session.scalars(sa.select(HumanInputIMBinding.id)).one() + _create_active_run(reconciliation_context.sessions, reconciliation_context.channel, _SECOND_RUN_ID) + replacement_directory = Directory( + ( + DirectoryEntry( + ProviderUserId("provider-user-2"), + "Replacement Reviewer", + "reviewer@example.com", + ), + ) + ) + + run = reconciliation_context.service(_Adapter(replacement_directory)).reconcile(_SECOND_RUN_ID) + + assert run.status is IMSyncRunStatus.SUCCEEDED + assert (run.added_count, run.removed_count) == (1, 1) + with reconciliation_context.sessions() as session: + identities = session.scalars(sa.select(HumanInputIMIdentity)).all() + binding = session.scalar(sa.select(HumanInputIMBinding)) + changes = session.scalars( + sa.select(HumanInputIMReconciliationChange).where( + HumanInputIMReconciliationChange.sync_run_id == str(_SECOND_RUN_ID) + ) + ).all() + assert [identity.provider_user_id for identity in identities] == ["provider-user-2"] + assert binding is not None + assert binding.id != original_binding_id + assert session.get(HumanInputIMBinding, original_binding_id) is None + assert binding.im_identity_id == identities[0].id + assert {change.operation.value for change in changes} == {"create", "replace", "delete"} + replacement = next(change for change in changes if change.operation.value == "replace") + assert isinstance(replacement.before_snapshot, IMBindingReconciliationSnapshot) + assert isinstance(replacement.after_snapshot, IMBindingReconciliationSnapshot) + assert replacement.before_snapshot.binding_id == original_binding_id + assert replacement.after_snapshot.binding_id == binding.id + assert replacement.im_binding_id == binding.id + + +def test_credential_failure_persists_a_safe_terminal_result( + reconciliation_context: _ReconciliationContext, +) -> None: + def fail_credentials(_channel: IMChannel) -> _Adapter: + raise coordinator_module.IMCredentialError("sensitive credential failure") + + service = IMChannelReconciliationService( + reconciliation_context.sessions, + reconciliation_context.channel, + fail_credentials, + lambda _session: _ContactReader(), + clock=lambda: _NOW, + ) + + run = service.reconcile(_RUN_ID) + + assert run.status is IMSyncRunStatus.FAILED + assert run.error_code == "directory_read_failed" + assert "sensitive" not in (run.error_message or "") + + +@pytest.mark.parametrize("replacement_count", [1, 301]) +def test_sync_applies_creates_replacements_deletions_and_preserves_unchanged_bindings( + reconciliation_context: _ReconciliationContext, + sqlite_engine: Engine, + replacement_count: int, +) -> None: + # A database guard also catches UPDATEs issued by ORM autoflush or bulk writes. + with sqlite_engine.begin() as connection: + connection.execute( + sa.text(""" + CREATE TRIGGER reject_binding_update BEFORE UPDATE ON human_input_im_bindings + BEGIN + SELECT RAISE(ABORT, 'IM Bindings are immutable'); + END + """) + ) + contacts = tuple( + Contact( + id=ContactId(str(UUID(int=index + 501))), + type=ContactType.WORKSPACE, + name=f"Reviewer {index}", + email=f"reviewer-{index}@example.com", + avatar_file_id=None, + created_at=_NOW, + ) + for index in range(replacement_count + 3) + ) + reader = _ContactListReader(contacts) + + def service(entries: tuple[DirectoryEntry, ...]) -> IMChannelReconciliationService: + return IMChannelReconciliationService( + reconciliation_context.sessions, + reconciliation_context.channel, + lambda _channel: _Adapter(Directory(entries)), + lambda _session: reader, + clock=lambda: _NOW, + ) + + first_entries = tuple( + DirectoryEntry(ProviderUserId(f"old-{index}"), contact.name, contact.email) + for index, contact in enumerate(contacts[:-1]) + ) + assert service(first_entries).reconcile(_RUN_ID).status is IMSyncRunStatus.SUCCEEDED + with reconciliation_context.sessions() as session: + original_ids = {record.contact_id: record.id for record in session.scalars(sa.select(HumanInputIMBinding))} + _create_active_run(reconciliation_context.sessions, reconciliation_context.channel, _SECOND_RUN_ID) + + # Keep the first assignment, remove the second, replace the middle group, + # and bind the final Contact for the first time. + next_entries = (first_entries[0],) + tuple( + DirectoryEntry(ProviderUserId(f"new-{index}"), contacts[index].name, contacts[index].email) + for index in range(2, len(contacts)) + ) + run = service(next_entries).reconcile(_SECOND_RUN_ID) + + assert run.status is IMSyncRunStatus.SUCCEEDED + assert (run.added_count, run.removed_count, run.skipped_count) == (replacement_count + 1, replacement_count + 1, 1) + with reconciliation_context.sessions() as session: + current = { + binding.contact_id: (binding.id, identity.provider_user_id) + for binding, identity in session.execute( + sa.select(HumanInputIMBinding, HumanInputIMIdentity).join( + HumanInputIMIdentity, HumanInputIMIdentity.id == HumanInputIMBinding.im_identity_id + ) + ) + } + assert current[str(contacts[0].id)] == (original_ids[str(contacts[0].id)], "old-0") + assert str(contacts[1].id) not in current + assert len(current) == replacement_count + 2 + for index in range(2, len(contacts)): + binding_id, provider_user_id = current[str(contacts[index].id)] + assert binding_id != original_ids.get(str(contacts[index].id)) + assert provider_user_id == f"new-{index}" + changes = session.scalars( + sa.select(HumanInputIMReconciliationChange).where( + HumanInputIMReconciliationChange.sync_run_id == str(_SECOND_RUN_ID), + HumanInputIMReconciliationChange.subject_kind == "binding", + ) + ).all() + assert ( + sorted(change.operation.value for change in changes) + == ["create", "delete"] + ["replace"] * replacement_count + ) + results = session.scalars( + sa.select(HumanInputIMSyncResult).where( + HumanInputIMSyncResult.sync_run_id == str(_SECOND_RUN_ID), + HumanInputIMSyncResult.result_type == IMSyncResultType.ADDED, + ) + ).all() + assert {result.im_binding_id for result in results} == {current[str(contact.id)][0] for contact in contacts[2:]} + + +def test_sync_replacement_insert_failure_restores_old_bindings_and_history( + reconciliation_context: _ReconciliationContext, +) -> None: + assert ( + reconciliation_context.service(_Adapter(_successful_directory())).reconcile(_RUN_ID).status + is IMSyncRunStatus.SUCCEEDED + ) + with reconciliation_context.sessions.begin() as session: + original = session.scalars(sa.select(HumanInputIMBinding)).one() + original_id, original_identity_id = original.id, original.im_identity_id + original_change_count = session.scalar(sa.select(sa.func.count(HumanInputIMReconciliationChange.id))) + session.execute( + sa.text(""" + CREATE TRIGGER reject_binding_insert BEFORE INSERT ON human_input_im_bindings + BEGIN + SELECT RAISE(ABORT, 'binding insert unavailable'); + END + """) + ) + _create_active_run(reconciliation_context.sessions, reconciliation_context.channel, _SECOND_RUN_ID) + directory = Directory((DirectoryEntry(ProviderUserId("replacement"), "Reviewer", "reviewer@example.com"),)) + + run = reconciliation_context.service(_Adapter(directory)).reconcile(_SECOND_RUN_ID) + + assert run.status is IMSyncRunStatus.FAILED + with reconciliation_context.sessions() as session: + binding = session.scalars(sa.select(HumanInputIMBinding)).one() + assert (binding.id, binding.im_identity_id) == (original_id, original_identity_id) + assert session.scalars(sa.select(HumanInputIMIdentity.id)).one() == original_identity_id + assert session.scalar(sa.select(sa.func.count(HumanInputIMReconciliationChange.id))) == original_change_count + result = session.scalars( + sa.select(HumanInputIMSyncResult).where(HumanInputIMSyncResult.sync_run_id == str(_SECOND_RUN_ID)) + ).one() + assert result.result_type is IMSyncResultType.FAILED + + +@pytest.mark.parametrize("status", [IMSyncRunStatus.QUEUED, IMSyncRunStatus.RUNNING]) +def test_worker_with_current_channel_finishes_old_revision_without_provider_access( + reconciliation_context: _ReconciliationContext, + status: IMSyncRunStatus, +) -> None: + with reconciliation_context.sessions.begin() as session: + session.get_one(HumanInputIMChannel, str(_CHANNEL_ID)).config_version = 2 + session.get_one(HumanInputIMSyncRun, str(_RUN_ID)).status = status + + def unexpected_adapter(_channel: IMChannel) -> _Adapter: + pytest.fail("stale synchronization must not access the provider") + + service = IMChannelReconciliationService( + reconciliation_context.sessions, + replace(reconciliation_context.channel, config_version=2), + unexpected_adapter, + lambda _session: _ContactReader(), + clock=lambda: _NOW, + ) + run = service.reconcile(_RUN_ID) + assert run.status is IMSyncRunStatus.FAILED + assert run.error_code == "stale_revision" + assert run.finished_at == _NOW + assert service.reconcile(_RUN_ID) == run + with reconciliation_context.sessions() as session: + assert session.scalar(sa.select(sa.func.count(HumanInputIMSyncResult.id))) == 1 + diagnostic = session.scalar(sa.select(HumanInputIMSyncResult)) + assert diagnostic is not None + assert diagnostic.reason_code == "stale_revision" + assert diagnostic.sync_run_id == str(_RUN_ID) + assert diagnostic.channel_id == str(_CHANNEL_ID) + assert session.scalar(sa.select(sa.func.count(HumanInputIMIdentity.id))) == 0 + assert session.scalar(sa.select(sa.func.count(HumanInputIMBinding.id))) == 0 + + +@pytest.mark.parametrize("status", [IMSyncRunStatus.QUEUED, IMSyncRunStatus.RUNNING]) +def test_trigger_retires_abandoned_old_revision_and_creates_current_run( + reconciliation_context: _ReconciliationContext, + status: IMSyncRunStatus, +) -> None: + current_channel = replace(reconciliation_context.channel, config_version=2) + with reconciliation_context.sessions.begin() as session: + session.get_one(HumanInputIMChannel, str(_CHANNEL_ID)).config_version = 2 + session.get_one(HumanInputIMSyncRun, str(_RUN_ID)).status = status + with reconciliation_context.sessions.begin() as session: + repository = SQLAlchemyIMControlPlaneRepository(session, current_channel) + decision = repository.create_or_get_active_run( + sync_run_id=_SECOND_RUN_ID, started_by_account_id=_ACCOUNT_ID, now=_NOW + ) + assert decision.run is not None + assert decision.run.id == _SECOND_RUN_ID + assert decision.run.channel_revision.config_version == 2 + assert decision.run.status is IMSyncRunStatus.QUEUED + with reconciliation_context.sessions() as session: + old_run = session.get_one(HumanInputIMSyncRun, str(_RUN_ID)) + assert old_run.status is IMSyncRunStatus.FAILED + assert old_run.error_code == "stale_revision" + assert session.scalar(sa.select(sa.func.count(HumanInputIMSyncResult.id))) == 1 + + +def test_old_channel_snapshot_cannot_terminate_a_newer_run( + reconciliation_context: _ReconciliationContext, +) -> None: + with reconciliation_context.sessions.begin() as session: + session.get_one(HumanInputIMChannel, str(_CHANNEL_ID)).config_version = 2 + session.get_one(HumanInputIMSyncRun, str(_RUN_ID)).channel_config_version = 2 + with pytest.raises(IMSyncRunNotFoundError): + reconciliation_context.service(_Adapter(_successful_directory())).reconcile(_RUN_ID) + with reconciliation_context.sessions() as session: + assert session.get_one(HumanInputIMSyncRun, str(_RUN_ID)).status is IMSyncRunStatus.QUEUED + assert session.scalar(sa.select(sa.func.count(HumanInputIMSyncResult.id))) == 0 + + +def test_revision_change_during_stale_completion_requires_worker_retry( + reconciliation_context: _ReconciliationContext, +) -> None: + with reconciliation_context.sessions.begin() as session: + session.get_one(HumanInputIMChannel, str(_CHANNEL_ID)).config_version = 3 + service = IMChannelReconciliationService( + reconciliation_context.sessions, + replace(reconciliation_context.channel, config_version=2), + lambda _channel: pytest.fail("stale synchronization must not access the provider"), + lambda _session: _ContactReader(), + clock=lambda: _NOW, + ) + with pytest.raises(IMSyncRetryableError): + service.reconcile(_RUN_ID) + with reconciliation_context.sessions() as session: + assert session.get_one(HumanInputIMSyncRun, str(_RUN_ID)).status is IMSyncRunStatus.QUEUED + assert session.scalar(sa.select(sa.func.count(HumanInputIMSyncResult.id))) == 0 diff --git a/api/tests/unit_tests/services/human_input_v2/test_im_channel_service.py b/api/tests/unit_tests/services/human_input_v2/test_im_channel_service.py new file mode 100644 index 00000000000..240a429e55d --- /dev/null +++ b/api/tests/unit_tests/services/human_input_v2/test_im_channel_service.py @@ -0,0 +1,1092 @@ +"""Observable contracts for the owner-bound IM Channel application service.""" + +from __future__ import annotations + +import json +import re +from base64 import b64decode +from collections.abc import Callable +from dataclasses import dataclass, field, fields, replace +from datetime import datetime +from typing import cast, override + +import pytest +from sqlalchemy.orm import Session, sessionmaker +from yarl import URL + +from configs import dify_config +from configs.deploy import IMEventTransportMode +from core.human_input_v2.entities import IMProvider +from core.human_input_v2.im_integration.adapters.credentials import ( + DingTalkCredentials, + FeishuCredentials, + IMProviderCredentials, + LarkCredentials, + MSTeamsCredentials, + SlackCredentials, + WeComCredentials, +) +from core.human_input_v2.im_integration.adapters.entities import ( + CredentialTestFailure, + CredentialTestFailureKind, + CredentialTestSuccess, +) +from core.human_input_v2.shared import AccountId, TenantId +from libs.key_providers.base import BaseKeyProvider +from repositories.human_input_v2.channel_credentials import ChannelEncryptedCredentials +from repositories.human_input_v2.im_channel_repository import ( + IMChannel, + IMChannelAlreadyConfiguredError, + IMChannelId, + IMChannelStatus, + StaleIMChannelWriteError, + WebhookId, +) +from services.human_input_v2.credential_update import FeishuCredentialsUpdate, SlackCredentialsUpdate, Unchanged +from services.human_input_v2.errors import ( + ChannelAlreadyConfiguredError, + ChannelNotFoundError, + ChannelProviderError, + ProviderConfigurationUpdatedError, + ProviderFailureKind, + ReplacementRequiredError, +) +from services.human_input_v2.im_channel_service import ( + IMChannelService, + IMChannelView, + WorkspaceIMChannelService, +) +from services.human_input_v2.im_credential_codec import IMCredentialCodec + + +@dataclass(frozen=True) +class _CredentialCase: + credentials: IMProviderCredentials + app_identifier: str + + +_CASES = ( + _CredentialCase( + FeishuCredentials( + provider=IMProvider.FEISHU, + app_id="feishu-app", + app_secret="feishu-secret", + verification_token="feishu-verification", + encrypt_key="feishu-encrypt-key", + ), + "feishu-app", + ), + _CredentialCase( + LarkCredentials( + provider=IMProvider.LARK, + app_id="lark-app", + app_secret="lark-secret", + verification_token=None, + encrypt_key=None, + ), + "lark-app", + ), + _CredentialCase( + SlackCredentials( + provider=IMProvider.SLACK, + client_id="slack-client", + client_secret="slack-client-secret", + signing_secret="slack-signing-secret", + bot_token="xoxb-slack-bot-token", + app_token="xapp-slack-app-token", + ), + "slack-client", + ), + _CredentialCase( + DingTalkCredentials( + provider=IMProvider.DING_TALK, + corp_id="ding-corp", + client_id="ding-client", + client_secret="ding-secret", + ), + "ding-client", + ), + _CredentialCase( + MSTeamsCredentials( + provider=IMProvider.MS_TEAMS, + tenant_id="00000000-0000-0000-0000-000000000001", + client_id="00000000-0000-0000-0000-000000000002", + client_secret="teams-secret", + ), + "00000000-0000-0000-0000-000000000002", + ), + _CredentialCase( + WeComCredentials( + provider=IMProvider.WE_COM, + corp_id="wecom-corp", + agent_id="1001", + secret="wecom-secret", + ), + "1001", + ), +) +_SLACK = _CASES[2].credentials +_NOW = datetime(2026, 8, 31, 8, 30) + + +@dataclass +class _State: + current: IMChannel | None = None + writer_error: Exception | None = None + events: list[str] = field(default_factory=list) + last_written: IMChannel | None = None + + +class _Transaction: + def __init__(self, state: _State) -> None: + self._state = state + + def __enter__(self) -> _Transaction: + assert self._state.events is not None + self._state.events.append("transaction_begin") + return self + + def __exit__(self, exc_type: object, exc: object, traceback: object) -> bool: + del exc_type, traceback + assert self._state.events is not None + self._state.events.append("transaction_rollback" if exc is not None else "transaction_commit") + return False + + +class _Session: + def __init__(self, state: _State) -> None: + self._state = state + + def __enter__(self) -> _Session: + assert self._state.events is not None + self._state.events.append("session_open") + return self + + def __exit__(self, exc_type: object, exc: object, traceback: object) -> bool: + del exc_type, exc, traceback + assert self._state.events is not None + self._state.events.append("session_close") + return False + + def begin(self) -> _Transaction: + return _Transaction(self._state) + + +class _SessionFactory: + def __init__(self, state: _State) -> None: + self._state = state + self.calls = 0 + + def __call__(self) -> _Session: + self.calls += 1 + return _Session(self._state) + + +class _Reader: + def __init__(self, state: _State) -> None: + self._state = state + + def get(self) -> IMChannel | None: + assert self._state.events is not None + self._state.events.append("reader_get") + return self._state.current + + +class _Writer: + def __init__(self, state: _State) -> None: + self._state = state + + def _raise_or_record(self, operation: str, channel: IMChannel | None = None) -> None: + assert self._state.events is not None + self._state.events.append(f"writer_{operation}") + if self._state.writer_error is not None: + raise self._state.writer_error + self._state.last_written = channel + + def create(self, channel: IMChannel) -> IMChannel: + self._raise_or_record("create", channel) + self._state.current = channel + return channel + + def update(self, channel: IMChannel, expected_config_version: int) -> IMChannel: + del expected_config_version + self._raise_or_record("update", channel) + self._state.current = channel + return channel + + def replace( + self, + current_channel_id: IMChannelId, + expected_config_version: int, + replacement: IMChannel, + ) -> IMChannel: + del current_channel_id, expected_config_version + self._raise_or_record("replace", replacement) + self._state.current = replacement + return replacement + + def delete(self, channel_id: IMChannelId, expected_config_version: int) -> None: + del channel_id, expected_config_version + self._raise_or_record("delete") + self._state.current = None + + +class _Cipher: + def __init__(self, events: list[str], error: Exception | None = None) -> None: + self.events = events + self.error = error + self.plaintexts: list[str] = [] + self.stored_plaintext: str | None = None + + def encrypt(self, plaintext: str) -> bytes: + self.events.append("encrypt") + if self.error is not None: + raise self.error + self.plaintexts.append(plaintext) + return b"opaque-ciphertext" + + def decrypt(self, ciphertext: bytes) -> str: + del ciphertext + if self.stored_plaintext is not None: + return self.stored_plaintext + raise AssertionError("management projection must not decrypt credentials") + + +class _TestService(IMChannelService): + def __init__( + self, + state: _State, + *, + mode: IMEventTransportMode = IMEventTransportMode.WEBHOOK, + cipher_error: Exception | None = None, + ) -> None: + self.state = state + self.session_factory = _SessionFactory(state) + assert state.events is not None + self.cipher = _Cipher(state.events, cipher_error) + super().__init__(self.session_factory, IMCredentialCodec(self.cipher), mode) # type: ignore[arg-type] + + @override + def _new_reader(self, session: Session) -> _Reader: + del session + assert self.state.events is not None + self.state.events.append("new_reader") + return _Reader(self.state) + + @override + def _new_writer(self, session: Session) -> _Writer: + del session + assert self.state.events is not None + self.state.events.append("new_writer") + return _Writer(self.state) + + +class _Adapter: + def __init__( + self, + events: list[str], + result: CredentialTestSuccess | CredentialTestFailure | Exception, + close_error: Exception | None = None, + ) -> None: + self._events = events + self._result = result + self._close_error = close_error + + def test_credentials(self) -> CredentialTestSuccess | CredentialTestFailure: + self._events.append("test_credentials") + if isinstance(self._result, Exception): + raise self._result + return self._result + + def close(self) -> None: + self._events.append("adapter_close") + if self._close_error is not None: + raise self._close_error + + +def _install_adapter( + monkeypatch: pytest.MonkeyPatch, + state: _State, + *, + provider: IMProvider = IMProvider.SLACK, + provider_tenant_id: str | None = None, + result: CredentialTestSuccess | CredentialTestFailure | Exception | None = None, + build_error: Exception | None = None, + close_error: Exception | None = None, +) -> None: + from services.human_input_v2 import im_channel_service as service_module + + assert state.events is not None + adapter_result = result or CredentialTestSuccess(provider, provider_tenant_id or f"{provider.value}-tenant") + + def build(credentials: IMProviderCredentials) -> _Adapter: + state.events.append("build_adapter") + if build_error is not None: + raise build_error + assert credentials.provider is provider + return _Adapter(state.events, adapter_result, close_error) + + monkeypatch.setattr(service_module, "build_im_provider_adapter", build) + + +def _channel( + *, + provider: IMProvider = IMProvider.SLACK, + channel_id: str = "channel-1", + provider_tenant_id: str | None = None, + webhook_id: str = "persisted-webhook-id", + config_version: int = 3, + status: IMChannelStatus = IMChannelStatus.CONNECTED, + status_reason: str | None = None, +) -> IMChannel: + return IMChannel( + id=IMChannelId(channel_id), + created_at=datetime(2026, 8, 30, 8), + updated_at=datetime(2026, 8, 30, 9), + provider=provider, + provider_tenant_id=provider_tenant_id or f"{provider.value}-tenant", + encrypted_credentials=ChannelEncryptedCredentials(ciphertext="credential-secret-ciphertext"), + app_identifier=f"{provider.value}-application", + webhook_id=WebhookId(webhook_id), + config_version=config_version, + status=status, + status_reason=status_reason, + ) + + +def test_available_provider_inventory_is_complete_and_stable() -> None: + service = _TestService(_State()) + + assert service.available_providers() == ( + IMProvider.SLACK, + IMProvider.FEISHU, + IMProvider.LARK, + IMProvider.DING_TALK, + IMProvider.MS_TEAMS, + IMProvider.WE_COM, + ) + + +def test_candidate_test_uses_no_session_reader_writer_or_cipher(monkeypatch: pytest.MonkeyPatch) -> None: + state = _State() + service = _TestService(state) + _install_adapter(monkeypatch, state) + + assert service.test(_SLACK) is None + + assert service.session_factory.calls == 0 + assert state.events == ["build_adapter", "test_credentials", "adapter_close"] + assert service.cipher.plaintexts == [] + + +@pytest.mark.parametrize( + ("failure_kind", "expected_kind", "expected_description"), + [ + ( + CredentialTestFailureKind.AUTHENTICATION_REJECTED, + ProviderFailureKind.INVALID_CREDENTIALS, + "The submitted credentials are invalid.", + ), + ( + CredentialTestFailureKind.TENANT_ID_UNAVAILABLE, + ProviderFailureKind.CONNECTION_FAILURE, + "The provider connection could not be established.", + ), + ( + CredentialTestFailureKind.UNKNOWN, + ProviderFailureKind.CONNECTION_FAILURE, + "The provider connection could not be established.", + ), + ], +) +def test_classified_provider_failures_map_to_safe_stable_errors( + monkeypatch: pytest.MonkeyPatch, + failure_kind: CredentialTestFailureKind, + expected_kind: ProviderFailureKind, + expected_description: str, +) -> None: + state = _State() + service = _TestService(state) + _install_adapter( + monkeypatch, + state, + result=CredentialTestFailure(failure_kind, "provider-secret-diagnostic"), + ) + + with pytest.raises(ChannelProviderError) as captured: + service.test(_SLACK) + + assert captured.value.kind is expected_kind + assert captured.value.status_description == expected_description + assert "provider-secret-diagnostic" not in repr(captured.value) + assert state.events == ["build_adapter", "test_credentials", "adapter_close"] + + +@pytest.mark.parametrize("operation", ["test", "create"]) +def test_provider_diagnostics_reach_channel_errors_without_raw_provider_reason( + monkeypatch: pytest.MonkeyPatch, operation: str +) -> None: + state = _State() + service = _TestService(state) + _install_adapter( + monkeypatch, + state, + result=CredentialTestFailure( + CredentialTestFailureKind.UNKNOWN, + "raw provider-secret-diagnostic", + "missing_scope", + ("users:read.email",), + ), + ) + + command = service.test if operation == "test" else service.create + with pytest.raises(ChannelProviderError) as captured: + command(_SLACK) + + assert captured.value.status_description == ( + "The provider connection could not be established. API error code: missing_scope. " + "Missing permissions: users:read.email." + ) + assert "provider-secret-diagnostic" not in str(captured.value) + assert state.current is None + + +@pytest.mark.parametrize("failure_point", ["build", "test", "close", "cipher"]) +def test_unclassified_provider_adapter_and_cipher_failures_propagate_unchanged( + monkeypatch: pytest.MonkeyPatch, + failure_point: str, +) -> None: + state = _State() + error = LookupError(f"unclassified-{failure_point}-secret") + service = _TestService(state, cipher_error=error if failure_point == "cipher" else None) + _install_adapter( + monkeypatch, + state, + build_error=error if failure_point == "build" else None, + result=error if failure_point == "test" else None, + close_error=error if failure_point == "close" else None, + ) + + operation: Callable[[], object] = ( + (lambda: service.create(_SLACK)) if failure_point == "cipher" else (lambda: service.test(_SLACK)) + ) + with pytest.raises(LookupError) as captured: + operation() + + assert captured.value is error + assert "transaction_begin" not in state.events + + +@pytest.mark.parametrize("case", _CASES, ids=lambda case: case.credentials.provider.value) +def test_create_prepares_and_persists_complete_channel_for_every_provider( + monkeypatch: pytest.MonkeyPatch, + case: _CredentialCase, +) -> None: + state = _State() + service = _TestService(state) + _install_adapter(monkeypatch, state, provider=case.credentials.provider) + calls = 0 + + def now() -> datetime: + nonlocal calls + calls += 1 + return _NOW + + monkeypatch.setattr(service, "_now", now) + + view = service.create(case.credentials) + + persisted = state.last_written + assert persisted is not None + assert calls == 1 + assert persisted.provider is case.credentials.provider + assert persisted.provider_tenant_id == f"{case.credentials.provider.value}-tenant" + assert persisted.app_identifier == case.app_identifier + assert persisted.config_version == 1 + assert persisted.status is IMChannelStatus.CONNECTED + assert persisted.status_reason is None + assert persisted.created_at == persisted.updated_at == _NOW + assert len(persisted.webhook_id) == 32 + assert re.fullmatch(r"[A-Za-z0-9_-]{32}", persisted.webhook_id) + assert json.loads(service.cipher.plaintexts[0]) == case.credentials.model_dump(mode="json") + assert b64decode(persisted.encrypted_credentials.ciphertext) == b"opaque-ciphertext" + assert view.id == persisted.id + assert view.config_version == 1 + assert "credential-secret" not in repr(view) + assert state.events is not None + assert state.events.index("adapter_close") < state.events.index("encrypt") + assert state.events.index("encrypt") < state.events.index("transaction_begin") + assert state.events.index("writer_create") < state.events.index("transaction_commit") + + +def test_create_rejects_observed_current_before_provider_io(monkeypatch: pytest.MonkeyPatch) -> None: + state = _State(current=_channel()) + service = _TestService(state) + _install_adapter(monkeypatch, state, build_error=AssertionError("provider must not be called")) + + with pytest.raises(ChannelAlreadyConfiguredError): + service.create(_SLACK) + + assert "build_adapter" not in state.events + assert "transaction_begin" not in state.events + + +def test_repository_create_conflict_remains_authoritative(monkeypatch: pytest.MonkeyPatch) -> None: + state = _State(writer_error=IMChannelAlreadyConfiguredError("owner-key-detail")) + service = _TestService(state) + _install_adapter(monkeypatch, state) + + with pytest.raises(ChannelAlreadyConfiguredError) as captured: + service.create(_SLACK) + + assert "owner-key-detail" not in str(captured.value) + assert "transaction_rollback" in state.events + + +def test_current_and_addressed_reads_return_only_credential_free_views() -> None: + state = _State( + current=_channel( + status=IMChannelStatus.CONNECTION_FAILURE, + status_reason="Safe provider connection state.", + ) + ) + service = _TestService(state, mode=IMEventTransportMode.STREAM) + + current = service.get_current() + addressed = service.get(IMChannelId("channel-1")) + + assert current == addressed + assert current is not None + assert current.status_reason == "Safe provider connection state." + assert current.app_identifier == "slack-application" + assert {field.name for field in fields(IMChannelView)} == { + "id", + "created_at", + "updated_at", + "provider", + "status", + "status_reason", + "app_identifier", + "webhook_url", + "config_version", + } + rendered = repr(current) + assert "credential-secret-ciphertext" not in rendered + assert "persisted-webhook-id" not in rendered + assert "owner_key" not in rendered + assert "configured_by" not in rendered + + +def test_missing_current_and_addressed_id_mismatch_are_not_found() -> None: + service = _TestService(_State()) + assert service.get_current() is None + + with pytest.raises(ChannelNotFoundError): + service.get(IMChannelId("missing")) + + service.state.current = _channel(channel_id="current") + with pytest.raises(ChannelNotFoundError): + service.get(IMChannelId("another-owner-or-id")) + + +@pytest.mark.parametrize("provider", list(IMProvider)) +@pytest.mark.parametrize("mode", list(IMEventTransportMode)) +def test_projection_covers_both_modes_and_all_provider_capabilities( + monkeypatch: pytest.MonkeyPatch, + provider: IMProvider, + mode: IMEventTransportMode, +) -> None: + monkeypatch.setattr(dify_config, "TRIGGER_URL", "https://example.com/dify/base/?old=1#fragment") + state = _State(current=_channel(provider=provider, webhook_id="unsafe/id ?#%")) + service = _TestService(state, mode=mode) + + view = service.get_current() + + assert view is not None + if mode is IMEventTransportMode.WEBHOOK and provider.supports_webhook(): + assert view.webhook_url == ("https://example.com/dify/base/callbacks/human-input/v2/im/unsafe%2Fid%20%3F%23%25") + else: + assert view.webhook_url is None + assert service.cipher.plaintexts == [] + assert "build_adapter" not in state.events + + +def test_projection_uses_current_trigger_origin_without_persistence_mutation( + monkeypatch: pytest.MonkeyPatch, +) -> None: + state = _State(current=_channel()) + service = _TestService(state) + monkeypatch.setattr(dify_config, "TRIGGER_URL", "https://first.example/root") + first = service.get_current() + monkeypatch.setattr(dify_config, "TRIGGER_URL", "https://second.example/new-root/") + second = service.get_current() + + assert first is not None + assert second is not None + assert first.webhook_url is not None + assert second.webhook_url is not None + assert URL(first.webhook_url).origin() == URL("https://first.example").origin() + assert URL(second.webhook_url).origin() == URL("https://second.example").origin() + assert first.id == second.id + assert first.config_version == second.config_version == 3 + assert not any(event.startswith("writer_") for event in state.events or []) + + +@pytest.mark.parametrize("failure", ["wrong_id", "stale", "provider"]) +def test_update_local_prechecks_avoid_provider_io( + monkeypatch: pytest.MonkeyPatch, + failure: str, +) -> None: + state = _State(current=_channel()) + service = _TestService(state) + _install_adapter(monkeypatch, state, build_error=AssertionError("provider must not be called")) + channel_id = IMChannelId("wrong") if failure == "wrong_id" else IMChannelId("channel-1") + version = 2 if failure == "stale" else 3 + credentials = _CASES[0].credentials if failure == "provider" else _SLACK + expected_error = { + "wrong_id": ChannelNotFoundError, + "stale": ProviderConfigurationUpdatedError, + "provider": ReplacementRequiredError, + }[failure] + + with pytest.raises(expected_error): + service.update(channel_id, version, credentials) + + assert "build_adapter" not in state.events + assert "transaction_begin" not in state.events + + +def test_update_rejects_changed_provider_tenant_without_mutation(monkeypatch: pytest.MonkeyPatch) -> None: + state = _State(current=_channel(provider_tenant_id="original-tenant")) + service = _TestService(state) + _install_adapter(monkeypatch, state, provider_tenant_id="replacement-tenant") + + with pytest.raises(ReplacementRequiredError): + service.update(IMChannelId("channel-1"), 3, _SLACK) + + assert "test_credentials" in state.events + assert "transaction_begin" not in state.events + assert state.current is not None + assert state.current.provider_tenant_id == "original-tenant" + + +def test_update_preserves_identity_and_advances_version_once(monkeypatch: pytest.MonkeyPatch) -> None: + original = _channel(provider_tenant_id="slack-tenant") + state = _State(current=original) + service = _TestService(state) + _install_adapter(monkeypatch, state, provider_tenant_id="slack-tenant") + calls = 0 + + def now() -> datetime: + nonlocal calls + calls += 1 + return _NOW + + monkeypatch.setattr(service, "_now", now) + + view = service.update(original.id, 3, _SLACK) + + updated = state.last_written + assert updated is not None + assert calls == 1 + assert updated.id == original.id + assert updated.webhook_id == original.webhook_id + assert updated.created_at == original.created_at + assert updated.updated_at == _NOW + assert updated.config_version == 4 + assert updated.status is IMChannelStatus.CONNECTED + assert updated.status_reason is None + assert view.id == original.id + assert view.config_version == 4 + + +@pytest.mark.parametrize("operation", ["update", "replace", "delete"]) +def test_stale_repository_writes_map_without_retry( + monkeypatch: pytest.MonkeyPatch, + operation: str, +) -> None: + state = _State(current=_channel(), writer_error=StaleIMChannelWriteError("database-cas-detail")) + service = _TestService(state) + _install_adapter(monkeypatch, state) + + operations = { + "update": lambda: service.update(IMChannelId("channel-1"), 3, _SLACK), + "replace": lambda: service.replace(IMChannelId("channel-1"), 3, _SLACK), + "delete": lambda: service.delete(IMChannelId("channel-1"), 3), + } + + with pytest.raises(ProviderConfigurationUpdatedError) as captured: + operations[operation]() + + assert "database-cas-detail" not in str(captured.value) + assert (state.events or []).count(f"writer_{operation}") == 1 + assert "transaction_rollback" in state.events + + +def test_replacement_generates_new_identity_webhook_and_initial_version( + monkeypatch: pytest.MonkeyPatch, +) -> None: + original = _channel() + state = _State(current=original) + service = _TestService(state) + _install_adapter(monkeypatch, state) + monkeypatch.setattr(service, "_now", lambda: _NOW) + monkeypatch.setattr(service, "_new_channel_id", lambda: IMChannelId("replacement-channel")) + monkeypatch.setattr(service, "_new_webhook_id", lambda: WebhookId("replacement-webhook-id")) + + view = service.replace(original.id, original.config_version, _SLACK) + + replacement = state.last_written + assert replacement is not None + assert replacement.id == IMChannelId("replacement-channel") + assert replacement.id != original.id + assert replacement.webhook_id == WebhookId("replacement-webhook-id") + assert replacement.webhook_id != original.webhook_id + assert replacement.created_at == replacement.updated_at == _NOW + assert replacement.config_version == 1 + assert view.id == replacement.id + + +@pytest.mark.parametrize("operation", ["replace", "delete"]) +@pytest.mark.parametrize("failure", ["wrong_id", "stale"]) +def test_replacement_and_delete_local_failures_avoid_provider_and_mutation( + monkeypatch: pytest.MonkeyPatch, + operation: str, + failure: str, +) -> None: + state = _State(current=_channel()) + service = _TestService(state) + _install_adapter(monkeypatch, state, build_error=AssertionError("provider must not be called")) + channel_id = IMChannelId("wrong") if failure == "wrong_id" else IMChannelId("channel-1") + version = 2 if failure == "stale" else 3 + expected_error = ChannelNotFoundError if failure == "wrong_id" else ProviderConfigurationUpdatedError + + operations = { + "replace": lambda: service.replace(channel_id, version, _SLACK), + "delete": lambda: service.delete(channel_id, version), + } + + with pytest.raises(expected_error): + operations[operation]() + + assert "build_adapter" not in state.events + assert not any(event.startswith("writer_") for event in state.events or []) + + +def test_delete_performs_no_provider_work_and_returns_after_commit(monkeypatch: pytest.MonkeyPatch) -> None: + state = _State(current=_channel()) + service = _TestService(state) + _install_adapter(monkeypatch, state, build_error=AssertionError("provider must not be called")) + + deleted_id = service.delete(IMChannelId("channel-1"), 3) + + assert deleted_id == IMChannelId("channel-1") + assert "build_adapter" not in state.events + assert state.events is not None + assert state.events.index("writer_delete") < state.events.index("transaction_commit") + assert state.events[-2:] == ["transaction_commit", "session_close"] + + +@pytest.mark.parametrize("operation", ["create", "update", "replace", "delete"]) +def test_unclassified_persistence_failures_propagate_and_roll_back( + monkeypatch: pytest.MonkeyPatch, + operation: str, +) -> None: + error = RuntimeError("sql-owner-key-credential-detail") + state = _State(current=None if operation == "create" else _channel(), writer_error=error) + service = _TestService(state) + _install_adapter(monkeypatch, state) + + operations = { + "create": lambda: service.create(_SLACK), + "update": lambda: service.update(IMChannelId("channel-1"), 3, _SLACK), + "replace": lambda: service.replace(IMChannelId("channel-1"), 3, _SLACK), + "delete": lambda: service.delete(IMChannelId("channel-1"), 3), + } + + with pytest.raises(RuntimeError) as captured: + operations[operation]() + + assert captured.value is error + assert "transaction_rollback" in state.events + assert "transaction_commit" not in state.events + + +def test_workspace_service_binds_reader_writer_cipher_and_actor_to_trusted_context( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from services.human_input_v2 import im_channel_service as service_module + + tenant_id = TenantId("trusted-tenant") + account_id = AccountId("trusted-account") + state = _State() + bindings: list[tuple[object, ...]] = [] + + class BoundCipher(_Cipher): + def __init__(self, key_provider: object, bound_tenant_id: str) -> None: + bindings.append(("cipher", key_provider, bound_tenant_id)) + assert state.events is not None + super().__init__(state.events) + + class BoundReader(_Reader): + def __init__(self, session: object, bound_tenant_id: TenantId) -> None: + bindings.append(("reader", session, bound_tenant_id)) + super().__init__(state) + + class BoundWriter(_Writer): + def __init__( + self, + session: object, + bound_tenant_id: TenantId, + bound_account_id: AccountId, + ) -> None: + bindings.append(("writer", session, bound_tenant_id, bound_account_id)) + super().__init__(state) + + key_provider = object() + monkeypatch.setattr(service_module, "TenantBoundCredentialCipher", BoundCipher) + monkeypatch.setattr(service_module, "WorkspaceIMChannelReader", BoundReader) + monkeypatch.setattr(service_module, "WorkspaceIMChannelWriter", BoundWriter) + monkeypatch.setattr(dify_config, "HUMAN_INPUT_IM_EVENT_TRANSPORT_MODE", IMEventTransportMode.STREAM) + _install_adapter(monkeypatch, state) + service = WorkspaceIMChannelService( + cast(sessionmaker[Session], _SessionFactory(state)), + tenant_id, + account_id, + cast(BaseKeyProvider, key_provider), + ) + + view = service.create(_SLACK) + + assert view.provider is IMProvider.SLACK + assert bindings[0] == ("cipher", key_provider, "trusted-tenant") + assert any(binding[0] == "reader" and binding[2] == tenant_id for binding in bindings) + assert any(binding[0] == "writer" and binding[2:] == (tenant_id, account_id) for binding in bindings) + + +def test_partial_update_merges_then_encrypts_the_entire_credential_document(monkeypatch: pytest.MonkeyPatch) -> None: + credentials = _CASES[0].credentials + state = _State() + service = _TestService(state) + state.current = replace( + _channel(provider=IMProvider.FEISHU), + encrypted_credentials=IMCredentialCodec(service.cipher).seal(credentials), + ) + service.cipher.stored_plaintext = credentials.model_dump_json() + _install_adapter(monkeypatch, state, provider=IMProvider.FEISHU) + edit = FeishuCredentialsUpdate( + app_id=Unchanged.VALUE, + app_secret="rotated-secret", + verification_token=Unchanged.VALUE, + encrypt_key=None, + ) + + view = service.update(state.current.id, state.current.config_version, edit) + + assert json.loads(service.cipher.plaintexts[-1]) == { + "provider": "feishu", + "app_id": "feishu-app", + "app_secret": "rotated-secret", + "verification_token": "feishu-verification", + "encrypt_key": None, + } + assert state.current is not None + assert b64decode(state.current.encrypted_credentials.ciphertext) == b"opaque-ciphertext" + assert view.config_version == 4 + assert state.current.webhook_id == WebhookId("persisted-webhook-id") + assert "rotated-secret" not in repr(view) + + +@pytest.mark.parametrize("stale", [False, True]) +@pytest.mark.parametrize("saved_test", [False, True]) +def test_partial_update_checks_id_and_version_before_decryption_or_provider_io(stale: bool, saved_test: bool) -> None: + current = _channel() + state = _State(current=current) + service = _TestService(state) + edit = SlackCredentialsUpdate(*([Unchanged.VALUE] * 5)) + operation = service.test_saved if saved_test else service.update + + with pytest.raises(ProviderConfigurationUpdatedError if stale else ChannelNotFoundError): + operation( + current.id if stale else IMChannelId("missing"), + current.config_version + 1 if stale else current.config_version, + edit, + ) + + assert state.current == current + assert service.cipher.plaintexts == [] + + +def test_saved_connection_test_uses_merged_credentials_without_writing(monkeypatch: pytest.MonkeyPatch) -> None: + from services.human_input_v2 import im_channel_service as service_module + + credentials = _CASES[0].credentials + state = _State() + service = _TestService(state) + current = replace( + _channel(provider=IMProvider.FEISHU), encrypted_credentials=IMCredentialCodec(service.cipher).seal(credentials) + ) + state.current = current + service.cipher.stored_plaintext = credentials.model_dump_json() + tested: list[IMProviderCredentials] = [] + + def build(candidate: IMProviderCredentials) -> _Adapter: + tested.append(candidate) + return _Adapter(state.events, CredentialTestSuccess(IMProvider.FEISHU, "feishu-tenant")) + + monkeypatch.setattr(service_module, "build_im_provider_adapter", build) + edit = FeishuCredentialsUpdate(Unchanged.VALUE, "temporary-secret", Unchanged.VALUE, None) + service.test_saved(current.id, current.config_version, edit) + + assert len(tested) == 1 + assert isinstance(tested[0], FeishuCredentials) + assert tested[0].app_id == "feishu-app" + assert tested[0].app_secret == "temporary-secret" + assert tested[0].encrypt_key is None + assert state.current == current + assert len(service.cipher.plaintexts) == 1 + + +@pytest.mark.parametrize("case", _CASES, ids=lambda case: case.credentials.provider.value) +def test_all_retained_credentials_round_trip_through_one_encrypted_document( + monkeypatch: pytest.MonkeyPatch, case: _CredentialCase +) -> None: + from pydantic import TypeAdapter + + from controllers.console.human_input_v2.provider_updates import IMProviderCredentialsUpdateInput + from controllers.console.human_input_v2.provider_views import im_credentials_response + + credentials = case.credentials + edit_payload = { + name: value if name == "provider" else {"type": "unchanged"} + for name, value in credentials.model_dump(mode="json").items() + } + edit = TypeAdapter(IMProviderCredentialsUpdateInput).validate_python(edit_payload).to_owner_update() + state = _State() + service = _TestService(state) + current = replace( + _channel(provider=credentials.provider), + encrypted_credentials=IMCredentialCodec(service.cipher).seal(credentials), + ) + state.current = current + service.cipher.stored_plaintext = credentials.model_dump_json() + _install_adapter(monkeypatch, state, provider=credentials.provider) + + service.test_saved(current.id, current.config_version, edit) + assert state.current == current + assert len(service.cipher.plaintexts) == 1 + service.update(current.id, current.config_version, edit) + assert json.loads(service.cipher.plaintexts[-1]) == credentials.model_dump(mode="json") + detail = service.get_detail(current.id) + masked = im_credentials_response(detail.credentials).model_dump(mode="json") + assert masked["provider"] == credentials.provider.value + assert "credential-secret-ciphertext" not in repr(detail) + if isinstance(credentials, SlackCredentials): + assert masked["app_token"] == {"type": "redacted"} + if isinstance(credentials, LarkCredentials): + assert masked["encrypt_key"] is None + assert masked["verification_token"] is None + elif isinstance(credentials, FeishuCredentials): + assert masked["encrypt_key"] == {"type": "redacted"} + assert masked["verification_token"] == {"type": "redacted"} + + +@pytest.mark.parametrize( + ("case", "expected"), + [ + ( + _CASES[0], + { + "provider": "feishu", + "app_id": "feishu-app", + "app_secret": {"type": "redacted"}, + "verification_token": {"type": "redacted"}, + "encrypt_key": {"type": "redacted"}, + }, + ), + ( + _CASES[1], + { + "provider": "lark", + "app_id": "lark-app", + "app_secret": {"type": "redacted"}, + "verification_token": None, + "encrypt_key": None, + }, + ), + ( + _CASES[2], + { + "provider": "slack", + "client_id": "slack-client", + "client_secret": {"type": "redacted"}, + "signing_secret": {"type": "redacted"}, + "bot_token": {"type": "redacted"}, + "app_token": {"type": "redacted"}, + }, + ), + ( + _CASES[3], + { + "provider": "ding_talk", + "corp_id": "ding-corp", + "client_id": "ding-client", + "client_secret": {"type": "redacted"}, + }, + ), + ( + _CASES[4], + { + "provider": "ms_teams", + "tenant_id": "00000000-0000-0000-0000-000000000001", + "client_id": "00000000-0000-0000-0000-000000000002", + "client_secret": {"type": "redacted"}, + }, + ), + ( + _CASES[5], + { + "provider": "we_com", + "corp_id": "wecom-corp", + "agent_id": "1001", + "secret": {"type": "redacted"}, + }, + ), + ], + ids=[case.credentials.provider.value for case in _CASES], +) +def test_credential_details_expose_identifiers_and_redact_only_secrets( + case: _CredentialCase, expected: dict[str, object] +) -> None: + from controllers.console.human_input_v2.provider_views import im_credentials_response + + state = _State() + service = _TestService(state) + current = replace( + _channel(provider=case.credentials.provider), + encrypted_credentials=IMCredentialCodec(service.cipher).seal(case.credentials), + ) + state.current = current + service.cipher.stored_plaintext = case.credentials.model_dump_json() + + detail = service.get_detail(current.id) + + assert im_credentials_response(detail.credentials).model_dump(mode="json") == expected + assert state.current == current + assert state.last_written is None + + +def test_saved_connection_test_rejects_another_provider_before_using_saved_credentials( + monkeypatch: pytest.MonkeyPatch, +) -> None: + current = _channel(provider=IMProvider.SLACK) + state = _State(current=current) + service = _TestService(state) + _install_adapter(monkeypatch, state, build_error=AssertionError("provider must not be called")) + edit = FeishuCredentialsUpdate(Unchanged.VALUE, Unchanged.VALUE, Unchanged.VALUE, Unchanged.VALUE) + + with pytest.raises(ReplacementRequiredError): + service.test_saved(current.id, current.config_version, edit) + + assert state.current == current + assert state.last_written is None diff --git a/api/tests/unit_tests/services/human_input_v2/test_im_contact_sync_composition.py b/api/tests/unit_tests/services/human_input_v2/test_im_contact_sync_composition.py new file mode 100644 index 00000000000..fed4a3dfe8f --- /dev/null +++ b/api/tests/unit_tests/services/human_input_v2/test_im_contact_sync_composition.py @@ -0,0 +1,349 @@ +"""Production composition contracts for Channel-bound IM Contact synchronization.""" + +from __future__ import annotations + +from base64 import b64encode +from collections.abc import Callable +from datetime import datetime + +import pytest +from sqlalchemy import Engine +from sqlalchemy.orm import Session, sessionmaker + +from core.human_input_v2.entities import IMProvider, IMSyncRunStatus +from core.human_input_v2.im_integration.adapters import ( + CredentialTestFailure, + CredentialTestFailureKind, + Directory, + DirectoryReadFailure, + IMCardEventDecoder, + IMDirectory, + IMDynamicCardMessaging, + IMEventConsumer, + IMEventStream, + IMMessaging, + IMProviderAdapter, + IMWebhookHandler, + SlackCredentials, +) +from core.human_input_v2.im_integration.adapters.credentials import FeishuCredentials, IMProviderCredentials +from core.human_input_v2.shared import AccountId, ContactId, DeploymentScope, IMSyncRunId, TenantId, WorkspaceScope +from libs.uuid_utils import uuidv7 +from repositories.human_input_v2.channel_credentials import ChannelEncryptedCredentials +from repositories.human_input_v2.contact import Contact +from repositories.human_input_v2.im_channel_repository import ( + IMChannel, + IMChannelId, + IMChannelStatus, + WebhookId, +) +from repositories.human_input_v2.im_integration.repository import SQLAlchemyIMControlPlaneRepository +from repositories.human_input_v2.sqlalchemy_im_channel_repository import ( + DeploymentIMChannelWriter, + WorkspaceIMChannelWriter, +) +from services.human_input_v2.im_contact_sync import ( + ContactIMBindingService, + IMContactSyncWorker, + IMSyncService, + composition, +) +from services.human_input_v2.im_contact_sync.composition import DifyIMChannelAdapterFactory +from services.human_input_v2.im_credential_codec import IMCredentialError + +_NOW = datetime(2026, 8, 11, 8) +_TENANT_ID = TenantId("00000000-0000-0000-0000-000000000101") +_CHANNEL_ID = IMChannelId("00000000-0000-0000-0000-000000000201") +_RUN_ID = IMSyncRunId("00000000-0000-0000-0000-000000000301") + + +class _FailingDirectory: + def read_directory(self) -> DirectoryReadFailure: + return DirectoryReadFailure("provider unavailable") + + +class _SuccessfulDirectory: + def read_directory(self) -> Directory: + return Directory(()) + + +class _SlackAdapter: + provider = IMProvider.SLACK + directory: IMDirectory = _FailingDirectory() + + @classmethod + def card_event_decoder(cls) -> IMCardEventDecoder | None: + return None + + def test_credentials(self) -> CredentialTestFailure: + return CredentialTestFailure(CredentialTestFailureKind.UNKNOWN, "not exercised") + + @property + def messaging(self) -> IMMessaging: + raise AssertionError("messaging is outside this composition contract") + + @property + def dynamic_card_messaging(self) -> IMDynamicCardMessaging | None: + return None + + def create_webhook_handler(self, consumer: IMEventConsumer) -> IMWebhookHandler | None: + del consumer + return None + + def create_stream_handler(self, consumer: IMEventConsumer) -> IMEventStream | None: + del consumer + return None + + def close(self) -> None: + pass + + +class _SuccessfulSlackAdapter(_SlackAdapter): + directory: IMDirectory = _SuccessfulDirectory() + + +class _BoundedCipher: + def __init__(self, decrypt: Callable[[bytes], str]) -> None: + self._decrypt = decrypt + + def encrypt(self, plaintext: str) -> bytes: + raise AssertionError(f"runtime adapter composition must not encrypt credentials: {plaintext}") + + def decrypt(self, ciphertext: bytes) -> str: + return self._decrypt(ciphertext) + + +class _EmptyContactReader: + def list_contacts(self, page: int, limit: int) -> tuple[()]: + del page, limit + return () + + def get_contact(self, contact_id: ContactId) -> Contact | None: + del contact_id + return None + + +def _envelope(ciphertext: bytes) -> ChannelEncryptedCredentials: + return ChannelEncryptedCredentials(ciphertext=b64encode(ciphertext).decode()) + + +def _channel( + *, + provider: IMProvider = IMProvider.SLACK, + encrypted_credentials: ChannelEncryptedCredentials | None = None, +) -> IMChannel: + return IMChannel( + id=_CHANNEL_ID, + created_at=_NOW, + updated_at=_NOW, + provider=provider, + provider_tenant_id="provider-team-1", + encrypted_credentials=encrypted_credentials or _envelope(b"opaque-slack-ciphertext"), + app_identifier="client-1", + webhook_id=WebhookId("00000000000000000000000000000001"), + config_version=1, + status=IMChannelStatus.CONNECTED, + ) + + +def test_channel_factory_decrypts_then_builds_the_provider_adapter() -> None: + plaintext_credentials = SlackCredentials( + provider=IMProvider.SLACK, + client_id="client-1", + client_secret="plain-client", + signing_secret="plain-signing", + bot_token="xoxb-plain-bot", + app_token=None, + ) + decryptions: list[bytes] = [] + captured_credentials: list[IMProviderCredentials] = [] + + def decrypt(ciphertext: bytes) -> str: + decryptions.append(ciphertext) + return plaintext_credentials.model_dump_json() + + def build_adapter(credentials: IMProviderCredentials) -> IMProviderAdapter: + captured_credentials.append(credentials) + return _SlackAdapter() + + adapter = DifyIMChannelAdapterFactory(_BoundedCipher(decrypt), build_adapter)(_channel()) + + assert isinstance(adapter, _SlackAdapter) + assert decryptions == [b"opaque-slack-ciphertext"] + assert captured_credentials == [plaintext_credentials] + assert isinstance(captured_credentials[0], SlackCredentials) + assert captured_credentials[0].app_token is None + + +@pytest.mark.parametrize("failure", ["provider_mismatch", "decrypt_failure"]) +def test_channel_factory_fails_safely_before_adapter_construction(failure: str) -> None: + adapter_calls: list[IMProviderCredentials] = [] + + def decrypt(_ciphertext: bytes) -> str: + if failure == "decrypt_failure": + raise RuntimeError("raw decrypt failure with plaintext-secret") + return FeishuCredentials( + provider=IMProvider.FEISHU, + app_id="feishu-app", + app_secret="plaintext-secret", + verification_token=None, + encrypt_key=None, + ).model_dump_json() + + def build_adapter(credentials: IMProviderCredentials) -> IMProviderAdapter: + adapter_calls.append(credentials) + return _SlackAdapter() + + with pytest.raises(IMCredentialError) as error_info: + DifyIMChannelAdapterFactory(_BoundedCipher(decrypt), build_adapter)(_channel()) + + assert adapter_calls == [] + assert "plaintext-secret" not in str(error_info.value) + assert "opaque-slack-ciphertext" not in str(error_info.value) + + +def test_application_composition_wires_commands_queries_and_worker(sqlite_engine: Engine) -> None: + sessions = sessionmaker(bind=sqlite_engine, expire_on_commit=False) + + application = composition.build_im_contact_sync_application(session_maker=sessions) + + assert isinstance(application.sync_service, IMSyncService) + assert isinstance(application.binding_service, ContactIMBindingService) + assert isinstance(application.worker, IMContactSyncWorker) + assert "controllers" not in composition.__dict__ + + +def _seed_run( + sessions: sessionmaker[Session], + channel: IMChannel, + owner_scope: WorkspaceScope | DeploymentScope, +) -> None: + with sessions.begin() as session: + if isinstance(owner_scope, WorkspaceScope): + WorkspaceIMChannelWriter(session, owner_scope.id, AccountId(str(uuidv7()))).create(channel) + else: + DeploymentIMChannelWriter(session).create(channel) + decision = SQLAlchemyIMControlPlaneRepository(session, channel).create_or_get_active_run( + sync_run_id=_RUN_ID, + started_by_account_id=None, + now=_NOW, + ) + assert decision.run is not None + + +def test_deployment_reconciliation_fails_safely_without_explicit_adapter_injection(sqlite_engine: Engine) -> None: + sessions = sessionmaker(bind=sqlite_engine, expire_on_commit=False) + _seed_run(sessions, _channel(), DeploymentScope()) + worker = composition.build_im_contact_sync_worker( + session_maker=sessions, + deployment_contact_reader_factory=lambda _session: _EmptyContactReader(), + ) + + run = worker.execute(_RUN_ID, DeploymentScope()) + + assert run.status is IMSyncRunStatus.FAILED + assert run.error_code == "directory_read_failed" + + +def test_explicit_deployment_adapter_is_used(sqlite_engine: Engine) -> None: + sessions = sessionmaker(bind=sqlite_engine, expire_on_commit=False) + _seed_run(sessions, _channel(), DeploymentScope()) + calls: list[IMChannel] = [] + + def deployment_adapter(channel: IMChannel) -> IMProviderAdapter: + calls.append(channel) + return _SlackAdapter() + + worker = composition.build_im_contact_sync_worker( + session_maker=sessions, + deployment_contact_reader_factory=lambda _session: _EmptyContactReader(), + deployment_adapter_factory=deployment_adapter, + ) + + run = worker.execute(_RUN_ID, DeploymentScope()) + + assert run.status is IMSyncRunStatus.FAILED + assert calls == [_channel()] + + +def test_workspace_default_adapter_uses_a_tenant_bound_cipher( + sqlite_engine: Engine, + monkeypatch: pytest.MonkeyPatch, +) -> None: + sessions = sessionmaker(bind=sqlite_engine, expire_on_commit=False) + _seed_run(sessions, _channel(), WorkspaceScope(id=_TENANT_ID)) + captured_tenants: list[str] = [] + captured_ciphers: list[object] = [] + + class CapturedTenantCipher: + def __init__(self, _provider: object, tenant_id: str) -> None: + captured_tenants.append(tenant_id) + + class CapturedAdapterFactory: + def __init__(self, cipher: object) -> None: + captured_ciphers.append(cipher) + + def __call__(self, _channel: IMChannel) -> IMProviderAdapter: + return _SlackAdapter() + + monkeypatch.setattr(composition, "TenantBoundCredentialCipher", CapturedTenantCipher) + monkeypatch.setattr(composition, "DifyIMChannelAdapterFactory", CapturedAdapterFactory) + worker = composition.build_im_contact_sync_worker(session_maker=sessions) + + run = worker.execute(_RUN_ID, WorkspaceScope(id=_TENANT_ID)) + + assert run.status is IMSyncRunStatus.FAILED + assert captured_tenants == [str(_TENANT_ID)] + assert len(captured_ciphers) == 1 + + +def test_workspace_reconciliation_loads_contacts_and_succeeds_with_an_empty_directory( + sqlite_engine: Engine, +) -> None: + sessions = sessionmaker(bind=sqlite_engine, expire_on_commit=False) + _seed_run(sessions, _channel(), WorkspaceScope(id=_TENANT_ID)) + worker = composition.build_im_contact_sync_worker( + session_maker=sessions, + workspace_adapter_factory=lambda _channel: _SuccessfulSlackAdapter(), + ) + + run = worker.execute(_RUN_ID, WorkspaceScope(id=_TENANT_ID)) + + assert run.status is IMSyncRunStatus.SUCCEEDED + + +def test_composition_helpers_reject_unsupported_or_unconfigured_scopes( + sqlite_engine: Engine, + monkeypatch: pytest.MonkeyPatch, +) -> None: + sessions = sessionmaker(bind=sqlite_engine, expire_on_commit=False) + assert isinstance(composition.build_im_sync_service(session_maker=sessions), IMSyncService) + + with sessions() as session, pytest.raises(TypeError, match="unsupported IM Channel owner scope"): + composition._resolve_channel(session, object()) # type: ignore[arg-type] + + monkeypatch.setattr(composition, "_resolve_channel", lambda _session, _scope: None) + create_reconciliation = composition._reconciliation_factory(sessions, None, None, None) + with pytest.raises(composition.IMChannelNotConfiguredError, match="Owner has no IM Channel"): + create_reconciliation(WorkspaceScope(id=_TENANT_ID)) + + +def test_dispatch_preserves_workspace_and_deployment_celery_payloads(monkeypatch: pytest.MonkeyPatch) -> None: + from tasks.im_contact_sync_tasks import reconcile_im_contacts_task + + dispatched: list[tuple[tuple[str, str, str | None], str]] = [] + monkeypatch.setattr( + reconcile_im_contacts_task, + "apply_async", + lambda *, args, queue: dispatched.append((args, queue)), + ) + + composition._dispatch(_RUN_ID, WorkspaceScope(id=_TENANT_ID)) + composition._dispatch(_RUN_ID, DeploymentScope()) + with pytest.raises(TypeError, match="unsupported IM Channel owner scope"): + composition._dispatch(_RUN_ID, object()) # type: ignore[arg-type] + + assert dispatched == [ + ((str(_RUN_ID), "workspace", str(_TENANT_ID)), "human_input_contact_sync"), + ((str(_RUN_ID), "deployment", None), "human_input_contact_sync"), + ] diff --git a/api/tests/unit_tests/services/human_input_v2/test_im_contact_sync_contracts.py b/api/tests/unit_tests/services/human_input_v2/test_im_contact_sync_contracts.py new file mode 100644 index 00000000000..9a0d60626cf --- /dev/null +++ b/api/tests/unit_tests/services/human_input_v2/test_im_contact_sync_contracts.py @@ -0,0 +1,25 @@ +"""Ownership contracts for IM provider adapter consumers.""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import TypeAliasType, get_args, get_origin, get_type_hints + +from core.human_input_v2.im_integration.adapters import IMProviderAdapter +from services.human_input_v2.im_contact_sync import composition, coordinator + + +def _callable_return_type(annotation: object) -> object: + if isinstance(annotation, TypeAliasType): + annotation = annotation.__value__ + assert get_origin(annotation) is Callable + _, return_type = get_args(annotation) + return return_type + + +def test_external_adapter_factories_return_the_complete_adapter_contract() -> None: + channel_factory_return = get_type_hints(composition.DifyIMChannelAdapterFactory.__call__)["return"] + reconciliation_factory = get_type_hints(coordinator.IMChannelReconciliationService.__init__)["adapter_factory"] + + assert channel_factory_return is IMProviderAdapter + assert _callable_return_type(reconciliation_factory) is IMProviderAdapter diff --git a/api/tests/unit_tests/services/human_input_v2/test_im_contact_sync_worker.py b/api/tests/unit_tests/services/human_input_v2/test_im_contact_sync_worker.py new file mode 100644 index 00000000000..2c93dbaad2b --- /dev/null +++ b/api/tests/unit_tests/services/human_input_v2/test_im_contact_sync_worker.py @@ -0,0 +1,143 @@ +"""Idempotent worker and Celery-boundary tests for IM Contact sync.""" + +from __future__ import annotations + +from dataclasses import replace +from datetime import datetime +from typing import override + +import pytest + +from core.human_input_v2.entities import IMProvider, IMSyncRunStatus +from core.human_input_v2.im_integration import IMChannelRevision, IMSyncRun +from core.human_input_v2.shared import IMSyncRunId, TenantId, WorkspaceScope +from services.human_input_v2.im_contact_sync.coordinator import IMChannelReconciliationService, IMSyncRetryableError +from services.human_input_v2.im_contact_sync.service import IMSyncService +from services.human_input_v2.im_contact_sync.worker import IMContactSyncWorker +from tasks import im_contact_sync_tasks + +_NOW = datetime(2026, 8, 11, 8) +_SCOPE = WorkspaceScope(id=TenantId("workspace-1")) + + +def _queued_run() -> IMSyncRun: + return IMSyncRun.create( + sync_run_id=IMSyncRunId("run-1"), + channel_revision=IMChannelRevision("channel-1", 1), + provider=IMProvider.FEISHU, + started_by_account_id=None, + now=_NOW, + ) + + +def _terminal_run(status: IMSyncRunStatus = IMSyncRunStatus.SUCCEEDED) -> IMSyncRun: + return replace( + _queued_run(), + status=status, + started_at=_NOW, + finished_at=_NOW, + updated_at=_NOW, + ) + + +class _SyncService(IMSyncService): + def __init__(self, run: IMSyncRun | None) -> None: + self.run = run + + @override + def load_run(self, sync_run_id: IMSyncRunId) -> IMSyncRun | None: + assert sync_run_id == IMSyncRunId("run-1") + return self.run + + +class _ReconciliationService(IMChannelReconciliationService): + def __init__(self, terminal_run: IMSyncRun) -> None: + self.terminal_run = terminal_run + self.calls: list[IMSyncRunId] = [] + self.error: Exception | None = None + + @override + def reconcile(self, sync_run_id: IMSyncRunId) -> IMSyncRun: + self.calls.append(sync_run_id) + if self.error is not None: + raise self.error + return self.terminal_run + + +def test_terminal_redelivery_returns_persisted_state_without_reconciliation() -> None: + persisted_run = _terminal_run() + sync_service = _SyncService(persisted_run) + reconciliation_service = _ReconciliationService(persisted_run) + worker = IMContactSyncWorker(sync_service, lambda _scope: reconciliation_service) + + result = worker.execute(persisted_run.id, _SCOPE) + + assert result is persisted_run + assert reconciliation_service.calls == [] + + +def test_active_delivery_reconciles_once_and_returns_terminal_state() -> None: + terminal_run = _terminal_run() + sync_service = _SyncService(_queued_run()) + reconciliation_service = _ReconciliationService(terminal_run) + worker = IMContactSyncWorker(sync_service, lambda _scope: reconciliation_service) + + result = worker.execute(IMSyncRunId("run-1"), _SCOPE) + + assert result is terminal_run + assert reconciliation_service.calls == [IMSyncRunId("run-1")] + + +def test_duplicate_delivery_returns_first_persisted_result_without_reconciliation() -> None: + terminal_run = _terminal_run() + sync_service = _SyncService(_queued_run()) + + class PersistingReconciliationService(_ReconciliationService): + @override + def reconcile(self, sync_run_id: IMSyncRunId) -> IMSyncRun: + result = super().reconcile(sync_run_id) + sync_service.run = result + return result + + reconciliation_service = PersistingReconciliationService(terminal_run) + worker = IMContactSyncWorker(sync_service, lambda _scope: reconciliation_service) + + first_result = worker.execute(IMSyncRunId("run-1"), _SCOPE) + duplicate_result = worker.execute(IMSyncRunId("run-1"), _SCOPE) + + assert first_result is terminal_run + assert duplicate_result is terminal_run + assert reconciliation_service.calls == [IMSyncRunId("run-1")] + + +def test_retryable_coordinator_failure_remains_retryable() -> None: + sync_service = _SyncService(_queued_run()) + reconciliation_service = _ReconciliationService(_terminal_run()) + reconciliation_service.error = IMSyncRetryableError("transaction unavailable") + worker = IMContactSyncWorker(sync_service, lambda _scope: reconciliation_service) + + with pytest.raises(IMSyncRetryableError): + worker.execute(IMSyncRunId("run-1"), _SCOPE) + + +def test_celery_entrypoint_reconstructs_scope_and_returns_persisted_terminal_status( + monkeypatch: pytest.MonkeyPatch, +) -> None: + terminal_run = _terminal_run(IMSyncRunStatus.FAILED) + + class _Worker: + def execute(self, sync_run_id: IMSyncRunId, scope: WorkspaceScope) -> IMSyncRun: + assert sync_run_id == IMSyncRunId("run-1") + assert scope == _SCOPE + return terminal_run + + monkeypatch.setattr(im_contact_sync_tasks, "build_im_contact_sync_worker", lambda: _Worker()) + + result = im_contact_sync_tasks.reconcile_im_contacts_task.run("run-1", "workspace", "workspace-1") + + assert result == IMSyncRunStatus.FAILED.value + + +def test_celery_entrypoint_rejects_invalid_scope_payload() -> None: + with pytest.raises(ValueError, match="scope"): + im_contact_sync_tasks.reconcile_im_contacts_task.run("run-1", "workspace", None) diff --git a/api/tests/unit_tests/services/human_input_v2/test_im_provider_adapter.py b/api/tests/unit_tests/services/human_input_v2/test_im_provider_adapter.py new file mode 100644 index 00000000000..042167f182f --- /dev/null +++ b/api/tests/unit_tests/services/human_input_v2/test_im_provider_adapter.py @@ -0,0 +1,129 @@ +"""Contracts for the shared IM provider adapter builder.""" + +from __future__ import annotations + +import inspect + +import pytest + +from core.human_input_v2.entities import IMProvider +from core.human_input_v2.im_integration import adapters +from core.human_input_v2.im_integration.adapters import ( + DingTalkCredentials, + FeishuCredentials, + LarkCredentials, + MSTeamsCredentials, + SlackCredentials, + WeComCredentials, +) +from core.human_input_v2.im_integration.adapters.credentials import IMProviderCredentials +from core.human_input_v2.im_integration.adapters.dingtalk import DingTalkIMProviderAdapter +from core.human_input_v2.im_integration.adapters.factory import build_im_provider_adapter +from core.human_input_v2.im_integration.adapters.feishu_lark import ( + FeishuIMProviderAdapter, + LarkIMProviderAdapter, +) +from core.human_input_v2.im_integration.adapters.ms_teams import MSTeamsIMProviderAdapter +from core.human_input_v2.im_integration.adapters.slack import SlackIMProviderAdapter +from core.human_input_v2.im_integration.adapters.wecom import WeComIMProviderAdapter + +_CASES: tuple[tuple[IMProviderCredentials, type[object]], ...] = ( + ( + SlackCredentials( + provider=IMProvider.SLACK, + client_id="slack-client", + client_secret="slack-client-secret", + signing_secret="slack-signing-secret", + bot_token="xoxb-slack-bot-token", + app_token="xapp-slack-app-token", + ), + SlackIMProviderAdapter, + ), + ( + FeishuCredentials( + provider=IMProvider.FEISHU, + app_id="feishu-app", + app_secret="feishu-secret", + verification_token="feishu-verification", + encrypt_key="feishu-encrypt-key", + ), + FeishuIMProviderAdapter, + ), + ( + LarkCredentials( + provider=IMProvider.LARK, + app_id="lark-app", + app_secret="lark-secret", + verification_token=None, + encrypt_key=None, + ), + LarkIMProviderAdapter, + ), + ( + DingTalkCredentials( + provider=IMProvider.DING_TALK, + corp_id="dingtalk-corp", + client_id="dingtalk-client", + client_secret="dingtalk-secret", + ), + DingTalkIMProviderAdapter, + ), + ( + MSTeamsCredentials( + provider=IMProvider.MS_TEAMS, + tenant_id="00000000-0000-0000-0000-000000000001", + client_id="00000000-0000-0000-0000-000000000002", + client_secret="ms-teams-secret", + ), + MSTeamsIMProviderAdapter, + ), + ( + WeComCredentials( + provider=IMProvider.WE_COM, + corp_id="wecom-corp", + agent_id="1001", + secret="wecom-secret", + ), + WeComIMProviderAdapter, + ), +) + + +@pytest.mark.parametrize(("credentials", "expected_type"), _CASES, ids=lambda value: str(value)) +def test_builder_dispatches_each_resolved_credential_to_its_exact_adapter( + credentials: IMProviderCredentials, + expected_type: type[object], +) -> None: + adapter = build_im_provider_adapter(credentials) + + try: + assert type(adapter) is expected_type + finally: + adapter.close() + + +def test_builder_rejects_an_unsupported_object() -> None: + with pytest.raises(TypeError, match="unsupported IM provider credentials"): + build_im_provider_adapter(object()) # type: ignore[arg-type] + + +def test_builder_accepts_only_already_resolved_credentials() -> None: + signature = inspect.signature(build_im_provider_adapter) + + assert tuple(signature.parameters) == ("credentials",) + assert signature.parameters["credentials"].default is inspect.Parameter.empty + + +def test_adapter_package_does_not_reexport_sdk_bound_implementations() -> None: + hidden_names = { + "DingTalkIMProviderAdapter", + "MSTeamsIMProviderAdapter", + "RedisCacheAccessTokenProvider", + "SlackIMProviderAdapter", + "WeComIMProviderAdapter", + "build_im_provider_adapter", + } + + assert "__getattr__" not in adapters.__dict__ + assert hidden_names.isdisjoint(adapters.__dict__) + assert hidden_names.isdisjoint(adapters.__all__) diff --git a/api/tests/unit_tests/services/human_input_v2/test_im_sync_service.py b/api/tests/unit_tests/services/human_input_v2/test_im_sync_service.py new file mode 100644 index 00000000000..7d962a2c26c --- /dev/null +++ b/api/tests/unit_tests/services/human_input_v2/test_im_sync_service.py @@ -0,0 +1,231 @@ +"""Application behavior tests for Channel-bound IM synchronization commands.""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass, replace +from datetime import datetime + +import pytest +import sqlalchemy as sa +from sqlalchemy.engine import Engine +from sqlalchemy.orm import Session, sessionmaker + +from core.human_input_v2.entities import IMProvider, IMSyncResultType, IMSyncRunStatus +from core.human_input_v2.im_integration import SyncResultFact +from core.human_input_v2.shared import ( + AccountId, + ContactId, + DirectoryScope, + IMBindingId, + IMIdentityId, + IMSyncResultId, + IMSyncRunId, + TenantId, + WorkspaceScope, +) +from models.human_input_v2 import HumanInputIMSyncRun +from repositories.human_input_v2.channel_credentials import ChannelEncryptedCredentials +from repositories.human_input_v2.im_binding_repository import IMBindingAssignment +from repositories.human_input_v2.im_channel_repository import ( + IMChannel, + IMChannelId, + IMChannelStatus, + WebhookId, +) +from repositories.human_input_v2.im_identity_repository import IMIdentityObservation, OpaqueProviderPayload +from repositories.human_input_v2.im_integration.mappers import sync_result_to_record +from repositories.human_input_v2.sqlalchemy_im_binding_repository import SQLAlchemyIMBindingRepository +from repositories.human_input_v2.sqlalchemy_im_channel_repository import ( + WorkspaceIMChannelReader, + WorkspaceIMChannelWriter, +) +from repositories.human_input_v2.sqlalchemy_im_identity_repository import SQLAlchemyIMIdentityRepository +from services.human_input_v2.im_contact_sync.service import ( + IMChannelNotConfiguredError, + IMSyncDispatchUnavailableError, + IMSyncRevisionChangedError, + IMSyncRunNotFoundError, + IMSyncService, +) + +_NOW = datetime(2026, 8, 11, 8) +_TENANT_ID = TenantId("00000000-0000-0000-0000-000000000101") +_ACCOUNT_ID = AccountId("00000000-0000-0000-0000-000000000201") +_CHANNEL_ID = IMChannelId("00000000-0000-0000-0000-000000000301") +_RUN_ID = IMSyncRunId("00000000-0000-0000-0000-000000000401") +_IDENTITY_ID = IMIdentityId("00000000-0000-0000-0000-000000000501") +_SCOPE = WorkspaceScope(id=_TENANT_ID) + + +def _channel(*, config_version: int = 1) -> IMChannel: + return IMChannel( + id=_CHANNEL_ID, + created_at=_NOW, + updated_at=_NOW, + provider=IMProvider.FEISHU, + provider_tenant_id="provider-tenant-1", + encrypted_credentials=ChannelEncryptedCredentials(ciphertext="opaque-ciphertext"), + app_identifier="app-1", + webhook_id=WebhookId("00000000000000000000000000000001"), + config_version=config_version, + status=IMChannelStatus.CONNECTED, + ) + + +@dataclass +class _ServiceContext: + sessions: sessionmaker[Session] + dispatched: list[tuple[IMSyncRunId, DirectoryScope]] + + def service(self, *, channel_override: IMChannel | None = None) -> IMSyncService: + def resolve_channel(session: Session, owner_scope: DirectoryScope) -> IMChannel | None: + assert owner_scope == _SCOPE + if channel_override is not None: + return channel_override + assert isinstance(owner_scope, WorkspaceScope) + return WorkspaceIMChannelReader(session, _TENANT_ID).get() + + return IMSyncService( + self.sessions, + resolve_channel, + lambda run_id, scope: self.dispatched.append((run_id, scope)), + clock=lambda: _NOW, + run_id_factory=lambda: _RUN_ID, + ) + + +@pytest.fixture +def service_context(sqlite_engine: Engine) -> _ServiceContext: + sessions = sessionmaker(bind=sqlite_engine, expire_on_commit=False) + with sessions.begin() as session: + WorkspaceIMChannelWriter(session, _TENANT_ID, _ACCOUNT_ID).create(_channel()) + return _ServiceContext(sessions, []) + + +def test_created_or_existing_active_run_is_serialized_and_dispatched_by_state( + service_context: _ServiceContext, +) -> None: + service = service_context.service() + + created = service.create_or_get_active_run(_SCOPE, _ACCOUNT_ID) + retried = service.create_or_get_active_run(_SCOPE, _ACCOUNT_ID) + + assert created == retried + assert created.channel_revision.channel_id == str(_CHANNEL_ID) + assert service_context.dispatched == [(_RUN_ID, _SCOPE), (_RUN_ID, _SCOPE)] + with service_context.sessions.begin() as session: + session.get_one(HumanInputIMSyncRun, str(_RUN_ID)).status = IMSyncRunStatus.RUNNING + + running = service.create_or_get_active_run(_SCOPE, _ACCOUNT_ID) + assert running.status is IMSyncRunStatus.RUNNING + assert service_context.dispatched == [(_RUN_ID, _SCOPE), (_RUN_ID, _SCOPE)] + with service_context.sessions() as session: + assert session.scalar(sa.select(sa.func.count(HumanInputIMSyncRun.id))) == 1 + + +def test_dispatch_failure_occurs_after_queued_run_is_committed( + service_context: _ServiceContext, + caplog: pytest.LogCaptureFixture, +) -> None: + def fail_dispatch(_run_id: IMSyncRunId, _scope: DirectoryScope) -> None: + raise ConnectionError("queue unavailable") + + def resolve_channel(session: Session, scope: DirectoryScope) -> IMChannel | None: + assert isinstance(scope, WorkspaceScope) + return WorkspaceIMChannelReader(session, scope.id).get() + + service = IMSyncService( + service_context.sessions, + resolve_channel, + fail_dispatch, + clock=lambda: _NOW, + run_id_factory=lambda: _RUN_ID, + ) + + with caplog.at_level(logging.ERROR), pytest.raises(IMSyncDispatchUnavailableError) as error_info: + service.create_or_get_active_run(_SCOPE, _ACCOUNT_ID) + + assert isinstance(error_info.value.__cause__, ConnectionError) + assert str(_RUN_ID) in caplog.text + with service_context.sessions() as session: + assert session.get(HumanInputIMSyncRun, str(_RUN_ID)) is not None + + +def test_missing_or_stale_channel_fails_before_run_creation(service_context: _ServiceContext) -> None: + missing = IMSyncService(service_context.sessions, lambda _session, _scope: None, lambda _id, _scope: None) + with pytest.raises(IMChannelNotConfiguredError): + missing.create_or_get_active_run(_SCOPE, _ACCOUNT_ID) + + with pytest.raises(IMSyncRevisionChangedError): + service_context.service(channel_override=replace(_channel(), config_version=2)).create_or_get_active_run( + _SCOPE, + _ACCOUNT_ID, + ) + + with service_context.sessions() as session: + assert session.scalar(sa.select(sa.func.count(HumanInputIMSyncRun.id))) == 0 + + +def test_latest_result_and_identity_queries_use_current_channel(service_context: _ServiceContext) -> None: + service = service_context.service() + run = service.create_or_get_active_run(_SCOPE, _ACCOUNT_ID) + with service_context.sessions.begin() as session: + identities = SQLAlchemyIMIdentityRepository(session, _CHANNEL_ID) + identities.create( + _IDENTITY_ID, + IMIdentityObservation( + provider_user_id="provider-user-1", + display_name="Reviewer", + email="reviewer@example.com", + raw_payload=OpaqueProviderPayload({}), + sync_run_id=run.id, + observed_at=_NOW, + ), + ) + SQLAlchemyIMBindingRepository(session, _CHANNEL_ID).create( + IMBindingAssignment( + ContactId("00000000-0000-0000-0000-000000000601"), + _IDENTITY_ID, + _NOW, + ), + bound_by_account_id=None, + ) + session.add( + sync_result_to_record( + SyncResultFact( + id=IMSyncResultId("00000000-0000-0000-0000-000000000701"), + channel_id=IMChannelId(str(_CHANNEL_ID)), + sync_run_id=run.id, + operation_key="result:not-matched:provider-user-1", + result_type=IMSyncResultType.NOT_MATCHED, + provider_user_id="provider-user-1", + display_name="Reviewer", + email="reviewer@example.com", + normalized_email=None, + contact_id=None, + identity_id=_IDENTITY_ID, + binding_id=IMBindingId("00000000-0000-0000-0000-000000000801"), + removal_reason=None, + reason_code="contact_not_found", + reason_message=None, + directory_entry_payload=None, + contact_snapshot=None, + identity_snapshot=None, + created_at=_NOW, + updated_at=_NOW, + ) + ) + ) + + assert service.get_latest_run(_SCOPE) == run + result_page = service.list_latest_results(_SCOPE, IMSyncResultType.NOT_MATCHED, page=1, limit=20) + identity_page = service.search_identities(_SCOPE, keyword="REVIEWER", page=1, limit=20) + assert [result.id for result in result_page.items] == [IMSyncResultId("00000000-0000-0000-0000-000000000701")] + assert [identity.id for identity in identity_page.items] == [_IDENTITY_ID] + assert identity_page.items[0].binding_status.value == "bound" + + +def test_latest_queries_fail_closed_without_a_current_run(service_context: _ServiceContext) -> None: + with pytest.raises(IMSyncRunNotFoundError): + service_context.service().get_latest_run(_SCOPE) diff --git a/api/tests/unit_tests/services/human_input_v2/test_im_tenant_credential_cipher.py b/api/tests/unit_tests/services/human_input_v2/test_im_tenant_credential_cipher.py new file mode 100644 index 00000000000..8bb6cb8c01b --- /dev/null +++ b/api/tests/unit_tests/services/human_input_v2/test_im_tenant_credential_cipher.py @@ -0,0 +1,69 @@ +"""Tenant-bound IM credential cipher contracts.""" + +from typing import override + +from libs.key_providers.base import BaseKeyProvider +from services.human_input_v2.im_tenant_credential_cipher import TenantBoundCredentialCipher + + +class _RecordingKeyProvider(BaseKeyProvider): + def __init__(self) -> None: + self.encrypt_calls: list[tuple[str, str]] = [] + self.decrypt_calls: list[tuple[str, bytes]] = [] + + @override + def generate_key_pair(self, tenant_id: str) -> str: + raise AssertionError(f"key provisioning is outside this contract: {tenant_id}") + + @override + def encrypt(self, tenant_id: str, text: str) -> bytes: + self.encrypt_calls.append((tenant_id, text)) + return b"opaque-ciphertext" + + @override + def get_decrypt_decoding(self, tenant_id: str) -> object: + raise AssertionError(f"the wrapper must call the provider decrypt boundary directly: {tenant_id}") + + @override + def decrypt_with_decoding(self, encrypted_text: bytes, decoding: object) -> str: + raise AssertionError(f"the wrapper must not assemble a decoding path: {encrypted_text!r}, {decoding!r}") + + @override + def decrypt(self, tenant_id: str, encrypted_text: bytes) -> str: + self.decrypt_calls.append((tenant_id, encrypted_text)) + return "recovered-plaintext" + + +def test_cipher_captures_one_tenant_and_forwards_exact_encrypt_decrypt_types() -> None: + key_provider = _RecordingKeyProvider() + cipher = TenantBoundCredentialCipher(key_provider, "workspace-1") + ciphertext = b"stored-ciphertext" + + encrypted = cipher.encrypt("serialized-credentials") + decrypted = cipher.decrypt(ciphertext) + + assert encrypted == b"opaque-ciphertext" + assert isinstance(encrypted, bytes) + assert decrypted == "recovered-plaintext" + assert isinstance(decrypted, str) + assert key_provider.encrypt_calls == [("workspace-1", "serialized-credentials")] + assert key_provider.decrypt_calls == [("workspace-1", ciphertext)] + + +def test_cipher_keeps_the_captured_tenant_stable_across_multiple_calls() -> None: + key_provider = _RecordingKeyProvider() + cipher = TenantBoundCredentialCipher(key_provider, "workspace-captured") + + cipher.encrypt("first") + cipher.encrypt("second") + cipher.decrypt(b"first-ciphertext") + cipher.decrypt(b"second-ciphertext") + + assert key_provider.encrypt_calls == [ + ("workspace-captured", "first"), + ("workspace-captured", "second"), + ] + assert key_provider.decrypt_calls == [ + ("workspace-captured", b"first-ciphertext"), + ("workspace-captured", b"second-ciphertext"), + ] diff --git a/api/tests/unit_tests/services/human_input_v2/test_im_webhook_runtime.py b/api/tests/unit_tests/services/human_input_v2/test_im_webhook_runtime.py new file mode 100644 index 00000000000..7f1eb6aa1c7 --- /dev/null +++ b/api/tests/unit_tests/services/human_input_v2/test_im_webhook_runtime.py @@ -0,0 +1,898 @@ +"""HTTP-to-worker behavior for authenticated IM card approvals.""" + +import hashlib +import hmac +import json +import time +from functools import partial +from pathlib import Path +from unittest.mock import AsyncMock, create_autospec +from urllib.parse import urlencode +from uuid import uuid4 + +import pytest +import sqlalchemy as sa +from flask import Blueprint +from sqlalchemy.orm import Session + +from configs import dify_config +from configs.deploy import IMEventTransportMode +from core.human_input_v2.entities import IMProvider +from core.human_input_v2.shared import AccountId, ContactId, IMIdentityId, TenantId +from core.workflow.nodes.human_input.enums import HumanInputFormStatus +from core.workflow.nodes.human_input_v2.entities import HumanInputNodeData +from dify_app import DifyApp +from extensions import ext_blueprints +from extensions.ext_key_provider import KeyProviderManager +from graphon.enums import WorkflowExecutionStatus +from libs.datetime_utils import naive_utc_now +from libs.key_providers.base import BaseKeyProvider +from models.account import Account, AccountStatus, TenantAccountJoin +from models.human_input_v2 import ( + HumanInputAuditEvent, + HumanInputContactIdentity, + HumanInputDelivery, + HumanInputForm, + HumanInputIMBinding, + HumanInputIMBindingWorkspaceOverride, + HumanInputIMChannel, + HumanInputIMIdentity, + HumanInputPlatformContactWorkspaceEntry, + IMCallbackVerificationEvent, + IMIdentityRawPayload, + IMMessageEvent, +) +from models.workflow import WorkflowPause, WorkflowRun +from repositories.human_input_v2.contact import ExternalContact +from repositories.human_input_v2.delivery_repository import ( + DeliveryCreateParams, + IMUserTargetSnapshot, + SubmissionAuthType, +) +from repositories.human_input_v2.im_binding_repository import IMBindingAssignment, IMBindingKind +from repositories.human_input_v2.im_channel_repository import IMChannel, IMChannelId, IMChannelStatus, WebhookId +from repositories.human_input_v2.recipient_repository import ContactRecipientSubject +from repositories.human_input_v2.sqlalchemy_contact_repository import SQLAlchemyContactRepository +from repositories.human_input_v2.sqlalchemy_delivery_repository import SQLAlchemyDeliveryRepository +from repositories.human_input_v2.sqlalchemy_im_binding_repository import SQLAlchemyIMBindingRepository +from repositories.human_input_v2.sqlalchemy_im_channel_repository import WorkspaceIMChannelWriter +from services.human_input_v2.form_service import AccountInitiator, CreatedForm +from services.human_input_v2.im_credential_codec import IMCredentialCodec +from services.human_input_v2.im_tenant_credential_cipher import TenantBoundCredentialCipher +from tasks.im_message_inbox_tasks import process_im_message_inbox_record +from tests.unit_tests.services.human_input_v2.test_form_delivery_runtime import Context + +pytest_plugins = ( + "tests.unit_tests.services.human_input_v2.test_form_delivery_runtime", + "tests.unit_tests.extensions.test_ext_blueprints", +) + +_SIGNING_SECRET = "slack-test-signing-secret" +_WEBHOOK_ID = "0123456789abcdef0123456789abcdef" +_TOKEN = "slack-approval-correlation-token" +_USER = "U012SANITIZED" +_TEAM = "T012SANITIZED" +_PATH = f"/callbacks/human-input/v2/im/{_WEBHOOK_ID}" + + +def _add_identity(session: Session, channel_id: IMChannelId, provider_user_id: str) -> HumanInputIMIdentity: + identity = HumanInputIMIdentity( + channel_id=channel_id, + provider_user_id=provider_user_id, + raw_payload=IMIdentityRawPayload({}), + last_seen_sync_run_id=str(uuid4()), + last_seen_at=naive_utc_now(), + ) + session.add(identity) + return identity + + +@pytest.fixture +def binding_scope() -> str: + return "default" + + +@pytest.fixture +def approval(context: Context, monkeypatch: pytest.MonkeyPatch, binding_scope: str) -> tuple[str, IMChannelId]: + from core.human_input_v2.im_integration.adapters.credentials import SlackCredentials + + key_provider = create_autospec(BaseKeyProvider, instance=True) + key_provider.encrypt.side_effect = lambda _tenant, text: text.encode() + key_provider.decrypt.side_effect = lambda _tenant, ciphertext: ciphertext.decode() + monkeypatch.setattr(KeyProviderManager, "provider", property(lambda _: key_provider)) + monkeypatch.setattr(dify_config, "HUMAN_INPUT_IM_EVENT_TRANSPORT_MODE", IMEventTransportMode.WEBHOOK) + node = HumanInputNodeData.model_validate( + { + "title": "Review", + "form_content": "{{#$output.note#}} {{#$output.decision#}}", + "inputs": [ + {"type": "paragraph", "output_variable_name": "note"}, + { + "type": "select", + "output_variable_name": "decision", + "option_source": {"type": "constant", "value": ["选项 α", "选项 β"]}, + }, + ], + "user_actions": [{"id": "approve", "title": "Approve"}], + "recipients_spec": [{"type": "initiator"}], + "message_template": {"subject": "Review", "body": "Open {{#url#}}"}, + "debug_mode": {"enabled": False, "channels": []}, + } + ) + created = context.prepare(node) + assert isinstance(created, CreatedForm) + initiator = context.execution.initiator + assert isinstance(initiator, AccountInitiator) + now = naive_utc_now() + channel_id = IMChannelId(str(uuid4())) + credentials = SlackCredentials( + provider=IMProvider.SLACK, + client_id="client", + client_secret="secret", + signing_secret=_SIGNING_SECRET, + bot_token="xoxb-test", + ) + cipher = TenantBoundCredentialCipher(key_provider, str(created.form.tenant_id)) + with context.sessions.begin() as session: + WorkspaceIMChannelWriter(session, created.form.tenant_id, initiator.account_id).create( + IMChannel( + id=channel_id, + created_at=now, + updated_at=now, + provider=IMProvider.SLACK, + provider_tenant_id=_TEAM, + encrypted_credentials=IMCredentialCodec(cipher).seal(credentials), + app_identifier="app", + webhook_id=WebhookId(_WEBHOOK_ID), + config_version=1, + status=IMChannelStatus.CONNECTED, + ) + ) + subject = created.recipients[0].subject + assert isinstance(subject, ContactRecipientSubject) + identity = _add_identity(session, channel_id, _USER) + binding = HumanInputIMBinding(channel_id=channel_id, contact_id=subject.contact_id, im_identity_id=identity.id) + session.add(binding) + effective_binding_id = binding.id + if binding_scope != "default": + other_identity = _add_identity(session, channel_id, "default-user") + local_override = binding_scope != "other_workspace_override" + override = HumanInputIMBindingWorkspaceOverride( + channel_id=channel_id, + tenant_id=created.form.tenant_id if local_override else str(uuid4()), + contact_id=subject.contact_id, + im_identity_id=identity.id if local_override else other_identity.id, + ) + session.add(override) + if local_override: + effective_binding_id = override.id + if binding_scope != "workspace_override_same_identity": + binding.im_identity_id = other_identity.id + snapshot = IMUserTargetSnapshot.model_validate( + { + "im_provider": IMProvider.SLACK, + "im_tenant_id": _TEAM, + "im_provider_user_id": _USER, + "im_binding_id": effective_binding_id, + "message_type": "card", + } + ) + SQLAlchemyDeliveryRepository(session).create_delivery( + tenant_id=created.form.tenant_id, + form_id=created.form.id, + params=DeliveryCreateParams( + recipient_id=created.recipients[0].id, + token_hash=hashlib.sha256(_TOKEN.encode()).hexdigest(), + auth_type=SubmissionAuthType.IM, + target_snapshot=snapshot, + ), + ) + run = session.get_one(WorkflowRun, created.form.workflow_run_id) + run.status = WorkflowExecutionStatus.PAUSED + session.add(WorkflowPause(workflow_id=run.workflow_id, workflow_run_id=run.id, state_object_key="checkpoint")) + return created.form.id, channel_id + + +@pytest.fixture +def callback_app( + approval: tuple[str, IMChannelId], fresh_blueprints: dict[str, Blueprint], monkeypatch: pytest.MonkeyPatch +) -> tuple[DifyApp, list[str]]: + _ = approval, fresh_blueprints + app = DifyApp(__name__) + app.config["TESTING"] = True + published = [] + monkeypatch.setattr( + process_im_message_inbox_record, "apply_async", lambda *, args, **_options: published.append(args[0]) + ) + ext_blueprints.init_app(app) + return app, published + + +def _request_body(*, user_id: str = _USER, token: str = _TOKEN) -> bytes: + fixture = Path(__file__).parents[2] / "core/human_input_v2/im_provider/fixtures/slack_block_actions_webhook.json" + callback = json.loads(fixture.read_text().replace("说明📝", "note").replace("选择🌐", "decision")) + callback["user"]["id"] = user_id + metadata = json.dumps({"action_id": "approve", "correlation_token": token, "version": 1}) + callback["actions"][0]["action_id"] = "approve" + callback["actions"][0]["value"] = metadata + callback["message"]["blocks"][-1]["elements"][0]["action_id"] = "approve" + callback["message"]["blocks"][-1]["elements"][0]["value"] = metadata + return urlencode({"payload": json.dumps(callback)}).encode() + + +def _headers(body: bytes, *, secret: str = _SIGNING_SECRET, age: int = 0) -> dict[str, str]: + timestamp = str(int(time.time()) - age) + signature = hmac.new(secret.encode(), b"v0:" + timestamp.encode() + b":" + body, hashlib.sha256).hexdigest() + return { + "Content-Type": "application/x-www-form-urlencoded", + "X-Slack-Request-Timestamp": timestamp, + "X-Slack-Signature": f"v0={signature}", + } + + +@pytest.mark.parametrize("provider", [IMProvider.FEISHU, IMProvider.LARK, IMProvider.SLACK]) +def test_url_verification_returns_provider_response_without_persisting_or_publishing_an_event( + context: Context, approval: tuple[str, IMChannelId], callback_app: tuple[DifyApp, list[str]], provider: IMProvider +) -> None: + from core.human_input_v2.im_integration.adapters.credentials import IMProviderCredentialsAdapter + + _, channel_id = approval + app, published = callback_app + if provider in (IMProvider.FEISHU, IMProvider.LARK): + credentials = IMProviderCredentialsAdapter.validate_python( + {"provider": provider, "app_id": "cli_test_app", "app_secret": "test-app-secret"} + ) + with context.sessions.begin() as session: + channel = session.get_one(HumanInputIMChannel, channel_id) + cipher = TenantBoundCredentialCipher(KeyProviderManager().provider, str(context.execution.tenant_id)) + channel.provider = provider + channel.encrypted_credentials = IMCredentialCodec(cipher).seal(credentials) + + body = json.dumps( + {"type": "url_verification", "challenge": "url-verification-challenge", "token": "test-verification-token"} + ).encode() + headers = _headers(body) if provider is IMProvider.SLACK else {} + headers["Content-Type"] = "application/json" + + response = app.test_client().post(_PATH, data=body, headers=headers) + + assert response.status_code == 200 + assert response.content_type == "application/json" + assert response.get_json() == {"challenge": "url-verification-challenge"} + assert published == [] + with context.sessions() as session: + assert session.scalar(sa.select(sa.func.count()).select_from(IMMessageEvent)) == 0 + + +def test_signed_callback_is_committed_before_ack_then_submitted_by_worker( + context: Context, + approval: tuple[str, IMChannelId], + callback_app: tuple[DifyApp, list[str]], + monkeypatch: pytest.MonkeyPatch, +) -> None: + from tasks.app_generate.workflow_execute_task import resume_app_execution + + form_id, _ = approval + app, published = callback_app + resumed = [] + + def resume(*, kwargs: dict[str, dict[str, str]]) -> None: + with context.sessions() as session: + form = session.get_one(HumanInputForm, form_id) + assert form.status == HumanInputFormStatus.SUBMITTED + assert form.selected_action_id == "approve" + assert form.raw_submission_inputs is not None + assert json.loads(form.raw_submission_inputs) == {"note": "你好,世界 🌍", "decision": "选项 β"} + resumed.append(kwargs["payload"]["workflow_run_id"]) + + monkeypatch.setattr(resume_app_execution, "apply_async", resume) + body = _request_body() + response = app.test_client().post(_PATH, data=body, headers=_headers(body)) + assert response.status_code == 200 + with context.sessions() as session: + inbox = session.get_one(IMMessageEvent, published[0]) + assert inbox.processed_at is None + assert session.get_one(HumanInputForm, form_id).status == HumanInputFormStatus.WAITING + with app.app_context(): + process_im_message_inbox_record.run(published[0]) + process_im_message_inbox_record.run(published[0]) + with context.sessions() as session: + assert session.get_one(IMMessageEvent, published[0]).processed_at is not None + assert resumed == [context.execution.workflow_run_id] + + +@pytest.mark.parametrize("matching_actor", [True, False], ids=["recipient", "another-user"]) +def test_teams_callback_authorizes_the_directory_object_id( + context: Context, + approval: tuple[str, IMChannelId], + callback_app: tuple[DifyApp, list[str]], + monkeypatch: pytest.MonkeyPatch, + matching_actor: bool, +) -> None: + from botframework.connector.auth import ClaimsIdentity, JwtTokenValidation + + from core.human_input_v2.im_integration.adapters.credentials import MSTeamsCredentials + from tasks.app_generate.workflow_execute_task import resume_app_execution + + form_id, channel_id = approval + app, published = callback_app + directory_user_id = "11111111-2222-4333-8444-abcdefabcdef" + provider_tenant_id = "11111111-1111-4111-8111-111111111111" + credentials = MSTeamsCredentials( + provider=IMProvider.MS_TEAMS, + tenant_id=provider_tenant_id, + client_id="22222222-2222-4222-8222-222222222222", + client_secret="test-only-secret", + ) + cipher = TenantBoundCredentialCipher(KeyProviderManager().provider, str(context.execution.tenant_id)) + with context.sessions.begin() as session: + channel = session.get_one(HumanInputIMChannel, channel_id) + channel.provider = IMProvider.MS_TEAMS + channel.provider_tenant_id = provider_tenant_id + channel.encrypted_credentials = IMCredentialCodec(cipher).seal(credentials) + identity = session.scalars( + sa.select(HumanInputIMIdentity).where(HumanInputIMIdentity.channel_id == channel_id) + ).one() + identity.provider_user_id = directory_user_id + delivery = session.scalars(sa.select(HumanInputDelivery).where(HumanInputDelivery.form_id == form_id)).one() + target = delivery.target_snapshot + assert isinstance(target, IMUserTargetSnapshot) + delivery.target_snapshot = IMUserTargetSnapshot( + im_provider=IMProvider.MS_TEAMS, + im_tenant_id=provider_tenant_id, + im_provider_user_id=directory_user_id, + im_binding_id=target.im_binding_id, + message_type="card", + ) + monkeypatch.setattr( + JwtTokenValidation, + "authenticate_request", + AsyncMock(return_value=ClaimsIdentity({"aud": credentials.client_id}, True)), + ) + resumed: list[str] = [] + monkeypatch.setattr( + resume_app_execution, + "apply_async", + lambda *, kwargs: resumed.append(kwargs["payload"]["workflow_run_id"]), + ) + callback = { + "type": "message", + "id": "teams-card-submission", + "serviceUrl": "https://smba.trafficmanager.net/teams/", + "channelId": "msteams", + "conversation": {"tenantId": provider_tenant_id}, + "channelData": {"tenant": {"id": provider_tenant_id}}, + "from": { + "id": "29:test-only-bot-framework-user", + "aadObjectId": directory_user_id.upper() if matching_actor else "33333333-3333-4333-8333-333333333333", + }, + "value": { + "__dify.human_input": {"version": 1, "action_id": "approve", "correlation_token": _TOKEN}, + "note": "Approved from Teams", + "decision": "选项 α", + }, + } + + response = app.test_client().post( + _PATH, + json=callback, + headers={"Authorization": "Bearer test-only-token"}, + ) + assert response.status_code == 200 + with app.app_context(): + process_im_message_inbox_record.run(published[0]) + + with context.sessions() as session: + form = session.get_one(HumanInputForm, form_id) + assert session.get_one(IMMessageEvent, published[0]).processed_at is not None + audit = session.scalars(sa.select(HumanInputAuditEvent)).one() + assert isinstance(audit.payload, IMCallbackVerificationEvent) + assert audit.payload.succeeded is matching_actor + if matching_actor: + assert form.status == HumanInputFormStatus.SUBMITTED + assert form.selected_action_id == "approve" + assert form.raw_submission_inputs is not None + assert json.loads(form.raw_submission_inputs) == {"note": "Approved from Teams", "decision": "选项 α"} + else: + assert form.status == HumanInputFormStatus.WAITING + assert form.submitted_at is None + assert audit.payload.reason == "Callback user does not match the delivery target user." + assert resumed == ([context.execution.workflow_run_id] if matching_actor else []) + + +@pytest.mark.parametrize(("secret", "age"), [("wrong-signing-secret", 0), (_SIGNING_SECRET, 600)]) +def test_invalid_signature_or_old_request_cannot_enter_inbox( + context: Context, callback_app: tuple[DifyApp, list[str]], secret: str, age: int +) -> None: + app, published = callback_app + body = _request_body() + response = app.test_client().post(_PATH, data=body, headers=_headers(body, secret=secret, age=age)) + assert response.status_code == 401 + assert published == [] + with context.sessions() as session: + assert session.scalar(sa.select(sa.func.count()).select_from(IMMessageEvent)) == 0 + + +@pytest.mark.parametrize(("user_id", "token"), [("another-user", _TOKEN), (_USER, "unknown-token")]) +def test_authenticated_callback_cannot_submit_another_recipient( + context: Context, + approval: tuple[str, IMChannelId], + callback_app: tuple[DifyApp, list[str]], + user_id: str, + token: str, +) -> None: + form_id, _ = approval + app, published = callback_app + body = _request_body(user_id=user_id, token=token) + assert app.test_client().post(_PATH, data=body, headers=_headers(body)).status_code == 200 + with app.app_context(): + process_im_message_inbox_record.run(published[0]) + with context.sessions() as session: + assert session.get_one(HumanInputForm, form_id).status == HumanInputFormStatus.WAITING + assert session.get_one(IMMessageEvent, published[0]).processed_at is not None + + +def test_oversized_callback_and_unknown_route_fail_before_acceptance( + callback_app: tuple[DifyApp, list[str]], monkeypatch: pytest.MonkeyPatch +) -> None: + app, published = callback_app + monkeypatch.setattr(dify_config, "WEBHOOK_REQUEST_BODY_MAX_SIZE", 16) + body = _request_body() + assert app.test_client().post(_PATH, data=body, headers=_headers(body)).status_code == 413 + assert app.test_client().post("/callbacks/human-input/v2/im/invalid", data=b"").status_code == 404 + assert published == [] + + +@pytest.mark.usefixtures("approval", "fresh_blueprints") +def test_stream_mode_does_not_register_webhook_route(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(dify_config, "HUMAN_INPUT_IM_EVENT_TRANSPORT_MODE", IMEventTransportMode.STREAM) + app = DifyApp(__name__) + ext_blueprints.init_app(app) + assert app.test_client().post(_PATH, data=b"").status_code == 404 + + +def test_worker_redelivery_repairs_resume_publication_without_resubmitting( + context: Context, + approval: tuple[str, IMChannelId], + callback_app: tuple[DifyApp, list[str]], + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + from tasks.app_generate.workflow_execute_task import resume_app_execution + from tasks.im_message_inbox_tasks import IMInboxTaskRetryError + + form_id, _ = approval + app, published = callback_app + publications: list[dict[str, object]] = [] + + def publication(*, kwargs: dict[str, object]) -> None: + publications.append(kwargs) + if len(publications) == 1: + raise ConnectionError("sensitive-broker-detail") + + monkeypatch.setattr(resume_app_execution, "apply_async", publication) + body = _request_body() + assert app.test_client().post(_PATH, data=body, headers=_headers(body)).status_code == 200 + with app.app_context(), pytest.raises(IMInboxTaskRetryError): + process_im_message_inbox_record.run(published[0]) + with context.sessions() as session: + committed_at = session.get_one(HumanInputForm, form_id).submitted_at + assert committed_at is not None + assert session.get_one(IMMessageEvent, published[0]).processed_at is None + with app.app_context(): + process_im_message_inbox_record.run(published[0]) + with context.sessions() as session: + assert session.get_one(HumanInputForm, form_id).submitted_at == committed_at + assert session.get_one(IMMessageEvent, published[0]).processed_at is not None + assert "sensitive-broker-detail" not in caplog.text + + +@pytest.mark.parametrize( + ("mutation", "expected_reason"), + [ + ("deleted", "Callback channel was not found."), + ("another_workspace", "Channel and delivery belong to different workspaces."), + ], +) +def test_worker_rechecks_current_channel_ownership( + context: Context, + approval: tuple[str, IMChannelId], + callback_app: tuple[DifyApp, list[str]], + mutation: str, + expected_reason: str, + caplog: pytest.LogCaptureFixture, +) -> None: + form_id, channel_id = approval + app, published = callback_app + body = _request_body() + assert app.test_client().post(_PATH, data=body, headers=_headers(body)).status_code == 200 + with context.sessions.begin() as session: + channel = session.get_one(HumanInputIMChannel, channel_id) + if mutation == "deleted": + session.delete(channel) + else: + channel.owner_key = f"workspace:{uuid4()}" + with app.app_context(): + process_im_message_inbox_record.run(published[0]) + with context.sessions() as session: + assert session.get_one(HumanInputForm, form_id).status == HumanInputFormStatus.WAITING + assert session.get_one(IMMessageEvent, published[0]).processed_at is not None + assert session.scalar(sa.select(sa.func.count()).select_from(HumanInputAuditEvent)) == 0 + assert expected_reason in caplog.text + assert f"channel_id={channel_id}" in caplog.text + assert "tenant_id=None form_id=None" in caplog.text + assert _TOKEN not in caplog.text + assert _SIGNING_SECRET not in caplog.text + + +@pytest.mark.parametrize( + ("binding_scope", "mutation", "expected_reason"), + [ + ("default", "contact_deleted", "Callback contact is no longer available in the workspace."), + ("default", "membership_removed", "Callback contact is no longer available in the workspace."), + ("default", "account_disabled", "Callback contact is no longer available in the workspace."), + ("default", "binding_removed", "Callback contact has no effective IM binding for the channel."), + ("default", "binding_replaced", "Callback contact binding has changed since delivery."), + ("default", "binding_round_trip", "Callback contact binding has changed since delivery."), + ("default", "binding_recreated", "Callback contact binding has changed since delivery."), + ("default", "binding_transferred", "Callback contact has no effective IM binding for the channel."), + ("default", "binding_transferred_back", "Callback contact binding has changed since delivery."), + ("default", "identity_removed", "Callback binding identity was not found in the channel."), + ("default", "identity_channel_changed", "Callback binding identity was not found in the channel."), + ("default", "identity_user_changed", "Callback user does not match the bound identity user."), + ("default", "override_added", "Callback contact binding has changed since delivery."), + ("default", "override_added_same_identity", "Callback contact binding has changed since delivery."), + ("workspace_override", "override_replaced", "Callback contact binding has changed since delivery."), + ("workspace_override", "override_round_trip", "Callback contact binding has changed since delivery."), + ("workspace_override", "override_reset", "Callback contact binding has changed since delivery."), + ("workspace_override_same_identity", "override_reset", "Callback contact binding has changed since delivery."), + ], +) +def test_worker_rejects_revoked_im_authorization( + context: Context, + approval: tuple[str, IMChannelId], + callback_app: tuple[DifyApp, list[str]], + monkeypatch: pytest.MonkeyPatch, + mutation: str, + expected_reason: str, + caplog: pytest.LogCaptureFixture, +) -> None: + from tasks.app_generate.workflow_execute_task import resume_app_execution + from tasks.human_input_im_card_tasks import update_human_input_im_card_task + + form_id, channel_id = approval + app, published = callback_app + initiator = context.execution.initiator + assert isinstance(initiator, AccountInitiator) + with context.sessions.begin() as session: + binding = session.scalars(sa.select(HumanInputIMBinding)).one() + identity_id = session.scalars( + sa.select(HumanInputIMIdentity.id).where(HumanInputIMIdentity.provider_user_id == _USER) + ).one() + contact_id = binding.contact_id + replacement = _add_identity(session, channel_id, "replacement-user") + replacement_id = replacement.id + + body = _request_body() + assert app.test_client().post(_PATH, data=body, headers=_headers(body)).status_code == 200 + with context.sessions.begin() as session: + binding = session.scalars(sa.select(HumanInputIMBinding)).one() + match mutation: + case "contact_deleted": + session.delete(session.get_one(HumanInputContactIdentity, contact_id)) + case "membership_removed": + session.execute( + sa.delete(TenantAccountJoin).where( + TenantAccountJoin.tenant_id == context.execution.tenant_id, + TenantAccountJoin.account_id == initiator.account_id, + ) + ) + case "account_disabled": + session.get_one(Account, initiator.account_id).status = AccountStatus.BANNED + case "binding_removed": + session.delete(binding) + case "binding_replaced" | "binding_round_trip" | "override_replaced" | "override_round_trip": + repository = SQLAlchemyIMBindingRepository(session, channel_id) + assign = ( + repository.replace + if mutation in ("binding_replaced", "binding_round_trip") + else partial(repository.set_workspace_override, TenantId(context.execution.tenant_id)) + ) + assign( + IMBindingAssignment(ContactId(contact_id), IMIdentityId(replacement_id), naive_utc_now()), + bound_by_account_id=None, + ) + if mutation in ("binding_round_trip", "override_round_trip"): + assign( + IMBindingAssignment(ContactId(contact_id), IMIdentityId(identity_id), naive_utc_now()), + bound_by_account_id=None, + ) + case "binding_recreated": + session.delete(binding) + session.flush() + session.add( + HumanInputIMBinding(channel_id=channel_id, contact_id=contact_id, im_identity_id=identity_id) + ) + case "binding_transferred" | "binding_transferred_back": + target_contact_id = ContactId(str(uuid4())) + SQLAlchemyContactRepository(session).save_external_contact( + TenantId(context.execution.tenant_id), + ExternalContact(target_contact_id, "Other reviewer", "other@example.com", None, naive_utc_now()), + ) + repository = SQLAlchemyIMBindingRepository(session, channel_id) + repository.replace( + IMBindingAssignment(target_contact_id, IMIdentityId(identity_id), naive_utc_now()), + bound_by_account_id=None, + ) + if mutation == "binding_transferred_back": + repository.replace( + IMBindingAssignment(ContactId(contact_id), IMIdentityId(identity_id), naive_utc_now()), + bound_by_account_id=None, + ) + case "identity_removed": + session.delete(session.get_one(HumanInputIMIdentity, identity_id)) + case "identity_channel_changed": + session.get_one(HumanInputIMIdentity, identity_id).channel_id = str(uuid4()) + case "identity_user_changed": + session.get_one(HumanInputIMIdentity, identity_id).provider_user_id = "changed-user" + case "override_added" | "override_added_same_identity": + session.add( + HumanInputIMBindingWorkspaceOverride( + channel_id=channel_id, + tenant_id=context.execution.tenant_id, + contact_id=contact_id, + im_identity_id=identity_id if mutation == "override_added_same_identity" else replacement_id, + ) + ) + case "override_reset": + session.delete(session.scalars(sa.select(HumanInputIMBindingWorkspaceOverride)).one()) + + resume_publications: list[dict[str, object]] = [] + card_publications: list[dict[str, object]] = [] + + def resumed(*, kwargs: dict[str, object]) -> None: + resume_publications.append(kwargs) + + def card_updates(*, kwargs: dict[str, object]) -> None: + card_publications.append(kwargs) + + monkeypatch.setattr(resume_app_execution, "apply_async", resumed) + monkeypatch.setattr(update_human_input_im_card_task, "apply_async", card_updates) + with app.app_context(): + process_im_message_inbox_record.run(published[0]) + with context.sessions() as session: + form = session.get_one(HumanInputForm, form_id) + assert form.status == HumanInputFormStatus.WAITING + assert form.submitted_at is None + assert form.selected_action_id is None + assert form.raw_submission_inputs is None + assert session.get_one(WorkflowRun, form.workflow_run_id).status == WorkflowExecutionStatus.PAUSED + assert session.get_one(IMMessageEvent, published[0]).processed_at is not None + audit = session.scalars(sa.select(HumanInputAuditEvent)).one() + assert audit.tenant_id == context.execution.tenant_id + assert audit.form_id == form_id + assert audit.payload == IMCallbackVerificationEvent( + succeeded=False, reason=expected_reason, request_ip_address="127.0.0.1" + ) + assert expected_reason in caplog.text + assert f"tenant_id={context.execution.tenant_id} form_id={form_id}" in caplog.text + assert f"channel_id={channel_id}" in caplog.text + assert _TOKEN not in caplog.text + assert _SIGNING_SECRET not in caplog.text + assert not resume_publications + assert not card_publications + + +@pytest.mark.parametrize("binding_scope", ["default", "workspace_override", "other_workspace_override"]) +@pytest.mark.parametrize("retry_assignment", [False, True]) +def test_worker_accepts_current_binding_matching_frozen_target( + context: Context, + approval: tuple[str, IMChannelId], + callback_app: tuple[DifyApp, list[str]], + monkeypatch: pytest.MonkeyPatch, + binding_scope: str, + retry_assignment: bool, +) -> None: + from tasks.app_generate.workflow_execute_task import resume_app_execution + + form_id, channel_id = approval + app, published = callback_app + with context.sessions.begin() as session: + binding = session.scalars(sa.select(HumanInputIMBinding)).one() + if binding_scope == "workspace_override": + replacement = _add_identity(session, channel_id, "replacement-user") + binding.im_identity_id = replacement.id + contact_id = ContactId(binding.contact_id) + resumed_calls: list[dict[str, object]] = [] + + def resumed(*, kwargs: dict[str, object]) -> None: + resumed_calls.append(kwargs) + + monkeypatch.setattr(resume_app_execution, "apply_async", resumed) + body = _request_body() + assert app.test_client().post(_PATH, data=body, headers=_headers(body)).status_code == 200 + if retry_assignment: + with context.sessions.begin() as session: + repository = SQLAlchemyIMBindingRepository(session, channel_id) + tenant_id = TenantId(context.execution.tenant_id) + effective = repository.get_effective(tenant_id, contact_id) + assert effective is not None + assign = ( + repository.replace + if effective.kind is IMBindingKind.DEFAULT + else partial(repository.set_workspace_override, tenant_id) + ) + assign( + IMBindingAssignment(effective.contact_id, effective.identity_id, naive_utc_now()), + bound_by_account_id=AccountId(str(uuid4())), + ) + with app.app_context(): + process_im_message_inbox_record.run(published[0]) + with context.sessions() as session: + assert session.get_one(HumanInputForm, form_id).status == HumanInputFormStatus.SUBMITTED + assert len(resumed_calls) == 1 + + +@pytest.mark.parametrize("keep_platform_entry", [False, True]) +def test_worker_uses_current_platform_contact_visibility( + context: Context, + approval: tuple[str, IMChannelId], + callback_app: tuple[DifyApp, list[str]], + monkeypatch: pytest.MonkeyPatch, + keep_platform_entry: bool, +) -> None: + from tasks.app_generate.workflow_execute_task import resume_app_execution + + form_id, _ = approval + app, published = callback_app + initiator = context.execution.initiator + assert isinstance(initiator, AccountInitiator) + with context.sessions.begin() as session: + binding = session.scalars(sa.select(HumanInputIMBinding)).one() + session.execute( + sa.delete(TenantAccountJoin).where( + TenantAccountJoin.tenant_id == context.execution.tenant_id, + TenantAccountJoin.account_id == initiator.account_id, + ) + ) + entry = HumanInputPlatformContactWorkspaceEntry( + tenant_id=context.execution.tenant_id, + contact_id=binding.contact_id, + added_by_account_id=initiator.account_id, + ) + session.add(entry) + entry_id = entry.id + body = _request_body() + assert app.test_client().post(_PATH, data=body, headers=_headers(body)).status_code == 200 + if not keep_platform_entry: + with context.sessions.begin() as session: + session.delete(session.get_one(HumanInputPlatformContactWorkspaceEntry, entry_id)) + resumed_calls: list[dict[str, object]] = [] + + def resumed(*, kwargs: dict[str, object]) -> None: + resumed_calls.append(kwargs) + + monkeypatch.setattr(resume_app_execution, "apply_async", resumed) + with app.app_context(): + process_im_message_inbox_record.run(published[0]) + with context.sessions() as session: + form = session.get_one(HumanInputForm, form_id) + if keep_platform_entry: + assert form.status == HumanInputFormStatus.SUBMITTED + assert len(resumed_calls) == 1 + else: + assert form.status == HumanInputFormStatus.WAITING + assert form.submitted_at is None + assert not resumed_calls + + +@pytest.mark.parametrize(("ip_address", "expected_ip"), [("192.0.2.15", "192.0.2.15"), (None, None), ("", None)]) +@pytest.mark.parametrize( + ("user_id", "token", "action", "succeeded", "reason", "submitted"), + [ + (_USER, _TOKEN, "approve", True, "verified", True), + ("another-user", _TOKEN, "approve", False, "Callback user does not match the delivery target user.", False), + (_USER, _TOKEN, "unknown-action", True, "verified", False), + (_USER, "unknown-token", "approve", None, None, False), + ], +) +def test_callback_verification_audit_is_independent_of_submission( + context: Context, + approval: tuple[str, IMChannelId], + callback_app: tuple[DifyApp, list[str]], + monkeypatch: pytest.MonkeyPatch, + user_id: str, + token: str, + action: str, + succeeded: bool | None, + reason: str | None, + submitted: bool, + ip_address: str | None, + expected_ip: str | None, + caplog: pytest.LogCaptureFixture, +) -> None: + from tasks.app_generate.workflow_execute_task import resume_app_execution + + monkeypatch.setattr(resume_app_execution, "apply_async", lambda **_kwargs: None) + form_id, _ = approval + app, published = callback_app + body = _request_body(user_id=user_id, token=token).replace(b"approve", action.encode()) + assert ( + app.test_client() + .post(_PATH, data=body, headers=_headers(body), environ_overrides={"REMOTE_ADDR": ip_address}) + .status_code + == 200 + ) + with app.app_context(): + process_im_message_inbox_record.run(published[0]) + with context.sessions() as session: + assert (session.get_one(HumanInputForm, form_id).status == HumanInputFormStatus.SUBMITTED) is submitted + audits = list(session.scalars(sa.select(HumanInputAuditEvent))) + if succeeded is None: + assert audits == [] + assert "Callback delivery was not found." in caplog.text + assert "tenant_id=None form_id=None" in caplog.text + assert token not in caplog.text + return + assert len(audits) == 1 + audit = audits[0] + assert audit.tenant_id == context.execution.tenant_id + assert audit.form_id == form_id + assert audit.created_at is not None + assert reason is not None + assert audit.payload == IMCallbackVerificationEvent( + succeeded=succeeded, reason=reason, request_ip_address=expected_ip + ) + stored = audit.payload.model_dump_json() + assert _TOKEN not in stored + assert _SIGNING_SECRET not in stored + if not succeeded: + assert reason in caplog.text + assert f"tenant_id={context.execution.tenant_id} form_id={form_id}" in caplog.text + assert _TOKEN not in caplog.text + assert _SIGNING_SECRET not in caplog.text + + +def test_unauthenticated_callback_does_not_create_form_audit( + context: Context, callback_app: tuple[DifyApp, list[str]] +) -> None: + app, published = callback_app + body = _request_body() + response = app.test_client().post(_PATH, data=body, headers=_headers(body, secret="wrong-signing-secret")) + assert response.status_code == 401 + assert published == [] + with context.sessions() as session: + assert session.scalar(sa.select(sa.func.count()).select_from(HumanInputAuditEvent)) == 0 + + +def test_verification_storage_failure_propagates_without_recording_rejection( + context: Context, approval: tuple[str, IMChannelId], monkeypatch: pytest.MonkeyPatch +) -> None: + from sqlalchemy.exc import OperationalError + + from services.human_input_v2.submission_service import HumanInputSubmissionService, IMFormActor + + form_id, channel_id = approval + + def fail_binding_read( + _repository: SQLAlchemyIMBindingRepository, _tenant_id: TenantId, _contact_id: ContactId + ) -> None: + raise OperationalError("SELECT", {}, OSError("Storage unavailable")) + + monkeypatch.setattr(SQLAlchemyIMBindingRepository, "get_effective", fail_binding_read) + service = HumanInputSubmissionService(session_factory=context.sessions) + with pytest.raises(OperationalError): + service.submit_im_form( + actor=IMFormActor( + channel_id=channel_id, + provider=IMProvider.SLACK, + provider_tenant_id=_TEAM, + provider_user_id=_USER, + ), + form_token=_TOKEN, + action="approve", + inputs={"note": "Approved", "decision": "选项 β"}, + ) + with context.sessions() as session: + assert session.get_one(HumanInputForm, form_id).status == HumanInputFormStatus.WAITING + assert session.scalar(sa.select(sa.func.count()).select_from(HumanInputAuditEvent)) == 0 diff --git a/api/tests/unit_tests/services/human_input_v2/test_message_template_test_service.py b/api/tests/unit_tests/services/human_input_v2/test_message_template_test_service.py new file mode 100644 index 00000000000..15cbe8ee0c1 --- /dev/null +++ b/api/tests/unit_tests/services/human_input_v2/test_message_template_test_service.py @@ -0,0 +1,714 @@ +"""Draft template sending against real workspace repositories, without provider I/O.""" + +from __future__ import annotations + +import hashlib +import json +import re +from dataclasses import dataclass, field +from datetime import datetime, timedelta +from unittest.mock import Mock, create_autospec +from urllib.parse import urlsplit + +import pytest +from pydantic import JsonValue +from sqlalchemy import delete, event +from sqlalchemy.engine import Engine +from sqlalchemy.exc import SQLAlchemyError +from sqlalchemy.orm import Session, sessionmaker + +from configs import dify_config +from core.human_input_v2.entities import DebugChannel, EmailProviderType, IMProvider +from core.human_input_v2.im_integration.adapters.entities import MessageAccepted, MessageSendingError +from core.human_input_v2.im_integration.adapters.message_locator import MessageLocator +from core.human_input_v2.im_integration.adapters.protocols import IMProviderAdapter +from core.human_input_v2.resolved_form import ParagraphInput, SelectInput +from core.human_input_v2.shared.values import AccountId, TenantId +from core.workflow.nodes.human_input.enums import HumanInputFormKind, HumanInputFormStatus +from graphon.variables.segments import ArrayStringSegment, StringSegment +from graphon.variables.variables import StringVariable +from libs.datetime_utils import naive_utc_now +from models.account import Account, AccountStatus, Tenant, TenantAccountJoin, TenantAccountRole +from models.human_input_v2 import ( + ContactSubjectType, + HumanInputContactIdentity, + HumanInputDelivery, + HumanInputEmailProvider, + HumanInputForm, + HumanInputIMBinding, + HumanInputIMBindingWorkspaceOverride, + HumanInputIMChannel, + HumanInputIMIdentity, + HumanInputRecipient, + IMIdentityRawPayload, +) +from models.workflow import Workflow, WorkflowDraftVariable, WorkflowType +from repositories.human_input_v2.channel_credentials import ChannelEncryptedCredentials +from repositories.human_input_v2.delivery_repository import ( + EmailTargetSnapshot, + IMUserTargetSnapshot, + SubmissionAuthType, +) +from repositories.human_input_v2.email_channel.entities import ResendCredentials +from repositories.human_input_v2.email_channel.ports import EmailProviderOperationError +from repositories.human_input_v2.email_channel.repository import SQLAlchemyEmailChannelRepository +from repositories.human_input_v2.im_channel_repository import IMChannel, IMChannelId, IMChannelStatus, WebhookId +from repositories.human_input_v2.sqlalchemy_delivery_repository import SQLAlchemyDeliveryRepository +from repositories.human_input_v2.sqlalchemy_im_channel_repository import WorkspaceIMChannelWriter +from services.human_input_v2.form_service import HumanInputDeliveryTestFormService +from services.human_input_v2.message_template_test_service import ( + MessageTemplateSendError, + MessageTemplateTestError, + MessageTemplateTestService, +) +from services.human_input_v2.resend_channel import ResendProviderGateway +from services.human_input_v2.submission_service import ( + HumanInputFormError, + HumanInputFormErrorCode, + HumanInputSubmissionService, +) +from services.workflow_draft_variable_service import DraftVarLoader + +_TENANT = TenantId("00000000-0000-0000-0000-000000000001") +_ACCOUNT = AccountId("00000000-0000-0000-0000-000000000002") +_APP = "00000000-0000-0000-0000-000000000003" +_CHANNEL = IMChannelId("00000000-0000-0000-0000-000000000004") +_CONTACT = "00000000-0000-0000-0000-000000000005" +_IDENTITY = "00000000-0000-0000-0000-000000000006" +_NOW = datetime(2026, 9, 11) + + +@dataclass +class _Context: + service: MessageTemplateTestService + sessions: sessionmaker[Session] + gateway: Mock + adapter: Mock + adapter_errors: list[Exception] = field(default_factory=list) + requested_channels: list[IMChannel] = field(default_factory=list) + + def template( + self, + subject: str, + body: str, + *, + version: str = "2", + form_content: str = "Please review\n\n{{#$output.note#}}", + ) -> None: + with self.sessions.begin() as session: + workflow = session.query(Workflow).one() + workflow.graph = json.dumps( + { + "nodes": [ + { + "id": "approval", + "data": { + "type": "human-input", + "version": version, + "title": "Approval", + "message_template": {"subject": subject, "body": body}, + "recipients_spec": [{"type": "onetime_email", "email": "other@example.com"}], + "debug_mode": {"enabled": False, "channels": []}, + "form_content": form_content, + "inputs": [{"type": "paragraph", "output_variable_name": "note"}], + "user_actions": [{"id": "approve", "title": "Approve"}], + }, + } + ], + "edges": [], + } + ) + + def send(self, channel: DebugChannel = DebugChannel.EMAIL, /, **kwargs: JsonValue) -> None: + self.service.send_test(app_id=_APP, node_id="approval", channel=channel, inputs=kwargs) + + def request_url(self, channel: DebugChannel = DebugChannel.EMAIL) -> str: + message = ( + self.gateway.send_message.call_args.kwargs["html"] + if channel is DebugChannel.EMAIL + else self.adapter.messaging.send_text.call_args.args[1] + ) + match = re.search(r"https://console\.example\.com/human-input/[A-Za-z0-9_-]+", message) + assert match is not None, message + return match.group() + + +@pytest.fixture +def context(monkeypatch: pytest.MonkeyPatch, sqlite_engine: Engine) -> _Context: + engine = sqlite_engine + sessions = sessionmaker(engine, expire_on_commit=False) + gateway = create_autospec(ResendProviderGateway, instance=True) + adapter = create_autospec(IMProviderAdapter, instance=True) + adapter_errors: list[Exception] = [] + requested_channels: list[IMChannel] = [] + + def adapter_factory(_channel: IMChannel) -> IMProviderAdapter: + requested_channels.append(_channel) + if adapter_errors: + raise adapter_errors[0] + return adapter + + service = MessageTemplateTestService( + session_factory=sessions, + tenant_id=_TENANT, + account_id=_ACCOUNT, + account_email="editor@example.com", + email_repository_factory=SQLAlchemyEmailChannelRepository, + email_gateway=gateway, + im_adapter_factory=adapter_factory, + form_service=HumanInputDeliveryTestFormService( + session_factory=sessions, + tenant_id=_TENANT, + account_id=_ACCOUNT, + variable_loader_factory=lambda app_id: DraftVarLoader( + engine=engine, app_id=app_id, tenant_id=str(_TENANT), user_id=str(_ACCOUNT) + ), + ), + ) + monkeypatch.setattr( + "core.helper.encrypter.decrypt_token", + lambda _tenant_id, _token: json.dumps({"api_key": "decrypted-key"}), + ) + monkeypatch.setattr(dify_config, "CONSOLE_WEB_URL", "https://console.example.com/") + with sessions.begin() as session: + account = Account(name="Editor", email="editor@example.com", status=AccountStatus.ACTIVE) + account.id = str(_ACCOUNT) + tenant = Tenant(name="Workspace") + tenant.id = str(_TENANT) + contact = HumanInputContactIdentity(subject_type=ContactSubjectType.ACCOUNT, account_id=str(_ACCOUNT)) + contact.id = _CONTACT + session.add_all( + [ + account, + tenant, + contact, + TenantAccountJoin(tenant_id=str(_TENANT), account_id=str(_ACCOUNT), role=TenantAccountRole.NORMAL), + Workflow( + tenant_id=str(_TENANT), + app_id=_APP, + type=WorkflowType.WORKFLOW, + version="draft", + graph="{}", + _features="{}", + created_by=str(_ACCOUNT), + ), + HumanInputEmailProvider( + tenant_id=str(_TENANT), + provider=EmailProviderType.RESEND, + sender_email="approvals@example.com", + sender_name="Approvals", + encrypted_credentials=ChannelEncryptedCredentials(ciphertext="key"), + ), + ] + ) + WorkspaceIMChannelWriter(session, _TENANT, _ACCOUNT).create( + IMChannel( + id=_CHANNEL, + created_at=_NOW, + updated_at=_NOW, + provider=IMProvider.SLACK, + provider_tenant_id="slack-workspace", + encrypted_credentials=ChannelEncryptedCredentials(ciphertext="key"), + app_identifier="slack-app", + webhook_id=WebhookId("webhook"), + config_version=1, + status=IMChannelStatus.CONNECTED, + status_reason=None, + ) + ) + identity = HumanInputIMIdentity( + channel_id=str(_CHANNEL), + provider_user_id="editor-slack-id", + raw_payload=IMIdentityRawPayload({}), + last_seen_sync_run_id="00000000-0000-0000-0000-000000000007", + last_seen_at=_NOW, + ) + identity.id = _IDENTITY + session.add_all( + [identity, HumanInputIMBinding(channel_id=str(_CHANNEL), contact_id=_CONTACT, im_identity_id=_IDENTITY)] + ) + active_connections: set[object] = set() + event.listen(engine, "begin", lambda conn: active_connections.add(conn)) + event.listen(engine, "rollback", lambda conn: active_connections.discard(conn)) + event.listen(engine, "commit", lambda conn: active_connections.discard(conn)) + + def accepted(*_args: object, **_kwargs: object) -> MessageAccepted: + assert not active_connections, "Provider I/O must happen after database transactions close" + return MessageAccepted(MessageLocator("provider-message")) + + gateway.send_message.side_effect = accepted + adapter.messaging.send_text.side_effect = accepted + result = _Context(service, sessions, gateway, adapter, adapter_errors, requested_channels) + result.template("Review {{#start.name#}}", "Please review {{#url#}}") + return result + + +def test_email_renders_and_sends_only_to_editor_with_a_real_test_form(context: _Context) -> None: + context.send(**{"#start.name#": "Alice\r\nBcc: other@example.com"}) + + candidate = context.gateway.send_message.call_args.args[0] + assert candidate.credentials.api_key == "decrypted-key" + assert str(candidate.sender_email) == "approvals@example.com" + sent = context.gateway.send_message.call_args.kwargs + assert sent["to"] == "editor@example.com" + assert sent["subject"] == "Review Alice Bcc: other@example.com" + token = urlsplit(context.request_url()).path.rsplit("/", 1)[1] + form = HumanInputSubmissionService(session_factory=context.sessions).get_console_form( + account_id=_ACCOUNT, form_token=token + ) + assert form.form_kind is HumanInputFormKind.DELIVERY_TEST + assert form.workflow_run_id is None + assert form.node_execution_id is None + assert form.resolved_form.title == "Approval" + assert form.resolved_form.legacy_form_content == "Please review\n\n{{#$output.note#}}" + assert form.resolved_form.actions[0].id == "approve" + with context.sessions() as session: + recipient = session.query(HumanInputRecipient).one() + assert recipient.subject_value == _CONTACT + assert not context.requested_channels + + +def test_email_template_test_uses_complete_encrypted_credentials( + context: _Context, monkeypatch: pytest.MonkeyPatch +) -> None: + from cryptography.fernet import Fernet + + from core.helper import encrypter + from core.human_input_v2.shared import NormalizedEmail + from repositories.human_input_v2.email_channel.entities import ResendCandidate + from services.human_input_v2.email_credential_codec import seal_email_credentials + + cipher = Fernet(Fernet.generate_key()) + monkeypatch.setattr(encrypter, "encrypt_token", lambda _tenant, token: cipher.encrypt(token.encode()).decode()) + monkeypatch.setattr(encrypter, "decrypt_token", lambda _tenant, token: cipher.decrypt(token.encode()).decode()) + candidate = ResendCandidate( + NormalizedEmail("encrypted@example.com"), "Encrypted Sender", ResendCredentials(api_key="complete-api-key") + ) + with context.sessions.begin() as session: + record = session.query(HumanInputEmailProvider).one() + record.sender_email = str(candidate.sender_email) + record.sender_name = candidate.sender_name + record.encrypted_credentials = seal_email_credentials(_TENANT, candidate.credentials) + + context.send(**{"#start.name#": "Alice"}) + + assert context.gateway.send_message.call_args.args[0] == candidate + with context.sessions() as session: + assert session.query(HumanInputEmailProvider).one().config_version == 1 + + +def test_email_preserves_markdown_headings_and_approval_link(context: _Context) -> None: + context.template("### HITL v2 Migration Check", "# H1\n\n## H2\n\n[Migration approval]({{#url#}})") + + context.send() + + sent = context.gateway.send_message.call_args.kwargs + assert sent["html"] == ( + f'

H1

\n

H2

\n

Migration approval

' + ) + + +@pytest.mark.parametrize("channel", [DebugChannel.EMAIL, DebugChannel.SLACK]) +def test_test_link_submits_only_its_frozen_form_without_resuming_a_workflow( + context: _Context, monkeypatch: pytest.MonkeyPatch, channel: DebugChannel +) -> None: + from tasks.app_generate.workflow_execute_task import resume_app_execution + + resumed_calls: list[dict[str, object]] = [] + + def resumed(*, kwargs: dict[str, object]) -> None: + resumed_calls.append(kwargs) + + monkeypatch.setattr(resume_app_execution, "apply_async", resumed) + context.send(channel, **{"#start.name#": "Alice"}) + token = urlsplit(context.request_url(channel)).path.rsplit("/", 1)[1] + submissions = HumanInputSubmissionService(session_factory=context.sessions) + original = submissions.get_console_form(account_id=_ACCOUNT, form_token=token) + context.template("Edited subject", "Edited body {{#url#}}", form_content="Edited form") + assert submissions.get_console_form(account_id=_ACCOUNT, form_token=token).resolved_form == original.resolved_form + + submissions.submit_console_form( + account_id=_ACCOUNT, form_token=token, action="approve", inputs={"note": "Approved"} + ) + + with context.sessions() as session: + form = session.query(HumanInputForm).one() + assert form.status is HumanInputFormStatus.SUBMITTED + assert form.selected_action_id == "approve" + assert form.raw_submission_inputs is not None + assert json.loads(form.raw_submission_inputs) == {"note": "Approved"} + delivery = session.query(HumanInputDelivery).one() + assert delivery.token_hash == hashlib.sha256(token.encode()).hexdigest() + assert token not in delivery.target_snapshot.model_dump_json() + assert not resumed_calls + with pytest.raises(HumanInputFormError) as submitted: + submissions.get_console_form(account_id=_ACCOUNT, form_token=token) + assert submitted.value.code is HumanInputFormErrorCode.ALREADY_SUBMITTED + + +@pytest.mark.parametrize("channel", [DebugChannel.EMAIL, DebugChannel.SLACK]) +def test_test_link_requires_its_editors_current_workspace_membership(context: _Context, channel: DebugChannel) -> None: + context.send(channel, **{"#start.name#": "Alice"}) + token = urlsplit(context.request_url(channel)).path.rsplit("/", 1)[1] + submissions = HumanInputSubmissionService(session_factory=context.sessions) + with pytest.raises(HumanInputFormError) as another_editor: + submissions.get_console_form(account_id=AccountId("00000000-0000-0000-0000-000000000099"), form_token=token) + assert another_editor.value.code is HumanInputFormErrorCode.NOT_FOUND + with context.sessions.begin() as session: + session.execute(delete(TenantAccountJoin)) + with pytest.raises(HumanInputFormError) as removed_member: + submissions.get_console_form(account_id=_ACCOUNT, form_token=token) + assert removed_member.value.code is HumanInputFormErrorCode.NOT_FOUND + + +def test_each_send_creates_a_separate_test_form_and_link(context: _Context) -> None: + context.send(**{"#start.name#": "Alice"}) + first_url = context.request_url() + context.send(**{"#start.name#": "Alice"}) + assert context.request_url() != first_url + with context.sessions() as session: + assert session.query(HumanInputForm).count() == 2 + assert session.query(HumanInputDelivery).count() == 2 + + +def test_test_form_obeys_node_and_global_deadlines(context: _Context) -> None: + before = naive_utc_now() + context.send(**{"#start.name#": "Alice"}) + after = naive_utc_now() + token = urlsplit(context.request_url()).path.rsplit("/", 1)[1] + form = HumanInputSubmissionService(session_factory=context.sessions).get_console_form( + account_id=_ACCOUNT, form_token=token + ) + assert before + timedelta(hours=36) <= form.expiration_time <= after + timedelta(hours=36) + global_timeout = timedelta(seconds=dify_config.HUMAN_INPUT_GLOBAL_TIMEOUT_SECONDS) + assert before + global_timeout <= form.global_timeout_deadline <= after + global_timeout + + +def test_test_form_resolves_form_only_draft_values_and_message_test_overrides(context: _Context) -> None: + with context.sessions.begin() as session: + workflow = session.query(Workflow).one() + graph = json.loads(workflow.graph) + node = graph["nodes"][0]["data"] + node["form_content"] = "Review {{#start.name#}}: {{#draft.context#}}\n{{#$output.note#}}\n{{#$output.choice#}}" + node["inputs"] = [ + { + "type": "paragraph", + "output_variable_name": "note", + "default": {"type": "variable", "selector": ["draft", "context"]}, + }, + { + "type": "select", + "output_variable_name": "choice", + "option_source": {"type": "variable", "selector": ["draft", "choices"]}, + }, + ] + workflow.graph = json.dumps(graph) + session.add_all( + [ + WorkflowDraftVariable.new_node_variable( + app_id=_APP, + user_id=str(_ACCOUNT), + node_id="draft", + name=name, + value=value, + node_execution_id="00000000-0000-0000-0000-000000000009", + ) + for name, value in ( + ("context", StringSegment(value="Saved context")), + ("choices", ArrayStringSegment(value=["Yes", "No"])), + ) + ] + ) + context.send(**{"#start.name#": "Alice"}) + token = urlsplit(context.request_url()).path.rsplit("/", 1)[1] + form = HumanInputSubmissionService(session_factory=context.sessions).get_console_form( + account_id=_ACCOUNT, form_token=token + ) + assert form.resolved_form.legacy_form_content == ( + "Review Alice: Saved context\n{{#$output.note#}}\n{{#$output.choice#}}" + ) + paragraph = next(part for part in form.resolved_form.parts if isinstance(part, ParagraphInput)) + assert paragraph.default_value == "Saved context" + select = next(part for part in form.resolved_form.parts if isinstance(part, SelectInput)) + assert select.options == ("Yes", "No") + + +@pytest.mark.parametrize("content", ["Review {{#draft.missing#}}", "Review {{#env.missing#}}"]) +def test_missing_form_variable_rejects_before_creating_or_sending(context: _Context, content: str) -> None: + context.template("Review", "Open {{#url#}}", form_content=content) + + with pytest.raises(MessageTemplateTestError, match="unavailable"): + context.send() + + context.gateway.send_message.assert_not_called() + with context.sessions() as session: + assert session.query(HumanInputForm).count() == 0 + assert session.query(HumanInputDelivery).count() == 0 + + +def test_invalid_form_definition_rejects_before_creating_or_sending(context: _Context) -> None: + context.template("Review", "Open {{#url#}}", form_content="{{#$output.missing#}}") + + with pytest.raises(MessageTemplateTestError, match="valid.*form"): + context.send() + + context.gateway.send_message.assert_not_called() + with context.sessions() as session: + assert session.query(HumanInputForm).count() == 0 + + +@pytest.mark.parametrize("channel", [DebugChannel.EMAIL, DebugChannel.SLACK]) +def test_failed_grant_storage_rolls_back_the_form_and_prevents_send( + context: _Context, monkeypatch: pytest.MonkeyPatch, channel: DebugChannel +) -> None: + def reject_delivery(*_args: object, **_kwargs: object) -> None: + raise SQLAlchemyError("Storage unavailable") + + monkeypatch.setattr(SQLAlchemyDeliveryRepository, "create_delivery", reject_delivery) + + with pytest.raises(SQLAlchemyError): + context.send(channel, **{"#start.name#": "Alice"}) + + with context.sessions() as session: + assert session.query(HumanInputForm).count() == 0 + assert session.query(HumanInputRecipient).count() == 0 + assert session.query(HumanInputDelivery).count() == 0 + context.gateway.send_message.assert_not_called() + context.adapter.messaging.send_text.assert_not_called() + if channel is DebugChannel.SLACK: + context.adapter.close.assert_called_once() + + +def test_im_uses_current_editors_binding_and_closes_adapter(context: _Context) -> None: + context.send(DebugChannel.SLACK, **{"#start.name#": "Alice"}) + context.adapter.messaging.send_text.assert_called_once_with( + "editor-slack-id", f"Please review {context.request_url(DebugChannel.SLACK)}", subject="Review Alice" + ) + context.adapter.close.assert_called_once() + context.gateway.send_message.assert_not_called() + with context.sessions() as session: + delivery = session.query(HumanInputDelivery).one() + binding = session.query(HumanInputIMBinding).one() + assert isinstance(delivery.target_snapshot, IMUserTargetSnapshot) + assert delivery.target_snapshot.im_binding_id == binding.id + + +@pytest.mark.parametrize("channel", list(DebugChannel)) +def test_requested_debug_channel_selects_the_notification_and_access_target( + context: _Context, channel: DebugChannel +) -> None: + with context.sessions.begin() as session: + workflow = session.query(Workflow).one() + graph = json.loads(workflow.graph) + node = graph["nodes"][0]["data"] + node["recipients_spec"] = [{"type": "dynamic_email", "selector": ["upstream", "missing"]}] + node["debug_mode"] = {"enabled": True, "channels": ["feishu"]} + workflow.graph = json.dumps(graph) + if channel is not DebugChannel.EMAIL: + session.query(HumanInputIMChannel).one().provider = IMProvider(channel.value) + + context.send(channel, **{"#start.name#": "Alice"}) + + with context.sessions() as session: + delivery = session.query(HumanInputDelivery).one() + assert delivery.auth_type is SubmissionAuthType.CONSOLE + if channel is DebugChannel.EMAIL: + assert isinstance(delivery.target_snapshot, EmailTargetSnapshot) + assert delivery.target_snapshot.email_address == "editor@example.com" + else: + assert isinstance(delivery.target_snapshot, IMUserTargetSnapshot) + assert delivery.target_snapshot.im_provider.value == channel.value + assert delivery.target_snapshot.im_provider_user_id == "editor-slack-id" + if channel is DebugChannel.EMAIL: + context.gateway.send_message.assert_called_once() + assert not context.requested_channels + else: + context.adapter.messaging.send_text.assert_called_once() + context.gateway.send_message.assert_not_called() + context.adapter.close.assert_called_once() + assert context.request_url(channel) + + +@pytest.mark.parametrize( + ("subject", "expected_subject"), + [ + ("### HITL v2 Migration Check", "### HITL v2 Migration Check"), + ("**Review** {{#start.name#}}", "**Review** _Alice_"), + ("[Review](https://example.com) ©", "[Review](https://example.com) ©"), + (r"`Review` C:\approvals", r"`Review` C:\approvals"), + ], +) +def test_im_subject_stays_literal_while_body_renders_markdown( + context: _Context, subject: str, expected_subject: str +) -> None: + context.template(subject, "# H1\n\n## H2\n\n[Approval]({{#url#}})") + + context.send(DebugChannel.SLACK, **{"#start.name#": "_Alice_"}) + + context.adapter.messaging.send_text.assert_called_once_with( + "editor-slack-id", + f"# H1\n\n## H2\n\n[Approval]({context.request_url(DebugChannel.SLACK)})", + subject=expected_subject, + ) + + +@pytest.mark.parametrize("provider", [IMProvider.DING_TALK, IMProvider.WE_COM]) +def test_plain_text_im_does_not_expose_markdown_escapes(context: _Context, provider: IMProvider) -> None: + with context.sessions.begin() as session: + session.query(HumanInputIMChannel).one().provider = provider + context.template("### Review **Alice**", "Open {{#url#}}") + + context.send(DebugChannel(provider.value)) + + context.adapter.messaging.send_text.assert_called_once_with( + "editor-slack-id", f"Open {context.request_url(DebugChannel(provider.value))}", subject="### Review **Alice**" + ) + + +@pytest.mark.parametrize("channel", [DebugChannel.EMAIL, DebugChannel.SLACK]) +def test_provider_failure_is_reported_without_leaking_provider_details( + context: _Context, channel: DebugChannel +) -> None: + context.gateway.send_message.side_effect = EmailProviderOperationError("private-provider-detail") + context.adapter.messaging.send_text.side_effect = None + context.adapter.messaging.send_text.return_value = MessageSendingError("private-provider-detail") + with pytest.raises(MessageTemplateSendError) as raised: + context.send(channel, **{"#start.name#": "Alice"}) + assert "private-provider-detail" not in str(raised.value) + if channel is DebugChannel.SLACK: + context.adapter.close.assert_called_once() + + +@pytest.mark.parametrize( + "missing", + [ + HumanInputEmailProvider, + HumanInputIMChannel, + HumanInputIMBinding, + HumanInputIMIdentity, + HumanInputContactIdentity, + TenantAccountJoin, + ], +) +def test_missing_channel_or_current_editor_binding_prevents_send( + context: _Context, + missing: type[ + HumanInputEmailProvider + | HumanInputIMChannel + | HumanInputIMBinding + | HumanInputIMIdentity + | HumanInputContactIdentity + | TenantAccountJoin + ], +) -> None: + with context.sessions.begin() as session: + session.execute(delete(missing)) + channel = DebugChannel.EMAIL if missing is HumanInputEmailProvider else DebugChannel.SLACK + with pytest.raises(MessageTemplateTestError): + context.send(channel, **{"#start.name#": "Alice"}) + context.gateway.send_message.assert_not_called() + assert not context.requested_channels + + +def test_other_im_provider_is_not_silently_substituted(context: _Context) -> None: + with pytest.raises(MessageTemplateTestError, match="not configured"): + context.send(DebugChannel.FEISHU, **{"#start.name#": "Alice"}) + assert not context.requested_channels + + +@pytest.mark.parametrize("version", ["1", "3"]) +def test_other_node_versions_are_rejected(context: _Context, version: str) -> None: + context.template("Review", "Body", version=version) + with pytest.raises(MessageTemplateTestError): + context.send() + context.gateway.send_message.assert_not_called() + + +def test_missing_variable_rejects_before_any_send(context: _Context) -> None: + with pytest.raises(MessageTemplateTestError, match="start.name"): + context.send() + context.gateway.send_message.assert_not_called() + + +def test_other_workspace_draft_is_inaccessible(context: _Context) -> None: + with context.sessions.begin() as session: + session.query(Workflow).one().tenant_id = "00000000-0000-0000-0000-000000000099" + with pytest.raises(MessageTemplateTestError, match="not found"): + context.send(**{"#start.name#": "Alice"}) + context.gateway.send_message.assert_not_called() + + +def test_input_values_are_not_interpreted_as_template_tokens(context: _Context) -> None: + context.template("Review", "{{#start.value#}}") + context.send(**{"#start.value#": "Literal {{#url#}} and {{#env.secret#}}"}) + html = context.gateway.send_message.call_args.kwargs["html"] + assert "{{#url#}}" in html + assert "{{#env.secret#}}" in html + + +def test_environment_variables_use_saved_values_and_ignore_request_overrides(context: _Context) -> None: + context.template("{{#env.name#}}", "{{#start.count#}} / {{#start.tags#}}") + with context.sessions.begin() as session: + session.query(Workflow).one().environment_variables = [StringVariable(name="name", value="Saved")] + inputs: dict[str, JsonValue] = {"#env.name#": "Override", "#start.count#": 3, "#start.tags#": ["A", "B"]} + context.send(**inputs) + sent = context.gateway.send_message.call_args.kwargs + assert sent["subject"] == "Saved" + assert "3" in sent["html"] + assert "A" in sent["html"] + assert "B" in sent["html"] + + +def test_workspace_override_is_used_instead_of_default_binding(context: _Context) -> None: + override_identity_id = "00000000-0000-0000-0000-000000000008" + with context.sessions.begin() as session: + identity = HumanInputIMIdentity( + channel_id=str(_CHANNEL), + provider_user_id="override-slack-id", + raw_payload=IMIdentityRawPayload({}), + last_seen_sync_run_id="00000000-0000-0000-0000-000000000007", + last_seen_at=_NOW, + ) + identity.id = override_identity_id + session.add_all( + [ + identity, + HumanInputIMBindingWorkspaceOverride( + channel_id=str(_CHANNEL), + tenant_id=str(_TENANT), + contact_id=_CONTACT, + im_identity_id=override_identity_id, + ), + ] + ) + context.send(DebugChannel.SLACK, **{"#start.name#": "Alice"}) + assert context.adapter.messaging.send_text.call_args.args[0] == "override-slack-id" + with context.sessions() as session: + delivery = session.query(HumanInputDelivery).one() + override = session.query(HumanInputIMBindingWorkspaceOverride).one() + assert isinstance(delivery.target_snapshot, IMUserTargetSnapshot) + assert delivery.target_snapshot.im_binding_id == override.id + + +@pytest.mark.parametrize(("subject", "body"), [(" ", "Body"), ("Subject", "\n"), ("", "Body")]) +def test_blank_rendered_template_is_rejected(context: _Context, subject: str, body: str) -> None: + context.template(subject, body) + with pytest.raises(MessageTemplateTestError, match="must not be blank"): + context.send() + context.gateway.send_message.assert_not_called() + + +def test_invalid_im_credentials_are_reported_without_attempting_send(context: _Context) -> None: + from services.human_input_v2.im_credential_codec import IMCredentialError + + context.adapter_errors.append(IMCredentialError("IM credential configuration is unavailable")) + with pytest.raises(MessageTemplateTestError, match="credentials"): + context.send(DebugChannel.SLACK, **{"#start.name#": "Alice"}) + context.adapter.messaging.send_text.assert_not_called() + + +def test_email_body_empty_after_html_sanitization_is_rejected(context: _Context) -> None: + context.template("Review", "") + with pytest.raises(MessageTemplateTestError, match="body"): + context.send() + context.gateway.send_message.assert_not_called() diff --git a/api/tests/unit_tests/services/human_input_v2/test_node_data_migration.py b/api/tests/unit_tests/services/human_input_v2/test_node_data_migration.py new file mode 100644 index 00000000000..0c1e6e0b82b --- /dev/null +++ b/api/tests/unit_tests/services/human_input_v2/test_node_data_migration.py @@ -0,0 +1,699 @@ +import inspect +from collections.abc import Sequence +from dataclasses import FrozenInstanceError +from typing import Never, override +from uuid import uuid4 + +import pytest +from sqlalchemy import Engine, event +from sqlalchemy.orm import Session, sessionmaker + +from controllers.common import human_input_v2_migration as migration_boundary +from core.human_input_v2.shared.values import NormalizedEmail, TenantId +from core.workflow.nodes.human_input.entities import HumanInputNodeDataFull +from core.workflow.nodes.human_input_v2 import migration as migration_module +from core.workflow.nodes.human_input_v2.migration import ( + LegacyDeliveryParseIssue, + MemberEmailSnapshot, + MigrationBlockerCode, + NodeMigrationConversion, + ResolvedMemberEmail, + convert_legacy_human_input_node_data, +) +from models.account import Account, AccountStatus, TenantAccountJoin +from services.human_input_v2 import node_data_migration as service_module +from services.human_input_v2.composition import build_human_input_node_data_migration_service +from services.human_input_v2.node_data_migration import ( + HumanInputNodeDataMigrationService, + MigrationNode, + NodeDataMigrationFailure, + NodeDataMigrationSuccess, +) +from services.human_input_v2.workspace_member_email_lookup import SQLAlchemyWorkspaceMemberEmailLookup + +_EMAIL_METHOD_ID = "11111111-1111-4111-8111-111111111111" +_WEBAPP_METHOD_ID = "22222222-2222-4222-8222-222222222222" + + +def _member_node(title: str, *member_ids: str) -> HumanInputNodeDataFull: + return HumanInputNodeDataFull.model_validate( + { + "title": title, + "delivery_methods": [ + { + "id": _EMAIL_METHOD_ID, + "type": "email", + "config": { + "recipients": { + "items": [{"type": "member", "reference_id": member_id} for member_id in member_ids] + }, + "subject": "Review", + "body": "Please review", + }, + } + ], + } + ) + + +class _LookupSpy: + def __init__(self, unavailable_account_ids: frozenset[str] = frozenset()) -> None: + self.calls: list[tuple[TenantId, tuple[str, ...]]] = [] + self._unavailable_account_ids = unavailable_account_ids + + def find_member_emails(self, tenant_id: TenantId, account_ids: Sequence[str]) -> MemberEmailSnapshot: + self.calls.append((tenant_id, tuple(account_ids))) + return MemberEmailSnapshot( + tuple( + ResolvedMemberEmail(account_id, NormalizedEmail(f"{account_id}@example.com")) + for account_id in account_ids + if account_id not in self._unavailable_account_ids + ) + ) + + +class _ReadOnlyBoundarySpy(_LookupSpy): + def __init__(self) -> None: + super().__init__() + self.write_attempts: list[str] = [] + + def __getattr__(self, attribute_name: str) -> Never: + self.write_attempts.append(attribute_name) + raise AssertionError(f"unexpected persistence access: {attribute_name}") + + +class _ConverterSpy: + def __init__(self) -> None: + self.snapshot_ids: list[int] = [] + + def __call__( + self, + node_data: HumanInputNodeDataFull, + member_emails: MemberEmailSnapshot, + ) -> NodeMigrationConversion: + self.snapshot_ids.append(id(member_emails)) + with pytest.raises(FrozenInstanceError): + member_emails.__setattr__("entries", ()) + return convert_legacy_human_input_node_data(node_data, member_emails) + + +def test_batch_uses_one_ordered_member_lookup_and_one_immutable_snapshot() -> None: + lookup = _LookupSpy() + converter = _ConverterSpy() + service = HumanInputNodeDataMigrationService(member_email_lookup=lookup, converter=converter) + nodes = ( + MigrationNode("node-1", _member_node("First", "member-2", "member-1")), + MigrationNode("node-2", _member_node("Second", "member-1", "member-3")), + ) + + outcome = service.migrate(tenant_id=TenantId("workspace-1"), nodes=nodes) + + assert lookup.calls == [("workspace-1", ("member-2", "member-1", "member-3"))] + assert len(set(converter.snapshot_ids)) == 1 + assert isinstance(outcome, NodeDataMigrationSuccess) + assert [result.node_id for result in outcome.data] == ["node-1", "node-2"] + + +def test_service_collects_real_frontend_user_id_member_references() -> None: + lookup = _LookupSpy() + service = HumanInputNodeDataMigrationService(member_email_lookup=lookup) + node_data = HumanInputNodeDataFull.model_validate( + { + "title": "Approval", + "delivery_methods": [ + { + "id": _EMAIL_METHOD_ID, + "type": "email", + "config": { + "subject": "Review", + "body": "Please review", + "recipients": {"items": [{"type": "member", "reference_id": "member-1"}]}, + }, + } + ], + } + ) + + outcome = service.migrate( + tenant_id=TenantId("workspace-1"), + nodes=(MigrationNode("node-1", node_data),), + ) + + assert lookup.calls == [("workspace-1", ("member-1",))] + assert isinstance(outcome, NodeDataMigrationSuccess) + assert outcome.data[0].node_data.recipients_spec[0].model_dump(mode="json") == { + "type": "onetime_email", + "email": "member-1@example.com", + } + + +def test_service_merges_typed_preflight_issues_and_discards_converted_data() -> None: + lookup = _LookupSpy() + service = HumanInputNodeDataMigrationService(member_email_lookup=lookup) + node_data = HumanInputNodeDataFull.model_validate( + { + "title": "Approval", + "delivery_methods": [ + { + "id": "22222222-2222-4222-8222-222222222222", + "type": "webapp", + "config": {}, + } + ], + } + ) + issue = LegacyDeliveryParseIssue( + code=MigrationBlockerCode.UNSUPPORTED_DELIVERY_METHOD, + method_position=0, + method_id="33333333-3333-4333-8333-333333333333", + value="im", + ) + + outcome = service.migrate( + tenant_id=TenantId("workspace-1"), + nodes=( + MigrationNode( + "node-1", + node_data, + method_positions=(1,), + preflight_issues=(issue,), + ), + ), + ) + + assert isinstance(outcome, NodeDataMigrationFailure) + assert [(blocker.code, blocker.method_id, blocker.value) for blocker in outcome.blockers] == [ + ("unsupported-delivery-method", "33333333-3333-4333-8333-333333333333", "im") + ] + assert not hasattr(outcome, "data") + + +def test_service_preserves_method_order_when_merging_preflight_and_conversion_blockers() -> None: + service = HumanInputNodeDataMigrationService(member_email_lookup=_LookupSpy()) + node_data = HumanInputNodeDataFull.model_validate( + { + "title": "Approval", + "delivery_methods": [ + { + "id": "11111111-1111-4111-8111-111111111111", + "type": "email", + "config": { + "subject": "Review", + "body": "Please review", + "recipients": {"items": [{"type": "external", "email": "invalid-email"}]}, + }, + } + ], + } + ) + issue = LegacyDeliveryParseIssue( + code=MigrationBlockerCode.UNSUPPORTED_DELIVERY_METHOD, + method_position=1, + method_id="33333333-3333-4333-8333-333333333333", + value="im", + ) + + outcome = service.migrate( + tenant_id=TenantId("workspace-1"), + nodes=( + MigrationNode( + "node-1", + node_data, + method_positions=(0,), + preflight_issues=(issue,), + ), + ), + ) + + assert isinstance(outcome, NodeDataMigrationFailure) + assert [(blocker.code, blocker.method_id) for blocker in outcome.blockers] == [ + ("invalid-email", "11111111-1111-4111-8111-111111111111"), + ("unsupported-delivery-method", "33333333-3333-4333-8333-333333333333"), + ("missing-recipients", None), + ] + + +def test_service_preserves_recipient_source_order_across_preflight_and_converter_blockers() -> None: + transport_node_data = migration_boundary.LegacyHITLv1NodeData.model_validate( + { + "title": "Approval", + "delivery_methods": [ + { + "id": _EMAIL_METHOD_ID, + "type": "email", + "config": { + "subject": "Review", + "body": "Please review", + "recipients": { + "items": [ + {"type": "external", "email": "invalid-email"}, + {"type": "external", "email": 7}, + {"type": "member"}, + {"type": "external", "email": "approver@example.com"}, + ] + }, + }, + } + ], + } + ) + service = HumanInputNodeDataMigrationService(member_email_lookup=_LookupSpy()) + + outcome = service.migrate( + tenant_id=TenantId("workspace-1"), + nodes=( + MigrationNode.from_preflight( + "node-1", migration_boundary.preflight_legacy_human_input_node_data(transport_node_data) + ), + ), + ) + + assert isinstance(outcome, NodeDataMigrationFailure) + assert [(blocker.code, blocker.value) for blocker in outcome.blockers] == [ + ("invalid-email", "invalid-email"), + ("invalid-email", None), + ("unresolved-member", None), + ] + + +def test_service_preserves_adversarial_preflight_and_conversion_order_without_partial_data() -> None: + transport_node_data = migration_boundary.LegacyHITLv1NodeData.model_validate( + { + "title": "Approval", + "default_value": [{"key": "fallback", "type": "object", "value": False}], + "delivery_methods": [ + {"id": None, "type": "webapp", "config": {}}, + { + "id": _EMAIL_METHOD_ID, + "type": "email", + "config": { + "subject": "Review", + "body": "Please review", + "recipients": {"items": [{"type": "external", "email": "invalid-email"}]}, + }, + }, + {"id": _EMAIL_METHOD_ID, "type": "email", "enabled": False, "config": None}, + {"id": _WEBAPP_METHOD_ID, "type": "webapp", "config": {}}, + ], + } + ) + service = HumanInputNodeDataMigrationService(member_email_lookup=_LookupSpy()) + + outcome = service.migrate( + tenant_id=TenantId("workspace-1"), + nodes=( + MigrationNode.from_preflight( + "node-1", migration_boundary.preflight_legacy_human_input_node_data(transport_node_data) + ), + ), + ) + + assert isinstance(outcome, NodeDataMigrationFailure) + assert not hasattr(outcome, "data") + assert [(blocker.code, blocker.method_id, blocker.value) for blocker in outcome.blockers] == [ + ("invalid-default-value", None, "default_value"), + ("unsupported-delivery-method", None, "webapp"), + ("invalid-email", _EMAIL_METHOD_ID, "invalid-email"), + ("configured-disabled-method", _EMAIL_METHOD_ID, "email"), + ] + + +def test_service_orders_invalid_delivery_ids_without_partial_data_or_persistence_writes() -> None: + transport_node_data = migration_boundary.LegacyHITLv1NodeData.model_validate( + { + "title": "Approval", + "delivery_methods": [ + {"type": "webapp", "config": {}}, + {"id": None, "type": "webapp", "config": {}}, + {"id": "invalid-id", "type": "webapp", "config": {}}, + {"id": _WEBAPP_METHOD_ID, "type": "webapp", "config": {}}, + ], + } + ) + lookup = _ReadOnlyBoundarySpy() + service = HumanInputNodeDataMigrationService(member_email_lookup=lookup) + + outcome = service.migrate( + tenant_id=TenantId("workspace-1"), + nodes=( + MigrationNode.from_preflight( + "node-1", migration_boundary.preflight_legacy_human_input_node_data(transport_node_data) + ), + ), + ) + + assert lookup.calls == [("workspace-1", ())] + assert lookup.write_attempts == [] + assert isinstance(outcome, NodeDataMigrationFailure) + assert not hasattr(outcome, "data") + assert [(blocker.code, blocker.method_id, blocker.value) for blocker in outcome.blockers] == [ + ("unsupported-delivery-method", None, "webapp"), + ("unsupported-delivery-method", "invalid-id", "webapp"), + ] + + +def test_service_source_orders_disabled_unknown_methods_as_unsupported_without_partial_data() -> None: + transport_node_data = migration_boundary.LegacyHITLv1NodeData.model_validate( + { + "title": "Approval", + "delivery_methods": [ + {"id": "unknown-1", "type": "future-one", "enabled": False}, + { + "id": _EMAIL_METHOD_ID, + "type": "email", + "config": { + "subject": "Review", + "body": "Please review", + "recipients": {"items": [{"type": "external", "email": "invalid-email"}]}, + }, + }, + {"id": "unknown-2", "type": "future-two", "enabled": False, "config": {}}, + {"id": _WEBAPP_METHOD_ID, "type": "webapp", "config": {}}, + ], + } + ) + service = HumanInputNodeDataMigrationService(member_email_lookup=_LookupSpy()) + + outcome = service.migrate( + tenant_id=TenantId("workspace-1"), + nodes=( + MigrationNode.from_preflight( + "node-1", migration_boundary.preflight_legacy_human_input_node_data(transport_node_data) + ), + ), + ) + + assert isinstance(outcome, NodeDataMigrationFailure) + assert not hasattr(outcome, "data") + assert [(blocker.code, blocker.method_id, blocker.value) for blocker in outcome.blockers] == [ + ("unsupported-delivery-method", "unknown-1", "future-one"), + ("invalid-email", _EMAIL_METHOD_ID, "invalid-email"), + ("unsupported-delivery-method", "unknown-2", "future-two"), + ] + + +def test_service_orders_duplicate_method_ids_by_canonical_index_not_id() -> None: + duplicate_method_id = "11111111-1111-4111-8111-111111111111" + node_data = HumanInputNodeDataFull.model_validate( + { + "title": "Approval", + "delivery_methods": [ + { + "id": duplicate_method_id, + "type": "email", + "config": { + "subject": "Review", + "body": "Please review", + "recipients": {"items": [{"type": "external", "email": "first-invalid"}]}, + }, + }, + { + "id": duplicate_method_id, + "type": "email", + "config": { + "subject": "Review", + "body": "Please review", + "recipients": {"items": [{"type": "external", "email": "second-invalid"}]}, + }, + }, + ], + } + ) + issue = LegacyDeliveryParseIssue( + code=MigrationBlockerCode.UNSUPPORTED_DELIVERY_METHOD, + method_position=1, + method_id="unsupported-between-duplicates", + value="im", + ) + service = HumanInputNodeDataMigrationService(member_email_lookup=_LookupSpy()) + + outcome = service.migrate( + tenant_id=TenantId("workspace-1"), + nodes=( + MigrationNode( + "node-1", + node_data, + method_positions=(0, 2), + preflight_issues=(issue,), + ), + ), + ) + + assert isinstance(outcome, NodeDataMigrationFailure) + assert [(blocker.code, blocker.method_id, blocker.value) for blocker in outcome.blockers] == [ + ("invalid-email", duplicate_method_id, "first-invalid"), + ("unsupported-delivery-method", "unsupported-between-duplicates", "im"), + ("invalid-email", duplicate_method_id, "second-invalid"), + ("missing-recipients", None, None), + ] + + +def test_service_aggregates_real_im_and_disabled_unknown_method_blockers() -> None: + lookup = _LookupSpy() + service = HumanInputNodeDataMigrationService(member_email_lookup=lookup) + node_data = HumanInputNodeDataFull.model_validate( + { + "title": "Approval", + "delivery_methods": [], + } + ) + issues = ( + LegacyDeliveryParseIssue( + code=MigrationBlockerCode.UNSUPPORTED_DELIVERY_METHOD, + method_position=0, + method_id="im-1", + value="im", + ), + LegacyDeliveryParseIssue( + code=MigrationBlockerCode.CONFIGURED_DISABLED_METHOD, + method_position=1, + method_id="future-1", + value="future-channel", + ), + ) + + outcome = service.migrate( + tenant_id=TenantId("workspace-1"), + nodes=(MigrationNode("node-1", node_data, preflight_issues=issues),), + ) + + assert isinstance(outcome, NodeDataMigrationFailure) + assert [(blocker.node_id, blocker.code, blocker.method_id, blocker.value) for blocker in outcome.blockers] == [ + ("node-1", "unsupported-delivery-method", "im-1", "im"), + ("node-1", "configured-disabled-method", "future-1", "future-channel"), + ("node-1", "missing-recipients", None, None), + ] + + +def test_batch_aggregates_blockers_discards_valid_results_and_retries_equivalently() -> None: + lookup = _LookupSpy(frozenset({"missing-member"})) + service = HumanInputNodeDataMigrationService(member_email_lookup=lookup) + valid_node = HumanInputNodeDataFull.model_validate( + { + "title": "Valid", + "delivery_methods": [{"id": _WEBAPP_METHOD_ID, "type": "webapp", "config": {}}], + } + ) + invalid_node = HumanInputNodeDataFull.model_validate( + { + "title": "Invalid", + "delivery_methods": [ + { + "id": _EMAIL_METHOD_ID, + "type": "email", + "config": { + "subject": "Review", + "body": "Please review", + "recipients": { + "items": [ + {"type": "external", "email": "invalid-email"}, + {"type": "member", "reference_id": "missing-member"}, + ] + }, + }, + } + ], + } + ) + nodes = (MigrationNode("node-valid", valid_node), MigrationNode("node-invalid", invalid_node)) + + first = service.migrate(tenant_id=TenantId("workspace-1"), nodes=nodes) + second = service.migrate(tenant_id=TenantId("workspace-1"), nodes=nodes) + + assert first == second + assert isinstance(first, NodeDataMigrationFailure) + assert not hasattr(first, "data") + assert [(blocker.node_id, blocker.node_title, blocker.code) for blocker in first.blockers] == [ + ("node-invalid", "Invalid", "invalid-email"), + ("node-invalid", "Invalid", "unresolved-member"), + ("node-invalid", "Invalid", "missing-recipients"), + ] + assert lookup.calls == [ + ("workspace-1", ("missing-member",)), + ("workspace-1", ("missing-member",)), + ] + + +def test_success_blocked_and_repeated_requests_have_no_persistence_write_boundary() -> None: + service_source = inspect.getsource(service_module) + for forbidden_dependency in ( + "extensions.ext_database", + "models.", + "repositories.", + "db.session", + ".commit(", + ".flush(", + ): + assert forbidden_dependency not in service_source + + lookup = _ReadOnlyBoundarySpy() + service = HumanInputNodeDataMigrationService(member_email_lookup=lookup) + successful_nodes = ( + MigrationNode( + "node-success", + HumanInputNodeDataFull.model_validate( + { + "title": "Success", + "delivery_methods": [{"id": _WEBAPP_METHOD_ID, "type": "webapp", "config": {}}], + } + ), + ), + ) + blocked_nodes = ( + MigrationNode( + "node-blocked", + HumanInputNodeDataFull.model_validate({"title": "Blocked", "delivery_methods": []}), + ), + ) + + success = service.migrate(tenant_id=TenantId("workspace-1"), nodes=successful_nodes) + blocked = service.migrate(tenant_id=TenantId("workspace-1"), nodes=blocked_nodes) + repeated_first = service.migrate(tenant_id=TenantId("workspace-1"), nodes=successful_nodes) + repeated_second = service.migrate(tenant_id=TenantId("workspace-1"), nodes=successful_nodes) + + assert isinstance(success, NodeDataMigrationSuccess) + assert isinstance(blocked, NodeDataMigrationFailure) + assert repeated_first == repeated_second == success + assert lookup.write_attempts == [] + + +def test_whole_workspace_does_not_enumerate_members() -> None: + lookup = _LookupSpy() + service = HumanInputNodeDataMigrationService(member_email_lookup=lookup) + node_data = HumanInputNodeDataFull.model_validate( + { + "title": "Approval", + "delivery_methods": [ + { + "id": _EMAIL_METHOD_ID, + "type": "email", + "config": { + "subject": "Review", + "body": "Please review", + "recipients": {"whole_workspace": True}, + }, + } + ], + } + ) + + outcome = service.migrate( + tenant_id=TenantId("workspace-1"), + nodes=(MigrationNode("node-1", node_data),), + ) + + assert isinstance(outcome, NodeDataMigrationSuccess) + assert lookup.calls == [("workspace-1", ())] + + +class _TrackingReadSession(Session): + close_count = 0 + commit_count = 0 + flush_count = 0 + + @override + def close(self) -> None: + _TrackingReadSession.close_count += 1 + super().close() + + @override + def commit(self) -> None: + _TrackingReadSession.commit_count += 1 + super().commit() + + @override + def flush(self, objects: Sequence[object] | None = None) -> None: + _TrackingReadSession.flush_count += 1 + super().flush(objects) + + +def test_sql_lookup_is_tenant_scoped_active_read_only_and_closes_session( + sqlite_session_factory: sessionmaker[Session], +) -> None: + tenant_id = TenantId(str(uuid4())) + other_tenant_id = TenantId(str(uuid4())) + with sqlite_session_factory.begin() as arrange_session: + active = Account(name="Active", email=" ACTIVE@Example.COM ") + banned = Account(name="Banned", email="banned@example.com", status=AccountStatus.BANNED) + invalid_email = Account(name="Invalid", email="invalid-email") + cross_workspace = Account(name="Cross", email="cross@example.com") + arrange_session.add_all([active, banned, invalid_email, cross_workspace]) + arrange_session.flush() + arrange_session.add_all( + [ + TenantAccountJoin(tenant_id=tenant_id, account_id=active.id), + TenantAccountJoin(tenant_id=tenant_id, account_id=banned.id), + TenantAccountJoin(tenant_id=tenant_id, account_id=invalid_email.id), + TenantAccountJoin(tenant_id=other_tenant_id, account_id=cross_workspace.id), + ] + ) + + _TrackingReadSession.close_count = 0 + _TrackingReadSession.commit_count = 0 + _TrackingReadSession.flush_count = 0 + engine = sqlite_session_factory.kw["bind"] + assert isinstance(engine, Engine) + read_factory = sessionmaker(bind=engine, class_=_TrackingReadSession, expire_on_commit=False) + select_count = 0 + + def count_selects( + _connection: object, + _cursor: object, + statement: str, + _parameters: object, + _context: object, + _executemany: object, + ) -> None: + nonlocal select_count + if statement.lstrip().upper().startswith("SELECT"): + select_count += 1 + + event.listen(engine, "before_cursor_execute", count_selects) + try: + lookup = SQLAlchemyWorkspaceMemberEmailLookup(read_factory) + member_emails = lookup.find_member_emails( + tenant_id, + (active.id, banned.id, invalid_email.id, cross_workspace.id, str(uuid4())), + ) + finally: + event.remove(engine, "before_cursor_execute", count_selects) + + assert isinstance(member_emails, migration_module.MemberEmailSnapshot) + assert [(entry.member_id, str(entry.email)) for entry in member_emails.entries] == [ + (active.id, "active@example.com") + ] + assert select_count == 1 + assert _TrackingReadSession.close_count == 1 + assert _TrackingReadSession.commit_count == 0 + assert _TrackingReadSession.flush_count == 0 + + +def test_composition_injects_sql_lookup_and_core_converter_has_no_infrastructure_imports() -> None: + service = build_human_input_node_data_migration_service() + + assert isinstance(service, HumanInputNodeDataMigrationService) + assert isinstance(service._member_email_lookup, SQLAlchemyWorkspaceMemberEmailLookup) + + converter_source = inspect.getsource(migration_module) + for forbidden_import in ("flask", "sqlalchemy", "models", "repositories"): + assert forbidden_import not in converter_source diff --git a/api/tests/unit_tests/services/human_input_v2/test_runtime_service_api.py b/api/tests/unit_tests/services/human_input_v2/test_runtime_service_api.py new file mode 100644 index 00000000000..c112ea02148 --- /dev/null +++ b/api/tests/unit_tests/services/human_input_v2/test_runtime_service_api.py @@ -0,0 +1,98 @@ +"""Real runtime initialization must retain the Service API run's EndUser grant.""" + +import hashlib +from uuid import uuid4 + +import pytest +import sqlalchemy as sa +from cryptography.fernet import Fernet +from sqlalchemy.orm import Session, sessionmaker + +from core.app.entities.app_invoke_entities import DifyRunContext, InvokeFrom, UserFrom +from core.helper import encrypter +from core.workflow.nodes.human_input.enums import HumanInputFormStatus +from core.workflow.nodes.human_input_v2.entities import HumanInputNodeData +from core.workflow.system_variables import SystemVariableKey, system_variable_selector +from graphon.enums import WorkflowExecutionStatus +from graphon.runtime import VariablePool +from models.enums import CreatorUserRole, EndUserType, WorkflowRunTriggeredFrom +from models.human_input_v2 import HumanInputDelivery, HumanInputRecipient, RecipientSubjectType +from models.model import AppMode, EndUser +from models.workflow import WorkflowRun, WorkflowType +from repositories.human_input_v2.delivery_repository import SubmissionAuthType +from services.human_input_v2.composition import WorkflowHumanInputRuntime +from tests.helpers.unit.model_factories import make_app, make_tenant + + +@pytest.mark.parametrize("invoke_from", [InvokeFrom.WEB_APP, InvokeFrom.SERVICE_API]) +def test_end_user_initiator_creates_a_reusable_approval_token( + sqlite_session_factory: sessionmaker[Session], + monkeypatch: pytest.MonkeyPatch, + invoke_from: InvokeFrom, +) -> None: + tenant = make_tenant(tenant_id=str(uuid4())) + application = make_app(app_id=str(uuid4()), tenant_id=tenant.id, mode=AppMode.WORKFLOW) + end_user = EndUser( + id=str(uuid4()), + tenant_id=tenant.id, + app_id=application.id, + type=EndUserType.SERVICE_API if invoke_from == InvokeFrom.SERVICE_API else EndUserType.BROWSER, + session_id="runtime-initiator", + ) + run = WorkflowRun( + id=str(uuid4()), + tenant_id=tenant.id, + app_id=application.id, + workflow_id=str(uuid4()), + type=WorkflowType.WORKFLOW, + triggered_from=WorkflowRunTriggeredFrom.APP_RUN, + version="1", + status=WorkflowExecutionStatus.RUNNING, + created_by_role=CreatorUserRole.END_USER, + created_by=end_user.id, + ) + with sqlite_session_factory.begin() as session: + session.add_all([tenant, application, end_user, run]) + cipher = Fernet(Fernet.generate_key()) + monkeypatch.setattr(encrypter, "encrypt_token", lambda _tenant, token: cipher.encrypt(token.encode()).decode()) + monkeypatch.setattr(encrypter, "decrypt_token", lambda _tenant, token: cipher.decrypt(token.encode()).decode()) + runtime = WorkflowHumanInputRuntime( + DifyRunContext( + tenant_id=tenant.id, + app_id=application.id, + user_id=end_user.id, + user_from=UserFrom.END_USER, + invoke_from=invoke_from, + ) + ) + node = HumanInputNodeData.model_validate( + { + "title": "Approval", + "form_content": "Review {{#$output.note#}}", + "inputs": [{"type": "paragraph", "output_variable_name": "note"}], + "user_actions": [{"id": "approve", "title": "Approve"}], + "recipients_spec": [{"type": "initiator"}], + "message_template": {"subject": "Review", "body": "Open {{#url#}}"}, + "debug_mode": {"enabled": False, "channels": []}, + } + ) + pool = VariablePool() + pool.add(system_variable_selector(SystemVariableKey.WORKFLOW_EXECUTION_ID), run.id) + execution_id = str(uuid4()) + prepared = runtime.prepare_form(node_execution_id=execution_id, node_data=node, variable_pool=pool) + assert prepared.form.status == HumanInputFormStatus.WAITING + assert prepared.form_token is not None + with sqlite_session_factory() as session: + recipient = session.scalars(sa.select(HumanInputRecipient)).one() + assert recipient.subject_type == RecipientSubjectType.END_USER + assert recipient.subject_value == end_user.id + delivery = session.scalars(sa.select(HumanInputDelivery)).one() + assert delivery.recipient_id == recipient.id + assert delivery.auth_type == SubmissionAuthType.WEB_APP + assert delivery.token_hash == hashlib.sha256(prepared.form_token.get_secret_value().encode()).hexdigest() + revisited = runtime.prepare_form(node_execution_id=execution_id, node_data=node, variable_pool=pool) + assert revisited.form.id == prepared.form.id + assert revisited.form_token == prepared.form_token + with sqlite_session_factory() as session: + assert session.scalar(sa.select(sa.func.count()).select_from(HumanInputRecipient)) == 1 + assert session.scalar(sa.select(sa.func.count()).select_from(HumanInputDelivery)) == 1 diff --git a/api/tests/unit_tests/services/human_input_v2/test_submission_service.py b/api/tests/unit_tests/services/human_input_v2/test_submission_service.py new file mode 100644 index 00000000000..d53db53a23d --- /dev/null +++ b/api/tests/unit_tests/services/human_input_v2/test_submission_service.py @@ -0,0 +1,211 @@ +"""Accepted submissions retain the exact authenticated delivery across all surfaces.""" + +from collections.abc import Callable +from hashlib import sha256 + +import pytest +from flask.testing import FlaskClient +from sqlalchemy import event +from sqlalchemy.orm import Session + +from core.human_input_v2.entities import IMProvider +from core.workflow.nodes.human_input.enums import HumanInputFormStatus +from models.human_input_v2 import HumanInputForm +from repositories.human_input_v2.delivery_repository import DeliveryCreateParams +from repositories.human_input_v2.im_channel_repository import IMChannelId +from repositories.human_input_v2.sqlalchemy_delivery_repository import SQLAlchemyDeliveryRepository +from repositories.human_input_v2.sqlalchemy_form_repository import SQLAlchemyFormRepository +from services.human_input_v2.email_otp_service import EmailOtpError +from services.human_input_v2.form_service import AccountInitiator +from services.human_input_v2.submission_service import ( + HumanInputFormError, + HumanInputSubmissionService, + IMFormActor, + ServiceAPIFormActor, + WebAppFormActor, +) +from tests.unit_tests.services.human_input_v2.test_form_delivery_runtime import Context, PublicFormApproval + +pytest_plugins = ( + "tests.unit_tests.services.human_input_v2.test_form_delivery_runtime", + "tests.unit_tests.services.human_input_v2.test_im_webhook_runtime", +) + + +@pytest.fixture(params=[False, True], ids=["commit", "rollback-then-retry"]) +def fail_commit(request: pytest.FixtureRequest) -> bool: + return request.param is True + + +@pytest.fixture +def submission_service(context: Context, monkeypatch: pytest.MonkeyPatch) -> HumanInputSubmissionService: + from tasks.app_generate.workflow_execute_task import resume_app_execution + + monkeypatch.setattr(resume_app_execution, "apply_async", lambda **_kwargs: None) + return HumanInputSubmissionService(session_factory=context.sessions) + + +def _assert_delivery_attribution( + context: Context, + form_id: str, + token: str, + submit: Callable[[str], None], + *, + fail_commit: bool, + im_retry: bool = False, +) -> None: + with context.sessions.begin() as session: + deliveries = SQLAlchemyDeliveryRepository(session) + original = deliveries.get_delivery_by_token_hash(sha256(token.encode()).hexdigest()) + assert original is not None + newer_token = f"{token}-newer" + newer = deliveries.create_delivery( + tenant_id=original.tenant_id, + form_id=form_id, + params=DeliveryCreateParams( + recipient_id=original.recipient_id, + token_hash=sha256(newer_token.encode()).hexdigest(), + auth_type=original.auth_type, + target_snapshot=original.target_snapshot, + ), + ) + assert newer.id != original.id + + if fail_commit: + + def reject_commit(session: Session) -> None: + if session.get_one(HumanInputForm, form_id).status == HumanInputFormStatus.SUBMITTED: + raise ConnectionError("Submission commit failed") + + event.listen(context.sessions, "before_commit", reject_commit) + try: + with pytest.raises(ConnectionError, match="Submission commit failed"): + submit(token) + finally: + event.remove(context.sessions, "before_commit", reject_commit) + with context.sessions() as session: + stored = session.get_one(HumanInputForm, form_id) + assert stored.status == HumanInputFormStatus.WAITING + assert stored.submitted_by_recipient_id is None + assert stored.submitted_by_delivery_id is None + + submit(token) + with context.sessions() as session: + stored = session.get_one(HumanInputForm, form_id) + assert stored.submitted_by_delivery_id == original.id + assert stored.submitted_by_recipient_id == original.recipient_id + repository = SQLAlchemyFormRepository(session, original.tenant_id, stored.app_id) + accepted = repository.get_form_by_id(form_id) + assert accepted is not None + assert accepted.submission is not None + assert accepted.submission.submitted_by_delivery_id == original.id + + for duplicate_token in (token, newer_token): + if im_retry: + submit(duplicate_token) + else: + with pytest.raises((HumanInputFormError, EmailOtpError)): + submit(duplicate_token) + with context.sessions() as session: + assert ( + SQLAlchemyFormRepository(session, original.tenant_id, stored.app_id).get_form_by_id(form_id) == accepted + ) + + +def test_console_submission_records_authenticated_delivery( + context: Context, + console_approval: tuple[str, str], + submission_service: HumanInputSubmissionService, + fail_commit: bool, +) -> None: + form_id, token = console_approval + initiator = context.execution.initiator + assert isinstance(initiator, AccountInitiator) + _assert_delivery_attribution( + context, + form_id, + token, + lambda token: submission_service.submit_console_form( + account_id=initiator.account_id, form_token=token, action="approve", inputs={"note": "Approved"} + ), + fail_commit=fail_commit, + ) + + +@pytest.mark.parametrize("surface", ["web_app", "service_api"]) +def test_end_user_submission_records_authenticated_delivery( + context: Context, + web_app_approval: tuple[FlaskClient, str, str, dict[str, str], str], + submission_service: HumanInputSubmissionService, + fail_commit: bool, + surface: str, +) -> None: + _, form_id, token, _, end_user_id = web_app_approval + tenant_id, app_id = context.execution.tenant_id, context.execution.app_id + + def submit(token: str) -> None: + if surface == "web_app": + submission_service.submit_web_app_form( + actor=WebAppFormActor(tenant_id, app_id, end_user_id), + form_token=token, + action="approve", + inputs={"note": "Approved"}, + ) + else: + submission_service.submit_service_api_form( + actor=ServiceAPIFormActor(tenant_id, app_id), + end_user_id=end_user_id, + form_token=token, + action="approve", + inputs={"note": "Approved"}, + ) + + _assert_delivery_attribution(context, form_id, token, submit, fail_commit=fail_commit) + + +def test_public_email_submission_records_authenticated_delivery( + context: Context, + public_form_approval: PublicFormApproval, + submission_service: HumanInputSubmissionService, + fail_commit: bool, +) -> None: + from controllers.web.human_input_form_access_request import build_email_otp_service + + approval = public_form_approval + _assert_delivery_attribution( + context, + approval.form_id, + approval.token, + lambda token: submission_service.submit_public_form( + email_otp_service=build_email_otp_service(), + form_token=token, + action="approve", + inputs={"note": "Approved"}, + challenge_token=approval.challenge.challenge_token, + otp_code=approval.otp_code, + ip_address=None, + ), + fail_commit=fail_commit, + ) + + +def test_im_submission_records_authenticated_delivery( + context: Context, + approval: tuple[str, IMChannelId], + submission_service: HumanInputSubmissionService, + fail_commit: bool, +) -> None: + form_id, channel_id = approval + _assert_delivery_attribution( + context, + form_id, + "slack-approval-correlation-token", + lambda token: submission_service.submit_im_form( + actor=IMFormActor(channel_id, IMProvider.SLACK, "T012SANITIZED", "U012SANITIZED"), + form_token=token, + action="approve", + inputs={"note": "Approved", "decision": "选项 α"}, + ), + fail_commit=fail_commit, + im_retry=True, + ) diff --git a/api/tests/unit_tests/services/test_feature_service_im.py b/api/tests/unit_tests/services/test_feature_service_im.py new file mode 100644 index 00000000000..96188e2764d --- /dev/null +++ b/api/tests/unit_tests/services/test_feature_service_im.py @@ -0,0 +1,25 @@ +from collections.abc import Callable +from unittest.mock import patch + +import pytest + +from enums import DeploymentEdition +from services.feature_service import FeatureService + + +@pytest.mark.parametrize( + ("edition", "centrally_managed"), + [ + (DeploymentEdition.COMMUNITY, False), + (DeploymentEdition.CLOUD, False), + (DeploymentEdition.ENTERPRISE, True), + ], +) +def test_workspace_features_report_im_management_mode( + config_overrides: Callable[..., None], edition: DeploymentEdition, centrally_managed: bool +) -> None: + config_overrides(DEPLOYMENT_EDITION=edition) + with patch("services.feature_service.EnterpriseService.get_workspace_info", return_value={}): + features = FeatureService.get_features("") + + assert features.model_dump()["im_centrally_managed"] is centrally_managed diff --git a/api/tests/unit_tests/services/test_human_input_email_channel_management_service.py b/api/tests/unit_tests/services/test_human_input_email_channel_management_service.py new file mode 100644 index 00000000000..ab2c013f6e0 --- /dev/null +++ b/api/tests/unit_tests/services/test_human_input_email_channel_management_service.py @@ -0,0 +1,559 @@ +"""Email configuration owner contracts without Flask, SQLAlchemy, or network I/O.""" + +from dataclasses import replace +from datetime import datetime +from typing import Never + +import pytest +from pydantic import ValidationError +from sqlalchemy.orm import Session + +from core.helper import encrypter +from core.human_input_v2.entities import EmailProviderType +from core.human_input_v2.shared import ( + AccountId, + EmailProviderId, + NormalizedEmail, + TenantId, + WorkspaceScope, +) +from repositories.human_input_v2.channel_credentials import ChannelEncryptedCredentials +from repositories.human_input_v2.email_channel import ( + CreateEmailConfigurationResult, + CreateEmailConfigurationStatus, + DeleteEmailConfigurationResult, + DeleteEmailConfigurationStatus, + EmailChannelConfiguration, + EmailConfigurationSnapshot, + EmailProviderOperationError, + EmailProviderValidationError, + ResendCandidate, + UpdateEmailConfigurationResult, + UpdateEmailConfigurationStatus, +) +from repositories.human_input_v2.email_channel.entities import ResendCredentials +from services.human_input_v2.email_channel_management_service import HumanInputEmailChannelManagementService +from services.human_input_v2.email_credential_codec import load_email_credentials, seal_email_credentials +from services.human_input_v2.errors import ( + ChannelAlreadyConfiguredError, + ChannelNotFoundError, + ChannelProviderError, + ProviderConfigurationUpdatedError, + ProviderFailureKind, + UnexpectedChannelProviderError, +) + +_NOW = datetime(2026, 8, 20, 8) +_LATER = datetime(2026, 8, 20, 9) +_SCOPE = WorkspaceScope(TenantId("workspace-1")) +_ACTOR_ID = AccountId("account-1") + + +class FakeRepository: + def __init__(self, current: EmailChannelConfiguration | None = None) -> None: + self.current = current + self.events: list[str] = [] + self.force_create_conflict = False + self.force_update_stale = False + self.force_delete_stale = False + + def load(self, tenant_id: TenantId) -> EmailChannelConfiguration | None: + self.events.append("load") + if self.current is None or self.current.tenant_id != tenant_id: + return None + return self.current + + def create(self, configuration: EmailChannelConfiguration) -> CreateEmailConfigurationResult: + self.events.append("create") + if self.force_create_conflict or self.current is not None: + return CreateEmailConfigurationResult(CreateEmailConfigurationStatus.CONFLICT, None) + self.current = configuration + return CreateEmailConfigurationResult(CreateEmailConfigurationStatus.CREATED, configuration) + + def update( + self, + configuration: EmailChannelConfiguration, + *, + expected: EmailConfigurationSnapshot, + now: datetime, + ) -> UpdateEmailConfigurationResult: + self.events.append("update") + if self.force_update_stale or self.current is None or self.current.snapshot != expected: + return UpdateEmailConfigurationResult(UpdateEmailConfigurationStatus.STALE, None) + self.current = replace( + configuration, + config_version=self.current.config_version + 1, + updated_at=now, + ) + return UpdateEmailConfigurationResult(UpdateEmailConfigurationStatus.UPDATED, self.current) + + def delete(self, tenant_id: TenantId, *, expected: EmailConfigurationSnapshot) -> DeleteEmailConfigurationResult: + self.events.append("delete") + if self.current is None or self.current.tenant_id != tenant_id: + return DeleteEmailConfigurationResult(DeleteEmailConfigurationStatus.NOT_CONFIGURED) + if self.force_delete_stale or self.current.snapshot != expected: + return DeleteEmailConfigurationResult(DeleteEmailConfigurationStatus.STALE) + self.current = None + return DeleteEmailConfigurationResult(DeleteEmailConfigurationStatus.DELETED) + + +class FakeProviderGateway: + def __init__(self, events: list[str]) -> None: + self._events = events + self.failure: Exception | None = None + self.settings: list[ResendCandidate] = [] + + def send_test(self, candidate: ResendCandidate) -> None: + self._events.append("send_test") + self.settings.append(candidate) + if self.failure is not None: + raise self.failure + + +def _candidate(api_key: str = "new-api-key") -> ResendCandidate: + return ResendCandidate( + sender_email=NormalizedEmail("Sender@Example.com"), + sender_name="Sender", + credentials=ResendCredentials(api_key=api_key), + ) + + +def _configuration( + *, + configuration_id: str = "email-1", + updated_at: datetime = _NOW, +) -> EmailChannelConfiguration: + return EmailChannelConfiguration( + id=EmailProviderId(configuration_id), + tenant_id=_SCOPE.id, + sender_email=NormalizedEmail("old@example.com"), + sender_name="Old Sender", + encrypted_credentials=seal_email_credentials(_SCOPE.id, ResendCredentials(api_key="saved-api-key")), + configured_by_account_id=_ACTOR_ID, + created_at=_NOW, + updated_at=updated_at, + ) + + +def _service( + repository: FakeRepository, + provider_gateway: FakeProviderGateway, +) -> HumanInputEmailChannelManagementService: + return HumanInputEmailChannelManagementService( + provider_gateway, + session_factory=Session, + repository_factory=lambda _session: repository, + clock=lambda: _LATER, + id_factory=lambda: "email-new", + ) + + +@pytest.fixture(autouse=True) +def _protect_email_credentials(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + encrypter, + "encrypt_token", + lambda tenant_id, api_key: f"{tenant_id}:protected:{api_key}", + ) + monkeypatch.setattr( + encrypter, "decrypt_token", lambda tenant_id, ciphertext: ciphertext.removeprefix(f"{tenant_id}:protected:") + ) + + +def test_email_credentials_require_non_empty_ciphertext() -> None: + with pytest.raises(ValidationError): + ChannelEncryptedCredentials(ciphertext="") + + +def test_create_protects_without_provider_io_and_returns_safe_owner_view( + monkeypatch: pytest.MonkeyPatch, +) -> None: + events: list[str] = [] + repository = FakeRepository() + provider_gateway = FakeProviderGateway(events) + monkeypatch.setattr( + encrypter, + "encrypt_token", + lambda tenant_id, api_key: events.append("protect") or f"{tenant_id}:protected:{api_key}", + ) + + view = _service(repository, provider_gateway).create(_SCOPE, _ACTOR_ID, _candidate()) + + assert events == ["protect"] + assert repository.events == ["load", "create"] + assert view.id == EmailProviderId("email-new") + assert view.provider is EmailProviderType.RESEND + assert view.sender_email == "sender@example.com" + assert view.sender_name == "Sender" + assert view.revision == repository.current.snapshot if repository.current is not None else False + assert "new-api-key" not in repr(view) + assert repository.current is not None + assert "new-api-key" not in repr(repository.current) + + +def test_create_rejects_existing_configuration_before_provider_io() -> None: + events: list[str] = [] + repository = FakeRepository(_configuration()) + provider_gateway = FakeProviderGateway(events) + + with pytest.raises(ChannelAlreadyConfiguredError): + _service(repository, provider_gateway).create(_SCOPE, _ACTOR_ID, _candidate()) + + assert events == [] + assert repository.events == ["load"] + + +def test_update_requires_matching_path_identity_and_owner_revision_before_provider_io() -> None: + current = _configuration() + for channel_id, expected_revision in ( + (EmailProviderId("email-other"), current.snapshot), + (current.id, replace(current.snapshot, config_version=current.config_version + 1)), + ): + events: list[str] = [] + repository = FakeRepository(current) + provider_gateway = FakeProviderGateway(events) + service = _service(repository, provider_gateway) + + expected_error = ChannelNotFoundError if channel_id != current.id else ProviderConfigurationUpdatedError + with pytest.raises(expected_error): + service.update(_SCOPE, channel_id, expected_revision, _ACTOR_ID, _candidate()) + + assert events == [] + assert repository.events == ["load"] + assert repository.current == current + + +def test_update_uses_repository_cas_and_advances_the_owner_revision( + monkeypatch: pytest.MonkeyPatch, +) -> None: + events: list[str] = [] + repository = FakeRepository(_configuration()) + provider_gateway = FakeProviderGateway(events) + monkeypatch.setattr( + encrypter, + "encrypt_token", + lambda tenant_id, api_key: events.append("protect") or f"{tenant_id}:protected:{api_key}", + ) + service = _service(repository, provider_gateway) + current = service.get(_SCOPE, EmailProviderId("email-1")) + + updated = service.update( + _SCOPE, + current.id, + current.revision, + _ACTOR_ID, + _candidate("rotated-api-key"), + ) + + assert events == ["protect"] + assert repository.events == ["load", "load", "update"] + assert updated.id == current.id + assert updated.revision != current.revision + assert repository.current is not None + assert repository.current.sender_name == "Sender" + + +def test_update_maps_a_racing_repository_write_to_provider_configuration_updated() -> None: + events: list[str] = [] + repository = FakeRepository(_configuration()) + repository.force_update_stale = True + provider_gateway = FakeProviderGateway(events) + service = _service(repository, provider_gateway) + current = service.get_current(_SCOPE) + assert current is not None + + with pytest.raises(ProviderConfigurationUpdatedError): + service.update(_SCOPE, current.id, current.revision, _ACTOR_ID, _candidate()) + + assert repository.current == _configuration() + + +def test_partial_update_cannot_overwrite_credentials_changed_during_encryption(monkeypatch: pytest.MonkeyPatch) -> None: + from services.human_input_v2.credential_update import ResendChannelUpdate, ResendCredentialsUpdate, Unchanged + + current = _configuration() + winner = replace( + current, + encrypted_credentials=seal_email_credentials(_SCOPE.id, _candidate("concurrent-api-key").credentials), + config_version=2, + ) + repository = FakeRepository(current) + gateway = FakeProviderGateway([]) + + def encrypt_while_another_save_wins(tenant_id: str, plaintext: str) -> str: + repository.current = winner + return f"{tenant_id}:protected:{plaintext}" + + monkeypatch.setattr(encrypter, "encrypt_token", encrypt_while_another_save_wins) + edit = ResendChannelUpdate(Unchanged.VALUE, "Stale Sender", ResendCredentialsUpdate(api_key=Unchanged.VALUE)) + + with pytest.raises(ProviderConfigurationUpdatedError): + _service(repository, gateway).update(_SCOPE, current.id, current.snapshot, _ACTOR_ID, edit) + + assert repository.current == winner + assert repository.current is not None + assert ( + load_email_credentials(repository.current.tenant_id, repository.current.encrypted_credentials) + == _candidate("concurrent-api-key").credentials + ) + assert gateway.settings == [] + + +def test_delete_requires_identity_and_snapshot_cas() -> None: + events: list[str] = [] + repository = FakeRepository(_configuration()) + provider_gateway = FakeProviderGateway(events) + service = _service(repository, provider_gateway) + current = service.get_current(_SCOPE) + assert current is not None + + with pytest.raises(ProviderConfigurationUpdatedError): + service.delete( + _SCOPE, + current.id, + replace(current.revision, config_version=current.revision.config_version + 1), + ) + assert repository.current is not None + + deleted_id = service.delete(_SCOPE, current.id, current.revision) + + assert deleted_id == current.id + assert repository.current is None + assert events == [] + + +def test_delete_maps_repository_cas_loss_without_deleting_the_current_configuration() -> None: + events: list[str] = [] + repository = FakeRepository(_configuration()) + repository.force_delete_stale = True + service = _service(repository, FakeProviderGateway(events)) + current = service.get_current(_SCOPE) + assert current is not None + + with pytest.raises(ProviderConfigurationUpdatedError): + service.delete(_SCOPE, current.id, current.revision) + + assert repository.current is not None + + +def test_candidate_test_uses_only_submitted_credentials_and_never_persists() -> None: + events: list[str] = [] + current = _configuration() + repository = FakeRepository(current) + provider_gateway = FakeProviderGateway(events) + + result = _service(repository, provider_gateway).test(_SCOPE, _candidate()) + + assert result is None + assert events == ["send_test"] + assert repository.events == [] + assert repository.current == current + assert provider_gateway.settings[0].credentials.api_key == "new-api-key" + + +@pytest.mark.parametrize( + ("provider_error", "expected_kind"), + [ + (EmailProviderValidationError("raw-invalid-code"), ProviderFailureKind.INVALID_CREDENTIALS), + (EmailProviderOperationError("raw-connection-code"), ProviderFailureKind.CONNECTION_FAILURE), + ], +) +def test_expected_provider_failures_are_safely_classified( + provider_error: Exception, + expected_kind: ProviderFailureKind, +) -> None: + events: list[str] = [] + repository = FakeRepository() + provider_gateway = FakeProviderGateway(events) + provider_gateway.failure = provider_error + + with pytest.raises(ChannelProviderError) as captured: + _service(repository, provider_gateway).test(_SCOPE, _candidate("sensitive-api-key")) + + assert captured.value.kind is expected_kind + assert "raw-" not in str(captured.value) + assert "sensitive-api-key" not in repr(captured.value) + assert repository.events == [] + + +def test_unexpected_provider_failure_is_detail_free_and_preserves_state() -> None: + events: list[str] = [] + current = _configuration() + repository = FakeRepository(current) + provider_gateway = FakeProviderGateway(events) + provider_gateway.failure = RuntimeError("raw provider response with sensitive-api-key") + service = _service(repository, provider_gateway) + with pytest.raises(UnexpectedChannelProviderError) as captured: + service.test(_SCOPE, _candidate("sensitive-api-key")) + + assert "raw provider response" not in str(captured.value) + assert "sensitive-api-key" not in repr(captured.value) + assert repository.current == current + assert repository.events == [] + + +def test_email_test_exposes_upstream_code_in_safe_user_description() -> None: + repository = FakeRepository() + gateway = FakeProviderGateway([]) + gateway.failure = EmailProviderValidationError("invalid_api_key", provider_error_code="restricted_api_key") + + with pytest.raises(ChannelProviderError) as captured: + _service(repository, gateway).test(_SCOPE, _candidate("sensitive-api-key")) + + assert captured.value.status_description == ( + "The submitted credentials are invalid. API error code: restricted_api_key." + ) + assert "sensitive-api-key" not in str(captured.value) + + +def test_credential_protection_failure_propagates_and_never_persists( + monkeypatch: pytest.MonkeyPatch, +) -> None: + events: list[str] = [] + repository = FakeRepository() + failure = OSError("encryption backend unavailable") + + def fail_protection(_tenant_id: str, _api_key: str) -> Never: + raise failure + + monkeypatch.setattr(encrypter, "encrypt_token", fail_protection) + + with pytest.raises(OSError, match="encryption backend unavailable") as captured: + _service(repository, FakeProviderGateway(events)).create( + _SCOPE, + _ACTOR_ID, + _candidate("sensitive-api-key"), + ) + + assert captured.value is failure + assert events == [] + assert repository.events == ["load"] + assert repository.current is None + + +@pytest.mark.parametrize("retain_key", [True, False]) +@pytest.mark.parametrize( + ("sender_field", "expected_email", "expected_name"), + [ + ("sender_name", "old@example.com", "New Sender"), + ("sender_email", "new@example.com", "Old Sender"), + (None, "old@example.com", "Old Sender"), + ], +) +def test_partial_update_preserves_settings_and_protects_the_complete_credentials( + retain_key: bool, sender_field: str | None, expected_email: str, expected_name: str +) -> None: + from services.human_input_v2.credential_update import ResendChannelUpdate, ResendCredentialsUpdate, Unchanged + + current = _configuration() + repository = FakeRepository(current) + gateway = FakeProviderGateway([]) + service = _service(repository, gateway) + edit = ResendChannelUpdate( + sender_email=NormalizedEmail("new@example.com") if sender_field == "sender_email" else Unchanged.VALUE, + sender_name="New Sender" if sender_field == "sender_name" else Unchanged.VALUE, + credentials=ResendCredentialsUpdate(api_key=Unchanged.VALUE if retain_key else "replacement-api-key"), + ) + + view = service.update(_SCOPE, current.id, current.snapshot, _ACTOR_ID, edit) + + assert view.sender_email == expected_email + assert view.sender_name == expected_name + assert repository.current is not None + assert repository.current.sender_email == NormalizedEmail(expected_email) + assert repository.current.sender_name == expected_name + assert load_email_credentials( + repository.current.tenant_id, repository.current.encrypted_credentials + ) == ResendCredentials(api_key="saved-api-key" if retain_key else "replacement-api-key") + assert view.revision.config_version == 2 + assert gateway.settings == [] + + +@pytest.mark.parametrize("temporary_key", [None, "temporary-api-key"]) +def test_saved_test_uses_saved_key_or_override_without_persisting(temporary_key: str | None) -> None: + from services.human_input_v2.credential_update import ResendChannelUpdate, ResendCredentialsUpdate, Unchanged + + current = _configuration() + repository = FakeRepository(current) + gateway = FakeProviderGateway([]) + edit = ResendChannelUpdate( + Unchanged.VALUE, + "Temporary Sender", + ResendCredentialsUpdate(api_key=temporary_key if temporary_key is not None else Unchanged.VALUE), + ) + + _service(repository, gateway).test_saved(_SCOPE, current.id, current.snapshot, edit) + + assert len(gateway.settings) == 1 + assert gateway.settings[0].credentials.api_key == (temporary_key if temporary_key is not None else "saved-api-key") + assert gateway.settings[0].sender_email == NormalizedEmail("old@example.com") + assert gateway.settings[0].sender_name == "Temporary Sender" + assert repository.current == current + + +def test_saved_test_rejects_stale_revision_before_decrypting(monkeypatch: pytest.MonkeyPatch) -> None: + from services.human_input_v2.credential_update import ResendChannelUpdate, ResendCredentialsUpdate, Unchanged + + current = _configuration() + repository = FakeRepository(current) + gateway = FakeProviderGateway([]) + + def unexpected_decrypt(*_args: object) -> Never: + pytest.fail("a stale test must not decrypt credentials") + + monkeypatch.setattr(encrypter, "decrypt_token", unexpected_decrypt) + edit = ResendChannelUpdate(Unchanged.VALUE, Unchanged.VALUE, ResendCredentialsUpdate(api_key=Unchanged.VALUE)) + with pytest.raises(ProviderConfigurationUpdatedError): + _service(repository, gateway).test_saved(_SCOPE, current.id, replace(current.snapshot, config_version=2), edit) + assert gateway.settings == [] + assert repository.current == current + + +@pytest.mark.parametrize("replacement_key", [None, "replacement-api-key"]) +@pytest.mark.parametrize("operation", ["update", "test"]) +def test_decryption_failure_propagates_without_sending_or_persisting( + monkeypatch: pytest.MonkeyPatch, + replacement_key: str | None, + operation: str, +) -> None: + from services.human_input_v2.credential_update import ResendChannelUpdate, ResendCredentialsUpdate, Unchanged + + current = _configuration() + repository = FakeRepository(current) + gateway = FakeProviderGateway([]) + failure = OSError("decryption backend unavailable") + + def fail_decryption(*_args: object) -> Never: + raise failure + + monkeypatch.setattr(encrypter, "decrypt_token", fail_decryption) + edit = ResendChannelUpdate( + Unchanged.VALUE, + Unchanged.VALUE, + ResendCredentialsUpdate(api_key=Unchanged.VALUE if replacement_key is None else replacement_key), + ) + service = _service(repository, gateway) + if operation == "test": + with pytest.raises(OSError, match="decryption backend unavailable") as captured: + service.test_saved(_SCOPE, current.id, current.snapshot, edit) + else: + with pytest.raises(OSError, match="decryption backend unavailable") as captured: + service.update(_SCOPE, current.id, current.snapshot, _ACTOR_ID, edit) + assert captured.value is failure + assert gateway.settings == [] + assert repository.current == current + + +def test_reading_sender_metadata_does_not_decrypt_credentials(monkeypatch: pytest.MonkeyPatch) -> None: + repository = FakeRepository(_configuration()) + + def unavailable_decryption(*_args: object) -> Never: + pytest.fail("configuration metadata must remain readable without decryption") + + monkeypatch.setattr(encrypter, "decrypt_token", unavailable_decryption) + service = _service(repository, FakeProviderGateway([])) + current = service.get_current(_SCOPE) + assert current is not None + assert current.sender_email == "old@example.com" + assert current.sender_name == "Old Sender" + assert service.get(_SCOPE, current.id) == current diff --git a/api/tests/unit_tests/services/test_human_input_form_delivery_provider.py b/api/tests/unit_tests/services/test_human_input_form_delivery_provider.py new file mode 100644 index 00000000000..e6883eea6a4 --- /dev/null +++ b/api/tests/unit_tests/services/test_human_input_form_delivery_provider.py @@ -0,0 +1,338 @@ +from datetime import UTC, datetime + +import pytest +from sqlalchemy import Engine +from sqlalchemy.orm import Session + +from core.workflow.human_input_adapter import ( + DeliveryMethodType, + EmailDeliveryConfig, + EmailDeliveryMethod, + EmailRecipients, +) +from graphon.runtime import VariablePool +from models.human_input import ( + EmailExternalRecipientPayload, + EmailMemberRecipientPayload, + HumanInputDelivery, + HumanInputForm, + HumanInputFormRecipient, + InstantMessageRecipientPayload, + RecipientType, + StandaloneWebAppRecipientPayload, +) +from services import human_input_form_delivery_provider as provider_module +from services.human_input_form_delivery_provider import ( + EmailHumanInputFormDeliveryProvider, + HumanInputFormDeliveryContext, + HumanInputFormDeliveryDispatcher, + HumanInputFormDeliveryProviderRegistry, + HumanInputFormDeliveryRecipientContext, + UnsupportedInstantMessageHumanInputFormDeliveryProvider, +) + + +class _DummyMail: + def __init__(self) -> None: + self.sent: list[dict[str, str]] = [] + + def send(self, *, to: str, subject: str, html: str) -> None: + self.sent.append({"to": to, "subject": subject, "html": html}) + + +class _DummyProvider: + delivery_method_type: DeliveryMethodType + + def __init__(self, delivery_method_type: DeliveryMethodType = DeliveryMethodType.EMAIL) -> None: + self.delivery_method_type = delivery_method_type + self.contexts: list[HumanInputFormDeliveryContext] = [] + + def send(self, *, context: HumanInputFormDeliveryContext) -> None: + self.contexts.append(context) + + +class _FailingProvider: + delivery_method_type: DeliveryMethodType + + def __init__(self, delivery_method_type: DeliveryMethodType = DeliveryMethodType.EMAIL) -> None: + self.delivery_method_type = delivery_method_type + + def send(self, *, context: HumanInputFormDeliveryContext) -> None: + del context + raise TimeoutError("provider timed out") + + +def _make_form() -> HumanInputForm: + form = HumanInputForm( + tenant_id="tenant-1", + app_id="app-1", + workflow_run_id="run-1", + conversation_id=None, + node_id="node-1", + form_definition="{}", + rendered_content="Rendered", + expiration_time=datetime.now(UTC), + ) + form.id = "form-1" + return form + + +def _make_delivery(*, method_type: DeliveryMethodType, payload: str = "{}") -> HumanInputDelivery: + delivery = HumanInputDelivery( + form_id="form-1", + delivery_method_type=method_type, + delivery_config_id=None, + channel_payload=payload, + ) + delivery.id = f"delivery-{method_type}" + return delivery + + +def _make_recipient( + *, + payload: str, + token: str | None = "token-1", + recipient_type: RecipientType = RecipientType.EMAIL_EXTERNAL, +) -> HumanInputFormRecipient: + recipient = HumanInputFormRecipient( + form_id="form-1", + delivery_id="delivery-email", + recipient_type=recipient_type, + recipient_payload=payload, + access_token=token, + ) + recipient.id = f"recipient-{token or 'missing'}" + return recipient + + +def _make_email_payload(*, subject: str = "Subject", body: str = "Body {{#url#}}") -> str: + method = EmailDeliveryMethod( + config=EmailDeliveryConfig( + recipients=EmailRecipients(include_bound_group=False, items=[]), + subject=subject, + body=body, + ) + ) + return method.model_dump_json() + + +def _make_recipient_context(*, recipient: HumanInputFormRecipient) -> HumanInputFormDeliveryRecipientContext: + return HumanInputFormDeliveryRecipientContext( + recipient_id=recipient.id, + recipient_type=recipient.recipient_type, + recipient_payload=recipient.recipient_payload, + access_token=recipient.access_token, + ) + + +def _make_context( + *, + form: HumanInputForm | None = None, + delivery: HumanInputDelivery | None = None, + recipients: list[HumanInputFormRecipient] | None = None, + variable_pool: VariablePool | None = None, +) -> HumanInputFormDeliveryContext: + form = form or _make_form() + delivery = delivery or _make_delivery(method_type=DeliveryMethodType.EMAIL, payload=_make_email_payload()) + return HumanInputFormDeliveryContext( + form_id=form.id, + tenant_id=form.tenant_id, + app_id=form.app_id, + workflow_run_id=form.workflow_run_id, + rendered_content=form.rendered_content, + delivery_id=delivery.id, + delivery_method_type=delivery.delivery_method_type, + delivery_config_id=delivery.delivery_config_id, + channel_payload=delivery.channel_payload, + recipients=tuple(_make_recipient_context(recipient=recipient) for recipient in recipients or []), + variable_pool=variable_pool, + ) + + +def test_registry_dispatches_by_delivery_method_type() -> None: + provider = _DummyProvider() + registry = HumanInputFormDeliveryProviderRegistry([provider]) + context = _make_context(delivery=_make_delivery(method_type=DeliveryMethodType.EMAIL)) + + assert registry.dispatch(context=context) is True + assert provider.contexts == [context] + + +def test_registry_skips_unsupported_delivery_method(caplog: pytest.LogCaptureFixture) -> None: + registry = HumanInputFormDeliveryProviderRegistry() + context = _make_context(delivery=_make_delivery(method_type=DeliveryMethodType.WEBAPP)) + + with caplog.at_level("WARNING"): + assert registry.dispatch(context=context) is False + + assert "No human input form delivery provider registered" in caplog.text + + +def test_dispatcher_continues_when_provider_fails(caplog: pytest.LogCaptureFixture) -> None: + failing_provider = _FailingProvider(DeliveryMethodType.EMAIL) + next_provider = _DummyProvider(DeliveryMethodType.IM) + dispatcher = HumanInputFormDeliveryDispatcher( + registry=HumanInputFormDeliveryProviderRegistry([failing_provider, next_provider]) + ) + email_context = _make_context(delivery=_make_delivery(method_type=DeliveryMethodType.EMAIL)) + im_context = _make_context(delivery=_make_delivery(method_type=DeliveryMethodType.IM)) + + with caplog.at_level("ERROR"): + dispatcher.dispatch_contexts((email_context, im_context)) + + assert next_provider.contexts == [im_context] + assert "Failed to dispatch human input form delivery" in caplog.text + assert "form_id=form-1" in caplog.text + assert f"delivery_id={email_context.delivery_id}" in caplog.text + assert "method=email" in caplog.text + + +def test_dispatcher_loads_contexts_in_session_and_dispatches_after_session_exit(sqlite_engine: Engine) -> None: + provider = _DummyProvider() + dispatcher = HumanInputFormDeliveryDispatcher(registry=HumanInputFormDeliveryProviderRegistry([provider])) + form = _make_form() + delivery = _make_delivery(method_type=DeliveryMethodType.EMAIL) + recipient = _make_recipient( + payload=EmailExternalRecipientPayload(email="external@example.com").model_dump_json(), + ) + variable_pool = VariablePool() + with Session(sqlite_engine, expire_on_commit=False) as setup, setup.begin(): + setup.add_all([form, delivery, recipient]) + with Session(sqlite_engine) as session: + contexts = dispatcher.load_form_contexts( + session=session, + form=form, + variable_pool=variable_pool, + delivery_method_types=(DeliveryMethodType.EMAIL,), + ) + assert provider.contexts == [] + + dispatcher.dispatch_contexts(contexts) + + assert len(provider.contexts) == 1 + context = provider.contexts[0] + assert context.form_id == form.id + assert context.delivery_id == delivery.id + assert context.recipients == (_make_recipient_context(recipient=recipient),) + assert context.variable_pool is variable_pool + + +def test_email_provider_renders_form_link_per_recipient(monkeypatch: pytest.MonkeyPatch) -> None: + mail = _DummyMail() + monkeypatch.setattr(provider_module.dify_config, "APP_WEB_URL", "https://app.example.com/") + variable_pool = VariablePool() + variable_pool.add(["node1", "value"], "OK") + context = _make_context( + delivery=_make_delivery( + method_type=DeliveryMethodType.EMAIL, + payload=_make_email_payload( + subject="Notice\r\nBCC:attacker@example.com Alert", + body="Link {{#url#}} value {{#node1.value#}}", + ), + ), + recipients=[ + _make_recipient( + payload=EmailMemberRecipientPayload(user_id="user-1", email="member@example.com").model_dump_json(), + token="member-token", + ), + _make_recipient( + payload=EmailExternalRecipientPayload(email="external@example.com").model_dump_json(), + token="external-token", + ), + ], + variable_pool=variable_pool, + ) + + EmailHumanInputFormDeliveryProvider(mail_client=mail).send(context=context) + + assert [message["to"] for message in mail.sent] == ["member@example.com", "external@example.com"] + assert all(message["subject"] == "Notice BCC:attacker@example.com Alert" for message in mail.sent) + assert "https://app.example.com/form/member-token" in mail.sent[0]["html"] + assert "https://app.example.com/form/external-token" in mail.sent[1]["html"] + assert all("value OK" in message["html"] for message in mail.sent) + + +def test_email_provider_ignores_non_email_recipients() -> None: + mail = _DummyMail() + context = _make_context( + delivery=_make_delivery(method_type=DeliveryMethodType.EMAIL, payload=_make_email_payload()), + recipients=[ + _make_recipient(payload=StandaloneWebAppRecipientPayload().model_dump_json(), token="web-token"), + ], + ) + + EmailHumanInputFormDeliveryProvider(mail_client=mail).send(context=context) + + assert mail.sent == [] + + +def test_email_provider_skips_invalid_recipient_payload_and_sends_valid_recipient( + caplog: pytest.LogCaptureFixture, +) -> None: + mail = _DummyMail() + context = _make_context( + delivery=_make_delivery(method_type=DeliveryMethodType.EMAIL, payload=_make_email_payload()), + recipients=[ + _make_recipient(payload='{"TYPE":"email_external","email":123}', token="invalid-token"), + _make_recipient( + payload=EmailExternalRecipientPayload(email="external@example.com").model_dump_json(), + token="external-token", + ), + ], + ) + + with caplog.at_level("WARNING"): + EmailHumanInputFormDeliveryProvider(mail_client=mail).send(context=context) + + assert [message["to"] for message in mail.sent] == ["external@example.com"] + assert "Invalid human input recipient payload, recipient_id=recipient-invalid-token" in caplog.text + + +def test_email_provider_skips_invalid_delivery_payload(caplog: pytest.LogCaptureFixture) -> None: + mail = _DummyMail() + context = _make_context(delivery=_make_delivery(method_type=DeliveryMethodType.EMAIL, payload='{"invalid": true}')) + + with caplog.at_level("WARNING"): + EmailHumanInputFormDeliveryProvider(mail_client=mail).send(context=context) + + assert mail.sent == [] + assert "Invalid human input email delivery payload" in caplog.text + + +def test_instant_message_provider_safely_skips_without_network(caplog: pytest.LogCaptureFixture) -> None: + context = _make_context( + delivery=_make_delivery( + method_type=DeliveryMethodType.IM, payload='{"type":"im","config":{"provider":"slack"}}' + ), + recipients=[ + _make_recipient( + payload=InstantMessageRecipientPayload( + provider="slack", + recipient_kind="channel", + channel_id="C123", + ).model_dump_json(), + token="im-token", + recipient_type=RecipientType.INSTANT_MESSAGE, + ) + ], + ) + + with caplog.at_level("WARNING"): + UnsupportedInstantMessageHumanInputFormDeliveryProvider().send(context=context) + + assert "Human input instant message delivery is not implemented" in caplog.text + + +def test_instant_message_provider_skips_invalid_delivery_payload(caplog: pytest.LogCaptureFixture) -> None: + context = _make_context( + delivery=_make_delivery( + method_type=DeliveryMethodType.IM, + payload='{"type":"im","config":{"provider":123}}', + ), + ) + + with caplog.at_level("WARNING"): + UnsupportedInstantMessageHumanInputFormDeliveryProvider().send(context=context) + + assert "Invalid human input instant message delivery payload" in caplog.text + assert "Human input instant message delivery is not implemented" not in caplog.text diff --git a/api/tests/unit_tests/services/test_human_input_resend_channel.py b/api/tests/unit_tests/services/test_human_input_resend_channel.py new file mode 100644 index 00000000000..4a21b07e5ab --- /dev/null +++ b/api/tests/unit_tests/services/test_human_input_resend_channel.py @@ -0,0 +1,186 @@ +"""Resend management adapter tests without live provider credentials.""" + +from __future__ import annotations + +from dataclasses import dataclass, replace + +import httpx +import pytest + +from core.human_input_v2.shared import NormalizedEmail +from repositories.human_input_v2.email_channel import ( + EmailProviderOperationError, + EmailProviderValidationError, + ResendCandidate, +) +from repositories.human_input_v2.email_channel.entities import ResendCredentials +from services.human_input_v2.resend_channel import ResendProviderGateway + + +@dataclass +class FakeResponse: + status_code: int + body: object + + def json(self) -> object: + if isinstance(self.body, Exception): + raise self.body + return self.body + + +class FakeHTTPClient: + def __init__(self, *responses: FakeResponse | Exception) -> None: + self.responses = list(responses) + self.calls: list[tuple[str, str, dict[str, object]]] = [] + + def post(self, url: str, **kwargs: object) -> FakeResponse: + return self._request("POST", url, kwargs) + + def _request(self, method: str, url: str, kwargs: dict[str, object]) -> FakeResponse: + self.calls.append((method, url, kwargs)) + result = self.responses.pop(0) + if isinstance(result, Exception): + raise result + return result + + +def _candidate() -> ResendCandidate: + return ResendCandidate( + sender_email=NormalizedEmail("approvals@example.com"), + sender_name="Approvals", + credentials=ResendCredentials(api_key="re_secret"), + ) + + +def test_send_test_uses_resend_test_recipient_once_with_unique_idempotency_key() -> None: + http_client = FakeHTTPClient(FakeResponse(200, {"id": "email-1"})) + gateway = ResendProviderGateway( + http_client=http_client, + id_factory=lambda: "test-1", + ) + + gateway.send_test(_candidate()) + + assert len(http_client.calls) == 1 + method, url, kwargs = http_client.calls[0] + assert method == "POST" + assert url == "https://api.resend.com/emails" + headers = kwargs["headers"] + assert isinstance(headers, dict) + assert headers["Idempotency-Key"] == "human-input-channel-test/test-1" + assert headers["Authorization"] == "Bearer re_secret" + assert headers["User-Agent"] == "Dify-Human-Input/1.0" + assert kwargs["max_retries"] == 0 + assert kwargs["json"] == { + "from": "Approvals ", + "to": ["delivered@resend.dev"], + "subject": "Dify Human Input channel test", + "html": ( + "

Your Resend channel is connected to Dify Human Input.

" + "

You can close this email after confirming delivery.

" + ), + } + + +def test_send_test_rejects_invalid_credentials_after_a_successful_test() -> None: + http_client = FakeHTTPClient( + FakeResponse(200, {"id": "email-1"}), + FakeResponse(403, {"name": "invalid_api_key"}), + ) + gateway = ResendProviderGateway(http_client=http_client) + + gateway.send_test(_candidate()) + with pytest.raises(EmailProviderValidationError): + gateway.send_test(replace(_candidate(), credentials=ResendCredentials(api_key="re_invalid"))) + + for (_, _, kwargs), api_key in zip(http_client.calls, ("re_secret", "re_invalid"), strict=True): + headers = kwargs["headers"] + assert isinstance(headers, dict) + assert headers["Authorization"] == f"Bearer {api_key}" + + +@pytest.mark.parametrize( + ("response", "exception_type", "code"), + [ + (FakeResponse(403, {"name": "invalid_api_key"}), EmailProviderValidationError, "invalid_api_key"), + (FakeResponse(403, {"name": "restricted_api_key"}), EmailProviderValidationError, "invalid_api_key"), + ( + FakeResponse(422, {"name": "invalid_from_address"}), + EmailProviderValidationError, + "invalid_sender", + ), + ( + FakeResponse(429, {"name": "monthly_quota_exceeded"}), + EmailProviderOperationError, + "provider_quota_exhausted", + ), + (FakeResponse(503, {}), EmailProviderOperationError, "provider_unavailable"), + (FakeResponse(200, {}), EmailProviderOperationError, "provider_response_malformed"), + ], +) +def test_send_test_maps_provider_responses_to_safe_failures( + response: FakeResponse, + exception_type: type[EmailProviderValidationError] | type[EmailProviderOperationError], + code: str, +) -> None: + gateway = ResendProviderGateway(http_client=FakeHTTPClient(response)) + + with pytest.raises(exception_type) as raised: + gateway.send_test(_candidate()) + + assert raised.value.code == code + assert "re_secret" not in repr(raised.value) + + +def test_transport_failure_is_classified_without_credential_material() -> None: + request = httpx.Request("POST", "https://api.resend.com/emails") + gateway = ResendProviderGateway(http_client=FakeHTTPClient(httpx.ReadTimeout("request timed out", request=request))) + + with pytest.raises(EmailProviderOperationError) as raised: + gateway.send_test(_candidate()) + + assert raised.value.code == "provider_timeout" + assert "re_secret" not in repr(raised.value) + + +@pytest.mark.parametrize( + "name", ["restricted_api_key", "validation_error", "monthly_quota_exceeded", "new_provider_error"] +) +def test_provider_error_preserves_original_resend_code_without_raw_message(name: str) -> None: + gateway = ResendProviderGateway( + http_client=FakeHTTPClient( + FakeResponse( + 403, + { + "name": name, + "message": "raw response containing re_secret", + }, + ) + ) + ) + + with pytest.raises((EmailProviderValidationError, EmailProviderOperationError)) as captured: + gateway.send_test(_candidate()) + + assert captured.value.provider_error_code == name + assert "re_secret" not in repr(captured.value) + + +def test_send_message_uses_rendered_content_and_selected_channel_credentials() -> None: + http_client = FakeHTTPClient(FakeResponse(200, {"id": "email-1"})) + gateway = ResendProviderGateway(http_client=http_client, id_factory=lambda: "message-1") + + gateway.send_message(_candidate(), to="editor@example.com", subject="Rendered subject", html="

Rendered body

") + + assert len(http_client.calls) == 1 + _, _, kwargs = http_client.calls[0] + assert kwargs["json"] == { + "from": "Approvals ", + "to": ["editor@example.com"], + "subject": "Rendered subject", + "html": "

Rendered body

", + } + headers = kwargs["headers"] + assert isinstance(headers, dict) + assert headers["Idempotency-Key"] == "human-input-message/message-1" + assert kwargs["max_retries"] == 0 diff --git a/api/tests/unit_tests/services/test_message_query_adapters.py b/api/tests/unit_tests/services/test_message_query_adapters.py index af559297407..f32b184b670 100644 --- a/api/tests/unit_tests/services/test_message_query_adapters.py +++ b/api/tests/unit_tests/services/test_message_query_adapters.py @@ -1,8 +1,10 @@ """Exercise detached file restoration against real SQLAlchemy and HTTP transports.""" +import hashlib +import json from collections.abc import Callable from dataclasses import dataclass, replace -from datetime import datetime +from datetime import datetime, timedelta from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from threading import Thread from typing import override @@ -16,13 +18,27 @@ from sqlalchemy.orm import Session, SessionTransaction, sessionmaker from core.app.entities.app_invoke_entities import InvokeFrom, UserFrom from core.app.file_access import FileAccessScope, bind_file_access_scope -from core.helper import http_client_pooling +from core.helper import encrypter, http_client_pooling +from core.human_input import ButtonStyle +from core.human_input_v2.resolved_form import MarkdownFragment, ParagraphInput, ResolvedForm, UserAction from core.tools.signature import verify_tool_file_signature from core.workflow.file_reference import build_file_reference, resolve_file_record_id +from core.workflow.nodes.human_input.enums import HumanInputFormStatus from extensions.storage.storage_type import StorageType +from graphon.enums import WorkflowExecutionStatus, WorkflowNodeExecutionStatus from graphon.file import FILE_MODEL_IDENTITY, File, FileTransferMethod, FileType from models import AppModelConfig, MessageFile, ToolFile, UploadFile -from models.enums import CreatorUserRole, MessageFileBelongsTo +from models.enums import CreatorUserRole, MessageFileBelongsTo, WorkflowRunTriggeredFrom +from models.execution_extra_content import HumanInputContent +from models.human_input_v2 import HumanInputDelivery +from models.human_input_v2 import HumanInputForm as V2Form +from models.workflow import WorkflowNodeExecutionModel, WorkflowNodeExecutionTriggeredFrom, WorkflowRun, WorkflowType +from repositories.human_input_v2.delivery_repository import ( + DeliveryTargetType, + EmailTargetSnapshot, + InitiatorSnapshot, + SubmissionAuthType, +) from repositories.sqlalchemy_execution_extra_content_repository import SQLAlchemyExecutionExtraContentRepository from services.entities.message_entities import MessageFileReference, MessageInputValue from services.message_query_adapters import ExecutionExtraContentReader, MessageFileResolver @@ -328,7 +344,8 @@ def test_extra_content_reader_serializes_real_database_content(sqlite_session_fa with sqlite_session_factory.begin() as session: fixture = create_human_input_message_fixture(session) reader = ExecutionExtraContentReader( - repository=SQLAlchemyExecutionExtraContentRepository(session_maker=sqlite_session_factory) + repository=SQLAlchemyExecutionExtraContentRepository(session_maker=sqlite_session_factory), + session_factory=sqlite_session_factory, ) result = reader.get_by_message_ids(["missing", fixture.message.id]) assert result[0] == [] @@ -341,3 +358,127 @@ def test_extra_content_reader_serializes_real_database_content(sqlite_session_fa submission = result[1][0]["form_submission_data"] assert isinstance(submission, dict) assert submission["submitted_data"] == {"name": "Alice"} + + +@pytest.mark.parametrize( + ("status", "initiator"), + [ + (HumanInputFormStatus.WAITING, True), + (HumanInputFormStatus.WAITING, False), + (HumanInputFormStatus.SUBMITTED, True), + (HumanInputFormStatus.TIMEOUT, True), + (HumanInputFormStatus.EXPIRED, True), + ], +) +def test_v2_history_restores_frozen_forms_and_only_waiting_initiator_tokens( + sqlite_session_factory: sessionmaker[Session], + monkeypatch: pytest.MonkeyPatch, + status: HumanInputFormStatus, + initiator: bool, +) -> None: + tenant_id, app_id, run_id, execution_id, form_id, message_id, recipient_id = [str(uuid4()) for _ in range(7)] + now = datetime(2026, 10, 26) + token = "original-initiator-token" + monkeypatch.setattr(encrypter, "decrypt_token", lambda _tenant, _ciphertext: token) + resolved = ResolvedForm( + title="Frozen approval", + parts=( + MarkdownFragment(text="Review: "), + ParagraphInput(output_variable_name="comment", default_value="Draft"), + ), + actions=(UserAction(id="approve", title="Approve", button_style=ButtonStyle.PRIMARY),), + legacy_form_content="Review: {{#$output.comment#}}", + ) + with sqlite_session_factory.begin() as session: + session.add( + WorkflowRun( + id=run_id, + tenant_id=tenant_id, + app_id=app_id, + workflow_id=str(uuid4()), + type=WorkflowType.CHAT, + triggered_from=WorkflowRunTriggeredFrom.APP_RUN, + version="1", + status=WorkflowExecutionStatus.PAUSED, + created_by_role=CreatorUserRole.ACCOUNT, + created_by=str(uuid4()), + ) + ) + session.add( + WorkflowNodeExecutionModel( + id=execution_id, + tenant_id=tenant_id, + app_id=app_id, + workflow_id=str(uuid4()), + workflow_run_id=run_id, + triggered_from=WorkflowNodeExecutionTriggeredFrom.WORKFLOW_RUN, + index=1, + node_id="approval", + node_type="human-input", + title="Approval", + status=WorkflowNodeExecutionStatus.PAUSED, + created_by_role=CreatorUserRole.ACCOUNT, + created_by=str(uuid4()), + ) + ) + delivery = HumanInputDelivery( + tenant_id=tenant_id, + form_id=form_id, + recipient_id=recipient_id, + token_hash=hashlib.sha256(token.encode()).hexdigest(), + auth_type=SubmissionAuthType.CONSOLE, + target_type=DeliveryTargetType.INITIATOR if initiator else DeliveryTargetType.EMAIL, + target_snapshot=InitiatorSnapshot(protected_form_token="encrypted") + if initiator + else EmailTargetSnapshot(email_address="reviewer@example.com"), + ) + session.add(delivery) + submitted = status == HumanInputFormStatus.SUBMITTED + form = V2Form( + tenant_id=tenant_id, + app_id=app_id, + workflow_run_id=run_id, + node_execution_id=execution_id, + resolved_form=resolved, + expiration_time=now + timedelta(days=1), + global_timeout_deadline=now + timedelta(days=2), + status=status, + submitted_at=now if submitted else None, + submitted_by_recipient_id=recipient_id if submitted else None, + submitted_by_delivery_id=delivery.id if submitted else None, + selected_action_id="approve" if submitted else None, + raw_submission_inputs=json.dumps({"comment": "Accepted"}) if submitted else None, + normalized_submission_data=json.dumps({"comment": {"value_type": "string", "value": "Accepted"}}) + if submitted + else None, + ) + form.id = form_id + session.add(form) + session.add(HumanInputContent.new(workflow_run_id=run_id, form_id=form_id, message_id=message_id)) + + reader = ExecutionExtraContentReader( + repository=SQLAlchemyExecutionExtraContentRepository(session_maker=sqlite_session_factory), + session_factory=sqlite_session_factory, + ) + contents = reader.get_by_message_ids(["missing", message_id]) + + assert contents[0] == [] + assert len(contents[1]) == 1 + content = contents[1][0] + assert content["workflow_run_id"] == run_id + assert content["submitted"] is submitted + definition = content["form_definition"] + assert isinstance(definition, dict) + assert definition["form_version"] == "2" + assert definition["form_id"] == form_id + assert definition["node_id"] == "approval" + assert definition["node_title"] == "Frozen approval" + assert definition["form_content"] == "Review: {{#$output.comment#}}" + assert definition["resolved_default_values"] == {"comment": "Draft"} + assert definition.get("form_token") == (token if status == HumanInputFormStatus.WAITING and initiator else None) + if submitted: + submission = content["form_submission_data"] + assert isinstance(submission, dict) + assert submission["action_text"] == "Approve" + assert submission["submitted_data"] == {"comment": "Accepted"} + assert submission["rendered_content"] == "Review: Accepted" diff --git a/api/tests/unit_tests/services/test_message_query_service.py b/api/tests/unit_tests/services/test_message_query_service.py index c8c368ec46d..a6950d49178 100644 --- a/api/tests/unit_tests/services/test_message_query_service.py +++ b/api/tests/unit_tests/services/test_message_query_service.py @@ -75,7 +75,8 @@ def test_extra_contents_are_loaded_after_message_session_closes( messages=MessageRepository(session_factory=sqlite_session_factory), files=MessageFileResolver(), extra_contents=ExecutionExtraContentReader( - repository=SQLAlchemyExecutionExtraContentRepository(session_maker=contents_factory) + repository=SQLAlchemyExecutionExtraContentRepository(session_maker=contents_factory), + session_factory=contents_factory, ), ) try: @@ -122,6 +123,7 @@ def test_extra_contents_are_loaded_after_message_session_closes( "submitted": submitted, "form_definition": { "form_id": form_id, + "form_version": "1", "node_id": "node-id", "node_title": "Approval", "form_content": "Rendered block", @@ -167,7 +169,8 @@ def test_empty_page_does_not_open_extra_content_session(sqlite_session_factory: messages=MessageRepository(session_factory=sqlite_session_factory), files=MessageFileResolver(), extra_contents=ExecutionExtraContentReader( - repository=SQLAlchemyExecutionExtraContentRepository(session_maker=contents_factory) + repository=SQLAlchemyExecutionExtraContentRepository(session_maker=contents_factory), + session_factory=contents_factory, ), ).get_page( app_id=app_id, diff --git a/api/tests/unit_tests/services/test_workflow_event_snapshot_service.py b/api/tests/unit_tests/services/test_workflow_event_snapshot_service.py index bc154b0d219..fa6540bfd58 100644 --- a/api/tests/unit_tests/services/test_workflow_event_snapshot_service.py +++ b/api/tests/unit_tests/services/test_workflow_event_snapshot_service.py @@ -27,7 +27,12 @@ from core.app.layers.pause_state_persist_layer import ( _WorkflowGenerateEntityWrapper, ) from core.workflow.human_input_policy import FormDisposition, HumanInputSurface -from core.workflow.nodes.human_input.entities import SelectInputConfig, StringListSource +from core.workflow.nodes.human_input.entities import ( + FormDefinition, + FormInputConfig, + SelectInputConfig, + StringListSource, +) from core.workflow.nodes.human_input.enums import HumanInputFormStatus, ValueSourceType from core.workflow.nodes.human_input.pause_reason import HumanInputRequired from graphon.enums import WorkflowExecutionStatus, WorkflowNodeExecutionStatus @@ -37,7 +42,13 @@ from libs.datetime_utils import to_utc_timestamp from models.enums import ConversationFromSource, CreatorUserRole from models.human_input import HumanInputForm, HumanInputFormRecipient, RecipientType from models.model import AppMode, Message -from models.workflow import WorkflowNodeExecutionModel, WorkflowNodeExecutionTriggeredFrom, WorkflowPause, WorkflowRun +from models.workflow import ( + WorkflowNodeExecutionModel, + WorkflowNodeExecutionTriggeredFrom, + WorkflowPause, + WorkflowPauseReason, + WorkflowRun, +) from models.workflow import WorkflowType as ModelWorkflowType from repositories.api_workflow_node_execution_repository import WorkflowNodeExecutionSnapshot from repositories.entities.workflow_pause import WorkflowPauseEntity @@ -54,6 +65,9 @@ from services.workflow_event_snapshot_service import ( _resolve_task_id, build_workflow_event_stream, ) +from services.workflow_event_snapshot_service import ( + _build_paused_snapshot_events as _build_checkpoint_snapshot_events, +) @dataclass(frozen=True) @@ -61,7 +75,7 @@ class _FakePauseEntity(WorkflowPauseEntity): pause_id: str workflow_run_id: str paused_at_value: datetime - pause_reasons: Sequence[HumanInputRequired] + pause_reasons: Sequence[HumanInputRequired] | None = None @property @override @@ -87,10 +101,6 @@ class _FakePauseEntity(WorkflowPauseEntity): def paused_at(self) -> datetime: return self.paused_at_value - @override - def get_pause_reasons(self) -> Sequence[HumanInputRequired]: - return self.pause_reasons - def _build_workflow_run(status: WorkflowExecutionStatus) -> WorkflowRun: workflow_run = WorkflowRun( @@ -134,7 +144,9 @@ def _build_snapshot(status: WorkflowNodeExecutionStatus) -> WorkflowNodeExecutio ) -def _build_resumption_context(task_id: str, *, select_options: list[str] | None = None) -> WorkflowResumptionContext: +def _build_resumption_context( + task_id: str, *, select_options: list[str] | None = None, pause_reasons: Sequence[HumanInputRequired] | None = None +) -> WorkflowResumptionContext: app_config = WorkflowUIBasedAppConfig( tenant_id="tenant-1", app_id="app-1", @@ -156,6 +168,17 @@ def _build_resumption_context(task_id: str, *, select_options: list[str] | None runtime_state = RuntimeState(workflow_id="test-workflow", variable_pool=VariablePool(), start_at=0.0) if select_options is not None: runtime_state.variable_pool.add(("start", "options"), select_options) + from graphon.entities.pause_reason import HitlRequired + + reasons = ( + pause_reasons + if pause_reasons is not None + else [_human_input_reason("form-1").model_copy(update={"node_id": "node-1"})] + ) + for reason in reasons: + runtime_state.graph_execution.pause( + HitlRequired(session_id=reason.form_id, node_id=reason.node_id, node_title=reason.node_title) + ) runtime_state.set_output("result", "value") wrapper = _WorkflowGenerateEntityWrapper(entity=generate_entity) return WorkflowResumptionContext( @@ -179,8 +202,6 @@ def test_build_snapshot_events_applies_message_context() -> None: node_snapshots=[snapshot], task_id="task-1", message_context=message_context, - pause_entity=None, - resumption_context=None, node_executions={}, ) @@ -334,7 +355,9 @@ def _persist_human_input_form( session_maker: sessionmaker[Session], *, recipients: Sequence[HumanInputFormRecipient] = (), + inputs: Sequence[FormInputConfig] = (), form_id: str = "form-1", + node_id: str = "node-1", status: HumanInputFormStatus = HumanInputFormStatus.WAITING, ) -> datetime: expiration_time = datetime(2024, 1, 1) @@ -344,14 +367,31 @@ def _persist_human_input_form( app_id="app-1", workflow_run_id="run-1", conversation_id=None, - node_id="node-1", - form_definition='{"display_in_ui": true}', + node_id=node_id, + form_definition=FormDefinition( + form_content="content", + rendered_content="content", + inputs=list(inputs), + expiration_time=expiration_time, + display_in_ui=True, + ).model_dump_json(), rendered_content="content", expiration_time=expiration_time, status=status, ) with session_maker.begin() as session: session.add(form) + session.add( + WorkflowPauseReason.from_entity( + pause_id="pause-1", + pause_reason=HumanInputRequired( + form_id=form_id, + node_id=node_id, + node_title="Human Input", + form_content="content", + ), + ) + ) session.add_all(recipients) return expiration_time @@ -408,10 +448,6 @@ class _PauseEntity(WorkflowPauseEntity): def get_state(self) -> bytes: return self.state - @override - def get_pause_reasons(self) -> list[Any]: - return [] - def test_get_message_context_by_conversation_should_return_none_when_no_message( sqlite_session_factory: sessionmaker[Session], @@ -1139,7 +1175,8 @@ def test_build_workflow_event_stream_should_continue_when_pause_loading_fails( # Assert assert events[0] == StreamEvent.PING - assert snapshot_builder.call_args.kwargs["pause_entity"] is None + assert events == [StreamEvent.PING, {"event": StreamEvent.WORKFLOW_FINISHED}] + assert buffer_state.stop_event.is_set() def test_is_terminal_event_respects_close_on_pause_flag() -> None: @@ -1170,18 +1207,9 @@ def test_build_snapshot_events_preserves_public_form_token( pause_id="pause-1", workflow_run_id="run-1", paused_at_value=datetime(2024, 1, 1, tzinfo=UTC), - pause_reasons=[ - HumanInputRequired( - form_id="form-1", - form_content="content", - node_id="node-1", - node_title="Human Input", - form_token="wtok", - ) - ], ) - events = _build_snapshot_events( + events = _build_checkpoint_snapshot_events( workflow_run=workflow_run, node_snapshots=[snapshot], task_id="task-ctx", @@ -1194,9 +1222,11 @@ def test_build_snapshot_events_preserves_public_form_token( assert events[-2]["event"] == StreamEvent.HUMAN_INPUT_REQUIRED assert events[-2]["data"]["form_token"] == "wtok" + assert events[-2]["data"]["form_version"] == "1" assert events[-2]["data"]["expiration_time"] == to_utc_timestamp(expiration_time) pause_data = events[-1]["data"] assert pause_data["reasons"][0]["form_token"] == "wtok" + assert pause_data["reasons"][0]["form_version"] == "1" assert pause_data["reasons"][0]["expiration_time"] == to_utc_timestamp(expiration_time) @@ -1217,17 +1247,9 @@ def _build_recipient_snapshot_events( pause_id="pause-1", workflow_run_id="run-1", paused_at_value=expiration_time, - pause_reasons=[ - HumanInputRequired( - form_id="form-1", - form_content="content", - node_id="node-1", - node_title="Human Input", - ) - ], ) - return _build_snapshot_events( + return _build_checkpoint_snapshot_events( workflow_run=workflow_run, node_snapshots=[snapshot], task_id="task-ctx", @@ -1320,32 +1342,26 @@ def test_build_snapshot_events_resolves_pause_reason_select_options( "form-1": FormDisposition(form_token="wtok", approval_channels=[]) }, ) - _persist_human_input_form(sqlite_session_factory) + _persist_human_input_form( + sqlite_session_factory, + inputs=[ + SelectInputConfig( + output_variable_name="decision", + option_source=StringListSource( + type=ValueSourceType.VARIABLE, + selector=["start", "options"], + value=[], + ), + ) + ], + ) pause_entity = _FakePauseEntity( pause_id="pause-1", workflow_run_id="run-1", paused_at_value=datetime(2024, 1, 1, tzinfo=UTC), - pause_reasons=[ - HumanInputRequired( - form_id="form-1", - form_content="content", - inputs=[ - SelectInputConfig( - output_variable_name="decision", - option_source=StringListSource( - type=ValueSourceType.VARIABLE, - selector=["start", "options"], - value=[], - ), - ) - ], - node_id="node-1", - node_title="Human Input", - ) - ], ) - events = _build_snapshot_events( + events = _build_checkpoint_snapshot_events( workflow_run=workflow_run, node_snapshots=[snapshot], task_id="task-ctx", @@ -1373,14 +1389,6 @@ def test_build_workflow_event_stream_loads_pause_tokens_without_flask_app_contex pause_id="pause-1", workflow_run_id="run-1", paused_at_value=datetime(2024, 1, 1, tzinfo=UTC), - pause_reasons=[ - HumanInputRequired( - form_id="form-1", - form_content="content", - node_id="node-1", - node_title="Human Input", - ) - ], ) workflow_run_repo = SimpleNamespace(get_workflow_pause=MagicMock(return_value=pause_entity)) node_repo = SimpleNamespace(get_execution_snapshots_by_workflow_run=MagicMock(return_value=[])) @@ -1435,7 +1443,7 @@ def _build_paused_snapshot_events( pause_reason_form_ids: Sequence[str] | None = None, ) -> list[Mapping[str, Any]]: for form_id, status in forms.items(): - _persist_human_input_form(session_maker, form_id=form_id, status=status) + _persist_human_input_form(session_maker, form_id=form_id, node_id=f"node-{form_id}", status=status) reason_form_ids = list(forms) if pause_reason_form_ids is None else pause_reason_form_ids pause_entity = _FakePauseEntity( pause_id="pause-1", @@ -1443,25 +1451,28 @@ def _build_paused_snapshot_events( paused_at_value=datetime(2024, 1, 1, tzinfo=UTC), pause_reasons=[_human_input_reason(form_id) for form_id in reason_form_ids], ) - return _build_snapshot_events( + return _build_checkpoint_snapshot_events( workflow_run=_build_workflow_run(WorkflowExecutionStatus.PAUSED), node_snapshots=[_build_snapshot(WorkflowNodeExecutionStatus.PAUSED)], task_id="task-ctx", message_context=None, pause_entity=pause_entity, - resumption_context=_build_resumption_context("task-ctx"), + resumption_context=_build_resumption_context("task-ctx", pause_reasons=pause_entity.pause_reasons), session_maker=session_maker, node_executions={}, ) class _PausedRunRepo: - def __init__(self, pause_entity: WorkflowPauseEntity) -> None: + def __init__(self, pause_entity: _FakePauseEntity) -> None: self._pause_entity = pause_entity def get_workflow_pause(self, _workflow_run_id: str) -> WorkflowPauseEntity: return self._pause_entity + def get_legacy_pause_reasons(self, _session: Session, _pause_id: str) -> Sequence[HumanInputRequired]: + return self._pause_entity.pause_reasons or () + class _EmptyNodeRepo: def get_execution_snapshots_by_workflow_run(self, **_kwargs: object) -> list[WorkflowNodeExecutionSnapshot]: diff --git a/api/tests/unit_tests/services/test_workflow_run_agg.py b/api/tests/unit_tests/services/test_workflow_run_agg.py index fb552e0b601..89ef535a290 100644 --- a/api/tests/unit_tests/services/test_workflow_run_agg.py +++ b/api/tests/unit_tests/services/test_workflow_run_agg.py @@ -24,6 +24,7 @@ from core.app.layers.trigger_post_layer import TriggerPostLayer from core.repositories.sqlalchemy_workflow_execution_repository import SQLAlchemyWorkflowExecutionRepository from core.repositories.sqlalchemy_workflow_node_execution_repository import SQLAlchemyWorkflowNodeExecutionRepository from core.workflow.nodes.human_input.entities import HumanInputNodeData, ParagraphInputConfig, UserActionConfig +from core.workflow.nodes.human_input.pause_reason import HumanInputRequired from extensions.ext_storage import storage from extensions.storage.opendal_storage import OpenDALStorage from graphon.engine_events import ( @@ -34,6 +35,7 @@ from graphon.engine_events import ( GraphRunStartedEvent, GraphRunSucceededEvent, ) +from graphon.entities.pause_reason import HitlRequired from graphon.enums import WorkflowExecutionStatus from graphon.nodes.start.entities import StartNodeData from graphon.runtime import RuntimeState @@ -54,6 +56,7 @@ from models.workflow import ( WorkflowRun, ) from models.workflow import WorkflowType as ModelWorkflowType +from repositories.human_input_v2.form_repository import Form from services.agent.workspace_service import AgentWorkspaceService from services.workflow.entities import WorkflowScheduleCFSPlanEntity from services.workflow_run_agg import WorkflowRunAgg @@ -68,6 +71,7 @@ from tests.unit_tests.core.app.apps.pipeline.test_pipeline_runner import ( ) from tests.unit_tests.core.workflow.nodes.agent_v2.test_binding_resolver import _agent, _binding, _snapshot from tests.unit_tests.core.workflow.nodes.agent_v2.test_session_store import _install_backend_client +from tests.unit_tests.core.workflow.nodes.human_input_v2.test_node import form as _v2_form_fixture """Pipeline tool lifecycle through the real Engine and SQLAlchemy repositories.""" @@ -396,7 +400,9 @@ class TestWorkflowRunAggCases: monkeypatch.setattr(storage, "storage_runner", OpenDALStorage(scheme="fs", root=str(tmp_path)), raising=False) from core.app.apps import workflow_app_runner - monkeypatch.setattr(workflow_app_runner.dispatch_human_input_email_task, "apply_async", lambda **_kwargs: None) + monkeypatch.setattr( + workflow_app_runner.dispatch_human_input_form_delivery_task, "apply_async", lambda **_kwargs: None + ) @staticmethod @pytest.mark.usefixtures("_local_storage") @@ -1047,3 +1053,88 @@ class TestWorkflowRunAggCases: with Session(sqlite_engine) as session: assert session.scalar(select(WorkflowRun)) is None assert session.scalar(select(HumanInputForm)) is None + + +v2_form = _v2_form_fixture + + +@pytest.mark.parametrize("token", ["initiator-token", None]) +def test_v2_pause_persists_only_checkpoint_references_and_publishes_frozen_form( + sqlite_engine: Engine, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, token: str | None, v2_form: Form +) -> None: + from dataclasses import replace + + from pydantic import SecretStr + + from core.human_input_v2.shared.values import TenantId + from core.workflow.nodes.human_input_v2.entities import HumanInputNodeData as V2NodeData + from core.workflow.nodes.human_input_v2.runtime import PreparedForm + from models.workflow import WorkflowPauseReason + + monkeypatch.setattr(storage, "storage_runner", OpenDALStorage(scheme="fs", root=str(tmp_path)), raising=False) + runner, workflow_repository, node_repository = make_workflow_runner(sqlite_engine, human_input=True) + entity = runner.application_generate_entity + graph = runner._workflow.graph_dict + graph["nodes"][1]["data"] = V2NodeData.model_validate( + { + "type": "human-input", + "version": "2", + "title": "Approval", + "form_content": "Changed content", + "recipients_spec": [], + "user_actions": [], + "message_template": {"subject": "Notice", "body": "[Request URL]"}, + "debug_mode": {"enabled": False, "channels": []}, + } + ).model_dump(mode="json") + runner._workflow.graph = json.dumps(graph) + source_form = v2_form + prepared_forms: list[PreparedForm] = [] + + def prepare(_runtime: object, *, node_execution_id: str, **_kwargs: object) -> PreparedForm: + prepared = PreparedForm( + replace( + source_form, + id=node_execution_id, + node_execution_id=node_execution_id, + tenant_id=TenantId(entity.app_config.tenant_id), + app_id=entity.app_config.app_id, + workflow_run_id=entity.workflow_execution_id, + ), + SecretStr(token) if token is not None else None, + ) + prepared_forms.append(prepared) + return prepared + + monkeypatch.setattr("services.human_input_v2.composition.WorkflowHumanInputRuntime.prepare_form", prepare) + + def reject_legacy_lookup(*_args: object, **_kwargs: object) -> None: + pytest.fail("V2 forms must not be loaded from the legacy repository") + + monkeypatch.setattr( + "core.repositories.human_input_repository.HumanInputFormSubmissionRepository.get_by_form_ids", + reject_legacy_lookup, + ) + WorkflowRunAgg.run( + runner, PauseStateLayerConfig(sqlite_engine, entity.user_id), workflow_repository, node_repository + ) + events = [message.event for message in runner._queue_manager.listen()] + paused = events[-1] + assert isinstance(paused, QueueWorkflowPausedEvent), events + assert isinstance(paused.reasons[0], HumanInputRequired) + assert paused.reasons[0].form_version == "2" + assert paused.reasons[0].form_content == source_form.resolved_form.legacy_form_content + assert paused.human_input_v2_forms[prepared_forms[0].form.id] is prepared_forms[0] + with Session(sqlite_engine) as session: + pause = session.scalar( + select(WorkflowPause).where(WorkflowPause.workflow_run_id == entity.workflow_execution_id) + ) + assert pause is not None + assert list(session.scalars(select(WorkflowPauseReason).where(WorkflowPauseReason.pause_id == pause.id))) == [] + saved_state = storage.load(pause.state_object_key).decode() + context = WorkflowResumptionContext.loads(saved_state) + restored = RuntimeState.from_snapshot(context.serialized_graph_runtime_state) + assert isinstance(restored.graph_execution.pause_reasons[0], HitlRequired) + assert restored.graph_execution.pause_reasons[0].session_id == f"hitlv2:{prepared_forms[0].form.id}" + if token is not None: + assert token not in saved_state diff --git a/api/tests/unit_tests/services/test_workflow_run_service.py b/api/tests/unit_tests/services/test_workflow_run_service.py index a3d0c5cff8d..011a1a743f8 100644 --- a/api/tests/unit_tests/services/test_workflow_run_service.py +++ b/api/tests/unit_tests/services/test_workflow_run_service.py @@ -3,6 +3,8 @@ from types import SimpleNamespace from unittest.mock import ANY, MagicMock, create_autospec import pytest +from pydantic import TypeAdapter +from sqlalchemy.orm import Session, sessionmaker from core.workflow.node_execution_process_data import ( WORKFLOW_TOOL_INVOCATION_ID_KEY, @@ -11,14 +13,26 @@ from core.workflow.node_execution_process_data import ( from core.workflow.nodes.human_input.pause_reason import HumanInputRequired from graphon.entities.pause_reason import SchedulingPause from graphon.enums import WorkflowExecutionStatus +from graphon.runtime import RuntimeState, VariablePool from machinery.context import RequestContext from models import WorkflowRun, WorkflowRunTriggeredFrom from models.enums import CreatorUserRole +from models.workflow import WorkflowPause from models.workflow import WorkflowType as ModelWorkflowType -from repositories.sqlalchemy_api_workflow_run_repository import WorkflowRunMessageRef, WorkflowRunPauseRecord +from repositories.sqlalchemy_api_workflow_node_execution_repository import ( + DifyAPISQLAlchemyWorkflowNodeExecutionRepository, +) +from repositories.sqlalchemy_api_workflow_run_repository import ( + DifyAPISQLAlchemyWorkflowRunRepository, + WorkflowRunMessageRef, + WorkflowRunPauseRecord, + _PrivateWorkflowPauseEntity, +) from services import workflow_run_service as service_module from services.workflow_node_execution_trace_service import WorkflowNodeExecutionTrace +from services.workflow_pause_service import WorkflowPauseSnapshot from services.workflow_run_service import ( + WorkflowRunListArgs, WorkflowRunPauseDetails, WorkflowRunPausedNode, WorkflowRunService, @@ -31,6 +45,7 @@ def _workflow_run_service_service(dependencies: tuple[MagicMock, MagicMock]) -> node_executions, workflow_runs = dependencies return WorkflowRunService( workflow_runs=workflow_runs, + session_factory=sessionmaker(), node_executions=node_executions, ) @@ -124,7 +139,9 @@ class TestWorkflowRunServiceCases: service = _workflow_run_service_service(service_dependencies) expected = MagicMock(name="pagination") workflow_runs.get_paginated_workflow_runs.return_value = expected - args = {"limit": "7", "last_id": "last-1", "status": "succeeded"} + args = TypeAdapter(WorkflowRunListArgs).validate_python( + {"limit": "7", "last_id": "last-1", "status": "succeeded"} + ) result = service.get_paginate_workflow_runs( _workflow_run_service_request_context(workspace_id="tenant-1"), @@ -161,7 +178,7 @@ class TestWorkflowRunServiceCases: result = service.get_paginate_advanced_chat_workflow_runs( _workflow_run_service_request_context(), app_id="app-1", - args={"limit": "2"}, + args={"limit": 2}, ) assert result is pagination @@ -272,6 +289,7 @@ def _workflow_run_service_pause_service(workflow_runs: MagicMock) -> WorkflowRun return WorkflowRunService( workflow_runs=workflow_runs, node_executions=MagicMock(), + session_factory=sessionmaker(), ) @@ -284,88 +302,138 @@ def _workflow_run_service_pause_request_context(*, workspace_id: str = "tenant-1 ) -class TestWorkflowRunServicePauseCases: - @staticmethod - @pytest.fixture - def workflow_runs() -> MagicMock: - return MagicMock() +"""Tests for Console workflow pause details.""" - @staticmethod - def test_get_pause_details_returns_none_when_run_is_not_found(workflow_runs: MagicMock) -> None: - workflow_runs.get_pause_record.return_value = None - result = _workflow_run_service_pause_service(workflow_runs).get_pause_details( - _workflow_run_service_pause_request_context(), workflow_run_id="run-1" - ) +@pytest.fixture +def workflow_runs() -> DifyAPISQLAlchemyWorkflowRunRepository: + return DifyAPISQLAlchemyWorkflowRunRepository(sessionmaker()) - assert result is None - workflow_runs.get_pause_record.assert_called_once_with( - workspace_id="tenant-1", - workflow_run_id="run-1", - ) - @staticmethod - def test_get_pause_details_returns_empty_details_for_non_paused_run(workflow_runs: MagicMock) -> None: - workflow_runs.get_pause_record.return_value = WorkflowRunPauseRecord( - status=WorkflowExecutionStatus.SUCCEEDED, - paused_at=None, - reasons=(), - form_tokens={}, - ) +def _service(workflow_runs: DifyAPISQLAlchemyWorkflowRunRepository) -> WorkflowRunService: + return WorkflowRunService( + workflow_runs=workflow_runs, + node_executions=DifyAPISQLAlchemyWorkflowNodeExecutionRepository(sessionmaker()), + session_factory=sessionmaker(), + ) - result = _workflow_run_service_pause_service(workflow_runs).get_pause_details( - _workflow_run_service_pause_request_context(), workflow_run_id="run-1" - ) - assert result == WorkflowRunPauseDetails(paused_at=None, paused_nodes=()) +def _request_context(*, workspace_id: str = "tenant-1") -> RequestContext: + return RequestContext( + request_id="request-1", + trace_id="trace-1", + account_id="account-1", + active_workspace_id=workspace_id, + ) - @staticmethod - def test_get_pause_details_maps_human_input_and_token(workflow_runs: MagicMock) -> None: - reason = HumanInputRequired( - form_id="form-1", - form_content="Approve?", - node_id="node-1", - node_title="Approval", - ) - paused_at = datetime(2026, 1, 2, 3, 4, 5) - workflow_runs.get_pause_record.return_value = WorkflowRunPauseRecord( - status=WorkflowExecutionStatus.PAUSED, - paused_at=paused_at, + +def test_get_pause_details_returns_none_when_run_is_not_found( + workflow_runs: DifyAPISQLAlchemyWorkflowRunRepository, monkeypatch: pytest.MonkeyPatch +) -> None: + lookups: list[tuple[str, str]] = [] + + def missing(_session: Session, *, workspace_id: str, workflow_run_id: str) -> None: + lookups.append((workspace_id, workflow_run_id)) + + monkeypatch.setattr(workflow_runs, "get_pause_record", missing) + + result = _service(workflow_runs).get_pause_details(_request_context(), workflow_run_id="run-1") + + assert result is None + assert lookups == [("tenant-1", "run-1")] + + +@pytest.mark.parametrize("status", [WorkflowExecutionStatus.SUCCEEDED, WorkflowExecutionStatus.PAUSED]) +def test_get_pause_details_returns_empty_details_without_an_active_pause( + workflow_runs: DifyAPISQLAlchemyWorkflowRunRepository, + status: WorkflowExecutionStatus, + monkeypatch: pytest.MonkeyPatch, +) -> None: + record = WorkflowRunPauseRecord( + status=status, + pause=None, + ) + monkeypatch.setattr(workflow_runs, "get_pause_record", lambda *_args, **_kwargs: record) + + result = _service(workflow_runs).get_pause_details(_request_context(), workflow_run_id="run-1") + + assert result == WorkflowRunPauseDetails(paused_at=None, paused_nodes=()) + + +def test_get_pause_details_maps_human_input_and_token( + workflow_runs: DifyAPISQLAlchemyWorkflowRunRepository, monkeypatch: pytest.MonkeyPatch +) -> None: + reason = HumanInputRequired( + form_id="form-1", + form_content="Approve?", + node_id="node-1", + node_title="Approval", + ) + paused_at = datetime(2026, 1, 2, 3, 4, 5) + pause_model = WorkflowPause(workflow_id="workflow-1", workflow_run_id="run-1", state_object_key="state") + pause_model.created_at = paused_at + pause = _PrivateWorkflowPauseEntity(pause_model=pause_model) + record = WorkflowRunPauseRecord( + status=WorkflowExecutionStatus.PAUSED, + pause=pause, + ) + + lookups: list[tuple[str, str]] = [] + + def pause_record(_session: Session, *, workspace_id: str, workflow_run_id: str) -> WorkflowRunPauseRecord: + lookups.append((workspace_id, workflow_run_id)) + return record + + monkeypatch.setattr(workflow_runs, "get_pause_record", pause_record) + monkeypatch.setattr(workflow_runs, "get_legacy_form_tokens", lambda *_args, **_kwargs: {"form-1": "form-token"}) + monkeypatch.setattr( + "services.workflow_run_service.load_workflow_pause_snapshot", + lambda *_args, **_kwargs: WorkflowPauseSnapshot( + runtime_state=RuntimeState(workflow_id="workflow", variable_pool=VariablePool(), start_at=0), reasons=(reason,), - form_tokens={"form-1": "form-token"}, - ) + v2_forms={}, + ), + ) - result = _workflow_run_service_pause_service(workflow_runs).get_pause_details( - _workflow_run_service_pause_request_context(workspace_id="tenant-context"), - workflow_run_id="run-1", - ) + result = _service(workflow_runs).get_pause_details( + _request_context(workspace_id="tenant-context"), + workflow_run_id="run-1", + ) - assert result == WorkflowRunPauseDetails( - paused_at=paused_at, - paused_nodes=( - WorkflowRunPausedNode( - node_id="node-1", - node_title="Approval", - form_id="form-1", - form_token="form-token", - ), + assert result == WorkflowRunPauseDetails( + paused_at=paused_at, + paused_nodes=( + WorkflowRunPausedNode( + node_id="node-1", + node_title="Approval", + form_id="form-1", + form_version="1", + form_token="form-token", ), - ) - workflow_runs.get_pause_record.assert_called_once_with( - workspace_id="tenant-context", - workflow_run_id="run-1", - ) + ), + ) - @staticmethod - def test_get_pause_details_rejects_unsupported_pause_reason(workflow_runs: MagicMock) -> None: - workflow_runs.get_pause_record.return_value = WorkflowRunPauseRecord( - status=WorkflowExecutionStatus.PAUSED, - paused_at=None, + assert lookups == [("tenant-context", "run-1")] + + +def test_get_pause_details_rejects_unsupported_pause_reason( + workflow_runs: DifyAPISQLAlchemyWorkflowRunRepository, monkeypatch: pytest.MonkeyPatch +) -> None: + record = WorkflowRunPauseRecord( + status=WorkflowExecutionStatus.PAUSED, + pause=_PrivateWorkflowPauseEntity( + pause_model=WorkflowPause(workflow_id="workflow-1", workflow_run_id="run-1", state_object_key="state") + ), + ) + monkeypatch.setattr(workflow_runs, "get_pause_record", lambda *_args, **_kwargs: record) + monkeypatch.setattr( + "services.workflow_run_service.load_workflow_pause_snapshot", + lambda *_args, **_kwargs: WorkflowPauseSnapshot( + runtime_state=RuntimeState(workflow_id="workflow", variable_pool=VariablePool(), start_at=0), reasons=(SchedulingPause(message="Waiting for external input"),), - form_tokens={}, - ) + v2_forms={}, + ), + ) - with pytest.raises(NotImplementedError, match="Pause details do not support SchedulingPause"): - _workflow_run_service_pause_service(workflow_runs).get_pause_details( - _workflow_run_service_pause_request_context(), workflow_run_id="run-1" - ) + with pytest.raises(NotImplementedError, match="Pause details do not support SchedulingPause"): + _service(workflow_runs).get_pause_details(_request_context(), workflow_run_id="run-1") diff --git a/api/tests/unit_tests/services/test_workflow_tool_source_service.py b/api/tests/unit_tests/services/test_workflow_tool_source_service.py new file mode 100644 index 00000000000..918a5a87bf6 --- /dev/null +++ b/api/tests/unit_tests/services/test_workflow_tool_source_service.py @@ -0,0 +1,33 @@ +from unittest.mock import create_autospec + +import pytest + +from core.tools.workflow_as_tool.repository import WorkflowToolSource +from enums import WorkflowKind +from graphon.nodes.tool.exc import ToolRuntimeResolutionError +from repositories.workflow_tool_source_repository import SQLAlchemyWorkflowToolSourceRepository +from services.workflow_tool_source_service import WorkflowToolSourceService + + +@pytest.mark.parametrize("version", ["1", "2"]) +def test_nested_tools_reject_v2_human_input_without_disabling_top_level_v2(version: str) -> None: + repository = create_autospec(SQLAlchemyWorkflowToolSourceRepository, instance=True) + form_graph = {"nodes": [{"data": {"type": "human-input", "version": version}}]} + source = WorkflowToolSource( + app_id="source-app", + workflow_id="source-workflow", + graph_config=form_graph, + features_dict={}, + environment_variables=(), + workflow_kind=WorkflowKind.STANDARD, + ) + repository.get_by_provider_ids.return_value = [source] + service = WorkflowToolSourceService(repository) + # A top-level V2 node remains available in the same execution context. + assert service.load(tenant_id="tenant", graph_config=form_graph).sources == {} + tool_graph = {"nodes": [{"data": {"type": "tool", "provider_type": "workflow", "provider_id": "provider"}}]} + if version == "2": + with pytest.raises(ToolRuntimeResolutionError, match="Human Input V2.*nested Workflow Tools"): + service.load(tenant_id="tenant", graph_config=tool_graph) + else: + assert service.load(tenant_id="tenant", graph_config=tool_graph).sources == {"source-workflow": source} diff --git a/api/tests/unit_tests/services/tracing/test_provider_dispatch.py b/api/tests/unit_tests/services/tracing/test_provider_dispatch.py index 3748b0994de..9af18badcf1 100644 --- a/api/tests/unit_tests/services/tracing/test_provider_dispatch.py +++ b/api/tests/unit_tests/services/tracing/test_provider_dispatch.py @@ -22,6 +22,7 @@ import requests import wandb import weave from aliyun.log.logclient import LogClient +from aliyun.log.util import Util from dify_trace_aliyun.aliyun_trace import AliyunDataTrace from dify_trace_aliyun.config import AliyunConfig from dify_trace_aliyun.data_exporter.traceclient import TraceClient, convert_to_span_id @@ -215,6 +216,8 @@ class TestApplicationTraceNodeAliasCases: monkeypatch.setattr( AliyunLogStore, "_check_endpoint_connectivity", staticmethod(lambda _endpoint, _timeout: None) ) + # The SDK constructor probes the endpoint through a socket before any HTTP request. + monkeypatch.setattr(Util, "get_host_ip", staticmethod(lambda _host: "127.0.0.1")) def reject_http_transport(*_args: object, **_kwargs: object) -> None: pytest.fail("Configured LogStore test escaped its isolated transport boundary") diff --git a/api/tests/unit_tests/services/trigger/test_webhook_service.py b/api/tests/unit_tests/services/trigger/test_webhook_service.py index 7e5ac49b18e..fe1a369277f 100644 --- a/api/tests/unit_tests/services/trigger/test_webhook_service.py +++ b/api/tests/unit_tests/services/trigger/test_webhook_service.py @@ -7,7 +7,7 @@ from unittest.mock import MagicMock, patch import pytest from flask import Flask from sqlalchemy import Engine as _webhook_service_relationships_Engine -from sqlalchemy import Table, select +from sqlalchemy import select from sqlalchemy.engine import Engine as _webhook_service_Engine from sqlalchemy.orm import Session, sessionmaker from werkzeug.datastructures import FileStorage @@ -21,7 +21,6 @@ from core.workflow.nodes.trigger_webhook.entities import ( WebhookParameter, ) from graphon.variables.types import SegmentType -from models.base import TypeBase from models.enums import AppTriggerStatus, AppTriggerType, EndUserType from models.model import App, AppMode, EndUser from models.tools import ToolFile @@ -1273,8 +1272,6 @@ class TestWebhookServiceRelationshipsCases: def test_draft_sync_preserves_webhook_id_when_deleted_node_is_restored( monkeypatch: pytest.MonkeyPatch, sqlite_engine: _webhook_service_relationships_Engine ) -> None: - webhook_trigger_table = cast(Table, WorkflowWebhookTrigger.__table__) - TypeBase.metadata.create_all(sqlite_engine, tables=[webhook_trigger_table]) session_maker = sessionmaker(bind=sqlite_engine, expire_on_commit=False) app = cast( App, @@ -1320,8 +1317,6 @@ class TestWebhookServiceRelationshipsCases: def test_published_sync_removes_stale_webhook_relationships( monkeypatch: pytest.MonkeyPatch, sqlite_engine: _webhook_service_relationships_Engine ) -> None: - webhook_trigger_table = cast(Table, WorkflowWebhookTrigger.__table__) - TypeBase.metadata.create_all(sqlite_engine, tables=[webhook_trigger_table]) session_maker = sessionmaker(bind=sqlite_engine, expire_on_commit=False) app = cast( App, diff --git a/api/tests/unit_tests/services/workspace/test_member_service.py b/api/tests/unit_tests/services/workspace/test_member_service.py index 8528b98604d..3f53388ad35 100644 --- a/api/tests/unit_tests/services/workspace/test_member_service.py +++ b/api/tests/unit_tests/services/workspace/test_member_service.py @@ -12,9 +12,10 @@ from enums import DeploymentEdition from enums.account import TenantAccountRole as _workspace_member_query_service_TenantAccountRole from extensions.ext_redis import RedisClientWrapper from machinery.context import RequestContext -from models.account import Account, AccountStatus, Tenant, TenantAccountJoin +from models.account import Account, AccountStatus, Tenant, TenantAccountJoin, TenantAccountRole from models.account import TenantAccountRole as _member_service_TenantAccountRole from models.dataset import Dataset +from models.human_input_v2 import HumanInputContactIdentity from models.model import App, AppMode from services.account.adapters import RedisInvitationTokenStore from services.account_errors import AccountNotFoundError @@ -89,28 +90,6 @@ class RecordedRoleAssignments: class TestMemberServiceCases: - @staticmethod - @pytest.fixture - def members(sqlite_session: Session) -> tuple[str, str, str]: - owner = Account(name="Owner", email="owner@example.com") - member = Account(name="Member", email="member@example.com") - tenant = Tenant(name="Workspace") - sqlite_session.add_all( - [ - owner, - member, - tenant, - TenantAccountJoin( - account_id=owner.id, tenant_id=tenant.id, role=_member_service_TenantAccountRole.OWNER, current=True - ), - TenantAccountJoin( - account_id=member.id, tenant_id=tenant.id, role=_member_service_TenantAccountRole.NORMAL - ), - ] - ) - sqlite_session.commit() - return tenant.id, owner.id, member.id - @staticmethod @pytest.mark.parametrize("existing_member", [True, False]) def test_join_rejects_a_second_owner_without_changing_membership( @@ -1197,3 +1176,59 @@ class TestWorkspaceMemberQueryServiceCases: assert [member.id for member in result.members] == expected_ids assert members.workspace_ids == ["workspace-1"] assert roles.calls == [] + + +@pytest.mark.parametrize("existing_member", [True, False]) +def test_membership_write_repairs_missing_contact_identity_without_replacing_it( + account_domain: AccountDomain, + members: tuple[str, str, str], + sqlite_session: Session, + existing_member: bool, +) -> None: + workspace_id, _, member_id = members + if not existing_member: + membership = sqlite_session.scalars( + select(TenantAccountJoin).where( + TenantAccountJoin.tenant_id == workspace_id, + TenantAccountJoin.account_id == member_id, + ) + ).one() + sqlite_session.delete(membership) + sqlite_session.commit() + + assert sqlite_session.scalar(select(HumanInputContactIdentity.id)) is None + account_domain.workspaces.upsert_member( + workspace_id=workspace_id, account_id=member_id, role=TenantAccountRole.NORMAL + ) + contact_id = sqlite_session.scalars( + select(HumanInputContactIdentity.id).where(HumanInputContactIdentity.account_id == member_id) + ).one() + account_domain.workspaces.upsert_member( + workspace_id=workspace_id, account_id=member_id, role=TenantAccountRole.EDITOR + ) + assert ( + sqlite_session.scalars( + select(HumanInputContactIdentity.id).where(HumanInputContactIdentity.account_id == member_id) + ).one() + == contact_id + ) + + +@pytest.fixture +def members(sqlite_session: Session) -> tuple[str, str, str]: + owner = Account(name="Owner", email="owner@example.com") + member = Account(name="Member", email="member@example.com") + tenant = Tenant(name="Workspace") + sqlite_session.add_all( + [ + owner, + member, + tenant, + TenantAccountJoin( + account_id=owner.id, tenant_id=tenant.id, role=_member_service_TenantAccountRole.OWNER, current=True + ), + TenantAccountJoin(account_id=member.id, tenant_id=tenant.id, role=_member_service_TenantAccountRole.NORMAL), + ] + ) + sqlite_session.commit() + return tenant.id, owner.id, member.id diff --git a/api/tests/unit_tests/tasks/test_im_message_inbox_tasks.py b/api/tests/unit_tests/tasks/test_im_message_inbox_tasks.py new file mode 100644 index 00000000000..5e82e5ff4ea --- /dev/null +++ b/api/tests/unit_tests/tasks/test_im_message_inbox_tasks.py @@ -0,0 +1,137 @@ +"""Tests for Celery-owned IM callback execution and retry.""" + +from datetime import UTC, datetime + +import pytest +from celery.exceptions import Retry +from sqlalchemy.engine import Engine +from sqlalchemy.orm import sessionmaker + +from core.human_input_v2.entities import IMProvider +from core.human_input_v2.im_integration.adapters import AuthenticatedIMEvent, IMEventIngressKind +from core.human_input_v2.im_message_inbox import IMInboxRecord, IMInboxRecordId +from dify_app import DifyApp +from repositories.human_input_v2.im_channel_repository import IMChannelId +from services.human_input_v2.im_message_inbox.processor import IMInboxProcessor +from services.human_input_v2.im_message_inbox.storage import IMInboxStorage +from tasks.im_message_inbox_tasks import ( + IMInboxRuntimeNotConfiguredError, + IMInboxTaskRetryError, + configure_im_inbox_task_runtime, + process_im_message_inbox_record, +) + +_NOW = datetime(2026, 8, 2, 8, tzinfo=UTC) + + +class _RecordProcessor: + record_ids: list[IMInboxRecordId] + + def __init__(self) -> None: + self.record_ids = [] + + def process(self, record_id: IMInboxRecordId) -> None: + self.record_ids.append(record_id) + + +class _FailingRecordProcessor: + def process(self, record_id: IMInboxRecordId) -> None: + raise RuntimeError(f"payload must-not-log for {record_id}") + + +class _Consumer: + records: list[IMInboxRecord] + + def __init__(self) -> None: + self.records = [] + + def consume(self, record: IMInboxRecord) -> None: + self.records.append(record) + + +def _event() -> AuthenticatedIMEvent: + return AuthenticatedIMEvent( + provider=IMProvider.FEISHU, + provider_tenant_id="tenant-1", + event_id="event-1", + event_type="card.action", + occurred_at=None, + received_at=datetime(2026, 8, 2, 8), + ingress_kind=IMEventIngressKind.WEBHOOK, + payload='{"secret":"must-not-log"}', + ) + + +def test_processing_task_uses_human_input_delivery_queue(monkeypatch: pytest.MonkeyPatch) -> None: + dispatches: list[dict[str, object]] = [] + + def capture_dispatch(*_args: object, **options: object) -> None: + dispatches.append(options) + + monkeypatch.setitem(process_im_message_inbox_record.app.conf, "task_always_eager", False) + monkeypatch.setattr(process_im_message_inbox_record.app, "send_task", capture_dispatch) + + process_im_message_inbox_record.apply_async(args=("record-1",)) + + assert len(dispatches) == 1 + assert dispatches[0]["queue"] == "human_input_delivery" + + +def test_processing_task_fails_closed_when_runtime_is_not_configured() -> None: + app = DifyApp(__name__) + + with app.app_context(), pytest.raises(IMInboxRuntimeNotConfiguredError): + process_im_message_inbox_record.run("record-1") + + +def test_processing_task_passes_typed_record_id_to_processor() -> None: + app = DifyApp(__name__) + processor = _RecordProcessor() + configure_im_inbox_task_runtime(app, processor_factory=lambda: processor) + + with app.app_context(): + result = process_im_message_inbox_record.run("record-1") + + assert result is None + assert processor.record_ids == [IMInboxRecordId("record-1")] + + +def test_processing_task_retries_sanitized_processor_failure( + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + app = DifyApp(__name__) + configure_im_inbox_task_runtime(app, processor_factory=_FailingRecordProcessor) + retry_errors: list[Exception] = [] + + def retry(*, exc: Exception, **_: object) -> None: + retry_errors.append(exc) + raise Retry() + + monkeypatch.setattr(process_im_message_inbox_record, "retry", retry) + + with app.app_context(), pytest.raises(Retry): + process_im_message_inbox_record.run("record-1") + + assert len(retry_errors) == 1 + assert isinstance(retry_errors[0], IMInboxTaskRetryError) + assert "must-not-log" not in caplog.text + assert "must-not-log" not in str(retry_errors[0]) + + +def test_processing_task_loads_callback_and_records_processed_fact(sqlite_engine: Engine) -> None: + repository = IMInboxStorage(sessionmaker(bind=sqlite_engine, expire_on_commit=False)) + accepted = repository.insert_or_resolve(IMChannelId("channel-1"), _event(), now=_NOW) + consumer = _Consumer() + processor = IMInboxProcessor(repository=repository, consumer=consumer, clock=lambda: _NOW) + app = DifyApp(__name__) + configure_im_inbox_task_runtime(app, processor_factory=lambda: processor) + + with app.app_context(): + process_im_message_inbox_record.run(str(accepted.record_id)) + + assert len(consumer.records) == 1 + assert consumer.records[0].event == _event() + stored = repository.get(accepted.record_id) + assert stored is not None + assert stored.processed_at == datetime(2026, 8, 2, 8) diff --git a/api/tests/unit_tests/tasks/test_mail_human_input_delivery_task.py b/api/tests/unit_tests/tasks/test_mail_human_input_delivery_task.py index 709e6314c33..1617f7deb3f 100644 --- a/api/tests/unit_tests/tasks/test_mail_human_input_delivery_task.py +++ b/api/tests/unit_tests/tasks/test_mail_human_input_delivery_task.py @@ -1,201 +1,143 @@ -import json from collections.abc import Sequence from datetime import UTC, datetime, timedelta -from types import SimpleNamespace from uuid import uuid4 import pytest from sqlalchemy.engine import Engine from sqlalchemy.orm import Session, sessionmaker -from models.human_input import HumanInputForm, RecipientType +from graphon.runtime import VariablePool +from models.human_input import HumanInputDelivery, HumanInputForm, HumanInputFormRecipient +from services.human_input_form_delivery_provider import ( + HumanInputFormDeliveryContext, + HumanInputFormDeliveryDispatcher, + HumanInputFormDeliveryProviderRegistry, +) from tasks import mail_human_input_delivery_task as task_module -class _DummyMail: - def __init__(self) -> None: - self.sent: list[dict[str, str]] = [] - self._inited = True - - def is_inited(self) -> bool: - return self._inited - - def send(self, *, to: str, subject: str, html: str) -> None: - self.sent.append({"to": to, "subject": subject, "html": html}) - - -def _form(*, workflow_run_id: str | None = None) -> HumanInputForm: - return HumanInputForm( +@pytest.mark.parametrize( + "sqlite_session", [(HumanInputForm, HumanInputDelivery, HumanInputFormRecipient)], indirect=True +) +@pytest.mark.parametrize( + ("email_only", "mail_inited", "feature_enabled", "feature_error", "expected_methods"), + [ + (True, True, True, False, (task_module.DeliveryMethodType.EMAIL,)), + (True, True, False, False, ()), + (True, False, True, False, ()), + (False, True, True, False, task_module.FORM_DELIVERY_METHOD_TYPES), + (False, False, True, False, (task_module.DeliveryMethodType.IM,)), + (False, True, False, False, (task_module.DeliveryMethodType.IM,)), + (False, True, True, True, (task_module.DeliveryMethodType.IM,)), + ], + ids=[ + "legacy-email", + "legacy-disabled", + "legacy-unconfigured", + "all-channels", + "im-without-mail", + "im-without-feature", + "im-after-feature-error", + ], +) +def test_delivery_task_dispatches_available_persisted_channels( + monkeypatch: pytest.MonkeyPatch, + sqlite_engine: Engine, + sqlite_session: Session, + email_only: bool, + mail_inited: bool, + feature_enabled: bool, + feature_error: bool, + expected_methods: tuple[task_module.DeliveryMethodType, ...], +) -> None: + form = HumanInputForm( tenant_id=str(uuid4()), app_id=str(uuid4()), - workflow_run_id=workflow_run_id, + workflow_run_id=None, conversation_id=None, node_id="human-input", form_definition="{}", rendered_content="content", expiration_time=datetime.now(UTC) + timedelta(hours=1), ) - - -def _build_job(recipient_count: int = 1) -> task_module._EmailDeliveryJob: - recipients: list[task_module._EmailRecipient] = [] - for idx in range(recipient_count): - recipients.append(task_module._EmailRecipient(email=f"user{idx}@example.com", token=f"token-{idx}")) - - return task_module._EmailDeliveryJob( - form_id="form-1", - subject="Subject", - body="Body for {{#url}}", - form_content="content", - recipients=recipients, - ) - - -@pytest.mark.parametrize("sqlite_session", [(HumanInputForm,)], indirect=True) -def test_dispatch_human_input_email_task_sends_to_each_recipient( - monkeypatch: pytest.MonkeyPatch, sqlite_engine: Engine, sqlite_session: Session -) -> None: - mail = _DummyMail() - form = _form() sqlite_session.add(form) + sqlite_session.flush() + deliveries = [ + HumanInputDelivery(form_id=form.id, delivery_method_type=method, channel_payload="{}") + for method in task_module.FORM_DELIVERY_METHOD_TYPES + ] + sqlite_session.add_all(deliveries) sqlite_session.commit() + pool = VariablePool() + sent: list[HumanInputFormDeliveryContext] = [] + loading_sessions: list[Session] = [] + feature_tenants: list[str] = [] + load_contexts = HumanInputFormDeliveryDispatcher.load_form_contexts - monkeypatch.setattr(task_module, "mail", mail) - monkeypatch.setattr( - task_module.FeatureService, - "get_features", - lambda _tenant_id, **_kwargs: SimpleNamespace(human_input_email_delivery_enabled=True), + def load( + dispatcher: HumanInputFormDeliveryDispatcher, + *, + session: Session, + form: HumanInputForm, + variable_pool: VariablePool | None = None, + delivery_method_types: Sequence[task_module.DeliveryMethodType] | None = None, + ) -> tuple[HumanInputFormDeliveryContext, ...]: + loading_sessions.append(session) + assert session.bind is sqlite_engine + assert variable_pool is pool + assert delivery_method_types == ( + (task_module.DeliveryMethodType.EMAIL,) if email_only else task_module.FORM_DELIVERY_METHOD_TYPES + ) + return load_contexts( + dispatcher, + session=session, + form=form, + variable_pool=variable_pool, + delivery_method_types=delivery_method_types, + ) + + def dispatch( + _registry: HumanInputFormDeliveryProviderRegistry, + *, + context: HumanInputFormDeliveryContext, + continue_on_error: bool = True, + ) -> bool: + assert len(loading_sessions) == 1 + assert not loading_sessions[0].in_transaction() + assert not loading_sessions[0].identity_map + assert continue_on_error + assert context.variable_pool is pool + sent.append(context) + return True + + from services.entities.feature_entities import FeatureModel + from services.feature_service import FeatureService + + def features(tenant_id: str, **_kwargs: object) -> FeatureModel: + feature_tenants.append(tenant_id) + if feature_error: + raise RuntimeError("feature service unavailable") + return FeatureModel(human_input_email_delivery_enabled=feature_enabled) + + monkeypatch.setattr(task_module.mail, "is_inited", lambda: mail_inited) + monkeypatch.setattr(FeatureService, "get_features", features) + monkeypatch.setattr(task_module, "_load_variable_pool", lambda _run_id: pool) + monkeypatch.setattr(HumanInputFormDeliveryProviderRegistry, "dispatch", dispatch) + monkeypatch.setattr(HumanInputFormDeliveryDispatcher, "load_form_contexts", load) + task = ( + task_module.dispatch_human_input_email_task + if email_only + else task_module.dispatch_human_input_form_delivery_task ) - jobs: Sequence[task_module._EmailDeliveryJob] = [_build_job(recipient_count=2)] - monkeypatch.setattr(task_module, "_load_email_jobs", lambda _session, _form: jobs) - - task_module.dispatch_human_input_email_task( - form_id=form.id, - node_title="Approve", - session_factory=sessionmaker(bind=sqlite_engine, expire_on_commit=False), + task( + form_id=form.id, node_title="Approve", session_factory=sessionmaker(bind=sqlite_engine, expire_on_commit=False) ) - - assert len(mail.sent) == 2 - assert all(payload["subject"] == "Subject" for payload in mail.sent) - assert all("Body for" in payload["html"] for payload in mail.sent) - - -@pytest.mark.parametrize("sqlite_session", [(HumanInputForm,)], indirect=True) -def test_dispatch_human_input_email_task_skips_when_feature_disabled( - monkeypatch: pytest.MonkeyPatch, sqlite_engine: Engine, sqlite_session: Session -) -> None: - mail = _DummyMail() - form = _form() - sqlite_session.add(form) - sqlite_session.commit() - - monkeypatch.setattr(task_module, "mail", mail) - monkeypatch.setattr( - task_module.FeatureService, - "get_features", - lambda _tenant_id, **_kwargs: SimpleNamespace(human_input_email_delivery_enabled=False), - ) - monkeypatch.setattr(task_module, "_load_email_jobs", lambda _session, _form: []) - - task_module.dispatch_human_input_email_task( - form_id=form.id, - node_title="Approve", - session_factory=sessionmaker(bind=sqlite_engine, expire_on_commit=False), - ) - - assert mail.sent == [] - - -@pytest.mark.parametrize("sqlite_session", [(HumanInputForm,)], indirect=True) -def test_dispatch_human_input_email_task_replaces_body_variables( - monkeypatch: pytest.MonkeyPatch, sqlite_engine: Engine, sqlite_session: Session -) -> None: - mail = _DummyMail() - form = _form(workflow_run_id=str(uuid4())) - sqlite_session.add(form) - sqlite_session.commit() - job = task_module._EmailDeliveryJob( - form_id="form-1", - subject="Subject", - body="Body {{#node1.value#}}", - form_content="content", - recipients=[task_module._EmailRecipient(email="user@example.com", token="token-1")], - ) - - variable_pool = task_module.VariablePool() - variable_pool.add(["node1", "value"], "OK") - - monkeypatch.setattr(task_module, "mail", mail) - monkeypatch.setattr( - task_module.FeatureService, - "get_features", - lambda _tenant_id, **_kwargs: SimpleNamespace(human_input_email_delivery_enabled=True), - ) - monkeypatch.setattr(task_module, "_load_email_jobs", lambda _session, _form: [job]) - monkeypatch.setattr(task_module, "_load_variable_pool", lambda _workflow_run_id: variable_pool) - - task_module.dispatch_human_input_email_task( - form_id=form.id, - node_title="Approve", - session_factory=sessionmaker(bind=sqlite_engine, expire_on_commit=False), - ) - - assert mail.sent[0]["html"] == "

Body OK

" - - -@pytest.mark.parametrize("line_break", ["\r\n", "\r", "\n"]) -@pytest.mark.parametrize("sqlite_session", [(HumanInputForm,)], indirect=True) -def test_dispatch_human_input_email_task_sanitizes_subject( - monkeypatch: pytest.MonkeyPatch, - line_break: str, - sqlite_engine: Engine, - sqlite_session: Session, -) -> None: - mail = _DummyMail() - form = _form() - sqlite_session.add(form) - sqlite_session.commit() - job = task_module._EmailDeliveryJob( - form_id="form-1", - subject=f"Notice{line_break}BCC:attacker@example.com Alert", - body="Body", - form_content="content", - recipients=[task_module._EmailRecipient(email="user@example.com", token="token-1")], - ) - - monkeypatch.setattr(task_module, "mail", mail) - monkeypatch.setattr( - task_module.FeatureService, - "get_features", - lambda _tenant_id, **_kwargs: SimpleNamespace(human_input_email_delivery_enabled=True), - ) - monkeypatch.setattr(task_module, "_load_email_jobs", lambda _session, _form: [job]) - monkeypatch.setattr(task_module, "_load_variable_pool", lambda _workflow_run_id: None) - - task_module.dispatch_human_input_email_task( - form_id=form.id, - node_title="Approve", - session_factory=sessionmaker(bind=sqlite_engine, expire_on_commit=False), - ) - - assert mail.sent[0]["subject"] == "Notice BCC:attacker@example.com Alert" - - -def test_parse_recipient_payload_reads_email_and_type() -> None: - payload = json.dumps({"email": "user@example.com", "TYPE": RecipientType.EMAIL_MEMBER}) - - assert task_module._parse_recipient_payload(payload) == ("user@example.com", RecipientType.EMAIL_MEMBER) - - -@pytest.mark.parametrize( - "payload", - [ - "not json", - json.dumps({"TYPE": RecipientType.EMAIL_MEMBER}), - json.dumps({"email": "user@example.com", "TYPE": "unknown-channel"}), - json.dumps({"email": 42, "TYPE": RecipientType.EMAIL_MEMBER}), - ], -) -def test_parse_recipient_payload_rejects_unusable_payloads(payload: str) -> None: - """Payloads the recipients layer cannot produce must degrade to a skipped recipient.""" - assert task_module._parse_recipient_payload(payload) == (None, None) + assert tuple(context.delivery_method_type for context in sent) == expected_methods + assert len(loading_sessions) == (0 if email_only and (not mail_inited or not feature_enabled) else 1) + assert feature_tenants == ([form.tenant_id] if mail_inited else []) + for context in sent: + assert context.form_id == form.id + assert context.tenant_id == form.tenant_id + assert context.app_id == form.app_id + assert context.rendered_content == "content" + assert context.delivery_id in {delivery.id for delivery in deliveries} diff --git a/api/tests/unit_tests/test_worker_entrypoint.py b/api/tests/unit_tests/test_worker_entrypoint.py new file mode 100644 index 00000000000..32d45d3d175 --- /dev/null +++ b/api/tests/unit_tests/test_worker_entrypoint.py @@ -0,0 +1,14 @@ +import re +from pathlib import Path + +_ENTRYPOINT_PATH = Path(__file__).resolve().parents[2] / "docker" / "entrypoint.sh" +_DEFAULT_QUEUE_ASSIGNMENT = re.compile(r'^\s*DEFAULT_QUEUES="(?P[^"$]+)"$', re.MULTILINE) +_IM_CONTACT_SYNC_QUEUE = "human_input_contact_sync" + + +def test_default_worker_queue_sets_consume_im_contact_sync_tasks() -> None: + entrypoint = _ENTRYPOINT_PATH.read_text() + default_queue_sets = [match.group("queues").split(",") for match in _DEFAULT_QUEUE_ASSIGNMENT.finditer(entrypoint)] + + assert len(default_queue_sets) == 2 + assert all(_IM_CONTACT_SYNC_QUEUE in queues for queues in default_queue_sets) diff --git a/api/uv.lock b/api/uv.lock index f7e1899962d..1131e38bf0b 100644 --- a/api/uv.lock +++ b/api/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 5 +revision = 3 requires-python = "==3.13.*" resolution-markers = [ "sys_platform == 'win32'", @@ -187,14 +187,50 @@ version = "1.0.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/a0/87/1d7019d23891897cb076b2f7e3c81ab3c2ba91de3bb067196f675d60d34c/alibabacloud-credentials-api-1.0.0.tar.gz", hash = "sha256:8c340038d904f0218d7214a8f4088c31912bfcf279af2cbc7d9be4897a97dd2f", size = 2330, upload-time = "2025-01-13T05:53:04.931Z" } +[[package]] +name = "alibabacloud-dingtalk" +version = "2.2.54" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "alibabacloud-endpoint-util" }, + { name = "alibabacloud-gateway-dingtalk" }, + { name = "alibabacloud-gateway-spi" }, + { name = "alibabacloud-openapi-util" }, + { name = "alibabacloud-tea-openapi" }, + { name = "alibabacloud-tea-util" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1d/70/184a62b7271abe38f856c08131bc61b7a7e0ded006cbf39da9c76d6c2335/alibabacloud_dingtalk-2.2.54.tar.gz", hash = "sha256:c97fd332dbebf12e6826394f05767c25f6755d76212a60f67db0d1f99965b4f4", size = 2113073, upload-time = "2026-07-23T07:07:08.754Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/6f/ae53e468af95aacd68f8251f2d567fa16d520eac349e9952419b531dfbda/alibabacloud_dingtalk-2.2.54-py3-none-any.whl", hash = "sha256:67b4f1c70ad17fb7f1611d3594130c5318a047b1ddbb1506c21249fffaac4a9b", size = 2235207, upload-time = "2026-07-23T07:07:06.918Z" }, +] + +[[package]] +name = "alibabacloud-endpoint-util" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/92/7d/8cc92a95c920e344835b005af6ea45a0db98763ad6ad19299d26892e6c8d/alibabacloud_endpoint_util-0.0.4.tar.gz", hash = "sha256:a593eb8ddd8168d5dc2216cd33111b144f9189fcd6e9ca20e48f358a739bbf90", size = 2813, upload-time = "2025-06-12T07:20:52.572Z" } + +[[package]] +name = "alibabacloud-gateway-dingtalk" +version = "1.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "alibabacloud-gateway-spi" }, + { name = "alibabacloud-tea-util" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d2/40/751d8bdf133d7fcf053f10c98e8e506810e7bee06458a02eaaa14d30ac26/alibabacloud_gateway_dingtalk-1.0.2.tar.gz", hash = "sha256:acea8b0b1d11e0394913f0b0899ddd19c0bfceab716060449b57fcc250ceb300", size = 2938, upload-time = "2023-04-25T09:48:42.249Z" } + [[package]] name = "alibabacloud-gateway-spi" -version = "0.0.3" +version = "0.0.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "alibabacloud-credentials" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ab/98/d7111245f17935bf72ee9bea60bbbeff2bc42cdfe24d2544db52bc517e1a/alibabacloud_gateway_spi-0.0.3.tar.gz", hash = "sha256:10d1c53a3fc5f87915fbd6b4985b98338a776e9b44a0263f56643c5048223b8b", size = 4249, upload-time = "2025-02-23T16:29:54.222Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/67/7cd36a36bf0ec53efd3fe31eaf398713f375b7b1cdcd32c51b6c0fdc09e2/alibabacloud_gateway_spi-0.0.4.tar.gz", hash = "sha256:73d6e20d65b54eed26d89c19640d3a7572e18c45ecada627f806f5dbe8ed2130", size = 4253, upload-time = "2026-06-25T06:34:08.238Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/45/85/81170c45e9d1240736f9776a1c690b958c975509381221b5f8d960b5376d/alibabacloud_gateway_spi-0.0.4-py3-none-any.whl", hash = "sha256:0d5256e95d8719da8ec9611b7ffbb12c2d26fdf7ce52c8f64e4e06350731e893", size = 4290, upload-time = "2026-06-25T06:34:07.22Z" }, +] [[package]] name = "alibabacloud-gpdb20160503" @@ -210,6 +246,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cc/ae/7d52d8a550f1bf8a4eb3958233f0ceb1bf69aba07b235feac1aba4ca1579/alibabacloud_gpdb20160503-5.9.0-py3-none-any.whl", hash = "sha256:f4318bd5b512e1edea5dfb7de2f985270535d0b2b94f94684680dd99d617b0f5", size = 974562, upload-time = "2026-07-14T17:33:07.942Z" }, ] +[[package]] +name = "alibabacloud-openapi-util" +version = "0.2.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "alibabacloud-tea-util" }, + { name = "cryptography" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f6/51/be5802851a4ed20ac2c6db50ac8354a6e431e93db6e714ca39b50983626f/alibabacloud_openapi_util-0.2.4.tar.gz", hash = "sha256:87022b9dcb7593a601f7a40ca698227ac3ccb776b58cb7b06b8dc7f510995c34", size = 7981, upload-time = "2026-01-15T08:05:03.947Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/46/9b217343648b366eb93447f5d93116e09a61956005794aed5ef95a2e9e2e/alibabacloud_openapi_util-0.2.4-py3-none-any.whl", hash = "sha256:a2474f230b5965ae9a8c286e0dc86132a887928d02d20b8182656cf6b1b6c5bd", size = 7661, upload-time = "2026-01-15T08:05:01.374Z" }, +] + [[package]] name = "alibabacloud-tea" version = "0.4.3" @@ -647,6 +696,32 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/31/07/4cdc81a47bf862c0b06d91f1bc6782064e8b69ac9b5d4ff51d97e4ff03da/blis-1.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:7a0fc4b237a3a453bdc3c7ab48d91439fcd2d013b665c46948d9eaf9c3e45a97", size = 6192624, upload-time = "2025-11-17T12:28:14.197Z" }, ] +[[package]] +name = "botbuilder-schema" +version = "4.17.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "msrest" }, + { name = "urllib3" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/44/abac81a80ed8aea977afbf8c40c22dec11610bbc1e4bd5bff4a7d7386b94/botbuilder_schema-4.17.1-py2.py3-none-any.whl", hash = "sha256:34da28d721b0635fc41bbe3b368225b9883f527d6f17337f858efe80f74656eb", size = 38196, upload-time = "2026-01-05T19:49:45.501Z" }, +] + +[[package]] +name = "botframework-connector" +version = "4.17.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botbuilder-schema" }, + { name = "msal" }, + { name = "msrest" }, + { name = "pyjwt" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/37/6c325f4e6441cba2b3184e6c9a15c3ddc00b5e5aa47966398c3d9836b598/botframework_connector-4.17.1-py2.py3-none-any.whl", hash = "sha256:929d242a242f16c7c637eec7c239a84fd7f5f5bb7f95968407d3a9aa9d7bdf8d", size = 100883, upload-time = "2026-01-05T19:49:48.285Z" }, +] + [[package]] name = "boto3" version = "1.43.107" @@ -1399,15 +1474,18 @@ docs = [ name = "dify-api" version = "1.18.0" source = { virtual = "." } -default-groups = ["kms", "storage", "tools", "trace-all", "vdb-all"] dependencies = [ + { name = "alibabacloud-dingtalk" }, { name = "aliyun-log-python-sdk" }, { name = "azure-identity" }, { name = "bleach" }, + { name = "botbuilder-schema" }, + { name = "botframework-connector" }, { name = "boto3" }, { name = "celery" }, { name = "croniter" }, { name = "dify-agent" }, + { name = "email-validator" }, { name = "fastopenapi", extra = ["flask"] }, { name = "flask" }, { name = "flask-compress" }, @@ -1426,6 +1504,8 @@ dependencies = [ { name = "httpx", extra = ["socks"] }, { name = "httpx-sse" }, { name = "json-repair" }, + { name = "lark-oapi" }, + { name = "markdown-it-py" }, { name = "opentelemetry-distro" }, { name = "opentelemetry-instrumentation-celery" }, { name = "opentelemetry-instrumentation-flask" }, @@ -1435,12 +1515,15 @@ dependencies = [ { name = "opentelemetry-propagator-b3" }, { name = "psycogreen" }, { name = "psycopg2-binary" }, + { name = "pyjwt" }, { name = "python-socketio" }, { name = "readabilipy" }, { name = "redis", extra = ["hiredis"] }, { name = "resend" }, { name = "sendgrid" }, + { name = "slack-sdk" }, { name = "sseclient-py" }, + { name = "wechatpy" }, { name = "werkzeug" }, { name = "yarl" }, { name = "zstandard" }, @@ -1693,13 +1776,17 @@ vdb-xinference = [ [package.metadata] requires-dist = [ + { name = "alibabacloud-dingtalk", specifier = ">=2.2.54,<3.0.0" }, { name = "aliyun-log-python-sdk", specifier = "==0.9.44" }, { name = "azure-identity", specifier = ">=1.26.0,<2.0.0" }, { name = "bleach", specifier = ">=6.4.0,<7.0.0" }, + { name = "botbuilder-schema", specifier = ">=4.17.0,<5.0.0" }, + { name = "botframework-connector", specifier = ">=4.17.0,<5.0.0" }, { name = "boto3", specifier = ">=1.43.107,<2.0.0" }, { name = "celery", specifier = ">=5.6.3,<6.0.0" }, { name = "croniter", specifier = ">=6.2.2,<7.0.0" }, { name = "dify-agent", editable = "../dify-agent" }, + { name = "email-validator", specifier = ">=2.3.0,<3.0.0" }, { name = "fastopenapi", extras = ["flask"], specifier = "==0.7.0" }, { name = "flask", specifier = ">=3.1.3,<4.0.0" }, { name = "flask-compress", specifier = ">=1.25,<2.0.0" }, @@ -1718,6 +1805,8 @@ requires-dist = [ { name = "httpx", extras = ["socks"], specifier = "==0.28.1" }, { name = "httpx-sse", specifier = "==0.4.3" }, { name = "json-repair", specifier = "==0.60.1" }, + { name = "lark-oapi", specifier = "==1.7.2" }, + { name = "markdown-it-py", specifier = ">=4.0.0,<5.0.0" }, { name = "opentelemetry-distro", specifier = "==0.65b0" }, { name = "opentelemetry-instrumentation-celery", specifier = "==0.65b0" }, { name = "opentelemetry-instrumentation-flask", specifier = "==0.65b0" }, @@ -1727,12 +1816,15 @@ requires-dist = [ { name = "opentelemetry-propagator-b3", specifier = ">=1.45.0,<2.0.0" }, { name = "psycogreen", specifier = ">=1.0.2,<2.0.0" }, { name = "psycopg2-binary", specifier = ">=2.9.12,<3.0.0" }, + { name = "pyjwt", specifier = ">=2.10.1,<3.0.0" }, { name = "python-socketio", specifier = ">=5.13.0,<6.0.0" }, { name = "readabilipy", specifier = "==0.3.0" }, { name = "redis", extras = ["hiredis"], specifier = ">=7.4.0,<8.0.0" }, { name = "resend", specifier = ">=2.27.0,<3.0.0" }, { name = "sendgrid", specifier = ">=6.12.5,<7.0.0" }, + { name = "slack-sdk", specifier = ">=3.43.0,<4.0.0" }, { name = "sseclient-py", specifier = ">=1.8.0,<2.0.0" }, + { name = "wechatpy", specifier = ">=1.8.18,<2.0.0" }, { name = "werkzeug", specifier = ">=3.1.7,<4.0.0" }, { name = "yarl", specifier = ">=1.25.1,<2.0.0" }, { name = "zstandard", specifier = "==0.25.0" }, @@ -1759,7 +1851,7 @@ dev = [ { name = "pytest-xdist", specifier = ">=3.8.0" }, { name = "ruff", specifier = ">=0.16.9" }, { name = "scipy-stubs", specifier = ">=1.18.1.1" }, - { name = "testcontainers", specifier = ">=4.14.2" }, + { name = "testcontainers", specifier = ">=4.15.0,<4.16" }, { name = "types-aiofiles", specifier = ">=25.1.0.20260518" }, { name = "types-beautifulsoup4", specifier = ">=4.12.0" }, { name = "types-bleach", specifier = ">=6.4.0.20260728" }, @@ -2351,6 +2443,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, ] +[[package]] +name = "dnspython" +version = "2.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" }, +] + [[package]] name = "docker" version = "7.1.0" @@ -2424,6 +2525,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a2/09/c9dec8bd95bff6aaa8fe29a834257a6606608d0b2ed9932a1857683f736f/elasticsearch-8.14.0-py3-none-any.whl", hash = "sha256:cef8ef70a81af027f3da74a4f7d9296b390c636903088439087b8262a468c130", size = 480236, upload-time = "2024-06-06T13:31:00.987Z" }, ] +[[package]] +name = "email-validator" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dnspython" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426", size = 51238, upload-time = "2025-08-26T13:09:06.831Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", size = 35604, upload-time = "2025-08-26T13:09:05.858Z" }, +] + [[package]] name = "emoji" version = "2.15.0" @@ -3387,8 +3501,8 @@ name = "httpcore2" version = "2.12.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "h11" }, - { name = "truststore" }, + { name = "h11", marker = "sys_platform != 'emscripten'" }, + { name = "truststore", marker = "sys_platform != 'emscripten'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/be/ad/f4f0e57345f1870f3e8cb624e058d7eca6e5a27d33bcc3311d9b618734cd/httpcore2-2.12.0.tar.gz", hash = "sha256:9293522bba0aa7c4c8e9e3f040c16575bd8868e155a77fa30c7a9085a5eae648", size = 67548, upload-time = "2026-08-18T13:22:08.211Z" } wheels = [ @@ -3890,6 +4004,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/82/3d/14ce75ef66813643812f3093ab17e46d3a206942ce7376d31ec2d36229e7/lark-1.3.1-py3-none-any.whl", hash = "sha256:c629b661023a014c37da873b4ff58a817398d12635d3bbb2c5a03be7fe5d1e12", size = 113151, upload-time = "2025-10-27T18:25:54.882Z" }, ] +[[package]] +name = "lark-oapi" +version = "1.7.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, + { name = "pycryptodome" }, + { name = "requests" }, + { name = "requests-toolbelt" }, + { name = "websockets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/63/02/9992aa997e7085507237554ba5b49b998d77a6f8971e05ce105b04fbc324/lark_oapi-1.7.2.tar.gz", hash = "sha256:deee5ed90f6b54448e00af6749af90a1ac72f212050c24a6c619f5a12c542774", size = 2328627, upload-time = "2026-08-05T09:22:52.805Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/34/151efd46acca34d086a2db86d1820ec2452a72d1a8281bb73d5991f03505/lark_oapi-1.7.2-py3-none-any.whl", hash = "sha256:d73cbcac28d8e7cd75efe64a8c350da50b1a45be046c5e5b8382701ea8bb764c", size = 7629022, upload-time = "2026-08-05T09:22:50.485Z" }, +] + [[package]] name = "litellm" version = "1.95.0" @@ -4175,6 +4305,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5e/75/bd9b7bb966668920f06b200e84454c8f3566b102183bc55c5473d96cb2b9/msal_extensions-1.3.1-py3-none-any.whl", hash = "sha256:96d3de4d034504e969ac5e85bae8106c8373b5c6568e4c8fa7af2eca9dbe6bca", size = 20583, upload-time = "2025-03-14T23:51:03.016Z" }, ] +[[package]] +name = "msrest" +version = "0.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "azure-core" }, + { name = "certifi" }, + { name = "isodate" }, + { name = "requests" }, + { name = "requests-oauthlib" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/68/77/8397c8fb8fc257d8ea0fa66f8068e073278c65f05acb17dcb22a02bfdc42/msrest-0.7.1.zip", hash = "sha256:6e7661f46f3afd88b75667b7187a92829924446c7ea1d169be8c4bb7eeb788b9", size = 175332, upload-time = "2022-06-13T22:41:25.111Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/15/cf/f2966a2638144491f8696c27320d5219f48a072715075d168b31d3237720/msrest-0.7.1-py3-none-any.whl", hash = "sha256:21120a810e1233e5e6cc7fe40b474eeb4ec6f757a15d7cf86702c369f9567c32", size = 85384, upload-time = "2022-06-13T22:41:22.42Z" }, +] + [[package]] name = "multidict" version = "6.9.1" @@ -4845,6 +4991,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/99/2d/e5536a2a1b6fdd920d995e09315523be53bde5fe01f104894d9ba7421a8c/opik-1.11.2-py3-none-any.whl", hash = "sha256:1016b6db7563d847e50e463a2ae09e595b6921372dd52edeada660b82036e1b2", size = 1451056, upload-time = "2026-04-10T10:48:12.927Z" }, ] +[[package]] +name = "optionaldict" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ad/8b/cb72b1d40d21963ad1923ce529ae1f9ba49a13c18c7e6c47081c3dc1211f/optionaldict-0.1.2.tar.gz", hash = "sha256:175b62ee8259af703def42e0b44b413ea55e7b0732456cb2fd5cbbc998605966", size = 3454, upload-time = "2021-01-15T07:52:20.311Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/c2/408eb8d1ddacc36c6c31e0b0640b8493a4b3e88e26670bdd2b3a8ad7d704/optionaldict-0.1.2-py2.py3-none-any.whl", hash = "sha256:278d9b79b3d6a55b1b8ff5f9017bff6383a33426348d09e699ff50162bbcdab7", size = 3756, upload-time = "2021-01-15T07:52:18.69Z" }, +] + [[package]] name = "optype" version = "0.17.0" @@ -6367,6 +6522,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, ] +[[package]] +name = "slack-sdk" +version = "3.43.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/13/75/a4964eb771a0c74d79ee7a3bee6fb5d9718909dd1b675e80d62a6a0ad90a/slack_sdk-3.43.0.tar.gz", hash = "sha256:0553152e46c4259eb69f7464cdadc35ba4802ca10f9f5a849c92cf03d6c2ba07", size = 252769, upload-time = "2026-06-30T18:04:41.59Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e4/55/42141b8338d46323d5b3c6095201b044c670c20f898643b322ea9b1543a1/slack_sdk-3.43.0-py2.py3-none-any.whl", hash = "sha256:4b6557c65577fc172f685af218b811f9f3b4909e24cddd839ada09565f10c585", size = 315866, upload-time = "2026-06-30T18:04:39.636Z" }, +] + [[package]] name = "smart-open" version = "7.5.1" @@ -6705,7 +6869,7 @@ wheels = [ [[package]] name = "testcontainers" -version = "4.14.2" +version = "4.15.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "docker" }, @@ -6714,9 +6878,9 @@ dependencies = [ { name = "urllib3" }, { name = "wrapt" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ca/ac/a597c3a0e02b26cbed6dd07df68be1e57684766fd1c381dee9b170a99690/testcontainers-4.14.2.tar.gz", hash = "sha256:1340ccf16fe3acd9389a6c9e1d9ab21d9fe99a8afdf8165f89c3e69c1967d239", size = 166841, upload-time = "2026-03-18T05:19:16.696Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4b/13/2cc466bddf26d0085f30a2b2bd56b7f8708b54a54db833eec97c5c69129b/testcontainers-4.15.0.tar.gz", hash = "sha256:085cde086337632e19002719460b7b80bbab2bdd51bb3ea04f77d0de96504706", size = 95340, upload-time = "2026-07-24T23:08:01.731Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/13/2d/26b8b30067d94339afee62c3edc9b803a6eb9332f521ba77d8aaab5de873/testcontainers-4.14.2-py3-none-any.whl", hash = "sha256:0d0522c3cd8f8d9627cda41f7a6b51b639fa57bdc492923c045117933c668d68", size = 125712, upload-time = "2026-03-18T05:19:15.29Z" }, + { url = "https://files.pythonhosted.org/packages/00/7e/424aac8b355597835deb333e757a0e94b5ccf38ad00f07fe6ed1f4e17c88/testcontainers-4.15.0-py3-none-any.whl", hash = "sha256:8796c14e76604031ad39cf0ed3b8e9806283a1fbf5270965c2b1c594caa31b74", size = 160771, upload-time = "2026-07-24T23:08:00.13Z" }, ] [[package]] @@ -7817,6 +7981,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f3/ed/aad7e0f5a462d679f7b4d2e0d8502c3096740c883b5bbed5103146480937/webvtt_py-0.5.1-py3-none-any.whl", hash = "sha256:9d517d286cfe7fc7825e9d4e2079647ce32f5678eb58e39ef544ffbb932610b7", size = 19802, upload-time = "2024-05-30T13:40:14.661Z" }, ] +[[package]] +name = "wechatpy" +version = "1.8.18" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "optionaldict" }, + { name = "python-dateutil" }, + { name = "requests" }, + { name = "six" }, + { name = "xmltodict" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/80/34/849aaa2a03dc41b002e224006746bdb2eea729435225709139c4b3a95d2b/wechatpy-1.8.18.tar.gz", hash = "sha256:b356d0181abcf8487268e5759f942c4ffbb5090c00197858109e90000a301d08", size = 107318, upload-time = "2021-11-11T15:12:32.716Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/09/d6/a49a4571affd12dbb88067d7c3a874dc069ffa471c29fd3a8e2771e7f0c2/wechatpy-1.8.18-py2.py3-none-any.whl", hash = "sha256:bfd0457dfd3f4623a490534cc9cb9c29b53f9397b3ebbee0f11ee7925b9b71f6", size = 159152, upload-time = "2021-11-11T15:12:31.564Z" }, +] + [[package]] name = "werkzeug" version = "3.1.9" diff --git a/docker/.env.example b/docker/.env.example index 7607eb30ff7..01e7be49aab 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -18,6 +18,7 @@ SERVER_CONSOLE_API_URL=http://api:5001 CONSOLE_WEB_URL= SERVICE_API_URL= TRIGGER_URL=http://localhost +HUMAN_INPUT_IM_EVENT_TRANSPORT_MODE=WEBHOOK APP_API_URL= APP_WEB_URL= FILES_URL= diff --git a/docker/README.md b/docker/README.md index 76a8c9fa4ea..e4c9af0d2b0 100644 --- a/docker/README.md +++ b/docker/README.md @@ -274,6 +274,18 @@ Docker Compose reads `envs/*.env` files when present, then reads `.env` last so For the available variables and defaults, read the matching `envs/**/*.env.example` template and its service in `docker-compose.yaml`. +Custom worker queue lists set through `CELERY_QUEUES` or `CELERY_WORKER_QUEUES` must include +`human_input_delivery` to process durable Human Input IM inbox events and recover +unconfirmed Human Input v2 notifications, and +`human_input_contact_sync` to process manual IM Contact directory synchronization. + +Human Input IM channels in `WEBHOOK` mode build their Callback URL from `TRIGGER_URL`. +Set it to the externally reachable HTTPS origin of the API when configuring a remote +deployment. A localhost value is only suitable for local development. Reverse proxies +must forward `/callbacks/human-input/v2/im/` to the API and preserve the request path, +body, and provider signature headers. Copy the channel's Callback URL into the +provider's interaction configuration after changing the public origin. + ## Environment Variables Synchronization When upgrading Dify or pulling the latest changes, new environment variables may be introduced in `.env.example` only when they are required for startup, diff --git a/docker/envs/core-services/shared.env.example b/docker/envs/core-services/shared.env.example index fc004d00454..1a6280dab03 100644 --- a/docker/envs/core-services/shared.env.example +++ b/docker/envs/core-services/shared.env.example @@ -5,6 +5,7 @@ CONSOLE_WEB_URL= SERVICE_API_URL= TRIGGER_URL=http://localhost +HUMAN_INPUT_IM_EVENT_TRANSPORT_MODE=WEBHOOK APP_WEB_URL= # FILES_URL= # INTERNAL_FILES_URL= diff --git a/docker/envs/core-services/web.env.example b/docker/envs/core-services/web.env.example index 27b855ac19c..0b826189f59 100644 --- a/docker/envs/core-services/web.env.example +++ b/docker/envs/core-services/web.env.example @@ -39,6 +39,11 @@ ENABLE_WEBSITE_FIRECRAWL=true ENABLE_WEBSITE_WATERCRAWL=true NEXT_PUBLIC_ENABLE_SINGLE_DOLLAR_LATEX=false NEXT_PUBLIC_ENABLE_FEATURE_PREVIEW=true + +# Enable HITL IM, Contacts, V2 editing/migration and approval UI. Defaults to false. +# When false, retain legacy V1 behavior and disable V2 entry points without converting saved data. +# Restart the Web service and reload the page after changing this setting. +NEXT_PUBLIC_ENABLE_HITL_IM=false # Show Access Token settings to workspace owners. Disabled by default. NEXT_PUBLIC_ENABLE_ACCESS_TOKEN=false NEXT_PUBLIC_ENABLE_AGENT_V2=true diff --git a/docker/nginx/conf.d/default.conf.template b/docker/nginx/conf.d/default.conf.template index feee1cb7e67..178ab57042f 100644 --- a/docker/nginx/conf.d/default.conf.template +++ b/docker/nginx/conf.d/default.conf.template @@ -74,7 +74,13 @@ server { proxy_pass http://$api_upstream; include proxy.conf; } - + + location /callbacks/human-input/v2/im/ { + set $api_upstream api:5001; + proxy_pass http://$api_upstream; + include proxy.conf; + } + # placeholder for acme challenge location ${ACME_CHALLENGE_LOCATION} diff --git a/oxlint-suppressions.json b/oxlint-suppressions.json index 8c0d5f20355..a54bac67924 100644 --- a/oxlint-suppressions.json +++ b/oxlint-suppressions.json @@ -795,9 +795,6 @@ } }, "web/app/components/app/text-generate/item/index.tsx": { - "better-tailwindcss/enforce-logical-properties": { - "count": 7 - }, "dify/require-disable-directive-description": { "count": 4 }, @@ -993,7 +990,7 @@ }, "web/app/components/base/chat/chat-with-history/chat-wrapper.tsx": { "typescript/no-explicit-any": { - "count": 7 + "count": 6 } }, "web/app/components/base/chat/chat-with-history/context.ts": { @@ -4043,11 +4040,6 @@ "count": 3 } }, - "web/app/components/workflow/block-selector/blocks.tsx": { - "better-tailwindcss/enforce-logical-properties": { - "count": 4 - } - }, "web/app/components/workflow/block-selector/constants.tsx": { "react/only-export-components": { "count": 3 @@ -4241,11 +4233,6 @@ "count": 5 } }, - "web/app/components/workflow/header/running-title.tsx": { - "better-tailwindcss/enforce-logical-properties": { - "count": 2 - } - }, "web/app/components/workflow/header/test-run-menu-helpers.tsx": { "dify/require-disable-directive-description": { "count": 1 @@ -5027,11 +5014,6 @@ "count": 1 } }, - "web/app/components/workflow/nodes/human-input/panel.tsx": { - "better-tailwindcss/enforce-logical-properties": { - "count": 1 - } - }, "web/app/components/workflow/nodes/if-else/components/condition-files-list-value.tsx": { "better-tailwindcss/enforce-logical-properties": { "count": 4 @@ -5961,9 +5943,6 @@ } }, "web/app/components/workflow/panel/workflow-preview.tsx": { - "better-tailwindcss/enforce-logical-properties": { - "count": 7 - }, "jsx-a11y/click-events-have-key-events": { "count": 4 }, diff --git a/packages/contracts/generated/api/console/agent/types.gen.ts b/packages/contracts/generated/api/console/agent/types.gen.ts index 672fbcf0b65..69522a9ce4f 100644 --- a/packages/contracts/generated/api/console/agent/types.gen.ts +++ b/packages/contracts/generated/api/console/agent/types.gen.ts @@ -1822,6 +1822,7 @@ export type HumanInputFormDefinition = { form_content: string form_id: string form_token?: string | null + form_version?: '1' | '2' inputs?: Array node_id: string node_title: string diff --git a/packages/contracts/generated/api/console/agent/zod.gen.ts b/packages/contracts/generated/api/console/agent/zod.gen.ts index a8d7c53fe3e..04b3c04e8c1 100644 --- a/packages/contracts/generated/api/console/agent/zod.gen.ts +++ b/packages/contracts/generated/api/console/agent/zod.gen.ts @@ -2560,7 +2560,7 @@ export const zAgentSoulSandboxConfig = z.object({ * * Per-item shape for an ``array``-typed declared output. * - * PRD §OUTPUT 配置框 keeps arrays one level deep on first version; nested arrays + * The PRD output configuration section limits arrays to one level; nested arrays * are rejected so the runtime type checker and JSON Schema stay easy to reason * about. Stage 4 §4.2. */ @@ -3192,7 +3192,7 @@ export const zAgentSoulModelConfig = z.object({ * * File-output content check via a model-based comparison against a benchmark file. * - * Per PRD §OUTPUT 配置框, output check is **file-only** and optional. Stage 4 §4.3. + * Per the PRD output configuration section, output checks are **file-only** and optional. Stage 4 §4.3. */ export const zDeclaredOutputCheckConfig = z.object({ benchmark_file_ref: zAgentFileRefConfig.nullish(), @@ -3718,6 +3718,7 @@ export const zHumanInputFormDefinition = z.object({ form_content: z.string(), form_id: z.string(), form_token: z.string().nullish(), + form_version: z.enum(['1', '2']).optional().default('1'), inputs: z.array(zFormInputConfig).optional(), node_id: z.string(), node_title: z.string(), diff --git a/packages/contracts/generated/api/console/apps/errors.gen.ts b/packages/contracts/generated/api/console/apps/errors.gen.ts index 0fecc13004f..f82577bbc7c 100644 --- a/packages/contracts/generated/api/console/apps/errors.gen.ts +++ b/packages/contracts/generated/api/console/apps/errors.gen.ts @@ -524,12 +524,14 @@ export const post6Errors = { app_not_found: { status: 404, data: zAppNotFoundErr export const post7Errors = { app_not_found: { status: 404, data: zAppNotFoundError } } -export const post8Errors = { +export const post8Errors = { app_not_found: { status: 404, data: zAppNotFoundError } } + +export const post9Errors = { app_not_found: { status: 404, data: zAppNotFoundError }, rate_limit_error: { status: 429, data: zRateLimitError }, } -export const post9Errors = { +export const post10Errors = { agent_not_bound: { status: 400, data: zAgentNotBoundError }, agent_not_found_error: { status: 404, data: zAgentNotFoundError }, app_not_found: { status: 404, data: zAppNotFoundError }, @@ -559,7 +561,7 @@ export const get8Errors = { app_not_found: { status: 404, data: zAppNotFoundError }, } -export const post10Errors = { +export const post11Errors = { agent_not_bound: { status: 400, data: zAgentNotBoundError }, agent_not_found_error: { status: 404, data: zAgentNotFoundError }, app_not_found: { status: 404, data: zAppNotFoundError }, @@ -571,7 +573,7 @@ export const get9Errors = { app_not_found: { status: 404, data: zAppNotFoundError }, } -export const post11Errors = { +export const post12Errors = { agent_not_bound: { status: 400, data: zAgentNotBoundError }, agent_not_found_error: { status: 404, data: zAgentNotFoundError }, app_not_found: { status: 404, data: zAppNotFoundError }, @@ -621,7 +623,7 @@ export const get15Errors = { export const get16Errors = { app_not_found: { status: 404, data: zAppNotFoundError } } -export const post14Errors = { +export const post15Errors = { no_file_uploaded: { status: 400, data: zNoFileUploadedError }, too_many_files: { status: 400, data: zTooManyFilesError }, } @@ -630,7 +632,7 @@ export const get20Errors = { app_not_found: { status: 404, data: zAppNotFoundErr export const delete4Errors = { bad_request: { status: 400, data: zBadRequestError } } -export const post18Errors = { +export const post19Errors = { agent_version_not_found_error: { status: 404, data: zAgentVersionNotFoundError }, app_not_found: { status: 404, data: zAppNotFoundError }, app_unavailable: { status: 400, data: zAppUnavailableError }, @@ -671,7 +673,7 @@ export const get26Errors = { provider_quota_exceeded: { status: 400, data: zProviderQuotaExceededError }, } -export const post19Errors = { app_not_found: { status: 404, data: zAppNotFoundError } } +export const post20Errors = { app_not_found: { status: 404, data: zAppNotFoundError } } export const get27Errors = { app_not_found: { status: 404, data: zAppNotFoundError }, @@ -690,9 +692,9 @@ export const get28Errors = { export const get29Errors = { app_not_found: { status: 404, data: zAppNotFoundError } } -export const post20Errors = { app_not_found: { status: 404, data: zAppNotFoundError } } +export const post21Errors = { app_not_found: { status: 404, data: zAppNotFoundError } } -export const post21Errors = { +export const post22Errors = { app_not_found: { status: 404, data: zAppNotFoundError }, app_unavailable: { status: 400, data: zAppUnavailableError }, completion_request_error: { status: 400, data: zCompletionRequestError }, @@ -703,13 +705,13 @@ export const post21Errors = { export const get30Errors = { app_not_found: { status: 404, data: zAppNotFoundError } } -export const post22Errors = { app_not_found: { status: 404, data: zAppNotFoundError } } +export const post23Errors = { app_not_found: { status: 404, data: zAppNotFoundError } } export const get31Errors = { roster_agent_package_export_failed: { status: 500, data: zRosterAgentPackageExportFailedError }, } -export const post24Errors = { +export const post25Errors = { app_not_found: { status: 404, data: zAppNotFoundError }, app_unavailable: { status: 400, data: zAppUnavailableError }, message_feedback_rating_required: { status: 400, data: zMessageFeedbackRatingRequiredError }, @@ -720,7 +722,7 @@ export const get32Errors = { app_unavailable: { status: 400, data: zAppUnavailableError }, } -export const post26Errors = { app_not_found: { status: 404, data: zAppNotFoundError } } +export const post27Errors = { app_not_found: { status: 404, data: zAppNotFoundError } } export const get33Errors = { app_not_found: { status: 404, data: zAppNotFoundError } } @@ -728,18 +730,18 @@ export const get34Errors = { app_not_found: { status: 404, data: zAppNotFoundErr export const putErrors = { app_not_found: { status: 404, data: zAppNotFoundError } } -export const post29Errors = { app_not_found: { status: 404, data: zAppNotFoundError } } +export const post30Errors = { app_not_found: { status: 404, data: zAppNotFoundError } } export const get35Errors = { app_not_found: { status: 404, data: zAppNotFoundError } } -export const post30Errors = { app_not_found: { status: 404, data: zAppNotFoundError } } +export const post31Errors = { app_not_found: { status: 404, data: zAppNotFoundError } } export const put2Errors = { app_not_found: { status: 404, data: zAppNotFoundError } } -export const post31Errors = { app_not_found: { status: 404, data: zAppNotFoundError } } - export const post32Errors = { app_not_found: { status: 404, data: zAppNotFoundError } } +export const post33Errors = { app_not_found: { status: 404, data: zAppNotFoundError } } + export const get36Errors = { app_not_found: { status: 404, data: zAppNotFoundError } } export const get37Errors = { app_not_found: { status: 404, data: zAppNotFoundError } } @@ -764,7 +766,7 @@ export const get44Errors = { provider_quota_exceeded: { status: 400, data: zProviderQuotaExceededError }, } -export const post35Errors = { +export const post36Errors = { app_not_found: { status: 404, data: zAppNotFoundError }, app_unavailable: { status: 400, data: zAppUnavailableError }, completion_request_error: { status: 400, data: zCompletionRequestError }, @@ -774,7 +776,7 @@ export const post35Errors = { provider_quota_exceeded: { status: 400, data: zProviderQuotaExceededError }, } -export const post36Errors = { +export const post37Errors = { app_not_found: { status: 404, data: zAppNotFoundError }, invalid_tracing_config: { status: 400, data: zInvalidTracingConfigError }, trace_config_not_found: { status: 404, data: zTraceConfigNotFoundError }, @@ -813,7 +815,7 @@ export const patchErrors = { unsupported_tracing_provider: { status: 400, data: zUnsupportedTracingProviderError }, } -export const post37Errors = { +export const post38Errors = { app_not_found: { status: 404, data: zAppNotFoundError }, invalid_tracing_config: { status: 400, data: zInvalidTracingConfigError }, trace_config_already_exists: { status: 409, data: zTraceConfigAlreadyExistsError }, @@ -823,7 +825,7 @@ export const post37Errors = { unsupported_tracing_provider: { status: 400, data: zUnsupportedTracingProviderError }, } -export const post38Errors = { +export const post39Errors = { tracing_already_accepted: { status: 409, data: zTracingAlreadyAcceptedError }, tracing_duplicate_acknowledgement_required: { status: 409, @@ -840,7 +842,7 @@ export const get48Errors = { tracing_source_not_found: { status: 404, data: zTracingSourceNotFoundError }, } -export const post39Errors = { +export const post40Errors = { app_not_found: { status: 404, data: zAppNotFoundError }, app_trigger_not_found: { status: 404, data: zAppTriggerNotFoundError }, } @@ -851,7 +853,7 @@ export const get50Errors = { app_not_found: { status: 404, data: zAppNotFoundErr export const get51Errors = { app_not_found: { status: 404, data: zAppNotFoundError } } -export const post40Errors = { app_not_found: { status: 404, data: zAppNotFoundError } } +export const post41Errors = { app_not_found: { status: 404, data: zAppNotFoundError } } export const get52Errors = { app_not_found: { status: 404, data: zAppNotFoundError } } @@ -859,7 +861,7 @@ export const get53Errors = { app_not_found: { status: 404, data: zAppNotFoundErr export const get54Errors = { app_not_found: { status: 404, data: zAppNotFoundError } } -export const post41Errors = { +export const post42Errors = { agent_backend_unreachable: { status: 502, data: zAgentBackendUnreachableError }, app_not_found: { status: 404, data: zAppNotFoundError }, binding_file_download_unavailable: { status: 502, data: zBindingFileDownloadUnavailableError }, @@ -898,12 +900,12 @@ export const put3Errors = { forbidden: { status: 403, data: zForbiddenError }, } -export const post42Errors = { +export const post43Errors = { app_not_found: { status: 404, data: zAppNotFoundError }, forbidden: { status: 403, data: zForbiddenError }, } -export const post43Errors = { +export const post44Errors = { app_not_found: { status: 404, data: zAppNotFoundError }, forbidden: { status: 403, data: zForbiddenError }, } @@ -928,7 +930,7 @@ export const get60Errors = { forbidden: { status: 403, data: zForbiddenError }, } -export const post44Errors = { +export const post45Errors = { app_not_found: { status: 404, data: zAppNotFoundError }, forbidden: { status: 403, data: zForbiddenError }, } @@ -947,15 +949,13 @@ export const get66Errors = { app_not_found: { status: 404, data: zAppNotFoundErr export const get67Errors = { app_not_found: { status: 404, data: zAppNotFoundError } } -export const post45Errors = { app_not_found: { status: 404, data: zAppNotFoundError } } +export const post46Errors = { app_not_found: { status: 404, data: zAppNotFoundError } } export const get68Errors = { app_not_found: { status: 404, data: zAppNotFoundError }, draft_workflow_not_exist: { status: 404, data: zDraftWorkflowNotExistError }, } -export const post46Errors = { app_not_found: { status: 404, data: zAppNotFoundError } } - export const post47Errors = { app_not_found: { status: 404, data: zAppNotFoundError } } export const post48Errors = { app_not_found: { status: 404, data: zAppNotFoundError } } @@ -968,9 +968,13 @@ export const post51Errors = { app_not_found: { status: 404, data: zAppNotFoundEr export const post52Errors = { app_not_found: { status: 404, data: zAppNotFoundError } } +export const post53Errors = { app_not_found: { status: 404, data: zAppNotFoundError } } + +export const post54Errors = { app_not_found: { status: 404, data: zAppNotFoundError } } + export const get69Errors = { app_not_found: { status: 404, data: zAppNotFoundError } } -export const post53Errors = { +export const post55Errors = { agent_not_found_error: { status: 404, data: zAgentNotFoundError }, agent_version_conflict_error: { status: 409, data: zAgentVersionConflictError }, agent_version_not_found_error: { status: 404, data: zAgentVersionNotFoundError }, @@ -978,9 +982,9 @@ export const post53Errors = { invalid_composer_config_error: { status: 400, data: zInvalidComposerConfigError }, } -export const post54Errors = { app_not_found: { status: 404, data: zAppNotFoundError } } +export const post56Errors = { app_not_found: { status: 404, data: zAppNotFoundError } } -export const post55Errors = { +export const post57Errors = { agent_name_conflict_error: { status: 409, data: zAgentNameConflictError }, agent_not_found_error: { status: 404, data: zAgentNotFoundError }, agent_soul_locked_error: { status: 400, data: zAgentSoulLockedError }, @@ -990,7 +994,7 @@ export const post55Errors = { plaintext_secret_not_allowed_error: { status: 400, data: zPlaintextSecretNotAllowedError }, } -export const post56Errors = { +export const post58Errors = { agent_soul_locked_error: { status: 400, data: zAgentSoulLockedError }, app_not_found: { status: 404, data: zAppNotFoundError }, invalid_composer_config_error: { status: 400, data: zInvalidComposerConfigError }, @@ -1015,15 +1019,15 @@ export const put5Errors = { export const get71Errors = { app_not_found: { status: 404, data: zAppNotFoundError } } -export const post57Errors = { app_not_found: { status: 404, data: zAppNotFoundError } } +export const post59Errors = { app_not_found: { status: 404, data: zAppNotFoundError } } -export const post58Errors = { app_not_found: { status: 404, data: zAppNotFoundError } } +export const post60Errors = { app_not_found: { status: 404, data: zAppNotFoundError } } export const delete11Errors = { app_not_found: { status: 404, data: zAppNotFoundError } } export const get72Errors = { app_not_found: { status: 404, data: zAppNotFoundError } } -export const post59Errors = { +export const post61Errors = { app_not_found: { status: 404, data: zAppNotFoundError }, rate_limit_error: { status: 429, data: zRateLimitError }, } @@ -1053,12 +1057,12 @@ export const get76Errors = { export const get77Errors = { app_not_found: { status: 404, data: zAppNotFoundError } } -export const post60Errors = { +export const post62Errors = { app_not_found: { status: 404, data: zAppNotFoundError }, rate_limit_error: { status: 429, data: zRateLimitError }, } -export const post61Errors = { +export const post63Errors = { app_not_found: { status: 404, data: zAppNotFoundError }, rate_limit_error: { status: 429, data: zRateLimitError }, } @@ -1083,14 +1087,14 @@ export const get80Errors = { draft_workflow_not_exist: { status: 404, data: zDraftWorkflowNotExistError }, } -export const post62Errors = { +export const post64Errors = { app_not_found: { status: 404, data: zAppNotFoundError }, draft_workflow_not_sync: { status: 409, data: zDraftWorkflowNotSyncError }, } export const get81Errors = { app_not_found: { status: 404, data: zAppNotFoundError } } -export const post63Errors = { app_not_found: { status: 404, data: zAppNotFoundError } } +export const post65Errors = { app_not_found: { status: 404, data: zAppNotFoundError } } export const get82Errors = { app_not_found: { status: 404, data: zAppNotFoundError }, @@ -1120,7 +1124,7 @@ export const get86Errors = { webhook_trigger_not_found: { status: 404, data: zWebhookTriggerNotFoundError }, } -export const post64Errors = { app_not_found: { status: 404, data: zAppNotFoundError } } +export const post66Errors = { app_not_found: { status: 404, data: zAppNotFoundError } } export const delete14Errors = { app_not_found: { status: 404, data: zAppNotFoundError } } @@ -1128,7 +1132,7 @@ export const patch3Errors = { app_not_found: { status: 404, data: zAppNotFoundEr export const get87Errors = { app_not_found: { status: 404, data: zAppNotFoundError } } -export const post65Errors = { +export const post67Errors = { agent_not_published: { status: 409, data: zAgentNotPublishedError }, max_keys_exceeded: { status: 400, data: zMaxKeysExceededError }, } diff --git a/packages/contracts/generated/api/console/apps/orpc.gen.ts b/packages/contracts/generated/api/console/apps/orpc.gen.ts index 0e97d13e33c..a1ff3255300 100644 --- a/packages/contracts/generated/api/console/apps/orpc.gen.ts +++ b/packages/contracts/generated/api/console/apps/orpc.gen.ts @@ -343,6 +343,9 @@ import { zPostAppsByAppIdAdvancedChatWorkflowsDraftHumanInputNodesByNodeIdFormRunBody, zPostAppsByAppIdAdvancedChatWorkflowsDraftHumanInputNodesByNodeIdFormRunPath, zPostAppsByAppIdAdvancedChatWorkflowsDraftHumanInputNodesByNodeIdFormRunResponse, + zPostAppsByAppIdAdvancedChatWorkflowsDraftHumanInputNodesByNodeIdMessageTemplateTestBody, + zPostAppsByAppIdAdvancedChatWorkflowsDraftHumanInputNodesByNodeIdMessageTemplateTestPath, + zPostAppsByAppIdAdvancedChatWorkflowsDraftHumanInputNodesByNodeIdMessageTemplateTestResponse, zPostAppsByAppIdAdvancedChatWorkflowsDraftIterationNodesByNodeIdRunBody, zPostAppsByAppIdAdvancedChatWorkflowsDraftIterationNodesByNodeIdRunPath, zPostAppsByAppIdAdvancedChatWorkflowsDraftIterationNodesByNodeIdRunResponse, @@ -476,6 +479,9 @@ import { zPostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdFormRunBody, zPostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdFormRunPath, zPostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdFormRunResponse, + zPostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdMessageTemplateTestBody, + zPostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdMessageTemplateTestPath, + zPostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdMessageTemplateTestResponse, zPostAppsByAppIdWorkflowsDraftIterationNodesByNodeIdRunBody, zPostAppsByAppIdWorkflowsDraftIterationNodesByNodeIdRunPath, zPostAppsByAppIdWorkflowsDraftIterationNodesByNodeIdRunResponse, @@ -790,8 +796,38 @@ export const form = { run, } +export const post6 = oc + .route({ + inputStructure: 'detailed', + method: 'POST', + operationId: + 'postAppsByAppIdAdvancedChatWorkflowsDraftHumanInputNodesByNodeIdMessageTemplateTest', + path: '/apps/{app_id}/advanced-chat/workflows/draft/human-input/nodes/{node_id}/message-template/test', + tags: ['console'], + }) + .input( + z.object({ + body: zPostAppsByAppIdAdvancedChatWorkflowsDraftHumanInputNodesByNodeIdMessageTemplateTestBody, + params: + zPostAppsByAppIdAdvancedChatWorkflowsDraftHumanInputNodesByNodeIdMessageTemplateTestPath, + }), + ) + .output( + zPostAppsByAppIdAdvancedChatWorkflowsDraftHumanInputNodesByNodeIdMessageTemplateTestResponse, + ) + .errors({ app_not_found: { status: 404, data: zAppNotFoundError } }) + +export const test = { + post: post6, +} + +export const messageTemplate = { + test, +} + export const byNodeId = { form, + messageTemplate, } export const nodes = { @@ -807,7 +843,7 @@ export const humanInput = { * * Run draft workflow iteration node for advanced chat */ -export const post6 = oc +export const post7 = oc .route({ description: 'Run draft workflow iteration node for advanced chat', inputStructure: 'detailed', @@ -827,7 +863,7 @@ export const post6 = oc .errors({ app_not_found: { status: 404, data: zAppNotFoundError } }) export const run2 = { - post: post6, + post: post7, } export const byNodeId2 = { @@ -847,7 +883,7 @@ export const iteration = { * * Run draft workflow loop node for advanced chat */ -export const post7 = oc +export const post8 = oc .route({ description: 'Run draft workflow loop node for advanced chat', inputStructure: 'detailed', @@ -867,7 +903,7 @@ export const post7 = oc .errors({ app_not_found: { status: 404, data: zAppNotFoundError } }) export const run3 = { - post: post7, + post: post8, } export const byNodeId3 = { @@ -887,7 +923,7 @@ export const loop = { * * Run draft workflow for advanced chat application */ -export const post8 = oc +export const post9 = oc .route({ description: 'Run draft workflow for advanced chat application', inputStructure: 'detailed', @@ -910,7 +946,7 @@ export const post8 = oc }) export const run4 = { - post: post8, + post: post9, } export const draft = { @@ -929,7 +965,7 @@ export const advancedChat = { workflows: workflows2, } -export const post9 = oc +export const post10 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -953,7 +989,7 @@ export const post9 = oc }) export const batch = { - post: post9, + post: post10, } export const get6 = oc @@ -1054,7 +1090,7 @@ export const get8 = oc app_not_found: { status: 404, data: zAppNotFoundError }, }) -export const post10 = oc +export const post11 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -1079,7 +1115,7 @@ export const post10 = oc export const files = { get: get8, - post: post10, + post: post11, batch, byName, } @@ -1109,7 +1145,7 @@ export const manifest = { get: get9, } -export const post11 = oc +export const post12 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -1133,7 +1169,7 @@ export const post11 = oc }) export const upload = { - post: post11, + post: post12, } export const get10 = oc @@ -1377,7 +1413,7 @@ export const status = { /** * Enable or disable annotation reply for an app */ -export const post12 = oc +export const post13 = oc .route({ description: 'Enable or disable annotation reply for an app', inputStructure: 'detailed', @@ -1395,7 +1431,7 @@ export const post12 = oc .output(zPostAppsByAppIdAnnotationReplyByActionResponse) export const byAction = { - post: post12, + post: post13, status, } @@ -1425,7 +1461,7 @@ export const annotationSetting = { /** * Update annotation settings for an app */ -export const post13 = oc +export const post14 = oc .route({ description: 'Update annotation settings for an app', inputStructure: 'detailed', @@ -1443,7 +1479,7 @@ export const post13 = oc .output(zPostAppsByAppIdAnnotationSettingsByAnnotationSettingIdResponse) export const byAnnotationSettingId = { - post: post13, + post: post14, } export const annotationSettings = { @@ -1453,7 +1489,7 @@ export const annotationSettings = { /** * Batch import annotations from CSV file with rate limiting and security checks */ -export const post14 = oc +export const post15 = oc .route({ description: 'Batch import annotations from CSV file with rate limiting and security checks', inputStructure: 'detailed', @@ -1470,7 +1506,7 @@ export const post14 = oc }) export const batchImport = { - post: post14, + post: post15, } /** @@ -1574,7 +1610,7 @@ export const delete3 = oc /** * Update or delete an annotation */ -export const post15 = oc +export const post16 = oc .route({ description: 'Update or delete an annotation', inputStructure: 'detailed', @@ -1593,7 +1629,7 @@ export const post15 = oc export const byAnnotationId = { delete: delete3, - post: post15, + post: post16, hitHistories, } @@ -1638,7 +1674,7 @@ export const get23 = oc /** * Create a new annotation for an app */ -export const post16 = oc +export const post17 = oc .route({ description: 'Create a new annotation for an app', inputStructure: 'detailed', @@ -1656,7 +1692,7 @@ export const post16 = oc export const annotations = { delete: delete4, get: get23, - post: post16, + post: post17, batchImport, batchImportStatus, count: count2, @@ -1667,7 +1703,7 @@ export const annotations = { /** * Enable or disable app API */ -export const post17 = oc +export const post18 = oc .route({ description: 'Enable or disable app API', inputStructure: 'detailed', @@ -1680,13 +1716,13 @@ export const post17 = oc .output(zPostAppsByAppIdApiEnableResponse) export const apiEnable = { - post: post17, + post: post18, } /** * Transcript audio to text for chat messages */ -export const post18 = oc +export const post19 = oc .route({ description: 'Transcript audio to text for chat messages', inputStructure: 'detailed', @@ -1718,7 +1754,7 @@ export const post18 = oc }) export const audioToText = { - post: post18, + post: post19, } /** @@ -1830,7 +1866,7 @@ export const byMessageId = { /** * Stop a running chat message generation */ -export const post19 = oc +export const post20 = oc .route({ description: 'Stop a running chat message generation', inputStructure: 'detailed', @@ -1844,7 +1880,7 @@ export const post19 = oc .errors({ app_not_found: { status: 404, data: zAppNotFoundError } }) export const stop = { - post: post19, + post: post20, } export const byTaskId = { @@ -1951,7 +1987,7 @@ export const completionConversations = { /** * Stop a running completion message generation */ -export const post20 = oc +export const post21 = oc .route({ description: 'Stop a running completion message generation', inputStructure: 'detailed', @@ -1965,7 +2001,7 @@ export const post20 = oc .errors({ app_not_found: { status: 404, data: zAppNotFoundError } }) export const stop2 = { - post: post20, + post: post21, } export const byTaskId2 = { @@ -1975,7 +2011,7 @@ export const byTaskId2 = { /** * Generate completion message for debugging */ -export const post21 = oc +export const post22 = oc .route({ description: 'Generate completion message for debugging', inputStructure: 'detailed', @@ -2001,7 +2037,7 @@ export const post21 = oc }) export const completionMessages = { - post: post21, + post: post22, byTaskId: byTaskId2, } @@ -2037,7 +2073,7 @@ export const conversationVariables = { * Convert expert mode of chatbot app to workflow mode * Convert Completion App to Workflow App */ -export const post22 = oc +export const post23 = oc .route({ description: 'Convert application to workflow mode\nConvert expert mode of chatbot app to workflow mode\nConvert Completion App to Workflow App', @@ -2058,13 +2094,13 @@ export const post22 = oc .errors({ app_not_found: { status: 404, data: zAppNotFoundError } }) export const convertToWorkflow = { - post: post22, + post: post23, } /** * Create a copy of an existing application */ -export const post23 = oc +export const post24 = oc .route({ description: 'Create a copy of an existing application', inputStructure: 'detailed', @@ -2078,7 +2114,7 @@ export const post23 = oc .output(zPostAppsByAppIdCopyResponse) export const copy = { - post: post23, + post: post24, } /** @@ -2108,7 +2144,7 @@ export const export2 = { /** * Create or update message feedback (like/dislike) */ -export const post24 = oc +export const post25 = oc .route({ description: 'Create or update message feedback (like/dislike)', inputStructure: 'detailed', @@ -2126,13 +2162,13 @@ export const post24 = oc }) export const feedbacks = { - post: post24, + post: post25, } /** * Update application icon */ -export const post25 = oc +export const post26 = oc .route({ description: 'Update application icon', inputStructure: 'detailed', @@ -2145,7 +2181,7 @@ export const post25 = oc .output(zPostAppsByAppIdIconResponse) export const icon = { - post: post25, + post: post26, } /** @@ -2180,7 +2216,7 @@ export const messages = { * * Update application model configuration */ -export const post26 = oc +export const post27 = oc .route({ description: 'Update application model configuration', inputStructure: 'detailed', @@ -2197,13 +2233,13 @@ export const post26 = oc .errors({ app_not_found: { status: 404, data: zAppNotFoundError } }) export const modelConfig = { - post: post26, + post: post27, } /** * Check if app name is available */ -export const post27 = oc +export const post28 = oc .route({ description: 'Check if app name is available', inputStructure: 'detailed', @@ -2216,7 +2252,7 @@ export const post27 = oc .output(zPostAppsByAppIdNameResponse) export const name = { - post: post27, + post: post28, } export const get33 = oc @@ -2270,7 +2306,7 @@ export const networkAccessGroup = { status: status2, } -export const post28 = oc +export const post29 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -2282,13 +2318,13 @@ export const post28 = oc .output(zPostAppsByAppIdPublishToCreatorsPlatformResponse) export const publishToCreatorsPlatform = { - post: post28, + post: post29, } /** * Refresh MCP server configuration and regenerate server code */ -export const post29 = oc +export const post30 = oc .route({ description: 'Refresh MCP server configuration and regenerate server code', inputStructure: 'detailed', @@ -2302,7 +2338,7 @@ export const post29 = oc .errors({ app_not_found: { status: 404, data: zAppNotFoundError } }) export const refresh = { - post: post29, + post: post30, } /** @@ -2324,7 +2360,7 @@ export const get35 = oc /** * Create MCP server configuration for an application */ -export const post30 = oc +export const post31 = oc .route({ description: 'Create MCP server configuration for an application', inputStructure: 'detailed', @@ -2356,7 +2392,7 @@ export const put2 = oc export const server = { get: get35, - post: post30, + post: post31, put: put2, refresh, } @@ -2364,7 +2400,7 @@ export const server = { /** * Reset access token for application site */ -export const post31 = oc +export const post32 = oc .route({ description: 'Reset access token for application site', inputStructure: 'detailed', @@ -2378,13 +2414,13 @@ export const post31 = oc .errors({ app_not_found: { status: 404, data: zAppNotFoundError } }) export const accessTokenReset = { - post: post31, + post: post32, } /** * Update application site configuration */ -export const post32 = oc +export const post33 = oc .route({ description: 'Update application site configuration', inputStructure: 'detailed', @@ -2398,14 +2434,14 @@ export const post32 = oc .errors({ app_not_found: { status: 404, data: zAppNotFoundError } }) export const site = { - post: post32, + post: post33, accessTokenReset, } /** * Enable or disable app site */ -export const post33 = oc +export const post34 = oc .route({ description: 'Enable or disable app site', inputStructure: 'detailed', @@ -2418,7 +2454,7 @@ export const post33 = oc .output(zPostAppsByAppIdSiteEnableResponse) export const siteEnable = { - post: post33, + post: post34, } /** @@ -2439,7 +2475,7 @@ export const delete7 = oc /** * Star an application for the current account */ -export const post34 = oc +export const post35 = oc .route({ description: 'Star an application for the current account', inputStructure: 'detailed', @@ -2453,7 +2489,7 @@ export const post34 = oc export const star = { delete: delete7, - post: post34, + post: post35, } /** @@ -2701,7 +2737,7 @@ export const voices = { /** * Convert text to speech for chat messages */ -export const post35 = oc +export const post36 = oc .route({ description: 'Convert text to speech for chat messages', inputStructure: 'detailed', @@ -2728,7 +2764,7 @@ export const post35 = oc }) export const textToAudio = { - post: post35, + post: post36, voices, } @@ -2750,7 +2786,7 @@ export const get45 = oc /** * Update app tracing configuration */ -export const post36 = oc +export const post37 = oc .route({ description: 'Update app tracing configuration', inputStructure: 'detailed', @@ -2773,7 +2809,7 @@ export const post36 = oc export const trace = { get: get45, - post: post36, + post: post37, } export const get46 = oc @@ -2888,7 +2924,7 @@ export const patch = oc * * Create a new tracing configuration for an application */ -export const post37 = oc +export const post38 = oc .route({ description: 'Create a new tracing configuration for an application', inputStructure: 'detailed', @@ -2920,10 +2956,10 @@ export const traceConfig = { delete: delete8, get: get47, patch, - post: post37, + post: post38, } -export const post38 = oc +export const post39 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -2952,7 +2988,7 @@ export const post38 = oc }) export const retry = { - post: post38, + post: post39, } export const get48 = oc @@ -2991,7 +3027,7 @@ export const traceRuns = { /** * Update app trigger (enable/disable) */ -export const post39 = oc +export const post40 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -3013,7 +3049,7 @@ export const post39 = oc }) export const triggerEnable = { - post: post39, + post: post40, } /** @@ -3097,7 +3133,7 @@ export const count3 = { * * Stop running workflow task */ -export const post40 = oc +export const post41 = oc .route({ description: 'Stop running workflow task', inputStructure: 'detailed', @@ -3112,7 +3148,7 @@ export const post40 = oc .errors({ app_not_found: { status: 404, data: zAppNotFoundError } }) export const stop3 = { - post: post40, + post: post41, } export const byTaskId3 = { @@ -3202,7 +3238,7 @@ export const byRunId = { /** * Create a ToolFile from one workflow Agent Binding file and return its download URL */ -export const post41 = oc +export const post42 = oc .route({ description: 'Create a ToolFile from one workflow Agent Binding file and return its download URL', @@ -3228,7 +3264,7 @@ export const post41 = oc }) export const download4 = { - post: post41, + post: post42, } /** @@ -3427,7 +3463,7 @@ export const byReplyId = { * * Add a reply to a workflow comment */ -export const post42 = oc +export const post43 = oc .route({ description: 'Add a reply to a workflow comment', inputStructure: 'detailed', @@ -3451,7 +3487,7 @@ export const post42 = oc }) export const replies = { - post: post42, + post: post43, byReplyId, } @@ -3460,7 +3496,7 @@ export const replies = { * * Resolve a workflow comment */ -export const post43 = oc +export const post44 = oc .route({ description: 'Resolve a workflow comment', inputStructure: 'detailed', @@ -3478,7 +3514,7 @@ export const post43 = oc }) export const resolve = { - post: post43, + post: post44, } /** @@ -3588,7 +3624,7 @@ export const get60 = oc * * Create a new workflow comment */ -export const post44 = oc +export const post45 = oc .route({ description: 'Create a new workflow comment', inputStructure: 'detailed', @@ -3613,7 +3649,7 @@ export const post44 = oc export const comments = { get: get60, - post: post44, + post: post45, mentionUsers, byCommentId, } @@ -3801,7 +3837,7 @@ export const get67 = oc /** * Update conversation variables for workflow draft */ -export const post45 = oc +export const post46 = oc .route({ description: 'Update conversation variables for workflow draft', inputStructure: 'detailed', @@ -3821,7 +3857,7 @@ export const post45 = oc export const conversationVariables2 = { get: get67, - post: post45, + post: post46, } /** @@ -3849,7 +3885,7 @@ export const get68 = oc /** * Update environment variables for workflow draft */ -export const post46 = oc +export const post47 = oc .route({ description: 'Update environment variables for workflow draft', inputStructure: 'detailed', @@ -3869,13 +3905,13 @@ export const post46 = oc export const environmentVariables = { get: get68, - post: post46, + post: post47, } /** * Update draft workflow features */ -export const post47 = oc +export const post48 = oc .route({ description: 'Update draft workflow features', inputStructure: 'detailed', @@ -3894,7 +3930,7 @@ export const post47 = oc .errors({ app_not_found: { status: 404, data: zAppNotFoundError } }) export const features = { - post: post47, + post: post48, } /** @@ -3902,7 +3938,7 @@ export const features = { * * Test human input delivery for workflow */ -export const post48 = oc +export const post49 = oc .route({ description: 'Test human input delivery for workflow', inputStructure: 'detailed', @@ -3922,7 +3958,7 @@ export const post48 = oc .errors({ app_not_found: { status: 404, data: zAppNotFoundError } }) export const deliveryTest = { - post: post48, + post: post49, } /** @@ -3930,7 +3966,7 @@ export const deliveryTest = { * * Get human input form preview for workflow */ -export const post49 = oc +export const post50 = oc .route({ description: 'Get human input form preview for workflow', inputStructure: 'detailed', @@ -3950,7 +3986,7 @@ export const post49 = oc .errors({ app_not_found: { status: 404, data: zAppNotFoundError } }) export const preview4 = { - post: post49, + post: post50, } /** @@ -3958,7 +3994,7 @@ export const preview4 = { * * Submit human input form preview for workflow */ -export const post50 = oc +export const post51 = oc .route({ description: 'Submit human input form preview for workflow', inputStructure: 'detailed', @@ -3978,7 +4014,7 @@ export const post50 = oc .errors({ app_not_found: { status: 404, data: zAppNotFoundError } }) export const run5 = { - post: post50, + post: post51, } export const form2 = { @@ -3986,9 +4022,35 @@ export const form2 = { run: run5, } +export const post52 = oc + .route({ + inputStructure: 'detailed', + method: 'POST', + operationId: 'postAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdMessageTemplateTest', + path: '/apps/{app_id}/workflows/draft/human-input/nodes/{node_id}/message-template/test', + tags: ['console'], + }) + .input( + z.object({ + body: zPostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdMessageTemplateTestBody, + params: zPostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdMessageTemplateTestPath, + }), + ) + .output(zPostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdMessageTemplateTestResponse) + .errors({ app_not_found: { status: 404, data: zAppNotFoundError } }) + +export const test2 = { + post: post52, +} + +export const messageTemplate2 = { + test: test2, +} + export const byNodeId5 = { deliveryTest, form: form2, + messageTemplate: messageTemplate2, } export const nodes4 = { @@ -4004,7 +4066,7 @@ export const humanInput2 = { * * Run draft workflow iteration node */ -export const post51 = oc +export const post53 = oc .route({ description: 'Run draft workflow iteration node', inputStructure: 'detailed', @@ -4024,7 +4086,7 @@ export const post51 = oc .errors({ app_not_found: { status: 404, data: zAppNotFoundError } }) export const run6 = { - post: post51, + post: post53, } export const byNodeId6 = { @@ -4044,7 +4106,7 @@ export const iteration2 = { * * Run draft workflow loop node */ -export const post52 = oc +export const post54 = oc .route({ description: 'Run draft workflow loop node', inputStructure: 'detailed', @@ -4064,7 +4126,7 @@ export const post52 = oc .errors({ app_not_found: { status: 404, data: zAppNotFoundError } }) export const run7 = { - post: post52, + post: post54, } export const byNodeId7 = { @@ -4097,7 +4159,7 @@ export const candidates = { get: get69, } -export const post53 = oc +export const post55 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -4121,10 +4183,10 @@ export const post53 = oc }) export const copyFromRoster = { - post: post53, + post: post55, } -export const post54 = oc +export const post56 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -4142,10 +4204,10 @@ export const post54 = oc .errors({ app_not_found: { status: 404, data: zAppNotFoundError } }) export const impact = { - post: post54, + post: post56, } -export const post55 = oc +export const post57 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -4171,10 +4233,10 @@ export const post55 = oc }) export const saveToRoster = { - post: post55, + post: post57, } -export const post56 = oc +export const post58 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -4197,7 +4259,7 @@ export const post56 = oc }) export const validate = { - post: post56, + post: post58, } export const get70 = oc @@ -4281,7 +4343,7 @@ export const lastRun = { * * Run draft workflow node */ -export const post57 = oc +export const post59 = oc .route({ description: 'Run draft workflow node', inputStructure: 'detailed', @@ -4301,7 +4363,7 @@ export const post57 = oc .errors({ app_not_found: { status: 404, data: zAppNotFoundError } }) export const run8 = { - post: post57, + post: post59, } /** @@ -4309,7 +4371,7 @@ export const run8 = { * * Poll for trigger events and execute single node when event arrives */ -export const post58 = oc +export const post60 = oc .route({ description: 'Poll for trigger events and execute single node when event arrives', inputStructure: 'detailed', @@ -4324,7 +4386,7 @@ export const post58 = oc .errors({ app_not_found: { status: 404, data: zAppNotFoundError } }) export const run9 = { - post: post58, + post: post60, } export const trigger = { @@ -4386,7 +4448,7 @@ export const nodes7 = { * * Run draft workflow */ -export const post59 = oc +export const post61 = oc .route({ description: 'Run draft workflow', inputStructure: 'detailed', @@ -4409,7 +4471,7 @@ export const post59 = oc }) export const run10 = { - post: post59, + post: post61, } /** @@ -4551,7 +4613,7 @@ export const systemVariables = { * * Poll for trigger events and execute full workflow when event arrives */ -export const post60 = oc +export const post62 = oc .route({ description: 'Poll for trigger events and execute full workflow when event arrives', inputStructure: 'detailed', @@ -4574,7 +4636,7 @@ export const post60 = oc }) export const run11 = { - post: post60, + post: post62, } /** @@ -4582,7 +4644,7 @@ export const run11 = { * * Full workflow debug when the start node is a trigger */ -export const post61 = oc +export const post63 = oc .route({ description: 'Full workflow debug when the start node is a trigger', inputStructure: 'detailed', @@ -4605,7 +4667,7 @@ export const post61 = oc }) export const runAll = { - post: post61, + post: post63, } export const trigger2 = { @@ -4771,7 +4833,7 @@ export const get80 = oc * * Sync draft workflow configuration */ -export const post62 = oc +export const post64 = oc .route({ description: 'Sync draft workflow configuration', inputStructure: 'detailed', @@ -4795,7 +4857,7 @@ export const post62 = oc export const draft2 = { get: get80, - post: post62, + post: post64, conversationVariables: conversationVariables2, environmentVariables, features, @@ -4832,7 +4894,7 @@ export const get81 = oc /** * Publish workflow */ -export const post63 = oc +export const post65 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -4852,7 +4914,7 @@ export const post63 = oc export const publish = { get: get81, - post: post63, + post: post65, } /** @@ -5012,7 +5074,7 @@ export const triggers2 = { /** * Restore a published workflow version into the draft workflow */ -export const post64 = oc +export const post66 = oc .route({ description: 'Restore a published workflow version into the draft workflow', inputStructure: 'detailed', @@ -5026,7 +5088,7 @@ export const post64 = oc .errors({ app_not_found: { status: 404, data: zAppNotFoundError } }) export const restore = { - post: post64, + post: post66, } /** @@ -5239,7 +5301,7 @@ export const get89 = oc /** * Create a new API key for an app */ -export const post65 = oc +export const post67 = oc .route({ description: 'Create a new API key for an app', inputStructure: 'detailed', @@ -5258,7 +5320,7 @@ export const post65 = oc export const apiKeys = { get: get89, - post: post65, + post: post67, byApiKeyId, } @@ -5284,7 +5346,7 @@ export const get90 = oc /** * Create a new application */ -export const post66 = oc +export const post68 = oc .route({ description: 'Create a new application', inputStructure: 'detailed', @@ -5299,7 +5361,7 @@ export const post66 = oc export const apps = { get: get90, - post: post66, + post: post68, imports, recent, starred, diff --git a/packages/contracts/generated/api/console/apps/types.gen.ts b/packages/contracts/generated/api/console/apps/types.gen.ts index b92527ac984..9b7dd626171 100644 --- a/packages/contracts/generated/api/console/apps/types.gen.ts +++ b/packages/contracts/generated/api/console/apps/types.gen.ts @@ -200,6 +200,17 @@ export type HumanInputFormSubmitResponse = { [key: string]: unknown } +export type MessageTemplateTestRequest = { + channel: DebugChannel + inputs?: { + [key: string]: JsonValue + } +} + +export type MessageTemplateTestResponse = { + [key: string]: unknown +} + export type IterationNodeRunPayload = { inputs?: { [key: string]: unknown @@ -1802,6 +1813,17 @@ export type FormInputConfig = type: 'file-list' } & FileListInputConfig) +export type DebugChannel = + | 'ding_talk' + | 'email' + | 'feishu' + | 'lark' + | 'ms_teams' + | 'slack' + | 'we_com' + +export type JsonValue = unknown + export type ShallowJsonValue = | string | number @@ -3107,6 +3129,7 @@ export type HumanInputFormDefinition = { form_content: string form_id: string form_token?: string | null + form_version?: '1' | '2' inputs?: Array node_id: string node_title: string @@ -3770,8 +3793,6 @@ export type AppWeightsResponse = { [key: string]: unknown } -export type JsonValue = unknown - export type AppImageUploadResponse = { detail?: 'high' | 'low' | null enabled?: boolean @@ -4551,6 +4572,10 @@ export type RecentAppListResponseWritable = { data: Array } +export type MessageTemplateTestResponseWritable = { + [key: string]: unknown +} + export type GeneratedAppResponseWritable = ShallowJsonValue export type WorkflowCommentBasicListWritable = { @@ -5119,6 +5144,34 @@ export type PostAppsByAppIdAdvancedChatWorkflowsDraftHumanInputNodesByNodeIdForm export type PostAppsByAppIdAdvancedChatWorkflowsDraftHumanInputNodesByNodeIdFormRunResponse = PostAppsByAppIdAdvancedChatWorkflowsDraftHumanInputNodesByNodeIdFormRunResponses[keyof PostAppsByAppIdAdvancedChatWorkflowsDraftHumanInputNodesByNodeIdFormRunResponses] +export type PostAppsByAppIdAdvancedChatWorkflowsDraftHumanInputNodesByNodeIdMessageTemplateTestData = + { + body: MessageTemplateTestRequest + path: { + app_id: string + node_id: string + } + query?: never + url: '/apps/{app_id}/advanced-chat/workflows/draft/human-input/nodes/{node_id}/message-template/test' + } + +export type PostAppsByAppIdAdvancedChatWorkflowsDraftHumanInputNodesByNodeIdMessageTemplateTestErrors = + { + 404: AppNotFoundError | ErrorResponse + default: ErrorResponse + } + +export type PostAppsByAppIdAdvancedChatWorkflowsDraftHumanInputNodesByNodeIdMessageTemplateTestError = + PostAppsByAppIdAdvancedChatWorkflowsDraftHumanInputNodesByNodeIdMessageTemplateTestErrors[keyof PostAppsByAppIdAdvancedChatWorkflowsDraftHumanInputNodesByNodeIdMessageTemplateTestErrors] + +export type PostAppsByAppIdAdvancedChatWorkflowsDraftHumanInputNodesByNodeIdMessageTemplateTestResponses = + { + 200: MessageTemplateTestResponse + } + +export type PostAppsByAppIdAdvancedChatWorkflowsDraftHumanInputNodesByNodeIdMessageTemplateTestResponse = + PostAppsByAppIdAdvancedChatWorkflowsDraftHumanInputNodesByNodeIdMessageTemplateTestResponses[keyof PostAppsByAppIdAdvancedChatWorkflowsDraftHumanInputNodesByNodeIdMessageTemplateTestResponses] + export type PostAppsByAppIdAdvancedChatWorkflowsDraftIterationNodesByNodeIdRunData = { body: IterationNodeRunPayload path: { @@ -8548,6 +8601,31 @@ export type PostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdFormRunResponses export type PostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdFormRunResponse = PostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdFormRunResponses[keyof PostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdFormRunResponses] +export type PostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdMessageTemplateTestData = { + body: MessageTemplateTestRequest + path: { + app_id: string + node_id: string + } + query?: never + url: '/apps/{app_id}/workflows/draft/human-input/nodes/{node_id}/message-template/test' +} + +export type PostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdMessageTemplateTestErrors = { + 404: AppNotFoundError | ErrorResponse + default: ErrorResponse +} + +export type PostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdMessageTemplateTestError = + PostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdMessageTemplateTestErrors[keyof PostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdMessageTemplateTestErrors] + +export type PostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdMessageTemplateTestResponses = { + 200: MessageTemplateTestResponse +} + +export type PostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdMessageTemplateTestResponse = + PostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdMessageTemplateTestResponses[keyof PostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdMessageTemplateTestResponses] + export type PostAppsByAppIdWorkflowsDraftIterationNodesByNodeIdRunData = { body: IterationNodeRunPayload path: { diff --git a/packages/contracts/generated/api/console/apps/zod.gen.ts b/packages/contracts/generated/api/console/apps/zod.gen.ts index 718d41b78a8..5f7e5186894 100644 --- a/packages/contracts/generated/api/console/apps/zod.gen.ts +++ b/packages/contracts/generated/api/console/apps/zod.gen.ts @@ -160,6 +160,13 @@ export const zHumanInputFormPreviewSubmitPayload = z.object({ */ export const zHumanInputFormSubmitResponse = z.record(z.string(), z.unknown()) +/** + * MessageTemplateTestResponse + * + * Response body returned after one message-template test send. + */ +export const zMessageTemplateTestResponse = z.record(z.string(), z.unknown()) + /** * IterationNodeRunPayload */ @@ -1680,6 +1687,33 @@ export const zRecentAppListResponse = z.object({ data: z.array(zRecentAppResponse), }) +/** + * DebugChannel + * + * Delivery selection for editor debugging, resolved to current channel configuration. + */ +export const zDebugChannel = z.enum([ + 'ding_talk', + 'email', + 'feishu', + 'lark', + 'ms_teams', + 'slack', + 'we_com', +]) + +export const zJsonValue = z.unknown() + +/** + * MessageTemplateTestRequest + * + * Request body for sending one message-template test notification. + */ +export const zMessageTemplateTestRequest = z.object({ + channel: zDebugChannel, + inputs: z.record(z.string(), zJsonValue).optional(), +}) + export const zShallowJsonValue = z .union([ z.string(), @@ -3006,6 +3040,32 @@ export const zAppAnnotationReplyDisabledResponse = z.object({ enabled: z.literal(false), }) +/** + * AppEnabledExternalDataToolResponse + */ +export const zAppEnabledExternalDataToolResponse = z.object({ + config: z.record(z.string(), zJsonValue), + enabled: z.literal(true), + icon: z.string().optional(), + icon_background: z.string().optional(), + label: z.string().optional(), + type: z.string(), + variable: z.string(), +}) + +/** + * AppDisabledExternalDataToolResponse + */ +export const zAppDisabledExternalDataToolResponse = z.object({ + config: z.record(z.string(), zJsonValue).optional(), + enabled: z.literal(false), + icon: z.string().optional(), + icon_background: z.string().optional(), + label: z.string().optional(), + type: z.string().optional(), + variable: z.string().optional(), +}) + /** * AppEnabledConfigResponse */ @@ -3020,6 +3080,16 @@ export const zAppEnabledConfigResponse = z.object({ */ export const zPromptType = z.enum(['advanced', 'simple']) +/** + * AppSensitiveWordAvoidanceResponse + */ +export const zAppSensitiveWordAvoidanceResponse = z.object({ + config: z.record(z.string(), zJsonValue).optional(), + configs: z.array(zJsonValue).optional(), + enabled: z.boolean(), + type: z.string().optional(), +}) + /** * AppTextToSpeechResponse */ @@ -3331,6 +3401,18 @@ export const zFeedback = z.object({ rating: z.string(), }) +/** + * HumanInputFormSubmissionData + */ +export const zHumanInputFormSubmissionData = z.object({ + action_id: z.string(), + action_text: z.string(), + node_id: z.string(), + node_title: z.string(), + rendered_content: z.string(), + submitted_data: z.record(z.string(), zJsonValue).nullish(), +}) + /** * ExecutionContentType */ @@ -3887,7 +3969,7 @@ export const zAgentComposerBindingResponse = z.object({ * * Per-item shape for an ``array``-typed declared output. * - * PRD §OUTPUT 配置框 keeps arrays one level deep on first version; nested arrays + * The PRD output configuration section limits arrays to one level; nested arrays * are rejected so the runtime type checker and JSON Schema stay easy to reason * about. Stage 4 §4.2. */ @@ -4039,6 +4121,34 @@ export const zAppAgentPromptResponse = z.object({ next_iteration: z.string().optional(), }) +/** + * AppLegacyGoogleSearchToolResponse + */ +export const zAppLegacyGoogleSearchToolResponse = z.object({ + google_search: z.record(z.string(), zJsonValue), +}) + +/** + * AppLegacyWebReaderToolResponse + */ +export const zAppLegacyWebReaderToolResponse = z.object({ + web_reader: z.record(z.string(), zJsonValue), +}) + +/** + * AppLegacyWikipediaToolResponse + */ +export const zAppLegacyWikipediaToolResponse = z.object({ + wikipedia: z.record(z.string(), zJsonValue), +}) + +/** + * AppLegacyCurrentDatetimeToolResponse + */ +export const zAppLegacyCurrentDatetimeToolResponse = z.object({ + current_datetime: z.record(z.string(), zJsonValue), +}) + /** * AppEmbeddingModelResponse */ @@ -4103,84 +4213,6 @@ export const zAppRerankingModelResponse = z.object({ reranking_provider_name: z.string().optional(), }) -export const zJsonValue = z.unknown() - -/** - * AppEnabledExternalDataToolResponse - */ -export const zAppEnabledExternalDataToolResponse = z.object({ - config: z.record(z.string(), zJsonValue), - enabled: z.literal(true), - icon: z.string().optional(), - icon_background: z.string().optional(), - label: z.string().optional(), - type: z.string(), - variable: z.string(), -}) - -/** - * AppDisabledExternalDataToolResponse - */ -export const zAppDisabledExternalDataToolResponse = z.object({ - config: z.record(z.string(), zJsonValue).optional(), - enabled: z.literal(false), - icon: z.string().optional(), - icon_background: z.string().optional(), - label: z.string().optional(), - type: z.string().optional(), - variable: z.string().optional(), -}) - -/** - * AppSensitiveWordAvoidanceResponse - */ -export const zAppSensitiveWordAvoidanceResponse = z.object({ - config: z.record(z.string(), zJsonValue).optional(), - configs: z.array(zJsonValue).optional(), - enabled: z.boolean(), - type: z.string().optional(), -}) - -/** - * HumanInputFormSubmissionData - */ -export const zHumanInputFormSubmissionData = z.object({ - action_id: z.string(), - action_text: z.string(), - node_id: z.string(), - node_title: z.string(), - rendered_content: z.string(), - submitted_data: z.record(z.string(), zJsonValue).nullish(), -}) - -/** - * AppLegacyGoogleSearchToolResponse - */ -export const zAppLegacyGoogleSearchToolResponse = z.object({ - google_search: z.record(z.string(), zJsonValue), -}) - -/** - * AppLegacyWebReaderToolResponse - */ -export const zAppLegacyWebReaderToolResponse = z.object({ - web_reader: z.record(z.string(), zJsonValue), -}) - -/** - * AppLegacyWikipediaToolResponse - */ -export const zAppLegacyWikipediaToolResponse = z.object({ - wikipedia: z.record(z.string(), zJsonValue), -}) - -/** - * AppLegacyCurrentDatetimeToolResponse - */ -export const zAppLegacyCurrentDatetimeToolResponse = z.object({ - current_datetime: z.record(z.string(), zJsonValue), -}) - /** * AppImageUploadResponse */ @@ -5470,6 +5502,7 @@ export const zHumanInputFormDefinition = z.object({ form_content: z.string(), form_id: z.string(), form_token: z.string().nullish(), + form_version: z.enum(['1', '2']).optional().default('1'), inputs: z.array(zFormInputConfig).optional(), node_id: z.string(), node_title: z.string(), @@ -5760,7 +5793,7 @@ export const zAgentSoulModelConfig = z.object({ * * File-output content check via a model-based comparison against a benchmark file. * - * Per PRD §OUTPUT 配置框, output check is **file-only** and optional. Stage 4 §4.3. + * Per the PRD output configuration section, output checks are **file-only** and optional. Stage 4 §4.3. */ export const zDeclaredOutputCheckConfig = z.object({ benchmark_file_ref: zAgentFileRefConfig.nullish(), @@ -6164,6 +6197,13 @@ export const zComposerSavePayload = z.object({ version_note: z.string().nullish(), }) +/** + * MessageTemplateTestResponse + * + * Response body returned after one message-template test send. + */ +export const zMessageTemplateTestResponseWritable = z.record(z.string(), z.unknown()) + /** * GeneratedAppResponse */ @@ -6632,6 +6672,21 @@ export const zPostAppsByAppIdAdvancedChatWorkflowsDraftHumanInputNodesByNodeIdFo export const zPostAppsByAppIdAdvancedChatWorkflowsDraftHumanInputNodesByNodeIdFormRunResponse = zHumanInputFormSubmitResponse +export const zPostAppsByAppIdAdvancedChatWorkflowsDraftHumanInputNodesByNodeIdMessageTemplateTestBody = + zMessageTemplateTestRequest + +export const zPostAppsByAppIdAdvancedChatWorkflowsDraftHumanInputNodesByNodeIdMessageTemplateTestPath = + z.object({ + app_id: z.uuid(), + node_id: z.string(), + }) + +/** + * Success + */ +export const zPostAppsByAppIdAdvancedChatWorkflowsDraftHumanInputNodesByNodeIdMessageTemplateTestResponse = + zMessageTemplateTestResponse + export const zPostAppsByAppIdAdvancedChatWorkflowsDraftIterationNodesByNodeIdRunBody = zIterationNodeRunPayload @@ -8236,6 +8291,21 @@ export const zPostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdFormRunPath = export const zPostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdFormRunResponse = zHumanInputFormSubmitResponse +export const zPostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdMessageTemplateTestBody = + zMessageTemplateTestRequest + +export const zPostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdMessageTemplateTestPath = + z.object({ + app_id: z.uuid(), + node_id: z.string(), + }) + +/** + * Success + */ +export const zPostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdMessageTemplateTestResponse = + zMessageTemplateTestResponse + export const zPostAppsByAppIdWorkflowsDraftIterationNodesByNodeIdRunBody = zIterationNodeRunPayload export const zPostAppsByAppIdWorkflowsDraftIterationNodesByNodeIdRunPath = z.object({ diff --git a/packages/contracts/generated/api/console/errors.gen.ts b/packages/contracts/generated/api/console/errors.gen.ts index b53babfefc3..93c5935f12a 100644 --- a/packages/contracts/generated/api/console/errors.gen.ts +++ b/packages/contracts/generated/api/console/errors.gen.ts @@ -8,7 +8,7 @@ export { delete12Errors as 'DELETE /knowledge-fs/spaces/{control_space_id}' } fr export { delete13Errors as 'DELETE /apps/{app_id}/workflows/draft/variables' } from './apps/errors.gen.ts' export { delete13Errors as 'DELETE /knowledge-fs/uploads/{upload_id}' } from './knowledge-fs/errors.gen.ts' export { delete14Errors as 'DELETE /apps/{app_id}/workflows/{workflow_id}' } from './apps/errors.gen.ts' -export { delete16Errors as 'DELETE /workspaces/current/skills/{skill_id}' } from './workspaces/errors.gen.ts' +export { delete20Errors as 'DELETE /workspaces/current/skills/{skill_id}' } from './workspaces/errors.gen.ts' export { delete2Errors as 'DELETE /agent/{agent_id}/build-draft' } from './agent/errors.gen.ts' export { delete2Errors as 'DELETE /apps/{app_id}/agent/config/skills/{name}' } from './apps/errors.gen.ts' export { delete2Errors as 'DELETE /datasets/external-knowledge-api/{external_knowledge_api_id}' } from './datasets/errors.gen.ts' @@ -23,7 +23,6 @@ export { delete4Errors as 'DELETE /agent/{agent_id}/config/skills/{name}' } from export { delete4Errors as 'DELETE /apps/{app_id}/annotations' } from './apps/errors.gen.ts' export { delete4Errors as 'DELETE /knowledge-fs/spaces/{control_space_id}/documents/{document_id}' } from './knowledge-fs/errors.gen.ts' export { delete4Errors as 'DELETE /rag/pipelines/{pipeline_id}/workflows/draft/variables' } from './rag/errors.gen.ts' -export { delete4Errors as 'DELETE /workspaces/current/members/{member_id}' } from './workspaces/errors.gen.ts' export { delete5Errors as 'DELETE /agent/{agent_id}/trace-config' } from './agent/errors.gen.ts' export { delete5Errors as 'DELETE /apps/{app_id}/chat-conversations/{conversation_id}' } from './apps/errors.gen.ts' export { delete5Errors as 'DELETE /knowledge-fs/spaces/{control_space_id}/golden-questions/{question_id}' } from './knowledge-fs/errors.gen.ts' @@ -34,11 +33,13 @@ export { delete6Errors as 'DELETE /knowledge-fs/spaces/{control_space_id}/jobs/{ export { delete7Errors as 'DELETE /knowledge-fs/spaces/{control_space_id}/logical-documents/bulk' } from './knowledge-fs/errors.gen.ts' export { delete8Errors as 'DELETE /apps/{app_id}/trace-config' } from './apps/errors.gen.ts' export { delete8Errors as 'DELETE /knowledge-fs/spaces/{control_space_id}/logical-documents/{document_id}' } from './knowledge-fs/errors.gen.ts' +export { delete8Errors as 'DELETE /workspaces/current/members/{member_id}' } from './workspaces/errors.gen.ts' export { delete9Errors as 'DELETE /apps/{app_id}/workflow/comments/{comment_id}/replies/{reply_id}' } from './apps/errors.gen.ts' export { delete9Errors as 'DELETE /knowledge-fs/spaces/{control_space_id}/metadata/{field_id}' } from './knowledge-fs/errors.gen.ts' export { delete_Errors as 'DELETE /apps/{app_id}/agent/config/files/{name}' } from './apps/errors.gen.ts' export { delete_Errors as 'DELETE /installed-apps/{installed_app_id}/conversations/{c_id}' } from './installed-apps/errors.gen.ts' export { delete_Errors as 'DELETE /knowledge-fs/source-provider-preview/jobs/{job_id}' } from './knowledge-fs/errors.gen.ts' +export { get101Errors as 'GET /workspaces/current/summary' } from './workspaces/errors.gen.ts' export { get10Errors as 'GET /agent/{agent_id}/config/files/{name}/preview' } from './agent/errors.gen.ts' export { get10Errors as 'GET /apps/{app_id}/agent/config/skills/{name}/download' } from './apps/errors.gen.ts' export { get10Errors as 'GET /knowledge-fs/spaces/{control_space_id}/documents/{document_id}/multimodal' } from './knowledge-fs/errors.gen.ts' @@ -117,45 +118,38 @@ export { get26Errors as 'GET /knowledge-fs/spaces/{control_space_id}/overview/he export { get27Errors as 'GET /agent/{agent_id}/statistics/summary' } from './agent/errors.gen.ts' export { get27Errors as 'GET /apps/{app_id}/chat-messages' } from './apps/errors.gen.ts' export { get27Errors as 'GET /knowledge-fs/spaces/{control_space_id}/overview/inventory' } from './knowledge-fs/errors.gen.ts' -export { get27Errors as 'GET /workspaces/current/plugin/asset' } from './workspaces/errors.gen.ts' export { get28Errors as 'GET /agent/{agent_id}/text-to-audio/voices' } from './agent/errors.gen.ts' export { get28Errors as 'GET /apps/{app_id}/completion-conversations/{conversation_id}' } from './apps/errors.gen.ts' export { get28Errors as 'GET /knowledge-fs/spaces/{control_space_id}/overview/query-outcomes' } from './knowledge-fs/errors.gen.ts' export { get29Errors as 'GET /apps/{app_id}/completion-conversations' } from './apps/errors.gen.ts' export { get29Errors as 'GET /knowledge-fs/spaces/{control_space_id}/overview/stats' } from './knowledge-fs/errors.gen.ts' -export { get29Errors as 'GET /workspaces/current/plugin/debugging-key' } from './workspaces/errors.gen.ts' export { get2Errors as 'GET /account/delete/verify' } from './account/errors.gen.ts' export { get2Errors as 'GET /agent/{agent_id}/api-access' } from './agent/errors.gen.ts' export { get2Errors as 'GET /billing/subscription' } from './billing/errors.gen.ts' export { get2Errors as 'GET /explore/apps/{app_id}' } from './explore/errors.gen.ts' +export { get2Errors as 'GET /form/human_input/{form_token}' } from './form/errors.gen.ts' export { get2Errors as 'GET /installed-apps/{installed_app_id}/messages/{message_id}/more-like-this' } from './installed-apps/errors.gen.ts' export { get2Errors as 'GET /notion/pre-import/pages' } from './notion/errors.gen.ts' export { get2Errors as 'GET /trial-apps/{app_id}/agent/config/files/{name}/download' } from './trial-apps/errors.gen.ts' export { get30Errors as 'GET /agent/{agent_id}/trace-capabilities' } from './agent/errors.gen.ts' export { get30Errors as 'GET /apps/{app_id}/conversation-variables' } from './apps/errors.gen.ts' export { get30Errors as 'GET /knowledge-fs/spaces/{control_space_id}/permissions' } from './knowledge-fs/errors.gen.ts' -export { get30Errors as 'GET /workspaces/current/plugin/fetch-manifest' } from './workspaces/errors.gen.ts' export { get31Errors as 'GET /agent/{agent_id}/trace-config' } from './agent/errors.gen.ts' export { get31Errors as 'GET /apps/{app_id}/export' } from './apps/errors.gen.ts' export { get31Errors as 'GET /datasets/{dataset_id}/notion/sync' } from './datasets/errors.gen.ts' export { get31Errors as 'GET /knowledge-fs/spaces/{control_space_id}/quality/bad-cases/{bad_case_id}/trace-reference' } from './knowledge-fs/errors.gen.ts' -export { get31Errors as 'GET /workspaces/current/plugin/icon' } from './workspaces/errors.gen.ts' export { get32Errors as 'GET /agent/{agent_id}/trace-runs/{message_id}' } from './agent/errors.gen.ts' export { get32Errors as 'GET /apps/{app_id}/messages/{message_id}' } from './apps/errors.gen.ts' export { get32Errors as 'GET /knowledge-fs/spaces/{control_space_id}/quality/bad-cases/{bad_case_id}' } from './knowledge-fs/errors.gen.ts' -export { get32Errors as 'GET /workspaces/current/plugin/installed-ids' } from './workspaces/errors.gen.ts' export { get33Errors as 'GET /agent/{agent_id}/versions/{version_id}' } from './agent/errors.gen.ts' export { get33Errors as 'GET /apps/{app_id}/network-access-group/status' } from './apps/errors.gen.ts' export { get33Errors as 'GET /knowledge-fs/spaces/{control_space_id}/quality/bad-cases' } from './knowledge-fs/errors.gen.ts' -export { get33Errors as 'GET /workspaces/current/plugin/list' } from './workspaces/errors.gen.ts' export { get34Errors as 'GET /agent/{agent_id}/versions' } from './agent/errors.gen.ts' export { get34Errors as 'GET /apps/{app_id}/network-access-group' } from './apps/errors.gen.ts' export { get34Errors as 'GET /knowledge-fs/spaces/{control_space_id}/quality/replay-runs/{run_id}' } from './knowledge-fs/errors.gen.ts' -export { get34Errors as 'GET /workspaces/current/plugin/marketplace/pkg' } from './workspaces/errors.gen.ts' export { get35Errors as 'GET /agent/{agent_id}' } from './agent/errors.gen.ts' export { get35Errors as 'GET /apps/{app_id}/server' } from './apps/errors.gen.ts' export { get35Errors as 'GET /knowledge-fs/spaces/{control_space_id}/quality/replay-runs' } from './knowledge-fs/errors.gen.ts' -export { get35Errors as 'GET /workspaces/current/plugin/parameters/dynamic-options' } from './workspaces/errors.gen.ts' export { get36Errors as 'GET /agent' } from './agent/errors.gen.ts' export { get36Errors as 'GET /apps/{app_id}/statistics/average-response-time' } from './apps/errors.gen.ts' export { get36Errors as 'GET /knowledge-fs/spaces/{control_space_id}/research-tasks/{task_id}/partials' } from './knowledge-fs/errors.gen.ts' @@ -163,31 +157,36 @@ export { get37Errors as 'GET /apps/{app_id}/statistics/average-session-interacti export { get37Errors as 'GET /knowledge-fs/spaces/{control_space_id}/research-tasks/{task_id}' } from './knowledge-fs/errors.gen.ts' export { get38Errors as 'GET /apps/{app_id}/statistics/daily-conversations' } from './apps/errors.gen.ts' export { get38Errors as 'GET /knowledge-fs/spaces/{control_space_id}/research-tasks' } from './knowledge-fs/errors.gen.ts' -export { get38Errors as 'GET /workspaces/current/plugin/tasks/{task_id}' } from './workspaces/errors.gen.ts' export { get39Errors as 'GET /apps/{app_id}/statistics/daily-end-users' } from './apps/errors.gen.ts' export { get39Errors as 'GET /knowledge-fs/spaces/{control_space_id}/settings/migrations/{migration_id}' } from './knowledge-fs/errors.gen.ts' -export { get39Errors as 'GET /workspaces/current/plugin/tasks' } from './workspaces/errors.gen.ts' export { get3Errors as 'GET /installed-apps/{installed_app_id}/messages/{message_id}/suggested-questions' } from './installed-apps/errors.gen.ts' export { get3Errors as 'GET /knowledge-fs/research-tasks/{task_id}/events' } from './knowledge-fs/errors.gen.ts' export { get3Errors as 'GET /trial-apps/{app_id}/agent/config/files/{name}/preview' } from './trial-apps/errors.gen.ts' export { get40Errors as 'GET /apps/{app_id}/statistics/daily-messages' } from './apps/errors.gen.ts' export { get40Errors as 'GET /knowledge-fs/spaces/{control_space_id}/settings' } from './knowledge-fs/errors.gen.ts' -export { get40Errors as 'GET /workspaces/current/plugin/{category}/list' } from './workspaces/errors.gen.ts' +export { get40Errors as 'GET /workspaces/current/plugin/asset' } from './workspaces/errors.gen.ts' export { get41Errors as 'GET /apps/{app_id}/statistics/token-costs' } from './apps/errors.gen.ts' export { get41Errors as 'GET /knowledge-fs/spaces/{control_space_id}/source-connections' } from './knowledge-fs/errors.gen.ts' export { get42Errors as 'GET /apps/{app_id}/statistics/tokens-per-second' } from './apps/errors.gen.ts' export { get42Errors as 'GET /knowledge-fs/spaces/{control_space_id}/source-providers' } from './knowledge-fs/errors.gen.ts' +export { get42Errors as 'GET /workspaces/current/plugin/debugging-key' } from './workspaces/errors.gen.ts' export { get43Errors as 'GET /apps/{app_id}/statistics/user-satisfaction-rate' } from './apps/errors.gen.ts' export { get43Errors as 'GET /knowledge-fs/spaces/{control_space_id}/source-workflows/{run_id}/pages' } from './knowledge-fs/errors.gen.ts' +export { get43Errors as 'GET /workspaces/current/plugin/fetch-manifest' } from './workspaces/errors.gen.ts' export { get44Errors as 'GET /apps/{app_id}/text-to-audio/voices' } from './apps/errors.gen.ts' export { get44Errors as 'GET /knowledge-fs/spaces/{control_space_id}/source-workflows/{run_id}' } from './knowledge-fs/errors.gen.ts' +export { get44Errors as 'GET /workspaces/current/plugin/icon' } from './workspaces/errors.gen.ts' export { get45Errors as 'GET /knowledge-fs/spaces/{control_space_id}/sources/{source_id}/files' } from './knowledge-fs/errors.gen.ts' +export { get45Errors as 'GET /workspaces/current/plugin/installed-ids' } from './workspaces/errors.gen.ts' export { get46Errors as 'GET /apps/{app_id}/trace-capabilities' } from './apps/errors.gen.ts' export { get46Errors as 'GET /knowledge-fs/spaces/{control_space_id}/sources/{source_id}/pages' } from './knowledge-fs/errors.gen.ts' +export { get46Errors as 'GET /workspaces/current/plugin/list' } from './workspaces/errors.gen.ts' export { get47Errors as 'GET /apps/{app_id}/trace-config' } from './apps/errors.gen.ts' export { get47Errors as 'GET /knowledge-fs/spaces/{control_space_id}/sources/{source_id}/sync-policy' } from './knowledge-fs/errors.gen.ts' +export { get47Errors as 'GET /workspaces/current/plugin/marketplace/pkg' } from './workspaces/errors.gen.ts' export { get48Errors as 'GET /apps/{app_id}/trace-runs/{source_kind}/{source_id}' } from './apps/errors.gen.ts' export { get48Errors as 'GET /knowledge-fs/spaces/{control_space_id}/sources/{source_id}' } from './knowledge-fs/errors.gen.ts' +export { get48Errors as 'GET /workspaces/current/plugin/parameters/dynamic-options' } from './workspaces/errors.gen.ts' export { get49Errors as 'GET /apps/{app_id}/triggers' } from './apps/errors.gen.ts' export { get49Errors as 'GET /knowledge-fs/spaces/{control_space_id}/sources' } from './knowledge-fs/errors.gen.ts' export { get4Errors as 'GET /account/education/verify' } from './account/errors.gen.ts' @@ -201,10 +200,13 @@ export { get50Errors as 'GET /apps/{app_id}/workflow-app-logs' } from './apps/er export { get50Errors as 'GET /knowledge-fs/spaces/{control_space_id}/tags' } from './knowledge-fs/errors.gen.ts' export { get51Errors as 'GET /apps/{app_id}/workflow-runs/count' } from './apps/errors.gen.ts' export { get51Errors as 'GET /knowledge-fs/spaces/{control_space_id}/traces/{trace_id}/conflicts' } from './knowledge-fs/errors.gen.ts' +export { get51Errors as 'GET /workspaces/current/plugin/tasks/{task_id}' } from './workspaces/errors.gen.ts' export { get52Errors as 'GET /apps/{app_id}/workflow-runs/{run_id}/node-executions/{node_execution_id}/children' } from './apps/errors.gen.ts' export { get52Errors as 'GET /knowledge-fs/spaces/{control_space_id}/traces/{trace_id}/evidence' } from './knowledge-fs/errors.gen.ts' +export { get52Errors as 'GET /workspaces/current/plugin/tasks' } from './workspaces/errors.gen.ts' export { get53Errors as 'GET /apps/{app_id}/workflow-runs/{run_id}/node-executions' } from './apps/errors.gen.ts' export { get53Errors as 'GET /knowledge-fs/spaces/{control_space_id}/traces/{trace_id}/missing' } from './knowledge-fs/errors.gen.ts' +export { get53Errors as 'GET /workspaces/current/plugin/{category}/list' } from './workspaces/errors.gen.ts' export { get54Errors as 'GET /apps/{app_id}/workflow-runs/{run_id}' } from './apps/errors.gen.ts' export { get54Errors as 'GET /knowledge-fs/spaces/{control_space_id}/traces/{trace_id}' } from './knowledge-fs/errors.gen.ts' export { get55Errors as 'GET /apps/{app_id}/workflow-runs/{workflow_run_id}/agent-nodes/{node_id}/sandbox/files/read' } from './apps/errors.gen.ts' @@ -257,21 +259,20 @@ export { get7Errors as 'GET /snippets/{snippet_id}/workflows/draft/environment-v export { get7Errors as 'GET /trial-apps/{app_id}/agent/config/skills/{name}/files/download' } from './trial-apps/errors.gen.ts' export { get80Errors as 'GET /apps/{app_id}/workflows/draft' } from './apps/errors.gen.ts' export { get81Errors as 'GET /apps/{app_id}/workflows/publish' } from './apps/errors.gen.ts' -export { get81Errors as 'GET /workspaces/current/skills/{skill_id}/files/content' } from './workspaces/errors.gen.ts' export { get82Errors as 'GET /apps/{app_id}/workflows/published/runs/{run_id}/node-outputs/events' } from './apps/errors.gen.ts' -export { get82Errors as 'GET /workspaces/current/skills/{skill_id}/files/preview' } from './workspaces/errors.gen.ts' export { get83Errors as 'GET /apps/{app_id}/workflows/published/runs/{run_id}/node-outputs/{node_id}/{output_name}/preview' } from './apps/errors.gen.ts' export { get84Errors as 'GET /apps/{app_id}/workflows/published/runs/{run_id}/node-outputs/{node_id}' } from './apps/errors.gen.ts' export { get85Errors as 'GET /apps/{app_id}/workflows/published/runs/{run_id}/node-outputs' } from './apps/errors.gen.ts' export { get86Errors as 'GET /apps/{app_id}/workflows/triggers/webhook' } from './apps/errors.gen.ts' export { get87Errors as 'GET /apps/{app_id}/workflows' } from './apps/errors.gen.ts' -export { get88Errors as 'GET /workspaces/current/summary' } from './workspaces/errors.gen.ts' export { get8Errors as 'GET /agent/{agent_id}/composer' } from './agent/errors.gen.ts' export { get8Errors as 'GET /apps/{app_id}/agent/config/files' } from './apps/errors.gen.ts' export { get8Errors as 'GET /installed-apps/{installed_app_id}' } from './installed-apps/errors.gen.ts' export { get8Errors as 'GET /knowledge-fs/spaces/{control_space_id}/document-references/resolve' } from './knowledge-fs/errors.gen.ts' export { get8Errors as 'GET /rag/pipelines/{pipeline_id}/workflow-runs/{run_id}' } from './rag/errors.gen.ts' export { get8Errors as 'GET /trial-apps/{app_id}/agent/config/skills/{name}/files/preview' } from './trial-apps/errors.gen.ts' +export { get94Errors as 'GET /workspaces/current/skills/{skill_id}/files/content' } from './workspaces/errors.gen.ts' +export { get95Errors as 'GET /workspaces/current/skills/{skill_id}/files/preview' } from './workspaces/errors.gen.ts' export { get9Errors as 'GET /agent/{agent_id}/config/files/{name}/download' } from './agent/errors.gen.ts' export { get9Errors as 'GET /apps/{app_id}/agent/config/manifest' } from './apps/errors.gen.ts' export { get9Errors as 'GET /installed-apps' } from './installed-apps/errors.gen.ts' @@ -282,7 +283,7 @@ export { get9Errors as 'GET /trial-apps/{app_id}/agent/config/skills/{name}/insp export { getErrors as 'GET /apps/imports/{app_id}/check-dependencies' } from './apps/errors.gen.ts' export { getErrors as 'GET /billing/invoices' } from './billing/errors.gen.ts' export { getErrors as 'GET /compliance/download' } from './compliance/errors.gen.ts' -export { getErrors as 'GET /form/human_input/{form_token}' } from './form/errors.gen.ts' +export { getErrors as 'GET /form/human-input/v2/{form_token}' } from './form/errors.gen.ts' export { getErrors as 'GET /installed-apps/{installed_app_id}/conversations' } from './installed-apps/errors.gen.ts' export { getErrors as 'GET /remote-files/{url}' } from './remote-files/errors.gen.ts' export { getErrors as 'GET /trial-apps/{app_id}/agent-composer' } from './trial-apps/errors.gen.ts' @@ -301,15 +302,15 @@ export { patch4Errors as 'PATCH /datasets/{dataset_id}/documents/{document_id}/p export { patch4Errors as 'PATCH /knowledge-fs/spaces/{control_space_id}/logical-documents' } from './knowledge-fs/errors.gen.ts' export { patch5Errors as 'PATCH /datasets/{dataset_id}/documents/{document_id}/processing/{action}' } from './datasets/errors.gen.ts' export { patch5Errors as 'PATCH /knowledge-fs/spaces/{control_space_id}/metadata/{field_id}' } from './knowledge-fs/errors.gen.ts' -export { patch5Errors as 'PATCH /workspaces/current/skills/{skill_id}/files' } from './workspaces/errors.gen.ts' export { patch6Errors as 'PATCH /datasets/{dataset_id}/documents/{document_id}/segment/{action}' } from './datasets/errors.gen.ts' export { patch6Errors as 'PATCH /knowledge-fs/spaces/{control_space_id}/quality/bad-cases/{bad_case_id}' } from './knowledge-fs/errors.gen.ts' -export { patch6Errors as 'PATCH /workspaces/current/skills/{skill_id}/versions/{version_id}' } from './workspaces/errors.gen.ts' +export { patch6Errors as 'PATCH /workspaces/current/skills/{skill_id}/files' } from './workspaces/errors.gen.ts' export { patch7Errors as 'PATCH /datasets/{dataset_id}/documents/{document_id}/segments/{segment_id}/child_chunks/{child_chunk_id}' } from './datasets/errors.gen.ts' export { patch7Errors as 'PATCH /knowledge-fs/spaces/{control_space_id}/settings' } from './knowledge-fs/errors.gen.ts' -export { patch7Errors as 'PATCH /workspaces/current/skills/{skill_id}' } from './workspaces/errors.gen.ts' +export { patch7Errors as 'PATCH /workspaces/current/skills/{skill_id}/versions/{version_id}' } from './workspaces/errors.gen.ts' export { patch8Errors as 'PATCH /datasets/{dataset_id}/documents/{document_id}/segments/{segment_id}/child_chunks' } from './datasets/errors.gen.ts' export { patch8Errors as 'PATCH /knowledge-fs/spaces/{control_space_id}/sources/{source_id}' } from './knowledge-fs/errors.gen.ts' +export { patch8Errors as 'PATCH /workspaces/current/skills/{skill_id}' } from './workspaces/errors.gen.ts' export { patch9Errors as 'PATCH /datasets/{dataset_id}/documents/{document_id}/segments/{segment_id}' } from './datasets/errors.gen.ts' export { patch9Errors as 'PATCH /knowledge-fs/spaces/{control_space_id}' } from './knowledge-fs/errors.gen.ts' export { patchErrors as 'PATCH /account/profile' } from './account/errors.gen.ts' @@ -320,36 +321,33 @@ export { patchErrors as 'PATCH /datasets/external-knowledge-api/{external_knowle export { patchErrors as 'PATCH /installed-apps/{installed_app_id}/conversations/{c_id}/pin' } from './installed-apps/errors.gen.ts' export { patchErrors as 'PATCH /knowledge-fs/spaces/{control_space_id}/documents/{document_id}' } from './knowledge-fs/errors.gen.ts' export { post10Errors as 'POST /agent/{agent_id}/config/files' } from './agent/errors.gen.ts' -export { post10Errors as 'POST /apps/{app_id}/agent/config/files' } from './apps/errors.gen.ts' +export { post10Errors as 'POST /apps/{app_id}/agent/config/files/batch' } from './apps/errors.gen.ts' export { post10Errors as 'POST /installed-apps/{installed_app_id}/workflows/run' } from './installed-apps/errors.gen.ts' export { post10Errors as 'POST /knowledge-fs/spaces/{control_space_id}/golden-questions' } from './knowledge-fs/errors.gen.ts' export { post10Errors as 'POST /rag/pipelines/{pipeline_id}/workflows/draft/datasource/variables-inspect' } from './rag/errors.gen.ts' export { post10Errors as 'POST /snippets/{snippet_id}/workflows/draft' } from './snippets/errors.gen.ts' export { post11Errors as 'POST /agent/{agent_id}/config/skills/upload' } from './agent/errors.gen.ts' -export { post11Errors as 'POST /apps/{app_id}/agent/config/skills/upload' } from './apps/errors.gen.ts' +export { post11Errors as 'POST /apps/{app_id}/agent/config/files' } from './apps/errors.gen.ts' export { post11Errors as 'POST /installed-apps/{installed_app_id}/workflows/tasks/{task_id}/stop' } from './installed-apps/errors.gen.ts' export { post11Errors as 'POST /knowledge-fs/spaces/{control_space_id}/jobs/{job_id}/retry' } from './knowledge-fs/errors.gen.ts' export { post11Errors as 'POST /rag/pipelines/{pipeline_id}/workflows/draft/iteration/nodes/{node_id}/run' } from './rag/errors.gen.ts' export { post12Errors as 'POST /agent/{agent_id}/copy' } from './agent/errors.gen.ts' +export { post12Errors as 'POST /apps/{app_id}/agent/config/skills/upload' } from './apps/errors.gen.ts' export { post12Errors as 'POST /datasets/{dataset_id}/documents/{document_id}/segment' } from './datasets/errors.gen.ts' export { post12Errors as 'POST /knowledge-fs/spaces/{control_space_id}/logical-documents/download-zip' } from './knowledge-fs/errors.gen.ts' export { post12Errors as 'POST /rag/pipelines/{pipeline_id}/workflows/draft/loop/nodes/{node_id}/run' } from './rag/errors.gen.ts' -export { post12Errors as 'POST /workspaces/current/members/invite-email' } from './workspaces/errors.gen.ts' export { post13Errors as 'POST /agent/{agent_id}/debug-conversation/refresh' } from './agent/errors.gen.ts' export { post13Errors as 'POST /knowledge-fs/spaces/{control_space_id}/metadata' } from './knowledge-fs/errors.gen.ts' export { post13Errors as 'POST /rag/pipelines/{pipeline_id}/workflows/draft/nodes/{node_id}/run' } from './rag/errors.gen.ts' -export { post13Errors as 'POST /workspaces/current/members/owner-transfer-check' } from './workspaces/errors.gen.ts' export { post14Errors as 'POST /agent/{agent_id}/features' } from './agent/errors.gen.ts' -export { post14Errors as 'POST /apps/{app_id}/annotations/batch-import' } from './apps/errors.gen.ts' export { post14Errors as 'POST /datasets/{dataset_id}/documents/{document_id}/segments/{segment_id}/child_chunks' } from './datasets/errors.gen.ts' export { post14Errors as 'POST /knowledge-fs/spaces/{control_space_id}/quality/bad-cases' } from './knowledge-fs/errors.gen.ts' export { post14Errors as 'POST /rag/pipelines/{pipeline_id}/workflows/draft/run' } from './rag/errors.gen.ts' -export { post14Errors as 'POST /workspaces/current/members/send-owner-transfer-confirm-email' } from './workspaces/errors.gen.ts' export { post15Errors as 'POST /agent/{agent_id}/feedbacks' } from './agent/errors.gen.ts' +export { post15Errors as 'POST /apps/{app_id}/annotations/batch-import' } from './apps/errors.gen.ts' export { post15Errors as 'POST /datasets/{dataset_id}/documents' } from './datasets/errors.gen.ts' export { post15Errors as 'POST /knowledge-fs/spaces/{control_space_id}/quality/replay-runs' } from './knowledge-fs/errors.gen.ts' export { post15Errors as 'POST /rag/pipelines/{pipeline_id}/workflows/draft' } from './rag/errors.gen.ts' -export { post15Errors as 'POST /workspaces/current/members/{member_id}/owner-transfer' } from './workspaces/errors.gen.ts' export { post16Errors as 'POST /agent/{agent_id}/publish' } from './agent/errors.gen.ts' export { post16Errors as 'POST /datasets/{dataset_id}/external-hit-testing' } from './datasets/errors.gen.ts' export { post16Errors as 'POST /knowledge-fs/spaces/{control_space_id}/queries/admission' } from './knowledge-fs/errors.gen.ts' @@ -359,37 +357,39 @@ export { post17Errors as 'POST /datasets/{dataset_id}/hit-testing' } from './dat export { post17Errors as 'POST /knowledge-fs/spaces/{control_space_id}/queries' } from './knowledge-fs/errors.gen.ts' export { post17Errors as 'POST /rag/pipelines/{pipeline_id}/workflows/published/datasource/nodes/{node_id}/preview' } from './rag/errors.gen.ts' export { post18Errors as 'POST /agent/{agent_id}/text-to-audio' } from './agent/errors.gen.ts' -export { post18Errors as 'POST /apps/{app_id}/audio-to-text' } from './apps/errors.gen.ts' export { post18Errors as 'POST /knowledge-fs/spaces/{control_space_id}/query-stream-capability' } from './knowledge-fs/errors.gen.ts' export { post18Errors as 'POST /rag/pipelines/{pipeline_id}/workflows/published/datasource/nodes/{node_id}/run' } from './rag/errors.gen.ts' export { post19Errors as 'POST /agent/{agent_id}/trace' } from './agent/errors.gen.ts' -export { post19Errors as 'POST /apps/{app_id}/chat-messages/{task_id}/stop' } from './apps/errors.gen.ts' +export { post19Errors as 'POST /apps/{app_id}/audio-to-text' } from './apps/errors.gen.ts' export { post19Errors as 'POST /knowledge-fs/spaces/{control_space_id}/research-tasks/plan' } from './knowledge-fs/errors.gen.ts' export { post19Errors as 'POST /rag/pipelines/{pipeline_id}/workflows/published/run' } from './rag/errors.gen.ts' export { post20Errors as 'POST /agent/{agent_id}/trace-config' } from './agent/errors.gen.ts' -export { post20Errors as 'POST /apps/{app_id}/completion-messages/{task_id}/stop' } from './apps/errors.gen.ts' +export { post20Errors as 'POST /apps/{app_id}/chat-messages/{task_id}/stop' } from './apps/errors.gen.ts' export { post20Errors as 'POST /knowledge-fs/spaces/{control_space_id}/research-tasks' } from './knowledge-fs/errors.gen.ts' export { post20Errors as 'POST /rag/pipelines/{pipeline_id}/workflows/{workflow_id}/restore' } from './rag/errors.gen.ts' export { post21Errors as 'POST /agent/{agent_id}/trace-runs/{message_id}/retry' } from './agent/errors.gen.ts' -export { post21Errors as 'POST /apps/{app_id}/completion-messages' } from './apps/errors.gen.ts' +export { post21Errors as 'POST /apps/{app_id}/completion-messages/{task_id}/stop' } from './apps/errors.gen.ts' export { post21Errors as 'POST /knowledge-fs/spaces/{control_space_id}/source-connections/{connection_id}/refresh' } from './knowledge-fs/errors.gen.ts' export { post22Errors as 'POST /agent/{agent_id}/versions/{version_id}/restore' } from './agent/errors.gen.ts' -export { post22Errors as 'POST /apps/{app_id}/convert-to-workflow' } from './apps/errors.gen.ts' +export { post22Errors as 'POST /apps/{app_id}/completion-messages' } from './apps/errors.gen.ts' export { post22Errors as 'POST /knowledge-fs/spaces/{control_space_id}/source-connections' } from './knowledge-fs/errors.gen.ts' export { post23Errors as 'POST /agent' } from './agent/errors.gen.ts' +export { post23Errors as 'POST /apps/{app_id}/convert-to-workflow' } from './apps/errors.gen.ts' export { post23Errors as 'POST /datasets/{resource_id}/api-keys' } from './datasets/errors.gen.ts' export { post23Errors as 'POST /knowledge-fs/spaces/{control_space_id}/source-workflows/{run_id}/cancel' } from './knowledge-fs/errors.gen.ts' -export { post24Errors as 'POST /apps/{app_id}/feedbacks' } from './apps/errors.gen.ts' export { post24Errors as 'POST /datasets' } from './datasets/errors.gen.ts' export { post24Errors as 'POST /knowledge-fs/spaces/{control_space_id}/source-workflows/{run_id}/retry' } from './knowledge-fs/errors.gen.ts' +export { post24Errors as 'POST /workspaces/current/members/invite-email' } from './workspaces/errors.gen.ts' +export { post25Errors as 'POST /apps/{app_id}/feedbacks' } from './apps/errors.gen.ts' export { post25Errors as 'POST /knowledge-fs/spaces/{control_space_id}/source-workflows/{run_id}/selection' } from './knowledge-fs/errors.gen.ts' -export { post26Errors as 'POST /apps/{app_id}/model-config' } from './apps/errors.gen.ts' +export { post25Errors as 'POST /workspaces/current/members/owner-transfer-check' } from './workspaces/errors.gen.ts' export { post26Errors as 'POST /knowledge-fs/spaces/{control_space_id}/sources/{source_id}/async-import' } from './knowledge-fs/errors.gen.ts' +export { post26Errors as 'POST /workspaces/current/members/send-owner-transfer-confirm-email' } from './workspaces/errors.gen.ts' +export { post27Errors as 'POST /apps/{app_id}/model-config' } from './apps/errors.gen.ts' export { post27Errors as 'POST /knowledge-fs/spaces/{control_space_id}/sources/{source_id}/crawl-import' } from './knowledge-fs/errors.gen.ts' +export { post27Errors as 'POST /workspaces/current/members/{member_id}/owner-transfer' } from './workspaces/errors.gen.ts' export { post28Errors as 'POST /knowledge-fs/spaces/{control_space_id}/sources/{source_id}/crawl-preview' } from './knowledge-fs/errors.gen.ts' -export { post29Errors as 'POST /apps/{app_id}/server/refresh' } from './apps/errors.gen.ts' export { post29Errors as 'POST /knowledge-fs/spaces/{control_space_id}/sources/{source_id}/import' } from './knowledge-fs/errors.gen.ts' -export { post29Errors as 'POST /workspaces/current/plugin/install/github' } from './workspaces/errors.gen.ts' export { post2Errors as 'POST /account/change-email/reset' } from './account/errors.gen.ts' export { post2Errors as 'POST /agent/{agent_id}/api-keys' } from './agent/errors.gen.ts' export { post2Errors as 'POST /apps/imports' } from './apps/errors.gen.ts' @@ -397,38 +397,30 @@ export { post2Errors as 'POST /datasets/external' } from './datasets/errors.gen. export { post2Errors as 'POST /email-code-login' } from './email-code-login/errors.gen.ts' export { post2Errors as 'POST /email-register/validity' } from './email-register/errors.gen.ts' export { post2Errors as 'POST /forgot-password/validity' } from './forgot-password/errors.gen.ts' +export { post2Errors as 'POST /form/human_input/{form_token}' } from './form/errors.gen.ts' export { post2Errors as 'POST /installed-apps/{installed_app_id}/chat-messages/{task_id}/stop' } from './installed-apps/errors.gen.ts' export { post2Errors as 'POST /instruction-generate' } from './instruction-generate/errors.gen.ts' export { post2Errors as 'POST /knowledge-fs/source-provider-preview/jobs' } from './knowledge-fs/errors.gen.ts' export { post2Errors as 'POST /rag/pipeline/dataset' } from './rag/errors.gen.ts' export { post2Errors as 'POST /trial-apps/{app_id}/chat-messages/{task_id}/stop' } from './trial-apps/errors.gen.ts' -export { post30Errors as 'POST /apps/{app_id}/server' } from './apps/errors.gen.ts' +export { post30Errors as 'POST /apps/{app_id}/server/refresh' } from './apps/errors.gen.ts' export { post30Errors as 'POST /knowledge-fs/spaces/{control_space_id}/sources/{source_id}/import-files' } from './knowledge-fs/errors.gen.ts' -export { post30Errors as 'POST /workspaces/current/plugin/install/marketplace' } from './workspaces/errors.gen.ts' -export { post31Errors as 'POST /apps/{app_id}/site/access-token-reset' } from './apps/errors.gen.ts' +export { post31Errors as 'POST /apps/{app_id}/server' } from './apps/errors.gen.ts' export { post31Errors as 'POST /knowledge-fs/spaces/{control_space_id}/sources/{source_id}/sync' } from './knowledge-fs/errors.gen.ts' -export { post31Errors as 'POST /workspaces/current/plugin/install/pkg' } from './workspaces/errors.gen.ts' -export { post32Errors as 'POST /apps/{app_id}/site' } from './apps/errors.gen.ts' +export { post32Errors as 'POST /apps/{app_id}/site/access-token-reset' } from './apps/errors.gen.ts' export { post32Errors as 'POST /knowledge-fs/spaces/{control_space_id}/sources/{source_id}/test' } from './knowledge-fs/errors.gen.ts' -export { post32Errors as 'POST /workspaces/current/plugin/list/installations/ids' } from './workspaces/errors.gen.ts' +export { post33Errors as 'POST /apps/{app_id}/site' } from './apps/errors.gen.ts' export { post33Errors as 'POST /knowledge-fs/spaces/{control_space_id}/sources/{source_id}/workflow-imports' } from './knowledge-fs/errors.gen.ts' -export { post33Errors as 'POST /workspaces/current/plugin/list/latest-versions' } from './workspaces/errors.gen.ts' export { post34Errors as 'POST /knowledge-fs/spaces/{control_space_id}/sources' } from './knowledge-fs/errors.gen.ts' -export { post34Errors as 'POST /workspaces/current/plugin/parameters/dynamic-options-with-credentials' } from './workspaces/errors.gen.ts' -export { post35Errors as 'POST /apps/{app_id}/text-to-audio' } from './apps/errors.gen.ts' export { post35Errors as 'POST /knowledge-fs/spaces/{control_space_id}/upload-sessions/{upload_session_id}/abort' } from './knowledge-fs/errors.gen.ts' -export { post36Errors as 'POST /apps/{app_id}/trace' } from './apps/errors.gen.ts' +export { post36Errors as 'POST /apps/{app_id}/text-to-audio' } from './apps/errors.gen.ts' export { post36Errors as 'POST /knowledge-fs/spaces/{control_space_id}/upload-sessions/{upload_session_id}/complete' } from './knowledge-fs/errors.gen.ts' -export { post36Errors as 'POST /workspaces/current/plugin/tasks/delete_all' } from './workspaces/errors.gen.ts' -export { post37Errors as 'POST /apps/{app_id}/trace-config' } from './apps/errors.gen.ts' +export { post37Errors as 'POST /apps/{app_id}/trace' } from './apps/errors.gen.ts' export { post37Errors as 'POST /knowledge-fs/spaces/{control_space_id}/upload-sessions/{upload_session_id}/parts/{part_number}/presign' } from './knowledge-fs/errors.gen.ts' -export { post37Errors as 'POST /workspaces/current/plugin/tasks/{task_id}/delete/{identifier}' } from './workspaces/errors.gen.ts' -export { post38Errors as 'POST /apps/{app_id}/trace-runs/{source_kind}/{source_id}/retry' } from './apps/errors.gen.ts' +export { post38Errors as 'POST /apps/{app_id}/trace-config' } from './apps/errors.gen.ts' export { post38Errors as 'POST /knowledge-fs/spaces/{control_space_id}/upload-sessions/{upload_session_id}/small-file' } from './knowledge-fs/errors.gen.ts' -export { post38Errors as 'POST /workspaces/current/plugin/tasks/{task_id}/delete' } from './workspaces/errors.gen.ts' -export { post39Errors as 'POST /apps/{app_id}/trigger-enable' } from './apps/errors.gen.ts' +export { post39Errors as 'POST /apps/{app_id}/trace-runs/{source_kind}/{source_id}/retry' } from './apps/errors.gen.ts' export { post39Errors as 'POST /knowledge-fs/spaces/{control_space_id}/upload-sessions' } from './knowledge-fs/errors.gen.ts' -export { post39Errors as 'POST /workspaces/current/plugin/uninstall' } from './workspaces/errors.gen.ts' export { post3Errors as 'POST /account/change-email/validity' } from './account/errors.gen.ts' export { post3Errors as 'POST /agent/{agent_id}/audio-to-text' } from './agent/errors.gen.ts' export { post3Errors as 'POST /datasets/external-knowledge-api' } from './datasets/errors.gen.ts' @@ -438,24 +430,27 @@ export { post3Errors as 'POST /installed-apps/{installed_app_id}/chat-messages' export { post3Errors as 'POST /knowledge-fs/source-provider-preview' } from './knowledge-fs/errors.gen.ts' export { post3Errors as 'POST /trial-apps/{app_id}/chat-messages' } from './trial-apps/errors.gen.ts' export { post3Errors as 'POST /workflow-generate' } from './workflow-generate/errors.gen.ts' -export { post40Errors as 'POST /apps/{app_id}/workflow-runs/tasks/{task_id}/stop' } from './apps/errors.gen.ts' +export { post40Errors as 'POST /apps/{app_id}/trigger-enable' } from './apps/errors.gen.ts' export { post40Errors as 'POST /knowledge-fs/spaces' } from './knowledge-fs/errors.gen.ts' -export { post40Errors as 'POST /workspaces/current/plugin/upgrade/github' } from './workspaces/errors.gen.ts' -export { post41Errors as 'POST /apps/{app_id}/workflow-runs/{workflow_run_id}/agent-nodes/{node_id}/sandbox/files/download' } from './apps/errors.gen.ts' +export { post41Errors as 'POST /apps/{app_id}/workflow-runs/tasks/{task_id}/stop' } from './apps/errors.gen.ts' export { post41Errors as 'POST /knowledge-fs/tasks/{task_id}/stream-capability' } from './knowledge-fs/errors.gen.ts' -export { post41Errors as 'POST /workspaces/current/plugin/upgrade/marketplace' } from './workspaces/errors.gen.ts' -export { post42Errors as 'POST /apps/{app_id}/workflow/comments/{comment_id}/replies' } from './apps/errors.gen.ts' +export { post41Errors as 'POST /workspaces/current/plugin/install/github' } from './workspaces/errors.gen.ts' +export { post42Errors as 'POST /apps/{app_id}/workflow-runs/{workflow_run_id}/agent-nodes/{node_id}/sandbox/files/download' } from './apps/errors.gen.ts' export { post42Errors as 'POST /knowledge-fs/uploads' } from './knowledge-fs/errors.gen.ts' -export { post43Errors as 'POST /apps/{app_id}/workflow/comments/{comment_id}/resolve' } from './apps/errors.gen.ts' -export { post43Errors as 'POST /workspaces/current/plugin/upload/github' } from './workspaces/errors.gen.ts' -export { post44Errors as 'POST /apps/{app_id}/workflow/comments' } from './apps/errors.gen.ts' -export { post44Errors as 'POST /workspaces/current/plugin/upload/pkg' } from './workspaces/errors.gen.ts' -export { post45Errors as 'POST /apps/{app_id}/workflows/draft/conversation-variables' } from './apps/errors.gen.ts' -export { post46Errors as 'POST /apps/{app_id}/workflows/draft/environment-variables' } from './apps/errors.gen.ts' -export { post47Errors as 'POST /apps/{app_id}/workflows/draft/features' } from './apps/errors.gen.ts' -export { post48Errors as 'POST /apps/{app_id}/workflows/draft/human-input/nodes/{node_id}/delivery-test' } from './apps/errors.gen.ts' -export { post49Errors as 'POST /apps/{app_id}/workflows/draft/human-input/nodes/{node_id}/form/preview' } from './apps/errors.gen.ts' -export { post49Errors as 'POST /workspaces/current/skills/files/upload' } from './workspaces/errors.gen.ts' +export { post42Errors as 'POST /workspaces/current/plugin/install/marketplace' } from './workspaces/errors.gen.ts' +export { post43Errors as 'POST /apps/{app_id}/workflow/comments/{comment_id}/replies' } from './apps/errors.gen.ts' +export { post43Errors as 'POST /workspaces/current/plugin/install/pkg' } from './workspaces/errors.gen.ts' +export { post44Errors as 'POST /apps/{app_id}/workflow/comments/{comment_id}/resolve' } from './apps/errors.gen.ts' +export { post44Errors as 'POST /workspaces/current/plugin/list/installations/ids' } from './workspaces/errors.gen.ts' +export { post45Errors as 'POST /apps/{app_id}/workflow/comments' } from './apps/errors.gen.ts' +export { post45Errors as 'POST /workspaces/current/plugin/list/latest-versions' } from './workspaces/errors.gen.ts' +export { post46Errors as 'POST /apps/{app_id}/workflows/draft/conversation-variables' } from './apps/errors.gen.ts' +export { post46Errors as 'POST /workspaces/current/plugin/parameters/dynamic-options-with-credentials' } from './workspaces/errors.gen.ts' +export { post47Errors as 'POST /apps/{app_id}/workflows/draft/environment-variables' } from './apps/errors.gen.ts' +export { post48Errors as 'POST /apps/{app_id}/workflows/draft/features' } from './apps/errors.gen.ts' +export { post48Errors as 'POST /workspaces/current/plugin/tasks/delete_all' } from './workspaces/errors.gen.ts' +export { post49Errors as 'POST /apps/{app_id}/workflows/draft/human-input/nodes/{node_id}/delivery-test' } from './apps/errors.gen.ts' +export { post49Errors as 'POST /workspaces/current/plugin/tasks/{task_id}/delete/{identifier}' } from './workspaces/errors.gen.ts' export { post4Errors as 'POST /account/change-email' } from './account/errors.gen.ts' export { post4Errors as 'POST /agent/{agent_id}/build-chat/finalize' } from './agent/errors.gen.ts' export { post4Errors as 'POST /apps/{app_id}/advanced-chat/workflows/draft/human-input/nodes/{node_id}/form/preview' } from './apps/errors.gen.ts' @@ -464,37 +459,46 @@ export { post4Errors as 'POST /installed-apps/{installed_app_id}/completion-mess export { post4Errors as 'POST /knowledge-fs/spaces/{control_space_id}/background-tasks/{task_kind}/{task_id}/cancel' } from './knowledge-fs/errors.gen.ts' export { post4Errors as 'POST /snippets/{snippet_id}/workflows/draft/nodes/{node_id}/agent-composer/copy-from-roster' } from './snippets/errors.gen.ts' export { post4Errors as 'POST /trial-apps/{app_id}/completion-messages' } from './trial-apps/errors.gen.ts' -export { post50Errors as 'POST /apps/{app_id}/workflows/draft/human-input/nodes/{node_id}/form/run' } from './apps/errors.gen.ts' -export { post50Errors as 'POST /workspaces/current/skills/import' } from './workspaces/errors.gen.ts' -export { post51Errors as 'POST /apps/{app_id}/workflows/draft/iteration/nodes/{node_id}/run' } from './apps/errors.gen.ts' -export { post51Errors as 'POST /workspaces/current/skills/{skill_id}/assist/messages' } from './workspaces/errors.gen.ts' -export { post52Errors as 'POST /apps/{app_id}/workflows/draft/loop/nodes/{node_id}/run' } from './apps/errors.gen.ts' -export { post53Errors as 'POST /apps/{app_id}/workflows/draft/nodes/{node_id}/agent-composer/copy-from-roster' } from './apps/errors.gen.ts' -export { post53Errors as 'POST /workspaces/current/skills/{skill_id}/files/check' } from './workspaces/errors.gen.ts' -export { post54Errors as 'POST /apps/{app_id}/workflows/draft/nodes/{node_id}/agent-composer/impact' } from './apps/errors.gen.ts' -export { post54Errors as 'POST /workspaces/current/skills/{skill_id}/publish' } from './workspaces/errors.gen.ts' -export { post55Errors as 'POST /apps/{app_id}/workflows/draft/nodes/{node_id}/agent-composer/save-to-roster' } from './apps/errors.gen.ts' -export { post55Errors as 'POST /workspaces/current/skills/{skill_id}/restore' } from './workspaces/errors.gen.ts' -export { post56Errors as 'POST /apps/{app_id}/workflows/draft/nodes/{node_id}/agent-composer/validate' } from './apps/errors.gen.ts' -export { post56Errors as 'POST /workspaces/current/skills' } from './workspaces/errors.gen.ts' -export { post57Errors as 'POST /apps/{app_id}/workflows/draft/nodes/{node_id}/run' } from './apps/errors.gen.ts' -export { post58Errors as 'POST /apps/{app_id}/workflows/draft/nodes/{node_id}/trigger/run' } from './apps/errors.gen.ts' -export { post59Errors as 'POST /apps/{app_id}/workflows/draft/run' } from './apps/errors.gen.ts' +export { post50Errors as 'POST /apps/{app_id}/workflows/draft/human-input/nodes/{node_id}/form/preview' } from './apps/errors.gen.ts' +export { post50Errors as 'POST /workspaces/current/plugin/tasks/{task_id}/delete' } from './workspaces/errors.gen.ts' +export { post51Errors as 'POST /apps/{app_id}/workflows/draft/human-input/nodes/{node_id}/form/run' } from './apps/errors.gen.ts' +export { post51Errors as 'POST /workspaces/current/plugin/uninstall' } from './workspaces/errors.gen.ts' +export { post52Errors as 'POST /apps/{app_id}/workflows/draft/human-input/nodes/{node_id}/message-template/test' } from './apps/errors.gen.ts' +export { post52Errors as 'POST /workspaces/current/plugin/upgrade/github' } from './workspaces/errors.gen.ts' +export { post53Errors as 'POST /apps/{app_id}/workflows/draft/iteration/nodes/{node_id}/run' } from './apps/errors.gen.ts' +export { post53Errors as 'POST /workspaces/current/plugin/upgrade/marketplace' } from './workspaces/errors.gen.ts' +export { post54Errors as 'POST /apps/{app_id}/workflows/draft/loop/nodes/{node_id}/run' } from './apps/errors.gen.ts' +export { post55Errors as 'POST /apps/{app_id}/workflows/draft/nodes/{node_id}/agent-composer/copy-from-roster' } from './apps/errors.gen.ts' +export { post55Errors as 'POST /workspaces/current/plugin/upload/github' } from './workspaces/errors.gen.ts' +export { post56Errors as 'POST /apps/{app_id}/workflows/draft/nodes/{node_id}/agent-composer/impact' } from './apps/errors.gen.ts' +export { post56Errors as 'POST /workspaces/current/plugin/upload/pkg' } from './workspaces/errors.gen.ts' +export { post57Errors as 'POST /apps/{app_id}/workflows/draft/nodes/{node_id}/agent-composer/save-to-roster' } from './apps/errors.gen.ts' +export { post58Errors as 'POST /apps/{app_id}/workflows/draft/nodes/{node_id}/agent-composer/validate' } from './apps/errors.gen.ts' +export { post59Errors as 'POST /apps/{app_id}/workflows/draft/nodes/{node_id}/run' } from './apps/errors.gen.ts' export { post5Errors as 'POST /agent/{agent_id}/build-draft/apply' } from './agent/errors.gen.ts' export { post5Errors as 'POST /apps/{app_id}/advanced-chat/workflows/draft/human-input/nodes/{node_id}/form/run' } from './apps/errors.gen.ts' export { post5Errors as 'POST /datasets/init' } from './datasets/errors.gen.ts' export { post5Errors as 'POST /installed-apps/{installed_app_id}/completion-messages' } from './installed-apps/errors.gen.ts' export { post5Errors as 'POST /knowledge-fs/spaces/{control_space_id}/background-tasks/{task_kind}/{task_id}/retry' } from './knowledge-fs/errors.gen.ts' export { post5Errors as 'POST /trial-apps/{app_id}/files/upload' } from './trial-apps/errors.gen.ts' -export { post60Errors as 'POST /apps/{app_id}/workflows/draft/trigger/run' } from './apps/errors.gen.ts' -export { post61Errors as 'POST /apps/{app_id}/workflows/draft/trigger/run-all' } from './apps/errors.gen.ts' -export { post62Errors as 'POST /apps/{app_id}/workflows/draft' } from './apps/errors.gen.ts' -export { post63Errors as 'POST /apps/{app_id}/workflows/publish' } from './apps/errors.gen.ts' -export { post64Errors as 'POST /apps/{app_id}/workflows/{workflow_id}/restore' } from './apps/errors.gen.ts' -export { post65Errors as 'POST /apps/{resource_id}/api-keys' } from './apps/errors.gen.ts' +export { post60Errors as 'POST /apps/{app_id}/workflows/draft/nodes/{node_id}/trigger/run' } from './apps/errors.gen.ts' +export { post61Errors as 'POST /apps/{app_id}/workflows/draft/run' } from './apps/errors.gen.ts' +export { post61Errors as 'POST /workspaces/current/skills/files/upload' } from './workspaces/errors.gen.ts' +export { post62Errors as 'POST /apps/{app_id}/workflows/draft/trigger/run' } from './apps/errors.gen.ts' +export { post62Errors as 'POST /workspaces/current/skills/import' } from './workspaces/errors.gen.ts' +export { post63Errors as 'POST /apps/{app_id}/workflows/draft/trigger/run-all' } from './apps/errors.gen.ts' +export { post63Errors as 'POST /workspaces/current/skills/{skill_id}/assist/messages' } from './workspaces/errors.gen.ts' +export { post64Errors as 'POST /apps/{app_id}/workflows/draft' } from './apps/errors.gen.ts' +export { post65Errors as 'POST /apps/{app_id}/workflows/publish' } from './apps/errors.gen.ts' +export { post65Errors as 'POST /workspaces/current/skills/{skill_id}/files/check' } from './workspaces/errors.gen.ts' +export { post66Errors as 'POST /apps/{app_id}/workflows/{workflow_id}/restore' } from './apps/errors.gen.ts' +export { post66Errors as 'POST /workspaces/current/skills/{skill_id}/publish' } from './workspaces/errors.gen.ts' +export { post67Errors as 'POST /apps/{resource_id}/api-keys' } from './apps/errors.gen.ts' +export { post67Errors as 'POST /workspaces/current/skills/{skill_id}/restore' } from './workspaces/errors.gen.ts' +export { post68Errors as 'POST /workspaces/current/skills' } from './workspaces/errors.gen.ts' export { post6Errors as 'POST /account/delete' } from './account/errors.gen.ts' export { post6Errors as 'POST /agent/{agent_id}/build-draft/checkout' } from './agent/errors.gen.ts' -export { post6Errors as 'POST /apps/{app_id}/advanced-chat/workflows/draft/iteration/nodes/{node_id}/run' } from './apps/errors.gen.ts' +export { post6Errors as 'POST /apps/{app_id}/advanced-chat/workflows/draft/human-input/nodes/{node_id}/message-template/test' } from './apps/errors.gen.ts' export { post6Errors as 'POST /datasets/notion-indexing-estimate' } from './datasets/errors.gen.ts' export { post6Errors as 'POST /installed-apps/{installed_app_id}/conversations/{c_id}/name' } from './installed-apps/errors.gen.ts' export { post6Errors as 'POST /knowledge-fs/spaces/{control_space_id}/documents/reindex' } from './knowledge-fs/errors.gen.ts' @@ -502,24 +506,24 @@ export { post6Errors as 'POST /snippets/{snippet_id}/workflows/draft/nodes/{node export { post6Errors as 'POST /trial-apps/{app_id}/remote-files/upload' } from './trial-apps/errors.gen.ts' export { post7Errors as 'POST /account/education' } from './account/errors.gen.ts' export { post7Errors as 'POST /agent/{agent_id}/chat-messages/{task_id}/stop' } from './agent/errors.gen.ts' -export { post7Errors as 'POST /apps/{app_id}/advanced-chat/workflows/draft/loop/nodes/{node_id}/run' } from './apps/errors.gen.ts' +export { post7Errors as 'POST /apps/{app_id}/advanced-chat/workflows/draft/iteration/nodes/{node_id}/run' } from './apps/errors.gen.ts' export { post7Errors as 'POST /installed-apps/{installed_app_id}/messages/{message_id}/feedbacks' } from './installed-apps/errors.gen.ts' export { post7Errors as 'POST /knowledge-fs/spaces/{control_space_id}/documents' } from './knowledge-fs/errors.gen.ts' export { post7Errors as 'POST /rag/pipelines/{pipeline_id}/customized/publish' } from './rag/errors.gen.ts' export { post7Errors as 'POST /snippets/{snippet_id}/workflows/draft/nodes/{node_id}/agent-composer/validate' } from './snippets/errors.gen.ts' export { post7Errors as 'POST /trial-apps/{app_id}/text-to-audio' } from './trial-apps/errors.gen.ts' -export { post80Errors as 'POST /workspaces/custom-config/webapp-logo/upload' } from './workspaces/errors.gen.ts' -export { post83Errors as 'POST /workspaces/switch' } from './workspaces/errors.gen.ts' export { post8Errors as 'POST /account/init' } from './account/errors.gen.ts' export { post8Errors as 'POST /agent/{agent_id}/composer/validate' } from './agent/errors.gen.ts' -export { post8Errors as 'POST /apps/{app_id}/advanced-chat/workflows/draft/run' } from './apps/errors.gen.ts' +export { post8Errors as 'POST /apps/{app_id}/advanced-chat/workflows/draft/loop/nodes/{node_id}/run' } from './apps/errors.gen.ts' export { post8Errors as 'POST /installed-apps/{installed_app_id}/saved-messages' } from './installed-apps/errors.gen.ts' export { post8Errors as 'POST /knowledge-fs/spaces/{control_space_id}/golden-questions/bulk-import' } from './knowledge-fs/errors.gen.ts' export { post8Errors as 'POST /rag/pipelines/{pipeline_id}/workflow-runs/tasks/{task_id}/stop' } from './rag/errors.gen.ts' export { post8Errors as 'POST /trial-apps/{app_id}/workflows/run' } from './trial-apps/errors.gen.ts' +export { post92Errors as 'POST /workspaces/custom-config/webapp-logo/upload' } from './workspaces/errors.gen.ts' +export { post95Errors as 'POST /workspaces/switch' } from './workspaces/errors.gen.ts' export { post9Errors as 'POST /account/password' } from './account/errors.gen.ts' export { post9Errors as 'POST /agent/{agent_id}/config/files/batch' } from './agent/errors.gen.ts' -export { post9Errors as 'POST /apps/{app_id}/agent/config/files/batch' } from './apps/errors.gen.ts' +export { post9Errors as 'POST /apps/{app_id}/advanced-chat/workflows/draft/run' } from './apps/errors.gen.ts' export { post9Errors as 'POST /installed-apps/{installed_app_id}/text-to-audio' } from './installed-apps/errors.gen.ts' export { post9Errors as 'POST /knowledge-fs/spaces/{control_space_id}/golden-questions/evidence-matches' } from './knowledge-fs/errors.gen.ts' export { post9Errors as 'POST /rag/pipelines/{pipeline_id}/workflows/draft/datasource/nodes/{node_id}/run' } from './rag/errors.gen.ts' @@ -533,7 +537,7 @@ export { postErrors as 'POST /email-code-login/validity' } from './email-code-lo export { postErrors as 'POST /email-register/send-email' } from './email-register/errors.gen.ts' export { postErrors as 'POST /files/upload' } from './files/errors.gen.ts' export { postErrors as 'POST /forgot-password/resets' } from './forgot-password/errors.gen.ts' -export { postErrors as 'POST /form/human_input/{form_token}' } from './form/errors.gen.ts' +export { postErrors as 'POST /form/human-input/v2/{form_token}' } from './form/errors.gen.ts' export { postErrors as 'POST /installed-apps/{installed_app_id}/audio-to-text' } from './installed-apps/errors.gen.ts' export { postErrors as 'POST /knowledge-fs/query-stream' } from './knowledge-fs/errors.gen.ts' export { postErrors as 'POST /login' } from './login/errors.gen.ts' @@ -544,11 +548,10 @@ export { postErrors as 'POST /rule-generate' } from './rule-generate/errors.gen. export { postErrors as 'POST /rule-structured-output-generate' } from './rule-structured-output-generate/errors.gen.ts' export { postErrors as 'POST /trial-apps/{app_id}/audio-to-text' } from './trial-apps/errors.gen.ts' export { postErrors as 'POST /website/crawl' } from './website/errors.gen.ts' -export { put20Errors as 'PUT /workspaces/current/skills/{skill_id}/files' } from './workspaces/errors.gen.ts' +export { put24Errors as 'PUT /workspaces/current/skills/{skill_id}/files' } from './workspaces/errors.gen.ts' export { put2Errors as 'PUT /agent/{agent_id}/composer' } from './agent/errors.gen.ts' export { put2Errors as 'PUT /apps/{app_id}/server' } from './apps/errors.gen.ts' export { put2Errors as 'PUT /knowledge-fs/spaces/{control_space_id}/external-access' } from './knowledge-fs/errors.gen.ts' -export { put2Errors as 'PUT /workspaces/current/members/{member_id}/update-role' } from './workspaces/errors.gen.ts' export { put3Errors as 'PUT /agent/{agent_id}' } from './agent/errors.gen.ts' export { put3Errors as 'PUT /apps/{app_id}/workflow/comments/{comment_id}/replies/{reply_id}' } from './apps/errors.gen.ts' export { put3Errors as 'PUT /knowledge-fs/spaces/{control_space_id}/members' } from './knowledge-fs/errors.gen.ts' @@ -557,6 +560,7 @@ export { put4Errors as 'PUT /knowledge-fs/spaces/{control_space_id}/sources/{sou export { put5Errors as 'PUT /apps/{app_id}/workflows/draft/nodes/{node_id}/agent-composer' } from './apps/errors.gen.ts' export { put5Errors as 'PUT /knowledge-fs/spaces/{control_space_id}/tags' } from './knowledge-fs/errors.gen.ts' export { put6Errors as 'PUT /apps/{app_id}/workflows/draft/variables/{variable_id}/reset' } from './apps/errors.gen.ts' +export { put6Errors as 'PUT /workspaces/current/members/{member_id}/update-role' } from './workspaces/errors.gen.ts' export { putErrors as 'PUT /agent/{agent_id}/build-draft' } from './agent/errors.gen.ts' export { putErrors as 'PUT /apps/{app_id}/network-access-group' } from './apps/errors.gen.ts' export { putErrors as 'PUT /knowledge-fs/spaces/{control_space_id}/app-bindings' } from './knowledge-fs/errors.gen.ts' diff --git a/packages/contracts/generated/api/console/features/types.gen.ts b/packages/contracts/generated/api/console/features/types.gen.ts index e4f56eed25e..021e120de56 100644 --- a/packages/contracts/generated/api/console/features/types.gen.ts +++ b/packages/contracts/generated/api/console/features/types.gen.ts @@ -15,6 +15,7 @@ export type FeatureModel = { enable_knowledge_fs: boolean enable_skill: boolean human_input_email_delivery_enabled: boolean + im_centrally_managed: boolean is_allow_transfer_workspace: boolean knowledge_pipeline: KnowledgePipeline members: LimitationModel diff --git a/packages/contracts/generated/api/console/features/zod.gen.ts b/packages/contracts/generated/api/console/features/zod.gen.ts index e130a65f7b1..cc8cad882a0 100644 --- a/packages/contracts/generated/api/console/features/zod.gen.ts +++ b/packages/contracts/generated/api/console/features/zod.gen.ts @@ -118,6 +118,7 @@ export const zFeatureModel = z.object({ enable_knowledge_fs: z.boolean().default(false), enable_skill: z.boolean().default(true), human_input_email_delivery_enabled: z.boolean().default(false), + im_centrally_managed: z.boolean().default(false), is_allow_transfer_workspace: z.boolean().default(true), knowledge_pipeline: zKnowledgePipeline.default({ publish_enabled: false }), members: zLimitationModel.default({ limit: 1, size: 0 }), diff --git a/packages/contracts/generated/api/console/form/errors.gen.ts b/packages/contracts/generated/api/console/form/errors.gen.ts index ce49b179759..df7a29e6d08 100644 --- a/packages/contracts/generated/api/console/form/errors.gen.ts +++ b/packages/contracts/generated/api/console/form/errors.gen.ts @@ -10,6 +10,38 @@ const zErrorResponse = /* @__PURE__ */ (() => status: z.int(), }))() +const zAlreadySubmittedError = /* @__PURE__ */ (() => + zErrorResponse.and( + z.object({ + code: z.literal('already_submitted'), + status: z.literal(409), + }), + ))() + +const zExpiredError = /* @__PURE__ */ (() => + zErrorResponse.and( + z.object({ + code: z.literal('expired'), + status: z.literal(410), + }), + ))() + +const zInvalidInputError = /* @__PURE__ */ (() => + zErrorResponse.and( + z.object({ + code: z.literal('invalid_input'), + status: z.literal(400), + }), + ))() + +const zWorkflowNotActiveError = /* @__PURE__ */ (() => + zErrorResponse.and( + z.object({ + code: z.literal('workflow_not_active'), + status: z.literal(409), + }), + ))() + const zHumanInputFormSubmittedError = /* @__PURE__ */ (() => zErrorResponse.and( z.object({ @@ -35,10 +67,22 @@ const zHumanInputFormExpiredError = /* @__PURE__ */ (() => ))() export const getErrors = { - human_input_form_submitted: { status: 412, data: zHumanInputFormSubmittedError }, + already_submitted: { status: 409, data: zAlreadySubmittedError }, + expired: { status: 410, data: zExpiredError }, } export const postErrors = { + already_submitted: { status: 409, data: zAlreadySubmittedError }, + expired: { status: 410, data: zExpiredError }, + invalid_input: { status: 400, data: zInvalidInputError }, + workflow_not_active: { status: 409, data: zWorkflowNotActiveError }, +} + +export const get2Errors = { + human_input_form_submitted: { status: 412, data: zHumanInputFormSubmittedError }, +} + +export const post2Errors = { human_input_form_expired: { status: 412, data: zHumanInputFormExpiredError }, human_input_form_submitted: { status: 412, data: zHumanInputFormSubmittedError }, invalid_form_data: { status: 400, data: zInvalidFormDataError }, diff --git a/packages/contracts/generated/api/console/form/orpc.gen.ts b/packages/contracts/generated/api/console/form/orpc.gen.ts index 0d235f5df13..c425c611ea2 100644 --- a/packages/contracts/generated/api/console/form/orpc.gen.ts +++ b/packages/contracts/generated/api/console/form/orpc.gen.ts @@ -3,24 +3,83 @@ import { oc } from '@orpc/contract' import * as z from 'zod' import { + zAlreadySubmittedError, + zExpiredError, zHumanInputFormExpiredError, zHumanInputFormSubmittedError, zInvalidFormDataError, + zInvalidInputError, + zWorkflowNotActiveError, } from './zod.gen.ts' import { zGetFormHumanInputByFormTokenPath, zGetFormHumanInputByFormTokenResponse, + zGetFormHumanInputV2ByFormTokenPath, + zGetFormHumanInputV2ByFormTokenResponse, zPostFormHumanInputByFormTokenBody, zPostFormHumanInputByFormTokenPath, zPostFormHumanInputByFormTokenResponse, + zPostFormHumanInputV2ByFormTokenBody, + zPostFormHumanInputV2ByFormTokenPath, + zPostFormHumanInputV2ByFormTokenResponse, } from './zod.gen.ts' +/** + * Read a frozen v2 form using the current Account's Contact authorization. + */ +export const get = oc + .route({ + description: "Read a frozen v2 form using the current Account's Contact authorization.", + inputStructure: 'detailed', + method: 'GET', + operationId: 'getFormHumanInputV2ByFormToken', + path: '/form/human-input/v2/{form_token}', + tags: ['console'], + }) + .input(z.object({ params: zGetFormHumanInputV2ByFormTokenPath })) + .output(zGetFormHumanInputV2ByFormTokenResponse) + .errors({ + already_submitted: { status: 409, data: zAlreadySubmittedError }, + expired: { status: 410, data: zExpiredError }, + }) + +export const post = oc + .route({ + inputStructure: 'detailed', + method: 'POST', + operationId: 'postFormHumanInputV2ByFormToken', + path: '/form/human-input/v2/{form_token}', + tags: ['console'], + }) + .input( + z.object({ + body: zPostFormHumanInputV2ByFormTokenBody, + params: zPostFormHumanInputV2ByFormTokenPath, + }), + ) + .output(zPostFormHumanInputV2ByFormTokenResponse) + .errors({ + already_submitted: { status: 409, data: zAlreadySubmittedError }, + expired: { status: 410, data: zExpiredError }, + invalid_input: { status: 400, data: zInvalidInputError }, + workflow_not_active: { status: 409, data: zWorkflowNotActiveError }, + }) + +export const byFormToken = { + get, + post, +} + +export const v2 = { + byFormToken, +} + /** * Get human input form definition by form token * * GET /console/api/form/human_input/ */ -export const get = oc +export const get2 = oc .route({ description: 'GET /console/api/form/human_input/', inputStructure: 'detailed', @@ -47,7 +106,7 @@ export const get = oc * "action": "Approve" * } */ -export const post = oc +export const post2 = oc .route({ description: 'POST /console/api/form/human_input/\n\nRequest body:\n{\n "inputs": {\n "content": "User input content"\n },\n "action": "Approve"\n}', @@ -71,13 +130,14 @@ export const post = oc invalid_form_data: { status: 400, data: zInvalidFormDataError }, }) -export const byFormToken = { - get, - post, +export const byFormToken2 = { + get: get2, + post: post2, } export const humanInput = { - byFormToken, + v2, + byFormToken: byFormToken2, } export const form = { diff --git a/packages/contracts/generated/api/console/form/types.gen.ts b/packages/contracts/generated/api/console/form/types.gen.ts index 761d5ee325e..fa79b9339b3 100644 --- a/packages/contracts/generated/api/console/form/types.gen.ts +++ b/packages/contracts/generated/api/console/form/types.gen.ts @@ -4,13 +4,9 @@ export type ClientOptions = { baseUrl: `${string}://${string}/console/api` | (string & {}) } -export type ConsoleHumanInputFormDefinitionResponse = { - [key: string]: unknown -} - -export type HumanInputFormSubmittedError = ErrorResponse & { - code: 'human_input_form_submitted' - status: 412 +export type ConsoleHumanInputV2FormDefinitionResponse = { + expiration_time: number + resolved_form: ResolvedForm } export type ErrorResponse = { @@ -23,6 +19,46 @@ export type ErrorResponse = { status: number } +export type AlreadySubmittedError = ErrorResponse & { + code: 'already_submitted' + status: 409 +} + +export type ExpiredError = ErrorResponse & { + code: 'expired' + status: 410 +} + +export type ConsoleHumanInputV2SubmitPayload = { + action: string + inputs: { + [key: string]: JsonValue + } +} + +export type ConsoleHumanInputV2SubmitResponse = { + [key: string]: unknown +} + +export type InvalidInputError = ErrorResponse & { + code: 'invalid_input' + status: 400 +} + +export type WorkflowNotActiveError = ErrorResponse & { + code: 'workflow_not_active' + status: 409 +} + +export type ConsoleHumanInputFormDefinitionResponse = { + [key: string]: unknown +} + +export type HumanInputFormSubmittedError = ErrorResponse & { + code: 'human_input_form_submitted' + status: 412 +} + export type HumanInputFormSubmitPayload = { action: string inputs: { @@ -44,8 +80,132 @@ export type HumanInputFormExpiredError = ErrorResponse & { status: 412 } +export type ResolvedForm = { + actions: Array + legacy_form_content: string + parts: Array + title: string +} + export type JsonValue = unknown +export type UserAction = { + button_style: ButtonStyle + id: string + title: string +} + +export type FormPart = + | ({ + type: 'markdown_fragment' + } & MarkdownFragment) + | ({ + type: 'paragraph_input' + } & ParagraphInput) + | ({ + type: 'select_input' + } & SelectInput) + | ({ + type: 'file_input' + } & FileInput) + | ({ + type: 'file_list_input' + } & FileListInput) + +export type ButtonStyle = 'accent' | 'default' | 'ghost' | 'primary' + +export type MarkdownFragment = { + text: string + type?: 'markdown_fragment' +} + +export type ParagraphInput = { + default_value: string | null + output_variable_name: string + type?: 'paragraph_input' +} + +export type SelectInput = { + default_value: string | null + options: Array + output_variable_name: string + type?: 'select_input' +} + +export type FileInput = { + allowed_file_extensions: Array + allowed_file_types: Array + allowed_file_upload_methods: Array + output_variable_name: string + type?: 'file_input' +} + +export type FileListInput = { + allowed_file_extensions: Array + allowed_file_types: Array + allowed_file_upload_methods: Array + number_limits: number + output_variable_name: string + type?: 'file_list_input' +} + +export type FileType = 'audio' | 'custom' | 'document' | 'image' | 'video' + +export type FileTransferMethod = 'datasource_file' | 'local_file' | 'remote_url' | 'tool_file' + +export type GetFormHumanInputV2ByFormTokenData = { + body?: never + path: { + form_token: string + } + query?: never + url: '/form/human-input/v2/{form_token}' +} + +export type GetFormHumanInputV2ByFormTokenErrors = { + 401: ErrorResponse + 404: ErrorResponse + 409: AlreadySubmittedError | ErrorResponse + 410: ExpiredError | ErrorResponse + default: ErrorResponse +} + +export type GetFormHumanInputV2ByFormTokenError = + GetFormHumanInputV2ByFormTokenErrors[keyof GetFormHumanInputV2ByFormTokenErrors] + +export type GetFormHumanInputV2ByFormTokenResponses = { + 200: ConsoleHumanInputV2FormDefinitionResponse +} + +export type GetFormHumanInputV2ByFormTokenResponse = + GetFormHumanInputV2ByFormTokenResponses[keyof GetFormHumanInputV2ByFormTokenResponses] + +export type PostFormHumanInputV2ByFormTokenData = { + body: ConsoleHumanInputV2SubmitPayload + path: { + form_token: string + } + query?: never + url: '/form/human-input/v2/{form_token}' +} + +export type PostFormHumanInputV2ByFormTokenErrors = { + 400: InvalidInputError | ErrorResponse + 409: AlreadySubmittedError | WorkflowNotActiveError | ErrorResponse + 410: ExpiredError | ErrorResponse + default: ErrorResponse +} + +export type PostFormHumanInputV2ByFormTokenError = + PostFormHumanInputV2ByFormTokenErrors[keyof PostFormHumanInputV2ByFormTokenErrors] + +export type PostFormHumanInputV2ByFormTokenResponses = { + 200: ConsoleHumanInputV2SubmitResponse +} + +export type PostFormHumanInputV2ByFormTokenResponse = + PostFormHumanInputV2ByFormTokenResponses[keyof PostFormHumanInputV2ByFormTokenResponses] + export type GetFormHumanInputByFormTokenData = { body?: never path: { diff --git a/packages/contracts/generated/api/console/form/zod.gen.ts b/packages/contracts/generated/api/console/form/zod.gen.ts index 18724fa0f55..f06877b4544 100644 --- a/packages/contracts/generated/api/console/form/zod.gen.ts +++ b/packages/contracts/generated/api/console/form/zod.gen.ts @@ -2,11 +2,6 @@ import * as z from 'zod' -/** - * ConsoleHumanInputFormDefinitionResponse - */ -export const zConsoleHumanInputFormDefinitionResponse = z.record(z.string(), z.unknown()) - /** * ErrorResponse * @@ -23,6 +18,56 @@ export const zErrorResponse = z.object({ status: z.int(), }) +/** + * AlreadySubmittedError + */ +export const zAlreadySubmittedError = zErrorResponse.and( + z.object({ + code: z.literal('already_submitted'), + status: z.literal(409), + }), +) + +/** + * ExpiredError + */ +export const zExpiredError = zErrorResponse.and( + z.object({ + code: z.literal('expired'), + status: z.literal(410), + }), +) + +/** + * ConsoleHumanInputV2SubmitResponse + */ +export const zConsoleHumanInputV2SubmitResponse = z.record(z.string(), z.unknown()) + +/** + * InvalidInputError + */ +export const zInvalidInputError = zErrorResponse.and( + z.object({ + code: z.literal('invalid_input'), + status: z.literal(400), + }), +) + +/** + * WorkflowNotActiveError + */ +export const zWorkflowNotActiveError = zErrorResponse.and( + z.object({ + code: z.literal('workflow_not_active'), + status: z.literal(409), + }), +) + +/** + * ConsoleHumanInputFormDefinitionResponse + */ +export const zConsoleHumanInputFormDefinitionResponse = z.record(z.string(), z.unknown()) + /** * HumanInputFormSubmittedError * @@ -62,14 +107,167 @@ export const zHumanInputFormExpiredError = zErrorResponse.and( export const zJsonValue = z.unknown() +/** + * ConsoleHumanInputV2SubmitPayload + */ +export const zConsoleHumanInputV2SubmitPayload = z.object({ + action: z.string(), + inputs: z.record(z.string(), zJsonValue), +}) + /** * HumanInputFormSubmitPayload + * + * Legacy Human Input v1 submit payload shared by existing runtime surfaces. */ export const zHumanInputFormSubmitPayload = z.object({ action: z.string(), inputs: z.record(z.string(), zJsonValue), }) +/** + * ButtonStyle + * + * Button styles for user actions. + */ +export const zButtonStyle = z.enum(['accent', 'default', 'ghost', 'primary']) + +/** + * UserAction + * + * Resolved action values required for presentation and submission validation. + */ +export const zUserAction = z.object({ + button_style: zButtonStyle, + id: z.string(), + title: z.string(), +}) + +/** + * MarkdownFragment + * + * One source-ordered Markdown fragment in a resolved form. + */ +export const zMarkdownFragment = z.object({ + text: z.string(), + type: z.literal('markdown_fragment').optional().default('markdown_fragment'), +}) + +/** + * ParagraphInput + * + * Resolved multiline text input. + */ +export const zParagraphInput = z.object({ + default_value: z.string().nullable(), + output_variable_name: z.string(), + type: z.literal('paragraph_input').optional().default('paragraph_input'), +}) + +/** + * SelectInput + * + * Resolved single-choice input with immutable concrete options. + */ +export const zSelectInput = z.object({ + default_value: z.string().nullable(), + options: z.array(z.string()), + output_variable_name: z.string(), + type: z.literal('select_input').optional().default('select_input'), +}) + +/** + * FileType + */ +export const zFileType = z.enum(['audio', 'custom', 'document', 'image', 'video']) + +/** + * FileTransferMethod + */ +export const zFileTransferMethod = z.enum([ + 'datasource_file', + 'local_file', + 'remote_url', + 'tool_file', +]) + +/** + * FileInput + * + * Resolved single-file input constraints. + */ +export const zFileInput = z.object({ + allowed_file_extensions: z.array(z.string()), + allowed_file_types: z.array(zFileType), + allowed_file_upload_methods: z.array(zFileTransferMethod), + output_variable_name: z.string(), + type: z.literal('file_input').optional().default('file_input'), +}) + +/** + * FileListInput + * + * Resolved multi-file input constraints and effective number limit. + */ +export const zFileListInput = z.object({ + allowed_file_extensions: z.array(z.string()), + allowed_file_types: z.array(zFileType), + allowed_file_upload_methods: z.array(zFileTransferMethod), + number_limits: z.int(), + output_variable_name: z.string(), + type: z.literal('file_list_input').optional().default('file_list_input'), +}) + +export const zFormPart = z.discriminatedUnion('type', [ + zMarkdownFragment, + zParagraphInput, + zSelectInput, + zFileInput, + zFileListInput, +]) + +/** + * ResolvedForm + * + * Immutable authoritative presentation snapshot for one v2 form. + */ +export const zResolvedForm = z.object({ + actions: z.array(zUserAction), + legacy_form_content: z.string(), + parts: z.array(zFormPart), + title: z.string(), +}) + +/** + * ConsoleHumanInputV2FormDefinitionResponse + * + * Frozen form definition and effective expiration in UTC epoch seconds. + */ +export const zConsoleHumanInputV2FormDefinitionResponse = z.object({ + expiration_time: z.int(), + resolved_form: zResolvedForm, +}) + +export const zGetFormHumanInputV2ByFormTokenPath = z.object({ + form_token: z.string(), +}) + +/** + * Form retrieved + */ +export const zGetFormHumanInputV2ByFormTokenResponse = zConsoleHumanInputV2FormDefinitionResponse + +export const zPostFormHumanInputV2ByFormTokenBody = zConsoleHumanInputV2SubmitPayload + +export const zPostFormHumanInputV2ByFormTokenPath = z.object({ + form_token: z.string(), +}) + +/** + * Approval accepted + */ +export const zPostFormHumanInputV2ByFormTokenResponse = zConsoleHumanInputV2SubmitResponse + export const zGetFormHumanInputByFormTokenPath = z.object({ form_token: z.string(), }) diff --git a/packages/contracts/generated/api/console/installed-apps/types.gen.ts b/packages/contracts/generated/api/console/installed-apps/types.gen.ts index 245b8794c91..c321dc5ba88 100644 --- a/packages/contracts/generated/api/console/installed-apps/types.gen.ts +++ b/packages/contracts/generated/api/console/installed-apps/types.gen.ts @@ -812,6 +812,7 @@ export type HumanInputFormDefinition = { form_content: string form_id: string form_token?: string | null + form_version?: '1' | '2' inputs?: Array node_id: string node_title: string diff --git a/packages/contracts/generated/api/console/installed-apps/zod.gen.ts b/packages/contracts/generated/api/console/installed-apps/zod.gen.ts index 62b8992aa14..8241be8286f 100644 --- a/packages/contracts/generated/api/console/installed-apps/zod.gen.ts +++ b/packages/contracts/generated/api/console/installed-apps/zod.gen.ts @@ -1232,6 +1232,7 @@ export const zHumanInputFormDefinition = z.object({ form_content: z.string(), form_id: z.string(), form_token: z.string().nullish(), + form_version: z.enum(['1', '2']).optional().default('1'), inputs: z.array(zFormInputConfig).optional(), node_id: z.string(), node_title: z.string(), diff --git a/packages/contracts/generated/api/console/snippets/zod.gen.ts b/packages/contracts/generated/api/console/snippets/zod.gen.ts index 7b95d2371e3..ee89768d544 100644 --- a/packages/contracts/generated/api/console/snippets/zod.gen.ts +++ b/packages/contracts/generated/api/console/snippets/zod.gen.ts @@ -807,7 +807,7 @@ export const zDeclaredOutputType = z.enum([ * * Per-item shape for an ``array``-typed declared output. * - * PRD §OUTPUT 配置框 keeps arrays one level deep on first version; nested arrays + * The PRD output configuration section limits arrays to one level; nested arrays * are rejected so the runtime type checker and JSON Schema stay easy to reason * about. Stage 4 §4.2. */ @@ -1367,7 +1367,7 @@ export const zAgentSoulModelConfig = z.object({ * * File-output content check via a model-based comparison against a benchmark file. * - * Per PRD §OUTPUT 配置框, output check is **file-only** and optional. Stage 4 §4.3. + * Per the PRD output configuration section, output checks are **file-only** and optional. Stage 4 §4.3. */ export const zDeclaredOutputCheckConfig = z.object({ benchmark_file_ref: zAgentFileRefConfig.nullish(), diff --git a/packages/contracts/generated/api/console/workflow/types.gen.ts b/packages/contracts/generated/api/console/workflow/types.gen.ts index 1fca2bd527c..4cd5cdae8ff 100644 --- a/packages/contracts/generated/api/console/workflow/types.gen.ts +++ b/packages/contracts/generated/api/console/workflow/types.gen.ts @@ -30,6 +30,7 @@ export type PausedNodeResponse = { export type HumanInputPauseTypeResponse = { backstage_input_url?: string | null form_id: string + form_version?: '1' | '2' type: 'human_input' } diff --git a/packages/contracts/generated/api/console/workflow/zod.gen.ts b/packages/contracts/generated/api/console/workflow/zod.gen.ts index 8f587389626..dce67d5a91c 100644 --- a/packages/contracts/generated/api/console/workflow/zod.gen.ts +++ b/packages/contracts/generated/api/console/workflow/zod.gen.ts @@ -29,6 +29,7 @@ export const zErrorResponse = z.object({ export const zHumanInputPauseTypeResponse = z.object({ backstage_input_url: z.string().nullish(), form_id: z.string(), + form_version: z.enum(['1', '2']).optional().default('1'), type: z.literal('human_input'), }) diff --git a/packages/contracts/generated/api/console/workspaces/errors.gen.ts b/packages/contracts/generated/api/console/workspaces/errors.gen.ts index 1def2eaf4fd..9e108d3ec6c 100644 --- a/packages/contracts/generated/api/console/workspaces/errors.gen.ts +++ b/packages/contracts/generated/api/console/workspaces/errors.gen.ts @@ -212,12 +212,12 @@ const zAccountNotLinkTenantError = /* @__PURE__ */ (() => export const putErrors = { invalid_request: { status: 400, data: zInvalidRequestError } } -export const post12Errors = { +export const post24Errors = { invalid_role: { status: 400, data: zInvalidRoleError }, limit_exceeded: { status: 400, data: zLimitExceededError }, } -export const post13Errors = { +export const post25Errors = { email_code_error: { status: 400, data: zEmailCodeError }, invalid_email: { status: 400, data: zInvalidEmailError }, invalid_or_expired_token: { status: 400, data: zInvalidOrExpiredTokenError }, @@ -225,13 +225,13 @@ export const post13Errors = { owner_transfer_limit: { status: 429, data: zOwnerTransferLimitError }, } -export const post14Errors = { +export const post26Errors = { email_send_ip_limit: { status: 429, data: zEmailSendIpLimitError }, not_owner: { status: 400, data: zNotOwnerError }, owner_transfer_rate_limit_exceeded: { status: 429, data: zOwnerTransferRateLimitExceededError }, } -export const post15Errors = { +export const post27Errors = { cannot_transfer_owner_to_self: { status: 400, data: zCannotTransferOwnerToSelfError }, invalid_email: { status: 400, data: zInvalidEmailError }, invalid_or_expired_token: { status: 400, data: zInvalidOrExpiredTokenError }, @@ -239,7 +239,7 @@ export const post15Errors = { not_owner: { status: 400, data: zNotOwnerError }, } -export const put2Errors = { +export const put6Errors = { cannot_operate_self: { status: 400, data: zCannotOperateSelfError }, forbidden: { status: 403, data: zForbiddenError }, invalid_role: { status: 400, data: zInvalidRoleError }, @@ -247,98 +247,98 @@ export const put2Errors = { role_already_assigned: { status: 400, data: zRoleAlreadyAssignedError }, } -export const delete4Errors = { +export const delete8Errors = { cannot_operate_self: { status: 400, data: zCannotOperateSelfError }, forbidden: { status: 403, data: zForbiddenError }, member_not_found: { status: 404, data: zMemberNotFoundError }, } -export const get27Errors = { plugin_error: { status: 400, data: zPluginError } } +export const get40Errors = { plugin_error: { status: 400, data: zPluginError } } -export const get29Errors = { plugin_error: { status: 400, data: zPluginError } } +export const get42Errors = { plugin_error: { status: 400, data: zPluginError } } -export const get30Errors = { plugin_error: { status: 400, data: zPluginError } } +export const get43Errors = { plugin_error: { status: 400, data: zPluginError } } -export const get31Errors = { plugin_error: { status: 400, data: zPluginError } } - -export const post29Errors = { plugin_error: { status: 400, data: zPluginError } } - -export const post30Errors = { plugin_error: { status: 400, data: zPluginError } } - -export const post31Errors = { plugin_error: { status: 400, data: zPluginError } } - -export const get32Errors = { plugin_error: { status: 400, data: zPluginError } } - -export const post32Errors = { plugin_error: { status: 400, data: zPluginError } } - -export const post33Errors = { plugin_error: { status: 400, data: zPluginError } } - -export const get33Errors = { plugin_error: { status: 400, data: zPluginError } } - -export const get34Errors = { plugin_error: { status: 400, data: zPluginError } } - -export const get35Errors = { plugin_error: { status: 400, data: zPluginError } } - -export const post34Errors = { plugin_error: { status: 400, data: zPluginError } } - -export const post36Errors = { plugin_error: { status: 400, data: zPluginError } } - -export const post37Errors = { plugin_error: { status: 400, data: zPluginError } } - -export const post38Errors = { plugin_error: { status: 400, data: zPluginError } } - -export const get38Errors = { plugin_error: { status: 400, data: zPluginError } } - -export const get39Errors = { plugin_error: { status: 400, data: zPluginError } } - -export const post39Errors = { plugin_error: { status: 400, data: zPluginError } } - -export const post40Errors = { plugin_error: { status: 400, data: zPluginError } } +export const get44Errors = { plugin_error: { status: 400, data: zPluginError } } export const post41Errors = { plugin_error: { status: 400, data: zPluginError } } +export const post42Errors = { plugin_error: { status: 400, data: zPluginError } } + export const post43Errors = { plugin_error: { status: 400, data: zPluginError } } +export const get45Errors = { plugin_error: { status: 400, data: zPluginError } } + export const post44Errors = { plugin_error: { status: 400, data: zPluginError } } -export const get40Errors = { plugin_error: { status: 400, data: zPluginError } } +export const post45Errors = { plugin_error: { status: 400, data: zPluginError } } -export const post49Errors = { +export const get46Errors = { plugin_error: { status: 400, data: zPluginError } } + +export const get47Errors = { plugin_error: { status: 400, data: zPluginError } } + +export const get48Errors = { plugin_error: { status: 400, data: zPluginError } } + +export const post46Errors = { plugin_error: { status: 400, data: zPluginError } } + +export const post48Errors = { plugin_error: { status: 400, data: zPluginError } } + +export const post49Errors = { plugin_error: { status: 400, data: zPluginError } } + +export const post50Errors = { plugin_error: { status: 400, data: zPluginError } } + +export const get51Errors = { plugin_error: { status: 400, data: zPluginError } } + +export const get52Errors = { plugin_error: { status: 400, data: zPluginError } } + +export const post51Errors = { plugin_error: { status: 400, data: zPluginError } } + +export const post52Errors = { plugin_error: { status: 400, data: zPluginError } } + +export const post53Errors = { plugin_error: { status: 400, data: zPluginError } } + +export const post55Errors = { plugin_error: { status: 400, data: zPluginError } } + +export const post56Errors = { plugin_error: { status: 400, data: zPluginError } } + +export const get53Errors = { plugin_error: { status: 400, data: zPluginError } } + +export const post61Errors = { filename_missing: { status: 400, data: zFilenameMissingError }, no_file_uploaded: { status: 400, data: zNoFileUploadedError }, } -export const post50Errors = { invalid_request: { status: 400, data: zInvalidRequestError } } +export const post62Errors = { invalid_request: { status: 400, data: zInvalidRequestError } } -export const post51Errors = { invalid_request: { status: 400, data: zInvalidRequestError } } +export const post63Errors = { invalid_request: { status: 400, data: zInvalidRequestError } } -export const post53Errors = { invalid_request: { status: 400, data: zInvalidRequestError } } +export const post65Errors = { invalid_request: { status: 400, data: zInvalidRequestError } } -export const get81Errors = { invalid_request: { status: 400, data: zInvalidRequestError } } +export const get94Errors = { invalid_request: { status: 400, data: zInvalidRequestError } } -export const get82Errors = { invalid_request: { status: 400, data: zInvalidRequestError } } - -export const patch5Errors = { invalid_request: { status: 400, data: zInvalidRequestError } } - -export const put20Errors = { invalid_request: { status: 400, data: zInvalidRequestError } } - -export const post54Errors = { invalid_request: { status: 400, data: zInvalidRequestError } } - -export const post55Errors = { invalid_request: { status: 400, data: zInvalidRequestError } } +export const get95Errors = { invalid_request: { status: 400, data: zInvalidRequestError } } export const patch6Errors = { invalid_request: { status: 400, data: zInvalidRequestError } } -export const delete16Errors = { invalid_request: { status: 400, data: zInvalidRequestError } } +export const put24Errors = { invalid_request: { status: 400, data: zInvalidRequestError } } + +export const post66Errors = { invalid_request: { status: 400, data: zInvalidRequestError } } + +export const post67Errors = { invalid_request: { status: 400, data: zInvalidRequestError } } export const patch7Errors = { invalid_request: { status: 400, data: zInvalidRequestError } } -export const post56Errors = { invalid_request: { status: 400, data: zInvalidRequestError } } +export const delete20Errors = { invalid_request: { status: 400, data: zInvalidRequestError } } -export const get88Errors = { +export const patch8Errors = { invalid_request: { status: 400, data: zInvalidRequestError } } + +export const post68Errors = { invalid_request: { status: 400, data: zInvalidRequestError } } + +export const get101Errors = { current_workspace_archived: { status: 409, data: zCurrentWorkspaceArchivedError }, } -export const post80Errors = { +export const post92Errors = { file_too_large: { status: 413, data: zFileTooLargeError }, filename_not_exists_error: { status: 400, data: zFilenameNotExistsError }, no_file_uploaded: { status: 400, data: zNoFileUploadedError }, @@ -346,6 +346,6 @@ export const post80Errors = { unsupported_file_type: { status: 415, data: zUnsupportedFileTypeError }, } -export const post83Errors = { +export const post95Errors = { account_not_link_tenant: { status: 403, data: zAccountNotLinkTenantError }, } diff --git a/packages/contracts/generated/api/console/workspaces/orpc.gen.ts b/packages/contracts/generated/api/console/workspaces/orpc.gen.ts index 61f1e008dc2..7b02b27bf37 100644 --- a/packages/contracts/generated/api/console/workspaces/orpc.gen.ts +++ b/packages/contracts/generated/api/console/workspaces/orpc.gen.ts @@ -34,6 +34,16 @@ import { zDeleteWorkspacesCurrentCustomizedSnippetsBySnippetIdResponse, zDeleteWorkspacesCurrentEndpointsByIdPath, zDeleteWorkspacesCurrentEndpointsByIdResponse, + zDeleteWorkspacesCurrentHumanInputV2ChannelsEmailByChannelIdPath, + zDeleteWorkspacesCurrentHumanInputV2ChannelsEmailByChannelIdQuery, + zDeleteWorkspacesCurrentHumanInputV2ChannelsEmailByChannelIdResponse, + zDeleteWorkspacesCurrentHumanInputV2ChannelsImByChannelIdPath, + zDeleteWorkspacesCurrentHumanInputV2ChannelsImByChannelIdQuery, + zDeleteWorkspacesCurrentHumanInputV2ChannelsImByChannelIdResponse, + zDeleteWorkspacesCurrentHumanInputV2ContactsByContactIdImBindingsPath, + zDeleteWorkspacesCurrentHumanInputV2ContactsByContactIdImBindingsResponse, + zDeleteWorkspacesCurrentHumanInputV2ContactsByContactIdImOverridePath, + zDeleteWorkspacesCurrentHumanInputV2ContactsByContactIdImOverrideResponse, zDeleteWorkspacesCurrentMembersByMemberIdPath, zDeleteWorkspacesCurrentMembersByMemberIdResponse, zDeleteWorkspacesCurrentModelProvidersByProviderCredentialsBody, @@ -95,6 +105,29 @@ import { zGetWorkspacesCurrentEndpointsListPluginResponse, zGetWorkspacesCurrentEndpointsListQuery, zGetWorkspacesCurrentEndpointsListResponse, + zGetWorkspacesCurrentHumanInputV2ChannelProvidersResponse, + zGetWorkspacesCurrentHumanInputV2ChannelsEmailByChannelIdPath, + zGetWorkspacesCurrentHumanInputV2ChannelsEmailByChannelIdResponse, + zGetWorkspacesCurrentHumanInputV2ChannelsImByChannelIdPath, + zGetWorkspacesCurrentHumanInputV2ChannelsImByChannelIdResponse, + zGetWorkspacesCurrentHumanInputV2ChannelsResponse, + zGetWorkspacesCurrentHumanInputV2ContactOptionsBatchQuery, + zGetWorkspacesCurrentHumanInputV2ContactOptionsBatchResponse, + zGetWorkspacesCurrentHumanInputV2ContactOptionsQuery, + zGetWorkspacesCurrentHumanInputV2ContactOptionsResponse, + zGetWorkspacesCurrentHumanInputV2ContactsBatchQuery, + zGetWorkspacesCurrentHumanInputV2ContactsBatchResponse, + zGetWorkspacesCurrentHumanInputV2ContactsByContactIdPath, + zGetWorkspacesCurrentHumanInputV2ContactsByContactIdResponse, + zGetWorkspacesCurrentHumanInputV2ContactsQuery, + zGetWorkspacesCurrentHumanInputV2ContactsResponse, + zGetWorkspacesCurrentHumanInputV2ImIdentitiesQuery, + zGetWorkspacesCurrentHumanInputV2ImIdentitiesResponse, + zGetWorkspacesCurrentHumanInputV2ImSyncRunsLatestResponse, + zGetWorkspacesCurrentHumanInputV2ImSyncRunsLatestResultsQuery, + zGetWorkspacesCurrentHumanInputV2ImSyncRunsLatestResultsResponse, + zGetWorkspacesCurrentHumanInputV2OrganizationCandidatesQuery, + zGetWorkspacesCurrentHumanInputV2OrganizationCandidatesResponse, zGetWorkspacesCurrentMembersQuery, zGetWorkspacesCurrentMembersResponse, zGetWorkspacesCurrentModelProvidersByProviderCheckoutUrlPath, @@ -311,6 +344,9 @@ import { zPatchWorkspacesCurrentEndpointsByIdBody, zPatchWorkspacesCurrentEndpointsByIdPath, zPatchWorkspacesCurrentEndpointsByIdResponse, + zPatchWorkspacesCurrentHumanInputV2ContactsExternalByContactIdBody, + zPatchWorkspacesCurrentHumanInputV2ContactsExternalByContactIdPath, + zPatchWorkspacesCurrentHumanInputV2ContactsExternalByContactIdResponse, zPatchWorkspacesCurrentModelProvidersByProviderModelsDisableBody, zPatchWorkspacesCurrentModelProvidersByProviderModelsDisablePath, zPatchWorkspacesCurrentModelProvidersByProviderModelsDisableResponse, @@ -348,6 +384,32 @@ import { zPostWorkspacesCurrentEndpointsResponse, zPostWorkspacesCurrentEndpointsUpdateBody, zPostWorkspacesCurrentEndpointsUpdateResponse, + zPostWorkspacesCurrentHumanInputV2ChannelsEmailBody, + zPostWorkspacesCurrentHumanInputV2ChannelsEmailByChannelIdTestBody, + zPostWorkspacesCurrentHumanInputV2ChannelsEmailByChannelIdTestPath, + zPostWorkspacesCurrentHumanInputV2ChannelsEmailByChannelIdTestResponse, + zPostWorkspacesCurrentHumanInputV2ChannelsEmailResponse, + zPostWorkspacesCurrentHumanInputV2ChannelsEmailTestBody, + zPostWorkspacesCurrentHumanInputV2ChannelsEmailTestResponse, + zPostWorkspacesCurrentHumanInputV2ChannelsImBody, + zPostWorkspacesCurrentHumanInputV2ChannelsImByChannelIdReplacementBody, + zPostWorkspacesCurrentHumanInputV2ChannelsImByChannelIdReplacementPath, + zPostWorkspacesCurrentHumanInputV2ChannelsImByChannelIdReplacementResponse, + zPostWorkspacesCurrentHumanInputV2ChannelsImByChannelIdTestBody, + zPostWorkspacesCurrentHumanInputV2ChannelsImByChannelIdTestPath, + zPostWorkspacesCurrentHumanInputV2ChannelsImByChannelIdTestResponse, + zPostWorkspacesCurrentHumanInputV2ChannelsImResponse, + zPostWorkspacesCurrentHumanInputV2ChannelsImTestBody, + zPostWorkspacesCurrentHumanInputV2ChannelsImTestResponse, + zPostWorkspacesCurrentHumanInputV2ContactsExternalBody, + zPostWorkspacesCurrentHumanInputV2ContactsExternalResponse, + zPostWorkspacesCurrentHumanInputV2ContactsPlatformBody, + zPostWorkspacesCurrentHumanInputV2ContactsPlatformResponse, + zPostWorkspacesCurrentHumanInputV2ContactsRemoveBody, + zPostWorkspacesCurrentHumanInputV2ContactsRemoveResponse, + zPostWorkspacesCurrentHumanInputV2ImSyncRunsResponse, + zPostWorkspacesCurrentHumanInputV2NodeDataMigrationBody, + zPostWorkspacesCurrentHumanInputV2NodeDataMigrationResponse, zPostWorkspacesCurrentMembersByMemberIdOwnerTransferBody, zPostWorkspacesCurrentMembersByMemberIdOwnerTransferPath, zPostWorkspacesCurrentMembersByMemberIdOwnerTransferResponse, @@ -523,6 +585,18 @@ import { zPutWorkspacesCurrentAgentsByAgentIdSkillsBody, zPutWorkspacesCurrentAgentsByAgentIdSkillsPath, zPutWorkspacesCurrentAgentsByAgentIdSkillsResponse, + zPutWorkspacesCurrentHumanInputV2ChannelsEmailByChannelIdBody, + zPutWorkspacesCurrentHumanInputV2ChannelsEmailByChannelIdPath, + zPutWorkspacesCurrentHumanInputV2ChannelsEmailByChannelIdResponse, + zPutWorkspacesCurrentHumanInputV2ChannelsImByChannelIdBody, + zPutWorkspacesCurrentHumanInputV2ChannelsImByChannelIdPath, + zPutWorkspacesCurrentHumanInputV2ChannelsImByChannelIdResponse, + zPutWorkspacesCurrentHumanInputV2ContactsByContactIdImBindingsBody, + zPutWorkspacesCurrentHumanInputV2ContactsByContactIdImBindingsPath, + zPutWorkspacesCurrentHumanInputV2ContactsByContactIdImBindingsResponse, + zPutWorkspacesCurrentHumanInputV2ContactsByContactIdImOverrideBody, + zPutWorkspacesCurrentHumanInputV2ContactsByContactIdImOverridePath, + zPutWorkspacesCurrentHumanInputV2ContactsByContactIdImOverrideResponse, zPutWorkspacesCurrentMembersByMemberIdUpdateRoleBody, zPutWorkspacesCurrentMembersByMemberIdUpdateRolePath, zPutWorkspacesCurrentMembersByMemberIdUpdateRoleResponse, @@ -1144,7 +1218,642 @@ export const endpoints = { byId, } +export const get12 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getWorkspacesCurrentHumanInputV2ChannelProviders', + path: '/workspaces/current/human-input/v2/channel-providers', + tags: ['console'], + }) + .output(zGetWorkspacesCurrentHumanInputV2ChannelProvidersResponse) + +export const channelProviders = { + get: get12, +} + export const post12 = oc + .route({ + inputStructure: 'detailed', + method: 'POST', + operationId: 'postWorkspacesCurrentHumanInputV2ChannelsEmailTest', + path: '/workspaces/current/human-input/v2/channels/email/test', + tags: ['console'], + }) + .input(z.object({ body: zPostWorkspacesCurrentHumanInputV2ChannelsEmailTestBody })) + .output(zPostWorkspacesCurrentHumanInputV2ChannelsEmailTestResponse) + +export const test = { + post: post12, +} + +export const post13 = oc + .route({ + inputStructure: 'detailed', + method: 'POST', + operationId: 'postWorkspacesCurrentHumanInputV2ChannelsEmailByChannelIdTest', + path: '/workspaces/current/human-input/v2/channels/email/{channel_id}/test', + tags: ['console'], + }) + .input( + z.object({ + body: zPostWorkspacesCurrentHumanInputV2ChannelsEmailByChannelIdTestBody, + params: zPostWorkspacesCurrentHumanInputV2ChannelsEmailByChannelIdTestPath, + }), + ) + .output(zPostWorkspacesCurrentHumanInputV2ChannelsEmailByChannelIdTestResponse) + +export const test2 = { + post: post13, +} + +export const delete4 = oc + .route({ + inputStructure: 'detailed', + method: 'DELETE', + operationId: 'deleteWorkspacesCurrentHumanInputV2ChannelsEmailByChannelId', + path: '/workspaces/current/human-input/v2/channels/email/{channel_id}', + tags: ['console'], + }) + .input( + z.object({ + params: zDeleteWorkspacesCurrentHumanInputV2ChannelsEmailByChannelIdPath, + query: zDeleteWorkspacesCurrentHumanInputV2ChannelsEmailByChannelIdQuery, + }), + ) + .output(zDeleteWorkspacesCurrentHumanInputV2ChannelsEmailByChannelIdResponse) + +export const get13 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getWorkspacesCurrentHumanInputV2ChannelsEmailByChannelId', + path: '/workspaces/current/human-input/v2/channels/email/{channel_id}', + tags: ['console'], + }) + .input(z.object({ params: zGetWorkspacesCurrentHumanInputV2ChannelsEmailByChannelIdPath })) + .output(zGetWorkspacesCurrentHumanInputV2ChannelsEmailByChannelIdResponse) + +export const put2 = oc + .route({ + inputStructure: 'detailed', + method: 'PUT', + operationId: 'putWorkspacesCurrentHumanInputV2ChannelsEmailByChannelId', + path: '/workspaces/current/human-input/v2/channels/email/{channel_id}', + tags: ['console'], + }) + .input( + z.object({ + body: zPutWorkspacesCurrentHumanInputV2ChannelsEmailByChannelIdBody, + params: zPutWorkspacesCurrentHumanInputV2ChannelsEmailByChannelIdPath, + }), + ) + .output(zPutWorkspacesCurrentHumanInputV2ChannelsEmailByChannelIdResponse) + +export const byChannelId = { + delete: delete4, + get: get13, + put: put2, + test: test2, +} + +export const post14 = oc + .route({ + inputStructure: 'detailed', + method: 'POST', + operationId: 'postWorkspacesCurrentHumanInputV2ChannelsEmail', + path: '/workspaces/current/human-input/v2/channels/email', + tags: ['console'], + }) + .input(z.object({ body: zPostWorkspacesCurrentHumanInputV2ChannelsEmailBody })) + .output(zPostWorkspacesCurrentHumanInputV2ChannelsEmailResponse) + +export const email = { + post: post14, + test, + byChannelId, +} + +export const post15 = oc + .route({ + inputStructure: 'detailed', + method: 'POST', + operationId: 'postWorkspacesCurrentHumanInputV2ChannelsImTest', + path: '/workspaces/current/human-input/v2/channels/im/test', + tags: ['console'], + }) + .input(z.object({ body: zPostWorkspacesCurrentHumanInputV2ChannelsImTestBody })) + .output(zPostWorkspacesCurrentHumanInputV2ChannelsImTestResponse) + +export const test3 = { + post: post15, +} + +export const post16 = oc + .route({ + inputStructure: 'detailed', + method: 'POST', + operationId: 'postWorkspacesCurrentHumanInputV2ChannelsImByChannelIdReplacement', + path: '/workspaces/current/human-input/v2/channels/im/{channel_id}/replacement', + tags: ['console'], + }) + .input( + z.object({ + body: zPostWorkspacesCurrentHumanInputV2ChannelsImByChannelIdReplacementBody, + params: zPostWorkspacesCurrentHumanInputV2ChannelsImByChannelIdReplacementPath, + }), + ) + .output(zPostWorkspacesCurrentHumanInputV2ChannelsImByChannelIdReplacementResponse) + +export const replacement = { + post: post16, +} + +export const post17 = oc + .route({ + inputStructure: 'detailed', + method: 'POST', + operationId: 'postWorkspacesCurrentHumanInputV2ChannelsImByChannelIdTest', + path: '/workspaces/current/human-input/v2/channels/im/{channel_id}/test', + tags: ['console'], + }) + .input( + z.object({ + body: zPostWorkspacesCurrentHumanInputV2ChannelsImByChannelIdTestBody, + params: zPostWorkspacesCurrentHumanInputV2ChannelsImByChannelIdTestPath, + }), + ) + .output(zPostWorkspacesCurrentHumanInputV2ChannelsImByChannelIdTestResponse) + +export const test4 = { + post: post17, +} + +export const delete5 = oc + .route({ + inputStructure: 'detailed', + method: 'DELETE', + operationId: 'deleteWorkspacesCurrentHumanInputV2ChannelsImByChannelId', + path: '/workspaces/current/human-input/v2/channels/im/{channel_id}', + tags: ['console'], + }) + .input( + z.object({ + params: zDeleteWorkspacesCurrentHumanInputV2ChannelsImByChannelIdPath, + query: zDeleteWorkspacesCurrentHumanInputV2ChannelsImByChannelIdQuery, + }), + ) + .output(zDeleteWorkspacesCurrentHumanInputV2ChannelsImByChannelIdResponse) + +export const get14 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getWorkspacesCurrentHumanInputV2ChannelsImByChannelId', + path: '/workspaces/current/human-input/v2/channels/im/{channel_id}', + tags: ['console'], + }) + .input(z.object({ params: zGetWorkspacesCurrentHumanInputV2ChannelsImByChannelIdPath })) + .output(zGetWorkspacesCurrentHumanInputV2ChannelsImByChannelIdResponse) + +export const put3 = oc + .route({ + inputStructure: 'detailed', + method: 'PUT', + operationId: 'putWorkspacesCurrentHumanInputV2ChannelsImByChannelId', + path: '/workspaces/current/human-input/v2/channels/im/{channel_id}', + tags: ['console'], + }) + .input( + z.object({ + body: zPutWorkspacesCurrentHumanInputV2ChannelsImByChannelIdBody, + params: zPutWorkspacesCurrentHumanInputV2ChannelsImByChannelIdPath, + }), + ) + .output(zPutWorkspacesCurrentHumanInputV2ChannelsImByChannelIdResponse) + +export const byChannelId2 = { + delete: delete5, + get: get14, + put: put3, + replacement, + test: test4, +} + +export const post18 = oc + .route({ + inputStructure: 'detailed', + method: 'POST', + operationId: 'postWorkspacesCurrentHumanInputV2ChannelsIm', + path: '/workspaces/current/human-input/v2/channels/im', + tags: ['console'], + }) + .input(z.object({ body: zPostWorkspacesCurrentHumanInputV2ChannelsImBody })) + .output(zPostWorkspacesCurrentHumanInputV2ChannelsImResponse) + +export const im = { + post: post18, + test: test3, + byChannelId: byChannelId2, +} + +export const get15 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getWorkspacesCurrentHumanInputV2Channels', + path: '/workspaces/current/human-input/v2/channels', + tags: ['console'], + }) + .output(zGetWorkspacesCurrentHumanInputV2ChannelsResponse) + +export const channels = { + get: get15, + email, + im, +} + +/** + * Resolve Contact IDs persisted in workflow recipient configuration. Contacts unavailable in the current workspace are omitted. + */ +export const get16 = oc + .route({ + description: + 'Resolve Contact IDs persisted in workflow recipient configuration. Contacts unavailable in the current workspace are omitted.', + inputStructure: 'detailed', + method: 'GET', + operationId: 'getWorkspacesCurrentHumanInputV2ContactOptionsBatch', + path: '/workspaces/current/human-input/v2/contact-options/batch', + tags: ['console'], + }) + .input(z.object({ query: zGetWorkspacesCurrentHumanInputV2ContactOptionsBatchQuery })) + .output(zGetWorkspacesCurrentHumanInputV2ContactOptionsBatchResponse) + +export const batch = { + get: get16, +} + +/** + * List editor-safe Contact options for static recipient selection. The projection omits IM bindings and management metadata; unavailable Contacts are omitted. + */ +export const get17 = oc + .route({ + description: + 'List editor-safe Contact options for static recipient selection. The projection omits IM bindings and management metadata; unavailable Contacts are omitted.', + inputStructure: 'detailed', + method: 'GET', + operationId: 'getWorkspacesCurrentHumanInputV2ContactOptions', + path: '/workspaces/current/human-input/v2/contact-options', + tags: ['console'], + }) + .input( + z.object({ query: zGetWorkspacesCurrentHumanInputV2ContactOptionsQuery.optional() }).optional(), + ) + .output(zGetWorkspacesCurrentHumanInputV2ContactOptionsResponse) + +export const contactOptions = { + get: get17, + batch, +} + +/** + * Admin-only batch lookup for Contact management clients. Workflow editors must use the editor-safe contact-options/batch projection. + */ +export const get18 = oc + .route({ + description: + 'Admin-only batch lookup for Contact management clients. Workflow editors must use the editor-safe contact-options/batch projection.', + inputStructure: 'detailed', + method: 'GET', + operationId: 'getWorkspacesCurrentHumanInputV2ContactsBatch', + path: '/workspaces/current/human-input/v2/contacts/batch', + tags: ['console'], + }) + .input(z.object({ query: zGetWorkspacesCurrentHumanInputV2ContactsBatchQuery })) + .output(zGetWorkspacesCurrentHumanInputV2ContactsBatchResponse) + +export const batch2 = { + get: get18, +} + +export const patch3 = oc + .route({ + inputStructure: 'detailed', + method: 'PATCH', + operationId: 'patchWorkspacesCurrentHumanInputV2ContactsExternalByContactId', + path: '/workspaces/current/human-input/v2/contacts/external/{contact_id}', + tags: ['console'], + }) + .input( + z.object({ + body: zPatchWorkspacesCurrentHumanInputV2ContactsExternalByContactIdBody, + params: zPatchWorkspacesCurrentHumanInputV2ContactsExternalByContactIdPath, + }), + ) + .output(zPatchWorkspacesCurrentHumanInputV2ContactsExternalByContactIdResponse) + +export const byContactId = { + patch: patch3, +} + +export const post19 = oc + .route({ + inputStructure: 'detailed', + method: 'POST', + operationId: 'postWorkspacesCurrentHumanInputV2ContactsExternal', + path: '/workspaces/current/human-input/v2/contacts/external', + tags: ['console'], + }) + .input(z.object({ body: zPostWorkspacesCurrentHumanInputV2ContactsExternalBody })) + .output(zPostWorkspacesCurrentHumanInputV2ContactsExternalResponse) + +export const external = { + post: post19, + byContactId, +} + +export const post20 = oc + .route({ + inputStructure: 'detailed', + method: 'POST', + operationId: 'postWorkspacesCurrentHumanInputV2ContactsPlatform', + path: '/workspaces/current/human-input/v2/contacts/platform', + tags: ['console'], + }) + .input(z.object({ body: zPostWorkspacesCurrentHumanInputV2ContactsPlatformBody })) + .output(zPostWorkspacesCurrentHumanInputV2ContactsPlatformResponse) + +export const platform = { + post: post20, +} + +export const post21 = oc + .route({ + inputStructure: 'detailed', + method: 'POST', + operationId: 'postWorkspacesCurrentHumanInputV2ContactsRemove', + path: '/workspaces/current/human-input/v2/contacts/remove', + tags: ['console'], + }) + .input(z.object({ body: zPostWorkspacesCurrentHumanInputV2ContactsRemoveBody })) + .output(zPostWorkspacesCurrentHumanInputV2ContactsRemoveResponse) + +export const remove = { + post: post21, +} + +/** + * Delete the current default IM binding for the contact identified by the path. Workspace IM overrides are not modified. This endpoint is not used for resetting workspace IM override. For that purpose, use WorkspaceContactIMOverrideApi.delete instead. + */ +export const delete6 = oc + .route({ + description: + 'Delete the current default IM binding for the contact identified by the path. Workspace IM overrides are not modified. This endpoint is not used for resetting workspace IM override. For that purpose, use WorkspaceContactIMOverrideApi.delete instead.', + inputStructure: 'detailed', + method: 'DELETE', + operationId: 'deleteWorkspacesCurrentHumanInputV2ContactsByContactIdImBindings', + path: '/workspaces/current/human-input/v2/contacts/{contact_id}/im-bindings', + tags: ['console'], + }) + .input( + z.object({ params: zDeleteWorkspacesCurrentHumanInputV2ContactsByContactIdImBindingsPath }), + ) + .output(zDeleteWorkspacesCurrentHumanInputV2ContactsByContactIdImBindingsResponse) + +/** + * Set a default IM binding for a contact, atomically replacing any default binding for either the contact or the selected identity. Identical assignments preserve their binding ID; changed assignments receive a new binding ID. This endpoint is not used for creating workspace IM override. For that purpose, use WorkspaceContactIMOverrideApi.put instead. + */ +export const put4 = oc + .route({ + description: + 'Set a default IM binding for a contact, atomically replacing any default binding for either the contact or the selected identity. Identical assignments preserve their binding ID; changed assignments receive a new binding ID. This endpoint is not used for creating workspace IM override. For that purpose, use WorkspaceContactIMOverrideApi.put instead.', + inputStructure: 'detailed', + method: 'PUT', + operationId: 'putWorkspacesCurrentHumanInputV2ContactsByContactIdImBindings', + path: '/workspaces/current/human-input/v2/contacts/{contact_id}/im-bindings', + tags: ['console'], + }) + .input( + z.object({ + body: zPutWorkspacesCurrentHumanInputV2ContactsByContactIdImBindingsBody, + params: zPutWorkspacesCurrentHumanInputV2ContactsByContactIdImBindingsPath, + }), + ) + .output(zPutWorkspacesCurrentHumanInputV2ContactsByContactIdImBindingsResponse) + +export const imBindings = { + delete: delete6, + put: put4, +} + +/** + * Reset the IM override for a contact. This endpoint is used to clear the IM identity override for a contact in the workspace. + */ +export const delete7 = oc + .route({ + description: + 'Reset the IM override for a contact. This endpoint is used to clear the IM identity override for a contact in the workspace.', + inputStructure: 'detailed', + method: 'DELETE', + operationId: 'deleteWorkspacesCurrentHumanInputV2ContactsByContactIdImOverride', + path: '/workspaces/current/human-input/v2/contacts/{contact_id}/im-override', + tags: ['console'], + }) + .input( + z.object({ params: zDeleteWorkspacesCurrentHumanInputV2ContactsByContactIdImOverridePath }), + ) + .output(zDeleteWorkspacesCurrentHumanInputV2ContactsByContactIdImOverrideResponse) + +/** + * Set or reset the IM override for a contact. This endpoint is used to override the IM identity for a contact in the workspace. + */ +export const put5 = oc + .route({ + description: + 'Set or reset the IM override for a contact. This endpoint is used to override the IM identity for a contact in the workspace.', + inputStructure: 'detailed', + method: 'PUT', + operationId: 'putWorkspacesCurrentHumanInputV2ContactsByContactIdImOverride', + path: '/workspaces/current/human-input/v2/contacts/{contact_id}/im-override', + tags: ['console'], + }) + .input( + z.object({ + body: zPutWorkspacesCurrentHumanInputV2ContactsByContactIdImOverrideBody, + params: zPutWorkspacesCurrentHumanInputV2ContactsByContactIdImOverridePath, + }), + ) + .output(zPutWorkspacesCurrentHumanInputV2ContactsByContactIdImOverrideResponse) + +export const imOverride = { + delete: delete7, + put: put5, +} + +export const get19 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getWorkspacesCurrentHumanInputV2ContactsByContactId', + path: '/workspaces/current/human-input/v2/contacts/{contact_id}', + tags: ['console'], + }) + .input(z.object({ params: zGetWorkspacesCurrentHumanInputV2ContactsByContactIdPath })) + .output(zGetWorkspacesCurrentHumanInputV2ContactsByContactIdResponse) + +export const byContactId2 = { + get: get19, + imBindings, + imOverride, +} + +export const get20 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getWorkspacesCurrentHumanInputV2Contacts', + path: '/workspaces/current/human-input/v2/contacts', + tags: ['console'], + }) + .input(z.object({ query: zGetWorkspacesCurrentHumanInputV2ContactsQuery.optional() }).optional()) + .output(zGetWorkspacesCurrentHumanInputV2ContactsResponse) + +export const contacts = { + get: get20, + batch: batch2, + external, + platform, + remove, + byContactId: byContactId2, +} + +export const get21 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getWorkspacesCurrentHumanInputV2ImIdentities', + path: '/workspaces/current/human-input/v2/im-identities', + tags: ['console'], + }) + .input( + z.object({ query: zGetWorkspacesCurrentHumanInputV2ImIdentitiesQuery.optional() }).optional(), + ) + .output(zGetWorkspacesCurrentHumanInputV2ImIdentitiesResponse) + +export const imIdentities = { + get: get21, +} + +/** + * Return one required result bucket from the latest IM sync run using page and limit pagination. There is no all filter; the response contains page, limit, and total metadata without a run summary. + */ +export const get22 = oc + .route({ + description: + 'Return one required result bucket from the latest IM sync run using page and limit pagination. There is no all filter; the response contains page, limit, and total metadata without a run summary.', + inputStructure: 'detailed', + method: 'GET', + operationId: 'getWorkspacesCurrentHumanInputV2ImSyncRunsLatestResults', + path: '/workspaces/current/human-input/v2/im-sync-runs/latest/results', + tags: ['console'], + }) + .input(z.object({ query: zGetWorkspacesCurrentHumanInputV2ImSyncRunsLatestResultsQuery })) + .output(zGetWorkspacesCurrentHumanInputV2ImSyncRunsLatestResultsResponse) + +export const results = { + get: get22, +} + +/** + * Return the latest IM sync run summary. The UI uses finished_at as the explicit sync time; the response does not include started_by. + */ +export const get23 = oc + .route({ + description: + 'Return the latest IM sync run summary. The UI uses finished_at as the explicit sync time; the response does not include started_by.', + inputStructure: 'detailed', + method: 'GET', + operationId: 'getWorkspacesCurrentHumanInputV2ImSyncRunsLatest', + path: '/workspaces/current/human-input/v2/im-sync-runs/latest', + tags: ['console'], + }) + .output(zGetWorkspacesCurrentHumanInputV2ImSyncRunsLatestResponse) + +export const latest = { + get: get23, + results, +} + +export const post22 = oc + .route({ + inputStructure: 'detailed', + method: 'POST', + operationId: 'postWorkspacesCurrentHumanInputV2ImSyncRuns', + path: '/workspaces/current/human-input/v2/im-sync-runs', + tags: ['console'], + }) + .output(zPostWorkspacesCurrentHumanInputV2ImSyncRunsResponse) + +export const imSyncRuns = { + post: post22, + latest, +} + +/** + * Migrate node data from HITLv1 to HITLv2. A missing legacy version defaults to "1"; any other explicit version is rejected. Every legacy Email recipient becomes onetime_email, while whole_workspace becomes all_workspace_contacts. This side-effect-free endpoint only returns migrated node data; the caller owns applying it to the Draft workflow. + */ +export const post23 = oc + .route({ + description: + 'Migrate node data from HITLv1 to HITLv2. A missing legacy version defaults to "1"; any other explicit version is rejected. Every legacy Email recipient becomes onetime_email, while whole_workspace becomes all_workspace_contacts. This side-effect-free endpoint only returns migrated node data; the caller owns applying it to the Draft workflow.', + inputStructure: 'detailed', + method: 'POST', + operationId: 'postWorkspacesCurrentHumanInputV2NodeDataMigration', + path: '/workspaces/current/human-input/v2/node-data-migration', + tags: ['console'], + }) + .input(z.object({ body: zPostWorkspacesCurrentHumanInputV2NodeDataMigrationBody })) + .output(zPostWorkspacesCurrentHumanInputV2NodeDataMigrationResponse) + +export const nodeDataMigration = { + post: post23, +} + +export const get24 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getWorkspacesCurrentHumanInputV2OrganizationCandidates', + path: '/workspaces/current/human-input/v2/organization-candidates', + tags: ['console'], + }) + .input( + z + .object({ query: zGetWorkspacesCurrentHumanInputV2OrganizationCandidatesQuery.optional() }) + .optional(), + ) + .output(zGetWorkspacesCurrentHumanInputV2OrganizationCandidatesResponse) + +export const organizationCandidates = { + get: get24, +} + +export const v2 = { + channelProviders, + channels, + contactOptions, + contacts, + imIdentities, + imSyncRuns, + nodeDataMigration, + organizationCandidates, +} + +export const humanInput = { + v2, +} + +export const post24 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -1161,10 +1870,10 @@ export const post12 = oc }) export const inviteEmail = { - post: post12, + post: post24, } -export const post13 = oc +export const post25 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -1183,10 +1892,10 @@ export const post13 = oc }) export const ownerTransferCheck = { - post: post13, + post: post25, } -export const post14 = oc +export const post26 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -1203,10 +1912,10 @@ export const post14 = oc }) export const sendOwnerTransferConfirmEmail = { - post: post14, + post: post26, } -export const post15 = oc +export const post27 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -1230,10 +1939,10 @@ export const post15 = oc }) export const ownerTransfer = { - post: post15, + post: post27, } -export const put2 = oc +export const put6 = oc .route({ inputStructure: 'detailed', method: 'PUT', @@ -1257,10 +1966,10 @@ export const put2 = oc }) export const updateRole = { - put: put2, + put: put6, } -export const delete4 = oc +export const delete8 = oc .route({ inputStructure: 'detailed', method: 'DELETE', @@ -1277,12 +1986,12 @@ export const delete4 = oc }) export const byMemberId = { - delete: delete4, + delete: delete8, ownerTransfer, updateRole, } -export const get12 = oc +export const get25 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -1294,14 +2003,14 @@ export const get12 = oc .output(zGetWorkspacesCurrentMembersResponse) export const members = { - get: get12, + get: get25, inviteEmail, ownerTransferCheck, sendOwnerTransferConfirmEmail, byMemberId, } -export const get13 = oc +export const get26 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -1312,10 +2021,10 @@ export const get13 = oc .output(zGetWorkspacesCurrentModelProvidersCreditsResponse) export const credits = { - get: get13, + get: get26, } -export const get14 = oc +export const get27 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -1326,13 +2035,13 @@ export const get14 = oc .output(zGetWorkspacesCurrentModelProvidersSummaryResponse) export const summary = { - get: get14, + get: get27, } /** * @deprecated */ -export const get15 = oc +export const get28 = oc .route({ deprecated: true, inputStructure: 'detailed', @@ -1345,10 +2054,10 @@ export const get15 = oc .output(zGetWorkspacesCurrentModelProvidersByProviderCheckoutUrlResponse) export const checkoutUrl = { - get: get15, + get: get28, } -export const post16 = oc +export const post28 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -1365,10 +2074,10 @@ export const post16 = oc .output(zPostWorkspacesCurrentModelProvidersByProviderCredentialsSwitchResponse) export const switch_ = { - post: post16, + post: post28, } -export const post17 = oc +export const post29 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -1385,10 +2094,10 @@ export const post17 = oc .output(zPostWorkspacesCurrentModelProvidersByProviderCredentialsValidateResponse) export const validate = { - post: post17, + post: post29, } -export const delete5 = oc +export const delete9 = oc .route({ inputStructure: 'detailed', method: 'DELETE', @@ -1405,7 +2114,7 @@ export const delete5 = oc ) .output(zDeleteWorkspacesCurrentModelProvidersByProviderCredentialsResponse) -export const get16 = oc +export const get29 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -1421,7 +2130,7 @@ export const get16 = oc ) .output(zGetWorkspacesCurrentModelProvidersByProviderCredentialsResponse) -export const post18 = oc +export const post30 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -1438,7 +2147,7 @@ export const post18 = oc ) .output(zPostWorkspacesCurrentModelProvidersByProviderCredentialsResponse) -export const put3 = oc +export const put7 = oc .route({ inputStructure: 'detailed', method: 'PUT', @@ -1455,15 +2164,15 @@ export const put3 = oc .output(zPutWorkspacesCurrentModelProvidersByProviderCredentialsResponse) export const credentials = { - delete: delete5, - get: get16, - post: post18, - put: put3, + delete: delete9, + get: get29, + post: post30, + put: put7, switch: switch_, validate, } -export const post19 = oc +export const post31 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -1480,10 +2189,10 @@ export const post19 = oc .output(zPostWorkspacesCurrentModelProvidersByProviderModelsCredentialsSwitchResponse) export const switch2 = { - post: post19, + post: post31, } -export const post20 = oc +export const post32 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -1500,10 +2209,10 @@ export const post20 = oc .output(zPostWorkspacesCurrentModelProvidersByProviderModelsCredentialsValidateResponse) export const validate2 = { - post: post20, + post: post32, } -export const delete6 = oc +export const delete10 = oc .route({ inputStructure: 'detailed', method: 'DELETE', @@ -1520,7 +2229,7 @@ export const delete6 = oc ) .output(zDeleteWorkspacesCurrentModelProvidersByProviderModelsCredentialsResponse) -export const get17 = oc +export const get30 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -1536,7 +2245,7 @@ export const get17 = oc ) .output(zGetWorkspacesCurrentModelProvidersByProviderModelsCredentialsResponse) -export const post21 = oc +export const post33 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -1553,7 +2262,7 @@ export const post21 = oc ) .output(zPostWorkspacesCurrentModelProvidersByProviderModelsCredentialsResponse) -export const put4 = oc +export const put8 = oc .route({ inputStructure: 'detailed', method: 'PUT', @@ -1570,15 +2279,15 @@ export const put4 = oc .output(zPutWorkspacesCurrentModelProvidersByProviderModelsCredentialsResponse) export const credentials2 = { - delete: delete6, - get: get17, - post: post21, - put: put4, + delete: delete10, + get: get30, + post: post33, + put: put8, switch: switch2, validate: validate2, } -export const patch3 = oc +export const patch4 = oc .route({ inputStructure: 'detailed', method: 'PATCH', @@ -1595,10 +2304,10 @@ export const patch3 = oc .output(zPatchWorkspacesCurrentModelProvidersByProviderModelsDisableResponse) export const disable2 = { - patch: patch3, + patch: patch4, } -export const patch4 = oc +export const patch5 = oc .route({ inputStructure: 'detailed', method: 'PATCH', @@ -1615,10 +2324,10 @@ export const patch4 = oc .output(zPatchWorkspacesCurrentModelProvidersByProviderModelsEnableResponse) export const enable2 = { - patch: patch4, + patch: patch5, } -export const post22 = oc +export const post34 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -1639,10 +2348,10 @@ export const post22 = oc ) export const credentialsValidate = { - post: post22, + post: post34, } -export const post23 = oc +export const post35 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -1663,7 +2372,7 @@ export const post23 = oc ) export const credentialsValidate2 = { - post: post23, + post: post35, } export const byConfigId = { @@ -1675,7 +2384,7 @@ export const loadBalancingConfigs = { byConfigId, } -export const get18 = oc +export const get31 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -1692,10 +2401,10 @@ export const get18 = oc .output(zGetWorkspacesCurrentModelProvidersByProviderModelsParameterRulesResponse) export const parameterRules = { - get: get18, + get: get31, } -export const delete7 = oc +export const delete11 = oc .route({ inputStructure: 'detailed', method: 'DELETE', @@ -1712,7 +2421,7 @@ export const delete7 = oc ) .output(zDeleteWorkspacesCurrentModelProvidersByProviderModelsResponse) -export const get19 = oc +export const get32 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -1723,7 +2432,7 @@ export const get19 = oc .input(z.object({ params: zGetWorkspacesCurrentModelProvidersByProviderModelsPath })) .output(zGetWorkspacesCurrentModelProvidersByProviderModelsResponse) -export const post24 = oc +export const post36 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -1740,9 +2449,9 @@ export const post24 = oc .output(zPostWorkspacesCurrentModelProvidersByProviderModelsResponse) export const models = { - delete: delete7, - get: get19, - post: post24, + delete: delete11, + get: get32, + post: post36, credentials: credentials2, disable: disable2, enable: enable2, @@ -1750,7 +2459,7 @@ export const models = { parameterRules, } -export const post25 = oc +export const post37 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -1767,7 +2476,7 @@ export const post25 = oc .output(zPostWorkspacesCurrentModelProvidersByProviderPreferredProviderTypeResponse) export const preferredProviderType = { - post: post25, + post: post37, } export const byProvider = { @@ -1777,7 +2486,7 @@ export const byProvider = { preferredProviderType, } -export const get20 = oc +export const get33 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -1789,13 +2498,13 @@ export const get20 = oc .output(zGetWorkspacesCurrentModelProvidersResponse) export const modelProviders = { - get: get20, + get: get33, credits, summary, byProvider, } -export const get21 = oc +export const get34 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -1807,7 +2516,7 @@ export const get21 = oc .output(zGetWorkspacesCurrentModelsModelTypesByModelTypeResponse) export const byModelType = { - get: get21, + get: get34, } export const modelTypes = { @@ -1818,7 +2527,7 @@ export const models2 = { modelTypes, } -export const get22 = oc +export const get35 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -1829,10 +2538,10 @@ export const get22 = oc .output(zGetWorkspacesCurrentNetworkAccessGroupsCurrentIpResponse) export const currentIp = { - get: get22, + get: get35, } -export const get23 = oc +export const get36 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -1844,10 +2553,10 @@ export const get23 = oc .output(zGetWorkspacesCurrentNetworkAccessGroupsByGroupIdCheckCurrentIpResponse) export const checkCurrentIp = { - get: get23, + get: get36, } -export const delete8 = oc +export const delete12 = oc .route({ inputStructure: 'detailed', method: 'DELETE', @@ -1863,7 +2572,7 @@ export const delete8 = oc ) .output(zDeleteWorkspacesCurrentNetworkAccessGroupsByGroupIdResponse) -export const get24 = oc +export const get37 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -1874,7 +2583,7 @@ export const get24 = oc .input(z.object({ params: zGetWorkspacesCurrentNetworkAccessGroupsByGroupIdPath })) .output(zGetWorkspacesCurrentNetworkAccessGroupsByGroupIdResponse) -export const put5 = oc +export const put9 = oc .route({ inputStructure: 'detailed', method: 'PUT', @@ -1891,13 +2600,13 @@ export const put5 = oc .output(zPutWorkspacesCurrentNetworkAccessGroupsByGroupIdResponse) export const byGroupId = { - delete: delete8, - get: get24, - put: put5, + delete: delete12, + get: get37, + put: put9, checkCurrentIp, } -export const get25 = oc +export const get38 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -1907,7 +2616,7 @@ export const get25 = oc }) .output(zGetWorkspacesCurrentNetworkAccessGroupsResponse) -export const post26 = oc +export const post38 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -1920,13 +2629,13 @@ export const post26 = oc .output(zPostWorkspacesCurrentNetworkAccessGroupsResponse) export const networkAccessGroups = { - get: get25, - post: post26, + get: get38, + post: post38, currentIp, byGroupId, } -export const get26 = oc +export const get39 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -1937,10 +2646,10 @@ export const get26 = oc .output(zGetWorkspacesCurrentPermissionResponse) export const permission = { - get: get26, + get: get39, } -export const get27 = oc +export const get40 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -1953,10 +2662,10 @@ export const get27 = oc .errors({ plugin_error: { status: 400, data: zPluginError } }) export const asset = { - get: get27, + get: get40, } -export const post27 = oc +export const post39 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -1968,10 +2677,10 @@ export const post27 = oc .output(zPostWorkspacesCurrentPluginAutoUpgradeChangeResponse) export const change = { - post: post27, + post: post39, } -export const post28 = oc +export const post40 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -1983,10 +2692,10 @@ export const post28 = oc .output(zPostWorkspacesCurrentPluginAutoUpgradeExcludeResponse) export const exclude = { - post: post28, + post: post40, } -export const get28 = oc +export const get41 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -1998,7 +2707,7 @@ export const get28 = oc .output(zGetWorkspacesCurrentPluginAutoUpgradeFetchResponse) export const fetch_ = { - get: get28, + get: get41, } export const autoUpgrade = { @@ -2007,7 +2716,7 @@ export const autoUpgrade = { fetch: fetch_, } -export const get29 = oc +export const get42 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2019,10 +2728,10 @@ export const get29 = oc .errors({ plugin_error: { status: 400, data: zPluginError } }) export const debuggingKey = { - get: get29, + get: get42, } -export const get30 = oc +export const get43 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2035,10 +2744,10 @@ export const get30 = oc .errors({ plugin_error: { status: 400, data: zPluginError } }) export const fetchManifest = { - get: get30, + get: get43, } -export const get31 = oc +export const get44 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2051,10 +2760,10 @@ export const get31 = oc .errors({ plugin_error: { status: 400, data: zPluginError } }) export const icon = { - get: get31, + get: get44, } -export const post29 = oc +export const post41 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -2067,10 +2776,10 @@ export const post29 = oc .errors({ plugin_error: { status: 400, data: zPluginError } }) export const github = { - post: post29, + post: post41, } -export const post30 = oc +export const post42 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -2083,10 +2792,10 @@ export const post30 = oc .errors({ plugin_error: { status: 400, data: zPluginError } }) export const marketplace = { - post: post30, + post: post42, } -export const post31 = oc +export const post43 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -2099,7 +2808,7 @@ export const post31 = oc .errors({ plugin_error: { status: 400, data: zPluginError } }) export const pkg = { - post: post31, + post: post43, } export const install = { @@ -2108,7 +2817,7 @@ export const install = { pkg, } -export const get32 = oc +export const get45 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2121,10 +2830,10 @@ export const get32 = oc .errors({ plugin_error: { status: 400, data: zPluginError } }) export const installedIds = { - get: get32, + get: get45, } -export const post32 = oc +export const post44 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -2137,14 +2846,14 @@ export const post32 = oc .errors({ plugin_error: { status: 400, data: zPluginError } }) export const ids = { - post: post32, + post: post44, } export const installations = { ids, } -export const post33 = oc +export const post45 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -2157,10 +2866,10 @@ export const post33 = oc .errors({ plugin_error: { status: 400, data: zPluginError } }) export const latestVersions = { - post: post33, + post: post45, } -export const get33 = oc +export const get46 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2173,12 +2882,12 @@ export const get33 = oc .errors({ plugin_error: { status: 400, data: zPluginError } }) export const list2 = { - get: get33, + get: get46, installations, latestVersions, } -export const get34 = oc +export const get47 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2191,14 +2900,14 @@ export const get34 = oc .errors({ plugin_error: { status: 400, data: zPluginError } }) export const pkg2 = { - get: get34, + get: get47, } export const marketplace2 = { pkg: pkg2, } -export const get35 = oc +export const get48 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2211,13 +2920,13 @@ export const get35 = oc .errors({ plugin_error: { status: 400, data: zPluginError } }) export const dynamicOptions = { - get: get35, + get: get48, } /** * Fetch dynamic options using credentials directly (for edit mode) */ -export const post34 = oc +export const post46 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -2233,7 +2942,7 @@ export const post34 = oc .errors({ plugin_error: { status: 400, data: zPluginError } }) export const dynamicOptionsWithCredentials = { - post: post34, + post: post46, } export const parameters = { @@ -2241,7 +2950,7 @@ export const parameters = { dynamicOptionsWithCredentials, } -export const post35 = oc +export const post47 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -2253,10 +2962,10 @@ export const post35 = oc .output(zPostWorkspacesCurrentPluginPermissionChangeResponse) export const change2 = { - post: post35, + post: post47, } -export const get36 = oc +export const get49 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2267,7 +2976,7 @@ export const get36 = oc .output(zGetWorkspacesCurrentPluginPermissionFetchResponse) export const fetch2 = { - get: get36, + get: get49, } export const permission2 = { @@ -2275,7 +2984,7 @@ export const permission2 = { fetch: fetch2, } -export const get37 = oc +export const get50 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2287,10 +2996,10 @@ export const get37 = oc .output(zGetWorkspacesCurrentPluginReadmeResponse) export const readme = { - get: get37, + get: get50, } -export const post36 = oc +export const post48 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -2302,10 +3011,10 @@ export const post36 = oc .errors({ plugin_error: { status: 400, data: zPluginError } }) export const deleteAll = { - post: post36, + post: post48, } -export const post37 = oc +export const post49 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -2318,10 +3027,10 @@ export const post37 = oc .errors({ plugin_error: { status: 400, data: zPluginError } }) export const byIdentifier = { - post: post37, + post: post49, } -export const post38 = oc +export const post50 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -2333,12 +3042,12 @@ export const post38 = oc .output(zPostWorkspacesCurrentPluginTasksByTaskIdDeleteResponse) .errors({ plugin_error: { status: 400, data: zPluginError } }) -export const delete9 = { - post: post38, +export const delete13 = { + post: post50, byIdentifier, } -export const get38 = oc +export const get51 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2351,11 +3060,11 @@ export const get38 = oc .errors({ plugin_error: { status: 400, data: zPluginError } }) export const byTaskId = { - get: get38, - delete: delete9, + get: get51, + delete: delete13, } -export const get39 = oc +export const get52 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2368,12 +3077,12 @@ export const get39 = oc .errors({ plugin_error: { status: 400, data: zPluginError } }) export const tasks = { - get: get39, + get: get52, deleteAll, byTaskId, } -export const post39 = oc +export const post51 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -2386,10 +3095,10 @@ export const post39 = oc .errors({ plugin_error: { status: 400, data: zPluginError } }) export const uninstall = { - post: post39, + post: post51, } -export const post40 = oc +export const post52 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -2402,10 +3111,10 @@ export const post40 = oc .errors({ plugin_error: { status: 400, data: zPluginError } }) export const github2 = { - post: post40, + post: post52, } -export const post41 = oc +export const post53 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -2418,7 +3127,7 @@ export const post41 = oc .errors({ plugin_error: { status: 400, data: zPluginError } }) export const marketplace3 = { - post: post41, + post: post53, } export const upgrade = { @@ -2426,7 +3135,7 @@ export const upgrade = { marketplace: marketplace3, } -export const post42 = oc +export const post54 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -2437,10 +3146,10 @@ export const post42 = oc .output(zPostWorkspacesCurrentPluginUploadBundleResponse) export const bundle = { - post: post42, + post: post54, } -export const post43 = oc +export const post55 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -2453,10 +3162,10 @@ export const post43 = oc .errors({ plugin_error: { status: 400, data: zPluginError } }) export const github3 = { - post: post43, + post: post55, } -export const post44 = oc +export const post56 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -2469,7 +3178,7 @@ export const post44 = oc .errors({ plugin_error: { status: 400, data: zPluginError } }) export const pkg3 = { - post: post44, + post: post56, } export const upload = { @@ -2478,7 +3187,7 @@ export const upload = { pkg: pkg3, } -export const get40 = oc +export const get53 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2496,7 +3205,7 @@ export const get40 = oc .errors({ plugin_error: { status: 400, data: zPluginError } }) export const list3 = { - get: get40, + get: get53, } export const byCategory = { @@ -2523,7 +3232,7 @@ export const plugin2 = { byCategory, } -export const post45 = oc +export const post57 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -2536,10 +3245,10 @@ export const post45 = oc .output(zPostWorkspacesCurrentRbacAccessPoliciesByPolicyIdCopyResponse) export const copy = { - post: post45, + post: post57, } -export const delete10 = oc +export const delete14 = oc .route({ inputStructure: 'detailed', method: 'DELETE', @@ -2550,7 +3259,7 @@ export const delete10 = oc .input(z.object({ params: zDeleteWorkspacesCurrentRbacAccessPoliciesByPolicyIdPath })) .output(zDeleteWorkspacesCurrentRbacAccessPoliciesByPolicyIdResponse) -export const get41 = oc +export const get54 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2561,7 +3270,7 @@ export const get41 = oc .input(z.object({ params: zGetWorkspacesCurrentRbacAccessPoliciesByPolicyIdPath })) .output(zGetWorkspacesCurrentRbacAccessPoliciesByPolicyIdResponse) -export const put6 = oc +export const put10 = oc .route({ inputStructure: 'detailed', method: 'PUT', @@ -2578,13 +3287,13 @@ export const put6 = oc .output(zPutWorkspacesCurrentRbacAccessPoliciesByPolicyIdResponse) export const byPolicyId = { - delete: delete10, - get: get41, - put: put6, + delete: delete14, + get: get54, + put: put10, copy, } -export const get42 = oc +export const get55 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2594,7 +3303,7 @@ export const get42 = oc }) .output(zGetWorkspacesCurrentRbacAccessPoliciesResponse) -export const post46 = oc +export const post58 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -2607,12 +3316,12 @@ export const post46 = oc .output(zPostWorkspacesCurrentRbacAccessPoliciesResponse) export const accessPolicies = { - get: get42, - post: post46, + get: get55, + post: post58, byPolicyId, } -export const put7 = oc +export const put11 = oc .route({ inputStructure: 'detailed', method: 'PUT', @@ -2624,10 +3333,10 @@ export const put7 = oc .output(zPutWorkspacesCurrentRbacAccessPolicyBindingsByBindingIdLockResponse) export const lock = { - put: put7, + put: put11, } -export const put8 = oc +export const put12 = oc .route({ inputStructure: 'detailed', method: 'PUT', @@ -2639,7 +3348,7 @@ export const put8 = oc .output(zPutWorkspacesCurrentRbacAccessPolicyBindingsByBindingIdUnlockResponse) export const unlock = { - put: put8, + put: put12, } export const byBindingId = { @@ -2651,7 +3360,7 @@ export const accessPolicyBindings = { byBindingId, } -export const delete11 = oc +export const delete15 = oc .route({ inputStructure: 'detailed', method: 'DELETE', @@ -2667,7 +3376,7 @@ export const delete11 = oc ) .output(zDeleteWorkspacesCurrentRbacAgentsByAgentIdAccessPoliciesByPolicyIdMemberBindingsResponse) -export const get43 = oc +export const get56 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2683,11 +3392,11 @@ export const get43 = oc .output(zGetWorkspacesCurrentRbacAgentsByAgentIdAccessPoliciesByPolicyIdMemberBindingsResponse) export const memberBindings = { - delete: delete11, - get: get43, + delete: delete15, + get: get56, } -export const get44 = oc +export const get57 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2703,7 +3412,7 @@ export const get44 = oc .output(zGetWorkspacesCurrentRbacAgentsByAgentIdAccessPoliciesByPolicyIdRoleBindingsResponse) export const roleBindings = { - get: get44, + get: get57, } export const byPolicyId2 = { @@ -2715,7 +3424,7 @@ export const accessPolicies2 = { byPolicyId: byPolicyId2, } -export const get45 = oc +export const get58 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2732,10 +3441,10 @@ export const get45 = oc .output(zGetWorkspacesCurrentRbacAgentsByAgentIdAccessPolicyResponse) export const accessPolicy = { - get: get45, + get: get58, } -export const get46 = oc +export const get59 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2752,10 +3461,10 @@ export const get46 = oc .output(zGetWorkspacesCurrentRbacAgentsByAgentIdUserAccessPoliciesResponse) export const userAccessPolicies = { - get: get46, + get: get59, } -export const put9 = oc +export const put13 = oc .route({ inputStructure: 'detailed', method: 'PUT', @@ -2772,7 +3481,7 @@ export const put9 = oc .output(zPutWorkspacesCurrentRbacAgentsByAgentIdUsersByTargetAccountIdAccessPoliciesResponse) export const accessPolicies3 = { - put: put9, + put: put13, } export const byTargetAccountId = { @@ -2783,7 +3492,7 @@ export const users = { byTargetAccountId, } -export const get47 = oc +export const get60 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2794,7 +3503,7 @@ export const get47 = oc .input(z.object({ params: zGetWorkspacesCurrentRbacAgentsByAgentIdWhitelistPath })) .output(zGetWorkspacesCurrentRbacAgentsByAgentIdWhitelistResponse) -export const put10 = oc +export const put14 = oc .route({ inputStructure: 'detailed', method: 'PUT', @@ -2811,11 +3520,11 @@ export const put10 = oc .output(zPutWorkspacesCurrentRbacAgentsByAgentIdWhitelistResponse) export const whitelist = { - get: get47, - put: put10, + get: get60, + put: put14, } -export const get48 = oc +export const get61 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2827,7 +3536,7 @@ export const get48 = oc .output(zGetWorkspacesCurrentRbacAgentsByAgentIdWhitelistConfigResponse) export const whitelistConfig = { - get: get48, + get: get61, } export const byAgentId2 = { @@ -2843,7 +3552,7 @@ export const agents2 = { byAgentId: byAgentId2, } -export const delete12 = oc +export const delete16 = oc .route({ inputStructure: 'detailed', method: 'DELETE', @@ -2859,7 +3568,7 @@ export const delete12 = oc ) .output(zDeleteWorkspacesCurrentRbacAppsByAppIdAccessPoliciesByPolicyIdMemberBindingsResponse) -export const get49 = oc +export const get62 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2875,11 +3584,11 @@ export const get49 = oc .output(zGetWorkspacesCurrentRbacAppsByAppIdAccessPoliciesByPolicyIdMemberBindingsResponse) export const memberBindings2 = { - delete: delete12, - get: get49, + delete: delete16, + get: get62, } -export const get50 = oc +export const get63 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2895,7 +3604,7 @@ export const get50 = oc .output(zGetWorkspacesCurrentRbacAppsByAppIdAccessPoliciesByPolicyIdRoleBindingsResponse) export const roleBindings2 = { - get: get50, + get: get63, } export const byPolicyId3 = { @@ -2907,7 +3616,7 @@ export const accessPolicies4 = { byPolicyId: byPolicyId3, } -export const get51 = oc +export const get64 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2924,10 +3633,10 @@ export const get51 = oc .output(zGetWorkspacesCurrentRbacAppsByAppIdAccessPolicyResponse) export const accessPolicy2 = { - get: get51, + get: get64, } -export const get52 = oc +export const get65 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2944,10 +3653,10 @@ export const get52 = oc .output(zGetWorkspacesCurrentRbacAppsByAppIdUserAccessPoliciesResponse) export const userAccessPolicies2 = { - get: get52, + get: get65, } -export const put11 = oc +export const put15 = oc .route({ inputStructure: 'detailed', method: 'PUT', @@ -2964,7 +3673,7 @@ export const put11 = oc .output(zPutWorkspacesCurrentRbacAppsByAppIdUsersByTargetAccountIdAccessPoliciesResponse) export const accessPolicies5 = { - put: put11, + put: put15, } export const byTargetAccountId2 = { @@ -2975,7 +3684,7 @@ export const users2 = { byTargetAccountId: byTargetAccountId2, } -export const get53 = oc +export const get66 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2986,7 +3695,7 @@ export const get53 = oc .input(z.object({ params: zGetWorkspacesCurrentRbacAppsByAppIdWhitelistPath })) .output(zGetWorkspacesCurrentRbacAppsByAppIdWhitelistResponse) -export const put12 = oc +export const put16 = oc .route({ inputStructure: 'detailed', method: 'PUT', @@ -3003,11 +3712,11 @@ export const put12 = oc .output(zPutWorkspacesCurrentRbacAppsByAppIdWhitelistResponse) export const whitelist2 = { - get: get53, - put: put12, + get: get66, + put: put16, } -export const get54 = oc +export const get67 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3019,7 +3728,7 @@ export const get54 = oc .output(zGetWorkspacesCurrentRbacAppsByAppIdWhitelistConfigResponse) export const whitelistConfig2 = { - get: get54, + get: get67, } export const byAppId = { @@ -3035,7 +3744,7 @@ export const apps = { byAppId, } -export const delete13 = oc +export const delete17 = oc .route({ inputStructure: 'detailed', method: 'DELETE', @@ -3055,7 +3764,7 @@ export const delete13 = oc zDeleteWorkspacesCurrentRbacDatasetsByDatasetIdAccessPoliciesByPolicyIdMemberBindingsResponse, ) -export const get55 = oc +export const get68 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3075,11 +3784,11 @@ export const get55 = oc ) export const memberBindings3 = { - delete: delete13, - get: get55, + delete: delete17, + get: get68, } -export const get56 = oc +export const get69 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3095,7 +3804,7 @@ export const get56 = oc .output(zGetWorkspacesCurrentRbacDatasetsByDatasetIdAccessPoliciesByPolicyIdRoleBindingsResponse) export const roleBindings3 = { - get: get56, + get: get69, } export const byPolicyId4 = { @@ -3107,7 +3816,7 @@ export const accessPolicies6 = { byPolicyId: byPolicyId4, } -export const get57 = oc +export const get70 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3124,10 +3833,10 @@ export const get57 = oc .output(zGetWorkspacesCurrentRbacDatasetsByDatasetIdAccessPolicyResponse) export const accessPolicy3 = { - get: get57, + get: get70, } -export const get58 = oc +export const get71 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3144,10 +3853,10 @@ export const get58 = oc .output(zGetWorkspacesCurrentRbacDatasetsByDatasetIdUserAccessPoliciesResponse) export const userAccessPolicies3 = { - get: get58, + get: get71, } -export const put13 = oc +export const put17 = oc .route({ inputStructure: 'detailed', method: 'PUT', @@ -3164,7 +3873,7 @@ export const put13 = oc .output(zPutWorkspacesCurrentRbacDatasetsByDatasetIdUsersByTargetAccountIdAccessPoliciesResponse) export const accessPolicies7 = { - put: put13, + put: put17, } export const byTargetAccountId3 = { @@ -3175,7 +3884,7 @@ export const users3 = { byTargetAccountId: byTargetAccountId3, } -export const get59 = oc +export const get72 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3186,7 +3895,7 @@ export const get59 = oc .input(z.object({ params: zGetWorkspacesCurrentRbacDatasetsByDatasetIdWhitelistPath })) .output(zGetWorkspacesCurrentRbacDatasetsByDatasetIdWhitelistResponse) -export const put14 = oc +export const put18 = oc .route({ inputStructure: 'detailed', method: 'PUT', @@ -3203,11 +3912,11 @@ export const put14 = oc .output(zPutWorkspacesCurrentRbacDatasetsByDatasetIdWhitelistResponse) export const whitelist3 = { - get: get59, - put: put14, + get: get72, + put: put18, } -export const get60 = oc +export const get73 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3219,7 +3928,7 @@ export const get60 = oc .output(zGetWorkspacesCurrentRbacDatasetsByDatasetIdWhitelistConfigResponse) export const whitelistConfig3 = { - get: get60, + get: get73, } export const byDatasetId = { @@ -3235,7 +3944,7 @@ export const datasets = { byDatasetId, } -export const get61 = oc +export const get74 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3251,7 +3960,7 @@ export const get61 = oc ) .output(zGetWorkspacesCurrentRbacMembersByMemberIdRbacRolesResponse) -export const put15 = oc +export const put19 = oc .route({ inputStructure: 'detailed', method: 'PUT', @@ -3268,8 +3977,8 @@ export const put15 = oc .output(zPutWorkspacesCurrentRbacMembersByMemberIdRbacRolesResponse) export const rbacRoles = { - get: get61, - put: put15, + get: get74, + put: put19, } export const byMemberId2 = { @@ -3280,7 +3989,7 @@ export const members2 = { byMemberId: byMemberId2, } -export const get62 = oc +export const get75 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3291,10 +4000,10 @@ export const get62 = oc .output(zGetWorkspacesCurrentRbacMyPermissionsResponse) export const myPermissions = { - get: get62, + get: get75, } -export const get63 = oc +export const get76 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3305,10 +4014,10 @@ export const get63 = oc .output(zGetWorkspacesCurrentRbacRolePermissionsCatalogAgentResponse) export const agent = { - get: get63, + get: get76, } -export const get64 = oc +export const get77 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3319,10 +4028,10 @@ export const get64 = oc .output(zGetWorkspacesCurrentRbacRolePermissionsCatalogAppResponse) export const app = { - get: get64, + get: get77, } -export const get65 = oc +export const get78 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3333,10 +4042,10 @@ export const get65 = oc .output(zGetWorkspacesCurrentRbacRolePermissionsCatalogDatasetResponse) export const dataset = { - get: get65, + get: get78, } -export const get66 = oc +export const get79 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3347,7 +4056,7 @@ export const get66 = oc .output(zGetWorkspacesCurrentRbacRolePermissionsCatalogResponse) export const catalog = { - get: get66, + get: get79, agent, app, dataset, @@ -3357,7 +4066,7 @@ export const rolePermissions = { catalog, } -export const post47 = oc +export const post59 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -3376,10 +4085,10 @@ export const post47 = oc .output(zPostWorkspacesCurrentRbacRolesByRoleIdCopyResponse) export const copy2 = { - post: post47, + post: post59, } -export const get67 = oc +export const get80 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3396,10 +4105,10 @@ export const get67 = oc .output(zGetWorkspacesCurrentRbacRolesByRoleIdMembersResponse) export const members3 = { - get: get67, + get: get80, } -export const delete14 = oc +export const delete18 = oc .route({ inputStructure: 'detailed', method: 'DELETE', @@ -3410,7 +4119,7 @@ export const delete14 = oc .input(z.object({ params: zDeleteWorkspacesCurrentRbacRolesByRoleIdPath })) .output(zDeleteWorkspacesCurrentRbacRolesByRoleIdResponse) -export const get68 = oc +export const get81 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3426,7 +4135,7 @@ export const get68 = oc ) .output(zGetWorkspacesCurrentRbacRolesByRoleIdResponse) -export const put16 = oc +export const put20 = oc .route({ inputStructure: 'detailed', method: 'PUT', @@ -3444,14 +4153,14 @@ export const put16 = oc .output(zPutWorkspacesCurrentRbacRolesByRoleIdResponse) export const byRoleId = { - delete: delete14, - get: get68, - put: put16, + delete: delete18, + get: get81, + put: put20, copy: copy2, members: members3, } -export const get69 = oc +export const get82 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3462,7 +4171,7 @@ export const get69 = oc .input(z.object({ query: zGetWorkspacesCurrentRbacRolesQuery.optional() }).optional()) .output(zGetWorkspacesCurrentRbacRolesResponse) -export const post48 = oc +export const post60 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -3480,12 +4189,12 @@ export const post48 = oc .output(zPostWorkspacesCurrentRbacRolesResponse) export const roles = { - get: get69, - post: post48, + get: get82, + post: post60, byRoleId, } -export const put17 = oc +export const put21 = oc .route({ inputStructure: 'detailed', method: 'PUT', @@ -3502,10 +4211,10 @@ export const put17 = oc .output(zPutWorkspacesCurrentRbacWorkspaceAgentsAccessPoliciesByPolicyIdBindingsResponse) export const bindings = { - put: put17, + put: put21, } -export const get70 = oc +export const get83 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3521,10 +4230,10 @@ export const get70 = oc .output(zGetWorkspacesCurrentRbacWorkspaceAgentsAccessPoliciesByPolicyIdMemberBindingsResponse) export const memberBindings4 = { - get: get70, + get: get83, } -export const get71 = oc +export const get84 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3540,7 +4249,7 @@ export const get71 = oc .output(zGetWorkspacesCurrentRbacWorkspaceAgentsAccessPoliciesByPolicyIdRoleBindingsResponse) export const roleBindings4 = { - get: get71, + get: get84, } export const byPolicyId5 = { @@ -3553,7 +4262,7 @@ export const accessPolicies8 = { byPolicyId: byPolicyId5, } -export const get72 = oc +export const get85 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3569,7 +4278,7 @@ export const get72 = oc .output(zGetWorkspacesCurrentRbacWorkspaceAgentsAccessPolicyResponse) export const accessPolicy4 = { - get: get72, + get: get85, } export const agents3 = { @@ -3577,7 +4286,7 @@ export const agents3 = { accessPolicy: accessPolicy4, } -export const put18 = oc +export const put22 = oc .route({ inputStructure: 'detailed', method: 'PUT', @@ -3594,10 +4303,10 @@ export const put18 = oc .output(zPutWorkspacesCurrentRbacWorkspaceAppsAccessPoliciesByPolicyIdBindingsResponse) export const bindings2 = { - put: put18, + put: put22, } -export const get73 = oc +export const get86 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3613,10 +4322,10 @@ export const get73 = oc .output(zGetWorkspacesCurrentRbacWorkspaceAppsAccessPoliciesByPolicyIdMemberBindingsResponse) export const memberBindings5 = { - get: get73, + get: get86, } -export const get74 = oc +export const get87 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3632,7 +4341,7 @@ export const get74 = oc .output(zGetWorkspacesCurrentRbacWorkspaceAppsAccessPoliciesByPolicyIdRoleBindingsResponse) export const roleBindings5 = { - get: get74, + get: get87, } export const byPolicyId6 = { @@ -3645,7 +4354,7 @@ export const accessPolicies9 = { byPolicyId: byPolicyId6, } -export const get75 = oc +export const get88 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3661,7 +4370,7 @@ export const get75 = oc .output(zGetWorkspacesCurrentRbacWorkspaceAppsAccessPolicyResponse) export const accessPolicy5 = { - get: get75, + get: get88, } export const apps2 = { @@ -3669,7 +4378,7 @@ export const apps2 = { accessPolicy: accessPolicy5, } -export const put19 = oc +export const put23 = oc .route({ inputStructure: 'detailed', method: 'PUT', @@ -3686,10 +4395,10 @@ export const put19 = oc .output(zPutWorkspacesCurrentRbacWorkspaceDatasetsAccessPoliciesByPolicyIdBindingsResponse) export const bindings3 = { - put: put19, + put: put23, } -export const get76 = oc +export const get89 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3705,10 +4414,10 @@ export const get76 = oc .output(zGetWorkspacesCurrentRbacWorkspaceDatasetsAccessPoliciesByPolicyIdMemberBindingsResponse) export const memberBindings6 = { - get: get76, + get: get89, } -export const get77 = oc +export const get90 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3724,7 +4433,7 @@ export const get77 = oc .output(zGetWorkspacesCurrentRbacWorkspaceDatasetsAccessPoliciesByPolicyIdRoleBindingsResponse) export const roleBindings6 = { - get: get77, + get: get90, } export const byPolicyId7 = { @@ -3737,7 +4446,7 @@ export const accessPolicies10 = { byPolicyId: byPolicyId7, } -export const get78 = oc +export const get91 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3753,7 +4462,7 @@ export const get78 = oc .output(zGetWorkspacesCurrentRbacWorkspaceDatasetsAccessPolicyResponse) export const accessPolicy6 = { - get: get78, + get: get91, } export const datasets2 = { @@ -3780,7 +4489,7 @@ export const rbac = { workspace, } -export const post49 = oc +export const post61 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -3797,7 +4506,7 @@ export const post49 = oc }) export const upload2 = { - post: post49, + post: post61, } export const files = { @@ -3807,7 +4516,7 @@ export const files = { /** * Import a Skill zip package from multipart form field `file`. */ -export const post50 = oc +export const post62 = oc .route({ description: 'Import a Skill zip package from multipart form field `file`.', inputStructure: 'detailed', @@ -3822,10 +4531,10 @@ export const post50 = oc .errors({ invalid_request: { status: 400, data: zInvalidRequestError } }) export const import_ = { - post: post50, + post: post62, } -export const get79 = oc +export const get92 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3836,10 +4545,10 @@ export const get79 = oc .output(zGetWorkspacesCurrentSkillsTagsResponse) export const tags = { - get: get79, + get: get92, } -export const post51 = oc +export const post63 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -3857,14 +4566,14 @@ export const post51 = oc .errors({ invalid_request: { status: 400, data: zInvalidRequestError } }) export const messages = { - post: post51, + post: post63, } export const assist = { messages, } -export const post52 = oc +export const post64 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -3877,10 +4586,10 @@ export const post52 = oc .output(zPostWorkspacesCurrentSkillsBySkillIdDuplicateResponse) export const duplicate = { - post: post52, + post: post64, } -export const get80 = oc +export const get93 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3892,10 +4601,10 @@ export const get80 = oc .output(zGetWorkspacesCurrentSkillsBySkillIdExportResponse) export const export2 = { - get: get80, + get: get93, } -export const post53 = oc +export const post65 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -3913,10 +4622,10 @@ export const post53 = oc .errors({ invalid_request: { status: 400, data: zInvalidRequestError } }) export const check = { - post: post53, + post: post65, } -export const get81 = oc +export const get94 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3934,10 +4643,10 @@ export const get81 = oc .errors({ invalid_request: { status: 400, data: zInvalidRequestError } }) export const content = { - get: get81, + get: get94, } -export const get82 = oc +export const get95 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3955,10 +4664,10 @@ export const get82 = oc .errors({ invalid_request: { status: 400, data: zInvalidRequestError } }) export const preview = { - get: get82, + get: get95, } -export const patch5 = oc +export const patch6 = oc .route({ inputStructure: 'detailed', method: 'PATCH', @@ -3975,7 +4684,7 @@ export const patch5 = oc .output(zPatchWorkspacesCurrentSkillsBySkillIdFilesResponse) .errors({ invalid_request: { status: 400, data: zInvalidRequestError } }) -export const put20 = oc +export const put24 = oc .route({ inputStructure: 'detailed', method: 'PUT', @@ -3993,14 +4702,14 @@ export const put20 = oc .errors({ invalid_request: { status: 400, data: zInvalidRequestError } }) export const files2 = { - patch: patch5, - put: put20, + patch: patch6, + put: put24, check, content, preview, } -export const post54 = oc +export const post66 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -4018,10 +4727,10 @@ export const post54 = oc .errors({ invalid_request: { status: 400, data: zInvalidRequestError } }) export const publish = { - post: post54, + post: post66, } -export const get83 = oc +export const get96 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -4033,10 +4742,10 @@ export const get83 = oc .output(zGetWorkspacesCurrentSkillsBySkillIdReferencesResponse) export const references = { - get: get83, + get: get96, } -export const post55 = oc +export const post67 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -4054,10 +4763,10 @@ export const post55 = oc .errors({ invalid_request: { status: 400, data: zInvalidRequestError } }) export const restore = { - post: post55, + post: post67, } -export const delete15 = oc +export const delete19 = oc .route({ inputStructure: 'detailed', method: 'DELETE', @@ -4068,7 +4777,7 @@ export const delete15 = oc .input(z.object({ params: zDeleteWorkspacesCurrentSkillsBySkillIdVersionsByVersionIdPath })) .output(zDeleteWorkspacesCurrentSkillsBySkillIdVersionsByVersionIdResponse) -export const get84 = oc +export const get97 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -4079,7 +4788,7 @@ export const get84 = oc .input(z.object({ params: zGetWorkspacesCurrentSkillsBySkillIdVersionsByVersionIdPath })) .output(zGetWorkspacesCurrentSkillsBySkillIdVersionsByVersionIdResponse) -export const patch6 = oc +export const patch7 = oc .route({ inputStructure: 'detailed', method: 'PATCH', @@ -4097,12 +4806,12 @@ export const patch6 = oc .errors({ invalid_request: { status: 400, data: zInvalidRequestError } }) export const byVersionId = { - delete: delete15, - get: get84, - patch: patch6, + delete: delete19, + get: get97, + patch: patch7, } -export const get85 = oc +export const get98 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -4114,11 +4823,11 @@ export const get85 = oc .output(zGetWorkspacesCurrentSkillsBySkillIdVersionsResponse) export const versions = { - get: get85, + get: get98, byVersionId, } -export const delete16 = oc +export const delete20 = oc .route({ inputStructure: 'detailed', method: 'DELETE', @@ -4135,7 +4844,7 @@ export const delete16 = oc .output(zDeleteWorkspacesCurrentSkillsBySkillIdResponse) .errors({ invalid_request: { status: 400, data: zInvalidRequestError } }) -export const get86 = oc +export const get99 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -4146,7 +4855,7 @@ export const get86 = oc .input(z.object({ params: zGetWorkspacesCurrentSkillsBySkillIdPath })) .output(zGetWorkspacesCurrentSkillsBySkillIdResponse) -export const patch7 = oc +export const patch8 = oc .route({ inputStructure: 'detailed', method: 'PATCH', @@ -4164,9 +4873,9 @@ export const patch7 = oc .errors({ invalid_request: { status: 400, data: zInvalidRequestError } }) export const bySkillId = { - delete: delete16, - get: get86, - patch: patch7, + delete: delete20, + get: get99, + patch: patch8, assist, duplicate, export: export2, @@ -4177,7 +4886,7 @@ export const bySkillId = { versions, } -export const get87 = oc +export const get100 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -4188,7 +4897,7 @@ export const get87 = oc .input(z.object({ query: zGetWorkspacesCurrentSkillsQuery.optional() }).optional()) .output(zGetWorkspacesCurrentSkillsResponse) -export const post56 = oc +export const post68 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -4202,15 +4911,15 @@ export const post56 = oc .errors({ invalid_request: { status: 400, data: zInvalidRequestError } }) export const skills2 = { - get: get87, - post: post56, + get: get100, + post: post68, files, import: import_, tags, bySkillId, } -export const get88 = oc +export const get101 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -4222,10 +4931,10 @@ export const get88 = oc .errors({ current_workspace_archived: { status: 409, data: zCurrentWorkspaceArchivedError } }) export const summary2 = { - get: get88, + get: get101, } -export const get89 = oc +export const get102 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -4236,10 +4945,10 @@ export const get89 = oc .output(zGetWorkspacesCurrentToolLabelsResponse) export const toolLabels = { - get: get89, + get: get102, } -export const post57 = oc +export const post69 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -4251,10 +4960,10 @@ export const post57 = oc .output(zPostWorkspacesCurrentToolProviderApiAddResponse) export const add = { - post: post57, + post: post69, } -export const post58 = oc +export const post70 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -4265,11 +4974,11 @@ export const post58 = oc .input(z.object({ body: zPostWorkspacesCurrentToolProviderApiDeleteBody })) .output(zPostWorkspacesCurrentToolProviderApiDeleteResponse) -export const delete17 = { - post: post58, +export const delete21 = { + post: post70, } -export const get90 = oc +export const get103 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -4280,11 +4989,11 @@ export const get90 = oc .input(z.object({ query: zGetWorkspacesCurrentToolProviderApiGetQuery })) .output(zGetWorkspacesCurrentToolProviderApiGetResponse) -export const get91 = { - get: get90, +export const get104 = { + get: get103, } -export const get92 = oc +export const get105 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -4296,10 +5005,10 @@ export const get92 = oc .output(zGetWorkspacesCurrentToolProviderApiRemoteResponse) export const remote = { - get: get92, + get: get105, } -export const post59 = oc +export const post71 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -4311,10 +5020,10 @@ export const post59 = oc .output(zPostWorkspacesCurrentToolProviderApiSchemaResponse) export const schema = { - post: post59, + post: post71, } -export const post60 = oc +export const post72 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -4326,14 +5035,14 @@ export const post60 = oc .output(zPostWorkspacesCurrentToolProviderApiTestPreResponse) export const pre = { - post: post60, + post: post72, } -export const test = { +export const test5 = { pre, } -export const get93 = oc +export const get106 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -4345,10 +5054,10 @@ export const get93 = oc .output(zGetWorkspacesCurrentToolProviderApiToolsResponse) export const tools = { - get: get93, + get: get106, } -export const post61 = oc +export const post73 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -4360,21 +5069,21 @@ export const post61 = oc .output(zPostWorkspacesCurrentToolProviderApiUpdateResponse) export const update2 = { - post: post61, + post: post73, } export const api = { add, - delete: delete17, - get: get91, + delete: delete21, + get: get104, remote, schema, - test, + test: test5, tools, update: update2, } -export const post62 = oc +export const post74 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -4391,10 +5100,10 @@ export const post62 = oc .output(zPostWorkspacesCurrentToolProviderBuiltinByProviderAddResponse) export const add2 = { - post: post62, + post: post74, } -export const get94 = oc +export const get107 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -4411,10 +5120,10 @@ export const get94 = oc .output(zGetWorkspacesCurrentToolProviderBuiltinByProviderCredentialInfoResponse) export const info = { - get: get94, + get: get107, } -export const get95 = oc +export const get108 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -4434,7 +5143,7 @@ export const get95 = oc ) export const byCredentialType = { - get: get95, + get: get108, } export const schema2 = { @@ -4446,7 +5155,7 @@ export const credential = { schema: schema2, } -export const get96 = oc +export const get109 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -4463,10 +5172,10 @@ export const get96 = oc .output(zGetWorkspacesCurrentToolProviderBuiltinByProviderCredentialsResponse) export const credentials3 = { - get: get96, + get: get109, } -export const post63 = oc +export const post75 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -4483,10 +5192,10 @@ export const post63 = oc .output(zPostWorkspacesCurrentToolProviderBuiltinByProviderDefaultCredentialResponse) export const defaultCredential = { - post: post63, + post: post75, } -export const post64 = oc +export const post76 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -4502,11 +5211,11 @@ export const post64 = oc ) .output(zPostWorkspacesCurrentToolProviderBuiltinByProviderDeleteResponse) -export const delete18 = { - post: post64, +export const delete22 = { + post: post76, } -export const get97 = oc +export const get110 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -4518,10 +5227,10 @@ export const get97 = oc .output(zGetWorkspacesCurrentToolProviderBuiltinByProviderIconResponse) export const icon2 = { - get: get97, + get: get110, } -export const get98 = oc +export const get111 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -4533,10 +5242,10 @@ export const get98 = oc .output(zGetWorkspacesCurrentToolProviderBuiltinByProviderInfoResponse) export const info2 = { - get: get98, + get: get111, } -export const get99 = oc +export const get112 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -4550,10 +5259,10 @@ export const get99 = oc .output(zGetWorkspacesCurrentToolProviderBuiltinByProviderOauthClientSchemaResponse) export const clientSchema = { - get: get99, + get: get112, } -export const delete19 = oc +export const delete23 = oc .route({ inputStructure: 'detailed', method: 'DELETE', @@ -4568,7 +5277,7 @@ export const delete19 = oc ) .output(zDeleteWorkspacesCurrentToolProviderBuiltinByProviderOauthCustomClientResponse) -export const get100 = oc +export const get113 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -4581,7 +5290,7 @@ export const get100 = oc ) .output(zGetWorkspacesCurrentToolProviderBuiltinByProviderOauthCustomClientResponse) -export const post65 = oc +export const post77 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -4598,9 +5307,9 @@ export const post65 = oc .output(zPostWorkspacesCurrentToolProviderBuiltinByProviderOauthCustomClientResponse) export const customClient = { - delete: delete19, - get: get100, - post: post65, + delete: delete23, + get: get113, + post: post77, } export const oauth = { @@ -4608,7 +5317,7 @@ export const oauth = { customClient, } -export const get101 = oc +export const get114 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -4620,10 +5329,10 @@ export const get101 = oc .output(zGetWorkspacesCurrentToolProviderBuiltinByProviderToolsResponse) export const tools2 = { - get: get101, + get: get114, } -export const post66 = oc +export const post78 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -4640,7 +5349,7 @@ export const post66 = oc .output(zPostWorkspacesCurrentToolProviderBuiltinByProviderUpdateResponse) export const update3 = { - post: post66, + post: post78, } export const byProvider2 = { @@ -4648,7 +5357,7 @@ export const byProvider2 = { credential, credentials: credentials3, defaultCredential, - delete: delete18, + delete: delete22, icon: icon2, info: info2, oauth, @@ -4660,7 +5369,7 @@ export const builtin = { byProvider: byProvider2, } -export const post67 = oc +export const post79 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -4672,10 +5381,10 @@ export const post67 = oc .output(zPostWorkspacesCurrentToolProviderMcpAuthResponse) export const auth = { - post: post67, + post: post79, } -export const get102 = oc +export const get115 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -4687,14 +5396,14 @@ export const get102 = oc .output(zGetWorkspacesCurrentToolProviderMcpToolsByProviderIdResponse) export const byProviderId = { - get: get102, + get: get115, } export const tools3 = { byProviderId, } -export const get103 = oc +export const get116 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -4706,14 +5415,14 @@ export const get103 = oc .output(zGetWorkspacesCurrentToolProviderMcpUpdateByProviderIdResponse) export const byProviderId2 = { - get: get103, + get: get116, } export const update4 = { byProviderId: byProviderId2, } -export const delete20 = oc +export const delete24 = oc .route({ inputStructure: 'detailed', method: 'DELETE', @@ -4724,7 +5433,7 @@ export const delete20 = oc .input(z.object({ body: zDeleteWorkspacesCurrentToolProviderMcpBody })) .output(zDeleteWorkspacesCurrentToolProviderMcpResponse) -export const post68 = oc +export const post80 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -4735,7 +5444,7 @@ export const post68 = oc .input(z.object({ body: zPostWorkspacesCurrentToolProviderMcpBody })) .output(zPostWorkspacesCurrentToolProviderMcpResponse) -export const put21 = oc +export const put25 = oc .route({ inputStructure: 'detailed', method: 'PUT', @@ -4747,15 +5456,15 @@ export const put21 = oc .output(zPutWorkspacesCurrentToolProviderMcpResponse) export const mcp = { - delete: delete20, - post: post68, - put: put21, + delete: delete24, + post: post80, + put: put25, auth, tools: tools3, update: update4, } -export const post69 = oc +export const post81 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -4767,10 +5476,10 @@ export const post69 = oc .output(zPostWorkspacesCurrentToolProviderWorkflowCreateResponse) export const create2 = { - post: post69, + post: post81, } -export const post70 = oc +export const post82 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -4781,11 +5490,11 @@ export const post70 = oc .input(z.object({ body: zPostWorkspacesCurrentToolProviderWorkflowDeleteBody })) .output(zPostWorkspacesCurrentToolProviderWorkflowDeleteResponse) -export const delete21 = { - post: post70, +export const delete25 = { + post: post82, } -export const get104 = oc +export const get117 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -4798,11 +5507,11 @@ export const get104 = oc ) .output(zGetWorkspacesCurrentToolProviderWorkflowGetResponse) -export const get105 = { - get: get104, +export const get118 = { + get: get117, } -export const get106 = oc +export const get119 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -4814,10 +5523,10 @@ export const get106 = oc .output(zGetWorkspacesCurrentToolProviderWorkflowToolsResponse) export const tools4 = { - get: get106, + get: get119, } -export const post71 = oc +export const post83 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -4829,13 +5538,13 @@ export const post71 = oc .output(zPostWorkspacesCurrentToolProviderWorkflowUpdateResponse) export const update5 = { - post: post71, + post: post83, } export const workflow = { create: create2, - delete: delete21, - get: get105, + delete: delete25, + get: get118, tools: tools4, update: update5, } @@ -4847,7 +5556,7 @@ export const toolProvider = { workflow, } -export const get107 = oc +export const get120 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -4859,10 +5568,10 @@ export const get107 = oc .output(zGetWorkspacesCurrentToolProvidersResponse) export const toolProviders = { - get: get107, + get: get120, } -export const get108 = oc +export const get121 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -4873,10 +5582,10 @@ export const get108 = oc .output(zGetWorkspacesCurrentToolsApiResponse) export const api2 = { - get: get108, + get: get121, } -export const get109 = oc +export const get122 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -4887,10 +5596,10 @@ export const get109 = oc .output(zGetWorkspacesCurrentToolsBuiltinResponse) export const builtin2 = { - get: get109, + get: get122, } -export const get110 = oc +export const get123 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -4901,10 +5610,10 @@ export const get110 = oc .output(zGetWorkspacesCurrentToolsMcpResponse) export const mcp2 = { - get: get110, + get: get123, } -export const get111 = oc +export const get124 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -4915,7 +5624,7 @@ export const get111 = oc .output(zGetWorkspacesCurrentToolsWorkflowResponse) export const workflow2 = { - get: get111, + get: get124, } export const tools5 = { @@ -4925,7 +5634,7 @@ export const tools5 = { workflow: workflow2, } -export const get112 = oc +export const get125 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -4937,13 +5646,13 @@ export const get112 = oc .output(zGetWorkspacesCurrentTriggerProviderByProviderIconResponse) export const icon3 = { - get: get112, + get: get125, } /** * Get info for a trigger provider */ -export const get113 = oc +export const get126 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -4956,13 +5665,13 @@ export const get113 = oc .output(zGetWorkspacesCurrentTriggerProviderByProviderInfoResponse) export const info3 = { - get: get113, + get: get126, } /** * Remove custom OAuth client configuration */ -export const delete22 = oc +export const delete26 = oc .route({ inputStructure: 'detailed', method: 'DELETE', @@ -4977,7 +5686,7 @@ export const delete22 = oc /** * Get OAuth client configuration for a provider */ -export const get114 = oc +export const get127 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -4992,7 +5701,7 @@ export const get114 = oc /** * Configure custom OAuth client for a provider */ -export const post72 = oc +export const post84 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -5010,9 +5719,9 @@ export const post72 = oc .output(zPostWorkspacesCurrentTriggerProviderByProviderOauthClientResponse) export const client = { - delete: delete22, - get: get114, - post: post72, + delete: delete26, + get: get127, + post: post84, } export const oauth2 = { @@ -5022,7 +5731,7 @@ export const oauth2 = { /** * Build a subscription instance for a trigger provider */ -export const post73 = oc +export const post85 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -5044,7 +5753,7 @@ export const post73 = oc ) export const bySubscriptionBuilderId = { - post: post73, + post: post85, } export const build = { @@ -5054,7 +5763,7 @@ export const build = { /** * Add a new subscription instance for a trigger provider */ -export const post74 = oc +export const post86 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -5072,13 +5781,13 @@ export const post74 = oc .output(zPostWorkspacesCurrentTriggerProviderByProviderSubscriptionsBuilderCreateResponse) export const create3 = { - post: post74, + post: post86, } /** * Get the request logs for a subscription instance for a trigger provider */ -export const get115 = oc +export const get128 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -5099,7 +5808,7 @@ export const get115 = oc ) export const bySubscriptionBuilderId2 = { - get: get115, + get: get128, } export const logs = { @@ -5109,7 +5818,7 @@ export const logs = { /** * Update a subscription instance for a trigger provider */ -export const post75 = oc +export const post87 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -5131,7 +5840,7 @@ export const post75 = oc ) export const bySubscriptionBuilderId3 = { - post: post75, + post: post87, } export const update6 = { @@ -5141,7 +5850,7 @@ export const update6 = { /** * Verify and update a subscription instance for a trigger provider */ -export const post76 = oc +export const post88 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -5163,7 +5872,7 @@ export const post76 = oc ) export const bySubscriptionBuilderId4 = { - post: post76, + post: post88, } export const verifyAndUpdate = { @@ -5173,7 +5882,7 @@ export const verifyAndUpdate = { /** * Get a subscription instance for a trigger provider */ -export const get116 = oc +export const get129 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -5194,7 +5903,7 @@ export const get116 = oc ) export const bySubscriptionBuilderId5 = { - get: get116, + get: get129, } export const builder = { @@ -5209,7 +5918,7 @@ export const builder = { /** * List all trigger subscriptions for the current tenant's provider */ -export const get117 = oc +export const get130 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -5222,13 +5931,13 @@ export const get117 = oc .output(zGetWorkspacesCurrentTriggerProviderByProviderSubscriptionsListResponse) export const list4 = { - get: get117, + get: get130, } /** * Initiate OAuth authorization flow for a trigger provider */ -export const get118 = oc +export const get131 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -5245,7 +5954,7 @@ export const get118 = oc .output(zGetWorkspacesCurrentTriggerProviderByProviderSubscriptionsOauthAuthorizeResponse) export const authorize = { - get: get118, + get: get131, } export const oauth3 = { @@ -5255,7 +5964,7 @@ export const oauth3 = { /** * Verify credentials for an existing subscription (edit mode only) */ -export const post77 = oc +export const post89 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -5277,7 +5986,7 @@ export const post77 = oc ) export const bySubscriptionId = { - post: post77, + post: post89, } export const verify = { @@ -5301,7 +6010,7 @@ export const byProvider3 = { /** * Delete a subscription instance */ -export const post78 = oc +export const post90 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -5317,14 +6026,14 @@ export const post78 = oc ) .output(zPostWorkspacesCurrentTriggerProviderBySubscriptionIdSubscriptionsDeleteResponse) -export const delete23 = { - post: post78, +export const delete27 = { + post: post90, } /** * Update a subscription instance */ -export const post79 = oc +export const post91 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -5342,11 +6051,11 @@ export const post79 = oc .output(zPostWorkspacesCurrentTriggerProviderBySubscriptionIdSubscriptionsUpdateResponse) export const update7 = { - post: post79, + post: post91, } export const subscriptions2 = { - delete: delete23, + delete: delete27, update: update7, } @@ -5362,7 +6071,7 @@ export const triggerProvider = { /** * List all trigger providers for the current tenant */ -export const get119 = oc +export const get132 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -5374,7 +6083,7 @@ export const get119 = oc .output(zGetWorkspacesCurrentTriggersResponse) export const triggers = { - get: get119, + get: get132, } export const current = { @@ -5385,6 +6094,7 @@ export const current = { datasetOperators, defaultModel, endpoints, + humanInput, members, modelProviders, models: models2, @@ -5402,7 +6112,7 @@ export const current = { triggers, } -export const post80 = oc +export const post92 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -5422,14 +6132,14 @@ export const post80 = oc }) export const upload3 = { - post: post80, + post: post92, } export const webappLogo = { upload: upload3, } -export const get120 = oc +export const get133 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -5439,7 +6149,7 @@ export const get120 = oc }) .output(zGetWorkspacesCustomConfigResponse) -export const post81 = oc +export const post93 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -5451,12 +6161,12 @@ export const post81 = oc .output(zPostWorkspacesCustomConfigResponse) export const customConfig = { - get: get120, - post: post81, + get: get133, + post: post93, webappLogo, } -export const post82 = oc +export const post94 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -5468,10 +6178,10 @@ export const post82 = oc .output(zPostWorkspacesInfoResponse) export const info4 = { - post: post82, + post: post94, } -export const post83 = oc +export const post95 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -5484,10 +6194,10 @@ export const post83 = oc .errors({ account_not_link_tenant: { status: 403, data: zAccountNotLinkTenantError } }) export const switch3 = { - post: post83, + post: post95, } -export const get121 = oc +export const get134 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -5499,7 +6209,7 @@ export const get121 = oc .output(zGetWorkspacesByTenantIdModelProvidersByProviderByIconTypeByLangResponse) export const byLang = { - get: get121, + get: get134, } export const byIconType = { @@ -5518,7 +6228,7 @@ export const byTenantId = { modelProviders: modelProviders2, } -export const get122 = oc +export const get135 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -5529,7 +6239,7 @@ export const get122 = oc .output(zGetWorkspacesResponse) export const workspaces = { - get: get122, + get: get135, current, customConfig, info: info4, diff --git a/packages/contracts/generated/api/console/workspaces/types.gen.ts b/packages/contracts/generated/api/console/workspaces/types.gen.ts index d7d881231ea..792a2f9d2a3 100644 --- a/packages/contracts/generated/api/console/workspaces/types.gen.ts +++ b/packages/contracts/generated/api/console/workspaces/types.gen.ts @@ -173,6 +173,224 @@ export type EndpointUpdatePayload = { } } +export type ListChannelProvidersResponse = { + email_providers: Array + im_providers: Array +} + +export type ListChannelsResponse = { + channels: Array +} + +export type EmailChannelCreatePayload = { + credentials: EmailProviderCredentials + sender_email: string + sender_name: SenderName +} + +export type EmailChannelMutationResponse = { + summary: ChannelSummary +} + +export type EmailChannelTestPayload = { + credentials: EmailProviderCredentials + sender_email: string + sender_name: SenderName +} + +export type ChannelTestResponse = { + status: ChannelStatus + status_description: string +} + +export type ChannelDeleteResponse = { + channel_id: ChannelId +} + +export type ChannelConflictResponse = { + code: 'provider_configuration_updated' | 'replacement_required' + message: string + status?: 409 +} + +export type EmailChannelDetailResponse = { + credentials: ResendCredentialsResponse + sender_email: string + sender_name: string + summary: ChannelSummary +} + +export type EmailChannelUpdatePayload = { + credentials: EmailProviderCredentialsUpdateInput + expected_config_version: ConfigVersion + sender_email: string | UnchangedInput + sender_name: SenderName | UnchangedInput +} + +export type ImChannelCreatePayload = { + credentials: ImProviderCredentialsInput +} + +export type ImChannelMutationResponse = { + summary: ChannelSummary +} + +export type ImChannelTestPayload = { + credentials: ImProviderCredentialsInput +} + +export type ImChannelDetailResponse = { + credentials: ImProviderCredentialsResponse + summary: ChannelSummary +} + +export type ImChannelUpdatePayload = { + credentials: ImProviderCredentialsUpdateInput + expected_config_version: ConfigVersion +} + +export type ImChannelReplacementPayload = { + credentials: ImProviderCredentialsInput + expected_config_version: ConfigVersion +} + +export type ListContactOptionsResponse = { + data: Array + limit: number + page: number + total: number +} + +export type BatchGetContactOptionsResponse = { + data: Array +} + +export type ListContactsResponse = { + data: Array + limit: number + page: number + total: number +} + +export type BatchGetContactsResponse = { + data: Array +} + +export type ExternalContactCreateRequest = { + avatar?: string | null + email: string + name: string +} + +export type ExternalContactCreateResponse = { + contact: HumanInputContact +} + +export type ExternalContactUpdateRequest = { + avatar?: string | null + email?: string | null + name?: string | null +} + +export type ExternalContactUpdateResponse = { + contact: HumanInputContact +} + +export type AddPlatformContactsRequest = { + candidate_ids: Array +} + +export type AddPlatformContactsResponse = { + data: Array +} + +export type RemoveContactsRequest = { + contact_ids: Array +} + +export type RemoveContactsResponse = { + removed_contact_ids: Array +} + +export type GetContactResponse = { + contact: HumanInputContact + im_binding_details: Array +} + +export type DeleteImBindingResponse = { + [key: string]: unknown +} + +export type ImContactSyncErrorResponse = { + code: string + message: string + status: number +} + +export type CreateImBindingRequest = { + identity_id: string +} + +export type CreateImBindingResponse = { + contact: HumanInputContact +} + +export type ResetContactImOverrideResponse = { + contact: HumanInputContact +} + +export type SetContactImOverrideRequest = { + identity_id: string +} + +export type SetContactImOverrideResponse = { + contact: HumanInputContact +} + +export type ListImIdentitiesResponse = { + data: Array + limit: number + page: number + total: number +} + +export type CreateImSyncRunResponse = { + run: ImSyncRun +} + +export type GetLatestImSyncRunResponse = { + run: ImSyncRun +} + +export type ListLatestImSyncRunResultsResponse = { + data: Array + limit: number + page: number + total: number +} + +export type NodeDataMigrationPayload = { + nodes: Array +} + +export type NodeDataMigrationResponse = { + data: Array +} + +export type NodeDataMigrationFailureResponse = { + blockers: Array + code?: 'hitl_node_data_migration_failure' + message: string + status?: 400 +} + +export type ListOrganizationCandidatesResponse = { + data: Array + limit: number + page: number + total: number +} + export type MemberInvitePayload = { emails: Array language?: string | null @@ -1594,6 +1812,213 @@ export type EndpointListItemResponse = { url: string } +export type ChannelProvider = { + connection_mode?: 'custom_app' + provider: EmailProviderType | ImProvider +} + +export type ChannelSummary = { + config_version: ConfigVersion + created_at: number + display_identifier: string + id: ChannelId + kind: 'email' | 'im' + provider: EmailProviderType | ImProvider + status: ChannelStatus + status_description: string + updated_at: number + webhook_url: string | null +} + +export type EmailProviderCredentials = ResendCredentials + +export type SenderName = string + +export type ChannelStatus = + | 'configured' + | 'connected' + | 'connection_failure' + | 'invalid_credentials' + +export type ChannelId = string + +export type ResendCredentialsResponse = { + api_key: Redacted + provider: 'resend' +} + +export type EmailProviderCredentialsUpdateInput = ResendCredentialsUpdateInput + +export type ConfigVersion = string + +export type UnchangedInput = { + type: 'unchanged' +} + +export type ImProviderCredentialsInput = + | ({ + provider: 'feishu' + } & FeishuCredentialsInput) + | ({ + provider: 'lark' + } & LarkCredentialsInput) + | ({ + provider: 'slack' + } & SlackCredentialsInput) + | ({ + provider: 'ding_talk' + } & DingTalkCredentialsInput) + | ({ + provider: 'ms_teams' + } & MsTeamsCredentialsInput) + | ({ + provider: 'we_com' + } & WeComCredentialsInput) + +export type ImProviderCredentialsResponse = + | ({ + provider: 'feishu' | 'lark' + } & FeishuLarkCredentialsResponse) + | ({ + provider: 'slack' + } & SlackCredentialsResponse) + | ({ + provider: 'ding_talk' + } & DingTalkCredentialsResponse) + | ({ + provider: 'ms_teams' + } & MsTeamsCredentialsResponse) + | ({ + provider: 'we_com' + } & WeComCredentialsResponse) + +export type ImProviderCredentialsUpdateInput = + | ({ + provider: 'feishu' + } & FeishuCredentialsUpdateInput) + | ({ + provider: 'lark' + } & LarkCredentialsUpdateInput) + | ({ + provider: 'slack' + } & SlackCredentialsUpdateInput) + | ({ + provider: 'ding_talk' + } & DingTalkCredentialsUpdateInput) + | ({ + provider: 'ms_teams' + } & MsTeamsCredentialsUpdateInput) + | ({ + provider: 'we_com' + } & WeComCredentialsUpdateInput) + +export type ContactOption = { + avatar_url?: string | null + email?: string | null + id: string + name: string + type: HumanInputContactType +} + +export type HumanInputContact = { + avatar_url?: string + created_at: number + email?: string | null + id: string + im_bindings?: Array + name: string + type: HumanInputContactType +} + +export type HumanInputContactSummary = { + avatar_url?: string + created_at: number + id: string + name: string +} + +export type ContactImBindingDetail = { + id: string + identity: ContactImIdentityDetail + provider: ImProvider + scope: ImBindingScope +} + +export type ImIdentity = { + binding_status: ImIdentityBindingStatus + display_name?: string | null + email?: string | null + id: string + provider: ImProvider + provider_user_id: string +} + +export type ImSyncRun = { + channel_config_version: number + channel_id: string + error_message?: string | null + finished_at?: number | null + id: string + provider: ImProvider + result_counts: ImSyncRunResultCounts + started_at?: number | null + status: ImSyncRunStatus +} + +export type ImSyncResultItem = { + id: string + result: + | ({ + type: 'added' + } & ImSyncResultAdded) + | ({ + type: 'removed' + } & ImSyncResultRemoved) + | ({ + type: 'failed' + } & ImSyncResultFailed) + | ({ + type: 'not_matched' + } & ImSyncResultNotMatched) + | ({ + type: 'skipped' + } & ImSyncResultSkipped) +} + +export type NodeDataMigrationInput = { + node_data: LegacyHitLv1NodeData + node_id: string +} + +export type NodeDataMigrationResult = { + node_data: HumanInputNodeData + node_id: string +} + +export type NodeDataMigrationBlocker = { + code: + | 'configured-disabled-method' + | 'conflicting-email-templates' + | 'invalid-default-value' + | 'invalid-email' + | 'invalid-email-configuration' + | 'missing-recipients' + | 'unresolved-member' + | 'unsupported-delivery-method' + | 'unsupported-version' + method_id?: string | null + node_id: string + node_title: string + value?: string | null +} + +export type OrganizationCandidate = { + avatar_url?: string | null + email: string + id: string + name: string +} + export type MemberInviteSuccessResponse = { email: string status: 'success' @@ -2317,6 +2742,236 @@ export type EndpointProviderDeclarationResponse = { settings?: Array } +export type EmailProviderType = 'resend' + +export type ImProvider = 'ding_talk' | 'feishu' | 'lark' | 'ms_teams' | 'slack' | 'we_com' + +export type ResendCredentials = { + provider?: 'resend' +} + +export type Redacted = { + type?: 'redacted' +} + +export type ResendCredentialsUpdateInput = { + api_key: UnchangedInput + provider: 'resend' +} + +export type FeishuCredentialsInput = { + app_id: string + provider: 'feishu' +} + +export type LarkCredentialsInput = { + app_id: string + provider: 'lark' +} + +export type SlackCredentialsInput = { + client_id: string + provider: 'slack' +} + +export type DingTalkCredentialsInput = { + client_id: string + corp_id: string + provider: 'ding_talk' +} + +export type MsTeamsCredentialsInput = { + client_id: string + provider: 'ms_teams' + tenant_id: string +} + +export type WeComCredentialsInput = { + agent_id: string + corp_id: string + provider: 'we_com' +} + +export type FeishuLarkCredentialsResponse = { + app_id: string + app_secret: Redacted + encrypt_key: Redacted | null + provider: 'feishu' | 'lark' + verification_token: Redacted | null +} + +export type SlackCredentialsResponse = { + app_token: Redacted | null + bot_token: Redacted + client_id: string + client_secret: Redacted + provider: 'slack' + signing_secret: Redacted +} + +export type DingTalkCredentialsResponse = { + client_id: string + client_secret: Redacted + corp_id: string + provider: 'ding_talk' +} + +export type MsTeamsCredentialsResponse = { + client_id: string + client_secret: Redacted + provider: 'ms_teams' + tenant_id: string +} + +export type WeComCredentialsResponse = { + agent_id: string + corp_id: string + provider: 'we_com' + secret: Redacted +} + +export type FeishuCredentialsUpdateInput = { + app_id: Identifier | UnchangedInput + app_secret: UnchangedInput + encrypt_key: UnchangedInput + provider: 'feishu' + verification_token: UnchangedInput +} + +export type LarkCredentialsUpdateInput = { + app_id: Identifier | UnchangedInput + app_secret: UnchangedInput + encrypt_key: UnchangedInput + provider: 'lark' + verification_token: UnchangedInput +} + +export type SlackCredentialsUpdateInput = { + app_token: UnchangedInput + bot_token: UnchangedInput + client_id: Identifier | UnchangedInput + client_secret: UnchangedInput + provider: 'slack' + signing_secret: UnchangedInput +} + +export type DingTalkCredentialsUpdateInput = { + client_id: Identifier | UnchangedInput + client_secret: UnchangedInput + corp_id: Identifier | UnchangedInput + provider: 'ding_talk' +} + +export type MsTeamsCredentialsUpdateInput = { + client_id: UuidString | UnchangedInput + client_secret: UnchangedInput + provider: 'ms_teams' + tenant_id: UuidString | UnchangedInput +} + +export type WeComCredentialsUpdateInput = { + agent_id: string | UnchangedInput + corp_id: Identifier | UnchangedInput + provider: 'we_com' + secret: UnchangedInput +} + +export type HumanInputContactType = 'external' | 'platform' | 'workspace' + +export type ImBinding = { + id: string + provider: ImProvider + scope: ImBindingScope +} + +export type ContactImIdentityDetail = { + display_name: string | null + email: string | null + id: string + provider_user_id: string +} + +export type ImBindingScope = 'organization' | 'workspace' + +export type ImIdentityBindingStatus = 'bound' | 'unbound' + +export type ImSyncRunResultCounts = { + added: number + failed: number + not_matched: number + removed: number + skipped: number +} + +export type ImSyncRunStatus = 'failed' | 'queued' | 'running' | 'succeeded' + +export type ImSyncResultAdded = { + contact: HumanInputContactSummary + entry: ImDirectoryEntry + type?: 'added' +} + +export type ImSyncResultRemoved = { + contact: HumanInputContactSummary + last_known_identity: ImIdentitySnapshot + reason: ImSyncRemovalReason + type?: 'removed' +} + +export type ImSyncResultFailed = { + entry?: ImDirectoryEntry | null + reason: string + type?: 'failed' +} + +export type ImSyncResultNotMatched = { + entry?: ImDirectoryEntry | null + type?: 'not_matched' +} + +export type ImSyncResultSkipped = { + contact: HumanInputContactSummary + entry?: ImDirectoryEntry | null + type?: 'skipped' +} + +export type LegacyHitLv1NodeData = { + default_value?: Array | null + delivery_methods?: Array< + InteractiveSurfaceDeliveryMethod | EmailDeliveryMethod | InstantMessageDeliveryMethod + > + desc?: string | null + error_strategy?: ErrorStrategy | null + form_content?: string + inputs?: Array + retry_config?: RetryConfig + timeout?: number + timeout_unit?: TimeoutUnit + title?: string + type?: NodeType + user_actions?: Array + version?: string + [key: string]: unknown +} + +export type HumanInputNodeData = { + debug_mode: DebugModeConfig + default_value?: Array | null + desc?: string | null + error_strategy?: ErrorStrategy | null + form_content?: string + inputs?: Array + message_template: MessageTemplateConfig + recipients_spec: Array + retry_config?: RetryConfig + timeout?: number + timeout_unit?: TimeoutUnitStrictStrictFalse + title?: string + type?: 'human-input' + user_actions?: Array + version?: string +} + export type ConfigurateMethod = 'customizable-model' | 'predefined-model' export type CustomConfigurationResponse = { @@ -2816,6 +3471,126 @@ export type EndpointProviderConfigResponse = { url?: string | null } +export type RequiredSecret = string + +export type OptionalSecret = string | null + +export type Identifier = string + +export type UuidString = string + +export type ImDirectoryEntry = { + display_name?: string | null + email?: string | null + provider_user_id: string +} + +export type ImIdentitySnapshot = { + display_name?: string | null + email?: string | null + identity_id: string + provider_user_id: string +} + +export type ImSyncRemovalReason = + | 'binding_invalidated' + | 'binding_replaced' + | 'not_present_in_directory' + +export type DefaultValue = { + key: string + type: DefaultValueType + value?: unknown +} + +export type InteractiveSurfaceDeliveryMethod = { + config?: InteractiveSurfaceDeliveryConfig + enabled?: boolean + id?: string + type?: 'webapp' +} + +export type EmailDeliveryMethod = { + config: EmailDeliveryConfig + enabled?: boolean + id?: string + type?: 'email' +} + +export type InstantMessageDeliveryMethod = { + config: InstantMessageDeliveryConfig + enabled?: boolean + id?: string + type?: 'im' +} + +export type ErrorStrategy = 'default-value' | 'fail-branch' + +export type FormInputConfig = + | ({ + type: 'paragraph' + } & ParagraphInputConfig) + | ({ + type: 'select' + } & SelectInputConfig) + | ({ + type: 'file' + } & FileInputConfig) + | ({ + type: 'file-list' + } & FileListInputConfig) + +export type RetryConfig = { + max_retries?: number + retry_enabled?: boolean + retry_interval?: number +} + +export type TimeoutUnit = 'day' | 'hour' + +export type NodeType = string + +export type UserActionConfig = { + button_style?: ButtonStyle + id: string + title: string +} + +export type DebugModeConfig = { + channels: Array + enabled?: boolean +} + +export type MessageTemplateConfig = { + body: string + subject: string +} + +export type Contact = { + contact_id: string + type?: 'contact' +} + +export type DynamicEmail = { + selector: Array + type?: 'dynamic_email' +} + +export type OnetimeEmail = { + email: string + type?: 'onetime_email' +} + +export type Initiator = { + type?: 'initiator' +} + +export type AllWorkspaceContacts = { + type?: 'all_workspace_contacts' +} + +export type TimeoutUnitStrictStrictFalse = 'day' | 'hour' + export type UnaddedModelConfiguration = { model: string model_type: ModelType @@ -3041,6 +3816,72 @@ export type EndpointProviderConfigScope = | 'vision' | 'workflow' +export type DefaultValueType = + | 'array[file]' + | 'array[number]' + | 'array[object]' + | 'array[string]' + | 'number' + | 'object' + | 'string' + +export type InteractiveSurfaceDeliveryConfig = { + [key: string]: unknown +} + +export type EmailDeliveryConfig = { + body: string + debug_mode?: boolean + recipients: EmailRecipients + subject: string +} + +export type InstantMessageDeliveryConfig = { + message?: string | null + provider: InstantMessageProvider + recipients?: InstantMessageRecipients +} + +export type ParagraphInputConfig = { + default?: StringSource | null + output_variable_name: string + type?: 'paragraph' +} + +export type SelectInputConfig = { + option_source: StringListSource + output_variable_name: string + type?: 'select' +} + +export type FileInputConfig = { + allowed_file_extensions?: Array + allowed_file_types?: Array + allowed_file_upload_methods?: Array + output_variable_name: string + type?: 'file' +} + +export type FileListInputConfig = { + allowed_file_extensions?: Array + allowed_file_types?: Array + allowed_file_upload_methods?: Array + number_limits?: number + output_variable_name: string + type?: 'file-list' +} + +export type ButtonStyle = 'accent' | 'default' | 'ghost' | 'primary' + +export type DebugChannel = + | 'ding_talk' + | 'email' + | 'feishu' + | 'lark' + | 'ms_teams' + | 'slack' + | 'we_com' + export type FormOption = { label: ModelRuntimeI18nObject show_on?: Array @@ -3098,10 +3939,100 @@ export type Tool = { export type PluginParameterAutoGenerateType = 'prompt_instruction' +export type EmailRecipients = { + include_bound_group?: boolean + items?: Array +} + +export type InstantMessageProvider = 'discord' | 'slack' | 'teams' + +export type InstantMessageRecipients = { + items?: Array +} + +export type StringSource = { + selector?: Array + type: ValueSourceType + value?: string +} + +export type StringListSource = { + selector?: Array + type: ValueSourceType + value?: Array +} + +export type FileType = 'audio' | 'custom' | 'document' | 'image' | 'video' + +export type FileTransferMethod = 'datasource_file' | 'local_file' | 'remote_url' | 'tool_file' + +export type BoundRecipient = { + reference_id: string + type?: 'member' +} + +export type ExternalRecipient = { + email: string + type?: 'external' +} + +export type InstantMessageChannelRecipient = { + channel_id: string + type?: 'channel' +} + +export type InstantMessageUserRecipient = { + type?: 'user' + user_id: string +} + +export type ValueSourceType = 'constant' | 'variable' + export type AccountWithRoleListResponseWritable = { accounts: Array } +export type EmailChannelCreatePayloadWritable = { + credentials: EmailProviderCredentialsWritable + sender_email: string + sender_name: SenderName +} + +export type EmailChannelTestPayloadWritable = { + credentials: EmailProviderCredentialsWritable + sender_email: string + sender_name: SenderName +} + +export type EmailChannelUpdatePayloadWritable = { + credentials: EmailProviderCredentialsUpdateInputWritable + expected_config_version: ConfigVersion + sender_email: string | UnchangedInput + sender_name: SenderName | UnchangedInput +} + +export type ImChannelCreatePayloadWritable = { + credentials: ImProviderCredentialsInputWritable +} + +export type ImChannelTestPayloadWritable = { + credentials: ImProviderCredentialsInputWritable +} + +export type ImChannelUpdatePayloadWritable = { + credentials: ImProviderCredentialsUpdateInputWritable + expected_config_version: ConfigVersion +} + +export type ImChannelReplacementPayloadWritable = { + credentials: ImProviderCredentialsInputWritable + expected_config_version: ConfigVersion +} + +export type DeleteImBindingResponseWritable = { + [key: string]: unknown +} + export type AccountWithRoleResponseWritable = { avatar?: string | null created_at?: number | null @@ -3115,6 +4046,156 @@ export type AccountWithRoleResponseWritable = { status: string } +export type EmailProviderCredentialsWritable = ResendCredentialsWritable + +export type EmailProviderCredentialsUpdateInputWritable = ResendCredentialsUpdateInputWritable + +export type ImProviderCredentialsInputWritable = + | ({ + provider: 'feishu' + } & FeishuCredentialsInputWritable) + | ({ + provider: 'lark' + } & LarkCredentialsInputWritable) + | ({ + provider: 'slack' + } & SlackCredentialsInputWritable) + | ({ + provider: 'ding_talk' + } & DingTalkCredentialsInputWritable) + | ({ + provider: 'ms_teams' + } & MsTeamsCredentialsInputWritable) + | ({ + provider: 'we_com' + } & WeComCredentialsInputWritable) + +export type ImProviderCredentialsUpdateInputWritable = + | ({ + provider: 'feishu' + } & FeishuCredentialsUpdateInputWritable) + | ({ + provider: 'lark' + } & LarkCredentialsUpdateInputWritable) + | ({ + provider: 'slack' + } & SlackCredentialsUpdateInputWritable) + | ({ + provider: 'ding_talk' + } & DingTalkCredentialsUpdateInputWritable) + | ({ + provider: 'ms_teams' + } & MsTeamsCredentialsUpdateInputWritable) + | ({ + provider: 'we_com' + } & WeComCredentialsUpdateInputWritable) + +export type ResendCredentialsWritable = { + api_key: RequiredSecret + provider?: 'resend' +} + +export type ResendCredentialsUpdateInputWritable = { + api_key: RequiredSecret | UnchangedInput + provider: 'resend' +} + +export type FeishuCredentialsInputWritable = { + app_id: string + app_secret: RequiredSecret + encrypt_key?: OptionalSecret + provider: 'feishu' + verification_token?: OptionalSecret +} + +export type LarkCredentialsInputWritable = { + app_id: string + app_secret: RequiredSecret + encrypt_key?: OptionalSecret + provider: 'lark' + verification_token?: OptionalSecret +} + +export type SlackCredentialsInputWritable = { + app_token?: OptionalSecret + bot_token: RequiredSecret + client_id: string + client_secret: RequiredSecret + provider: 'slack' + signing_secret: RequiredSecret +} + +export type DingTalkCredentialsInputWritable = { + client_id: string + client_secret: RequiredSecret + corp_id: string + provider: 'ding_talk' +} + +export type MsTeamsCredentialsInputWritable = { + client_id: string + client_secret: RequiredSecret + provider: 'ms_teams' + tenant_id: string +} + +export type WeComCredentialsInputWritable = { + agent_id: string + corp_id: string + provider: 'we_com' + secret: RequiredSecret +} + +export type FeishuCredentialsUpdateInputWritable = { + app_id: Identifier | UnchangedInput + app_secret: RequiredSecret | UnchangedInput + encrypt_key: OptionalSecret | UnchangedInput + provider: 'feishu' + verification_token: OptionalSecret | UnchangedInput +} + +export type LarkCredentialsUpdateInputWritable = { + app_id: Identifier | UnchangedInput + app_secret: RequiredSecret | UnchangedInput + encrypt_key: OptionalSecret | UnchangedInput + provider: 'lark' + verification_token: OptionalSecret | UnchangedInput +} + +export type SlackCredentialsUpdateInputWritable = { + app_token: OptionalSecret | UnchangedInput + bot_token: RequiredSecret | UnchangedInput + client_id: Identifier | UnchangedInput + client_secret: RequiredSecret | UnchangedInput + provider: 'slack' + signing_secret: RequiredSecret | UnchangedInput +} + +export type DingTalkCredentialsUpdateInputWritable = { + client_id: Identifier | UnchangedInput + client_secret: RequiredSecret | UnchangedInput + corp_id: Identifier | UnchangedInput + provider: 'ding_talk' +} + +export type MsTeamsCredentialsUpdateInputWritable = { + client_id: UuidString | UnchangedInput + client_secret: RequiredSecret | UnchangedInput + provider: 'ms_teams' + tenant_id: UuidString | UnchangedInput +} + +export type WeComCredentialsUpdateInputWritable = { + agent_id: string | UnchangedInput + corp_id: Identifier | UnchangedInput + provider: 'we_com' + secret: RequiredSecret | UnchangedInput +} + +export type InteractiveSurfaceDeliveryConfigWritable = { + [key: string]: unknown +} + export type GetWorkspacesData = { body?: never path?: never @@ -3764,6 +4845,811 @@ export type PatchWorkspacesCurrentEndpointsByIdResponses = { export type PatchWorkspacesCurrentEndpointsByIdResponse = PatchWorkspacesCurrentEndpointsByIdResponses[keyof PatchWorkspacesCurrentEndpointsByIdResponses] +export type GetWorkspacesCurrentHumanInputV2ChannelProvidersData = { + body?: never + path?: never + query?: never + url: '/workspaces/current/human-input/v2/channel-providers' +} + +export type GetWorkspacesCurrentHumanInputV2ChannelProvidersErrors = { + default: ErrorResponse +} + +export type GetWorkspacesCurrentHumanInputV2ChannelProvidersError = + GetWorkspacesCurrentHumanInputV2ChannelProvidersErrors[keyof GetWorkspacesCurrentHumanInputV2ChannelProvidersErrors] + +export type GetWorkspacesCurrentHumanInputV2ChannelProvidersResponses = { + 200: ListChannelProvidersResponse +} + +export type GetWorkspacesCurrentHumanInputV2ChannelProvidersResponse = + GetWorkspacesCurrentHumanInputV2ChannelProvidersResponses[keyof GetWorkspacesCurrentHumanInputV2ChannelProvidersResponses] + +export type GetWorkspacesCurrentHumanInputV2ChannelsData = { + body?: never + path?: never + query?: never + url: '/workspaces/current/human-input/v2/channels' +} + +export type GetWorkspacesCurrentHumanInputV2ChannelsErrors = { + default: ErrorResponse +} + +export type GetWorkspacesCurrentHumanInputV2ChannelsError = + GetWorkspacesCurrentHumanInputV2ChannelsErrors[keyof GetWorkspacesCurrentHumanInputV2ChannelsErrors] + +export type GetWorkspacesCurrentHumanInputV2ChannelsResponses = { + 200: ListChannelsResponse +} + +export type GetWorkspacesCurrentHumanInputV2ChannelsResponse = + GetWorkspacesCurrentHumanInputV2ChannelsResponses[keyof GetWorkspacesCurrentHumanInputV2ChannelsResponses] + +export type PostWorkspacesCurrentHumanInputV2ChannelsEmailData = { + body: EmailChannelCreatePayloadWritable + path?: never + query?: never + url: '/workspaces/current/human-input/v2/channels/email' +} + +export type PostWorkspacesCurrentHumanInputV2ChannelsEmailErrors = { + default: ErrorResponse +} + +export type PostWorkspacesCurrentHumanInputV2ChannelsEmailError = + PostWorkspacesCurrentHumanInputV2ChannelsEmailErrors[keyof PostWorkspacesCurrentHumanInputV2ChannelsEmailErrors] + +export type PostWorkspacesCurrentHumanInputV2ChannelsEmailResponses = { + 200: EmailChannelMutationResponse +} + +export type PostWorkspacesCurrentHumanInputV2ChannelsEmailResponse = + PostWorkspacesCurrentHumanInputV2ChannelsEmailResponses[keyof PostWorkspacesCurrentHumanInputV2ChannelsEmailResponses] + +export type PostWorkspacesCurrentHumanInputV2ChannelsEmailTestData = { + body: EmailChannelTestPayloadWritable + path?: never + query?: never + url: '/workspaces/current/human-input/v2/channels/email/test' +} + +export type PostWorkspacesCurrentHumanInputV2ChannelsEmailTestErrors = { + default: ErrorResponse +} + +export type PostWorkspacesCurrentHumanInputV2ChannelsEmailTestError = + PostWorkspacesCurrentHumanInputV2ChannelsEmailTestErrors[keyof PostWorkspacesCurrentHumanInputV2ChannelsEmailTestErrors] + +export type PostWorkspacesCurrentHumanInputV2ChannelsEmailTestResponses = { + 200: ChannelTestResponse +} + +export type PostWorkspacesCurrentHumanInputV2ChannelsEmailTestResponse = + PostWorkspacesCurrentHumanInputV2ChannelsEmailTestResponses[keyof PostWorkspacesCurrentHumanInputV2ChannelsEmailTestResponses] + +export type DeleteWorkspacesCurrentHumanInputV2ChannelsEmailByChannelIdData = { + body?: never + path: { + channel_id: string + } + query: { + expected_config_version: string + } + url: '/workspaces/current/human-input/v2/channels/email/{channel_id}' +} + +export type DeleteWorkspacesCurrentHumanInputV2ChannelsEmailByChannelIdErrors = { + 409: ChannelConflictResponse | ErrorResponse + default: ErrorResponse +} + +export type DeleteWorkspacesCurrentHumanInputV2ChannelsEmailByChannelIdError = + DeleteWorkspacesCurrentHumanInputV2ChannelsEmailByChannelIdErrors[keyof DeleteWorkspacesCurrentHumanInputV2ChannelsEmailByChannelIdErrors] + +export type DeleteWorkspacesCurrentHumanInputV2ChannelsEmailByChannelIdResponses = { + 200: ChannelDeleteResponse +} + +export type DeleteWorkspacesCurrentHumanInputV2ChannelsEmailByChannelIdResponse = + DeleteWorkspacesCurrentHumanInputV2ChannelsEmailByChannelIdResponses[keyof DeleteWorkspacesCurrentHumanInputV2ChannelsEmailByChannelIdResponses] + +export type GetWorkspacesCurrentHumanInputV2ChannelsEmailByChannelIdData = { + body?: never + path: { + channel_id: string + } + query?: never + url: '/workspaces/current/human-input/v2/channels/email/{channel_id}' +} + +export type GetWorkspacesCurrentHumanInputV2ChannelsEmailByChannelIdErrors = { + default: ErrorResponse +} + +export type GetWorkspacesCurrentHumanInputV2ChannelsEmailByChannelIdError = + GetWorkspacesCurrentHumanInputV2ChannelsEmailByChannelIdErrors[keyof GetWorkspacesCurrentHumanInputV2ChannelsEmailByChannelIdErrors] + +export type GetWorkspacesCurrentHumanInputV2ChannelsEmailByChannelIdResponses = { + 200: EmailChannelDetailResponse +} + +export type GetWorkspacesCurrentHumanInputV2ChannelsEmailByChannelIdResponse = + GetWorkspacesCurrentHumanInputV2ChannelsEmailByChannelIdResponses[keyof GetWorkspacesCurrentHumanInputV2ChannelsEmailByChannelIdResponses] + +export type PutWorkspacesCurrentHumanInputV2ChannelsEmailByChannelIdData = { + body: EmailChannelUpdatePayloadWritable + path: { + channel_id: string + } + query?: never + url: '/workspaces/current/human-input/v2/channels/email/{channel_id}' +} + +export type PutWorkspacesCurrentHumanInputV2ChannelsEmailByChannelIdErrors = { + 409: ChannelConflictResponse | ErrorResponse + default: ErrorResponse +} + +export type PutWorkspacesCurrentHumanInputV2ChannelsEmailByChannelIdError = + PutWorkspacesCurrentHumanInputV2ChannelsEmailByChannelIdErrors[keyof PutWorkspacesCurrentHumanInputV2ChannelsEmailByChannelIdErrors] + +export type PutWorkspacesCurrentHumanInputV2ChannelsEmailByChannelIdResponses = { + 200: EmailChannelMutationResponse +} + +export type PutWorkspacesCurrentHumanInputV2ChannelsEmailByChannelIdResponse = + PutWorkspacesCurrentHumanInputV2ChannelsEmailByChannelIdResponses[keyof PutWorkspacesCurrentHumanInputV2ChannelsEmailByChannelIdResponses] + +export type PostWorkspacesCurrentHumanInputV2ChannelsEmailByChannelIdTestData = { + body: EmailChannelUpdatePayloadWritable + path: { + channel_id: string + } + query?: never + url: '/workspaces/current/human-input/v2/channels/email/{channel_id}/test' +} + +export type PostWorkspacesCurrentHumanInputV2ChannelsEmailByChannelIdTestErrors = { + 409: ChannelConflictResponse | ErrorResponse + default: ErrorResponse +} + +export type PostWorkspacesCurrentHumanInputV2ChannelsEmailByChannelIdTestError = + PostWorkspacesCurrentHumanInputV2ChannelsEmailByChannelIdTestErrors[keyof PostWorkspacesCurrentHumanInputV2ChannelsEmailByChannelIdTestErrors] + +export type PostWorkspacesCurrentHumanInputV2ChannelsEmailByChannelIdTestResponses = { + 200: ChannelTestResponse +} + +export type PostWorkspacesCurrentHumanInputV2ChannelsEmailByChannelIdTestResponse = + PostWorkspacesCurrentHumanInputV2ChannelsEmailByChannelIdTestResponses[keyof PostWorkspacesCurrentHumanInputV2ChannelsEmailByChannelIdTestResponses] + +export type PostWorkspacesCurrentHumanInputV2ChannelsImData = { + body: ImChannelCreatePayloadWritable + path?: never + query?: never + url: '/workspaces/current/human-input/v2/channels/im' +} + +export type PostWorkspacesCurrentHumanInputV2ChannelsImErrors = { + default: ErrorResponse +} + +export type PostWorkspacesCurrentHumanInputV2ChannelsImError = + PostWorkspacesCurrentHumanInputV2ChannelsImErrors[keyof PostWorkspacesCurrentHumanInputV2ChannelsImErrors] + +export type PostWorkspacesCurrentHumanInputV2ChannelsImResponses = { + 200: ImChannelMutationResponse +} + +export type PostWorkspacesCurrentHumanInputV2ChannelsImResponse = + PostWorkspacesCurrentHumanInputV2ChannelsImResponses[keyof PostWorkspacesCurrentHumanInputV2ChannelsImResponses] + +export type PostWorkspacesCurrentHumanInputV2ChannelsImTestData = { + body: ImChannelTestPayloadWritable + path?: never + query?: never + url: '/workspaces/current/human-input/v2/channels/im/test' +} + +export type PostWorkspacesCurrentHumanInputV2ChannelsImTestErrors = { + default: ErrorResponse +} + +export type PostWorkspacesCurrentHumanInputV2ChannelsImTestError = + PostWorkspacesCurrentHumanInputV2ChannelsImTestErrors[keyof PostWorkspacesCurrentHumanInputV2ChannelsImTestErrors] + +export type PostWorkspacesCurrentHumanInputV2ChannelsImTestResponses = { + 200: ChannelTestResponse +} + +export type PostWorkspacesCurrentHumanInputV2ChannelsImTestResponse = + PostWorkspacesCurrentHumanInputV2ChannelsImTestResponses[keyof PostWorkspacesCurrentHumanInputV2ChannelsImTestResponses] + +export type DeleteWorkspacesCurrentHumanInputV2ChannelsImByChannelIdData = { + body?: never + path: { + channel_id: string + } + query: { + expected_config_version: string + } + url: '/workspaces/current/human-input/v2/channels/im/{channel_id}' +} + +export type DeleteWorkspacesCurrentHumanInputV2ChannelsImByChannelIdErrors = { + 409: ChannelConflictResponse | ErrorResponse + default: ErrorResponse +} + +export type DeleteWorkspacesCurrentHumanInputV2ChannelsImByChannelIdError = + DeleteWorkspacesCurrentHumanInputV2ChannelsImByChannelIdErrors[keyof DeleteWorkspacesCurrentHumanInputV2ChannelsImByChannelIdErrors] + +export type DeleteWorkspacesCurrentHumanInputV2ChannelsImByChannelIdResponses = { + 200: ChannelDeleteResponse +} + +export type DeleteWorkspacesCurrentHumanInputV2ChannelsImByChannelIdResponse = + DeleteWorkspacesCurrentHumanInputV2ChannelsImByChannelIdResponses[keyof DeleteWorkspacesCurrentHumanInputV2ChannelsImByChannelIdResponses] + +export type GetWorkspacesCurrentHumanInputV2ChannelsImByChannelIdData = { + body?: never + path: { + channel_id: string + } + query?: never + url: '/workspaces/current/human-input/v2/channels/im/{channel_id}' +} + +export type GetWorkspacesCurrentHumanInputV2ChannelsImByChannelIdErrors = { + default: ErrorResponse +} + +export type GetWorkspacesCurrentHumanInputV2ChannelsImByChannelIdError = + GetWorkspacesCurrentHumanInputV2ChannelsImByChannelIdErrors[keyof GetWorkspacesCurrentHumanInputV2ChannelsImByChannelIdErrors] + +export type GetWorkspacesCurrentHumanInputV2ChannelsImByChannelIdResponses = { + 200: ImChannelDetailResponse +} + +export type GetWorkspacesCurrentHumanInputV2ChannelsImByChannelIdResponse = + GetWorkspacesCurrentHumanInputV2ChannelsImByChannelIdResponses[keyof GetWorkspacesCurrentHumanInputV2ChannelsImByChannelIdResponses] + +export type PutWorkspacesCurrentHumanInputV2ChannelsImByChannelIdData = { + body: ImChannelUpdatePayloadWritable + path: { + channel_id: string + } + query?: never + url: '/workspaces/current/human-input/v2/channels/im/{channel_id}' +} + +export type PutWorkspacesCurrentHumanInputV2ChannelsImByChannelIdErrors = { + 409: ChannelConflictResponse | ErrorResponse + default: ErrorResponse +} + +export type PutWorkspacesCurrentHumanInputV2ChannelsImByChannelIdError = + PutWorkspacesCurrentHumanInputV2ChannelsImByChannelIdErrors[keyof PutWorkspacesCurrentHumanInputV2ChannelsImByChannelIdErrors] + +export type PutWorkspacesCurrentHumanInputV2ChannelsImByChannelIdResponses = { + 200: ImChannelMutationResponse +} + +export type PutWorkspacesCurrentHumanInputV2ChannelsImByChannelIdResponse = + PutWorkspacesCurrentHumanInputV2ChannelsImByChannelIdResponses[keyof PutWorkspacesCurrentHumanInputV2ChannelsImByChannelIdResponses] + +export type PostWorkspacesCurrentHumanInputV2ChannelsImByChannelIdReplacementData = { + body: ImChannelReplacementPayloadWritable + path: { + channel_id: string + } + query?: never + url: '/workspaces/current/human-input/v2/channels/im/{channel_id}/replacement' +} + +export type PostWorkspacesCurrentHumanInputV2ChannelsImByChannelIdReplacementErrors = { + 409: ChannelConflictResponse | ErrorResponse + default: ErrorResponse +} + +export type PostWorkspacesCurrentHumanInputV2ChannelsImByChannelIdReplacementError = + PostWorkspacesCurrentHumanInputV2ChannelsImByChannelIdReplacementErrors[keyof PostWorkspacesCurrentHumanInputV2ChannelsImByChannelIdReplacementErrors] + +export type PostWorkspacesCurrentHumanInputV2ChannelsImByChannelIdReplacementResponses = { + 200: ImChannelMutationResponse +} + +export type PostWorkspacesCurrentHumanInputV2ChannelsImByChannelIdReplacementResponse = + PostWorkspacesCurrentHumanInputV2ChannelsImByChannelIdReplacementResponses[keyof PostWorkspacesCurrentHumanInputV2ChannelsImByChannelIdReplacementResponses] + +export type PostWorkspacesCurrentHumanInputV2ChannelsImByChannelIdTestData = { + body: ImChannelUpdatePayloadWritable + path: { + channel_id: string + } + query?: never + url: '/workspaces/current/human-input/v2/channels/im/{channel_id}/test' +} + +export type PostWorkspacesCurrentHumanInputV2ChannelsImByChannelIdTestErrors = { + 400: ErrorResponse + 409: ChannelConflictResponse | ErrorResponse + default: ErrorResponse +} + +export type PostWorkspacesCurrentHumanInputV2ChannelsImByChannelIdTestError = + PostWorkspacesCurrentHumanInputV2ChannelsImByChannelIdTestErrors[keyof PostWorkspacesCurrentHumanInputV2ChannelsImByChannelIdTestErrors] + +export type PostWorkspacesCurrentHumanInputV2ChannelsImByChannelIdTestResponses = { + 200: ChannelTestResponse +} + +export type PostWorkspacesCurrentHumanInputV2ChannelsImByChannelIdTestResponse = + PostWorkspacesCurrentHumanInputV2ChannelsImByChannelIdTestResponses[keyof PostWorkspacesCurrentHumanInputV2ChannelsImByChannelIdTestResponses] + +export type GetWorkspacesCurrentHumanInputV2ContactOptionsData = { + body?: never + path?: never + query?: { + keyword?: string + limit?: number + page?: number + } + url: '/workspaces/current/human-input/v2/contact-options' +} + +export type GetWorkspacesCurrentHumanInputV2ContactOptionsErrors = { + default: ErrorResponse +} + +export type GetWorkspacesCurrentHumanInputV2ContactOptionsError = + GetWorkspacesCurrentHumanInputV2ContactOptionsErrors[keyof GetWorkspacesCurrentHumanInputV2ContactOptionsErrors] + +export type GetWorkspacesCurrentHumanInputV2ContactOptionsResponses = { + 200: ListContactOptionsResponse +} + +export type GetWorkspacesCurrentHumanInputV2ContactOptionsResponse = + GetWorkspacesCurrentHumanInputV2ContactOptionsResponses[keyof GetWorkspacesCurrentHumanInputV2ContactOptionsResponses] + +export type GetWorkspacesCurrentHumanInputV2ContactOptionsBatchData = { + body?: never + path?: never + query: { + contact_ids: Array + } + url: '/workspaces/current/human-input/v2/contact-options/batch' +} + +export type GetWorkspacesCurrentHumanInputV2ContactOptionsBatchErrors = { + default: ErrorResponse +} + +export type GetWorkspacesCurrentHumanInputV2ContactOptionsBatchError = + GetWorkspacesCurrentHumanInputV2ContactOptionsBatchErrors[keyof GetWorkspacesCurrentHumanInputV2ContactOptionsBatchErrors] + +export type GetWorkspacesCurrentHumanInputV2ContactOptionsBatchResponses = { + 200: BatchGetContactOptionsResponse +} + +export type GetWorkspacesCurrentHumanInputV2ContactOptionsBatchResponse = + GetWorkspacesCurrentHumanInputV2ContactOptionsBatchResponses[keyof GetWorkspacesCurrentHumanInputV2ContactOptionsBatchResponses] + +export type GetWorkspacesCurrentHumanInputV2ContactsData = { + body?: never + path?: never + query?: { + group?: 'external' | 'platform' | 'workspace' + keyword?: string + limit?: number + page?: number + } + url: '/workspaces/current/human-input/v2/contacts' +} + +export type GetWorkspacesCurrentHumanInputV2ContactsErrors = { + default: ErrorResponse +} + +export type GetWorkspacesCurrentHumanInputV2ContactsError = + GetWorkspacesCurrentHumanInputV2ContactsErrors[keyof GetWorkspacesCurrentHumanInputV2ContactsErrors] + +export type GetWorkspacesCurrentHumanInputV2ContactsResponses = { + 200: ListContactsResponse +} + +export type GetWorkspacesCurrentHumanInputV2ContactsResponse = + GetWorkspacesCurrentHumanInputV2ContactsResponses[keyof GetWorkspacesCurrentHumanInputV2ContactsResponses] + +export type GetWorkspacesCurrentHumanInputV2ContactsBatchData = { + body?: never + path?: never + query: { + contact_ids: Array + } + url: '/workspaces/current/human-input/v2/contacts/batch' +} + +export type GetWorkspacesCurrentHumanInputV2ContactsBatchErrors = { + default: ErrorResponse +} + +export type GetWorkspacesCurrentHumanInputV2ContactsBatchError = + GetWorkspacesCurrentHumanInputV2ContactsBatchErrors[keyof GetWorkspacesCurrentHumanInputV2ContactsBatchErrors] + +export type GetWorkspacesCurrentHumanInputV2ContactsBatchResponses = { + 200: BatchGetContactsResponse +} + +export type GetWorkspacesCurrentHumanInputV2ContactsBatchResponse = + GetWorkspacesCurrentHumanInputV2ContactsBatchResponses[keyof GetWorkspacesCurrentHumanInputV2ContactsBatchResponses] + +export type PostWorkspacesCurrentHumanInputV2ContactsExternalData = { + body: ExternalContactCreateRequest + path?: never + query?: never + url: '/workspaces/current/human-input/v2/contacts/external' +} + +export type PostWorkspacesCurrentHumanInputV2ContactsExternalErrors = { + default: ErrorResponse +} + +export type PostWorkspacesCurrentHumanInputV2ContactsExternalError = + PostWorkspacesCurrentHumanInputV2ContactsExternalErrors[keyof PostWorkspacesCurrentHumanInputV2ContactsExternalErrors] + +export type PostWorkspacesCurrentHumanInputV2ContactsExternalResponses = { + 200: ExternalContactCreateResponse +} + +export type PostWorkspacesCurrentHumanInputV2ContactsExternalResponse = + PostWorkspacesCurrentHumanInputV2ContactsExternalResponses[keyof PostWorkspacesCurrentHumanInputV2ContactsExternalResponses] + +export type PatchWorkspacesCurrentHumanInputV2ContactsExternalByContactIdData = { + body: ExternalContactUpdateRequest + path: { + contact_id: string + } + query?: never + url: '/workspaces/current/human-input/v2/contacts/external/{contact_id}' +} + +export type PatchWorkspacesCurrentHumanInputV2ContactsExternalByContactIdErrors = { + default: ErrorResponse +} + +export type PatchWorkspacesCurrentHumanInputV2ContactsExternalByContactIdError = + PatchWorkspacesCurrentHumanInputV2ContactsExternalByContactIdErrors[keyof PatchWorkspacesCurrentHumanInputV2ContactsExternalByContactIdErrors] + +export type PatchWorkspacesCurrentHumanInputV2ContactsExternalByContactIdResponses = { + 200: ExternalContactUpdateResponse +} + +export type PatchWorkspacesCurrentHumanInputV2ContactsExternalByContactIdResponse = + PatchWorkspacesCurrentHumanInputV2ContactsExternalByContactIdResponses[keyof PatchWorkspacesCurrentHumanInputV2ContactsExternalByContactIdResponses] + +export type PostWorkspacesCurrentHumanInputV2ContactsPlatformData = { + body: AddPlatformContactsRequest + path?: never + query?: never + url: '/workspaces/current/human-input/v2/contacts/platform' +} + +export type PostWorkspacesCurrentHumanInputV2ContactsPlatformErrors = { + default: ErrorResponse +} + +export type PostWorkspacesCurrentHumanInputV2ContactsPlatformError = + PostWorkspacesCurrentHumanInputV2ContactsPlatformErrors[keyof PostWorkspacesCurrentHumanInputV2ContactsPlatformErrors] + +export type PostWorkspacesCurrentHumanInputV2ContactsPlatformResponses = { + 200: AddPlatformContactsResponse +} + +export type PostWorkspacesCurrentHumanInputV2ContactsPlatformResponse = + PostWorkspacesCurrentHumanInputV2ContactsPlatformResponses[keyof PostWorkspacesCurrentHumanInputV2ContactsPlatformResponses] + +export type PostWorkspacesCurrentHumanInputV2ContactsRemoveData = { + body: RemoveContactsRequest + path?: never + query?: never + url: '/workspaces/current/human-input/v2/contacts/remove' +} + +export type PostWorkspacesCurrentHumanInputV2ContactsRemoveErrors = { + default: ErrorResponse +} + +export type PostWorkspacesCurrentHumanInputV2ContactsRemoveError = + PostWorkspacesCurrentHumanInputV2ContactsRemoveErrors[keyof PostWorkspacesCurrentHumanInputV2ContactsRemoveErrors] + +export type PostWorkspacesCurrentHumanInputV2ContactsRemoveResponses = { + 200: RemoveContactsResponse +} + +export type PostWorkspacesCurrentHumanInputV2ContactsRemoveResponse = + PostWorkspacesCurrentHumanInputV2ContactsRemoveResponses[keyof PostWorkspacesCurrentHumanInputV2ContactsRemoveResponses] + +export type GetWorkspacesCurrentHumanInputV2ContactsByContactIdData = { + body?: never + path: { + contact_id: string + } + query?: never + url: '/workspaces/current/human-input/v2/contacts/{contact_id}' +} + +export type GetWorkspacesCurrentHumanInputV2ContactsByContactIdErrors = { + 404: ErrorResponse + default: ErrorResponse +} + +export type GetWorkspacesCurrentHumanInputV2ContactsByContactIdError = + GetWorkspacesCurrentHumanInputV2ContactsByContactIdErrors[keyof GetWorkspacesCurrentHumanInputV2ContactsByContactIdErrors] + +export type GetWorkspacesCurrentHumanInputV2ContactsByContactIdResponses = { + 200: GetContactResponse +} + +export type GetWorkspacesCurrentHumanInputV2ContactsByContactIdResponse = + GetWorkspacesCurrentHumanInputV2ContactsByContactIdResponses[keyof GetWorkspacesCurrentHumanInputV2ContactsByContactIdResponses] + +export type DeleteWorkspacesCurrentHumanInputV2ContactsByContactIdImBindingsData = { + body?: never + path: { + contact_id: string + } + query?: never + url: '/workspaces/current/human-input/v2/contacts/{contact_id}/im-bindings' +} + +export type DeleteWorkspacesCurrentHumanInputV2ContactsByContactIdImBindingsErrors = { + 403: ErrorResponse + 404: ImContactSyncErrorResponse | ErrorResponse + 503: ImContactSyncErrorResponse | ErrorResponse + default: ErrorResponse +} + +export type DeleteWorkspacesCurrentHumanInputV2ContactsByContactIdImBindingsError = + DeleteWorkspacesCurrentHumanInputV2ContactsByContactIdImBindingsErrors[keyof DeleteWorkspacesCurrentHumanInputV2ContactsByContactIdImBindingsErrors] + +export type DeleteWorkspacesCurrentHumanInputV2ContactsByContactIdImBindingsResponses = { + 200: DeleteImBindingResponse +} + +export type DeleteWorkspacesCurrentHumanInputV2ContactsByContactIdImBindingsResponse = + DeleteWorkspacesCurrentHumanInputV2ContactsByContactIdImBindingsResponses[keyof DeleteWorkspacesCurrentHumanInputV2ContactsByContactIdImBindingsResponses] + +export type PutWorkspacesCurrentHumanInputV2ContactsByContactIdImBindingsData = { + body: CreateImBindingRequest + path: { + contact_id: string + } + query?: never + url: '/workspaces/current/human-input/v2/contacts/{contact_id}/im-bindings' +} + +export type PutWorkspacesCurrentHumanInputV2ContactsByContactIdImBindingsErrors = { + 403: ErrorResponse + 404: ImContactSyncErrorResponse | ErrorResponse + 409: ImContactSyncErrorResponse | ErrorResponse + 503: ImContactSyncErrorResponse | ErrorResponse + default: ErrorResponse +} + +export type PutWorkspacesCurrentHumanInputV2ContactsByContactIdImBindingsError = + PutWorkspacesCurrentHumanInputV2ContactsByContactIdImBindingsErrors[keyof PutWorkspacesCurrentHumanInputV2ContactsByContactIdImBindingsErrors] + +export type PutWorkspacesCurrentHumanInputV2ContactsByContactIdImBindingsResponses = { + 200: CreateImBindingResponse +} + +export type PutWorkspacesCurrentHumanInputV2ContactsByContactIdImBindingsResponse = + PutWorkspacesCurrentHumanInputV2ContactsByContactIdImBindingsResponses[keyof PutWorkspacesCurrentHumanInputV2ContactsByContactIdImBindingsResponses] + +export type DeleteWorkspacesCurrentHumanInputV2ContactsByContactIdImOverrideData = { + body?: never + path: { + contact_id: string + } + query?: never + url: '/workspaces/current/human-input/v2/contacts/{contact_id}/im-override' +} + +export type DeleteWorkspacesCurrentHumanInputV2ContactsByContactIdImOverrideErrors = { + 403: ErrorResponse + 404: ImContactSyncErrorResponse | ErrorResponse + 422: ImContactSyncErrorResponse | ErrorResponse + 503: ImContactSyncErrorResponse | ErrorResponse + default: ErrorResponse +} + +export type DeleteWorkspacesCurrentHumanInputV2ContactsByContactIdImOverrideError = + DeleteWorkspacesCurrentHumanInputV2ContactsByContactIdImOverrideErrors[keyof DeleteWorkspacesCurrentHumanInputV2ContactsByContactIdImOverrideErrors] + +export type DeleteWorkspacesCurrentHumanInputV2ContactsByContactIdImOverrideResponses = { + 200: ResetContactImOverrideResponse +} + +export type DeleteWorkspacesCurrentHumanInputV2ContactsByContactIdImOverrideResponse = + DeleteWorkspacesCurrentHumanInputV2ContactsByContactIdImOverrideResponses[keyof DeleteWorkspacesCurrentHumanInputV2ContactsByContactIdImOverrideResponses] + +export type PutWorkspacesCurrentHumanInputV2ContactsByContactIdImOverrideData = { + body: SetContactImOverrideRequest + path: { + contact_id: string + } + query?: never + url: '/workspaces/current/human-input/v2/contacts/{contact_id}/im-override' +} + +export type PutWorkspacesCurrentHumanInputV2ContactsByContactIdImOverrideErrors = { + 403: ErrorResponse + 404: ImContactSyncErrorResponse | ErrorResponse + 409: ImContactSyncErrorResponse | ErrorResponse + 422: ImContactSyncErrorResponse | ErrorResponse + 503: ImContactSyncErrorResponse | ErrorResponse + default: ErrorResponse +} + +export type PutWorkspacesCurrentHumanInputV2ContactsByContactIdImOverrideError = + PutWorkspacesCurrentHumanInputV2ContactsByContactIdImOverrideErrors[keyof PutWorkspacesCurrentHumanInputV2ContactsByContactIdImOverrideErrors] + +export type PutWorkspacesCurrentHumanInputV2ContactsByContactIdImOverrideResponses = { + 200: SetContactImOverrideResponse +} + +export type PutWorkspacesCurrentHumanInputV2ContactsByContactIdImOverrideResponse = + PutWorkspacesCurrentHumanInputV2ContactsByContactIdImOverrideResponses[keyof PutWorkspacesCurrentHumanInputV2ContactsByContactIdImOverrideResponses] + +export type GetWorkspacesCurrentHumanInputV2ImIdentitiesData = { + body?: never + path?: never + query?: { + keyword?: string + limit?: number + page?: number + } + url: '/workspaces/current/human-input/v2/im-identities' +} + +export type GetWorkspacesCurrentHumanInputV2ImIdentitiesErrors = { + 404: ImContactSyncErrorResponse | ErrorResponse + default: ErrorResponse +} + +export type GetWorkspacesCurrentHumanInputV2ImIdentitiesError = + GetWorkspacesCurrentHumanInputV2ImIdentitiesErrors[keyof GetWorkspacesCurrentHumanInputV2ImIdentitiesErrors] + +export type GetWorkspacesCurrentHumanInputV2ImIdentitiesResponses = { + 200: ListImIdentitiesResponse +} + +export type GetWorkspacesCurrentHumanInputV2ImIdentitiesResponse = + GetWorkspacesCurrentHumanInputV2ImIdentitiesResponses[keyof GetWorkspacesCurrentHumanInputV2ImIdentitiesResponses] + +export type PostWorkspacesCurrentHumanInputV2ImSyncRunsData = { + body?: never + path?: never + query?: never + url: '/workspaces/current/human-input/v2/im-sync-runs' +} + +export type PostWorkspacesCurrentHumanInputV2ImSyncRunsErrors = { + 404: ImContactSyncErrorResponse | ErrorResponse + 409: ImContactSyncErrorResponse | ErrorResponse + 503: ImContactSyncErrorResponse | ErrorResponse + default: ErrorResponse +} + +export type PostWorkspacesCurrentHumanInputV2ImSyncRunsError = + PostWorkspacesCurrentHumanInputV2ImSyncRunsErrors[keyof PostWorkspacesCurrentHumanInputV2ImSyncRunsErrors] + +export type PostWorkspacesCurrentHumanInputV2ImSyncRunsResponses = { + 200: CreateImSyncRunResponse +} + +export type PostWorkspacesCurrentHumanInputV2ImSyncRunsResponse = + PostWorkspacesCurrentHumanInputV2ImSyncRunsResponses[keyof PostWorkspacesCurrentHumanInputV2ImSyncRunsResponses] + +export type GetWorkspacesCurrentHumanInputV2ImSyncRunsLatestData = { + body?: never + path?: never + query?: never + url: '/workspaces/current/human-input/v2/im-sync-runs/latest' +} + +export type GetWorkspacesCurrentHumanInputV2ImSyncRunsLatestErrors = { + 404: ImContactSyncErrorResponse | ErrorResponse + default: ErrorResponse +} + +export type GetWorkspacesCurrentHumanInputV2ImSyncRunsLatestError = + GetWorkspacesCurrentHumanInputV2ImSyncRunsLatestErrors[keyof GetWorkspacesCurrentHumanInputV2ImSyncRunsLatestErrors] + +export type GetWorkspacesCurrentHumanInputV2ImSyncRunsLatestResponses = { + 200: GetLatestImSyncRunResponse +} + +export type GetWorkspacesCurrentHumanInputV2ImSyncRunsLatestResponse = + GetWorkspacesCurrentHumanInputV2ImSyncRunsLatestResponses[keyof GetWorkspacesCurrentHumanInputV2ImSyncRunsLatestResponses] + +export type GetWorkspacesCurrentHumanInputV2ImSyncRunsLatestResultsData = { + body?: never + path?: never + query: { + limit?: number + page?: number + result: 'added' | 'failed' | 'not_matched' | 'removed' | 'skipped' + } + url: '/workspaces/current/human-input/v2/im-sync-runs/latest/results' +} + +export type GetWorkspacesCurrentHumanInputV2ImSyncRunsLatestResultsErrors = { + 404: ImContactSyncErrorResponse | ErrorResponse + default: ErrorResponse +} + +export type GetWorkspacesCurrentHumanInputV2ImSyncRunsLatestResultsError = + GetWorkspacesCurrentHumanInputV2ImSyncRunsLatestResultsErrors[keyof GetWorkspacesCurrentHumanInputV2ImSyncRunsLatestResultsErrors] + +export type GetWorkspacesCurrentHumanInputV2ImSyncRunsLatestResultsResponses = { + 200: ListLatestImSyncRunResultsResponse +} + +export type GetWorkspacesCurrentHumanInputV2ImSyncRunsLatestResultsResponse = + GetWorkspacesCurrentHumanInputV2ImSyncRunsLatestResultsResponses[keyof GetWorkspacesCurrentHumanInputV2ImSyncRunsLatestResultsResponses] + +export type PostWorkspacesCurrentHumanInputV2NodeDataMigrationData = { + body: NodeDataMigrationPayload + path?: never + query?: never + url: '/workspaces/current/human-input/v2/node-data-migration' +} + +export type PostWorkspacesCurrentHumanInputV2NodeDataMigrationErrors = { + 400: NodeDataMigrationFailureResponse | ErrorResponse + default: ErrorResponse +} + +export type PostWorkspacesCurrentHumanInputV2NodeDataMigrationError = + PostWorkspacesCurrentHumanInputV2NodeDataMigrationErrors[keyof PostWorkspacesCurrentHumanInputV2NodeDataMigrationErrors] + +export type PostWorkspacesCurrentHumanInputV2NodeDataMigrationResponses = { + 200: NodeDataMigrationResponse +} + +export type PostWorkspacesCurrentHumanInputV2NodeDataMigrationResponse = + PostWorkspacesCurrentHumanInputV2NodeDataMigrationResponses[keyof PostWorkspacesCurrentHumanInputV2NodeDataMigrationResponses] + +export type GetWorkspacesCurrentHumanInputV2OrganizationCandidatesData = { + body?: never + path?: never + query?: { + keyword?: string + limit?: number + page?: number + } + url: '/workspaces/current/human-input/v2/organization-candidates' +} + +export type GetWorkspacesCurrentHumanInputV2OrganizationCandidatesErrors = { + default: ErrorResponse +} + +export type GetWorkspacesCurrentHumanInputV2OrganizationCandidatesError = + GetWorkspacesCurrentHumanInputV2OrganizationCandidatesErrors[keyof GetWorkspacesCurrentHumanInputV2OrganizationCandidatesErrors] + +export type GetWorkspacesCurrentHumanInputV2OrganizationCandidatesResponses = { + 200: ListOrganizationCandidatesResponse +} + +export type GetWorkspacesCurrentHumanInputV2OrganizationCandidatesResponse = + GetWorkspacesCurrentHumanInputV2OrganizationCandidatesResponses[keyof GetWorkspacesCurrentHumanInputV2OrganizationCandidatesResponses] + export type GetWorkspacesCurrentMembersData = { body?: never path?: never diff --git a/packages/contracts/generated/api/console/workspaces/zod.gen.ts b/packages/contracts/generated/api/console/workspaces/zod.gen.ts index 0fc964b1278..627fb4f0034 100644 --- a/packages/contracts/generated/api/console/workspaces/zod.gen.ts +++ b/packages/contracts/generated/api/console/workspaces/zod.gen.ts @@ -111,6 +111,98 @@ export const zEndpointUpdatePayload = z.object({ settings: z.record(z.string(), z.unknown()), }) +/** + * ChannelConflictResponse + */ +export const zChannelConflictResponse = z.object({ + code: z.enum(['provider_configuration_updated', 'replacement_required']), + message: z.string(), + status: z.literal(409).optional().default(409), +}) + +/** + * ExternalContactCreateRequest + * + * Request body for creating or updating one external contact. + */ +export const zExternalContactCreateRequest = z.object({ + avatar: z.string().nullish(), + email: z.string(), + name: z.string().min(1).max(255), +}) + +/** + * ExternalContactUpdateRequest + * + * Request body for creating or updating one external contact. + */ +export const zExternalContactUpdateRequest = z.object({ + avatar: z.string().nullish(), + email: z.string().nullish(), + name: z.string().min(1).max(255).nullish(), +}) + +/** + * AddPlatformContactsRequest + * + * Request body for adding one or more organization members as platform contacts. + */ +export const zAddPlatformContactsRequest = z.object({ + candidate_ids: z.array(z.string()).min(1), +}) + +/** + * RemoveContactsRequest + * + * Request body for batch-removing platform or external contacts. + */ +export const zRemoveContactsRequest = z.object({ + contact_ids: z.array(z.string()).min(1), +}) + +/** + * RemoveContactsResponse + * + * Response body returned after batch-removing contacts. + */ +export const zRemoveContactsResponse = z.object({ + removed_contact_ids: z.array(z.string()), +}) + +/** + * DeleteIMBindingResponse + */ +export const zDeleteImBindingResponse = z.record(z.string(), z.unknown()) + +/** + * IMContactSyncErrorResponse + * + * Stable expected application failure returned by IM sync and binding routes. + */ +export const zImContactSyncErrorResponse = z.object({ + code: z.string(), + message: z.string(), + status: z.int(), +}) + +/** + * CreateIMBindingRequest + * + * Request body for setting one workspace-scoped IM override. + */ +export const zCreateImBindingRequest = z.object({ + identity_id: z.string(), +}) + +/** + * SetContactIMOverrideRequest + * + * Request body for setting one workspace-scoped IM override. + */ +export const zSetContactImOverrideRequest = z.object({ + identity_id: z.string(), +}) + /** * MemberInvitePayload */ @@ -1372,6 +1464,127 @@ export const zSnippetImportResponse = z.object({ warnings: z.array(zDslImportWarning), }) +export const zSenderName = z.string().min(1).max(255) + +/** + * ChannelStatus + */ +export const zChannelStatus = z.enum([ + 'configured', + 'connected', + 'connection_failure', + 'invalid_credentials', +]) + +/** + * ChannelTestResponse + */ +export const zChannelTestResponse = z.object({ + status: zChannelStatus, + status_description: z.string(), +}) + +export const zChannelId = z.string().min(1) + +/** + * ChannelDeleteResponse + */ +export const zChannelDeleteResponse = z.object({ + channel_id: zChannelId, +}) + +export const zConfigVersion = z.string().min(1) + +/** + * UnchangedInput + * + * Retain the addressed saved field; valid only for updates and saved tests. + */ +export const zUnchangedInput = z.object({ + type: z.literal('unchanged'), +}) + +/** + * HumanInputContactSummary + * + * A trimmed version of `HumanInputContact` that only includes the fields needed for workflow orchestration. + */ +export const zHumanInputContactSummary = z.object({ + avatar_url: z.string().optional().default(''), + created_at: z.int(), + id: z.string(), + name: z.string(), +}) + +/** + * BatchGetContactsResponse + */ +export const zBatchGetContactsResponse = z.object({ + data: z.array(zHumanInputContactSummary), +}) + +/** + * NodeDataMigrationBlocker + * + * Stable node-scoped reason why the backend cannot produce lossless v2 data. + */ +export const zNodeDataMigrationBlocker = z.object({ + code: z.enum([ + 'configured-disabled-method', + 'conflicting-email-templates', + 'invalid-default-value', + 'invalid-email', + 'invalid-email-configuration', + 'missing-recipients', + 'unresolved-member', + 'unsupported-delivery-method', + 'unsupported-version', + ]), + method_id: z.string().nullish(), + node_id: z.string(), + node_title: z.string(), + value: z.string().nullish(), +}) + +/** + * NodeDataMigrationFailureResponse + * + * Whole-batch failure response without partial converted node data. + */ +export const zNodeDataMigrationFailureResponse = z.object({ + blockers: z.array(zNodeDataMigrationBlocker), + code: z + .literal('hitl_node_data_migration_failure') + .optional() + .default('hitl_node_data_migration_failure'), + message: z.string(), + status: z.literal(400).optional().default(400), +}) + +/** + * OrganizationCandidate + * + * One organization member candidate that may become a platform contact. + */ +export const zOrganizationCandidate = z.object({ + avatar_url: z.string().nullish(), + email: z.string(), + id: z.string(), + name: z.string(), +}) + +/** + * ListOrganizationCandidatesResponse + * + * Paginated response body for organization candidate search. + */ +export const zListOrganizationCandidatesResponse = z.object({ + data: z.array(zOrganizationCandidate), + limit: z.int(), + page: z.int(), + total: z.int(), +}) + /** * MemberInviteSuccessResponse */ @@ -2424,6 +2637,554 @@ export const zAccountWithRoleListResponse = z.object({ accounts: z.array(zAccountWithRoleResponse), }) +/** + * EmailProviderType + * + * Email provider supported by organization-level Human Input delivery. + */ +export const zEmailProviderType = z.enum(['resend']) + +/** + * IMProvider + * + * IM provider supported by Human Input contact and delivery flows. + */ +export const zImProvider = z.enum(['ding_talk', 'feishu', 'lark', 'ms_teams', 'slack', 'we_com']) + +/** + * ChannelProvider + */ +export const zChannelProvider = z.object({ + connection_mode: z.literal('custom_app').optional().default('custom_app'), + provider: z.union([zEmailProviderType, zImProvider]), +}) + +/** + * ListChannelProvidersResponse + */ +export const zListChannelProvidersResponse = z.object({ + email_providers: z.array(zChannelProvider), + im_providers: z.array(zChannelProvider), +}) + +/** + * ChannelSummary + */ +export const zChannelSummary = z.object({ + config_version: zConfigVersion, + created_at: z.int(), + display_identifier: z.string(), + id: zChannelId, + kind: z.enum(['email', 'im']), + provider: z.union([zEmailProviderType, zImProvider]), + status: zChannelStatus, + status_description: z.string(), + updated_at: z.int(), + webhook_url: z.string().nullable(), +}) + +/** + * ListChannelsResponse + */ +export const zListChannelsResponse = z.object({ + channels: z.array(zChannelSummary), +}) + +/** + * EmailChannelMutationResponse + */ +export const zEmailChannelMutationResponse = z.object({ + summary: zChannelSummary, +}) + +/** + * IMChannelMutationResponse + */ +export const zImChannelMutationResponse = z.object({ + summary: zChannelSummary, +}) + +/** + * ResendCredentials + */ +export const zResendCredentials = z.object({ + provider: z.literal('resend').optional().default('resend'), +}) + +export const zEmailProviderCredentials = zResendCredentials + +/** + * EmailChannelCreatePayload + */ +export const zEmailChannelCreatePayload = z.object({ + credentials: zEmailProviderCredentials, + sender_email: z.string(), + sender_name: zSenderName, +}) + +/** + * EmailChannelTestPayload + */ +export const zEmailChannelTestPayload = z.object({ + credentials: zEmailProviderCredentials, + sender_email: z.string(), + sender_name: zSenderName, +}) + +/** + * Redacted + * + * A configured credential whose stored value is omitted. + */ +export const zRedacted = z.object({ + type: z.literal('redacted').optional().default('redacted'), +}) + +/** + * ResendCredentialsResponse + */ +export const zResendCredentialsResponse = z.object({ + api_key: zRedacted, + provider: z.literal('resend'), +}) + +/** + * EmailChannelDetailResponse + */ +export const zEmailChannelDetailResponse = z.object({ + credentials: zResendCredentialsResponse, + sender_email: z.string(), + sender_name: z.string(), + summary: zChannelSummary, +}) + +/** + * ResendCredentialsUpdateInput + */ +export const zResendCredentialsUpdateInput = z.object({ + api_key: zUnchangedInput, + provider: z.literal('resend'), +}) + +export const zEmailProviderCredentialsUpdateInput = zResendCredentialsUpdateInput + +/** + * EmailChannelUpdatePayload + */ +export const zEmailChannelUpdatePayload = z.object({ + credentials: zEmailProviderCredentialsUpdateInput, + expected_config_version: zConfigVersion, + sender_email: z.union([z.string(), zUnchangedInput]), + sender_name: z.union([zSenderName, zUnchangedInput]), +}) + +/** + * FeishuCredentialsInput + */ +export const zFeishuCredentialsInput = z.object({ + app_id: z.string().min(1), + provider: z.literal('feishu'), +}) + +/** + * LarkCredentialsInput + */ +export const zLarkCredentialsInput = z.object({ + app_id: z.string().min(1), + provider: z.literal('lark'), +}) + +/** + * SlackCredentialsInput + */ +export const zSlackCredentialsInput = z.object({ + client_id: z.string().min(1), + provider: z.literal('slack'), +}) + +/** + * DingTalkCredentialsInput + */ +export const zDingTalkCredentialsInput = z.object({ + client_id: z.string().min(1), + corp_id: z.string().min(1), + provider: z.literal('ding_talk'), +}) + +/** + * MSTeamsCredentialsInput + */ +export const zMsTeamsCredentialsInput = z.object({ + client_id: z.string().min(1), + provider: z.literal('ms_teams'), + tenant_id: z.string().min(1), +}) + +/** + * WeComCredentialsInput + */ +export const zWeComCredentialsInput = z.object({ + agent_id: z.string().min(1), + corp_id: z.string().min(1), + provider: z.literal('we_com'), +}) + +export const zImProviderCredentialsInput = z.discriminatedUnion('provider', [ + zFeishuCredentialsInput, + zLarkCredentialsInput, + zSlackCredentialsInput, + zDingTalkCredentialsInput, + zMsTeamsCredentialsInput, + zWeComCredentialsInput, +]) + +/** + * IMChannelCreatePayload + */ +export const zImChannelCreatePayload = z.object({ + credentials: zImProviderCredentialsInput, +}) + +/** + * IMChannelTestPayload + */ +export const zImChannelTestPayload = z.object({ + credentials: zImProviderCredentialsInput, +}) + +/** + * IMChannelReplacementPayload + */ +export const zImChannelReplacementPayload = z.object({ + credentials: zImProviderCredentialsInput, + expected_config_version: zConfigVersion, +}) + +/** + * FeishuLarkCredentialsResponse + */ +export const zFeishuLarkCredentialsResponse = z.object({ + app_id: z.string(), + app_secret: zRedacted, + encrypt_key: zRedacted.nullable(), + provider: z.enum(['feishu', 'lark']), + verification_token: zRedacted.nullable(), +}) + +/** + * SlackCredentialsResponse + */ +export const zSlackCredentialsResponse = z.object({ + app_token: zRedacted.nullable(), + bot_token: zRedacted, + client_id: z.string(), + client_secret: zRedacted, + provider: z.literal('slack'), + signing_secret: zRedacted, +}) + +/** + * DingTalkCredentialsResponse + */ +export const zDingTalkCredentialsResponse = z.object({ + client_id: z.string(), + client_secret: zRedacted, + corp_id: z.string(), + provider: z.literal('ding_talk'), +}) + +/** + * MSTeamsCredentialsResponse + */ +export const zMsTeamsCredentialsResponse = z.object({ + client_id: z.string(), + client_secret: zRedacted, + provider: z.literal('ms_teams'), + tenant_id: z.string(), +}) + +/** + * WeComCredentialsResponse + */ +export const zWeComCredentialsResponse = z.object({ + agent_id: z.string(), + corp_id: z.string(), + provider: z.literal('we_com'), + secret: zRedacted, +}) + +export const zImProviderCredentialsResponse = z.discriminatedUnion('provider', [ + zFeishuLarkCredentialsResponse.extend({ provider: z.literal('feishu') }), + zFeishuLarkCredentialsResponse.extend({ provider: z.literal('lark') }), + zSlackCredentialsResponse, + zDingTalkCredentialsResponse, + zMsTeamsCredentialsResponse, + zWeComCredentialsResponse, +]) + +/** + * IMChannelDetailResponse + */ +export const zImChannelDetailResponse = z.object({ + credentials: zImProviderCredentialsResponse, + summary: zChannelSummary, +}) + +/** + * HumanInputContactType + * + * Concrete contact classification resolved in one workspace. + */ +export const zHumanInputContactType = z.enum(['external', 'platform', 'workspace']) + +/** + * ContactOption + * + * Least-privilege contact projection returned to workflow editors. + */ +export const zContactOption = z.object({ + avatar_url: z.string().nullish(), + email: z.string().nullish(), + id: z.string(), + name: z.string(), + type: zHumanInputContactType, +}) + +/** + * ListContactOptionsResponse + * + * Paginated editor-safe contact picker response. + */ +export const zListContactOptionsResponse = z.object({ + data: z.array(zContactOption), + limit: z.int(), + page: z.int(), + total: z.int(), +}) + +/** + * BatchGetContactOptionsResponse + */ +export const zBatchGetContactOptionsResponse = z.object({ + data: z.array(zContactOption), +}) + +/** + * ContactIMIdentityDetail + * + * Display fields for the identity selected by an effective contact binding. + */ +export const zContactImIdentityDetail = z.object({ + display_name: z.string().nullable(), + email: z.string().nullable(), + id: z.string(), + provider_user_id: z.string(), +}) + +/** + * IMBindingScope + * + * Resolution scope of a contact-to-IM-identity binding. + */ +export const zImBindingScope = z.enum(['organization', 'workspace']) + +/** + * ContactIMBindingDetail + */ +export const zContactImBindingDetail = z.object({ + id: z.string(), + identity: zContactImIdentityDetail, + provider: zImProvider, + scope: zImBindingScope, +}) + +/** + * IMBinding + */ +export const zImBinding = z.object({ + id: z.string(), + provider: zImProvider, + scope: zImBindingScope, +}) + +/** + * HumanInputContact + * + * One contact entity returned by contact-related APIs. + */ +export const zHumanInputContact = z.object({ + avatar_url: z.string().optional().default(''), + created_at: z.int(), + email: z.string().nullish(), + id: z.string(), + im_bindings: z.array(zImBinding).optional(), + name: z.string(), + type: zHumanInputContactType, +}) + +/** + * ListContactsResponse + * + * Paginated response body for contact list APIs. + */ +export const zListContactsResponse = z.object({ + data: z.array(zHumanInputContact), + limit: z.int(), + page: z.int(), + total: z.int(), +}) + +/** + * ExternalContactCreateResponse + */ +export const zExternalContactCreateResponse = z.object({ + contact: zHumanInputContact, +}) + +/** + * ExternalContactUpdateResponse + */ +export const zExternalContactUpdateResponse = z.object({ + contact: zHumanInputContact, +}) + +/** + * AddPlatformContactsResponse + * + * Response body for adding platform contacts. + */ +export const zAddPlatformContactsResponse = z.object({ + data: z.array(zHumanInputContact), +}) + +/** + * GetContactResponse + * + * Response body for one contact resolved in the current workspace scope. + */ +export const zGetContactResponse = z.object({ + contact: zHumanInputContact, + im_binding_details: z.array(zContactImBindingDetail), +}) + +/** + * CreateIMBindingResponse + * + * Response body returned after binding one IM identity to the workspace. + */ +export const zCreateImBindingResponse = z.object({ + contact: zHumanInputContact, +}) + +/** + * ResetContactIMOverrideResponse + * + * Response body returned after resetting one contact IM override. + */ +export const zResetContactImOverrideResponse = z.object({ + contact: zHumanInputContact, +}) + +/** + * SetContactIMOverrideResponse + * + * Response body returned after setting one contact IM override. + */ +export const zSetContactImOverrideResponse = z.object({ + contact: zHumanInputContact, +}) + +/** + * IMIdentityBindingStatus + * + * Whether a synchronized IM identity is currently bound. + */ +export const zImIdentityBindingStatus = z.enum(['bound', 'unbound']) + +/** + * IMIdentity + * + * One synced IM identity that may be bound or overridden. + */ +export const zImIdentity = z.object({ + binding_status: zImIdentityBindingStatus, + display_name: z.string().nullish(), + email: z.string().nullish(), + id: z.string(), + provider: zImProvider, + provider_user_id: z.string(), +}) + +/** + * ListIMIdentitiesResponse + * + * Paginated response body for synced IM identity search. + */ +export const zListImIdentitiesResponse = z.object({ + data: z.array(zImIdentity), + limit: z.int(), + page: z.int(), + total: z.int(), +}) + +/** + * IMSyncRunResultCounts + * + * Aggregate result counts for one IM sync run. + */ +export const zImSyncRunResultCounts = z.object({ + added: z.int(), + failed: z.int(), + not_matched: z.int(), + removed: z.int(), + skipped: z.int(), +}) + +/** + * IMSyncRunStatus + * + * Lifecycle state of a manual IM directory synchronization. + */ +export const zImSyncRunStatus = z.enum(['failed', 'queued', 'running', 'succeeded']) + +/** + * IMSyncRun + * + * One IM sync run snapshot. + * + * The latest-only UI displays ``finished_at`` as the explicit sync time. The + * transport contract intentionally does not expose a ``started_by`` actor. + */ +export const zImSyncRun = z.object({ + channel_config_version: z.int().gte(1), + channel_id: z.string(), + error_message: z.string().nullish(), + finished_at: z.int().nullish(), + id: z.string(), + provider: zImProvider, + result_counts: zImSyncRunResultCounts, + started_at: z.int().nullish(), + status: zImSyncRunStatus, +}) + +/** + * CreateIMSyncRunResponse + * + * Response body returned after creating one sync run. + */ +export const zCreateImSyncRunResponse = z.object({ + run: zImSyncRun, +}) + +/** + * GetLatestIMSyncRunResponse + * + * Response body for reading the latest IM sync run summary. + */ +export const zGetLatestImSyncRunResponse = z.object({ + run: zImSyncRun, +}) + /** * ConfigurateMethod * @@ -3310,6 +4071,292 @@ export const zEndpointDeclarationResponse = z.object({ path: z.string(), }) +export const zRequiredSecret = z.string().min(1) + +export const zOptionalSecret = z.string().min(1).nullable() + +export const zIdentifier = z.string().min(1).regex(/\S/) + +/** + * FeishuCredentialsUpdateInput + */ +export const zFeishuCredentialsUpdateInput = z.object({ + app_id: z.union([zIdentifier, zUnchangedInput]), + app_secret: zUnchangedInput, + encrypt_key: zUnchangedInput, + provider: z.literal('feishu'), + verification_token: zUnchangedInput, +}) + +/** + * LarkCredentialsUpdateInput + */ +export const zLarkCredentialsUpdateInput = z.object({ + app_id: z.union([zIdentifier, zUnchangedInput]), + app_secret: zUnchangedInput, + encrypt_key: zUnchangedInput, + provider: z.literal('lark'), + verification_token: zUnchangedInput, +}) + +/** + * SlackCredentialsUpdateInput + */ +export const zSlackCredentialsUpdateInput = z.object({ + app_token: zUnchangedInput, + bot_token: zUnchangedInput, + client_id: z.union([zIdentifier, zUnchangedInput]), + client_secret: zUnchangedInput, + provider: z.literal('slack'), + signing_secret: zUnchangedInput, +}) + +/** + * DingTalkCredentialsUpdateInput + */ +export const zDingTalkCredentialsUpdateInput = z.object({ + client_id: z.union([zIdentifier, zUnchangedInput]), + client_secret: zUnchangedInput, + corp_id: z.union([zIdentifier, zUnchangedInput]), + provider: z.literal('ding_talk'), +}) + +/** + * WeComCredentialsUpdateInput + */ +export const zWeComCredentialsUpdateInput = z.object({ + agent_id: z.union([z.string(), zUnchangedInput]), + corp_id: z.union([zIdentifier, zUnchangedInput]), + provider: z.literal('we_com'), + secret: zUnchangedInput, +}) + +export const zUuidString = z + .string() + .regex(/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/) + +/** + * MSTeamsCredentialsUpdateInput + */ +export const zMsTeamsCredentialsUpdateInput = z.object({ + client_id: z.union([zUuidString, zUnchangedInput]), + client_secret: zUnchangedInput, + provider: z.literal('ms_teams'), + tenant_id: z.union([zUuidString, zUnchangedInput]), +}) + +export const zImProviderCredentialsUpdateInput = z.discriminatedUnion('provider', [ + zFeishuCredentialsUpdateInput, + zLarkCredentialsUpdateInput, + zSlackCredentialsUpdateInput, + zDingTalkCredentialsUpdateInput, + zMsTeamsCredentialsUpdateInput, + zWeComCredentialsUpdateInput, +]) + +/** + * IMChannelUpdatePayload + */ +export const zImChannelUpdatePayload = z.object({ + credentials: zImProviderCredentialsUpdateInput, + expected_config_version: zConfigVersion, +}) + +/** + * IMDirectoryEntry + * + * Normalized provider-side account observed during an IM sync run. + * + * The entry is run-scoped input to identity and binding reconciliation. It does + * not represent a stable Dify identity and must not be referenced by bindings + * or runtime authorization. Sync results may retain a snapshot for display, + * diagnostics, and audit; durable references use IMIdentity or IMBinding IDs. + */ +export const zImDirectoryEntry = z.object({ + display_name: z.string().nullish(), + email: z.string().nullish(), + provider_user_id: z.string(), +}) + +/** + * IMSyncResultAdded + */ +export const zImSyncResultAdded = z.object({ + contact: zHumanInputContactSummary, + entry: zImDirectoryEntry, + type: z.literal('added').optional().default('added'), +}) + +/** + * IMSyncResultFailed + */ +export const zImSyncResultFailed = z.object({ + entry: zImDirectoryEntry.nullish(), + reason: z.string(), + type: z.literal('failed').optional().default('failed'), +}) + +/** + * IMSyncResultNotMatched + */ +export const zImSyncResultNotMatched = z.object({ + entry: zImDirectoryEntry.nullish(), + type: z.literal('not_matched').optional().default('not_matched'), +}) + +/** + * IMSyncResultSkipped + */ +export const zImSyncResultSkipped = z.object({ + contact: zHumanInputContactSummary, + entry: zImDirectoryEntry.nullish(), + type: z.literal('skipped').optional().default('skipped'), +}) + +/** + * IMIdentitySnapshot + * + * Last known persistent IM identity state retained by a sync result. + */ +export const zImIdentitySnapshot = z.object({ + display_name: z.string().nullish(), + email: z.string().nullish(), + identity_id: z.string(), + provider_user_id: z.string(), +}) + +/** + * IMSyncRemovalReason + * + * Stable reason for removing or replacing a current IM binding. + */ +export const zImSyncRemovalReason = z.enum([ + 'binding_invalidated', + 'binding_replaced', + 'not_present_in_directory', +]) + +/** + * IMSyncResultRemoved + */ +export const zImSyncResultRemoved = z.object({ + contact: zHumanInputContactSummary, + last_known_identity: zImIdentitySnapshot, + reason: zImSyncRemovalReason, + type: z.literal('removed').optional().default('removed'), +}) + +/** + * IMSyncResultItem + * + * One paginated reconciliation result entry for the latest sync run. + */ +export const zImSyncResultItem = z.object({ + id: z.string(), + result: z.discriminatedUnion('type', [ + zImSyncResultAdded, + zImSyncResultRemoved, + zImSyncResultFailed, + zImSyncResultNotMatched, + zImSyncResultSkipped, + ]), +}) + +/** + * ListLatestIMSyncRunResultsResponse + * + * Page-based latest-run results without cursor state or a repeated run summary. + */ +export const zListLatestImSyncRunResultsResponse = z.object({ + data: z.array(zImSyncResultItem), + limit: z.int(), + page: z.int(), + total: z.int(), +}) + +/** + * ErrorStrategy + */ +export const zErrorStrategy = z.enum(['default-value', 'fail-branch']) + +/** + * RetryConfig + * + * node retry config + */ +export const zRetryConfig = z.object({ + max_retries: z.int().optional().default(0), + retry_enabled: z.boolean().optional().default(false), + retry_interval: z.int().optional().default(0), +}) + +/** + * TimeoutUnit + * + * Timeout unit for form expiration. + */ +export const zTimeoutUnit = z.enum(['day', 'hour']) + +export const zNodeType = z.string() + +/** + * MessageTemplateConfig + */ +export const zMessageTemplateConfig = z.object({ + body: z.string(), + subject: z.string(), +}) + +/** + * Contact + */ +export const zContact = z.object({ + contact_id: z.string(), + type: z.literal('contact').optional().default('contact'), +}) + +/** + * DynamicEmail + */ +export const zDynamicEmail = z.object({ + selector: z.array(z.string()), + type: z.literal('dynamic_email').optional().default('dynamic_email'), +}) + +/** + * OnetimeEmail + */ +export const zOnetimeEmail = z.object({ + email: z.string(), + type: z.literal('onetime_email').optional().default('onetime_email'), +}) + +/** + * Initiator + */ +export const zInitiator = z.object({ + type: z.literal('initiator').optional().default('initiator'), +}) + +/** + * AllWorkspaceContacts + * + * Migration marker preserving legacy whole-workspace recipient intent. + * + * Runtime expansion is deliberately owned by the recipient resolver rather + * than the node-data migration helper. + */ +export const zAllWorkspaceContacts = z.object({ + type: z.literal('all_workspace_contacts').optional().default('all_workspace_contacts'), +}) + +/** + * TimeoutUnit + * + * Timeout unit for form expiration. + */ +export const zTimeoutUnitStrictStrictFalse = z.enum(['day', 'hour']) + /** * UnaddedModelConfiguration * @@ -3706,6 +4753,87 @@ export const zEndpointListResponse = z.object({ endpoints: z.array(zEndpointListItemResponse), }) +/** + * DefaultValueType + */ +export const zDefaultValueType = z.enum([ + 'array[file]', + 'array[number]', + 'array[object]', + 'array[string]', + 'number', + 'object', + 'string', +]) + +/** + * DefaultValue + */ +export const zDefaultValue = z.object({ + key: z.string(), + type: zDefaultValueType, + value: z.unknown().optional(), +}) + +/** + * _InteractiveSurfaceDeliveryConfig + */ +export const zInteractiveSurfaceDeliveryConfig = z.record(z.string(), z.unknown()) + +/** + * InteractiveSurfaceDeliveryMethod + */ +export const zInteractiveSurfaceDeliveryMethod = z.object({ + config: zInteractiveSurfaceDeliveryConfig.optional(), + enabled: z.boolean().optional().default(true), + id: z.uuid().optional(), + type: z.literal('webapp').optional().default('webapp'), +}) + +/** + * ButtonStyle + * + * Button styles for user actions. + */ +export const zButtonStyle = z.enum(['accent', 'default', 'ghost', 'primary']) + +/** + * UserActionConfig + * + * User action configuration. + */ +export const zUserActionConfig = z.object({ + button_style: zButtonStyle.optional().default('default'), + id: z + .string() + .max(20) + .regex(/^[A-Za-z_][A-Za-z0-9_]*$/), + title: z.string().max(100), +}) + +/** + * DebugChannel + * + * Delivery selection for editor debugging, resolved to current channel configuration. + */ +export const zDebugChannel = z.enum([ + 'ding_talk', + 'email', + 'feishu', + 'lark', + 'ms_teams', + 'slack', + 'we_com', +]) + +/** + * DebugModeConfig + */ +export const zDebugModeConfig = z.object({ + channels: z.array(zDebugChannel), + enabled: z.boolean().optional().default(false), +}) + /** * FormShowOnObject * @@ -4488,6 +5616,287 @@ export const zPluginBundleDependency = z.object({ */ export const zPluginBundleUploadResponse = z.array(zPluginBundleDependency) +/** + * InstantMessageProvider + */ +export const zInstantMessageProvider = z.enum(['discord', 'slack', 'teams']) + +/** + * FileType + */ +export const zFileType = z.enum(['audio', 'custom', 'document', 'image', 'video']) + +/** + * FileTransferMethod + */ +export const zFileTransferMethod = z.enum([ + 'datasource_file', + 'local_file', + 'remote_url', + 'tool_file', +]) + +/** + * FileInputConfig + */ +export const zFileInputConfig = z.object({ + allowed_file_extensions: z.array(z.string()).optional(), + allowed_file_types: z.array(zFileType).optional(), + allowed_file_upload_methods: z.array(zFileTransferMethod).optional(), + output_variable_name: z.string(), + type: z.literal('file').optional().default('file'), +}) + +/** + * FileListInputConfig + */ +export const zFileListInputConfig = z.object({ + allowed_file_extensions: z.array(z.string()).optional(), + allowed_file_types: z.array(zFileType).optional(), + allowed_file_upload_methods: z.array(zFileTransferMethod).optional(), + number_limits: z.int().gte(0).optional().default(0), + output_variable_name: z.string(), + type: z.literal('file-list').optional().default('file-list'), +}) + +/** + * BoundRecipient + */ +export const zBoundRecipient = z.object({ + reference_id: z.string(), + type: z.literal('member').optional().default('member'), +}) + +/** + * ExternalRecipient + */ +export const zExternalRecipient = z.object({ + email: z.string(), + type: z.literal('external').optional().default('external'), +}) + +/** + * EmailRecipients + */ +export const zEmailRecipients = z.object({ + include_bound_group: z.boolean().optional().default(false), + items: z.array(z.union([zBoundRecipient, zExternalRecipient])).optional(), +}) + +/** + * EmailDeliveryConfig + */ +export const zEmailDeliveryConfig = z.object({ + body: z.string(), + debug_mode: z.boolean().optional().default(false), + recipients: zEmailRecipients, + subject: z.string(), +}) + +/** + * EmailDeliveryMethod + */ +export const zEmailDeliveryMethod = z.object({ + config: zEmailDeliveryConfig, + enabled: z.boolean().optional().default(true), + id: z.uuid().optional(), + type: z.literal('email').optional().default('email'), +}) + +/** + * InstantMessageChannelRecipient + */ +export const zInstantMessageChannelRecipient = z.object({ + channel_id: z.string(), + type: z.literal('channel').optional().default('channel'), +}) + +/** + * InstantMessageUserRecipient + */ +export const zInstantMessageUserRecipient = z.object({ + type: z.literal('user').optional().default('user'), + user_id: z.string(), +}) + +/** + * InstantMessageRecipients + */ +export const zInstantMessageRecipients = z.object({ + items: z + .array(z.union([zInstantMessageChannelRecipient, zInstantMessageUserRecipient])) + .optional(), +}) + +/** + * InstantMessageDeliveryConfig + */ +export const zInstantMessageDeliveryConfig = z.object({ + message: z.string().nullish(), + provider: zInstantMessageProvider, + recipients: zInstantMessageRecipients.optional(), +}) + +/** + * InstantMessageDeliveryMethod + */ +export const zInstantMessageDeliveryMethod = z.object({ + config: zInstantMessageDeliveryConfig, + enabled: z.boolean().optional().default(true), + id: z.uuid().optional(), + type: z.literal('im').optional().default('im'), +}) + +/** + * ValueSourceType + * + * ValueSourceType records whether the value comes from a static setting + * in form definition, or a variable while the workflow is running. + */ +export const zValueSourceType = z.enum(['constant', 'variable']) + +/** + * StringSource + * + * Default configuration for form inputs. + */ +export const zStringSource = z.object({ + selector: z.array(z.string()).optional(), + type: zValueSourceType, + value: z.string().optional().default(''), +}) + +/** + * ParagraphInputConfig + * + * Form input definition. + */ +export const zParagraphInputConfig = z.object({ + default: zStringSource.nullish(), + output_variable_name: z.string(), + type: z.literal('paragraph').optional().default('paragraph'), +}) + +/** + * StringListSource + */ +export const zStringListSource = z.object({ + selector: z.array(z.string()).optional(), + type: zValueSourceType, + value: z.array(z.string()).optional(), +}) + +/** + * SelectInputConfig + */ +export const zSelectInputConfig = z.object({ + option_source: zStringListSource, + output_variable_name: z.string(), + type: z.literal('select').optional().default('select'), +}) + +export const zFormInputConfig = z.discriminatedUnion('type', [ + zParagraphInputConfig, + zSelectInputConfig, + zFileInputConfig, + zFileListInputConfig, +]) + +/** + * HumanInputNodeDataFull + */ +export const zLegacyHitLv1NodeData = z.object({ + default_value: z.array(zDefaultValue).nullish(), + delivery_methods: z + .array( + z.union([ + zInteractiveSurfaceDeliveryMethod, + zEmailDeliveryMethod, + zInstantMessageDeliveryMethod, + ]), + ) + .optional(), + desc: z.string().nullish(), + error_strategy: zErrorStrategy.nullish(), + form_content: z.string().optional().default(''), + inputs: z.array(zFormInputConfig).optional(), + retry_config: zRetryConfig.optional(), + timeout: z.int().optional().default(36), + timeout_unit: zTimeoutUnit.optional().default('hour'), + title: z.string().optional().default(''), + type: zNodeType.optional().default('human-input'), + user_actions: z.array(zUserActionConfig).optional(), + version: z.string().optional().default('1'), +}) + +/** + * NodeDataMigrationInput + * + * One legacy node submitted through the frontend migration adapter boundary. + */ +export const zNodeDataMigrationInput = z.object({ + node_data: zLegacyHitLv1NodeData, + node_id: z.string(), +}) + +/** + * NodeDataMigrationPayload + * + * Complete legacy-node batch submitted for one migration attempt. + */ +export const zNodeDataMigrationPayload = z.object({ + nodes: z.array(zNodeDataMigrationInput).min(1), +}) + +/** + * HumanInputNodeData + * + * Human Input node data. + */ +export const zHumanInputNodeData = z.object({ + debug_mode: zDebugModeConfig, + default_value: z.array(zDefaultValue).nullish(), + desc: z.string().nullish(), + error_strategy: zErrorStrategy.nullish(), + form_content: z.string().optional().default(''), + inputs: z.array(zFormInputConfig).optional(), + message_template: zMessageTemplateConfig, + recipients_spec: z.array( + z.union([zContact, zDynamicEmail, zOnetimeEmail, zInitiator, zAllWorkspaceContacts]), + ), + retry_config: zRetryConfig.optional(), + timeout: z.int().optional().default(36), + timeout_unit: zTimeoutUnitStrictStrictFalse.optional().default('hour'), + title: z.string().optional().default(''), + type: z.literal('human-input').optional().default('human-input'), + user_actions: z.array(zUserActionConfig).optional(), + version: z.string().optional().default('2'), +}) + +/** + * NodeDataMigrationResult + * + * One converted node returned with its frontend correlation identifier. + */ +export const zNodeDataMigrationResult = z.object({ + node_data: zHumanInputNodeData, + node_id: z.string(), +}) + +/** + * NodeDataMigrationResponse + * + * Successful all-node conversion response. + */ +export const zNodeDataMigrationResponse = z.object({ + data: z.array(zNodeDataMigrationResult), +}) + +/** + * DeleteIMBindingResponse + */ +export const zDeleteImBindingResponseWritable = z.record(z.string(), z.unknown()) + /** * AccountWithRoleResponse */ @@ -4511,6 +5920,235 @@ export const zAccountWithRoleListResponseWritable = z.object({ accounts: z.array(zAccountWithRoleResponseWritable), }) +/** + * ResendCredentials + */ +export const zResendCredentialsWritable = z.object({ + api_key: zRequiredSecret, + provider: z.literal('resend').optional().default('resend'), +}) + +export const zEmailProviderCredentialsWritable = zResendCredentialsWritable + +/** + * EmailChannelCreatePayload + */ +export const zEmailChannelCreatePayloadWritable = z.object({ + credentials: zEmailProviderCredentialsWritable, + sender_email: z.string(), + sender_name: zSenderName, +}) + +/** + * EmailChannelTestPayload + */ +export const zEmailChannelTestPayloadWritable = z.object({ + credentials: zEmailProviderCredentialsWritable, + sender_email: z.string(), + sender_name: zSenderName, +}) + +/** + * ResendCredentialsUpdateInput + */ +export const zResendCredentialsUpdateInputWritable = z.object({ + api_key: z.union([zRequiredSecret, zUnchangedInput]), + provider: z.literal('resend'), +}) + +export const zEmailProviderCredentialsUpdateInputWritable = zResendCredentialsUpdateInputWritable + +/** + * EmailChannelUpdatePayload + */ +export const zEmailChannelUpdatePayloadWritable = z.object({ + credentials: zEmailProviderCredentialsUpdateInputWritable, + expected_config_version: zConfigVersion, + sender_email: z.union([z.string(), zUnchangedInput]), + sender_name: z.union([zSenderName, zUnchangedInput]), +}) + +/** + * FeishuCredentialsInput + */ +export const zFeishuCredentialsInputWritable = z.object({ + app_id: z.string().min(1), + app_secret: zRequiredSecret, + encrypt_key: zOptionalSecret.optional(), + provider: z.literal('feishu'), + verification_token: zOptionalSecret.optional(), +}) + +/** + * LarkCredentialsInput + */ +export const zLarkCredentialsInputWritable = z.object({ + app_id: z.string().min(1), + app_secret: zRequiredSecret, + encrypt_key: zOptionalSecret.optional(), + provider: z.literal('lark'), + verification_token: zOptionalSecret.optional(), +}) + +/** + * SlackCredentialsInput + */ +export const zSlackCredentialsInputWritable = z.object({ + app_token: zOptionalSecret.optional(), + bot_token: zRequiredSecret, + client_id: z.string().min(1), + client_secret: zRequiredSecret, + provider: z.literal('slack'), + signing_secret: zRequiredSecret, +}) + +/** + * DingTalkCredentialsInput + */ +export const zDingTalkCredentialsInputWritable = z.object({ + client_id: z.string().min(1), + client_secret: zRequiredSecret, + corp_id: z.string().min(1), + provider: z.literal('ding_talk'), +}) + +/** + * MSTeamsCredentialsInput + */ +export const zMsTeamsCredentialsInputWritable = z.object({ + client_id: z.string().min(1), + client_secret: zRequiredSecret, + provider: z.literal('ms_teams'), + tenant_id: z.string().min(1), +}) + +/** + * WeComCredentialsInput + */ +export const zWeComCredentialsInputWritable = z.object({ + agent_id: z.string().min(1), + corp_id: z.string().min(1), + provider: z.literal('we_com'), + secret: zRequiredSecret, +}) + +export const zImProviderCredentialsInputWritable = z.discriminatedUnion('provider', [ + zFeishuCredentialsInputWritable, + zLarkCredentialsInputWritable, + zSlackCredentialsInputWritable, + zDingTalkCredentialsInputWritable, + zMsTeamsCredentialsInputWritable, + zWeComCredentialsInputWritable, +]) + +/** + * IMChannelCreatePayload + */ +export const zImChannelCreatePayloadWritable = z.object({ + credentials: zImProviderCredentialsInputWritable, +}) + +/** + * IMChannelTestPayload + */ +export const zImChannelTestPayloadWritable = z.object({ + credentials: zImProviderCredentialsInputWritable, +}) + +/** + * IMChannelReplacementPayload + */ +export const zImChannelReplacementPayloadWritable = z.object({ + credentials: zImProviderCredentialsInputWritable, + expected_config_version: zConfigVersion, +}) + +/** + * FeishuCredentialsUpdateInput + */ +export const zFeishuCredentialsUpdateInputWritable = z.object({ + app_id: z.union([zIdentifier, zUnchangedInput]), + app_secret: z.union([zRequiredSecret, zUnchangedInput]), + encrypt_key: z.union([zOptionalSecret, zUnchangedInput]), + provider: z.literal('feishu'), + verification_token: z.union([zOptionalSecret, zUnchangedInput]), +}) + +/** + * LarkCredentialsUpdateInput + */ +export const zLarkCredentialsUpdateInputWritable = z.object({ + app_id: z.union([zIdentifier, zUnchangedInput]), + app_secret: z.union([zRequiredSecret, zUnchangedInput]), + encrypt_key: z.union([zOptionalSecret, zUnchangedInput]), + provider: z.literal('lark'), + verification_token: z.union([zOptionalSecret, zUnchangedInput]), +}) + +/** + * SlackCredentialsUpdateInput + */ +export const zSlackCredentialsUpdateInputWritable = z.object({ + app_token: z.union([zOptionalSecret, zUnchangedInput]), + bot_token: z.union([zRequiredSecret, zUnchangedInput]), + client_id: z.union([zIdentifier, zUnchangedInput]), + client_secret: z.union([zRequiredSecret, zUnchangedInput]), + provider: z.literal('slack'), + signing_secret: z.union([zRequiredSecret, zUnchangedInput]), +}) + +/** + * DingTalkCredentialsUpdateInput + */ +export const zDingTalkCredentialsUpdateInputWritable = z.object({ + client_id: z.union([zIdentifier, zUnchangedInput]), + client_secret: z.union([zRequiredSecret, zUnchangedInput]), + corp_id: z.union([zIdentifier, zUnchangedInput]), + provider: z.literal('ding_talk'), +}) + +/** + * MSTeamsCredentialsUpdateInput + */ +export const zMsTeamsCredentialsUpdateInputWritable = z.object({ + client_id: z.union([zUuidString, zUnchangedInput]), + client_secret: z.union([zRequiredSecret, zUnchangedInput]), + provider: z.literal('ms_teams'), + tenant_id: z.union([zUuidString, zUnchangedInput]), +}) + +/** + * WeComCredentialsUpdateInput + */ +export const zWeComCredentialsUpdateInputWritable = z.object({ + agent_id: z.union([z.string(), zUnchangedInput]), + corp_id: z.union([zIdentifier, zUnchangedInput]), + provider: z.literal('we_com'), + secret: z.union([zRequiredSecret, zUnchangedInput]), +}) + +export const zImProviderCredentialsUpdateInputWritable = z.discriminatedUnion('provider', [ + zFeishuCredentialsUpdateInputWritable, + zLarkCredentialsUpdateInputWritable, + zSlackCredentialsUpdateInputWritable, + zDingTalkCredentialsUpdateInputWritable, + zMsTeamsCredentialsUpdateInputWritable, + zWeComCredentialsUpdateInputWritable, +]) + +/** + * IMChannelUpdatePayload + */ +export const zImChannelUpdatePayloadWritable = z.object({ + credentials: zImProviderCredentialsUpdateInputWritable, + expected_config_version: zConfigVersion, +}) + +/** + * _InteractiveSurfaceDeliveryConfig + */ +export const zInteractiveSurfaceDeliveryConfigWritable = z.record(z.string(), z.unknown()) + /** * Success */ @@ -4755,6 +6393,346 @@ export const zPatchWorkspacesCurrentEndpointsByIdPath = z.object({ */ export const zPatchWorkspacesCurrentEndpointsByIdResponse = zEndpointMutationResponse +/** + * Success + */ +export const zGetWorkspacesCurrentHumanInputV2ChannelProvidersResponse = + zListChannelProvidersResponse + +/** + * Success + */ +export const zGetWorkspacesCurrentHumanInputV2ChannelsResponse = zListChannelsResponse + +export const zPostWorkspacesCurrentHumanInputV2ChannelsEmailBody = + zEmailChannelCreatePayloadWritable + +/** + * Success + */ +export const zPostWorkspacesCurrentHumanInputV2ChannelsEmailResponse = zEmailChannelMutationResponse + +export const zPostWorkspacesCurrentHumanInputV2ChannelsEmailTestBody = + zEmailChannelTestPayloadWritable + +/** + * Success + */ +export const zPostWorkspacesCurrentHumanInputV2ChannelsEmailTestResponse = zChannelTestResponse + +export const zDeleteWorkspacesCurrentHumanInputV2ChannelsEmailByChannelIdPath = z.object({ + channel_id: z.uuid(), +}) + +export const zDeleteWorkspacesCurrentHumanInputV2ChannelsEmailByChannelIdQuery = z.object({ + expected_config_version: z.string().min(1), +}) + +/** + * Success + */ +export const zDeleteWorkspacesCurrentHumanInputV2ChannelsEmailByChannelIdResponse = + zChannelDeleteResponse + +export const zGetWorkspacesCurrentHumanInputV2ChannelsEmailByChannelIdPath = z.object({ + channel_id: z.uuid(), +}) + +/** + * Success + */ +export const zGetWorkspacesCurrentHumanInputV2ChannelsEmailByChannelIdResponse = + zEmailChannelDetailResponse + +export const zPutWorkspacesCurrentHumanInputV2ChannelsEmailByChannelIdBody = + zEmailChannelUpdatePayloadWritable + +export const zPutWorkspacesCurrentHumanInputV2ChannelsEmailByChannelIdPath = z.object({ + channel_id: z.uuid(), +}) + +/** + * Success + */ +export const zPutWorkspacesCurrentHumanInputV2ChannelsEmailByChannelIdResponse = + zEmailChannelMutationResponse + +export const zPostWorkspacesCurrentHumanInputV2ChannelsEmailByChannelIdTestBody = + zEmailChannelUpdatePayloadWritable + +export const zPostWorkspacesCurrentHumanInputV2ChannelsEmailByChannelIdTestPath = z.object({ + channel_id: z.uuid(), +}) + +/** + * Success + */ +export const zPostWorkspacesCurrentHumanInputV2ChannelsEmailByChannelIdTestResponse = + zChannelTestResponse + +export const zPostWorkspacesCurrentHumanInputV2ChannelsImBody = zImChannelCreatePayloadWritable + +/** + * Success + */ +export const zPostWorkspacesCurrentHumanInputV2ChannelsImResponse = zImChannelMutationResponse + +export const zPostWorkspacesCurrentHumanInputV2ChannelsImTestBody = zImChannelTestPayloadWritable + +/** + * Success + */ +export const zPostWorkspacesCurrentHumanInputV2ChannelsImTestResponse = zChannelTestResponse + +export const zDeleteWorkspacesCurrentHumanInputV2ChannelsImByChannelIdPath = z.object({ + channel_id: z.uuid(), +}) + +export const zDeleteWorkspacesCurrentHumanInputV2ChannelsImByChannelIdQuery = z.object({ + expected_config_version: z.string().min(1), +}) + +/** + * Success + */ +export const zDeleteWorkspacesCurrentHumanInputV2ChannelsImByChannelIdResponse = + zChannelDeleteResponse + +export const zGetWorkspacesCurrentHumanInputV2ChannelsImByChannelIdPath = z.object({ + channel_id: z.uuid(), +}) + +/** + * Success + */ +export const zGetWorkspacesCurrentHumanInputV2ChannelsImByChannelIdResponse = + zImChannelDetailResponse + +export const zPutWorkspacesCurrentHumanInputV2ChannelsImByChannelIdBody = + zImChannelUpdatePayloadWritable + +export const zPutWorkspacesCurrentHumanInputV2ChannelsImByChannelIdPath = z.object({ + channel_id: z.uuid(), +}) + +/** + * Success + */ +export const zPutWorkspacesCurrentHumanInputV2ChannelsImByChannelIdResponse = + zImChannelMutationResponse + +export const zPostWorkspacesCurrentHumanInputV2ChannelsImByChannelIdReplacementBody = + zImChannelReplacementPayloadWritable + +export const zPostWorkspacesCurrentHumanInputV2ChannelsImByChannelIdReplacementPath = z.object({ + channel_id: z.uuid(), +}) + +/** + * Success + */ +export const zPostWorkspacesCurrentHumanInputV2ChannelsImByChannelIdReplacementResponse = + zImChannelMutationResponse + +export const zPostWorkspacesCurrentHumanInputV2ChannelsImByChannelIdTestBody = + zImChannelUpdatePayloadWritable + +export const zPostWorkspacesCurrentHumanInputV2ChannelsImByChannelIdTestPath = z.object({ + channel_id: z.uuid(), +}) + +/** + * Success + */ +export const zPostWorkspacesCurrentHumanInputV2ChannelsImByChannelIdTestResponse = + zChannelTestResponse + +export const zGetWorkspacesCurrentHumanInputV2ContactOptionsQuery = z.object({ + keyword: z.string().optional(), + limit: z.int().gt(0).lte(100).optional().default(20), + page: z.int().gt(0).optional().default(1), +}) + +/** + * Success + */ +export const zGetWorkspacesCurrentHumanInputV2ContactOptionsResponse = zListContactOptionsResponse + +export const zGetWorkspacesCurrentHumanInputV2ContactOptionsBatchQuery = z.object({ + contact_ids: z.array(z.string()), +}) + +/** + * Success + */ +export const zGetWorkspacesCurrentHumanInputV2ContactOptionsBatchResponse = + zBatchGetContactOptionsResponse + +export const zGetWorkspacesCurrentHumanInputV2ContactsQuery = z.object({ + group: z.enum(['external', 'platform', 'workspace']).optional(), + keyword: z.string().optional(), + limit: z.int().gt(0).lte(100).optional().default(20), + page: z.int().gt(0).optional().default(1), +}) + +/** + * Success + */ +export const zGetWorkspacesCurrentHumanInputV2ContactsResponse = zListContactsResponse + +export const zGetWorkspacesCurrentHumanInputV2ContactsBatchQuery = z.object({ + contact_ids: z.array(z.string()), +}) + +/** + * Success + */ +export const zGetWorkspacesCurrentHumanInputV2ContactsBatchResponse = zBatchGetContactsResponse + +export const zPostWorkspacesCurrentHumanInputV2ContactsExternalBody = zExternalContactCreateRequest + +/** + * Success + */ +export const zPostWorkspacesCurrentHumanInputV2ContactsExternalResponse = + zExternalContactCreateResponse + +export const zPatchWorkspacesCurrentHumanInputV2ContactsExternalByContactIdBody = + zExternalContactUpdateRequest + +export const zPatchWorkspacesCurrentHumanInputV2ContactsExternalByContactIdPath = z.object({ + contact_id: z.uuid(), +}) + +/** + * Success + */ +export const zPatchWorkspacesCurrentHumanInputV2ContactsExternalByContactIdResponse = + zExternalContactUpdateResponse + +export const zPostWorkspacesCurrentHumanInputV2ContactsPlatformBody = zAddPlatformContactsRequest + +/** + * Success + */ +export const zPostWorkspacesCurrentHumanInputV2ContactsPlatformResponse = + zAddPlatformContactsResponse + +export const zPostWorkspacesCurrentHumanInputV2ContactsRemoveBody = zRemoveContactsRequest + +/** + * Success + */ +export const zPostWorkspacesCurrentHumanInputV2ContactsRemoveResponse = zRemoveContactsResponse + +export const zGetWorkspacesCurrentHumanInputV2ContactsByContactIdPath = z.object({ + contact_id: z.uuid(), +}) + +/** + * Success + */ +export const zGetWorkspacesCurrentHumanInputV2ContactsByContactIdResponse = zGetContactResponse + +export const zDeleteWorkspacesCurrentHumanInputV2ContactsByContactIdImBindingsPath = z.object({ + contact_id: z.uuid(), +}) + +/** + * Success + */ +export const zDeleteWorkspacesCurrentHumanInputV2ContactsByContactIdImBindingsResponse = + zDeleteImBindingResponse + +export const zPutWorkspacesCurrentHumanInputV2ContactsByContactIdImBindingsBody = + zCreateImBindingRequest + +export const zPutWorkspacesCurrentHumanInputV2ContactsByContactIdImBindingsPath = z.object({ + contact_id: z.uuid(), +}) + +/** + * Success + */ +export const zPutWorkspacesCurrentHumanInputV2ContactsByContactIdImBindingsResponse = + zCreateImBindingResponse + +export const zDeleteWorkspacesCurrentHumanInputV2ContactsByContactIdImOverridePath = z.object({ + contact_id: z.uuid(), +}) + +/** + * Success + */ +export const zDeleteWorkspacesCurrentHumanInputV2ContactsByContactIdImOverrideResponse = + zResetContactImOverrideResponse + +export const zPutWorkspacesCurrentHumanInputV2ContactsByContactIdImOverrideBody = + zSetContactImOverrideRequest + +export const zPutWorkspacesCurrentHumanInputV2ContactsByContactIdImOverridePath = z.object({ + contact_id: z.uuid(), +}) + +/** + * Success + */ +export const zPutWorkspacesCurrentHumanInputV2ContactsByContactIdImOverrideResponse = + zSetContactImOverrideResponse + +export const zGetWorkspacesCurrentHumanInputV2ImIdentitiesQuery = z.object({ + keyword: z.string().optional(), + limit: z.int().gt(0).lte(100).optional().default(20), + page: z.int().gt(0).optional().default(1), +}) + +/** + * Success + */ +export const zGetWorkspacesCurrentHumanInputV2ImIdentitiesResponse = zListImIdentitiesResponse + +/** + * Success + */ +export const zPostWorkspacesCurrentHumanInputV2ImSyncRunsResponse = zCreateImSyncRunResponse + +/** + * Success + */ +export const zGetWorkspacesCurrentHumanInputV2ImSyncRunsLatestResponse = zGetLatestImSyncRunResponse + +export const zGetWorkspacesCurrentHumanInputV2ImSyncRunsLatestResultsQuery = z.object({ + limit: z.int().gt(0).lte(100).optional().default(20), + page: z.int().gt(0).optional().default(1), + result: z.enum(['added', 'failed', 'not_matched', 'removed', 'skipped']), +}) + +/** + * Success + */ +export const zGetWorkspacesCurrentHumanInputV2ImSyncRunsLatestResultsResponse = + zListLatestImSyncRunResultsResponse + +export const zPostWorkspacesCurrentHumanInputV2NodeDataMigrationBody = zNodeDataMigrationPayload + +/** + * Success + */ +export const zPostWorkspacesCurrentHumanInputV2NodeDataMigrationResponse = + zNodeDataMigrationResponse + +export const zGetWorkspacesCurrentHumanInputV2OrganizationCandidatesQuery = z.object({ + keyword: z.string().optional(), + limit: z.int().gt(0).lte(100).optional().default(20), + page: z.int().gt(0).optional().default(1), +}) + +/** + * Success + */ +export const zGetWorkspacesCurrentHumanInputV2OrganizationCandidatesResponse = + zListOrganizationCandidatesResponse + export const zGetWorkspacesCurrentMembersQuery = z.object({ language: z.enum(['en', 'ja', 'zh']).optional(), }) diff --git a/packages/contracts/generated/api/service/types.gen.ts b/packages/contracts/generated/api/service/types.gen.ts index 94f5164657f..9ef2398212e 100644 --- a/packages/contracts/generated/api/service/types.gen.ts +++ b/packages/contracts/generated/api/service/types.gen.ts @@ -1342,6 +1342,7 @@ export type HumanInputFormDefinition = { form_content: string form_id: string form_token?: string | null + form_version?: '1' | '2' inputs?: Array node_id: string node_title: string diff --git a/packages/contracts/generated/api/service/zod.gen.ts b/packages/contracts/generated/api/service/zod.gen.ts index c6a8f200bf0..56c183b5cae 100644 --- a/packages/contracts/generated/api/service/zod.gen.ts +++ b/packages/contracts/generated/api/service/zod.gen.ts @@ -1553,6 +1553,8 @@ export const zHumanInputFormSubmissionData = z.object({ /** * HumanInputFormSubmitPayload + * + * Legacy Human Input v1 submit payload shared by existing runtime surfaces. */ export const zHumanInputFormSubmitPayload = z.object({ action: z.string(), @@ -1561,6 +1563,8 @@ export const zHumanInputFormSubmitPayload = z.object({ /** * HumanInputFormSubmitPayload + * + * Legacy Human Input v1 submit payload shared by existing runtime surfaces. */ export const zHumanInputFormSubmitPayloadWithUser = z.object({ action: z.string(), @@ -5488,6 +5492,7 @@ export const zHumanInputFormDefinition = z.object({ form_content: z.string(), form_id: z.string(), form_token: z.string().nullish(), + form_version: z.enum(['1', '2']).optional().default('1'), inputs: z.array(zFormInputConfig).optional(), node_id: z.string(), node_title: z.string(), diff --git a/packages/contracts/generated/api/web/errors.gen.ts b/packages/contracts/generated/api/web/errors.gen.ts index 68bbe73227c..6b6269e18fa 100644 --- a/packages/contracts/generated/api/web/errors.gen.ts +++ b/packages/contracts/generated/api/web/errors.gen.ts @@ -34,6 +34,14 @@ const zAgentVersionNotFoundError = /* @__PURE__ */ (() => }), ))() +const zAlreadySubmittedError = /* @__PURE__ */ (() => + zErrorResponse.and( + z.object({ + code: z.literal('already_submitted'), + status: z.literal(409), + }), + ))() + const zAppMoreLikeThisDisabledError = /* @__PURE__ */ (() => zErrorResponse.and( z.object({ @@ -130,6 +138,14 @@ const zEmailSendIpLimitError = /* @__PURE__ */ (() => }), ))() +const zExpiredError = /* @__PURE__ */ (() => + zErrorResponse.and( + z.object({ + code: z.literal('expired'), + status: z.literal(410), + }), + ))() + const zFileExtensionBlockedError = /* @__PURE__ */ (() => zErrorResponse.and( z.object({ @@ -154,6 +170,38 @@ const zFilenameNotExistsError = /* @__PURE__ */ (() => }), ))() +const zHumanInputAccessDeliveryFailedError = /* @__PURE__ */ (() => + zErrorResponse.and( + z.object({ + code: z.literal('human_input_access_delivery_failed'), + status: z.literal(503), + }), + ))() + +const zHumanInputAccessRateLimitExceededError = /* @__PURE__ */ (() => + zErrorResponse.and( + z.object({ + code: z.literal('human_input_access_rate_limit_exceeded'), + status: z.literal(429), + }), + ))() + +const zHumanInputChallengeExpiredError = /* @__PURE__ */ (() => + zErrorResponse.and( + z.object({ + code: z.literal('human_input_challenge_expired'), + status: z.literal(400), + }), + ))() + +const zHumanInputChallengeStaleError = /* @__PURE__ */ (() => + zErrorResponse.and( + z.object({ + code: z.literal('human_input_challenge_stale'), + status: z.literal(400), + }), + ))() + const zHumanInputFormExpiredError = /* @__PURE__ */ (() => zErrorResponse.and( z.object({ @@ -162,6 +210,14 @@ const zHumanInputFormExpiredError = /* @__PURE__ */ (() => }), ))() +const zHumanInputFormNotFoundError = /* @__PURE__ */ (() => + zErrorResponse.and( + z.object({ + code: z.literal('human_input_form_not_found'), + status: z.literal(404), + }), + ))() + const zHumanInputFormSubmittedError = /* @__PURE__ */ (() => zErrorResponse.and( z.object({ @@ -170,6 +226,22 @@ const zHumanInputFormSubmittedError = /* @__PURE__ */ (() => }), ))() +const zHumanInputInvalidOtpError = /* @__PURE__ */ (() => + zErrorResponse.and( + z.object({ + code: z.literal('human_input_invalid_otp'), + status: z.literal(400), + }), + ))() + +const zHumanInputV2UnavailableError = /* @__PURE__ */ (() => + zErrorResponse.and( + z.object({ + code: z.literal('human_input_v2_unavailable'), + status: z.literal(503), + }), + ))() + const zInvalidEmailError = /* @__PURE__ */ (() => zErrorResponse.and( z.object({ @@ -186,6 +258,14 @@ const zInvalidFormDataError = /* @__PURE__ */ (() => }), ))() +const zInvalidInputError = /* @__PURE__ */ (() => + zErrorResponse.and( + z.object({ + code: z.literal('invalid_input'), + status: z.literal(400), + }), + ))() + const zInvalidOrExpiredTokenError = /* @__PURE__ */ (() => zErrorResponse.and( z.object({ @@ -426,6 +506,14 @@ const zWebFormRateLimitExceededError = /* @__PURE__ */ (() => }), ))() +const zWorkflowNotActiveError = /* @__PURE__ */ (() => + zErrorResponse.and( + z.object({ + code: z.literal('workflow_not_active'), + status: z.literal(409), + }), + ))() + export const postErrors = { app_unavailable: { status: 400, data: zAppUnavailableError }, audio_too_large: { status: 413, data: zAudioTooLargeError }, @@ -533,25 +621,74 @@ export const post12Errors = { } export const post13Errors = { + human_input_access_delivery_failed: { status: 503, data: zHumanInputAccessDeliveryFailedError }, + human_input_access_rate_limit_exceeded: { + status: 429, + data: zHumanInputAccessRateLimitExceededError, + }, human_input_form_expired: { status: 412, data: zHumanInputFormExpiredError }, + human_input_form_not_found: { status: 404, data: zHumanInputFormNotFoundError }, human_input_form_submitted: { status: 412, data: zHumanInputFormSubmittedError }, - web_form_rate_limit_exceeded: { status: 429, data: zWebFormRateLimitExceededError }, + human_input_v2_unavailable: { status: 503, data: zHumanInputV2UnavailableError }, +} + +export const post14Errors = { + human_input_form_expired: { status: 412, data: zHumanInputFormExpiredError }, + human_input_form_not_found: { status: 404, data: zHumanInputFormNotFoundError }, + human_input_form_submitted: { status: 412, data: zHumanInputFormSubmittedError }, + invalid_upload_token: { status: 403, data: zInvalidUploadTokenError }, } export const get2Errors = { + human_input_form_expired: { status: 412, data: zHumanInputFormExpiredError }, + human_input_form_not_found: { status: 404, data: zHumanInputFormNotFoundError }, + human_input_form_submitted: { status: 412, data: zHumanInputFormSubmittedError }, +} + +export const post15Errors = { + human_input_challenge_expired: { status: 400, data: zHumanInputChallengeExpiredError }, + human_input_challenge_stale: { status: 400, data: zHumanInputChallengeStaleError }, + human_input_form_expired: { status: 412, data: zHumanInputFormExpiredError }, + human_input_form_not_found: { status: 404, data: zHumanInputFormNotFoundError }, + human_input_form_submitted: { status: 412, data: zHumanInputFormSubmittedError }, + human_input_invalid_otp: { status: 400, data: zHumanInputInvalidOtpError }, + human_input_v2_unavailable: { status: 503, data: zHumanInputV2UnavailableError }, + invalid_input: { status: 400, data: zInvalidInputError }, + workflow_not_active: { status: 409, data: zWorkflowNotActiveError }, +} + +export const get3Errors = { + already_submitted: { status: 409, data: zAlreadySubmittedError }, + expired: { status: 410, data: zExpiredError }, +} + +export const post16Errors = { + already_submitted: { status: 409, data: zAlreadySubmittedError }, + expired: { status: 410, data: zExpiredError }, + invalid_input: { status: 400, data: zInvalidInputError }, + workflow_not_active: { status: 409, data: zWorkflowNotActiveError }, +} + +export const post17Errors = { human_input_form_expired: { status: 412, data: zHumanInputFormExpiredError }, human_input_form_submitted: { status: 412, data: zHumanInputFormSubmittedError }, web_form_rate_limit_exceeded: { status: 429, data: zWebFormRateLimitExceededError }, } -export const post14Errors = { +export const get4Errors = { + human_input_form_expired: { status: 412, data: zHumanInputFormExpiredError }, + human_input_form_submitted: { status: 412, data: zHumanInputFormSubmittedError }, + web_form_rate_limit_exceeded: { status: 429, data: zWebFormRateLimitExceededError }, +} + +export const post18Errors = { human_input_form_expired: { status: 412, data: zHumanInputFormExpiredError }, human_input_form_submitted: { status: 412, data: zHumanInputFormSubmittedError }, invalid_form_data: { status: 400, data: zInvalidFormDataError }, web_form_rate_limit_exceeded: { status: 429, data: zWebFormRateLimitExceededError }, } -export const post15Errors = { +export const post19Errors = { file_extension_blocked: { status: 400, data: zFileExtensionBlockedError }, file_too_large: { status: 413, data: zFileTooLargeError }, filename_not_exists_error: { status: 400, data: zFilenameNotExistsError }, @@ -571,17 +708,59 @@ export const post15Errors = { upload_token_required: { status: 401, data: zUploadTokenRequiredError }, } -export const post16Errors = { +export const post20Errors = { + file_extension_blocked: { status: 400, data: zFileExtensionBlockedError }, + file_too_large: { status: 413, data: zFileTooLargeError }, + filename_not_exists_error: { status: 400, data: zFilenameNotExistsError }, + human_input_form_expired: { status: 412, data: zHumanInputFormExpiredError }, + human_input_form_not_found: { status: 404, data: zHumanInputFormNotFoundError }, + human_input_form_submitted: { status: 412, data: zHumanInputFormSubmittedError }, + invalid_upload_token: { status: 403, data: zInvalidUploadTokenError }, + malformed_upload_token: { status: 400, data: zMalformedUploadTokenError }, + no_file_uploaded: { status: 400, data: zNoFileUploadedError }, + remote_file_access_denied: { status: 400, data: zRemoteFileAccessDeniedError }, + remote_file_invalid_response: { status: 502, data: zRemoteFileInvalidResponseError }, + remote_file_invalid_url: { status: 400, data: zRemoteFileInvalidUrlError }, + remote_file_not_found: { status: 404, data: zRemoteFileNotFoundError }, + remote_file_unavailable: { status: 502, data: zRemoteFileUnavailableError }, + remote_file_url_blocked: { status: 400, data: zRemoteFileUrlBlockedError }, + too_many_files: { status: 400, data: zTooManyFilesError }, + unsupported_file_type: { status: 415, data: zUnsupportedFileTypeError }, + upload_token_required: { status: 401, data: zUploadTokenRequiredError }, +} + +export const post21Errors = { + file_extension_blocked: { status: 400, data: zFileExtensionBlockedError }, + file_too_large: { status: 413, data: zFileTooLargeError }, + filename_not_exists_error: { status: 400, data: zFilenameNotExistsError }, + human_input_form_expired: { status: 412, data: zHumanInputFormExpiredError }, + human_input_form_not_found: { status: 404, data: zHumanInputFormNotFoundError }, + human_input_form_submitted: { status: 412, data: zHumanInputFormSubmittedError }, + invalid_upload_token: { status: 403, data: zInvalidUploadTokenError }, + malformed_upload_token: { status: 400, data: zMalformedUploadTokenError }, + no_file_uploaded: { status: 400, data: zNoFileUploadedError }, + remote_file_access_denied: { status: 400, data: zRemoteFileAccessDeniedError }, + remote_file_invalid_response: { status: 502, data: zRemoteFileInvalidResponseError }, + remote_file_invalid_url: { status: 400, data: zRemoteFileInvalidUrlError }, + remote_file_not_found: { status: 404, data: zRemoteFileNotFoundError }, + remote_file_unavailable: { status: 502, data: zRemoteFileUnavailableError }, + remote_file_url_blocked: { status: 400, data: zRemoteFileUrlBlockedError }, + too_many_files: { status: 400, data: zTooManyFilesError }, + unsupported_file_type: { status: 415, data: zUnsupportedFileTypeError }, + upload_token_required: { status: 401, data: zUploadTokenRequiredError }, +} + +export const post22Errors = { account_banned: { status: 400, data: zAccountBannedError }, authentication_failed: { status: 401, data: zAuthenticationFailedError }, } -export const post18Errors = { +export const post24Errors = { app_unavailable: { status: 400, data: zAppUnavailableError }, message_feedback_rating_required: { status: 400, data: zMessageFeedbackRatingRequiredError }, } -export const get4Errors = { +export const get6Errors = { app_more_like_this_disabled: { status: 403, data: zAppMoreLikeThisDisabledError }, app_unavailable: { status: 400, data: zAppUnavailableError }, completion_request_error: { status: 400, data: zCompletionRequestError }, @@ -591,7 +770,7 @@ export const get4Errors = { provider_quota_exceeded: { status: 400, data: zProviderQuotaExceededError }, } -export const get5Errors = { +export const get7Errors = { agent_version_not_found_error: { status: 404, data: zAgentVersionNotFoundError }, app_suggested_questions_after_answer_disabled: { status: 403, @@ -605,19 +784,19 @@ export const get5Errors = { provider_quota_exceeded: { status: 400, data: zProviderQuotaExceededError }, } -export const get6Errors = { +export const get8Errors = { app_unavailable: { status: 400, data: zAppUnavailableError }, not_chat_app: { status: 400, data: zNotChatAppError }, } -export const get7Errors = { app_unavailable: { status: 400, data: zAppUnavailableError } } +export const get9Errors = { app_unavailable: { status: 400, data: zAppUnavailableError } } -export const get8Errors = { +export const get10Errors = { agent_not_published: { status: 400, data: zAgentNotPublishedError }, app_unavailable: { status: 400, data: zAppUnavailableError }, } -export const post19Errors = { +export const post25Errors = { file_extension_blocked: { status: 400, data: zFileExtensionBlockedError }, file_too_large: { status: 413, data: zFileTooLargeError }, remote_file_access_denied: { status: 400, data: zRemoteFileAccessDeniedError }, @@ -629,7 +808,7 @@ export const post19Errors = { unsupported_file_type: { status: 415, data: zUnsupportedFileTypeError }, } -export const get10Errors = { +export const get12Errors = { remote_file_access_denied: { status: 400, data: zRemoteFileAccessDeniedError }, remote_file_invalid_response: { status: 502, data: zRemoteFileInvalidResponseError }, remote_file_invalid_url: { status: 400, data: zRemoteFileInvalidUrlError }, @@ -640,11 +819,11 @@ export const get10Errors = { export const delete2Errors = { not_completion_app: { status: 400, data: zNotCompletionAppError } } -export const get11Errors = { not_completion_app: { status: 400, data: zNotCompletionAppError } } +export const get13Errors = { not_completion_app: { status: 400, data: zNotCompletionAppError } } -export const post20Errors = { not_completion_app: { status: 400, data: zNotCompletionAppError } } +export const post26Errors = { not_completion_app: { status: 400, data: zNotCompletionAppError } } -export const post21Errors = { +export const post27Errors = { app_unavailable: { status: 400, data: zAppUnavailableError }, completion_request_error: { status: 400, data: zCompletionRequestError }, model_currently_not_support: { status: 400, data: zModelCurrentlyNotSupportError }, @@ -652,11 +831,11 @@ export const post21Errors = { provider_quota_exceeded: { status: 400, data: zProviderQuotaExceededError }, } -export const get14Errors = { invalid_param: { status: 400, data: zInvalidParamError } } - export const get16Errors = { invalid_param: { status: 400, data: zInvalidParamError } } -export const post22Errors = { +export const get18Errors = { invalid_param: { status: 400, data: zInvalidParamError } } + +export const post28Errors = { completion_request_error: { status: 400, data: zCompletionRequestError }, model_currently_not_support: { status: 400, data: zModelCurrentlyNotSupportError }, not_workflow_app: { status: 400, data: zNotWorkflowAppError }, @@ -669,4 +848,4 @@ export const post22Errors = { }, } -export const post23Errors = { not_workflow_app: { status: 400, data: zNotWorkflowAppError } } +export const post29Errors = { not_workflow_app: { status: 400, data: zNotWorkflowAppError } } diff --git a/packages/contracts/generated/api/web/orpc.gen.ts b/packages/contracts/generated/api/web/orpc.gen.ts index 70d6b7d01fe..9a2dd1078a4 100644 --- a/packages/contracts/generated/api/web/orpc.gen.ts +++ b/packages/contracts/generated/api/web/orpc.gen.ts @@ -6,6 +6,7 @@ import { zAccountBannedError, zAgentNotPublishedError, zAgentVersionNotFoundError, + zAlreadySubmittedError, zAppMoreLikeThisDisabledError, zAppSuggestedQuestionsAfterAnswerDisabledError, zAppUnavailableError, @@ -18,13 +19,22 @@ import { zEmailCodeError, zEmailPasswordResetLimitError, zEmailSendIpLimitError, + zExpiredError, zFileExtensionBlockedError, zFileTooLargeError, zFilenameNotExistsError, + zHumanInputAccessDeliveryFailedError, + zHumanInputAccessRateLimitExceededError, + zHumanInputChallengeExpiredError, + zHumanInputChallengeStaleError, zHumanInputFormExpiredError, + zHumanInputFormNotFoundError, zHumanInputFormSubmittedError, + zHumanInputInvalidOtpError, + zHumanInputV2UnavailableError, zInvalidEmailError, zInvalidFormDataError, + zInvalidInputError, zInvalidOrExpiredTokenError, zInvalidParamError, zInvalidUploadTokenError, @@ -55,6 +65,7 @@ import { zUnsupportedFileTypeError, zUploadTokenRequiredError, zWebFormRateLimitExceededError, + zWorkflowNotActiveError, } from './zod.gen.ts' import { zDeleteConversationsByCIdPath, @@ -65,6 +76,10 @@ import { zGetConversationsResponse, zGetFormHumanInputByFormTokenPath, zGetFormHumanInputByFormTokenResponse, + zGetFormHumanInputV2ByFormTokenPath, + zGetFormHumanInputV2ByFormTokenResponse, + zGetFormHumanInputV2PublicByFormTokenPath, + zGetFormHumanInputV2PublicByFormTokenResponse, zGetLoginStatusQuery, zGetLoginStatusResponse, zGetMessagesByMessageIdMoreLikeThisPath, @@ -123,7 +138,23 @@ import { zPostFormHumanInputByFormTokenResponse, zPostFormHumanInputByFormTokenUploadTokenPath, zPostFormHumanInputByFormTokenUploadTokenResponse, + zPostFormHumanInputV2ByFormTokenBody, + zPostFormHumanInputV2ByFormTokenPath, + zPostFormHumanInputV2ByFormTokenResponse, + zPostFormHumanInputV2PublicByFormTokenAccessRequestPath, + zPostFormHumanInputV2PublicByFormTokenAccessRequestResponse, + zPostFormHumanInputV2PublicByFormTokenBody, + zPostFormHumanInputV2PublicByFormTokenPath, + zPostFormHumanInputV2PublicByFormTokenResponse, + zPostFormHumanInputV2PublicByFormTokenUploadTokenPath, + zPostFormHumanInputV2PublicByFormTokenUploadTokenResponse, zPostHumanInputFormsFilesResponse, + zPostHumanInputV2FilesBody, + zPostHumanInputV2FilesHeaders, + zPostHumanInputV2FilesRemoteBody, + zPostHumanInputV2FilesRemoteHeaders, + zPostHumanInputV2FilesRemoteResponse, + zPostHumanInputV2FilesResponse, zPostLoginBody, zPostLoginResponse, zPostLogoutResponse, @@ -596,13 +627,178 @@ export const forgotPassword = { validity: validity2, } +export const post13 = oc + .route({ + inputStructure: 'detailed', + method: 'POST', + operationId: 'postFormHumanInputV2PublicByFormTokenAccessRequest', + path: '/form/human-input/v2/public/{form_token}/access-request', + tags: ['web'], + }) + .input(z.object({ params: zPostFormHumanInputV2PublicByFormTokenAccessRequestPath })) + .output(zPostFormHumanInputV2PublicByFormTokenAccessRequestResponse) + .errors({ + human_input_access_delivery_failed: { status: 503, data: zHumanInputAccessDeliveryFailedError }, + human_input_access_rate_limit_exceeded: { + status: 429, + data: zHumanInputAccessRateLimitExceededError, + }, + human_input_form_expired: { status: 412, data: zHumanInputFormExpiredError }, + human_input_form_not_found: { status: 404, data: zHumanInputFormNotFoundError }, + human_input_form_submitted: { status: 412, data: zHumanInputFormSubmittedError }, + human_input_v2_unavailable: { status: 503, data: zHumanInputV2UnavailableError }, + }) + +export const accessRequest = { + post: post13, +} + +export const post14 = oc + .route({ + inputStructure: 'detailed', + method: 'POST', + operationId: 'postFormHumanInputV2PublicByFormTokenUploadToken', + path: '/form/human-input/v2/public/{form_token}/upload-token', + tags: ['web'], + }) + .input(z.object({ params: zPostFormHumanInputV2PublicByFormTokenUploadTokenPath })) + .output(zPostFormHumanInputV2PublicByFormTokenUploadTokenResponse) + .errors({ + human_input_form_expired: { status: 412, data: zHumanInputFormExpiredError }, + human_input_form_not_found: { status: 404, data: zHumanInputFormNotFoundError }, + human_input_form_submitted: { status: 412, data: zHumanInputFormSubmittedError }, + invalid_upload_token: { status: 403, data: zInvalidUploadTokenError }, + }) + +export const uploadToken = { + post: post14, +} + +/** + * Read a public v2 form definition by delivery token without an OTP or login session. + */ +export const get2 = oc + .route({ + description: + 'Read a public v2 form definition by delivery token without an OTP or login session.', + inputStructure: 'detailed', + method: 'GET', + operationId: 'getFormHumanInputV2PublicByFormToken', + path: '/form/human-input/v2/public/{form_token}', + tags: ['web'], + }) + .input(z.object({ params: zGetFormHumanInputV2PublicByFormTokenPath })) + .output(zGetFormHumanInputV2PublicByFormTokenResponse) + .errors({ + human_input_form_expired: { status: 412, data: zHumanInputFormExpiredError }, + human_input_form_not_found: { status: 404, data: zHumanInputFormNotFoundError }, + human_input_form_submitted: { status: 412, data: zHumanInputFormSubmittedError }, + }) + +/** + * Submit a form with inputs, an action, and email verification proof. + */ +export const post15 = oc + .route({ + description: 'Submit a form with inputs, an action, and email verification proof.', + inputStructure: 'detailed', + method: 'POST', + operationId: 'postFormHumanInputV2PublicByFormToken', + path: '/form/human-input/v2/public/{form_token}', + tags: ['web'], + }) + .input( + z.object({ + body: zPostFormHumanInputV2PublicByFormTokenBody, + params: zPostFormHumanInputV2PublicByFormTokenPath, + }), + ) + .output(zPostFormHumanInputV2PublicByFormTokenResponse) + .errors({ + human_input_challenge_expired: { status: 400, data: zHumanInputChallengeExpiredError }, + human_input_challenge_stale: { status: 400, data: zHumanInputChallengeStaleError }, + human_input_form_expired: { status: 412, data: zHumanInputFormExpiredError }, + human_input_form_not_found: { status: 404, data: zHumanInputFormNotFoundError }, + human_input_form_submitted: { status: 412, data: zHumanInputFormSubmittedError }, + human_input_invalid_otp: { status: 400, data: zHumanInputInvalidOtpError }, + human_input_v2_unavailable: { status: 503, data: zHumanInputV2UnavailableError }, + invalid_input: { status: 400, data: zInvalidInputError }, + workflow_not_active: { status: 409, data: zWorkflowNotActiveError }, + }) + +export const byFormToken = { + get: get2, + post: post15, + accessRequest, + uploadToken, +} + +export const public_ = { + byFormToken, +} + +/** + * Read a v2 form using the original EndUser's Web App passport. + */ +export const get3 = oc + .route({ + description: "Read a v2 form using the original EndUser's Web App passport.", + inputStructure: 'detailed', + method: 'GET', + operationId: 'getFormHumanInputV2ByFormToken', + path: '/form/human-input/v2/{form_token}', + tags: ['web'], + }) + .input(z.object({ params: zGetFormHumanInputV2ByFormTokenPath })) + .output(zGetFormHumanInputV2ByFormTokenResponse) + .errors({ + already_submitted: { status: 409, data: zAlreadySubmittedError }, + expired: { status: 410, data: zExpiredError }, + }) + +/** + * Submit a v2 form using the original EndUser's Web App passport. + */ +export const post16 = oc + .route({ + description: "Submit a v2 form using the original EndUser's Web App passport.", + inputStructure: 'detailed', + method: 'POST', + operationId: 'postFormHumanInputV2ByFormToken', + path: '/form/human-input/v2/{form_token}', + tags: ['web'], + }) + .input( + z.object({ + body: zPostFormHumanInputV2ByFormTokenBody, + params: zPostFormHumanInputV2ByFormTokenPath, + }), + ) + .output(zPostFormHumanInputV2ByFormTokenResponse) + .errors({ + already_submitted: { status: 409, data: zAlreadySubmittedError }, + expired: { status: 410, data: zExpiredError }, + invalid_input: { status: 400, data: zInvalidInputError }, + workflow_not_active: { status: 409, data: zWorkflowNotActiveError }, + }) + +export const byFormToken2 = { + get: get3, + post: post16, +} + +export const v2 = { + public: public_, + byFormToken: byFormToken2, +} + /** * Issue an upload token for a human input form * * Issue an upload token for an active human input form * POST /api/form/human_input//upload-token */ -export const post13 = oc +export const post17 = oc .route({ description: 'Issue an upload token for an active human input form\nPOST /api/form/human_input//upload-token', @@ -621,8 +817,8 @@ export const post13 = oc web_form_rate_limit_exceeded: { status: 429, data: zWebFormRateLimitExceededError }, }) -export const uploadToken = { - post: post13, +export const uploadToken2 = { + post: post17, } /** @@ -631,7 +827,7 @@ export const uploadToken = { * Get a human input form definition by token * GET /api/form/human_input/ */ -export const get2 = oc +export const get4 = oc .route({ description: 'Get a human input form definition by token\nGET /api/form/human_input/', @@ -664,7 +860,7 @@ export const get2 = oc * "action": "Approve" * } */ -export const post14 = oc +export const post18 = oc .route({ description: 'Submit a human input form by token\nPOST /api/form/human_input/\n\nRequest body:\n{\n "inputs": {\n "content": "User input content"\n },\n "action": "Approve"\n}', @@ -689,14 +885,15 @@ export const post14 = oc web_form_rate_limit_exceeded: { status: 429, data: zWebFormRateLimitExceededError }, }) -export const byFormToken = { - get: get2, - post: post14, - uploadToken, +export const byFormToken3 = { + get: get4, + post: post18, + uploadToken: uploadToken2, } export const humanInput = { - byFormToken, + v2, + byFormToken: byFormToken3, } export const form = { @@ -706,7 +903,7 @@ export const form = { /** * Upload one local file or remote URL file for a HITL human input form */ -export const post15 = oc +export const post19 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -738,17 +935,116 @@ export const post15 = oc }) export const files2 = { - post: post15, + post: post19, } export const humanInputForms = { files: files2, } +/** + * Fetch a remote file using a v2 upload grant. + */ +export const post20 = oc + .route({ + description: 'Fetch a remote file using a v2 upload grant.', + inputStructure: 'detailed', + method: 'POST', + operationId: 'postHumanInputV2FilesRemote', + path: '/human-input/v2/files/remote', + successStatus: 201, + tags: ['web'], + }) + .input( + z.object({ + body: zPostHumanInputV2FilesRemoteBody, + headers: zPostHumanInputV2FilesRemoteHeaders, + }), + ) + .output(zPostHumanInputV2FilesRemoteResponse) + .errors({ + file_extension_blocked: { status: 400, data: zFileExtensionBlockedError }, + file_too_large: { status: 413, data: zFileTooLargeError }, + filename_not_exists_error: { status: 400, data: zFilenameNotExistsError }, + human_input_form_expired: { status: 412, data: zHumanInputFormExpiredError }, + human_input_form_not_found: { status: 404, data: zHumanInputFormNotFoundError }, + human_input_form_submitted: { status: 412, data: zHumanInputFormSubmittedError }, + invalid_upload_token: { status: 403, data: zInvalidUploadTokenError }, + malformed_upload_token: { status: 400, data: zMalformedUploadTokenError }, + no_file_uploaded: { status: 400, data: zNoFileUploadedError }, + remote_file_access_denied: { status: 400, data: zRemoteFileAccessDeniedError }, + remote_file_invalid_response: { status: 502, data: zRemoteFileInvalidResponseError }, + remote_file_invalid_url: { status: 400, data: zRemoteFileInvalidUrlError }, + remote_file_not_found: { status: 404, data: zRemoteFileNotFoundError }, + remote_file_unavailable: { status: 502, data: zRemoteFileUnavailableError }, + remote_file_url_blocked: { status: 400, data: zRemoteFileUrlBlockedError }, + too_many_files: { status: 400, data: zTooManyFilesError }, + unsupported_file_type: { status: 415, data: zUnsupportedFileTypeError }, + upload_token_required: { status: 401, data: zUploadTokenRequiredError }, + }) + +export const remote = { + post: post20, +} + +/** + * Upload a file or a remote URL using a v2 upload grant. + */ +export const post21 = oc + .route({ + description: 'Upload a file or a remote URL using a v2 upload grant.', + inputStructure: 'detailed', + method: 'POST', + operationId: 'postHumanInputV2Files', + path: '/human-input/v2/files', + successStatus: 201, + tags: ['web'], + }) + .input( + z.object({ + body: zPostHumanInputV2FilesBody.optional(), + headers: zPostHumanInputV2FilesHeaders, + }), + ) + .output(zPostHumanInputV2FilesResponse) + .errors({ + file_extension_blocked: { status: 400, data: zFileExtensionBlockedError }, + file_too_large: { status: 413, data: zFileTooLargeError }, + filename_not_exists_error: { status: 400, data: zFilenameNotExistsError }, + human_input_form_expired: { status: 412, data: zHumanInputFormExpiredError }, + human_input_form_not_found: { status: 404, data: zHumanInputFormNotFoundError }, + human_input_form_submitted: { status: 412, data: zHumanInputFormSubmittedError }, + invalid_upload_token: { status: 403, data: zInvalidUploadTokenError }, + malformed_upload_token: { status: 400, data: zMalformedUploadTokenError }, + no_file_uploaded: { status: 400, data: zNoFileUploadedError }, + remote_file_access_denied: { status: 400, data: zRemoteFileAccessDeniedError }, + remote_file_invalid_response: { status: 502, data: zRemoteFileInvalidResponseError }, + remote_file_invalid_url: { status: 400, data: zRemoteFileInvalidUrlError }, + remote_file_not_found: { status: 404, data: zRemoteFileNotFoundError }, + remote_file_unavailable: { status: 502, data: zRemoteFileUnavailableError }, + remote_file_url_blocked: { status: 400, data: zRemoteFileUrlBlockedError }, + too_many_files: { status: 400, data: zTooManyFilesError }, + unsupported_file_type: { status: 415, data: zUnsupportedFileTypeError }, + upload_token_required: { status: 401, data: zUploadTokenRequiredError }, + }) + +export const files3 = { + post: post21, + remote, +} + +export const v22 = { + files: files3, +} + +export const humanInput2 = { + v2: v22, +} + /** * Check login status */ -export const get3 = oc +export const get5 = oc .route({ description: 'Check login status', inputStructure: 'detailed', @@ -761,7 +1057,7 @@ export const get3 = oc .output(zGetLoginStatusResponse) export const status = { - get: get3, + get: get5, } /** @@ -769,7 +1065,7 @@ export const status = { * * Authenticate user for web application access */ -export const post16 = oc +export const post22 = oc .route({ description: 'Authenticate user for web application access', inputStructure: 'detailed', @@ -787,14 +1083,14 @@ export const post16 = oc }) export const login = { - post: post16, + post: post22, status, } /** * Logout user from web application */ -export const post17 = oc +export const post23 = oc .route({ description: 'Logout user from web application', inputStructure: 'detailed', @@ -806,13 +1102,13 @@ export const post17 = oc .output(zPostLogoutResponse) export const logout = { - post: post17, + post: post23, } /** * Submit feedback (like/dislike) for a specific message. */ -export const post18 = oc +export const post24 = oc .route({ description: 'Submit feedback (like/dislike) for a specific message.', inputStructure: 'detailed', @@ -835,13 +1131,13 @@ export const post18 = oc }) export const feedbacks = { - post: post18, + post: post24, } /** * Generate a new completion similar to an existing message (completion apps only). */ -export const get4 = oc +export const get6 = oc .route({ description: 'Generate a new completion similar to an existing message (completion apps only).', inputStructure: 'detailed', @@ -868,13 +1164,13 @@ export const get4 = oc }) export const moreLikeThis = { - get: get4, + get: get6, } /** * Get suggested follow-up questions after a message (chat apps only). If no usable model can be resolved or the model call to generate questions fails, the response is HTTP 200 with an empty data list. Model invocation failures during history token counting instead return HTTP 400 with `completion_request_error`. */ -export const get5 = oc +export const get7 = oc .route({ description: 'Get suggested follow-up questions after a message (chat apps only). If no usable model can be resolved or the model call to generate questions fails, the response is HTTP 200 with an empty data list. Model invocation failures during history token counting instead return HTTP 400 with `completion_request_error`.', @@ -901,7 +1197,7 @@ export const get5 = oc }) export const suggestedQuestions = { - get: get5, + get: get7, } export const byMessageId = { @@ -913,7 +1209,7 @@ export const byMessageId = { /** * Retrieve paginated list of messages from a conversation in a chat application. */ -export const get6 = oc +export const get8 = oc .route({ description: 'Retrieve paginated list of messages from a conversation in a chat application.', inputStructure: 'detailed', @@ -930,7 +1226,7 @@ export const get6 = oc }) export const messages = { - get: get6, + get: get8, byMessageId, } @@ -939,7 +1235,7 @@ export const messages = { * * Retrieve the metadata for a specific app. */ -export const get7 = oc +export const get9 = oc .route({ description: 'Retrieve the metadata for a specific app.', inputStructure: 'detailed', @@ -953,7 +1249,7 @@ export const get7 = oc .errors({ app_unavailable: { status: 400, data: zAppUnavailableError } }) export const meta = { - get: get7, + get: get9, } /** @@ -961,7 +1257,7 @@ export const meta = { * * Retrieve the parameters for a specific app. */ -export const get8 = oc +export const get10 = oc .route({ description: 'Retrieve the parameters for a specific app.', inputStructure: 'detailed', @@ -978,13 +1274,13 @@ export const get8 = oc }) export const parameters = { - get: get8, + get: get10, } /** * Get authentication passport for web application access */ -export const get9 = oc +export const get11 = oc .route({ description: 'Get authentication passport for web application access', inputStructure: 'detailed', @@ -997,7 +1293,7 @@ export const get9 = oc .output(zGetPassportResponse) export const passport = { - get: get9, + get: get11, } /** @@ -1028,7 +1324,7 @@ export const passport = { * FileTooLargeError: File exceeds size limit * UnsupportedFileTypeError: File type not supported */ -export const post19 = oc +export const post25 = oc .route({ description: 'Upload a file from a remote URL\nDownloads a file from the provided remote URL and uploads it\nto the platform storage for use in web applications.\n\nArgs:\n app_model: The associated application model\n end_user: The end user making the request\n\nJSON Parameters:\n url: The remote URL to download the file from (required)\n\nReturns:\n dict: File information including ID, signed URL, and metadata\n int: HTTP status code 201 for success\n\nRaises:\n RemoteFileInvalidUrlError: Remote file URL is invalid\n RemoteFileUrlBlockedError: Remote file URL is blocked\n RemoteFileNotFoundError: Remote file does not exist\n RemoteFileAccessDeniedError: Remote file requires authorization\n RemoteFileUnavailableError: Remote file is unavailable\n RemoteFileInvalidResponseError: Remote file response is invalid\n FileTooLargeError: File exceeds size limit\n UnsupportedFileTypeError: File type not supported', @@ -1055,7 +1351,7 @@ export const post19 = oc }) export const upload2 = { - post: post19, + post: post25, } /** @@ -1076,7 +1372,7 @@ export const upload2 = { * Raises: * HTTPException: If the remote file cannot be accessed */ -export const get10 = oc +export const get12 = oc .route({ description: 'Get information about a remote file\nRetrieves basic information about a file located at a remote URL,\nincluding content type and content length.\n\nArgs:\n app_model: The associated application model\n end_user: The end user making the request\n url: URL-encoded path to the remote file\n\nReturns:\n dict: Remote file information including type and length\n\nRaises:\n HTTPException: If the remote file cannot be accessed', @@ -1099,7 +1395,7 @@ export const get10 = oc }) export const byUrl = { - get: get10, + get: get12, } export const remoteFiles = { @@ -1131,7 +1427,7 @@ export const byMessageId2 = { /** * Retrieve paginated list of saved messages for a completion application. */ -export const get11 = oc +export const get13 = oc .route({ description: 'Retrieve paginated list of saved messages for a completion application.', inputStructure: 'detailed', @@ -1147,7 +1443,7 @@ export const get11 = oc /** * Save a specific message for later reference. */ -export const post20 = oc +export const post26 = oc .route({ description: 'Save a specific message for later reference.', inputStructure: 'detailed', @@ -1161,8 +1457,8 @@ export const post20 = oc .errors({ not_completion_app: { status: 400, data: zNotCompletionAppError } }) export const savedMessages = { - get: get11, - post: post20, + get: get13, + post: post26, byMessageId: byMessageId2, } @@ -1171,7 +1467,7 @@ export const savedMessages = { * * Retrieve app site information and configuration. */ -export const get12 = oc +export const get14 = oc .route({ description: 'Retrieve app site information and configuration.', inputStructure: 'detailed', @@ -1184,7 +1480,7 @@ export const get12 = oc .output(zGetSiteResponse) export const site = { - get: get12, + get: get14, } /** @@ -1196,7 +1492,7 @@ export const site = { * * Authentication configuration must be available before the authentication flow can be selected. */ -export const get13 = oc +export const get15 = oc .route({ description: 'Get the non-sensitive bootstrap snapshot exposed before Console or Web authentication. This is not a general feature registry.\nThis endpoint is akin to the `SystemFeatureApi` endpoint in api/controllers/console/feature.py,\nexcept it is intended for use by the web app, instead of the console dashboard.\n\nAuthentication configuration must be available before the authentication flow can be selected.', @@ -1210,7 +1506,7 @@ export const get13 = oc .output(zGetSystemFeaturesResponse) export const systemFeatures = { - get: get13, + get: get15, } /** @@ -1218,7 +1514,7 @@ export const systemFeatures = { * * Convert text to audio using text-to-speech service. */ -export const post21 = oc +export const post27 = oc .route({ description: 'Convert text to audio using text-to-speech service.', inputStructure: 'detailed', @@ -1239,13 +1535,13 @@ export const post21 = oc }) export const textToAudio = { - post: post21, + post: post27, } /** * Retrieve the access mode for a web application (public or restricted). */ -export const get14 = oc +export const get16 = oc .route({ description: 'Retrieve the access mode for a web application (public or restricted).', inputStructure: 'detailed', @@ -1259,13 +1555,13 @@ export const get14 = oc .errors({ invalid_param: { status: 400, data: zInvalidParamError } }) export const accessMode = { - get: get14, + get: get16, } /** * Check if user has permission to access a web application. */ -export const get15 = oc +export const get17 = oc .route({ description: 'Check if user has permission to access a web application.', inputStructure: 'detailed', @@ -1278,7 +1574,7 @@ export const get15 = oc .output(zGetWebappPermissionResponse) export const permission = { - get: get15, + get: get17, } export const webapp = { @@ -1293,7 +1589,7 @@ export const webapp = { * * Returns Server-Sent Events stream. */ -export const get16 = oc +export const get18 = oc .route({ description: 'GET /api/workflow//events\n\nReturns Server-Sent Events stream.', inputStructure: 'detailed', @@ -1308,7 +1604,7 @@ export const get16 = oc .errors({ invalid_param: { status: 400, data: zInvalidParamError } }) export const events = { - get: get16, + get: get18, } export const byTaskId3 = { @@ -1324,7 +1620,7 @@ export const workflow = { * * Execute a workflow with provided inputs and files. */ -export const post22 = oc +export const post28 = oc .route({ description: 'Execute a workflow with provided inputs and files.', inputStructure: 'detailed', @@ -1350,7 +1646,7 @@ export const post22 = oc }) export const run = { - post: post22, + post: post28, } /** @@ -1358,7 +1654,7 @@ export const run = { * * Stop a running workflow task. */ -export const post23 = oc +export const post29 = oc .route({ description: 'Stop a running workflow task.', inputStructure: 'detailed', @@ -1373,7 +1669,7 @@ export const post23 = oc .errors({ not_workflow_app: { status: 400, data: zNotWorkflowAppError } }) export const stop3 = { - post: post23, + post: post29, } export const byTaskId4 = { @@ -1399,6 +1695,7 @@ export const contract = { forgotPassword, form, humanInputForms, + humanInput: humanInput2, login, logout, messages, diff --git a/packages/contracts/generated/api/web/types.gen.ts b/packages/contracts/generated/api/web/types.gen.ts index 827f4079b16..955137a496d 100644 --- a/packages/contracts/generated/api/web/types.gen.ts +++ b/packages/contracts/generated/api/web/types.gen.ts @@ -47,6 +47,11 @@ export type AgentVersionNotFoundError = ErrorResponse & { status: 404 } +export type AlreadySubmittedError = ErrorResponse & { + code: 'already_submitted' + status: 409 +} + export type AnyJsonValue = unknown export type AppAccessModeQuery = { @@ -232,11 +237,24 @@ export type EventStreamResponse = string export type ExecutionContentType = 'human_input' +export type ExpiredError = ErrorResponse & { + code: 'expired' + status: 410 +} + export type FileExtensionBlockedError = ErrorResponse & { code: 'file_extension_blocked' status: 400 } +export type FileInput = { + allowed_file_extensions: Array + allowed_file_types: Array + allowed_file_upload_methods: Array + output_variable_name: string + type?: 'file_input' +} + export type FileInputConfig = { allowed_file_extensions?: Array allowed_file_types?: Array @@ -245,6 +263,15 @@ export type FileInputConfig = { type?: 'file' } +export type FileListInput = { + allowed_file_extensions: Array + allowed_file_types: Array + allowed_file_upload_methods: Array + number_limits: number + output_variable_name: string + type?: 'file_list_input' +} + export type FileListInputConfig = { allowed_file_extensions?: Array allowed_file_types?: Array @@ -314,6 +341,22 @@ export type ForgotPasswordSendPayload = { language?: string | null } +export type FormAccessRequestResponse = { + challenge_token: string + expires_in_seconds: number + resend_after_seconds: number +} + +export type FormDefinitionResponse = { + expiration_time: number + form_content?: string | null + inputs?: Array + resolved_default_values?: { + [key: string]: string + } + user_actions?: Array +} + export type FormInputConfig = | ({ type: 'paragraph' @@ -328,8 +371,54 @@ export type FormInputConfig = type: 'file-list' } & FileListInputConfig) +export type FormPart = + | ({ + type: 'markdown_fragment' + } & MarkdownFragment) + | ({ + type: 'paragraph_input' + } & ParagraphInput) + | ({ + type: 'select_input' + } & SelectInput) + | ({ + type: 'file_input' + } & FileInput) + | ({ + type: 'file_list_input' + } & FileListInput) + +export type FormSubmitResponse = { + [key: string]: unknown +} + +export type FormUploadTokenResponse = { + expires_at: number + upload_token: string +} + export type GeneratedAppResponse = ShallowJsonValue +export type HumanInputAccessDeliveryFailedError = ErrorResponse & { + code: 'human_input_access_delivery_failed' + status: 503 +} + +export type HumanInputAccessRateLimitExceededError = ErrorResponse & { + code: 'human_input_access_rate_limit_exceeded' + status: 429 +} + +export type HumanInputChallengeExpiredError = ErrorResponse & { + code: 'human_input_challenge_expired' + status: 400 +} + +export type HumanInputChallengeStaleError = ErrorResponse & { + code: 'human_input_challenge_stale' + status: 400 +} + export type HumanInputContent = { form_definition?: HumanInputFormDefinition | null form_submission_data?: HumanInputFormSubmissionData | null @@ -349,6 +438,7 @@ export type HumanInputFormDefinition = { form_content: string form_id: string form_token?: string | null + form_version?: '1' | '2' inputs?: Array node_id: string node_title: string @@ -373,6 +463,11 @@ export type HumanInputFormExpiredError = ErrorResponse & { status: 412 } +export type HumanInputFormNotFoundError = ErrorResponse & { + code: 'human_input_form_not_found' + status: 404 +} + export type HumanInputFormSubmissionData = { action_id: string action_text: string @@ -400,11 +495,34 @@ export type HumanInputFormSubmittedError = ErrorResponse & { status: 412 } +export type HumanInputInvalidOtpError = ErrorResponse & { + code: 'human_input_invalid_otp' + status: 400 +} + export type HumanInputUploadTokenResponse = { expires_at: number upload_token: string } +export type HumanInputV2FormSubmitRequest = { + action: string + challenge_token?: string | null + inputs: { + [key: string]: JsonValue + } + otp_code?: string | null +} + +export type HumanInputV2RemoteUploadPayload = { + url: string +} + +export type HumanInputV2UnavailableError = ErrorResponse & { + code: 'human_input_v2_unavailable' + status: 503 +} + export type InvalidEmailError = ErrorResponse & { code: 'invalid_email' status: 400 @@ -415,6 +533,11 @@ export type InvalidFormDataError = ErrorResponse & { status: 400 } +export type InvalidInputError = ErrorResponse & { + code: 'invalid_input' + status: 400 +} + export type InvalidOrExpiredTokenError = ErrorResponse & { code: 'invalid_or_expired_token' status: 400 @@ -476,6 +599,11 @@ export type MalformedUploadTokenError = ErrorResponse & { status: 400 } +export type MarkdownFragment = { + text: string + type?: 'markdown_fragment' +} + export type MessageFeedbackPayload = { content?: string | null rating?: 'dislike' | 'like' | null @@ -538,6 +666,12 @@ export type NotWorkflowAppError = ErrorResponse & { status: 400 } +export type ParagraphInput = { + default_value: string | null + output_variable_name: string + type?: 'paragraph_input' +} + export type ParagraphInputConfig = { default?: StringSource | null output_variable_name: string @@ -956,6 +1090,13 @@ export type RemoteFileUrlBlockedError = ErrorResponse & { status: 400 } +export type ResolvedForm = { + actions: Array + legacy_form_content: string + parts: Array + title: string +} + export type ResultResponse = { result: string } @@ -1022,6 +1163,13 @@ export type SavedMessageListQuery = { limit?: number } +export type SelectInput = { + default_value: string | null + options: Array + output_variable_name: string + type?: 'select_input' +} + export type SelectInputConfig = { option_source: StringListSource output_variable_name: string @@ -1150,6 +1298,12 @@ export type UploadTokenRequiredError = ErrorResponse & { status: 401 } +export type UserAction = { + button_style: ButtonStyle + id: string + title: string +} + export type UserActionConfig = { button_style?: ButtonStyle id: string @@ -1182,6 +1336,22 @@ export type WebAppCustomConfigResponse = { replace_webapp_logo?: string | null } +export type WebAppHumanInputV2FormDefinitionResponse = { + expiration_time: number + resolved_form: ResolvedForm +} + +export type WebAppHumanInputV2SubmitPayload = { + action: string + inputs: { + [key: string]: JsonValue + } +} + +export type WebAppHumanInputV2SubmitResponse = { + [key: string]: unknown +} + export type WebAppSiteResponse = { app_id: string can_replace_logo: boolean @@ -1260,6 +1430,11 @@ export type WebSiteResponse = { use_icon_as_answer_icon?: boolean | null } +export type WorkflowNotActiveError = ErrorResponse & { + code: 'workflow_not_active' + status: 409 +} + export type WorkflowRunPayload = { files?: Array< | { @@ -1296,12 +1471,20 @@ export type WorkflowRunPayload = { } } +export type FormSubmitResponseWritable = { + [key: string]: unknown +} + export type GeneratedAppResponseWritable = ShallowJsonValue export type HumanInputFormSubmitResponseWritable = { [key: string]: unknown } +export type WebAppHumanInputV2SubmitResponseWritable = { + [key: string]: unknown +} + export type WebMessageInfiniteScrollPaginationWritable = { data: Array has_more: boolean @@ -1770,6 +1953,174 @@ export type PostForgotPasswordValidityResponses = { export type PostForgotPasswordValidityResponse = PostForgotPasswordValidityResponses[keyof PostForgotPasswordValidityResponses] +export type GetFormHumanInputV2PublicByFormTokenData = { + body?: never + path: { + form_token: string + } + query?: never + url: '/form/human-input/v2/public/{form_token}' +} + +export type GetFormHumanInputV2PublicByFormTokenErrors = { + 404: HumanInputFormNotFoundError | ErrorResponse + 412: HumanInputFormExpiredError | HumanInputFormSubmittedError | ErrorResponse + default: ErrorResponse +} + +export type GetFormHumanInputV2PublicByFormTokenError = + GetFormHumanInputV2PublicByFormTokenErrors[keyof GetFormHumanInputV2PublicByFormTokenErrors] + +export type GetFormHumanInputV2PublicByFormTokenResponses = { + 200: FormDefinitionResponse +} + +export type GetFormHumanInputV2PublicByFormTokenResponse = + GetFormHumanInputV2PublicByFormTokenResponses[keyof GetFormHumanInputV2PublicByFormTokenResponses] + +export type PostFormHumanInputV2PublicByFormTokenData = { + body: HumanInputV2FormSubmitRequest + path: { + form_token: string + } + query?: never + url: '/form/human-input/v2/public/{form_token}' +} + +export type PostFormHumanInputV2PublicByFormTokenErrors = { + 400: + | HumanInputChallengeExpiredError + | HumanInputChallengeStaleError + | HumanInputInvalidOtpError + | InvalidInputError + | ErrorResponse + 404: HumanInputFormNotFoundError | ErrorResponse + 409: WorkflowNotActiveError | ErrorResponse + 412: HumanInputFormExpiredError | HumanInputFormSubmittedError | ErrorResponse + 422: ErrorResponse + 503: HumanInputV2UnavailableError | ErrorResponse + default: ErrorResponse +} + +export type PostFormHumanInputV2PublicByFormTokenError = + PostFormHumanInputV2PublicByFormTokenErrors[keyof PostFormHumanInputV2PublicByFormTokenErrors] + +export type PostFormHumanInputV2PublicByFormTokenResponses = { + 200: FormSubmitResponse +} + +export type PostFormHumanInputV2PublicByFormTokenResponse = + PostFormHumanInputV2PublicByFormTokenResponses[keyof PostFormHumanInputV2PublicByFormTokenResponses] + +export type PostFormHumanInputV2PublicByFormTokenAccessRequestData = { + body?: never + path: { + form_token: string + } + query?: never + url: '/form/human-input/v2/public/{form_token}/access-request' +} + +export type PostFormHumanInputV2PublicByFormTokenAccessRequestErrors = { + 404: HumanInputFormNotFoundError | ErrorResponse + 412: HumanInputFormExpiredError | HumanInputFormSubmittedError | ErrorResponse + 429: HumanInputAccessRateLimitExceededError | ErrorResponse + 503: HumanInputAccessDeliveryFailedError | HumanInputV2UnavailableError | ErrorResponse + default: ErrorResponse +} + +export type PostFormHumanInputV2PublicByFormTokenAccessRequestError = + PostFormHumanInputV2PublicByFormTokenAccessRequestErrors[keyof PostFormHumanInputV2PublicByFormTokenAccessRequestErrors] + +export type PostFormHumanInputV2PublicByFormTokenAccessRequestResponses = { + 200: FormAccessRequestResponse +} + +export type PostFormHumanInputV2PublicByFormTokenAccessRequestResponse = + PostFormHumanInputV2PublicByFormTokenAccessRequestResponses[keyof PostFormHumanInputV2PublicByFormTokenAccessRequestResponses] + +export type PostFormHumanInputV2PublicByFormTokenUploadTokenData = { + body?: never + path: { + form_token: string + } + query?: never + url: '/form/human-input/v2/public/{form_token}/upload-token' +} + +export type PostFormHumanInputV2PublicByFormTokenUploadTokenErrors = { + 403: InvalidUploadTokenError | ErrorResponse + 404: HumanInputFormNotFoundError | ErrorResponse + 412: HumanInputFormExpiredError | HumanInputFormSubmittedError | ErrorResponse + default: ErrorResponse +} + +export type PostFormHumanInputV2PublicByFormTokenUploadTokenError = + PostFormHumanInputV2PublicByFormTokenUploadTokenErrors[keyof PostFormHumanInputV2PublicByFormTokenUploadTokenErrors] + +export type PostFormHumanInputV2PublicByFormTokenUploadTokenResponses = { + 200: FormUploadTokenResponse +} + +export type PostFormHumanInputV2PublicByFormTokenUploadTokenResponse = + PostFormHumanInputV2PublicByFormTokenUploadTokenResponses[keyof PostFormHumanInputV2PublicByFormTokenUploadTokenResponses] + +export type GetFormHumanInputV2ByFormTokenData = { + body?: never + path: { + form_token: string + } + query?: never + url: '/form/human-input/v2/{form_token}' +} + +export type GetFormHumanInputV2ByFormTokenErrors = { + 401: ErrorResponse + 404: ErrorResponse + 409: AlreadySubmittedError | ErrorResponse + 410: ExpiredError | ErrorResponse + default: ErrorResponse +} + +export type GetFormHumanInputV2ByFormTokenError = + GetFormHumanInputV2ByFormTokenErrors[keyof GetFormHumanInputV2ByFormTokenErrors] + +export type GetFormHumanInputV2ByFormTokenResponses = { + 200: WebAppHumanInputV2FormDefinitionResponse +} + +export type GetFormHumanInputV2ByFormTokenResponse = + GetFormHumanInputV2ByFormTokenResponses[keyof GetFormHumanInputV2ByFormTokenResponses] + +export type PostFormHumanInputV2ByFormTokenData = { + body: WebAppHumanInputV2SubmitPayload + path: { + form_token: string + } + query?: never + url: '/form/human-input/v2/{form_token}' +} + +export type PostFormHumanInputV2ByFormTokenErrors = { + 400: InvalidInputError | ErrorResponse + 401: ErrorResponse + 404: ErrorResponse + 409: AlreadySubmittedError | WorkflowNotActiveError | ErrorResponse + 410: ExpiredError | ErrorResponse + 422: ErrorResponse + default: ErrorResponse +} + +export type PostFormHumanInputV2ByFormTokenError = + PostFormHumanInputV2ByFormTokenErrors[keyof PostFormHumanInputV2ByFormTokenErrors] + +export type PostFormHumanInputV2ByFormTokenResponses = { + 200: WebAppHumanInputV2SubmitResponse +} + +export type PostFormHumanInputV2ByFormTokenResponse = + PostFormHumanInputV2ByFormTokenResponses[keyof PostFormHumanInputV2ByFormTokenResponses] + export type GetFormHumanInputByFormTokenData = { body?: never path: { @@ -1888,6 +2239,91 @@ export type PostHumanInputFormsFilesResponses = { export type PostHumanInputFormsFilesResponse = PostHumanInputFormsFilesResponses[keyof PostHumanInputFormsFilesResponses] +export type PostHumanInputV2FilesData = { + body?: { + file?: Blob | File + url?: string + } + headers: { + Authorization: string + } + path?: never + query?: never + url: '/human-input/v2/files' +} + +export type PostHumanInputV2FilesErrors = { + 400: + | FileExtensionBlockedError + | FilenameNotExistsError + | MalformedUploadTokenError + | NoFileUploadedError + | RemoteFileAccessDeniedError + | RemoteFileInvalidUrlError + | RemoteFileUrlBlockedError + | TooManyFilesError + | ErrorResponse + 401: UploadTokenRequiredError | ErrorResponse + 403: InvalidUploadTokenError | ErrorResponse + 404: HumanInputFormNotFoundError | RemoteFileNotFoundError | ErrorResponse + 412: HumanInputFormExpiredError | HumanInputFormSubmittedError | ErrorResponse + 413: FileTooLargeError | ErrorResponse + 415: UnsupportedFileTypeError | ErrorResponse + 502: RemoteFileInvalidResponseError | RemoteFileUnavailableError | ErrorResponse + default: ErrorResponse +} + +export type PostHumanInputV2FilesError = + PostHumanInputV2FilesErrors[keyof PostHumanInputV2FilesErrors] + +export type PostHumanInputV2FilesResponses = { + 201: FileWithSignedUrl +} + +export type PostHumanInputV2FilesResponse = + PostHumanInputV2FilesResponses[keyof PostHumanInputV2FilesResponses] + +export type PostHumanInputV2FilesRemoteData = { + body: HumanInputV2RemoteUploadPayload + headers: { + Authorization: string + } + path?: never + query?: never + url: '/human-input/v2/files/remote' +} + +export type PostHumanInputV2FilesRemoteErrors = { + 400: + | FileExtensionBlockedError + | FilenameNotExistsError + | MalformedUploadTokenError + | NoFileUploadedError + | RemoteFileAccessDeniedError + | RemoteFileInvalidUrlError + | RemoteFileUrlBlockedError + | TooManyFilesError + | ErrorResponse + 401: UploadTokenRequiredError | ErrorResponse + 403: InvalidUploadTokenError | ErrorResponse + 404: HumanInputFormNotFoundError | RemoteFileNotFoundError | ErrorResponse + 412: HumanInputFormExpiredError | HumanInputFormSubmittedError | ErrorResponse + 413: FileTooLargeError | ErrorResponse + 415: UnsupportedFileTypeError | ErrorResponse + 502: RemoteFileInvalidResponseError | RemoteFileUnavailableError | ErrorResponse + default: ErrorResponse +} + +export type PostHumanInputV2FilesRemoteError = + PostHumanInputV2FilesRemoteErrors[keyof PostHumanInputV2FilesRemoteErrors] + +export type PostHumanInputV2FilesRemoteResponses = { + 201: FileWithSignedUrl +} + +export type PostHumanInputV2FilesRemoteResponse = + PostHumanInputV2FilesRemoteResponses[keyof PostHumanInputV2FilesRemoteResponses] + export type PostLoginData = { body: LoginPayload path?: never diff --git a/packages/contracts/generated/api/web/zod.gen.ts b/packages/contracts/generated/api/web/zod.gen.ts index 71f4f2892bc..295a2e72e7b 100644 --- a/packages/contracts/generated/api/web/zod.gen.ts +++ b/packages/contracts/generated/api/web/zod.gen.ts @@ -242,6 +242,16 @@ export const zAgentVersionNotFoundError = zErrorResponse.and( }), ) +/** + * AlreadySubmittedError + */ +export const zAlreadySubmittedError = zErrorResponse.and( + z.object({ + code: z.literal('already_submitted'), + status: z.literal(409), + }), +) + /** * AppMoreLikeThisDisabledError * @@ -396,6 +406,16 @@ export const zEventStreamResponse = z.string() */ export const zExecutionContentType = z.enum(['human_input']) +/** + * ExpiredError + */ +export const zExpiredError = zErrorResponse.and( + z.object({ + code: z.literal('expired'), + status: z.literal(410), + }), +) + /** * FileExtensionBlockedError * @@ -456,6 +476,19 @@ export const zFileTransferMethod = z.enum([ */ export const zFileType = z.enum(['audio', 'custom', 'document', 'image', 'video']) +/** + * FileInput + * + * Resolved single-file input constraints. + */ +export const zFileInput = z.object({ + allowed_file_extensions: z.array(z.string()), + allowed_file_types: z.array(zFileType), + allowed_file_upload_methods: z.array(zFileTransferMethod), + output_variable_name: z.string(), + type: z.literal('file_input').optional().default('file_input'), +}) + /** * FileInputConfig */ @@ -467,6 +500,20 @@ export const zFileInputConfig = z.object({ type: z.literal('file').optional().default('file'), }) +/** + * FileListInput + * + * Resolved multi-file input constraints and effective number limit. + */ +export const zFileListInput = z.object({ + allowed_file_extensions: z.array(z.string()), + allowed_file_types: z.array(zFileType), + allowed_file_upload_methods: z.array(zFileTransferMethod), + number_limits: z.int(), + output_variable_name: z.string(), + type: z.literal('file_list_input').optional().default('file_list_input'), +}) + /** * FileListInputConfig */ @@ -531,6 +578,74 @@ export const zForgotPasswordSendPayload = z.object({ language: z.string().nullish(), }) +/** + * FormAccessRequestResponse + * + * Response body returned after creating one OTP challenge. + */ +export const zFormAccessRequestResponse = z.object({ + challenge_token: z.string(), + expires_in_seconds: z.int(), + resend_after_seconds: z.int(), +}) + +/** + * FormSubmitResponse + * + * Empty response body returned after a Human Input v2 form submission. + */ +export const zFormSubmitResponse = z.record(z.string(), z.unknown()) + +/** + * FormUploadTokenResponse + * + * Response body returned when issuing a Human Input v2 upload token. + */ +export const zFormUploadTokenResponse = z.object({ + expires_at: z.int(), + upload_token: z.string(), +}) + +/** + * HumanInputAccessDeliveryFailedError + */ +export const zHumanInputAccessDeliveryFailedError = zErrorResponse.and( + z.object({ + code: z.literal('human_input_access_delivery_failed'), + status: z.literal(503), + }), +) + +/** + * HumanInputAccessRateLimitExceededError + */ +export const zHumanInputAccessRateLimitExceededError = zErrorResponse.and( + z.object({ + code: z.literal('human_input_access_rate_limit_exceeded'), + status: z.literal(429), + }), +) + +/** + * HumanInputChallengeExpiredError + */ +export const zHumanInputChallengeExpiredError = zErrorResponse.and( + z.object({ + code: z.literal('human_input_challenge_expired'), + status: z.literal(400), + }), +) + +/** + * HumanInputChallengeStaleError + */ +export const zHumanInputChallengeStaleError = zErrorResponse.and( + z.object({ + code: z.literal('human_input_challenge_stale'), + status: z.literal(400), + }), +) + /** * HumanInputFileUploadFormPayload * @@ -550,6 +665,16 @@ export const zHumanInputFormExpiredError = zErrorResponse.and( }), ) +/** + * HumanInputFormNotFoundError + */ +export const zHumanInputFormNotFoundError = zErrorResponse.and( + z.object({ + code: z.literal('human_input_form_not_found'), + status: z.literal(404), + }), +) + /** * HumanInputFormSubmitResponse */ @@ -557,8 +682,6 @@ export const zHumanInputFormSubmitResponse = z.record(z.string(), z.unknown()) /** * HumanInputFormSubmittedError - * - * This form has already been submitted by another user, form_id={form_id} */ export const zHumanInputFormSubmittedError = zErrorResponse.and( z.object({ @@ -567,6 +690,16 @@ export const zHumanInputFormSubmittedError = zErrorResponse.and( }), ) +/** + * HumanInputInvalidOtpError + */ +export const zHumanInputInvalidOtpError = zErrorResponse.and( + z.object({ + code: z.literal('human_input_invalid_otp'), + status: z.literal(400), + }), +) + /** * HumanInputUploadTokenResponse */ @@ -575,6 +708,23 @@ export const zHumanInputUploadTokenResponse = z.object({ upload_token: z.string(), }) +/** + * HumanInputV2RemoteUploadPayload + */ +export const zHumanInputV2RemoteUploadPayload = z.object({ + url: z.url().min(1).max(2083), +}) + +/** + * HumanInputV2UnavailableError + */ +export const zHumanInputV2UnavailableError = zErrorResponse.and( + z.object({ + code: z.literal('human_input_v2_unavailable'), + status: z.literal(503), + }), +) + /** * InvalidEmailError * @@ -597,6 +747,16 @@ export const zInvalidFormDataError = zErrorResponse.and( }), ) +/** + * InvalidInputError + */ +export const zInvalidInputError = zErrorResponse.and( + z.object({ + code: z.literal('invalid_input'), + status: z.literal(400), + }), +) + /** * InvalidOrExpiredTokenError * @@ -649,12 +809,26 @@ export const zHumanInputFormSubmissionData = z.object({ /** * HumanInputFormSubmitPayload + * + * Legacy Human Input v1 submit payload shared by existing runtime surfaces. */ export const zHumanInputFormSubmitPayload = z.object({ action: z.string(), inputs: z.record(z.string(), zJsonValue), }) +/** + * HumanInputV2FormSubmitRequest + * + * Public Human Input v2 submit payload, independent from the v1 form contract. + */ +export const zHumanInputV2FormSubmitRequest = z.object({ + action: z.string(), + challenge_token: z.string().nullish(), + inputs: z.record(z.string(), zJsonValue), + otp_code: z.string().nullish(), +}) + /** * KnowledgeFsCitation * @@ -724,6 +898,16 @@ export const zMalformedUploadTokenError = zErrorResponse.and( }), ) +/** + * MarkdownFragment + * + * One source-ordered Markdown fragment in a resolved form. + */ +export const zMarkdownFragment = z.object({ + text: z.string(), + type: z.literal('markdown_fragment').optional().default('markdown_fragment'), +}) + /** * MessageFeedbackPayload */ @@ -847,6 +1031,17 @@ export const zNotWorkflowAppError = zErrorResponse.and( }), ) +/** + * ParagraphInput + * + * Resolved multiline text input. + */ +export const zParagraphInput = z.object({ + default_value: z.string().nullable(), + output_variable_name: z.string(), + type: z.literal('paragraph_input').optional().default('paragraph_input'), +}) + /** * PassportAccessTokenResponse */ @@ -1097,6 +1292,26 @@ export const zSavedMessageListQuery = z.object({ limit: z.int().gte(1).lte(100).optional().default(20), }) +/** + * SelectInput + * + * Resolved single-choice input with immutable concrete options. + */ +export const zSelectInput = z.object({ + default_value: z.string().nullable(), + options: z.array(z.string()), + output_variable_name: z.string(), + type: z.literal('select_input').optional().default('select_input'), +}) + +export const zFormPart = z.discriminatedUnion('type', [ + zMarkdownFragment, + zParagraphInput, + zSelectInput, + zFileInput, + zFileListInput, +]) + export const zShallowJsonValue = z .union([ z.string(), @@ -1621,6 +1836,29 @@ export const zUploadTokenRequiredError = zErrorResponse.and( }), ) +/** + * UserAction + * + * Resolved action values required for presentation and submission validation. + */ +export const zUserAction = z.object({ + button_style: zButtonStyle, + id: z.string(), + title: z.string(), +}) + +/** + * ResolvedForm + * + * Immutable authoritative presentation snapshot for one v2 form. + */ +export const zResolvedForm = z.object({ + actions: z.array(zUserAction), + legacy_form_content: z.string(), + parts: z.array(zFormPart), + title: z.string(), +}) + /** * UserActionConfig * @@ -1690,6 +1928,19 @@ export const zFormInputConfig = z.discriminatedUnion('type', [ zFileListInputConfig, ]) +/** + * FormDefinitionResponse + * + * Response body containing a resolved human-input form definition. + */ +export const zFormDefinitionResponse = z.object({ + expiration_time: z.int(), + form_content: z.string().nullish(), + inputs: z.array(zFormInputConfig).optional(), + resolved_default_values: z.record(z.string(), z.string()).optional(), + user_actions: z.array(zUserActionConfig).optional(), +}) + /** * HumanInputFormDefinition */ @@ -1700,6 +1951,7 @@ export const zHumanInputFormDefinition = z.object({ form_content: z.string(), form_id: z.string(), form_token: z.string().nullish(), + form_version: z.enum(['1', '2']).optional().default('1'), inputs: z.array(zFormInputConfig).optional(), node_id: z.string(), node_title: z.string(), @@ -1792,6 +2044,27 @@ export const zWebAppCustomConfigResponse = z.object({ replace_webapp_logo: z.string().nullish(), }) +/** + * WebAppHumanInputV2FormDefinitionResponse + */ +export const zWebAppHumanInputV2FormDefinitionResponse = z.object({ + expiration_time: z.int(), + resolved_form: zResolvedForm, +}) + +/** + * WebAppHumanInputV2SubmitPayload + */ +export const zWebAppHumanInputV2SubmitPayload = z.object({ + action: z.string(), + inputs: z.record(z.string(), zJsonValue), +}) + +/** + * WebAppHumanInputV2SubmitResponse + */ +export const zWebAppHumanInputV2SubmitResponse = z.record(z.string(), z.unknown()) + /** * WebFormRateLimitExceededError * @@ -1905,6 +2178,16 @@ export const zHumanInputFormDefinitionResponse = z.object({ user_actions: z.array(zUserActionConfig), }) +/** + * WorkflowNotActiveError + */ +export const zWorkflowNotActiveError = zErrorResponse.and( + z.object({ + code: z.literal('workflow_not_active'), + status: z.literal(409), + }), +) + /** * WorkflowRunPayload */ @@ -1946,6 +2229,13 @@ export const zWorkflowRunPayload = z.object({ inputs: z.record(z.string(), z.unknown()), }) +/** + * FormSubmitResponse + * + * Empty response body returned after a Human Input v2 form submission. + */ +export const zFormSubmitResponseWritable = z.record(z.string(), z.unknown()) + /** * GeneratedAppResponse */ @@ -1956,6 +2246,11 @@ export const zGeneratedAppResponseWritable = zShallowJsonValue */ export const zHumanInputFormSubmitResponseWritable = z.record(z.string(), z.unknown()) +/** + * WebAppHumanInputV2SubmitResponse + */ +export const zWebAppHumanInputV2SubmitResponseWritable = z.record(z.string(), z.unknown()) + /** * WebMessageListItem */ @@ -2129,6 +2424,65 @@ export const zPostForgotPasswordValidityBody = zForgotPasswordCheckPayload */ export const zPostForgotPasswordValidityResponse = zVerificationTokenResponse +export const zGetFormHumanInputV2PublicByFormTokenPath = z.object({ + form_token: z.string(), +}) + +/** + * Form definition + */ +export const zGetFormHumanInputV2PublicByFormTokenResponse = zFormDefinitionResponse + +export const zPostFormHumanInputV2PublicByFormTokenBody = zHumanInputV2FormSubmitRequest + +export const zPostFormHumanInputV2PublicByFormTokenPath = z.object({ + form_token: z.string(), +}) + +/** + * Form submitted + */ +export const zPostFormHumanInputV2PublicByFormTokenResponse = zFormSubmitResponse + +export const zPostFormHumanInputV2PublicByFormTokenAccessRequestPath = z.object({ + form_token: z.string(), +}) + +/** + * Success + */ +export const zPostFormHumanInputV2PublicByFormTokenAccessRequestResponse = + zFormAccessRequestResponse + +export const zPostFormHumanInputV2PublicByFormTokenUploadTokenPath = z.object({ + form_token: z.string(), +}) + +/** + * Success + */ +export const zPostFormHumanInputV2PublicByFormTokenUploadTokenResponse = zFormUploadTokenResponse + +export const zGetFormHumanInputV2ByFormTokenPath = z.object({ + form_token: z.string(), +}) + +/** + * Form retrieved + */ +export const zGetFormHumanInputV2ByFormTokenResponse = zWebAppHumanInputV2FormDefinitionResponse + +export const zPostFormHumanInputV2ByFormTokenBody = zWebAppHumanInputV2SubmitPayload + +export const zPostFormHumanInputV2ByFormTokenPath = z.object({ + form_token: z.string(), +}) + +/** + * Approval accepted + */ +export const zPostFormHumanInputV2ByFormTokenResponse = zWebAppHumanInputV2SubmitResponse + export const zGetFormHumanInputByFormTokenPath = z.object({ form_token: z.string(), }) @@ -2163,6 +2517,31 @@ export const zPostFormHumanInputByFormTokenUploadTokenResponse = zHumanInputUplo */ export const zPostHumanInputFormsFilesResponse = zFileResponse +export const zPostHumanInputV2FilesBody = z.object({ + file: z.custom((value) => value instanceof Blob || value instanceof File).optional(), + url: z.string().optional(), +}) + +export const zPostHumanInputV2FilesHeaders = z.object({ + Authorization: z.string(), +}) + +/** + * File uploaded + */ +export const zPostHumanInputV2FilesResponse = zFileWithSignedUrl + +export const zPostHumanInputV2FilesRemoteBody = zHumanInputV2RemoteUploadPayload + +export const zPostHumanInputV2FilesRemoteHeaders = z.object({ + Authorization: z.string(), +}) + +/** + * File uploaded + */ +export const zPostHumanInputV2FilesRemoteResponse = zFileWithSignedUrl + export const zPostLoginBody = zLoginPayload /** diff --git a/packages/contracts/scripts/compose-console-openapi.mjs b/packages/contracts/scripts/compose-console-openapi.mjs index dca627991ab..eb9056b9851 100644 --- a/packages/contracts/scripts/compose-console-openapi.mjs +++ b/packages/contracts/scripts/compose-console-openapi.mjs @@ -52,6 +52,9 @@ export function composeConsoleDocuments(sources) { continue } operation.operationId = `${name}_${method}_${path}` + // Keep the new kebab-case segment from renaming legacy human_input client members. + if (name === 'console' && path === '/form/human-input/v2/{form_token}') + operation['x-client-path'] = ['form', 'humanInput', 'v2', 'byFormToken', method] // Preserve source-level servers when operations do not override them. if (!operation.servers && !item.servers && document.servers) operation.servers = document.servers diff --git a/packages/openapi-content-mapper/integration/console-preparation.test.mjs b/packages/openapi-content-mapper/integration/console-preparation.test.mjs index b9b83f36dd2..e93277cdb99 100644 --- a/packages/openapi-content-mapper/integration/console-preparation.test.mjs +++ b/packages/openapi-content-mapper/integration/console-preparation.test.mjs @@ -55,6 +55,34 @@ test('every existing Console member retains its name, HTTP method and route', (t ) }) +test('Human Input v2 keeps the legacy Console client members', () => { + const responses = { 204: { description: 'Success' } } + const document = composeConsoleDocuments([ + { + name: 'console', + document: { + openapi: '3.1.0', + paths: { + '/form/human_input/{form_token}': { get: { responses }, post: { responses } }, + '/form/human-input/v2/{form_token}': { get: { responses }, post: { responses } }, + }, + }, + }, + ]) + const router = createRouter(describeOperations(document)) + const humanInput = router.children.get('form')?.children.get('humanInput') + for (const method of ['get', 'post']) { + assert.equal( + humanInput?.children.get('byFormToken')?.methods.get(method)?.path, + '/form/human_input/{form_token}', + ) + assert.equal( + humanInput?.children.get('v2')?.children.get('byFormToken')?.methods.get(method)?.path, + '/form/human-input/v2/{form_token}', + ) + } +}) + test('composition isolates schemas and rejects ambiguous routes and external references', () => { const source = (path, type) => ({ openapi: '3.1.0', diff --git a/packages/openapi-content-mapper/integration/console-type-differences.json b/packages/openapi-content-mapper/integration/console-type-differences.json index 34719f902ca..22f1317b57b 100644 --- a/packages/openapi-content-mapper/integration/console-type-differences.json +++ b/packages/openapi-content-mapper/integration/console-type-differences.json @@ -1,4 +1,8 @@ { + "console DELETE /workspaces/current/human-input/v2/contacts/{contact_id}/im-override": [ + "compatible", + "parsed-vs-wire-output" + ], "console GET /account/delete/verify": ["no-input-schema", "compatible"], "console GET /account/education": ["no-input-schema", "compatible"], "console GET /account/education/verify": ["no-input-schema", "compatible"], @@ -116,6 +120,7 @@ "console GET /features/vector-space": ["no-input-schema", "parsed-vs-wire-output"], "console GET /files/support-type": ["no-input-schema", "compatible"], "console GET /files/upload": ["no-input-schema", "compatible"], + "console GET /form/human-input/v2/{form_token}": ["compatible", "parsed-vs-wire-output"], "console GET /init": ["no-input-schema", "compatible"], "console GET /installed-apps/{installed_app_id}/messages": [ "compatible", @@ -202,6 +207,20 @@ ], "console GET /trial-models": ["no-input-schema", "compatible"], "console GET /workflow-run-archives": ["no-input-schema", "compatible"], + "console GET /workflow/{workflow_run_id}/pause-details": ["compatible", "parsed-vs-wire-output"], + "console GET /workspaces/current/human-input/v2/channel-providers": [ + "no-input-schema", + "parsed-vs-wire-output" + ], + "console GET /workspaces/current/human-input/v2/channels": ["no-input-schema", "compatible"], + "console GET /workspaces/current/human-input/v2/channels/email/{channel_id}": [ + "compatible", + "parsed-vs-wire-output" + ], + "console GET /workspaces/current/human-input/v2/channels/im/{channel_id}": [ + "compatible", + "parsed-vs-wire-output" + ], "console GET /workspaces": ["no-input-schema", "compatible"], "console GET /workspaces/{tenant_id}/model-providers/{provider}/{icon_type}/{lang}": [ "compatible", @@ -216,6 +235,26 @@ "console GET /workspaces/current/default-model": ["compatible", "parsed-vs-wire-output"], "console GET /workspaces/current/endpoints/list": ["compatible", "parsed-vs-wire-output"], "console GET /workspaces/current/endpoints/list/plugin": ["compatible", "parsed-vs-wire-output"], + "console GET /workspaces/current/human-input/v2/contacts": [ + "compatible", + "parsed-vs-wire-output" + ], + "console GET /workspaces/current/human-input/v2/contacts/{contact_id}": [ + "compatible", + "parsed-vs-wire-output" + ], + "console GET /workspaces/current/human-input/v2/contacts/batch": [ + "compatible", + "parsed-vs-wire-output" + ], + "console GET /workspaces/current/human-input/v2/im-sync-runs/latest": [ + "no-input-schema", + "compatible" + ], + "console GET /workspaces/current/human-input/v2/im-sync-runs/latest/results": [ + "compatible", + "parsed-vs-wire-output" + ], "console GET /workspaces/current/model-providers": ["compatible", "parsed-vs-wire-output"], "console GET /workspaces/current/model-providers/{provider}/models": [ "compatible", @@ -481,6 +520,10 @@ "parsed-vs-wire-output" ], "console PATCH /onboarding/step-by-step-tour/state": ["compatible", "parsed-vs-wire-output"], + "console PATCH /workspaces/current/human-input/v2/contacts/external/{contact_id}": [ + "compatible", + "parsed-vs-wire-output" + ], "console PATCH /workspaces/current/skills/{skill_id}": ["compatible", "parsed-vs-wire-output"], "console PATCH /workspaces/current/skills/{skill_id}/files": [ "compatible", @@ -597,6 +640,19 @@ "console POST /trial-apps/{app_id}/workflows/run": ["compatible", "undocumented-output"], "console POST /workflow-generate": ["compatible", "parsed-vs-wire-output"], "console POST /workflow-generate/stream": ["compatible", "parsed-vs-wire-output"], + "console POST /workspaces/current/human-input/v2/contacts/external": [ + "compatible", + "parsed-vs-wire-output" + ], + "console POST /workspaces/current/human-input/v2/contacts/platform": [ + "compatible", + "parsed-vs-wire-output" + ], + "console POST /workspaces/current/human-input/v2/im-sync-runs": ["no-input-schema", "compatible"], + "console POST /workspaces/current/human-input/v2/node-data-migration": [ + "compatible", + "parsed-vs-wire-output" + ], "console POST /workspaces/current/network-access-groups": ["compatible", "parsed-vs-wire-output"], "console POST /workspaces/current/plugin/list/installations/ids": [ "compatible", @@ -657,6 +713,14 @@ "compatible", "parsed-vs-wire-output" ], + "console PUT /workspaces/current/human-input/v2/contacts/{contact_id}/im-bindings": [ + "compatible", + "parsed-vs-wire-output" + ], + "console PUT /workspaces/current/human-input/v2/contacts/{contact_id}/im-override": [ + "compatible", + "parsed-vs-wire-output" + ], "console PUT /workspaces/current/network-access-groups/{group_id}": [ "compatible", "parsed-vs-wire-output" diff --git a/web/.env.example b/web/.env.example index 28b2a55576b..5fccfca0f2e 100644 --- a/web/.env.example +++ b/web/.env.example @@ -104,6 +104,11 @@ NEXT_PUBLIC_ENABLE_SINGLE_DOLLAR_LATEX=false # /refine slash commands in the "Go to Anything" command palette) NEXT_PUBLIC_ENABLE_FEATURE_PREVIEW=true +# Enable HITL IM, Contacts, V2 editing/migration and approval UI. Defaults to false. +# When false, retain legacy V1 behavior and disable V2 entry points without converting saved data. +# Restart the Web service and reload the page after changing this setting. +NEXT_PUBLIC_ENABLE_HITL_IM=false + # Show Access Token settings to workspace owners. Disabled by default. NEXT_PUBLIC_ENABLE_ACCESS_TOKEN=false diff --git a/web/__tests__/env.spec.ts b/web/__tests__/env.spec.ts index e9956041b01..cf60a72c4e8 100644 --- a/web/__tests__/env.spec.ts +++ b/web/__tests__/env.spec.ts @@ -1,12 +1,14 @@ describe('env runtime transport', () => { const envKeys = [ 'NEXT_PUBLIC_ENABLE_AGENT_V2', + 'NEXT_PUBLIC_ENABLE_HITL_IM', 'NEXT_PUBLIC_MARKDOWN_FORM_FIELD_NAME_EXTRA_CHARS', 'NEXT_PUBLIC_MARKDOWN_FORM_FIELD_NAME_MAX_LENGTH', 'NEXT_PUBLIC_TURNSTILE_SITE_KEY', ] as const const attributes = [ 'data-enable-agent-v2', + 'data-enable-hitl-im', 'data-enable-agent-v-2', 'data-markdown-form-field-name-extra-chars', 'data-markdown-form-field-name-max-length', @@ -32,6 +34,37 @@ describe('env runtime transport', () => { }) }) + it.each([ + [undefined, false], + ['false', false], + ['true', true], + ['0', false], + ['1', true], + ] as const)( + 'transports the HITL IM release gate from server to browser for %s', + async (value, enabled) => { + vi.stubEnv('NEXT_PUBLIC_ENABLE_HITL_IM', value) + vi.doMock('../utils/client', () => ({ isClient: false, isServer: true })) + const { env: serverEnv, getDatasetMap } = await import('../env') + expect(serverEnv.NEXT_PUBLIC_ENABLE_HITL_IM).toBe(enabled) + const dataset = getDatasetMap() + expect(dataset['data-enable-hitl-im']).toBe(enabled) + + document.body.setAttribute('data-enable-hitl-im', String(dataset['data-enable-hitl-im'])) + vi.stubEnv('NEXT_PUBLIC_ENABLE_HITL_IM', String(!enabled)) + vi.resetModules() + vi.doMock('../utils/client', () => ({ isClient: true, isServer: false })) + const { env: browserEnv } = await import('../env') + expect(browserEnv.NEXT_PUBLIC_ENABLE_HITL_IM).toBe(enabled) + }, + ) + + it('keeps HITL IM disabled when the browser runtime attribute is absent', async () => { + vi.stubEnv('NEXT_PUBLIC_ENABLE_HITL_IM', 'true') + const { env } = await import('../env') + expect(env.NEXT_PUBLIC_ENABLE_HITL_IM).toBe(false) + }) + it('should read NEXT_PUBLIC_ENABLE_AGENT_V2 from the browser runtime dataset key', async () => { document.body.setAttribute('data-enable-agent-v2', 'true') diff --git a/web/app/(commonLayout)/contacts/__tests__/page.spec.tsx b/web/app/(commonLayout)/contacts/__tests__/page.spec.tsx new file mode 100644 index 00000000000..d821a6a0996 --- /dev/null +++ b/web/app/(commonLayout)/contacts/__tests__/page.spec.tsx @@ -0,0 +1,45 @@ +import type { ReactNode } from 'react' +import { render, screen } from '@testing-library/react' +import ContactsPage from '../page' + +const mockConfig = vi.hoisted(() => ({ ENABLE_HITL_IM: false })) + +vi.mock('@/config', () => ({ + ENABLE_FEATURE_PREVIEW: true, + get ENABLE_HITL_IM() { + return mockConfig.ENABLE_HITL_IM + }, +})) + +vi.mock('@/features/contacts/management/composition', () => ({ + ContactsManagementRuntimeProvider: ({ children }: { children: ReactNode }) => children, +})) + +vi.mock('@/features/contacts/management/directory-page', () => ({ + ContactsDirectoryPage: () =>

Contacts directory

, +})) + +vi.mock('@/next/navigation', () => ({ + notFound: () => { + throw new Error('NEXT_NOT_FOUND') + }, +})) + +describe('Contacts route release gate', () => { + beforeEach(() => { + mockConfig.ENABLE_HITL_IM = false + }) + + it('rejects direct navigation before rendering the directory when HITL IM is not released', () => { + expect(() => ContactsPage()).toThrow('NEXT_NOT_FOUND') + expect(screen.queryByRole('heading', { name: 'Contacts directory' })).not.toBeInTheDocument() + }) + + it('opens the directory when HITL IM is released', () => { + mockConfig.ENABLE_HITL_IM = true + + render() + + expect(screen.getByRole('heading', { name: 'Contacts directory' })).toBeInTheDocument() + }) +}) diff --git a/web/app/(commonLayout)/contacts/page.tsx b/web/app/(commonLayout)/contacts/page.tsx new file mode 100644 index 00000000000..69c121f13f3 --- /dev/null +++ b/web/app/(commonLayout)/contacts/page.tsx @@ -0,0 +1,13 @@ +import { ContactsManagementRuntimeProvider } from '@/features/contacts/management/composition' +import { ContactsDirectoryPage } from '@/features/contacts/management/directory-page' +import { isContactsManagementEnabled } from '@/features/contacts/management/feature-flag' +import { notFound } from '@/next/navigation' + +export default function ContactsPage() { + if (!isContactsManagementEnabled()) notFound() + return ( + + + + ) +} diff --git a/web/app/(humanInputLayout)/__tests__/release-gate.spec.tsx b/web/app/(humanInputLayout)/__tests__/release-gate.spec.tsx new file mode 100644 index 00000000000..d1faa30f407 --- /dev/null +++ b/web/app/(humanInputLayout)/__tests__/release-gate.spec.tsx @@ -0,0 +1,54 @@ +import type { ReactNode } from 'react' +import { render, screen } from '@testing-library/react' +import PublicV2Page from '../form-v2/[token]/page' +import ConsolePage from '../human-input/[formToken]/page' + +const config = vi.hoisted(() => ({ enabled: false, publicForm: vi.fn(), consoleForm: vi.fn() })) +vi.mock('@/config', async (importOriginal) => ({ + ...(await importOriginal()), + get ENABLE_HITL_IM() { + return config.enabled + }, +})) +vi.mock('@/next/navigation', () => ({ + notFound: () => { + throw new Error('NEXT_NOT_FOUND') + }, +})) +vi.mock('../form-v2/[token]/form', () => ({ + default: () => { + config.publicForm() + return
Public V2 form
+ }, +})) +vi.mock('@/features/human-input/console/form', () => ({ + default: () => { + config.consoleForm() + return
Console V2 form
+ }, +})) +vi.mock('@/app/(commonLayout)/hydration-boundary', () => ({ + CommonLayoutHydrationBoundary: ({ children }: { children: ReactNode }) => children, +})) + +beforeEach(() => { + config.enabled = false +}) + +it('blocks the public V2 route before mounting its form when disabled', () => { + expect(() => render()).toThrow('NEXT_NOT_FOUND') + expect(config.publicForm).not.toHaveBeenCalled() +}) +it('blocks the Console V2 route before mounting its form when disabled', async () => { + await expect(ConsolePage({ params: Promise.resolve({ formToken: 'token' }) })).rejects.toThrow( + 'NEXT_NOT_FOUND', + ) + expect(config.consoleForm).not.toHaveBeenCalled() +}) +it('allows both V2 route owners when enabled', async () => { + config.enabled = true + render() + render(await ConsolePage({ params: Promise.resolve({ formToken: 'token' }) })) + expect(screen.getByText('Public V2 form')).toBeInTheDocument() + expect(screen.getByText('Console V2 form')).toBeInTheDocument() +}) diff --git a/web/app/(humanInputLayout)/form-v2/[token]/__tests__/form.spec.tsx b/web/app/(humanInputLayout)/form-v2/[token]/__tests__/form.spec.tsx new file mode 100644 index 00000000000..cb21a2244af --- /dev/null +++ b/web/app/(humanInputLayout)/form-v2/[token]/__tests__/form.spec.tsx @@ -0,0 +1,49 @@ +import { render, screen } from '@testing-library/react' +import FormContent from '../form' + +const mockRoute = vi.hoisted(() => ({ token: 'v2-token' })) +const mockHumanInputV2Form = vi.hoisted(() => + vi.fn(({ token }: { token: string }) =>
v2-form:{token}
), +) +const mockLegacyHook = vi.hoisted(() => vi.fn()) + +vi.mock('@/next/navigation', () => ({ + useParams: () => ({ token: mockRoute.token }), +})) + +vi.mock('@/features/human-input/public/form', () => ({ + default: (props: { token: string }) => mockHumanInputV2Form(props), +})) + +vi.mock('@/service/use-share', () => ({ + useGetHumanInputForm: mockLegacyHook, +})) + +vi.mock('@/hooks/use-document-title', () => ({ + default: vi.fn(), +})) + +describe('/form-v2/[token] route', () => { + beforeEach(() => { + vi.clearAllMocks() + mockRoute.token = 'v2-token' + }) + + it('passes the v2 route token to the isolated feature and never invokes the legacy form hook', () => { + render() + + expect(screen.getByText('v2-form:v2-token')).toBeInTheDocument() + expect(mockHumanInputV2Form).toHaveBeenCalledWith({ token: 'v2-token' }) + expect(mockLegacyHook).not.toHaveBeenCalled() + }) + + it('keys the feature session by the current route token', () => { + const { unmount } = render() + unmount() + mockRoute.token = 'next-token' + render() + + expect(screen.getByText('v2-form:next-token')).toBeInTheDocument() + expect(mockHumanInputV2Form).toHaveBeenLastCalledWith({ token: 'next-token' }) + }) +}) diff --git a/web/app/(humanInputLayout)/form-v2/[token]/form.tsx b/web/app/(humanInputLayout)/form-v2/[token]/form.tsx new file mode 100644 index 00000000000..e683a8ea4c3 --- /dev/null +++ b/web/app/(humanInputLayout)/form-v2/[token]/form.tsx @@ -0,0 +1,14 @@ +'use client' +import * as React from 'react' +import HumanInputV2Form from '@/features/human-input/public/form' +import useDocumentTitle from '@/hooks/use-document-title' +import { useParams } from '@/next/navigation' + +const FormContent = () => { + const { token } = useParams<{ token: string }>() + useDocumentTitle('') + + return +} + +export default React.memo(FormContent) diff --git a/web/app/(humanInputLayout)/form-v2/[token]/page.tsx b/web/app/(humanInputLayout)/form-v2/[token]/page.tsx new file mode 100644 index 00000000000..8cf60616fbe --- /dev/null +++ b/web/app/(humanInputLayout)/form-v2/[token]/page.tsx @@ -0,0 +1,14 @@ +import { ENABLE_HITL_IM } from '@/config' +import { notFound } from '@/next/navigation' +import FormContent from './form' + +const FormPage = () => { + if (!ENABLE_HITL_IM) notFound() + return ( +
+ +
+ ) +} + +export default FormPage diff --git a/web/app/(humanInputLayout)/form/[token]/__tests__/form.spec.tsx b/web/app/(humanInputLayout)/form/[token]/__tests__/form.spec.tsx index 721774da99f..b6c9f3efab7 100644 --- a/web/app/(humanInputLayout)/form/[token]/__tests__/form.spec.tsx +++ b/web/app/(humanInputLayout)/form/[token]/__tests__/form.spec.tsx @@ -1,33 +1,71 @@ -import type { FormData } from '../form' import type { FileEntity } from '@/app/components/base/file-uploader/types' +import type { LegacyHumanInputFormData } from '@/features/human-input/form/types' import { ORPCError } from '@orpc/client' import { act, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' +import { useFileUploadContext } from '@/app/components/base/file-uploader/upload-context' import { UserActionButtonType } from '@/app/components/workflow/nodes/human-input/types' import { InputVarType, SupportUploadFileTypes } from '@/app/components/workflow/types' import { renderWithConsoleQuery as render } from '@/test/console/query-data' import { TransferMethod } from '@/types/app' import FormContent from '../form' -const { mockGetForm, mockSubmitForm, uploadState } = vi.hoisted(() => ({ - mockGetForm: vi.fn(), - mockSubmitForm: vi.fn(), - uploadState: { onChange: undefined as ((files: FileEntity[]) => void) | undefined }, +const releaseGate = vi.hoisted(() => ({ enabled: true })) +vi.mock('@/config', async (importOriginal) => ({ + ...(await importOriginal()), + get ENABLE_HITL_IM() { + return releaseGate.enabled + }, })) +const { mockGetForm, mockSubmitForm, mockUploadLocal, mockUploadRemote, uploadState } = vi.hoisted( + () => ({ + mockGetForm: vi.fn(), + mockSubmitForm: vi.fn(), + mockUploadLocal: vi.fn(), + mockUploadRemote: vi.fn(), + uploadState: { onChange: undefined as ((files: FileEntity[]) => void) | undefined }, + }), +) + vi.mock('@/next/navigation', () => ({ useParams: () => ({ token: 'token-123' }) })) vi.mock('@/service/share', async (importOriginal) => ({ ...(await importOriginal()), getHumanInputForm: mockGetForm, submitHumanInputForm: mockSubmitForm, + uploadHumanInputFormLocalFile: mockUploadLocal, + uploadHumanInputFormRemoteFileInfo: mockUploadRemote, })) // Retain the callback captured when upload starts, as the asynchronous uploader does. // Form parsing, text fields, validation and submission all remain real. vi.mock('@/app/components/base/file-uploader', () => ({ FileUploaderInAttachmentWrapper: ({ onChange }: { onChange: (files: FileEntity[]) => void }) => { + const { uploadStrategy } = useFileUploadContext() if (!uploadState.onChange) uploadState.onChange = onChange - return
+ return ( +
+ + +
+ ) }, })) @@ -43,12 +81,12 @@ const uploadedFile: FileEntity = { } const completeUpload = () => act(() => uploadState.onChange?.([uploadedFile])) -describe('Human input share form', () => { +describe.each([false, true])('Human input share form with IM gate %s', (enabled) => { const originalTitle = document.title afterEach(() => { document.title = originalTitle }) - const formData: FormData = { + const formData: LegacyHumanInputFormData = { site: { site: { title: 'Review App', @@ -97,17 +135,20 @@ describe('Human input share form', () => { } beforeEach(() => { + releaseGate.enabled = enabled mockGetForm.mockReset() mockSubmitForm.mockReset() mockGetForm.mockResolvedValue(formData) mockSubmitForm.mockResolvedValue({}) + mockUploadLocal.mockReset().mockResolvedValue(undefined) + mockUploadRemote.mockReset().mockResolvedValue({ id: 'remote-file' }) uploadState.onChange = undefined }) it('moves from loading to the requested form and updates its document title', async () => { - let resolve!: (data: FormData) => void + let resolve!: (data: LegacyHumanInputFormData) => void mockGetForm.mockReturnValue( - new Promise((done) => { + new Promise((done) => { resolve = done }), ) @@ -125,6 +166,25 @@ describe('Human input share form', () => { expect(screen.queryByLabelText('common.loading')).not.toBeInTheDocument() }) + it('keeps both attachment upload methods scoped to the legacy form token', async () => { + const user = userEvent.setup() + render() + + await user.click(await screen.findByRole('button', { name: 'Upload local attachment' })) + await user.click(screen.getByRole('button', { name: 'Upload linked attachment' })) + + expect(mockUploadLocal).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ + formToken: 'token-123', + file: expect.any(File), + }), + ) + expect(mockUploadRemote).toHaveBeenCalledExactlyOnceWith( + 'token-123', + 'https://example.com/review.pdf', + ) + }) + it.each([ { code: 'human_input_form_expired', diff --git a/web/app/(humanInputLayout)/form/[token]/form.tsx b/web/app/(humanInputLayout)/form/[token]/form.tsx index 7f1e7e3a0ec..8f58d1dd668 100644 --- a/web/app/(humanInputLayout)/form/[token]/form.tsx +++ b/web/app/(humanInputLayout)/form/[token]/form.tsx @@ -1,35 +1,36 @@ 'use client' - -import type { FormInputItem, UserAction } from '@/app/components/workflow/nodes/human-input/types' -import type { CustomConfigValueType, SiteInfo } from '@/models/share' -import type { HumanInputResolvedValue } from '@/types/workflow' import { ORPCError } from '@orpc/client' import * as React from 'react' import { useTranslation } from 'react-i18next' +import { getProcessedHumanInputFormInputs } from '@/app/components/base/chat/chat/answer/human-input-content/utils' +import { + FileUploadContext, + useFileUploadContext, +} from '@/app/components/base/file-uploader/upload-context' import { LoadingPlaceholder } from '@/app/components/base/loading-placeholder' +import { ENABLE_HITL_IM } from '@/config' +import FormStatusCard from '@/features/human-input/form/form-status-card' +import LoadedFormContent from '@/features/human-input/form/loaded-form-content' +import { normalizeLegacyHumanInputForm } from '@/features/human-input/form/normalize-legacy-definition' +import { createLegacyFormUploadStrategy } from '@/features/human-input/public/legacy-upload' import useDocumentTitle from '@/hooks/use-document-title' import { useParams } from '@/next/navigation' import { useGetHumanInputForm } from '@/service/use-share' -import FormStatusCard from './form-status-card' -import LoadedFormContent from './loaded-form-content' +import LegacyLoadedFormContent from './loaded-form-content' import { useFormSubmit } from './use-form-submit' -export type FormData = { - site: { - site: SiteInfo - custom_config?: Record | null - } - form_content: string - inputs: FormInputItem[] - resolved_default_values: Record - user_actions: UserAction[] - expiration_time: number -} - const FormContent = () => { const { t } = useTranslation(['common', 'share']) const { token } = useParams<{ token: string }>() + const inheritedUploadContext = useFileUploadContext() + const uploadContext = React.useMemo( + () => ({ + ...inheritedUploadContext, + uploadStrategy: createLegacyFormUploadStrategy(token), + }), + [inheritedUploadContext, token], + ) const { data: formData, isLoading, error } = useGetHumanInputForm(token) const { isSubmitting, submit, success } = useFormSubmit(token) @@ -115,14 +116,27 @@ const FormContent = () => { } return ( - + + {!ENABLE_HITL_IM ? ( + + submit(getProcessedHumanInputFormInputs(formInputs, inputs) || {}, action) + } + /> + ) : ( + + )} + ) } diff --git a/web/app/(humanInputLayout)/form/[token]/loaded-form-content.tsx b/web/app/(humanInputLayout)/form/[token]/loaded-form-content.tsx index 85743c20b72..1935e0baddd 100644 --- a/web/app/(humanInputLayout)/form/[token]/loaded-form-content.tsx +++ b/web/app/(humanInputLayout)/form/[token]/loaded-form-content.tsx @@ -1,6 +1,6 @@ -import type { FormData } from './form' import type { HumanInputFieldValue } from '@/app/components/base/chat/chat/answer/human-input-content/field-renderer' import type { UserAction } from '@/app/components/workflow/nodes/human-input/types' +import type { LegacyHumanInputFormData as FormData } from '@/features/human-input/form/types' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' import { produce } from 'immer' @@ -15,7 +15,7 @@ import { initializeInputs, splitByOutputVar, } from '@/app/components/base/chat/chat/answer/human-input-content/utils' -import BrandingFooter from './branding-footer' +import BrandingFooter from '@/features/human-input/form/branding-footer' type LoadedFormContentProps = { formData: FormData diff --git a/web/app/(humanInputLayout)/form/[token]/use-form-submit.ts b/web/app/(humanInputLayout)/form/[token]/use-form-submit.ts index 44e1b6e347d..443110c2a95 100644 --- a/web/app/(humanInputLayout)/form/[token]/use-form-submit.ts +++ b/web/app/(humanInputLayout)/form/[token]/use-form-submit.ts @@ -1,7 +1,4 @@ -import type { HumanInputFieldValue } from '@/app/components/base/chat/chat/answer/human-input-content/field-renderer' -import type { FormInputItem } from '@/app/components/workflow/nodes/human-input/types' import { useCallback, useState } from 'react' -import { getProcessedHumanInputFormInputs } from '@/app/components/base/chat/chat/answer/human-input-content/utils' import { useSubmitHumanInputForm } from '@/service/use-share' export const useFormSubmit = (token: string) => { @@ -9,16 +6,12 @@ export const useFormSubmit = (token: string) => { const { mutate: submitForm, isPending: isSubmitting } = useSubmitHumanInputForm() const submit = useCallback( - ( - inputs: Record, - actionID: string, - formInputs: FormInputItem[], - ) => { + (inputs: Record | undefined, actionID: string) => { submitForm( { token, data: { - inputs: getProcessedHumanInputFormInputs(formInputs, inputs) || {}, + inputs: inputs || {}, action: actionID, }, }, diff --git a/web/app/(humanInputLayout)/human-input/[formToken]/page.tsx b/web/app/(humanInputLayout)/human-input/[formToken]/page.tsx new file mode 100644 index 00000000000..5be24f2fb0d --- /dev/null +++ b/web/app/(humanInputLayout)/human-input/[formToken]/page.tsx @@ -0,0 +1,17 @@ +import { CommonLayoutHydrationBoundary } from '@/app/(commonLayout)/hydration-boundary' +import { ENABLE_HITL_IM } from '@/config' +import ConsoleHumanInputForm from '@/features/human-input/console/form' +import { notFound } from '@/next/navigation' + +export default async function Page({ params }: { params: Promise<{ formToken: string }> }) { + if (!ENABLE_HITL_IM) notFound() + const { formToken } = await params + + return ( + +
+ +
+
+ ) +} diff --git a/web/app/components/app/configuration/config-var/config-modal/__tests__/index.spec.tsx b/web/app/components/app/configuration/config-var/config-modal/__tests__/index.spec.tsx index b6b0ae96130..7b1fd231643 100644 --- a/web/app/components/app/configuration/config-var/config-modal/__tests__/index.spec.tsx +++ b/web/app/components/app/configuration/config-var/config-modal/__tests__/index.spec.tsx @@ -18,6 +18,7 @@ import ConfigModal from '../index' vi.mock('@/next/navigation', async (importOriginal) => ({ ...(await importOriginal()), useParams: () => ({ appId: 'app-1' }), + usePathname: () => '/app/app-1/configuration', })) vi.mock('@monaco-editor/react', async () => ({ diff --git a/web/app/components/app/text-generate/item/__tests__/index.hitl-v2.spec.tsx b/web/app/components/app/text-generate/item/__tests__/index.hitl-v2.spec.tsx new file mode 100644 index 00000000000..dd6ac035ec3 --- /dev/null +++ b/web/app/components/app/text-generate/item/__tests__/index.hitl-v2.spec.tsx @@ -0,0 +1,206 @@ +import { act, render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { WorkflowRunningStatus } from '@/app/components/workflow/types' +import { AppSourceType } from '@/service/share' +import { + createHumanInputFormFixture, + createWorkflowProcessFixture, +} from '@/test/fixtures/workflow-process' +import GenerationItem from '../index' + +const mockFetchMoreLikeThis = vi.fn() +const mockFetchTextGenerationMessage = vi.fn() +const mockUpdateFeedback = vi.fn() +const mockSubmitHumanInputForm = vi.fn() +const mockSubmitWebAppHumanInputForm = vi.fn() +const mockSubmitHumanInputFormWorkflow = vi.fn() +const mockToastWarning = vi.fn() +vi.mock('@/next/navigation', () => ({ + useParams: () => ({ + appId: 'app-1', + }), +})) + +vi.mock('@/service/share', async () => { + const actual = await vi.importActual('@/service/share') + return { + ...actual, + fetchMoreLikeThis: (...args: unknown[]) => mockFetchMoreLikeThis(...args), + submitHumanInputForm: (...args: unknown[]) => mockSubmitHumanInputForm(...args), + updateFeedback: (...args: unknown[]) => mockUpdateFeedback(...args), + } +}) + +vi.mock('@/service/workflow', () => ({ + submitHumanInputForm: (...args: unknown[]) => mockSubmitHumanInputFormWorkflow(...args), +})) + +vi.mock('@/service/webapp-human-input', () => ({ + submitWebAppHumanInputForm: (...args: unknown[]) => mockSubmitWebAppHumanInputForm(...args), +})) + +vi.mock('@/service/debug', () => ({ + fetchTextGenerationMessage: (...args: unknown[]) => mockFetchTextGenerationMessage(...args), +})) + +vi.mock('@/app/components/base/chat/chat/context', () => ({ + useChatContext: () => ({ + config: { + text_to_speech: { + voice: 'alloy', + }, + }, + }), +})) + +vi.mock('@/app/components/base/markdown', () => ({ + Markdown: ({ content }: { content: string }) =>
{`markdown:${content}`}
, +})) + +vi.mock('@/app/notifications', () => ({ + toast: { + warning: (...args: unknown[]) => mockToastWarning(...args), + success: vi.fn(), + }, +})) + +vi.mock('../workflow-body', () => ({ + default: ({ + currentTab, + onSubmitHumanInputForm, + onSwitchTab, + }: { + currentTab: string + onSubmitHumanInputForm: ( + token: string, + data: { inputs: Record; action: string }, + ) => Promise + onSwitchTab: (tab: string) => Promise + }) => ( +
+
{`workflow-body:${currentTab}`}
+ + +
+ ), +})) + +describe('GenerationItem', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it.each([ + { + appSourceType: AppSourceType.webApp, + formVersion: '1' as const, + submit: mockSubmitWebAppHumanInputForm, + unusedSubmit: mockSubmitHumanInputForm, + }, + { + appSourceType: AppSourceType.webApp, + formVersion: '2' as const, + submit: mockSubmitWebAppHumanInputForm, + unusedSubmit: mockSubmitHumanInputForm, + }, + { + appSourceType: AppSourceType.webApp, + formVersion: undefined, + submit: mockSubmitWebAppHumanInputForm, + unusedSubmit: mockSubmitHumanInputForm, + }, + { + appSourceType: AppSourceType.tryApp, + formVersion: undefined, + submit: mockSubmitHumanInputForm, + unusedSubmit: mockSubmitWebAppHumanInputForm, + }, + ])( + 'preserves $appSourceType version $formVersion submission and workflow tab switching', + async ({ appSourceType, formVersion, submit, unusedSubmit }) => { + const user = userEvent.setup() + render( + , + ) + + expect(screen.getByText('workflow-body:RESULT')).toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'submit-human-input' })) + await user.click(screen.getByRole('button', { name: 'switch-workflow-tab' })) + + expect(submit).toHaveBeenCalledWith( + 'token-1', + { + action: 'submit', + inputs: { name: 'dify' }, + }, + ...(appSourceType === AppSourceType.webApp ? [formVersion] : []), + ) + expect(unusedSubmit).not.toHaveBeenCalled() + expect(mockSubmitHumanInputFormWorkflow).not.toHaveBeenCalled() + expect(screen.getByText('workflow-body:LOG')).toBeInTheDocument() + }, + ) + + it.each([true, false])( + 'waits for approval events and submits only when ready is %s', + async (ready) => { + const user = userEvent.setup() + let resolvePreparation: ((ready: boolean) => void) | undefined + const prepareHumanInputSubmission = vi.fn( + () => + new Promise((resolve) => { + resolvePreparation = resolve + }), + ) + render( + , + ) + await user.click(screen.getByRole('button', { name: 'submit-human-input' })) + expect(prepareHumanInputSubmission).toHaveBeenCalledOnce() + expect(mockSubmitWebAppHumanInputForm).not.toHaveBeenCalled() + await act(async () => resolvePreparation?.(ready)) + if (ready) { + expect(mockSubmitWebAppHumanInputForm).toHaveBeenCalledExactlyOnceWith( + 'token-1', + { + inputs: { name: 'dify' }, + action: 'submit', + }, + undefined, + ) + } else { + expect(mockSubmitWebAppHumanInputForm).not.toHaveBeenCalled() + } + expect(mockSubmitHumanInputForm).not.toHaveBeenCalled() + expect(mockSubmitHumanInputFormWorkflow).not.toHaveBeenCalled() + }, + ) +}) diff --git a/web/app/components/app/text-generate/item/__tests__/index.spec.tsx b/web/app/components/app/text-generate/item/__tests__/index.spec.tsx index a4c8378312a..9cee0e0b51e 100644 --- a/web/app/components/app/text-generate/item/__tests__/index.spec.tsx +++ b/web/app/components/app/text-generate/item/__tests__/index.spec.tsx @@ -1,7 +1,12 @@ +import type { HumanInputFormData } from '@/types/workflow' import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' +import { WorkflowRunningStatus } from '@/app/components/workflow/types' import { AppSourceType } from '@/service/share' -import { createWorkflowProcessFixture } from '@/test/fixtures/workflow-process' +import { + createHumanInputFormFixture, + createWorkflowProcessFixture, +} from '@/test/fixtures/workflow-process' import GenerationItem from '../index' const mockFetchMoreLikeThis = vi.fn() @@ -234,31 +239,82 @@ describe('GenerationItem', () => { }) }) - it('should route human input submissions to the workflow service for installed apps', async () => { + it.each(['1', '2', undefined] as const)( + 'submits the installed workflow form with its matching version %s', + async (version) => { + const user = userEvent.setup() + render( + , + ) + + expect(screen.getByText('workflow-body:RESULT')).toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'submit-human-input' })) + + expect(mockSubmitHumanInputFormWorkflow).toHaveBeenCalledWith( + 'token-1', + { action: 'submit', inputs: { name: 'dify' } }, + version, + ) + expect(mockSubmitHumanInputForm).not.toHaveBeenCalled() + }, + ) + + it('uses the current form version after workflow state updates', async () => { const user = userEvent.setup() - render( + const renderItem = (version: HumanInputFormData['form_version']) => ( , + /> ) + const { rerender } = render(renderItem('1')) + await user.click(screen.getByRole('button', { name: 'submit-human-input' })) + rerender(renderItem('2')) + await user.click(screen.getByRole('button', { name: 'submit-human-input' })) - expect(screen.getByText('workflow-body:RESULT')).toBeInTheDocument() - - await user.click(screen.getByText('submit-human-input')) - - expect(mockSubmitHumanInputFormWorkflow).toHaveBeenCalledWith('token-1', { - action: 'submit', - inputs: { name: 'dify' }, - }) + expect(mockSubmitHumanInputFormWorkflow).toHaveBeenNthCalledWith( + 1, + 'token-1', + { action: 'submit', inputs: { name: 'dify' } }, + '1', + ) + expect(mockSubmitHumanInputFormWorkflow).toHaveBeenNthCalledWith( + 2, + 'token-1', + { action: 'submit', inputs: { name: 'dify' } }, + '2', + ) + expect(mockSubmitHumanInputForm).not.toHaveBeenCalled() }) it('should route human input submissions to the share service and allow workflow tab switching', async () => { @@ -283,10 +339,14 @@ describe('GenerationItem', () => { await user.click(screen.getByText('submit-human-input')) await user.click(screen.getByText('switch-workflow-tab')) - expect(mockSubmitHumanInputForm).toHaveBeenCalledWith('token-1', { - action: 'submit', - inputs: { name: 'dify' }, - }) + expect(mockSubmitHumanInputForm).toHaveBeenCalledWith( + 'token-1', + { + action: 'submit', + inputs: { name: 'dify' }, + }, + undefined, + ) expect(screen.getByText('workflow-body:LOG')).toBeInTheDocument() }) @@ -384,3 +444,7 @@ describe('GenerationItem', () => { expect(mockFetchMoreLikeThis).not.toHaveBeenCalled() }) }) + +vi.mock('@/service/webapp-human-input', () => ({ + submitWebAppHumanInputForm: (...args: unknown[]) => mockSubmitHumanInputForm(...args), +})) diff --git a/web/app/components/app/text-generate/item/index.tsx b/web/app/components/app/text-generate/item/index.tsx index e5697f59df4..ff20e6d56fa 100644 --- a/web/app/components/app/text-generate/item/index.tsx +++ b/web/app/components/app/text-generate/item/index.tsx @@ -22,6 +22,7 @@ import { submitHumanInputForm, updateFeedback, } from '@/service/share' +import { submitWebAppHumanInputForm } from '@/service/webapp-human-input' import { submitHumanInputForm as submitHumanInputFormService } from '@/service/workflow' import GenerationActionGroups from './action-groups' import { @@ -39,6 +40,7 @@ type IGenerationItemProps = { className?: string isError: boolean onRetry: () => void + prepareHumanInputSubmission?: () => Promise content: any messageId?: string | null conversationId?: string @@ -69,6 +71,7 @@ const GenerationItem: FC = ({ className, isError, onRetry, + prepareHumanInputSubmission, content, messageId, isLoading, @@ -192,11 +195,17 @@ const GenerationItem: FC = ({ }, [workflowProcessData]) const handleSubmitHumanInputForm = useCallback( async (formToken: string, formData: HumanInputFormSubmitData) => { - if (appSourceType === AppSourceType.installedApp) - await submitHumanInputFormService(formToken, formData) - else await submitHumanInputForm(formToken, formData) + const form = workflowProcessData?.humanInputFormDataList?.find( + (item) => item.form_token === formToken, + ) + if (appSourceType === AppSourceType.installedApp) { + await submitHumanInputFormService(formToken, formData, form?.form_version) + } else if (appSourceType === AppSourceType.webApp) { + if (prepareHumanInputSubmission && !(await prepareHumanInputSubmission())) return + await submitWebAppHumanInputForm(formToken, formData, form?.form_version) + } else await submitHumanInputForm(formToken, formData) }, - [appSourceType], + [appSourceType, prepareHumanInputSubmission, workflowProcessData?.humanInputFormDataList], ) return ( @@ -237,11 +246,11 @@ const GenerationItem: FC = ({ {!workflowProcessData && taskId && (
- + {t(($) => $['generation.execution'], { ns: 'share' })} · {getGenerationTaskLabel(taskId, depth)} @@ -265,7 +274,7 @@ const GenerationItem: FC = ({ isMobile && (childMessageId || isQuerying) && depth < MAX_GENERATION_DEPTH && - 'pl-10', + 'ps-10', )} > {!isWorkflow && ( @@ -274,7 +283,7 @@ const GenerationItem: FC = ({ )} {/* action buttons */} -
+
= ({
diff --git a/web/app/components/base/chat/chat-with-history/__tests__/chat-wrapper.hitl-v2.spec.tsx b/web/app/components/base/chat/chat-with-history/__tests__/chat-wrapper.hitl-v2.spec.tsx new file mode 100644 index 00000000000..5d1a57bf199 --- /dev/null +++ b/web/app/components/base/chat/chat-with-history/__tests__/chat-wrapper.hitl-v2.spec.tsx @@ -0,0 +1,224 @@ +import type { ChatConfig } from '../../types' +import type { ChatWithHistoryContextValue } from '../context' +import type { AppData, AppMeta, ConversationItem } from '@/models/share' +import type { HumanInputFormData } from '@/types/workflow' +import { fireEvent, screen, waitFor } from '@testing-library/react' +import { submitWebAppHumanInputForm } from '@/service/webapp-human-input' +import { renderWithConsoleQuery as render } from '@/test/console/query-data' +import { useChat } from '../../chat/hooks' +import ChatWrapper from '../chat-wrapper' +import { useChatWithHistoryContext } from '../context' + +vi.mock('@/config', async (importOriginal) => ({ + ...(await importOriginal()), + ENABLE_HITL_IM: true, +})) + +const mockTrackEvent = vi.hoisted(() => vi.fn()) + +vi.mock('@/app/components/base/amplitude/web-app-event', () => ({ + trackWebAppEvent: mockTrackEvent, +})) + +vi.mock('../../chat/hooks', () => ({ + useChat: vi.fn(), +})) + +vi.mock('../context', () => ({ + useChatWithHistoryContext: vi.fn(), +})) + +vi.mock('@/next/navigation', () => ({ + useRouter: vi.fn(() => ({ + push: vi.fn(), + replace: vi.fn(), + prefetch: vi.fn(), + })), + usePathname: vi.fn(() => '/'), + useSearchParams: vi.fn(() => new URLSearchParams()), + useParams: vi.fn(() => ({ token: 'test-token' })), +})) + +vi.mock('../../utils', () => ({ + isValidGeneratedAnswer: vi.fn(), + getLastAnswer: vi.fn(), +})) + +vi.mock('@/service/share', () => ({ + fetchChatList: vi.fn(), + fetchSuggestedQuestions: vi.fn(), + getUrl: vi.fn(() => 'mock-url'), + stopChatMessageResponding: vi.fn(), + submitHumanInputForm: vi.fn(), + AppSourceType: { + installedApp: 'installedApp', + webApp: 'webApp', + }, +})) + +vi.mock('@/service/workflow', () => ({ + submitHumanInputForm: vi.fn(), +})) + +vi.mock('@/service/webapp-human-input', () => ({ + submitWebAppHumanInputForm: vi.fn(), +})) + +vi.mock('@/app/components/base/markdown', () => ({ + Markdown: ({ content }: { content: string }) =>
{content}
, +})) + +vi.mock('@/utils/model-config', () => ({ + formatBooleanInputs: vi.fn((forms, inputs) => inputs), +})) + +vi.mock('@/hooks/use-timestamp', () => ({ + default: () => ({ + formatTime: (timestamp: number) => `formatted-${timestamp}`, + formatDate: (value: string) => `formatted-${value}`, + }), +})) + +type ChatHookReturn = ReturnType + +const mockAppData = { + mode: 'advanced-chat', + site: { + title: 'Test Chat', + chat_color_theme: 'blue', + icon_type: 'image', + icon: 'test-icon', + icon_background: '#000000', + icon_url: 'https://example.com/icon.png', + use_icon_as_answer_icon: false, + }, +} as unknown as AppData + +const defaultContextValue: ChatWithHistoryContextValue = { + appData: mockAppData, + appParams: { + system_parameters: { vision_config: { enabled: true } }, + opening_statement: 'Default opening statement', + } as unknown as ChatConfig, + appMeta: { tool_icons: {} } as unknown as AppMeta, + currentConversationId: '1', + currentConversationItem: { id: '1', name: 'Conv 1' } as unknown as ConversationItem, + appPrevChatTree: [], + newConversationInputs: {}, + newConversationInputsRef: { + current: {}, + } as ChatWithHistoryContextValue['newConversationInputsRef'], + inputsForms: [], + isInstalledApp: false, + currentChatInstanceRef: { + current: { handleStop: vi.fn() }, + } as ChatWithHistoryContextValue['currentChatInstanceRef'], + setIsResponding: vi.fn(), + setClearChatList: vi.fn(), + appChatListDataLoading: false, + conversationList: [], + sidebarCollapseState: false, + handleSidebarCollapse: vi.fn(), + handlePinConversation: vi.fn(), + handleUnpinConversation: vi.fn(), + handleDeleteConversation: vi.fn(), + conversationRenaming: false, + handleRenameConversation: vi.fn(), + handleNewConversation: vi.fn(), + handleNewConversationInputsChange: vi.fn(), + handleStartChat: vi.fn(), + handleChangeConversation: vi.fn(), + handleNewConversationCompleted: vi.fn(), + handleFeedback: vi.fn(), + pinnedConversationList: [], + chatShouldReloadKey: '', + isMobile: false, + currentConversationInputs: null, + setCurrentConversationInputs: vi.fn(), + allInputsHidden: false, + initUserVariables: undefined, + appId: 'test-app-id', +} + +const defaultChatHookReturn: Partial = { + chatList: [], + handleSend: vi.fn(), + handleStop: vi.fn(), + handleSwitchSibling: vi.fn(), + prepareHumanInputSubmission: vi.fn().mockResolvedValue(true), + isResponding: false, + suggestedQuestions: [], +} + +describe('ChatWrapper', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(useChatWithHistoryContext).mockReturnValue(defaultContextValue) + vi.mocked(useChat).mockReturnValue(defaultChatHookReturn as ChatHookReturn) + }) + + it.each(['1', '2', undefined] as const)( + 'submits Web App forms with event version %s', + async (formVersion) => { + vi.mocked(useChatWithHistoryContext).mockReturnValue({ + ...defaultContextValue, + isInstalledApp: false, + }) + + vi.mocked(useChat).mockReturnValue({ + ...defaultChatHookReturn, + chatList: [ + { id: 'q1', content: 'Question' }, + { + id: 'a1', + isAnswer: true, + content: '', + humanInputFormDataList: [ + { + id: 'node1', + form_id: 'form1', + form_token: 'token-web-1', + form_version: formVersion, + node_id: 'node1', + node_title: 'Node Web 1', + display_in_ui: true, + form_content: '{{#$output.test#}}', + inputs: [ + { + variable: 'test', + label: 'Test', + type: 'paragraph', + required: true, + output_variable_name: 'test', + default: { type: 'text', value: '' }, + }, + ], + actions: [{ id: 'run', title: 'Run', button_style: 'primary' }], + }, + ] as unknown as HumanInputFormData[], + }, + ], + } as unknown as ChatHookReturn) + + render() + expect(await screen.findByText('Node Web 1'))!.toBeInTheDocument() + + const input = + screen.getAllByRole('textbox').find((el) => el.closest('.chat-answer-container')) || + screen.getAllByRole('textbox')[0] + fireEvent.change(input!, { target: { value: 'web-test' } }) + fireEvent.click(screen.getByText('Run')) + + await waitFor(() => { + expect(submitWebAppHumanInputForm).toHaveBeenCalledWith( + 'token-web-1', + { + inputs: { test: 'web-test' }, + action: 'run', + }, + formVersion, + ) + }) + }, + ) +}) diff --git a/web/app/components/base/chat/chat-with-history/__tests__/chat-wrapper.spec.tsx b/web/app/components/base/chat/chat-with-history/__tests__/chat-wrapper.spec.tsx index 5368888af8e..f133a247d8c 100644 --- a/web/app/components/base/chat/chat-with-history/__tests__/chat-wrapper.spec.tsx +++ b/web/app/components/base/chat/chat-with-history/__tests__/chat-wrapper.spec.tsx @@ -7,12 +7,8 @@ import { act, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { registerWebAppEventTracker } from '@/app/components/base/amplitude/web-app-event' import { InputVarType, WorkflowRunningStatus } from '@/app/components/workflow/types' -import { - fetchChatList, - fetchSuggestedQuestions, - stopChatMessageResponding, - submitHumanInputForm, -} from '@/service/share' +import { fetchChatList, fetchSuggestedQuestions, stopChatMessageResponding } from '@/service/share' +import { submitWebAppHumanInputForm as submitHumanInputForm } from '@/service/webapp-human-input' import { submitHumanInputForm as submitInstalledForm } from '@/service/workflow' import { renderWithConsoleQuery } from '@/test/console/query-data' import { createChatConfigFixture, createChatFileFixture } from '@/test/fixtures/chat' @@ -671,10 +667,14 @@ it.each([false, true])( const submit = isInstalledApp ? submitInstalledForm : submitHumanInputForm const otherSubmit = isInstalledApp ? submitHumanInputForm : submitInstalledForm await waitFor(() => - expect(submit).toHaveBeenCalledExactlyOnceWith('token-1', { - inputs: { review: 'Approved' }, - action: 'approve', - }), + expect(submit).toHaveBeenCalledExactlyOnceWith( + 'token-1', + { + inputs: { review: 'Approved' }, + action: 'approve', + }, + undefined, + ), ) expect(otherSubmit).not.toHaveBeenCalled() }, @@ -746,3 +746,5 @@ it.each([ }) expect(screen.getByRole('img', { name: label })).toHaveTextContent(initial) }) + +vi.mock('@/service/webapp-human-input', () => ({ submitWebAppHumanInputForm: vi.fn() })) diff --git a/web/app/components/base/chat/chat-with-history/chat-wrapper.tsx b/web/app/components/base/chat/chat-with-history/chat-wrapper.tsx index 976aa5bd7d8..35355457e27 100644 --- a/web/app/components/base/chat/chat-with-history/chat-wrapper.tsx +++ b/web/app/components/base/chat/chat-with-history/chat-wrapper.tsx @@ -1,5 +1,6 @@ import type { FileEntity } from '../../file-uploader/types' import type { SpeechToTextTarget } from '../../voice-input/types' +import type { HumanInputFormSubmitData } from '../chat/answer/human-input-content/type' import type { ChatConfig, ChatItem, ChatItemInTree, OnSend } from '../types' import { Avatar, AvatarFallback, AvatarImage } from '@langgenius/dify-ui/avatar' import { cn } from '@langgenius/dify-ui/cn' @@ -19,8 +20,8 @@ import { fetchSuggestedQuestions, getUrl, stopChatMessageResponding, - submitHumanInputForm, } from '@/service/share' +import { submitWebAppHumanInputForm } from '@/service/webapp-human-input' import { submitHumanInputForm as submitHumanInputFormService } from '@/service/workflow' import { TransferMethod } from '@/types/app' import { getAvatarInitial } from '@/utils/avatar' @@ -287,13 +288,16 @@ const ChatWrapper = () => { }, [chatList, currentConversationId]) const handleSubmitHumanInputForm = useCallback( - async (formToken: string, formData: any) => { + async (formToken: string, formData: HumanInputFormSubmitData) => { if (!(await prepareHumanInputSubmission())) return - if (isInstalledApp) await submitHumanInputFormService(formToken, formData) - else await submitHumanInputForm(formToken, formData) + const form = chatList + .flatMap((item) => item.humanInputFormDataList ?? []) + .find((item) => item.form_token === formToken) + if (isInstalledApp) await submitHumanInputFormService(formToken, formData, form?.form_version) + else await submitWebAppHumanInputForm(formToken, formData, form?.form_version) }, - [isInstalledApp, prepareHumanInputSubmission], + [chatList, isInstalledApp, prepareHumanInputSubmission], ) const [collapsed, setCollapsed] = useState(!!currentConversationId) diff --git a/web/app/components/base/chat/chat/answer/human-input-content/__tests__/executed-action.spec.tsx b/web/app/components/base/chat/chat/answer/human-input-content/__tests__/executed-action.spec.tsx new file mode 100644 index 00000000000..983019ada73 --- /dev/null +++ b/web/app/components/base/chat/chat/answer/human-input-content/__tests__/executed-action.spec.tsx @@ -0,0 +1,37 @@ +import { act, render, screen } from '@testing-library/react' +import { Suspense } from 'react' +import { describe, expect, it, vi } from 'vite-plus/test' +import { I18nClientProvider } from '@/app/components/provider/i18n' +import { changeLanguage } from '@/i18n/client' +import ExecutedAction from '../executed-action' + +vi.unmock('react-i18next') +const mocks = vi.hoisted(() => ({ load: vi.fn() })) +vi.mock('@/i18n/load-resource', () => ({ loadI18nResource: mocks.load })) + +describe('ExecutedAction translations', () => { + it('loads its namespace when a completed card opens without workflow translations', async () => { + const { loadI18nResource } = + await vi.importActual('@/i18n/load-resource') + mocks.load.mockImplementation(loadI18nResource) + + render( + + Loading action}> + + + , + ) + + expect(await screen.findByText(/has been triggered/)).toHaveTextContent( + 'Approve has been triggered', + ) + expect(mocks.load).toHaveBeenCalledWith('en-US', 'workflowHumanInput') + expect(screen.queryByText('nodes.humanInput.userActions.triggered')).not.toBeInTheDocument() + + await act(() => changeLanguage('zh-Hans')) + + expect(await screen.findByText('已触发')).toHaveTextContent('已触发Approve') + expect(mocks.load).toHaveBeenCalledWith('zh-Hans', 'workflowHumanInput') + }) +}) diff --git a/web/app/components/base/chat/chat/answer/human-input-content/__tests__/human-input-form.spec.tsx b/web/app/components/base/chat/chat/answer/human-input-content/__tests__/human-input-form.spec.tsx index 47e5c2525a9..4f80e923a90 100644 --- a/web/app/components/base/chat/chat/answer/human-input-content/__tests__/human-input-form.spec.tsx +++ b/web/app/components/base/chat/chat/answer/human-input-content/__tests__/human-input-form.spec.tsx @@ -11,6 +11,15 @@ import { createDeferred } from '@/test/deferred' import { TransferMethod } from '@/types/app' import HumanInputForm from '../human-input-form' +const releaseGate = vi.hoisted(() => ({ enabled: true })) + +vi.mock('@/config', async (importOriginal) => ({ + ...(await importOriginal()), + get ENABLE_HITL_IM() { + return releaseGate.enabled + }, +})) + vi.mock('@/app/components/base/file-uploader', () => ({ FileUploaderInAttachmentWrapper: ({ onChange, @@ -44,6 +53,10 @@ vi.mock('@/app/components/base/file-uploader', () => ({ })) describe('HumanInputForm', () => { + beforeEach(() => { + releaseGate.enabled = true + }) + const mockFormData: HumanInputFormData = { form_id: 'form_1', node_id: 'node_1', @@ -68,6 +81,45 @@ describe('HumanInputForm', () => { resolved_default_values: {}, } + it('does not expose a V2 form or its submit and upload controls when HITL IM is disabled', () => { + releaseGate.enabled = false + const onSubmit = vi.fn() + + render() + + expect(screen.getByRole('status')).toHaveTextContent('share.humanInputV2.unavailable') + expect(screen.queryByRole('textbox')).not.toBeInTheDocument() + expect(screen.queryByRole('button')).not.toBeInTheDocument() + expect(onSubmit).not.toHaveBeenCalled() + }) + + it.each([undefined, '1'] as const)( + 'keeps legacy version %s interactive when HITL IM is disabled', + async (form_version) => { + releaseGate.enabled = false + const user = userEvent.setup() + const onSubmit = vi.fn().mockResolvedValue(undefined) + + render() + await user.click(screen.getByRole('button', { name: 'Submit' })) + + expect(onSubmit).toHaveBeenCalledWith('token_123', { + action: 'action_1', + inputs: { field1: 'initial' }, + }) + }, + ) + + it('keeps V2 submissions available when HITL IM is enabled', async () => { + const user = userEvent.setup() + const onSubmit = vi.fn().mockResolvedValue(undefined) + + render() + await user.click(screen.getByRole('button', { name: 'Submit' })) + + expect(onSubmit).toHaveBeenCalledOnce() + }) + it('should handle input changes and submit correctly', async () => { const user = userEvent.setup() const mockOnSubmit = vi.fn().mockResolvedValue(undefined) diff --git a/web/app/components/base/chat/chat/answer/human-input-content/__tests__/unsubmitted.spec.tsx b/web/app/components/base/chat/chat/answer/human-input-content/__tests__/unsubmitted.spec.tsx index 94fbcf94590..397c9c1c51a 100644 --- a/web/app/components/base/chat/chat/answer/human-input-content/__tests__/unsubmitted.spec.tsx +++ b/web/app/components/base/chat/chat/answer/human-input-content/__tests__/unsubmitted.spec.tsx @@ -108,6 +108,40 @@ describe('UnsubmittedHumanInputContent Integration', () => { }) describe('Interactions', () => { + it.each([ + { error: new Error('Approval expired'), message: 'Approval expired' }, + { error: new Response(null, { status: 410 }), message: 'common.api.actionFailed' }, + ])( + 'should show $message and allow retry after a rejected submission', + async ({ error, message }) => { + const user = userEvent.setup() + const handleSubmit = vi.fn().mockRejectedValueOnce(error).mockResolvedValueOnce(undefined) + + render( + , + ) + + const input = screen.getByRole('textbox') + await user.clear(input) + await user.type(input, 'Preserved value') + const submitButton = screen.getByRole('button', { name: 'Submit' }) + await user.click(submitButton) + + expect(await screen.findByRole('alert')).toHaveTextContent(message) + await waitFor(() => expect(submitButton).toBeEnabled()) + expect(input).toHaveValue('Preserved value') + await user.click(submitButton) + + await waitFor(() => expect(screen.queryByRole('alert')).not.toBeInTheDocument()) + + expect(handleSubmit).toHaveBeenCalledTimes(2) + expect(handleSubmit).toHaveBeenLastCalledWith('token_123', { + action: 'btn_1', + inputs: { user_name: 'Preserved value' }, + }) + }, + ) + it('should update input values and call onSubmit', async () => { const user = userEvent.setup() const handleSubmit = vi.fn().mockImplementation(() => Promise.resolve()) diff --git a/web/app/components/base/chat/chat/answer/human-input-content/executed-action.tsx b/web/app/components/base/chat/chat/answer/human-input-content/executed-action.tsx index f7c4f9ceb81..a74961e3775 100644 --- a/web/app/components/base/chat/chat/answer/human-input-content/executed-action.tsx +++ b/web/app/components/base/chat/chat/answer/human-input-content/executed-action.tsx @@ -1,19 +1,22 @@ import type { ExecutedAction as ExecutedActionType } from './type' import { Separator } from '@langgenius/dify-ui/separator' import { memo } from 'react' -import { Trans } from 'react-i18next' +import { Trans, useTranslation } from 'react-i18next' type ExecutedActionProps = { executedAction: ExecutedActionType } const ExecutedAction = ({ executedAction }: ExecutedActionProps) => { + const { t } = useTranslation(['workflowHumanInput']) + return (
$['nodes.humanInput.userActions.triggered']} ns="workflowHumanInput" components={{ strong: }} diff --git a/web/app/components/base/chat/chat/answer/human-input-content/human-input-form.tsx b/web/app/components/base/chat/chat/answer/human-input-content/human-input-form.tsx index 9d0c936c14f..767bdec4c7c 100644 --- a/web/app/components/base/chat/chat/answer/human-input-content/human-input-form.tsx +++ b/web/app/components/base/chat/chat/answer/human-input-content/human-input-form.tsx @@ -5,6 +5,8 @@ import type { UserAction } from '@/app/components/workflow/nodes/human-input/typ import { Button } from '@langgenius/dify-ui/button' import * as React from 'react' import { useCallback, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { ENABLE_HITL_IM } from '@/config' import ContentItem from './content-item' import { getButtonStyle, @@ -15,13 +17,15 @@ import { splitByOutputVar, } from './utils' -const HumanInputForm = ({ formData, onSubmit }: HumanInputFormProps) => { +const HumanInputFormContent = ({ formData, onSubmit }: HumanInputFormProps) => { + const { t } = useTranslation(['common']) const formToken = formData.form_token const contentList = splitByOutputVar(formData.form_content) const renderedFormInputs = getRenderedFormInputs(formData.inputs, formData.form_content) const defaultInputs = initializeInputs(renderedFormInputs, formData.resolved_default_values || {}) const [inputs, setInputs] = useState(defaultInputs) const [isSubmitting, setIsSubmitting] = useState(false) + const [submissionError, setSubmissionError] = useState(null) const handleInputsChange = useCallback((name: string, value: HumanInputFieldValue) => { setInputs((prev) => ({ @@ -36,13 +40,18 @@ const HumanInputForm = ({ formData, onSubmit }: HumanInputFormProps) => { inputs: Record, ) => { setIsSubmitting(true) + setSubmissionError(null) try { await onSubmit?.(formToken, { inputs: getProcessedHumanInputFormInputs(renderedFormInputs, inputs) || {}, action: actionID, }) - } catch { - // The request layer reports the error; retain the inputs for another attempt. + } catch (error) { + setSubmissionError( + error instanceof Error && error.message + ? error.message + : t(($) => $['api.actionFailed'], { ns: 'common' }), + ) } finally { setIsSubmitting(false) } @@ -62,6 +71,11 @@ const HumanInputForm = ({ formData, onSubmit }: HumanInputFormProps) => { onInputChange={handleInputsChange} /> ))} + {submissionError && ( +
+ {submissionError} +
+ )}
{formData.actions.map((action: UserAction) => ( + + + + + + +
+ ), +})) + +vi.mock('@/service/share', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + fetchSuggestedQuestions: vi.fn(), + getUrl: vi.fn(() => '/chat-messages'), + stopChatMessageResponding: vi.fn(), + submitHumanInputForm: vi.fn(), + } +}) + +vi.mock('@/service/workflow', () => ({ + submitHumanInputForm: vi.fn(), +})) + +vi.mock('@/service/webapp-human-input', () => ({ + submitWebAppHumanInputForm: vi.fn(), +})) + +const mockIsDify = vi.fn(() => false) +vi.mock('../utils', () => ({ + isDify: () => mockIsDify(), +})) + +type UseChatReturn = ReturnType + +const createContextValue = ( + overrides: Partial = {}, +): EmbeddedChatbotContextValue => ({ + appMeta: { tool_icons: {} }, + appData: { + app_id: 'app-1', + mode: 'chat', + can_replace_logo: true, + custom_config: { + remove_webapp_brand: false, + replace_webapp_logo: '', + }, + enable_site: true, + end_user_id: 'user-1', + site: { + title: 'Embedded App', + icon_type: 'emoji', + icon: 'bot', + icon_background: '#000000', + icon_url: '', + use_icon_as_answer_icon: false, + }, + }, + appParams: { + system_parameters: { + audio_file_size_limit: 1, + file_size_limit: 1, + image_file_size_limit: 1, + video_file_size_limit: 1, + workflow_file_upload_limit: 1, + }, + more_like_this: { + enabled: false, + }, + } as ChatConfig, + appChatListDataLoading: false, + currentConversationId: '', + currentConversationItem: undefined, + appPrevChatList: [], + pinnedConversationList: [], + conversationList: [], + newConversationInputs: {}, + newConversationInputsRef: { current: {} }, + handleNewConversationInputsChange: vi.fn(), + inputsForms: [], + handleNewConversation: vi.fn(), + handleStartChat: vi.fn(), + handleChangeConversation: vi.fn(), + handleNewConversationCompleted: vi.fn(), + chatShouldReloadKey: 'reload-key', + isMobile: false, + isInstalledApp: false, + appSourceType: AppSourceType.webApp, + allowResetChat: true, + appId: 'app-1', + disableFeedback: false, + handleFeedback: vi.fn(), + currentChatInstanceRef: { current: { handleStop: vi.fn() } }, + theme: undefined, + clearChatList: false, + setClearChatList: vi.fn(), + isResponding: false, + setIsResponding: vi.fn(), + currentConversationInputs: {}, + setCurrentConversationInputs: vi.fn(), + allInputsHidden: false, + initUserVariables: {}, + ...overrides, +}) + +const createUseChatReturn = (overrides: Partial = {}): UseChatReturn => ({ + chatList: [], + setTargetMessageId: vi.fn() as UseChatReturn['setTargetMessageId'], + handleSend: vi.fn(), + handleResume: vi.fn(), + setIsResponding: vi.fn() as UseChatReturn['setIsResponding'], + handleStop: vi.fn(), + handleSwitchSibling: vi.fn(), + prepareHumanInputSubmission: vi.fn().mockResolvedValue(true), + isResponding: false, + suggestedQuestions: [], + handleRestart: vi.fn(), + handleAnnotationEdited: vi.fn(), + handleAnnotationAdded: vi.fn(), + handleAnnotationRemoved: vi.fn(), + ...overrides, +}) + +describe('EmbeddedChatbot chat-wrapper', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(useEmbeddedChatbotContext).mockReturnValue(createContextValue()) + vi.mocked(useChat).mockReturnValue(createUseChatReturn()) + }) + + describe('Human input submit behavior', () => { + it.each([undefined, '1', '2'] as const)( + 'submits installed app forms with event version %s', + async (formVersion) => { + let resolveWorkflowEventsReady: (isReady: boolean) => void = () => {} + const prepareHumanInputSubmission = vi.fn( + () => + new Promise((resolve) => { + resolveWorkflowEventsReady = resolve + }), + ) + vi.mocked(useChat).mockReturnValue( + createUseChatReturn({ + prepareHumanInputSubmission, + chatList: [ + { + id: 'answer-1', + content: '', + isAnswer: true, + humanInputFormDataList: [createHumanInputForm(formVersion)], + }, + ], + }), + ) + vi.mocked(useEmbeddedChatbotContext).mockReturnValue( + createContextValue({ + isInstalledApp: true, + }), + ) + + render() + fireEvent.click(screen.getByRole('button', { name: 'submit human input' })) + + expect(prepareHumanInputSubmission).toHaveBeenCalledOnce() + expect(submitHumanInputFormService).not.toHaveBeenCalled() + + await act(async () => { + resolveWorkflowEventsReady(true) + }) + await waitFor(() => { + expect(submitHumanInputFormService).toHaveBeenCalledWith( + 'form-token', + { + inputs: { answer: 'ok' }, + action: 'approve', + }, + formVersion, + ) + }) + expect(submitHumanInputForm).not.toHaveBeenCalled() + }, + ) + + it.each([undefined, '1', '2'] as const)( + 'submits Web App forms with event version %s and supports chat actions', + async (formVersion) => { + const handleSend = vi.fn() + const handleSwitchSibling = vi.fn() + const handleStop = vi.fn() + vi.mocked(useChat).mockReturnValue( + createUseChatReturn({ + handleSend, + handleSwitchSibling, + handleStop, + chatList: [ + { id: 'opening-1', isAnswer: true, isOpeningStatement: true, content: 'Welcome' }, + { id: 'question-1', isAnswer: false, content: 'Question' }, + { + id: 'answer-1', + isAnswer: true, + content: 'Answer', + parentMessageId: 'question-1', + humanInputFormDataList: [createHumanInputForm(formVersion)], + }, + ] as ChatItemInTree[], + }), + ) + vi.mocked(useEmbeddedChatbotContext).mockReturnValue( + createContextValue({ + isInstalledApp: false, + appSourceType: AppSourceType.tryApp, + isMobile: true, + inputsForms: [ + { variable: 'topic', label: 'Topic', required: false, type: InputVarType.textInput }, + ], + currentConversationId: 'conversation-1', + }), + ) + mockIsDify.mockReturnValue(true) + + render() + + expect(screen.getByText('chat count: 3')).toBeInTheDocument() + expect(screen.queryByText('inputs form')).not.toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: 'send through chat' })) + fireEvent.click(screen.getByRole('button', { name: 'regenerate answer' })) + fireEvent.click(screen.getByRole('button', { name: 'switch sibling' })) + fireEvent.click(screen.getByRole('button', { name: 'stop responding' })) + fireEvent.click(screen.getByRole('button', { name: 'submit human input' })) + + await waitFor(() => { + expect(submitHumanInputForm).toHaveBeenCalledWith( + 'form-token', + { + inputs: { answer: 'ok' }, + action: 'approve', + }, + formVersion, + ) + }) + expect(handleSend).toHaveBeenCalledTimes(2) + const sendOptions = handleSend.mock.calls[0]?.[2] as { + onGetSuggestedQuestions: (responseItemId: string) => void + } + sendOptions.onGetSuggestedQuestions('resp-1') + expect(handleSwitchSibling).toHaveBeenCalledWith( + 'sibling-2', + expect.objectContaining({ + isPublicAPI: false, + }), + ) + const switchOptions = handleSwitchSibling.mock.calls.find( + (call) => call[0] === 'sibling-2', + )?.[1] as { onGetSuggestedQuestions: (responseItemId: string) => void } + switchOptions.onGetSuggestedQuestions('resp-2') + expect(fetchSuggestedQuestions).toHaveBeenCalledWith( + 'resp-1', + AppSourceType.tryApp, + 'app-1', + ) + expect(fetchSuggestedQuestions).toHaveBeenCalledWith( + 'resp-2', + AppSourceType.tryApp, + 'app-1', + ) + expect(handleStop).toHaveBeenCalled() + expect(screen.queryByRole('img', { name: 'Alice' })).not.toBeInTheDocument() + expect(mockTrackEvent).not.toHaveBeenCalled() + + cleanup() + vi.mocked(useEmbeddedChatbotContext).mockReturnValue( + createContextValue({ + isMobile: true, + currentConversationId: '', + inputsForms: [ + { variable: 'topic', label: 'Topic', required: false, type: InputVarType.textInput }, + ], + }), + ) + vi.mocked(useChat).mockReturnValue( + createUseChatReturn({ + chatList: [ + { + id: 'opening-mobile', + isAnswer: true, + isOpeningStatement: true, + content: 'Mobile welcome', + }, + ], + }), + ) + + render() + expect(screen.getByText('inputs form')).toBeInTheDocument() + }, + ) + + it.each(['1', '2', undefined] as const)( + 'submits web app version %s through the passport-aware service', + async (formVersion) => { + const user = userEvent.setup() + vi.mocked(useChat).mockReturnValue( + createUseChatReturn({ + chatList: [ + { + id: 'answer-1', + isAnswer: true, + content: '', + humanInputFormDataList: [createHumanInputForm(formVersion)], + }, + ], + }), + ) + render() + + await user.click(screen.getByRole('button', { name: 'submit human input' })) + + expect(submitWebAppHumanInputForm).toHaveBeenCalledWith( + 'form-token', + { + inputs: { answer: 'ok' }, + action: 'approve', + }, + formVersion, + ) + expect(submitHumanInputForm).not.toHaveBeenCalled() + expect(submitHumanInputFormService).not.toHaveBeenCalled() + }, + ) + }) +}) diff --git a/web/app/components/base/chat/embedded-chatbot/__tests__/chat-wrapper.spec.tsx b/web/app/components/base/chat/embedded-chatbot/__tests__/chat-wrapper.spec.tsx index 7cbc33b3d09..fcaaae691f9 100644 --- a/web/app/components/base/chat/embedded-chatbot/__tests__/chat-wrapper.spec.tsx +++ b/web/app/components/base/chat/embedded-chatbot/__tests__/chat-wrapper.spec.tsx @@ -7,12 +7,8 @@ import { UserActionButtonType } from '@/app/components/workflow/nodes/human-inpu import { InputVarType, WorkflowRunningStatus } from '@/app/components/workflow/types' // oxlint-disable-next-line no-restricted-imports -- Exercise the existing chat SSE transport at its network boundary. import { sseGet, ssePost } from '@/service/base' -import { - AppSourceType, - fetchSuggestedQuestions, - stopChatMessageResponding, - submitHumanInputForm, -} from '@/service/share' +import { AppSourceType, fetchSuggestedQuestions, stopChatMessageResponding } from '@/service/share' +import { submitWebAppHumanInputForm as submitHumanInputForm } from '@/service/webapp-human-input' import { submitHumanInputForm as submitInstalledHumanInput } from '@/service/workflow' import { renderWithConsoleQuery } from '@/test/console/query-data' import { createChatConfigFixture, createChatFileFixture } from '@/test/fixtures/chat' @@ -269,7 +265,11 @@ describe('Embedded chatbot orchestration', () => { await waitFor(() => expect( installed ? submitInstalledHumanInput : submitHumanInputForm, - ).toHaveBeenCalledExactlyOnceWith('form-token', { inputs: {}, action: 'approve' }), + ).toHaveBeenCalledExactlyOnceWith( + 'form-token', + { inputs: {}, action: 'approve' }, + undefined, + ), ) expect(installed ? submitHumanInputForm : submitInstalledHumanInput).not.toHaveBeenCalled() }, @@ -418,3 +418,5 @@ describe('Embedded chatbot orchestration', () => { expect(screen.getByText('Welcome to the app')).toBeVisible() }) }) + +vi.mock('@/service/webapp-human-input', () => ({ submitWebAppHumanInputForm: vi.fn() })) diff --git a/web/app/components/base/chat/embedded-chatbot/chat-wrapper.tsx b/web/app/components/base/chat/embedded-chatbot/chat-wrapper.tsx index c8661545c3f..2e51df3e95c 100644 --- a/web/app/components/base/chat/embedded-chatbot/chat-wrapper.tsx +++ b/web/app/components/base/chat/embedded-chatbot/chat-wrapper.tsx @@ -21,6 +21,7 @@ import { stopChatMessageResponding, submitHumanInputForm, } from '@/service/share' +import { submitWebAppHumanInputForm } from '@/service/webapp-human-input' import { submitHumanInputForm as submitHumanInputFormService } from '@/service/workflow' import { TransferMethod } from '@/types/app' import { getAvatarInitial } from '@/utils/avatar' @@ -369,10 +370,15 @@ const ChatWrapper = () => { async (formToken: string, formData: HumanInputFormSubmitData) => { if (!(await prepareHumanInputSubmission())) return - if (isInstalledApp) await submitHumanInputFormService(formToken, formData) - else await submitHumanInputForm(formToken, formData) + const form = chatList + .flatMap((item) => item.humanInputFormDataList ?? []) + .find((item) => item.form_token === formToken) + if (isInstalledApp) await submitHumanInputFormService(formToken, formData, form?.form_version) + else if (appSourceType === AppSourceType.webApp) + await submitWebAppHumanInputForm(formToken, formData, form?.form_version) + else await submitHumanInputForm(formToken, formData, form?.form_version) }, - [isInstalledApp, prepareHumanInputSubmission], + [chatList, isInstalledApp, appSourceType, prepareHumanInputSubmission], ) const welcome = useMemo(() => { diff --git a/web/app/components/base/file-uploader/__tests__/hooks.spec.ts b/web/app/components/base/file-uploader/__tests__/hooks.spec.ts index 2ae523b2f72..8f9580b0315 100644 --- a/web/app/components/base/file-uploader/__tests__/hooks.spec.ts +++ b/web/app/components/base/file-uploader/__tests__/hooks.spec.ts @@ -1,13 +1,13 @@ import type { ReactNode } from 'react' import type { FileEntity } from '../types' +import type { FileUploadStrategy } from '../upload-context' import type { FileUpload } from '@/app/components/base/features/types' import { ORPCError } from '@orpc/client' -import { act, renderHook, waitFor } from '@testing-library/react' +import { act, fireEvent, render, renderHook, screen, waitFor } from '@testing-library/react' import { createElement } from 'react' import { toast } from '@/app/notifications' import { captureAppAccessScope, handleAppAccessError } from '@/features/app-access-error/state' import { uploadPublicRemoteFileInfo } from '@/service/common' -import { uploadHumanInputFormLocalFile, uploadHumanInputFormRemoteFileInfo } from '@/service/share' import { createConsoleQueryWrapper } from '@/test/console/query-data' import { createDeferred } from '@/test/deferred' import { createFileUploadConfigFixture } from '@/test/fixtures/file-upload-config' @@ -29,10 +29,6 @@ vi.mock('../utils', async (importOriginal) => ({ fileUpload: vi.fn(), })) vi.mock('@/service/common', () => ({ uploadPublicRemoteFileInfo: vi.fn() })) -vi.mock('@/service/share', () => ({ - uploadHumanInputFormLocalFile: vi.fn(), - uploadHumanInputFormRemoteFileInfo: vi.fn(), -})) const config: FileUpload = { enabled: true, @@ -71,7 +67,11 @@ function setup({ }: { files?: FileEntity[] fileConfig?: FileUpload - uploadUrls?: { localUploadUrl?: string; trialAppId?: string } + uploadUrls?: { + localUploadUrl?: string + trialAppId?: string + uploadStrategy?: FileUploadStrategy + } checkEnabled?: boolean } = {}) { const onChange = vi.fn() @@ -106,7 +106,6 @@ beforeEach(() => { navigation.pathname = '/chat' vi.spyOn(toast, 'error').mockReturnValue('toast-id') vi.mocked(uploadPublicRemoteFileInfo).mockResolvedValue(uploadResponse) - vi.mocked(uploadHumanInputFormRemoteFileInfo).mockResolvedValue(uploadResponse) }) afterEach(() => { vi.useRealTimers() @@ -295,25 +294,81 @@ it('uses resource-specific local and remote upload endpoints', async () => { expect(result.current.files.find((file) => file.isRemote)?.uploadedId).toBe('uploaded-file') }) -it('routes human-input form uploads and retries through the form token', async () => { - navigation.params = { token: 'form-token' } - navigation.pathname = '/form/form-token' - const { result } = setup({ files: [entity('retry')] }) - act(() => result.current.handleReUploadFile('retry')) - expect(uploadHumanInputFormLocalFile).toHaveBeenCalledWith( - expect.objectContaining({ formToken: 'form-token', file: expect.any(File) }), - ) - act(() => - result.current.handleLocalFileUpload(new File(['data'], 'a.txt', { type: 'text/plain' })), - ) - await waitFor(() => expect(uploadHumanInputFormLocalFile).toHaveBeenCalledTimes(2)) - await act(async () => result.current.handleLoadFileFromLink('https://example.com/report.pdf')) - expect(uploadHumanInputFormRemoteFileInfo).toHaveBeenCalledWith( - 'form-token', - 'https://example.com/report.pdf', +it('uses the injected local strategy for initial uploads, retry and paste across route changes', async () => { + const uploadLocal = vi.fn() + const uploadRemote = vi.fn() + navigation.params = { token: 'public-token' } + const { result, rerender } = setup({ + uploadUrls: { + localUploadUrl: '/trial-apps/trial-1/files/upload', + trialAppId: 'trial-1', + uploadStrategy: { uploadLocal, uploadRemote }, + }, + }) + const firstFile = new File(['data'], 'a.txt', { type: 'text/plain' }) + act(() => result.current.handleLocalFileUpload(firstFile)) + await waitFor(() => expect(uploadLocal).toHaveBeenCalledTimes(1)) + const first = uploadLocal.mock.calls[0]![0] + expect(first.file).toBe(firstFile) + act(() => first.onProgressCallback(40)) + expect(result.current.files[0]?.progress).toBe(40) + act(() => first.onErrorCallback(new Error('retryable upload failure'))) + expect(result.current.files[0]?.progress).toBe(-1) + + navigation.params = { token: 'different-token' } + navigation.pathname = '/another-page/different-token' + rerender() + act(() => result.current.handleReUploadFile(result.current.files[0]!.id)) + expect(uploadLocal).toHaveBeenCalledTimes(2) + const retry = uploadLocal.mock.calls[1]![0] + expect(retry.file).toBe(firstFile) + act(() => retry.onSuccessCallback(uploadResponse)) + expect(result.current.files[0]).toMatchObject({ + progress: 100, + uploadedId: uploadResponse.id, + }) + + const pastedFile = new File(['pasted'], 'pasted.txt', { type: 'text/plain' }) + render( + createElement('textarea', { + 'aria-label': 'Message', + onPaste: result.current.handleClipboardPasteFile, + }), ) + fireEvent.paste(screen.getByRole('textbox', { name: 'Message' }), { + clipboardData: { files: [pastedFile], getData: () => '' }, + }) + await waitFor(() => expect(uploadLocal).toHaveBeenCalledTimes(3)) + const pasted = uploadLocal.mock.calls[2]![0] + expect(pasted.file).toBe(pastedFile) + act(() => pasted.onSuccessCallback({ ...uploadResponse, id: 'pasted-file' })) + expect(result.current.files).toHaveLength(2) + expect(result.current.files[1]).toMatchObject({ progress: 100, uploadedId: 'pasted-file' }) expect(fileUpload).not.toHaveBeenCalled() + expect(uploadRemote).not.toHaveBeenCalled() +}) + +it('prioritizes the injected remote strategy over trial and public upload fallbacks', async () => { + const uploadLocal = vi.fn() + const uploadRemote = vi.fn().mockResolvedValue(uploadResponse) + navigation.params = { token: 'public-token' } + const { result } = setup({ + uploadUrls: { + localUploadUrl: '/trial-apps/trial-1/files/upload', + trialAppId: 'trial-1', + uploadStrategy: { uploadLocal, uploadRemote }, + }, + }) + await act(async () => result.current.handleLoadFileFromLink('https://example.com/report.pdf')) + expect(uploadRemote).toHaveBeenCalledExactlyOnceWith('https://example.com/report.pdf') + expect(result.current.files[0]).toMatchObject({ + progress: 100, + uploadedId: uploadResponse.id, + url: uploadResponse.url, + }) + expect(uploadLocal).not.toHaveBeenCalled() expect(uploadPublicRemoteFileInfo).not.toHaveBeenCalled() + expect(request).not.toHaveBeenCalled() }) it('keeps public chat tokens on the normal upload transport', async () => { @@ -326,7 +381,6 @@ it('keeps public chat tokens on the normal upload transport', async () => { await waitFor(() => expect(fileUpload).toHaveBeenCalledWith(expect.any(Object), true, undefined)) await act(async () => result.current.handleLoadFileFromLink('https://example.com/report.pdf')) expect(uploadPublicRemoteFileInfo).toHaveBeenCalledWith('https://example.com/report.pdf') - expect(uploadHumanInputFormLocalFile).not.toHaveBeenCalled() }) it.each(['unsupported', 'oversized', 'failed'] as const)( diff --git a/web/app/components/base/file-uploader/hooks.ts b/web/app/components/base/file-uploader/hooks.ts index 2e7b67cb64a..ad61961546c 100644 --- a/web/app/components/base/file-uploader/hooks.ts +++ b/web/app/components/base/file-uploader/hooks.ts @@ -19,10 +19,9 @@ import { import { SupportUploadFileTypes } from '@/app/components/workflow/types' import { toast } from '@/app/notifications' import { isAppAccessError } from '@/features/app-access-error/state' -import { useParams, usePathname } from '@/next/navigation' +import { useParams } from '@/next/navigation' import { uploadPublicRemoteFileInfo } from '@/service/common' import { consoleQuery } from '@/service/console' -import { uploadHumanInputFormLocalFile, uploadHumanInputFormRemoteFileInfo } from '@/service/share' import { TransferMethod } from '@/types/app' import { formatFileSize } from '@/utils/format' import { isAbortError } from '@/utils/is-abort-error' @@ -62,8 +61,7 @@ export const useFile = (fileConfig: FileUpload, noNeedToCheckEnable = true) => { const { t } = useTranslation(['common']) const fileStore = useFileStore() const params = useParams() - const pathname = usePathname() - const { localUploadUrl, trialAppId } = useFileUploadContext() + const { localUploadUrl, trialAppId, uploadStrategy } = useFileUploadContext() const { mutateAsync: uploadConsoleRemoteFile } = useMutation( consoleQuery.remoteFiles.upload.post.mutationOptions(), ) @@ -73,8 +71,6 @@ export const useFile = (fileConfig: FileUpload, noNeedToCheckEnable = true) => { const { imgSizeLimit, docSizeLimit, audioSizeLimit, videoSizeLimit } = getFileSizeLimit( fileConfig.fileUploadConfig, ) - const formToken = typeof params.token === 'string' ? params.token : undefined - const isHumanInputFormPage = !!formToken && /(?:^|\/)form\/[^/]+$/.test(pathname) const checkSizeLimit = useCallback( (fileType: string, fileSize: number) => { @@ -207,17 +203,14 @@ export const useFile = (fileConfig: FileUpload, noNeedToCheckEnable = true) => { }, } - if (isHumanInputFormPage) { - uploadHumanInputFormLocalFile({ - formToken: formToken!, - ...uploadParams, - }) + if (uploadStrategy) { + uploadStrategy.uploadLocal(uploadParams) } else { fileUpload(uploadParams, !!params.token, localUploadUrl) } } }, - [fileStore, t, handleUpdateFile, isHumanInputFormPage, formToken, params.token, localUploadUrl], + [fileStore, t, handleUpdateFile, uploadStrategy, params.token, localUploadUrl], ) const startProgressTimer = useCallback( @@ -256,7 +249,7 @@ export const useFile = (fileConfig: FileUpload, noNeedToCheckEnable = true) => { startProgressTimer(uploadingFile.id) let remoteUpload - if (isHumanInputFormPage) remoteUpload = uploadHumanInputFormRemoteFileInfo(formToken!, url) + if (uploadStrategy) remoteUpload = uploadStrategy.uploadRemote(url) else if (trialAppId) remoteUpload = uploadTrialRemoteFile({ params: { app_id: trialAppId }, body: { url } }) else if (params.token) remoteUpload = uploadPublicRemoteFileInfo(url) @@ -311,8 +304,7 @@ export const useFile = (fileConfig: FileUpload, noNeedToCheckEnable = true) => { fileConfig?.allowed_file_types, fileConfig.allowed_file_extensions, startProgressTimer, - isHumanInputFormPage, - formToken, + uploadStrategy, params.token, trialAppId, uploadConsoleRemoteFile, @@ -401,11 +393,8 @@ export const useFile = (fileConfig: FileUpload, noNeedToCheckEnable = true) => { }, } - if (isHumanInputFormPage) { - uploadHumanInputFormLocalFile({ - formToken: formToken!, - ...uploadParams, - }) + if (uploadStrategy) { + uploadStrategy.uploadLocal(uploadParams) } else { fileUpload(uploadParams, !!params.token, localUploadUrl) } @@ -428,8 +417,7 @@ export const useFile = (fileConfig: FileUpload, noNeedToCheckEnable = true) => { t, handleAddFile, handleUpdateFile, - isHumanInputFormPage, - formToken, + uploadStrategy, params.token, localUploadUrl, fileConfig?.allowed_file_types, diff --git a/web/app/components/base/file-uploader/upload-context.ts b/web/app/components/base/file-uploader/upload-context.ts index ac21de58274..079e674cba2 100644 --- a/web/app/components/base/file-uploader/upload-context.ts +++ b/web/app/components/base/file-uploader/upload-context.ts @@ -1,8 +1,17 @@ +import type { fileUpload } from './utils' +import type { uploadPublicRemoteFileInfo } from '@/service/common' import { createContext, use } from 'react' +/** The surface owns upload authorization; local failures use the supplied callbacks. */ +export type FileUploadStrategy = { + uploadLocal: (params: Parameters[0]) => void + uploadRemote: (url: string) => ReturnType +} + export const FileUploadContext = createContext<{ localUploadUrl?: string trialAppId?: string + uploadStrategy?: FileUploadStrategy }>({}) export function useFileUploadContext() { diff --git a/web/app/components/base/prompt-editor/__tests__/focus-transfer.browser.spec.tsx b/web/app/components/base/prompt-editor/__tests__/focus-transfer.browser.spec.tsx new file mode 100644 index 00000000000..1f837a40ae8 --- /dev/null +++ b/web/app/components/base/prompt-editor/__tests__/focus-transfer.browser.spec.tsx @@ -0,0 +1,71 @@ +import { useState } from 'react' +import { page, userEvent } from 'vite-plus/test/browser' +import { render } from 'vitest-browser-react' +import { EventEmitterContextProvider } from '@/context/event-emitter-provider' +import PromptEditor from '../index' + +function ClassEditors() { + const [classes, setClasses] = useState([ + { id: 'first', value: '' }, + { id: 'second', value: '' }, + ]) + const [activeClass, setActiveClass] = useState(null) + + return ( + + {classes.map(({ id, value }, index) => ( +
+ + setClasses((current) => + current.map((item) => (item.id === id ? { ...item, value: text } : item)), + ) + } + onFocus={() => setActiveClass(index)} + onBlur={() => setActiveClass(null)} + /> +
+ ))} + + {activeClass === null ? 'None' : `Class ${activeClass + 1}`} + + +
+ ) +} + +// Native selection transfer between real controlled Lexical roots must not refocus the +// previous class. The classifier owner tests replace these editors with input shims. +it('moves selection and text entry between class descriptions after editing', async () => { + await render() + const first = page.getByRole('textbox', { name: 'Class 1 description' }) + const second = page.getByRole('textbox', { name: 'Class 2 description' }) + const selection = page.getByRole('status', { name: 'Selected class' }) + + await first.click() + await userEvent.keyboard('First class') + await expect.element(first).toHaveTextContent('First class') + + await second.click() + await expect.element(second).toHaveFocus() + await expect.element(selection).toHaveTextContent('Class 2') + await userEvent.keyboard('Second class') + await expect.element(second).toHaveTextContent('Second class') + await expect.element(first).toHaveTextContent('First class') + + await first.click() + await expect.element(first).toHaveFocus() + await expect.element(selection).toHaveTextContent('Class 1') + + await page.getByRole('button', { name: 'Outside class editors' }).click() + await expect.element(selection).toHaveTextContent('None') +}) diff --git a/web/app/components/base/prompt-editor/plugins/hitl-input-block/__tests__/input-field.spec.tsx b/web/app/components/base/prompt-editor/plugins/hitl-input-block/__tests__/input-field.spec.tsx index 8d851794991..a233f56520d 100644 --- a/web/app/components/base/prompt-editor/plugins/hitl-input-block/__tests__/input-field.spec.tsx +++ b/web/app/components/base/prompt-editor/plugins/hitl-input-block/__tests__/input-field.spec.tsx @@ -171,31 +171,114 @@ describe('InputField', () => { ) }) - it('should disable save and show validation error when variable name is invalid', async () => { + it.each(['invalid name', '1name', 'user-name', 'a'.repeat(31)])( + 'should reject invalid variable name %s', + async (name) => { + const user = userEvent.setup() + const onChange = vi.fn() + + render( + , + ) + + const inputs = screen.getAllByRole('textbox') + await user.clear(inputs[0]!) + await user.type(inputs[0]!, name) + + expect( + screen.getByText( + 'workflowHumanInput.nodes.humanInput.insertInputField.variableNameInvalid', + ), + )!.toBeInTheDocument() + expect(screen.getByRole('button', { name: 'common.operation.save' }))!.toBeDisabled() + await user.click(screen.getByRole('button', { name: 'common.operation.save' })) + await user.keyboard('{Control>}{Enter}{/Control}') + expect(onChange).not.toHaveBeenCalled() + }, + ) + + it.each([ + { mode: 'insert', isEdit: false }, + { mode: 'edit', isEdit: true }, + ])( + 'should reject surrounding whitespace until the field name is corrected in $mode mode', + async ({ isEdit }) => { + const user = userEvent.setup() + const onChange = vi.fn() + render( + , + ) + + const nameInput = screen.getByRole('textbox', { name: /saveResponseAs/ }) + const saveButton = screen.getByRole('button', { + name: isEdit ? 'common.operation.save' : /insertInputField\.insert/, + }) + await user.clear(nameInput) + await user.type(nameInput, ' ReviewNote ') + + expect(screen.getByText(/insertInputField\.variableNameInvalid/)).toBeInTheDocument() + expect(saveButton).toBeDisabled() + await user.click(saveButton) + await user.keyboard('{Control>}{Enter}{/Control}') + expect(onChange).not.toHaveBeenCalled() + + await user.clear(nameInput) + await user.type(nameInput, 'ReviewNote') + expect(screen.queryByText(/insertInputField\.variableNameInvalid/)).not.toBeInTheDocument() + expect(saveButton).toBeEnabled() + await user.click(saveButton) + expect(onChange).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ output_variable_name: 'ReviewNote' }), + ) + }, + ) + + it.each([ + { mode: 'insert', isEdit: false }, + { mode: 'edit', isEdit: true }, + ])('should accept uppercase-start names and preserve case in $mode mode', async ({ isEdit }) => { const user = userEvent.setup() const onChange = vi.fn() render( , ) - const inputs = screen.getAllByRole('textbox') - await user.clear(inputs[0]!) - await user.type(inputs[0]!, 'invalid name') + const nameInput = screen.getByRole('textbox', { + name: /workflowHumanInput\.nodes\.humanInput\.insertInputField\.saveResponseAs/, + }) + await user.clear(nameInput) + await user.type(nameInput, 'Age') + const saveButton = screen.getByRole('button', { + name: isEdit + ? 'common.operation.save' + : /workflowHumanInput\.nodes\.humanInput\.insertInputField\.insert/, + }) + expect(saveButton).toBeEnabled() + await user.click(saveButton) - expect( - screen.getByText('workflowHumanInput.nodes.humanInput.insertInputField.variableNameInvalid'), - )!.toBeInTheDocument() - expect(screen.getByRole('button', { name: 'common.operation.save' }))!.toBeDisabled() - await user.click(screen.getByRole('button', { name: 'common.operation.save' })) - await user.keyboard('{Control>}{Enter}{/Control}') - expect(onChange).not.toHaveBeenCalled() + expect(onChange).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ output_variable_name: 'Age' }), + ) }) it('should disable save and show validation error when variable name already exists', async () => { diff --git a/web/app/components/base/prompt-editor/plugins/hitl-input-block/hitl-input-block-replacement-block.tsx b/web/app/components/base/prompt-editor/plugins/hitl-input-block/hitl-input-block-replacement-block.tsx index c48e477513d..41b2c494a6b 100644 --- a/web/app/components/base/prompt-editor/plugins/hitl-input-block/hitl-input-block-replacement-block.tsx +++ b/web/app/components/base/prompt-editor/plugins/hitl-input-block/hitl-input-block-replacement-block.tsx @@ -127,6 +127,7 @@ const HITLInputReplacementBlock = ({ }, []) const getMatch = useCallback((text: string) => { + REGEX.lastIndex = 0 const matchArr = REGEX.exec(text) if (matchArr === null) return null diff --git a/web/app/components/base/prompt-editor/plugins/hitl-input-block/input-field.tsx b/web/app/components/base/prompt-editor/plugins/hitl-input-block/input-field.tsx index fcbf0c75651..be62834b411 100644 --- a/web/app/components/base/prompt-editor/plugins/hitl-input-block/input-field.tsx +++ b/web/app/components/base/prompt-editor/plugins/hitl-input-block/input-field.tsx @@ -91,10 +91,9 @@ const InputField: React.FC = ({ return new Set(unavailableVariableNames.map((name) => name.trim()).filter(Boolean)) }, [unavailableVariableNames]) const variableNameError = useMemo(() => { - const name = tempPayload.output_variable_name.trim() - if (!name) return null - if (name.includes(' ')) return 'variableNameInvalid' - if (!/^[a-z_]\w{0,29}$/.test(name)) return 'variableNameInvalid' + const name = tempPayload.output_variable_name + if (!name.trim()) return null + if (!/^[a-z_]\w{0,29}$/i.test(name)) return 'variableNameInvalid' if (unavailableVariableNameSet.has(name)) return 'variableNameDuplicated' return null }, [tempPayload.output_variable_name, unavailableVariableNameSet]) diff --git a/web/app/components/header/account-setting/__tests__/billing-browser-fixture.tsx b/web/app/components/header/account-setting/__tests__/billing-browser-fixture.tsx index a936ef89e5b..02438d777b8 100644 --- a/web/app/components/header/account-setting/__tests__/billing-browser-fixture.tsx +++ b/web/app/components/header/account-setting/__tests__/billing-browser-fixture.tsx @@ -36,7 +36,7 @@ export async function renderBillingSettings() { const { queryClient } = createConsoleQueryWrapper({ currentWorkspace: { role: 'normal' }, workspacePermissionKeys: [], - features: { billing: { subscription: { plan: 'team' } } }, + features: { im_centrally_managed: true, billing: { subscription: { plan: 'team' } } }, systemFeatures: { deployment_edition: 'CLOUD', rbac_enabled: false }, }) queryClient.setQueryData(consoleQuery.features.vectorSpace.get.queryOptions().queryKey, { diff --git a/web/app/components/header/account-setting/__tests__/index.hitl-v2.spec.tsx b/web/app/components/header/account-setting/__tests__/index.hitl-v2.spec.tsx new file mode 100644 index 00000000000..d08948e2912 --- /dev/null +++ b/web/app/components/header/account-setting/__tests__/index.hitl-v2.spec.tsx @@ -0,0 +1,378 @@ +import type { GetFeaturesResponse } from '@dify/contracts/api/console/features/types.gen' +import type { AccountSettingTab } from '../constants' +import type { ConsoleStateFixture } from '@/test/console/state-fixture' +import { act, fireEvent, screen, waitFor } from '@testing-library/react' +import { NuqsTestingAdapter } from 'nuqs/adapters/testing' +import { useState } from 'react' +import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints' +import { consoleQuery } from '@/service/console' +import { + createConsoleQueryClient, + renderWithConsoleQuery, + seedFeatures, +} from '@/test/console/query-data' +import { ACCOUNT_SETTING_TAB } from '../constants' +import AccountSetting from '../index' + +const mockConfig = vi.hoisted(() => ({ + ENABLE_FEATURE_PREVIEW: true, + ENABLE_HITL_IM: true, + getFeatures: vi.fn<() => Promise>(), +})) +let canReplaceLogo = true + +const mockConsoleState = vi.hoisted(() => ({ + current: null as unknown, +})) + +vi.mock('@/config', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + get ENABLE_FEATURE_PREVIEW() { + return mockConfig.ENABLE_FEATURE_PREVIEW + }, + get ENABLE_HITL_IM() { + return mockConfig.ENABLE_HITL_IM + }, + } +}) + +vi.mock('@/context/workspace-state', async () => { + const { createWorkspaceStateModuleMock } = await import('@/test/console/state-fixture') + return createWorkspaceStateModuleMock(() => mockConsoleState.current ?? {}) +}) +vi.mock('@/context/permission-state', async () => { + const { createPermissionStateModuleMock } = await import('@/test/console/state-fixture') + return createPermissionStateModuleMock(() => mockConsoleState.current ?? {}) +}) +vi.mock('@/next/navigation', () => ({ + useRouter: vi.fn(() => ({ + push: vi.fn(), + replace: vi.fn(), + prefetch: vi.fn(), + })), + usePathname: vi.fn(() => '/'), + useParams: vi.fn(() => ({})), + useSearchParams: vi.fn(() => ({ get: vi.fn() })), +})) + +vi.mock('@/features/contacts/im-platform/account-setting-page', () => ({ + ContactsImPlatformAccountSettingPage: () =>
, +})) + +vi.mock('@/hooks/use-breakpoints', () => ({ + MediaType: { + mobile: 'mobile', + tablet: 'tablet', + pc: 'pc', + }, + default: vi.fn(), +})) + +vi.mock('next-themes', () => ({ + useTheme: vi.fn(() => ({ + theme: 'system', + setTheme: vi.fn(), + })), +})) + +vi.mock('@/app/components/header/account-setting/model-provider-page', () => ({ + default: ({ + onSearchTextChange, + searchText, + }: { + onSearchTextChange?: (value: string) => void + searchText: string + }) => ( + onSearchTextChange?.(event.target.value)} + /> + ), +})) + +vi.mock('@/service/use-datasource', () => ({ + useGetDataSourceListAuth: vi.fn(() => ({ data: { result: [] } })), +})) + +vi.mock('@/service/use-common', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useMembers: vi.fn(() => ({ data: { accounts: [] }, refetch: vi.fn() })), + } +}) + +vi.mock('@/service/console', async (importOriginal) => { + const actual = await importOriginal() + const { withConsoleResponses } = await import('@/test/console/service-responses') + const query = withConsoleResponses(actual.consoleQuery, { + 'workspaces.current.members.get': async () => ({ accounts: [] }), + }) + return { + ...actual, + consoleQuery: new Proxy(query, { + get(target, prop, receiver) { + if (prop === 'features') { + return { + get: { + queryKey: actual.consoleQuery.features.get.queryKey, + queryOptions: ( + ...args: Parameters + ) => ({ + ...actual.consoleQuery.features.get.queryOptions(...args), + queryFn: mockConfig.getFeatures, + }), + }, + } + } + if (prop === 'apiBasedExtension') { + return { + get: { + queryOptions: () => ({ + queryKey: ['console', 'api-based-extension'], + queryFn: () => Promise.resolve([]), + }), + }, + } + } + + return Reflect.get(target, prop, receiver) + }, + }), + } +}) + +vi.mock('@/app/components/billing/billing-page', () => ({ + __esModule: true, + default: () =>
, +})) + +vi.mock('@/app/components/custom/custom-page', () => ({ + __esModule: true, + default: () =>
custom.custom
, +})) + +vi.mock('@/app/components/header/account-setting/data-source-page-new', () => ({ + __esModule: true, + default: () =>
, +})) + +vi.mock('@/app/components/header/account-setting/permissions-page', () => ({ + __esModule: true, + default: () =>
, +})) + +vi.mock('@/app/components/header/account-setting/access-rules-page', () => ({ + __esModule: true, + default: () =>
, +})) + +const baseConsoleState: ConsoleStateFixture = { + userProfile: { + id: '1', + name: 'Test User', + email: 'test@example.com', + avatar: '', + avatar_url: '', + is_password_set: false, + }, + currentWorkspace: { + id: '1', + name: 'Workspace', + plan: null, + role: 'owner', + }, + isCurrentWorkspaceManager: true, + isCurrentWorkspaceOwner: true, + isCurrentWorkspaceDatasetOperator: false, + refreshCurrentWorkspace: vi.fn(), + isLoadingCurrentWorkspace: false, + workspacePermissionKeys: [ + 'workspace.member.manage', + 'workspace.role.manage', + 'data_source.manage', + 'api_extension.manage', + 'customization.manage', + ], +} + +describe('AccountSetting', () => { + const mockOnCancel = vi.fn() + const mockOnTabChange = vi.fn() + const renderAccountSetting = (props?: { + initialTab?: AccountSettingTab + onCancel?: () => void + onTabChange?: (tab: AccountSettingTab) => void + rbacEnabled?: boolean + deploymentEdition?: 'COMMUNITY' | 'ENTERPRISE' | 'CLOUD' + imCentrallyManaged?: boolean | 'missing' | 'pending' + }) => { + const { + initialTab = ACCOUNT_SETTING_TAB.MEMBERS, + onCancel = mockOnCancel, + onTabChange = mockOnTabChange, + rbacEnabled = true, + deploymentEdition = 'CLOUD', + imCentrallyManaged = false, + } = props ?? {} + const queryClient = createConsoleQueryClient() + if (imCentrallyManaged !== 'pending') { + const features = seedFeatures(queryClient, { + billing: { subscription: { plan: 'sandbox' } }, + can_replace_logo: canReplaceLogo, + }) + if (imCentrallyManaged !== 'missing') { + const featuresWithIMBinding = { + ...features, + im_centrally_managed: imCentrallyManaged, + } + queryClient.setQueryData(consoleQuery.features.get.queryKey(), featuresWithIMBinding) + } + } + + const StatefulAccountSetting = () => { + const [activeTab, setActiveTab] = useState(initialTab) + + return ( + { + setActiveTab(tab) + onTabChange(tab) + }} + /> + ) + } + + return renderWithConsoleQuery( + + + , + { + queryClient, + accountProfile: (mockConsoleState.current as ConsoleStateFixture).userProfile, + systemFeatures: { + deployment_edition: deploymentEdition, + webapp_auth: { enabled: true }, + branding: { enabled: false }, + enable_marketplace: true, + enable_collaboration_mode: false, + rbac_enabled: rbacEnabled, + }, + }, + ) + } + + beforeEach(() => { + vi.clearAllMocks() + mockConfig.ENABLE_FEATURE_PREVIEW = true + mockConfig.ENABLE_HITL_IM = true + mockConfig.getFeatures.mockImplementation(() => new Promise(() => {})) + canReplaceLogo = true + mockConsoleState.current = baseConsoleState + vi.mocked(useBreakpoints).mockReturnValue(MediaType.pc) + }) + + describe('Rendering', () => { + it('hides workspace channels when centrally managed even in Community edition', () => { + renderAccountSetting({ deploymentEdition: 'COMMUNITY', imCentrallyManaged: true }) + + expect( + screen.queryByRole('button', { name: 'contacts.imPlatform.title' }), + ).not.toBeInTheDocument() + }) + + it('allows workspace channels in Enterprise edition when not centrally managed', () => { + renderAccountSetting({ + initialTab: ACCOUNT_SETTING_TAB.IM_PLATFORM, + deploymentEdition: 'ENTERPRISE', + imCentrallyManaged: false, + }) + + expect(screen.getByRole('button', { name: 'contacts.imPlatform.title' })).toBeInTheDocument() + expect(screen.getByTestId('contacts-im-platform-page')).toBeInTheDocument() + }) + + it('keeps workspace channels available for older successful features responses', () => { + renderAccountSetting({ imCentrallyManaged: 'missing' }) + + expect(screen.getByRole('button', { name: 'contacts.imPlatform.title' })).toBeInTheDocument() + }) + + it('does not open workspace channels before features have loaded', () => { + renderAccountSetting({ + initialTab: ACCOUNT_SETTING_TAB.IM_PLATFORM, + imCentrallyManaged: 'pending', + }) + + expect( + screen.queryByRole('button', { name: 'contacts.imPlatform.title' }), + ).not.toBeInTheDocument() + expect(screen.queryByTestId('contacts-im-platform-page')).not.toBeInTheDocument() + }) + + it('closes workspace channels if the features request becomes unavailable', async () => { + const { queryClient } = renderAccountSetting({ initialTab: ACCOUNT_SETTING_TAB.IM_PLATFORM }) + expect(screen.getByTestId('contacts-im-platform-page')).toBeInTheDocument() + + await act(async () => { + mockConfig.getFeatures.mockRejectedValue(new Error('features unavailable')) + await queryClient.invalidateQueries({ queryKey: consoleQuery.features.get.queryKey() }) + }) + + await waitFor(() => + expect(screen.queryByTestId('contacts-im-platform-page')).not.toBeInTheDocument(), + ) + expect( + screen.queryByRole('button', { name: 'contacts.imPlatform.title' }), + ).not.toBeInTheDocument() + }) + + it('hides the IM platform entry and rejects deep links when HITL IM is not released', () => { + mockConfig.ENABLE_HITL_IM = false + + renderAccountSetting({ initialTab: ACCOUNT_SETTING_TAB.IM_PLATFORM }) + + expect( + screen.queryByRole('button', { name: 'contacts.imPlatform.title' }), + ).not.toBeInTheDocument() + expect(screen.queryByTestId('contacts-im-platform-page')).not.toBeInTheDocument() + expect(screen.getAllByText('navigation.settings.members').length).toBeGreaterThan(0) + }) + + it('keeps released IM channels available when unrelated feature previews are disabled', () => { + mockConfig.ENABLE_FEATURE_PREVIEW = false + + renderAccountSetting({ initialTab: ACCOUNT_SETTING_TAB.IM_PLATFORM }) + + expect(screen.getByRole('button', { name: 'contacts.imPlatform.title' })).toBeInTheDocument() + expect(screen.getByTestId('contacts-im-platform-page')).toBeInTheDocument() + }) + + it('rejects centrally managed workspace channels deep links', () => { + renderAccountSetting({ + initialTab: ACCOUNT_SETTING_TAB.IM_PLATFORM, + deploymentEdition: 'COMMUNITY', + imCentrallyManaged: true, + }) + + expect(screen.queryByTestId('contacts-im-platform-page')).not.toBeInTheDocument() + expect(screen.getAllByText('navigation.settings.members').length).toBeGreaterThan(0) + }) + }) + + describe('Tab Navigation', () => { + it('should mount the Contacts-owned IM platform page from its entry', () => { + renderAccountSetting() + + fireEvent.click(screen.getByRole('button', { name: 'contacts.imPlatform.title' })) + + expect(screen.getByTestId('contacts-im-platform-page')).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/header/account-setting/constants.ts b/web/app/components/header/account-setting/constants.ts index 6950fe6cd4c..78fd7b7a1ed 100644 --- a/web/app/components/header/account-setting/constants.ts +++ b/web/app/components/header/account-setting/constants.ts @@ -1,5 +1,6 @@ export const ACCOUNT_SETTING_TAB = { MEMBERS: 'members', + IM_PLATFORM: 'im-platform', IP_POLICIES: 'ip-policies', ROLES_AND_PERMISSIONS: 'roles-and-permissions', PERMISSION_SET: 'permission-set', @@ -14,6 +15,7 @@ export type AccountSettingTab = (typeof ACCOUNT_SETTING_TAB)[keyof typeof ACCOUN export const ACCOUNT_SETTING_TAB_VALUES = [ ACCOUNT_SETTING_TAB.MEMBERS, + ACCOUNT_SETTING_TAB.IM_PLATFORM, ACCOUNT_SETTING_TAB.IP_POLICIES, ACCOUNT_SETTING_TAB.ROLES_AND_PERMISSIONS, ACCOUNT_SETTING_TAB.PERMISSION_SET, diff --git a/web/app/components/header/account-setting/index.tsx b/web/app/components/header/account-setting/index.tsx index fdb27a97466..bcda1e0029b 100644 --- a/web/app/components/header/account-setting/index.tsx +++ b/web/app/components/header/account-setting/index.tsx @@ -18,13 +18,18 @@ import BillingPage from '@/app/components/billing/billing-page' import CustomPage from '@/app/components/custom/custom-page' import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants' import MenuDialog from '@/app/components/header/account-setting/menu-dialog' +import { useDocLink } from '@/context/i18n' import { workspacePermissionKeysAtom } from '@/context/permission-state' import { + currentWorkspaceAtom, isCurrentWorkspaceDatasetOperatorAtom, isCurrentWorkspaceManagerAtom, isCurrentWorkspaceOwnerAtom, } from '@/context/workspace-state' import { env } from '@/env' +import { selectIMCentrallyManaged } from '@/features/contacts/im-capability' +import { ContactsImPlatformAccountSettingPage } from '@/features/contacts/im-platform/account-setting-page' +import { isContactsImPlatformEnabled } from '@/features/contacts/im-platform/feature-flag' import { canReadNetworkAccessAtom } from '@/features/network-access/permissions' import { systemFeaturesQueryOptions } from '@/features/system-features/client' import { consoleQuery } from '@/service/console' @@ -68,15 +73,15 @@ export default function AccountSetting({ 'custom', 'navigation', 'accountSettings', + 'contacts', ]) - const { data: enableReplaceWebAppLogo } = useQuery( - consoleQuery.features.get.queryOptions({ - select: (features) => features.can_replace_logo, - }), - ) + const featuresQuery = useQuery(consoleQuery.features.get.queryOptions()) + const enableReplaceWebAppLogo = featuresQuery.data?.can_replace_logo + const docLink = useDocLink() const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) const isCurrentWorkspaceManager = useAtomValue(isCurrentWorkspaceManagerAtom) + const currentWorkspace = useAtomValue(currentWorkspaceAtom) const isCurrentWorkspaceOwner = useAtomValue(isCurrentWorkspaceOwnerAtom) const isCurrentWorkspaceDatasetOperator = useAtomValue(isCurrentWorkspaceDatasetOperatorAtom) const isRbacEnabled = systemFeatures.rbac_enabled @@ -87,6 +92,9 @@ export default function AccountSetting({ systemFeatures.deployment_edition === 'CLOUD' && !isCurrentWorkspaceDatasetOperator const canViewWorkflowLogArchives = systemFeatures.deployment_edition === 'CLOUD' && isCurrentWorkspaceManager + const canViewContactsImPlatform = + featuresQuery.isSuccess && + isContactsImPlatformEnabled(selectIMCentrallyManaged(featuresQuery.data)) const canViewIpPolicies = useAtomValue(canReadNetworkAccessAtom) const activeMenu = (() => { if (activeTab === ACCOUNT_SETTING_TAB.IP_POLICIES && !canViewIpPolicies) @@ -95,6 +103,8 @@ export default function AccountSetting({ return ACCOUNT_SETTING_TAB.PREFERENCES if (activeTab === ACCOUNT_SETTING_TAB.WORKFLOW_LOG_ARCHIVES && !canViewWorkflowLogArchives) return ACCOUNT_SETTING_TAB.MEMBERS + if (activeTab === ACCOUNT_SETTING_TAB.IM_PLATFORM && !canViewContactsImPlatform) + return ACCOUNT_SETTING_TAB.MEMBERS if ( (activeTab === ACCOUNT_SETTING_TAB.ROLES_AND_PERMISSIONS || activeTab === ACCOUNT_SETTING_TAB.PERMISSION_SET) && @@ -114,6 +124,13 @@ export default function AccountSetting({ icon: , activeIcon: , }, + { + key: ACCOUNT_SETTING_TAB.IM_PLATFORM, + name: t(($) => $['imPlatform.title'], { ns: 'contacts' }), + description: t(($) => $['imPlatform.description'], { ns: 'contacts' }), + icon: , + activeIcon: , + }, { key: ACCOUNT_SETTING_TAB.IP_POLICIES, name: t(($) => $['settings.ipPolicies'], { ns: 'common' }), @@ -169,6 +186,7 @@ export default function AccountSetting({ }, ] const activeItem = settingItems.find((item) => item.key === activeMenu) + const isChannelsPage = activeMenu === ACCOUNT_SETTING_TAB.IM_PLATFORM const visibleSettingItems: GroupItem[] = (() => { const visibleTabs: AccountSettingTab[] = [] @@ -176,6 +194,8 @@ export default function AccountSetting({ visibleTabs.push(ACCOUNT_SETTING_TAB.MEMBERS) if (canViewIpPolicies) visibleTabs.push(ACCOUNT_SETTING_TAB.IP_POLICIES) + if (canViewContactsImPlatform) visibleTabs.push(ACCOUNT_SETTING_TAB.IM_PLATFORM) + if (canManageWorkspaceRoles) { visibleTabs.push(ACCOUNT_SETTING_TAB.ROLES_AND_PERMISSIONS) visibleTabs.push(ACCOUNT_SETTING_TAB.PERMISSION_SET) @@ -275,7 +295,18 @@ export default function AccountSetting({ {activeItem?.description && (

- {activeItem?.description} + {activeItem.description} + {isChannelsPage && ( + + {t(($) => $['imPlatform.learnMore'], { ns: 'contacts' })} + + + )}

)}
@@ -290,10 +321,21 @@ export default function AccountSetting({ style={{ minWidth: 0 }} className="min-h-full w-full max-w-full pb-4" > -
+
{/* First-use translations and queries must not suspend the dialog shell. */} }> {activeMenu === ACCOUNT_SETTING_TAB.MEMBERS && } + {activeMenu === ACCOUNT_SETTING_TAB.IM_PLATFORM && ( + + )} {activeMenu === ACCOUNT_SETTING_TAB.IP_POLICIES && } {activeMenu === ACCOUNT_SETTING_TAB.ROLES_AND_PERMISSIONS && ( diff --git a/web/app/components/header/account-setting/ip-policies-page/__tests__/index.browser.spec.tsx b/web/app/components/header/account-setting/ip-policies-page/__tests__/index.browser.spec.tsx index 6ed33367ea6..f92db65f14d 100644 --- a/web/app/components/header/account-setting/ip-policies-page/__tests__/index.browser.spec.tsx +++ b/web/app/components/header/account-setting/ip-policies-page/__tests__/index.browser.spec.tsx @@ -18,7 +18,12 @@ vi.mock('@/service/console/browser', () => ({ }, })) vi.mock('@/app/components/base/amplitude', () => ({ trackEvent: vi.fn() })) -vi.mock('@/next/navigation', () => ({ useRouter: () => ({ refresh: vi.fn() }) })) +vi.mock('@/next/navigation', () => ({ + useRouter: () => ({ refresh: vi.fn() }), + usePathname: () => '/account/settings', + useParams: () => ({}), + useSearchParams: () => new URLSearchParams(), +})) vi.mock('#i18n', async (importOriginal) => ({ ...(await importOriginal()), useLocale: () => 'en-US', diff --git a/web/app/components/header/account-setting/members-page/__tests__/delete-member.browser.spec.tsx b/web/app/components/header/account-setting/members-page/__tests__/delete-member.browser.spec.tsx index 2535403cddd..38ee9ae6e22 100644 --- a/web/app/components/header/account-setting/members-page/__tests__/delete-member.browser.spec.tsx +++ b/web/app/components/header/account-setting/members-page/__tests__/delete-member.browser.spec.tsx @@ -2,6 +2,12 @@ import type { AccountWithRoleResponse } from '@dify/contracts/api/console/worksp import type { consoleClient } from '@/service/console' import { page, userEvent } from 'vite-plus/test/browser' import { render } from 'vitest-browser-react' +import { createContactsMockRepository } from '@/features/contacts/management/__tests__/fixtures/repository' +import { + ContactsMockScenario, + createContactsMockScenario, +} from '@/features/contacts/management/__tests__/fixtures/scenarios' +import { ContactsManagementProvider } from '@/features/contacts/management/composition' import { consoleQuery } from '@/service/console' import { createConsoleQueryWrapper } from '@/test/console/query-data' import { QueryClientTestProvider } from '@/test/console/query-provider' @@ -12,6 +18,10 @@ const api = vi.hoisted(() => ({ features: vi.fn(), members: vi.fn(), remove: vi.fn(), + contactsEnabled: vi.fn(() => false), +})) +vi.mock('@/features/contacts/management/feature-flag', () => ({ + isContactsManagementEnabled: api.contactsEnabled, })) vi.mock('@/service/console', async (importOriginal) => { const actual = await importOriginal() @@ -39,6 +49,7 @@ const member = (id: string): AccountWithRoleResponse => ({ status: 'active', }) beforeEach(() => { + api.contactsEnabled.mockReturnValue(false) vi.stubGlobal('BASE_UI_ANIMATIONS_DISABLED', false) vi.stubGlobal( 'fetch', @@ -56,7 +67,7 @@ afterEach(() => { vi.unstubAllGlobals() }) -async function setup(ids: string[]) { +async function setup(ids: string[], contactRemoval = false) { let accounts = ids.map(member) api.members.mockReset().mockImplementation(async () => ({ accounts })) api.remove.mockReset().mockImplementation(async ({ params }) => { @@ -68,9 +79,31 @@ async function setup(ids: string[]) { workspacePermissionKeys: ['workspace.member.manage'], }) api.features.mockResolvedValue(queryClient.getQueryData(consoleQuery.features.get.queryKey())!) + const scenario = createContactsMockScenario(ContactsMockScenario.EeMixed) + const contactsRepository = createContactsMockRepository({ scenario }) + const removeContactMember = vi + .spyOn(contactsRepository, 'removeMember') + .mockImplementation(async ({ memberId }) => { + accounts = accounts.filter((account) => account.id !== memberId) + return { kind: 'removed', contactId: null, contactOutcome: 'not_found' } + }) const screen = await render( - + {contactRemoval ? ( + + + + ) : ( + + )} , ) const open = async (id: string) => { @@ -81,9 +114,29 @@ async function setup(ids: string[]) { await expect.element(screen.getByRole('alertdialog')).toBeVisible() await expect.element(screen.getByRole('menu')).not.toBeInTheDocument() } - return { screen, open } + return { screen, open, removeContactMember } } +it('returns focus to the next member after contact-aware removal deletes the opening row', async () => { + api.contactsEnabled.mockReturnValue(true) + const { screen, open, removeContactMember } = await setup(['Alpha', 'Beta'], true) + await open('Alpha') + const retention = screen.getByRole('checkbox', { name: /memberRemoval\.keepPlatform/ }) + await expect.element(retention).toBeChecked() + await retention.click() + await screen.getByRole('button', { name: 'contacts.memberRemoval.remove', exact: true }).click() + await expect.element(screen.getByRole('alertdialog')).not.toBeInTheDocument() + await expect + .element(screen.getByRole('button', { name: 'Alpha', exact: true })) + .not.toBeInTheDocument() + await expect.element(screen.getByRole('button', { name: 'Beta', exact: true })).toHaveFocus() + expect(removeContactMember).toHaveBeenCalledExactlyOnceWith({ + memberId: 'Alpha', + keepAsPlatformContact: false, + }) + expect(api.remove).not.toHaveBeenCalled() +}) + it('removes the member before a slow list refresh completes', async () => { const { screen, open } = await setup(['Alpha', 'Beta']) await open('Alpha') diff --git a/web/app/components/header/account-setting/members-page/__tests__/index.spec.tsx b/web/app/components/header/account-setting/members-page/__tests__/index.spec.tsx index 1357fee6a92..d18f6a00700 100644 --- a/web/app/components/header/account-setting/members-page/__tests__/index.spec.tsx +++ b/web/app/components/header/account-setting/members-page/__tests__/index.spec.tsx @@ -1,21 +1,47 @@ import type { AccountWithRoleResponse } from '@dify/contracts/api/console/workspaces/types.gen' +import type { ContactsManagementRepository } from '@/features/contacts/management/repository' +import type { + ContactsFeatureContextValue, + RemoveMemberResult, +} from '@/features/contacts/management/types' import type { Role } from '@/models/access-control' import type { ConsoleQueryTestOptions } from '@/test/console/query-data' +import { QueryObserver } from '@tanstack/react-query' import { act, screen, waitFor, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { NuqsTestingAdapter } from 'nuqs/adapters/testing' +import { onTestFinished } from 'vite-plus/test' +import { createContactsMockRepository } from '@/features/contacts/management/__tests__/fixtures/repository' +import { + ContactsMockScenario, + createContactsMockScenario, +} from '@/features/contacts/management/__tests__/fixtures/scenarios' +import { ContactsManagementProvider } from '@/features/contacts/management/composition' +import { consoleQuery } from '@/service/console' import { renderWithConsoleQuery } from '@/test/console/query-data' +import { createDeferred } from '@/test/deferred' import MembersPage from '../index' -const { membersRequest, rolesRequest, memberRolesRequest, updateRoles, inviteMember } = vi.hoisted( - () => ({ - membersRequest: vi.fn(), - rolesRequest: vi.fn(), - memberRolesRequest: vi.fn(), - updateRoles: vi.fn(), - inviteMember: vi.fn(), - }), -) +const { + membersRequest, + rolesRequest, + memberRolesRequest, + updateRoles, + inviteMember, + removeMember, + contactsEnabled, +} = vi.hoisted(() => ({ + membersRequest: vi.fn(), + rolesRequest: vi.fn(), + memberRolesRequest: vi.fn(), + updateRoles: vi.fn(), + inviteMember: vi.fn(), + removeMember: vi.fn(), + contactsEnabled: vi.fn(() => false), +})) +vi.mock('@/features/contacts/management/feature-flag', () => ({ + isContactsManagementEnabled: contactsEnabled, +})) vi.mock('@/service/console', async (importOriginal) => { const actual = await importOriginal() const { withConsoleResponses } = await import('@/test/console/service-responses') @@ -31,6 +57,7 @@ vi.mock('@/service/console', async (importOriginal) => { 'workspaces.current.rbac.members.byMemberId.rbacRoles.get': memberRolesRequest, 'workspaces.current.rbac.members.byMemberId.rbacRoles.put': updateRoles, 'workspaces.current.members.inviteEmail.post': inviteMember, + 'workspaces.current.members.byMemberId.delete': removeMember, }), } }) @@ -86,11 +113,17 @@ const choices = [ createRole({ id: 'role-next', name: 'Next role' }), createRole({ id: 'role-extra', name: 'Extra role' }), ] -async function renderMembersPage(options: ConsoleQueryTestOptions = {}) { - const view = renderWithConsoleQuery( +async function renderMembersPage( + options: ConsoleQueryTestOptions = {}, + contacts?: { context: ContactsFeatureContextValue; repository: ContactsManagementRepository }, +) { + const page = ( - , + + ) + const view = renderWithConsoleQuery( + contacts ? {page} : page, { accountProfile: { id: '1', email: 'owner@example.com' }, currentWorkspace: { name: 'Test Workspace', role: 'owner' }, @@ -120,8 +153,13 @@ async function chooseRoles(user: ReturnType, multiple: b beforeEach(() => { vi.clearAllMocks() + contactsEnabled.mockReturnValue(false) accounts = structuredClone(defaultAccounts) membersRequest.mockImplementation(async () => ({ accounts })) + removeMember.mockImplementation(async ({ params }: { params: { member_id: string } }) => { + accounts = accounts.filter((account) => account.id !== params.member_id) + return { result: 'success', tenant_id: 'tenant-1' } + }) rolesRequest.mockResolvedValue({ data: choices, pagination: { current_page: 1, total_pages: 1, total_count: 3, per_page: 20 }, @@ -146,6 +184,157 @@ beforeEach(() => { }) }) +function contactRemoval() { + const scenario = createContactsMockScenario(ContactsMockScenario.EeMixed) + const context: ContactsFeatureContextValue = { + deployment: scenario.deployment, + imCentrallyManaged: true, + permissions: scenario.permissions, + workspaceId: scenario.workspaceId, + } + const repository = createContactsMockRepository({ scenario }) + const remove = vi.spyOn(repository, 'removeMember').mockImplementation(async ({ memberId }) => { + accounts = accounts.filter((account) => account.id !== memberId) + return { kind: 'removed', contactId: null, contactOutcome: 'not_found' } + }) + return { context, repository, remove } +} + +async function openAdminRemoval(user: ReturnType) { + await user.click(screen.getByRole('button', { name: /members\.memberActions: Admin User/ })) + await user.click(screen.getByRole('menuitem', { name: /members\.removeFromTeam$/ })) + return screen.findByRole('alertdialog') +} + +const contactOptionsKey = consoleQuery.workspaces.current.humanInput.v2.contactOptions.get.key({ + type: 'query', +}) + +describe('MembersPage contact removal', () => { + it('submits the contact retention choice and removes the member from the list', async () => { + contactsEnabled.mockReturnValue(true) + const contacts = contactRemoval() + const user = userEvent.setup() + const { queryClient } = await renderMembersPage({}, contacts) + queryClient.setQueryData(contactOptionsKey, { cached: true }) + const refresh = createDeferred<{ cached: boolean }>() + const contactOptions = new QueryObserver(queryClient, { + queryKey: contactOptionsKey, + queryFn: () => refresh.promise, + staleTime: Infinity, + }) + const unsubscribe = contactOptions.subscribe(() => {}) + onTestFinished(() => { + refresh.resolve({ cached: false }) + unsubscribe() + }) + const dialog = await openAdminRemoval(user) + const retention = within(dialog).getByRole('checkbox', { name: /memberRemoval\.keepPlatform/ }) + expect(retention).toBeChecked() + await user.click(retention) + await user.click(within(dialog).getByRole('button', { name: 'contacts.memberRemoval.remove' })) + + await waitFor(() => + expect(contacts.remove).toHaveBeenCalledExactlyOnceWith({ + memberId: '2', + keepAsPlatformContact: false, + }), + ) + await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()) + expect(screen.queryByRole('row', { name: /admin@example.com/ })).not.toBeInTheDocument() + expect(removeMember).not.toHaveBeenCalled() + expect(queryClient.getQueryState(contactOptionsKey)?.isInvalidated).toBe(true) + }) + + it('blocks dismissal during contact removal and starts a fresh choice after closing a failure', async () => { + contactsEnabled.mockReturnValue(true) + const contacts = contactRemoval() + const request = createDeferred() + contacts.remove.mockReturnValueOnce(request.promise) + const user = userEvent.setup() + await renderMembersPage({}, contacts) + const dialog = await openAdminRemoval(user) + const retention = within(dialog).getByRole('checkbox', { name: /memberRemoval\.keepPlatform/ }) + await user.click(retention) + await user.click(within(dialog).getByRole('button', { name: 'contacts.memberRemoval.remove' })) + await user.keyboard('{Escape}') + expect(dialog).toBeInTheDocument() + expect(within(dialog).getByRole('button', { name: 'contacts.action.cancel' })).toBeDisabled() + expect(contacts.remove).toHaveBeenCalledOnce() + await act(async () => request.resolve({ kind: 'failed' })) + expect(await within(dialog).findByRole('alert')).toHaveTextContent( + 'contacts.memberRemoval.failed', + ) + expect(retention).not.toBeChecked() + await user.click(within(dialog).getByRole('button', { name: 'contacts.action.cancel' })) + await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()) + + const reopened = await openAdminRemoval(user) + expect( + within(reopened).getByRole('checkbox', { name: /memberRemoval\.keepPlatform/ }), + ).toBeChecked() + expect(within(reopened).queryByRole('alert')).not.toBeInTheDocument() + await user.click( + within(reopened).getByRole('button', { name: 'contacts.memberRemoval.remove' }), + ) + await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()) + expect(contacts.remove).toHaveBeenLastCalledWith({ memberId: '2', keepAsPlatformContact: true }) + }) + + it('retains the contact choice after failure and submits it on a successful retry', async () => { + contactsEnabled.mockReturnValue(true) + const contacts = contactRemoval() + contacts.remove.mockResolvedValueOnce({ kind: 'failed' }) + const user = userEvent.setup() + await renderMembersPage({}, contacts) + const dialog = await openAdminRemoval(user) + const retention = within(dialog).getByRole('checkbox', { name: /memberRemoval\.keepPlatform/ }) + await user.click(retention) + await user.click(within(dialog).getByRole('button', { name: 'contacts.memberRemoval.remove' })) + expect(await within(dialog).findByRole('alert')).toHaveTextContent( + 'contacts.memberRemoval.failed', + ) + expect(retention).not.toBeChecked() + await user.click(within(dialog).getByRole('button', { name: 'contacts.memberRemoval.remove' })) + await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()) + expect(contacts.remove).toHaveBeenCalledTimes(2) + expect(contacts.remove).toHaveBeenLastCalledWith({ + memberId: '2', + keepAsPlatformContact: false, + }) + }) + + it.each([ + { status: 'active', supportsMemberManagement: false, enabled: true, contactCopy: true }, + { status: 'pending', supportsMemberManagement: true, enabled: true, contactCopy: false }, + { status: 'active', supportsMemberManagement: true, enabled: false, contactCopy: false }, + ])( + 'uses standard removal for status=$status with contact support=$supportsMemberManagement', + async ({ status, supportsMemberManagement, enabled, contactCopy }) => { + contactsEnabled.mockReturnValue(enabled) + accounts[1] = { ...accounts[1]!, status } + const contacts = contactRemoval() + contacts.repository.supportsMemberManagement = supportsMemberManagement + const user = userEvent.setup() + const { queryClient } = await renderMembersPage({}, contacts) + queryClient.setQueryData(contactOptionsKey, { cached: true }) + const dialog = await openAdminRemoval(user) + expect(within(dialog).queryByRole('checkbox')).not.toBeInTheDocument() + if (contactCopy) expect(dialog).toHaveTextContent('contacts.memberRemoval.accessImpact') + await user.click( + within(dialog).getByRole('button', { + name: contactCopy ? 'contacts.memberRemoval.remove' : 'common.operation.confirm', + }), + ) + await waitFor(() => + expect(removeMember).toHaveBeenCalledExactlyOnceWith({ params: { member_id: '2' } }), + ) + expect(contacts.remove).not.toHaveBeenCalled() + expect(queryClient.getQueryState(contactOptionsKey)?.isInvalidated).toBe(true) + }, + ) +}) + describe('MembersPage', () => { it('shows account roles and the created time fallback in separate table cells', async () => { accounts[1] = { ...accounts[1]!, last_active_at: null, created_at: 1700000000 } diff --git a/web/app/components/header/account-setting/members-page/index.tsx b/web/app/components/header/account-setting/members-page/index.tsx index 53129478906..14512c2d8dd 100644 --- a/web/app/components/header/account-setting/members-page/index.tsx +++ b/web/app/components/header/account-setting/members-page/index.tsx @@ -1,5 +1,6 @@ 'use client' import type { + AccountWithRoleListResponse, AccountWithRoleResponse, MemberInviteResponse, } from '@dify/contracts/api/console/workspaces/types.gen' @@ -15,6 +16,7 @@ import { AlertDialogTitle, createAlertDialogHandle, } from '@langgenius/dify-ui/alert-dialog' +import { Avatar, AvatarFallback, AvatarImage } from '@langgenius/dify-ui/avatar' import { createDialogHandle, Dialog, @@ -23,7 +25,7 @@ import { } from '@langgenius/dify-ui/dialog' import { IconButton } from '@langgenius/dify-ui/icon-button' import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' -import { useMutation, useQuery, useSuspenseQuery } from '@tanstack/react-query' +import { useMutation, useQuery, useQueryClient, useSuspenseQuery } from '@tanstack/react-query' import { useAtomValue } from 'jotai' import { useRef, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -33,6 +35,11 @@ import UpgradeBtn from '@/app/components/billing/upgrade-btn' import { workspacePermissionKeysAtom } from '@/context/permission-state' import { currentWorkspaceAtom, isCurrentWorkspaceOwnerAtom } from '@/context/workspace-state' import { userProfileQueryOptions } from '@/features/account-profile/client' +import { ContactsManagementRuntimeProvider } from '@/features/contacts/management/composition' +import { useOptionalContactsManagement } from '@/features/contacts/management/composition-context' +import { isContactsManagementEnabled } from '@/features/contacts/management/feature-flag' +import { useRemoveContactMember } from '@/features/contacts/management/hooks' +import { MemberRemovalContactImpactContent } from '@/features/contacts/management/member-removal-content' import { systemFeaturesQueryOptions } from '@/features/system-features/client' import { getAccessControlTemplateLanguage, LanguagesSupported } from '@/i18n/language' import { consoleQuery } from '@/service/console' @@ -44,7 +51,7 @@ import { InviteModal } from './invite-modal' import MemberDetailsContent from './member-details-content' import MemberRow from './member-row' -const MembersPage = () => { +const MembersPageContent = () => { const { t } = useTranslation([ 'billing', 'common', @@ -52,7 +59,11 @@ const MembersPage = () => { 'accountSettings', 'permission', 'navigation', + 'contacts', ]) + const queryClient = useQueryClient() + const contactsManagement = useOptionalContactsManagement() + const contactRemoval = useRemoveContactMember() const locale = useLocale() const language = getAccessControlTemplateLanguage(locale) @@ -281,67 +292,166 @@ const MembersPage = () => { { - if (!open && removal.isPending && details.reason !== 'imperative-action') + if ( + !open && + (removal.isPending || contactRemoval.isPending) && + details.reason !== 'imperative-action' + ) details.cancel() }} onOpenChangeComplete={(open) => { - if (!open) removal.reset() + if (!open) { + removal.reset() + contactRemoval.reset() + } }} > - {({ payload }) => ( - { - const fallback = payload?.fallbackFocus.current - if (!fallback) return true - const target = - payload.adjacentFocusTargets.find((element) => element.isConnected) ?? fallback - target.focus({ preventScroll: true }) - return false - }} - > - - - {t(($) => $['members.removeFromTeamConfirmTitle'], { - ns: 'workspaceMembers', - memberName: payload?.member.name || payload?.member.email || '', - })} - - - {t(($) => $['members.removeFromTeamConfirmDescription'], { - ns: 'workspaceMembers', - })} - - {removal.isError && ( -

- {t(($) => $['api.actionFailed'], { ns: 'common' })} -

+ {({ payload }) => { + const showContactsRemovalDesign = Boolean( + payload && isContactsManagementEnabled() && payload.member.status !== 'pending', + ) + const useContactsAwareRemoval = Boolean( + showContactsRemovalDesign && + contactsManagement.context && + contactsManagement.repository && + contactsManagement.repository.supportsMemberManagement !== false, + ) + const memberName = payload?.member.name || payload?.member.email || '' + return ( + { + const fallback = payload?.fallbackFocus.current + if (!fallback) return true + const target = + payload.adjacentFocusTargets.find((element) => element.isConnected) ?? + fallback + target.focus({ preventScroll: true }) + return false + }} + > + {useContactsAwareRemoval && payload ? ( + { + queryClient.setQueriesData( + { queryKey: consoleQuery.workspaces.current.members.get.key() }, + (cached) => + cached && { + ...cached, + accounts: cached.accounts.filter( + (account) => account.id !== payload.member.id, + ), + }, + ) + payload.fallbackFocus.current = listRef.current + removeDialog.close() + }} + /> + ) : ( + <> + + {showContactsRemovalDesign && payload ? ( + <> + + {t( + ($) => + $[ + currentWorkspace.name + ? 'memberRemoval.workspaceTitle' + : 'memberRemoval.title' + ], + { + ns: 'contacts', + memberName, + workspaceName: currentWorkspace.name, + }, + )} + +
+ + + + {memberName[0]?.toLocaleUpperCase()} + + +
+
+ {memberName} +
+
+ {payload.member.email} +
+
+
+ }> +

+ {t(($) => $['memberRemoval.accessImpact'], { + ns: 'contacts', + memberName, + })} +

+

+ {t(($) => $['memberRemoval.contentImpact'], { ns: 'contacts' })} +

+
+ + ) : ( + <> + + {t(($) => $['members.removeFromTeamConfirmTitle'], { + ns: 'workspaceMembers', + memberName: payload?.member.name || payload?.member.email || '', + })} + + + {t(($) => $['members.removeFromTeamConfirmDescription'], { + ns: 'workspaceMembers', + })} + + + )} + {removal.isError && ( +

+ {t(($) => $['api.actionFailed'], { ns: 'common' })} +

+ )} +
+ + + {t(($) => $['operation.cancel'], { ns: 'common' })} + + { + if (!payload || removal.isPending) return + removal.mutate( + { params: { member_id: payload.member.id } }, + { + onSuccess: () => { + payload.fallbackFocus.current = listRef.current + removeDialog.close() + }, + }, + ) + }} + > + {showContactsRemovalDesign + ? t(($) => $['memberRemoval.remove'], { ns: 'contacts' }) + : t(($) => $['operation.confirm'], { ns: 'common' })} + + + )} -
- - - {t(($) => $['operation.cancel'], { ns: 'common' })} - - { - if (!payload || removal.isPending) return - removal.mutate( - { params: { member_id: payload.member.id } }, - { - onSuccess: () => { - payload.fallbackFocus.current = listRef.current - removeDialog.close() - }, - }, - ) - }} - > - {t(($) => $['operation.confirm'], { ns: 'common' })} - - -
- )} + + ) + }}
{({ payload: results }) => ( @@ -370,4 +480,12 @@ const MembersPage = () => { ) } -export default MembersPage +export default function MembersPage() { + const { context, repository } = useOptionalContactsManagement() + if (!isContactsManagementEnabled() || (context && repository)) return + return ( + + + + ) +} diff --git a/web/app/components/header/account-setting/members-page/invite-modal/__tests__/index.hitl-v2.spec.tsx b/web/app/components/header/account-setting/members-page/invite-modal/__tests__/index.hitl-v2.spec.tsx new file mode 100644 index 00000000000..49edf6db20e --- /dev/null +++ b/web/app/components/header/account-setting/members-page/invite-modal/__tests__/index.hitl-v2.spec.tsx @@ -0,0 +1,217 @@ +import type { GetFeaturesResponse } from '@dify/contracts/api/console/features/types.gen' +import type { MemberInviteResponse } from '@dify/contracts/api/console/workspaces/types.gen' +import { QueryClient } from '@tanstack/react-query' +import { render, screen, waitFor, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { vi } from 'vite-plus/test' +import { ContactsManagementMockProvider } from '@/features/contacts/management/__tests__/fixtures/composition' +import { + ContactsMockScenario, + createContactsMockScenario, +} from '@/features/contacts/management/__tests__/fixtures/scenarios' +import { seedCurrentWorkspaceQuery } from '@/test/console/current-workspace' +import { seedFeatures, seedSystemFeatures } from '@/test/console/query-data' +import { QueryClientTestProvider } from '@/test/console/query-provider' +import { InviteModal } from '../index' + +vi.mock('@/config', async (importOriginal) => ({ + ...(await importOriginal()), + ENABLE_HITL_IM: true, +})) + +const { fetchFeatures, inviteMember, fetchRoles } = vi.hoisted(() => ({ + fetchFeatures: vi.fn(), + inviteMember: vi.fn(), + fetchRoles: vi.fn(), +})) + +vi.mock('@/service/console', async (importOriginal) => { + const actual = await importOriginal() + const { withConsoleResponses } = await import('@/test/console/service-responses') + const { workspaces } = await import('@dify/contracts/api/console/workspaces/orpc.gen') + const { validateDefinedErrors } = await import('@/service/console/defined-errors') + const validate = validateDefinedErrors({ workspaces }) + return { + ...actual, + consoleQuery: withConsoleResponses(actual.consoleQuery, { + 'features.get': fetchFeatures, + 'workspaces.current.rbac.roles.get': fetchRoles, + 'workspaces.current.members.inviteEmail.post': (input: unknown) => + validate({ + context: {}, + input, + path: ['workspaces', 'current', 'members', 'inviteEmail', 'post'], + next: () => inviteMember(input), + }), + }), + } +}) + +describe('InviteModal', () => { + const onSend = vi.fn() + + const createQueryClient = () => + new QueryClient({ + defaultOptions: { + queries: { retry: false, staleTime: Infinity }, + mutations: { retry: false }, + }, + }) + + beforeEach(() => { + vi.clearAllMocks() + fetchRoles.mockResolvedValue({ + data: ['Admin', 'Editor'].map((name) => ({ + id: name.toLowerCase(), + tenant_id: 'tenant-id', + type: 'workspace', + category: 'global_system_default', + name, + description: `${name} role`, + is_builtin: true, + permission_keys: [], + role_tag: '', + })), + pagination: { total_count: 2, per_page: 20, current_page: 1, total_pages: 1 }, + }) + inviteMember.mockResolvedValue({ + result: 'success', + invitation_results: [], + tenant_id: 'tenant-id', + }) + }) + + const renderModal = async ({ + open = true, + isEmailSetup = true, + queryClient = createQueryClient(), + contactsScenario, + workspaceMembers = { enabled: true, size: 5, limit: 10 }, + }: { + open?: boolean + isEmailSetup?: boolean + queryClient?: QueryClient + contactsScenario?: ContactsMockScenario + workspaceMembers?: GetFeaturesResponse['workspace_members'] + } = {}) => { + seedCurrentWorkspaceQuery(queryClient) + seedSystemFeatures(queryClient, { deployment_edition: 'CLOUD' }) + const features = seedFeatures(queryClient, { workspace_members: workspaceMembers }) + fetchFeatures.mockResolvedValue(features) + + const result = render( + + {contactsScenario ? ( + + + + ) : ( + + )} + , + ) + if (open) { + const user = userEvent.setup() + await user.click(screen.getByRole('button', { name: /members\.invite$/i })) + } + return result + } + + const selectAdminRole = async (user: ReturnType) => { + await user.click(screen.getByRole('combobox', { name: /members\.role/i })) + await user.click(await screen.findByRole('option', { name: /Admin/i })) + } + + const addRecipients = async (user: ReturnType, value: string) => { + const input = screen.getByRole('textbox', { name: /members\.emailRecipients/i }) + await user.click(input) + await user.paste(value) + } + + it('asks for confirmation before inviting an email used by an External Contact', async () => { + const user = userEvent.setup() + inviteMember.mockResolvedValue({ + result: 'success', + invitation_results: [], + tenant_id: 'tenant-id', + } satisfies MemberInviteResponse) + await renderModal({ contactsScenario: ContactsMockScenario.EeMixed }) + + await addRecipients(user, 'external@example.com') + await selectAdminRole(user) + await user.click(screen.getByRole('button', { name: /members\.sendInvite/i })) + + const confirmation = await screen.findByRole('alertdialog', { + name: /contacts\.memberInviteUpgrade\.title/i, + }) + expect(inviteMember).not.toHaveBeenCalled() + + await user.click( + within(confirmation).getByRole('button', { name: /contacts\.action\.cancel/i }), + ) + + await waitFor(() => { + expect( + screen.queryByRole('alertdialog', { + name: /contacts\.memberInviteUpgrade\.title/i, + }), + ).not.toBeInTheDocument() + }) + expect(screen.getByText('external@example.com')).toBeInTheDocument() + expect(screen.getByRole('combobox', { name: /members\.role/i })).toHaveTextContent('Admin') + expect(inviteMember).not.toHaveBeenCalled() + }) + + it('submits the pending invitation only after Add and upgrade is confirmed', async () => { + const user = userEvent.setup() + inviteMember.mockResolvedValue({ + result: 'success', + invitation_results: [], + tenant_id: 'tenant-id', + } satisfies MemberInviteResponse) + await renderModal({ contactsScenario: ContactsMockScenario.EeMixed }) + + await addRecipients(user, 'external@example.com') + await selectAdminRole(user) + await user.click(screen.getByRole('button', { name: /members\.sendInvite/i })) + + const confirmation = await screen.findByRole('alertdialog', { + name: /contacts\.memberInviteUpgrade\.title/i, + }) + await user.click( + within(confirmation).getByRole('button', { + name: /contacts\.memberInviteUpgrade\.confirm/i, + }), + ) + + await waitFor(() => { + expect(inviteMember).toHaveBeenCalledOnce() + expect(inviteMember.mock.calls[0]?.[0]).toEqual({ + body: { + emails: ['external@example.com'], + role: 'admin', + language: 'en-US', + }, + }) + }) + }) + + it('submits directly when no invited email belongs to an External Contact', async () => { + const user = userEvent.setup() + inviteMember.mockResolvedValue({ + result: 'success', + invitation_results: [], + tenant_id: 'tenant-id', + } satisfies MemberInviteResponse) + await renderModal({ contactsScenario: ContactsMockScenario.EeMixed }) + + await addRecipients(user, 'new-member@example.com') + await selectAdminRole(user) + await user.click(screen.getByRole('button', { name: /members\.sendInvite/i })) + + await waitFor(() => expect(inviteMember).toHaveBeenCalledOnce()) + expect( + screen.queryByRole('alertdialog', { name: /contacts\.memberInviteUpgrade\.title/i }), + ).not.toBeInTheDocument() + }) +}) diff --git a/web/app/components/header/account-setting/members-page/invite-modal/index.tsx b/web/app/components/header/account-setting/members-page/invite-modal/index.tsx index fbfc80fc344..7a534065bbc 100644 --- a/web/app/components/header/account-setting/members-page/invite-modal/index.tsx +++ b/web/app/components/header/account-setting/members-page/invite-modal/index.tsx @@ -22,6 +22,9 @@ import { useAtomValue } from 'jotai' import { useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { useLocale } from '#i18n' +import { isContactsManagementEnabled } from '@/features/contacts/management/feature-flag' +import { useOptionalMemberInviteContactUpgrade } from '@/features/contacts/management/hooks' +import { MemberInviteContactUpgradeDialog } from '@/features/contacts/management/member-invite-contact-upgrade-dialog' import { deploymentEditionAtom } from '@/features/system-features/state' import { consoleQuery } from '@/service/console' import { InviteButton } from '../invite-button' @@ -34,6 +37,9 @@ type InviteModalProps = { onSend: (invitationResults: MemberInviteResponse['invitation_results']) => void } +type InviteSubmission = { emails: string[]; role: string } +type PendingUpgradeConfirmation = { contactIds: string[]; submission: InviteSubmission } + type InviteFieldName = 'emails' | 'role' type InviteFormValues = { emails: string @@ -179,63 +185,126 @@ export function InviteModal({ isEmailSetup, onSend }: InviteModalProps) { const queryClient = useQueryClient() const actionsRef = useRef(null) const formRef = useRef(null) + const contactUpgrade = useOptionalMemberInviteContactUpgrade() + const [pendingUpgradeConfirmation, setPendingUpgradeConfirmation] = + useState(null) + const [preparationFailed, setPreparationFailed] = useState(false) const { mutate, reset, isPending, error } = useMutation( consoleQuery.workspaces.current.members.inviteEmail.post.mutationOptions({ context: { silent: true }, - onSuccess: (response) => { - // The request alone decides success; seat and member refreshes run in the background. - void queryClient.invalidateQueries({ queryKey: consoleQuery.features.get.queryKey() }) - void queryClient.invalidateQueries({ - queryKey: consoleQuery.workspaces.current.members.get.key(), - }) - onSend(response.invitation_results) - actionsRef.current?.close() - }, }), ) + const isBusy = isPending || contactUpgrade.isChecking || contactUpgrade.isUpgrading + const submitInvitation = (submission: InviteSubmission, contactIds: string[] = []) => { + mutate( + { body: { ...submission, language: locale } }, + { + onSuccess: async (response) => { + void queryClient.invalidateQueries({ queryKey: consoleQuery.features.get.queryKey() }) + if (contactIds.length > 0) await contactUpgrade.upgradeContacts({ contactIds }) + setPendingUpgradeConfirmation(null) + void queryClient.invalidateQueries({ + queryKey: consoleQuery.workspaces.current.members.get.key(), + }) + onSend(response.invitation_results) + actionsRef.current?.close() + }, + onError: () => setPendingUpgradeConfirmation(null), + }, + ) + } + const handleSubmit = async (emails: string[], role: string) => { + if (isBusy) return + reset() + setPreparationFailed(false) + const submission = { emails, role } + if (isContactsManagementEnabled() && contactUpgrade.available) { + try { + const conflicts = await contactUpgrade.findConflicts({ emails }) + if (conflicts.length > 0) { + setPendingUpgradeConfirmation({ + contactIds: conflicts.map((contact) => contact.id), + submission, + }) + return + } + } catch { + setPreparationFailed(true) + return + } + } + submitInvitation(submission) + } + return ( - { - if (!nextOpen && isPending && details.reason !== 'imperative-action') details.cancel() - }} - onOpenChangeComplete={(open) => { - // A failure stays visible through the exit transition and ends with the session. - if (!open) reset() - }} - > - } /> - { - const emailInput = formRef.current?.elements.namedItem('emails') - return emailInput instanceof HTMLInputElement ? emailInput : true + <> + { + if (!nextOpen && isBusy && details.reason !== 'imperative-action') details.cancel() + }} + onOpenChangeComplete={(open) => { + // A failure stays visible through the exit transition and ends with the session. + if (!open) { + reset() + setPreparationFailed(false) + setPendingUpgradeConfirmation(null) + } }} > - - - {t(($) => $['members.inviteTeamMember'], { ns: 'workspaceMembers' })} - - - {t(($) => $['members.inviteTeamMemberTip'], { ns: 'workspaceMembers' })} - - $['operation.close'], { ns: 'common' })} - disabled={isPending} + } /> + { + const emailInput = formRef.current?.elements.namedItem('emails') + return emailInput instanceof HTMLInputElement ? emailInput : true + }} + > + + + {t(($) => $['members.inviteTeamMember'], { ns: 'workspaceMembers' })} + + + {t(($) => $['members.inviteTeamMemberTip'], { ns: 'workspaceMembers' })} + + $['operation.close'], { ns: 'common' })} + disabled={isBusy} + /> + + { + void handleSubmit(emails, role) + }} + onDismissError={() => { + reset() + setPreparationFailed(false) + }} /> - - mutate({ body: { emails, role, language: locale } })} - onDismissError={reset} - /> - - + + + { + if (!open && !isBusy) setPendingUpgradeConfirmation(null) + }} + onConfirm={() => { + if (pendingUpgradeConfirmation && !isBusy) + submitInvitation( + pendingUpgradeConfirmation.submission, + pendingUpgradeConfirmation.contactIds, + ) + }} + /> + ) } diff --git a/web/app/components/header/account-setting/preference-page/__tests__/index.browser.spec.tsx b/web/app/components/header/account-setting/preference-page/__tests__/index.browser.spec.tsx index 8dfc8538191..b9b0cba6409 100644 --- a/web/app/components/header/account-setting/preference-page/__tests__/index.browser.spec.tsx +++ b/web/app/components/header/account-setting/preference-page/__tests__/index.browser.spec.tsx @@ -27,7 +27,12 @@ vi.mock('@/service/console/browser', () => ({ }, }, })) -vi.mock('@/next/navigation', () => ({ useRouter: () => ({ refresh: vi.fn() }) })) +vi.mock('@/next/navigation', () => ({ + useRouter: () => ({ refresh: vi.fn() }), + usePathname: () => '/account/settings', + useParams: () => ({}), + useSearchParams: () => new URLSearchParams(), +})) vi.mock('#i18n', async (importOriginal) => ({ ...(await importOriginal()), useLocale: () => 'en-US', diff --git a/web/app/components/main-nav/__tests__/index.spec.tsx b/web/app/components/main-nav/__tests__/index.spec.tsx index 2290a015d67..fbee89e4588 100644 --- a/web/app/components/main-nav/__tests__/index.spec.tsx +++ b/web/app/components/main-nav/__tests__/index.spec.tsx @@ -37,6 +37,7 @@ import { MainNav } from '../index' const onPricingUrlUpdate = vi.hoisted(() => vi.fn()) const mockTrackEvent = vi.hoisted(() => vi.fn()) +const mockConfig = vi.hoisted(() => ({ ENABLE_HITL_IM: true })) const { mockInstalledAppsRequest, mockIsAgentV2Enabled, mockSwitchWorkspace, mockUpdatePinStatus } = vi.hoisted(() => ({ @@ -133,6 +134,9 @@ vi.mock('@/config', async (importOriginal) => { const actual = await importOriginal() return { ...actual, + get ENABLE_HITL_IM() { + return mockConfig.ENABLE_HITL_IM + }, SUPPORT_EMAIL_ADDRESS: '', ZENDESK_WIDGET_KEY: '', } @@ -350,6 +354,7 @@ function render(...args: Parameters) { describe('MainNav', () => { beforeEach(() => { vi.clearAllMocks() + mockConfig.ENABLE_HITL_IM = true mockInstalledAppsRequest.mockReset() mockUpdatePinStatus.mockReset() mockSwitchWorkspace.mockReset() @@ -443,6 +448,28 @@ describe('MainNav', () => { expect(integrationsLink).toHaveAttribute('href', '/integrations/model-provider') const marketplaceLink = screen.getByRole('link', { name: /navigation.mainNav.marketplace/ }) expect(marketplaceLink).toHaveAttribute('href', '/marketplace') + expect( + within(screen.getByRole('navigation', { name: 'common.navigation.primary' })) + .getAllByRole('link') + .map((link) => link.getAttribute('href')), + ).toEqual([ + '/', + '/apps', + '/agents', + '/datasets', + '/contacts', + '/skills', + '/integrations/model-provider', + '/marketplace', + ]) + }) + + it('hides Contacts while keeping other navigation available when HITL IM is not released', () => { + mockConfig.ENABLE_HITL_IM = false + + renderMainNav() + + expect(screen.queryByRole('link', { name: /contacts.directory.title/ })).not.toBeInTheDocument() expect( within(screen.getByRole('navigation', { name: 'common.navigation.primary' })) .getAllByRole('link') @@ -570,6 +597,7 @@ describe('MainNav', () => { 'href', '/datasets', ) + expect(screen.queryByRole('link', { name: /contacts.directory.title/ })).not.toBeInTheDocument() expect(screen.getByRole('link', { name: /navigation.mainNav.integrations/ })).toHaveAttribute( 'href', '/integrations/model-provider', @@ -602,6 +630,7 @@ describe('MainNav', () => { expect(screen.getByRole('link', { name: /navigation.menus.apps/ })).toBeInTheDocument() expect(screen.getByRole('link', { name: /Agents/ })).toBeInTheDocument() expect(screen.getByRole('link', { name: /navigation.menus.datasets/ })).toBeInTheDocument() + expect(screen.queryByRole('link', { name: /contacts.directory.title/ })).not.toBeInTheDocument() expect( screen.getByRole('link', { name: /navigation.mainNav.integrations/ }), ).toBeInTheDocument() diff --git a/web/app/components/main-nav/index.tsx b/web/app/components/main-nav/index.tsx index fd0f2dc0a0f..8adf8f676e3 100644 --- a/web/app/components/main-nav/index.tsx +++ b/web/app/components/main-nav/index.tsx @@ -10,9 +10,13 @@ import Badge from '@/app/components/base/badge' import { DifyLogo } from '@/app/components/base/logo/dify-logo' import EnvNav from '@/app/components/header/env-nav' import StepByStepTourMount from '@/app/components/step-by-step-tour/mount' -import { isCurrentWorkspaceDatasetOperatorAtom } from '@/context/workspace-state' +import { + isCurrentWorkspaceDatasetOperatorAtom, + isCurrentWorkspaceManagerAtom, +} from '@/context/workspace-state' import { userProfileQueryOptions } from '@/features/account-profile/client' import { isAgentV2Enabled } from '@/features/agent-v2/feature-flag' +import { isContactsManagementEnabled } from '@/features/contacts/management/feature-flag' import { useMarketplace } from '@/features/marketplace/client' import { useCanViewSkills } from '@/features/skills/permissions' import { systemFeaturesQueryOptions } from '@/features/system-features/client' @@ -30,8 +34,10 @@ const WebAppsSection = lazy(() => import('./components/web-apps-section')) export function MainNav({ className, initialPlatform }: MainNavProps) { const { t } = useTranslation(['common', 'navigation']) + const { t: tContacts } = useTranslation(['contacts']) const pathname = usePathname() const isCurrentWorkspaceDatasetOperator = useAtomValue(isCurrentWorkspaceDatasetOperatorAtom) + const isCurrentWorkspaceManager = useAtomValue(isCurrentWorkspaceManagerAtom) const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) const marketplaceEnabled = useMarketplace() !== null const { data: currentEnv } = useSuspenseQuery({ @@ -39,6 +45,7 @@ export function MainNav({ className, initialPlatform }: MainNavProps) { select: (data) => data.meta.currentEnv, }) const agentV2Enabled = isAgentV2Enabled() + const contactsEnabled = isContactsManagementEnabled() const canViewSkills = useCanViewSkills() const { data: enableSkill } = useQuery( consoleQuery.features.get.queryOptions({ @@ -53,25 +60,39 @@ export function MainNav({ className, initialPlatform }: MainNavProps) { MAIN_NAV_ROUTES.filter((route) => isMainNavRouteVisible(route, { agentV2Enabled, + canViewContacts: isCurrentWorkspaceManager, + contactsEnabled, canViewSkills, isCurrentWorkspaceDatasetOperator, marketplaceEnabled, skillEnabled: enableSkill === true, }), - ).map((route) => ({ - href: route.href, - label: 'label' in route ? route.label : t(($) => $[route.labelKey], { ns: 'navigation' }), - active: route.active, - icon: route.icon, - activeIcon: route.activeIcon, - })), + ).map((route) => { + const label = + 'label' in route + ? route.label + : route.key === 'contacts' + ? tContacts(($) => $['directory.title']) + : t(($) => $[route.labelKey], { ns: 'navigation' }) + + return { + href: route.href, + label, + active: route.active, + icon: route.icon, + activeIcon: route.activeIcon, + } + }), [ agentV2Enabled, + contactsEnabled, canViewSkills, enableSkill, isCurrentWorkspaceDatasetOperator, + isCurrentWorkspaceManager, marketplaceEnabled, t, + tContacts, ], ) diff --git a/web/app/components/main-nav/routes.ts b/web/app/components/main-nav/routes.ts index a8ba98b2b00..024bf5c44ac 100644 --- a/web/app/components/main-nav/routes.ts +++ b/web/app/components/main-nav/routes.ts @@ -11,11 +11,13 @@ export type MainNavRouteConfig = { icon: string activeIcon: string visibility: MainNavRouteVisibility - feature?: 'agentV2' | 'marketplace' + feature?: 'agentV2' | 'contacts' | 'marketplace' } & ({ label: string; labelKey?: never } | { label?: never; labelKey: string }) export type MainNavRouteVisibilityOptions = { agentV2Enabled: boolean + canViewContacts: boolean + contactsEnabled: boolean canViewSkills: boolean isCurrentWorkspaceDatasetOperator: boolean marketplaceEnabled: boolean @@ -28,6 +30,7 @@ export type DetailSidebarVisibilityOptions = Pick< > const VISIBLE_TO_ALL: MainNavRouteVisibility = () => true +const CAN_VIEW_CONTACTS: MainNavRouteVisibility = (options) => options.canViewContacts const SKILL_ENABLED_FOR_WORKSPACE: MainNavRouteVisibility = (options) => options.skillEnabled && options.canViewSkills && !options.isCurrentWorkspaceDatasetOperator @@ -76,6 +79,16 @@ export const MAIN_NAV_ROUTES = [ activeIcon: 'i-custom-vender-main-nav-knowledge-v2-active', visibility: VISIBLE_TO_ALL, }, + { + key: 'contacts', + href: '/contacts', + labelKey: 'directory.title', + active: (path: string) => isPathUnderRoute(path, '/contacts'), + icon: 'i-ri-contacts-book-2-line', + activeIcon: 'i-ri-contacts-book-2-fill', + visibility: CAN_VIEW_CONTACTS, + feature: 'contacts', + }, { key: 'skills', href: '/skills', @@ -118,6 +131,8 @@ export function isMainNavRouteVisible( if (route.feature === 'marketplace' && !options.marketplaceEnabled) return false + if (route.feature === 'contacts' && !options.contactsEnabled) return false + return route.visibility(options) } diff --git a/web/app/components/plugins/plugin-auth/types.ts b/web/app/components/plugins/plugin-auth/types.ts index 201b78533d3..b344b183704 100644 --- a/web/app/components/plugins/plugin-auth/types.ts +++ b/web/app/components/plugins/plugin-auth/types.ts @@ -43,8 +43,8 @@ export type Credential = { /** * True when the backend returned this credential only because the current node * still references it, but the visibility filter would normally hide it - * (another member's `only_me` credential). The row renders the "切换后不可再选回" - * hint and locks rename/edit/delete/set-default actions. + * (another member's `only_me` credential). The row warns that switching away + * prevents reselecting it and locks rename/edit/delete/set-default actions. */ from_other_member?: boolean } diff --git a/web/app/components/rag-pipeline/hooks/use-available-nodes-meta-data.ts b/web/app/components/rag-pipeline/hooks/use-available-nodes-meta-data.ts index 80a748dd6f6..b95652a2efa 100644 --- a/web/app/components/rag-pipeline/hooks/use-available-nodes-meta-data.ts +++ b/web/app/components/rag-pipeline/hooks/use-available-nodes-meta-data.ts @@ -7,6 +7,7 @@ import dataSourceEmptyDefault from '@/app/components/workflow/nodes/data-source- import dataSourceDefault from '@/app/components/workflow/nodes/data-source/default' import knowledgeBaseDefault from '@/app/components/workflow/nodes/knowledge-base/default' import { BlockEnum } from '@/app/components/workflow/types' +import { getNodePersistedType } from '@/app/components/workflow/utils/node' import { useDocLink } from '@/context/i18n' import { isAgentV2Enabled } from '@/features/agent-v2/feature-flag' import { consoleQuery } from '@/service/console' @@ -26,6 +27,7 @@ export const useAvailableNodesMetaData = () => { ...WORKFLOW_COMMON_NODES.filter( (node) => node.metaData.type !== BlockEnum.HumanInput && + node.metaData.type !== BlockEnum.HumanInputV2 && (knowledgeFsEnabled || node.metaData.type !== BlockEnum.KnowledgeRetrievalV2) && (agentV2Enabled ? node.metaData.type !== BlockEnum.Agent @@ -65,7 +67,7 @@ export const useAvailableNodesMetaData = () => { }, defaultValue: { ...node.defaultValue, - type: metaData.type === BlockEnum.AgentV2 ? BlockEnum.Agent : metaData.type, + type: getNodePersistedType(metaData.type), title, }, } diff --git a/web/app/components/share/text-generation/result/__tests__/workflow-events-subscription.spec.ts b/web/app/components/share/text-generation/result/__tests__/workflow-events-subscription.spec.ts new file mode 100644 index 00000000000..12ae4e575ed --- /dev/null +++ b/web/app/components/share/text-generation/result/__tests__/workflow-events-subscription.spec.ts @@ -0,0 +1,149 @@ +import type { WorkflowEventsHandlers } from '../workflow-events-subscription' +import type { WorkflowFinishedResponse, WorkflowPausedResponse } from '@/types/workflow' +import { createWorkflowEventsSubscription } from '../workflow-events-subscription' + +const sseGetMock = vi.hoisted(() => + vi.fn<(url: string, options: object, handlers: WorkflowEventsHandlers) => Promise>(), +) +vi.mock('@/service/base', () => ({ sseGet: sseGetMock })) + +const paused: WorkflowPausedResponse = { + event: 'workflow_paused', + task_id: 'task-1', + workflow_run_id: 'run-1', + data: { workflow_run_id: 'run-1', outputs: {}, paused_nodes: [], reasons: [] }, +} +const finished: WorkflowFinishedResponse = { + event: 'workflow_finished', + task_id: 'task-1', + workflow_run_id: 'run-1', + data: { + id: 'run-1', + workflow_id: 'workflow-1', + status: 'succeeded', + outputs: { decision: 'Approved' }, + error: '', + elapsed_time: 1, + total_tokens: 0, + total_steps: 3, + created_at: 0, + finished_at: 1, + created_by: { id: 'user-1', name: 'User', email: 'user@example.com' }, + }, +} +const streamOptions = (index: number) => { + const call = sseGetMock.mock.calls[index] + if (!call) throw new Error(`Missing workflow stream ${index}`) + return call[2] +} + +describe('Workflow approval continuation', () => { + beforeEach(() => { + vi.clearAllMocks() + sseGetMock.mockResolvedValue(undefined) + }) + + it('reconnects an idle paused stream and waits for its snapshot before allowing approval', async () => { + const subscription = createWorkflowEventsSubscription() + subscription.start('run-1', { isPublicAPI: true }) + streamOptions(0).onWorkflowPaused?.(paused) + streamOptions(0).onCompleted?.(false) + + let ready = false + const preparation = subscription.prepare().then((value) => { + ready = value + return value + }) + await Promise.resolve() + expect(ready).toBe(false) + expect(sseGetMock).toHaveBeenCalledTimes(2) + expect(sseGetMock).toHaveBeenLastCalledWith( + '/workflow/run-1/events?include_state_snapshot=true&continue_on_pause=true', + {}, + expect.objectContaining({ isPublicAPI: true }), + ) + streamOptions(1).onWorkflowPaused?.(paused) + expect(await preparation).toBe(true) + subscription.dispose() + }) + + it('shows an already finished snapshot and blocks a duplicate approval', async () => { + const onWorkflowFinished = vi.fn() + const subscription = createWorkflowEventsSubscription() + subscription.start('run-1', { onWorkflowFinished }) + const preparation = subscription.prepare() + streamOptions(0).onWorkflowFinished?.(finished) + expect(await preparation).toBe(false) + expect(onWorkflowFinished).toHaveBeenCalledWith(finished) + subscription.dispose() + }) + + it('recovers one disconnect after approval without starting a retry loop', async () => { + const subscription = createWorkflowEventsSubscription() + subscription.start('run-1', {}) + streamOptions(0).onWorkflowPaused?.(paused) + expect(await subscription.prepare()).toBe(true) + streamOptions(0).onCompleted?.(false) + expect(sseGetMock).toHaveBeenCalledTimes(2) + streamOptions(1).onCompleted?.(false) + expect(sseGetMock).toHaveBeenCalledTimes(2) + subscription.dispose() + }) + + it('keeps one connection for simultaneous preparations and allows retry after a failed read', async () => { + const subscription = createWorkflowEventsSubscription() + subscription.start('run-1', {}) + const first = subscription.prepare() + const second = subscription.prepare() + expect(sseGetMock).toHaveBeenCalledTimes(1) + streamOptions(0).onError?.('Offline') + expect(await first).toBe(false) + expect(await second).toBe(false) + const retry = subscription.prepare() + expect(sseGetMock).toHaveBeenCalledTimes(2) + streamOptions(1).onWorkflowPaused?.(paused) + expect(await retry).toBe(true) + subscription.dispose() + }) + + it('aborts the connection on cleanup and ignores late events from the old run', async () => { + const onWorkflowFinished = vi.fn() + const onAbort = vi.fn() + const subscription = createWorkflowEventsSubscription({ onAbort }) + subscription.start('run-1', { onWorkflowFinished }) + const controller = new AbortController() + streamOptions(0).getAbortController?.(controller) + const preparation = subscription.prepare() + subscription.dispose() + expect(await preparation).toBe(false) + expect(controller.signal.aborted).toBe(true) + streamOptions(0).onWorkflowFinished?.(finished) + expect(onWorkflowFinished).not.toHaveBeenCalled() + expect(onAbort).not.toHaveBeenCalled() + }) + + it('blocks approval after a pause snapshot whose connection has already closed', async () => { + const subscription = createWorkflowEventsSubscription() + subscription.start('run-1', {}) + const preparation = subscription.prepare() + streamOptions(0).onWorkflowPaused?.(paused) + streamOptions(0).onCompleted?.(false) + expect(await preparation).toBe(false) + subscription.dispose() + }) + + it('notifies the owner once when the user stops an active or idle paused run', async () => { + const onAbort = vi.fn() + const subscription = createWorkflowEventsSubscription({ onAbort }) + subscription.start('run-1', {}) + const controller = new AbortController() + streamOptions(0).getAbortController?.(controller) + streamOptions(0).onWorkflowPaused?.(paused) + streamOptions(0).onCompleted?.(false) + controller.abort() + expect(onAbort).toHaveBeenCalledOnce() + expect(await subscription.prepare()).toBe(false) + subscription.dispose() + expect(onAbort).toHaveBeenCalledOnce() + }) +}) diff --git a/web/app/components/share/text-generation/result/__tests__/workflow-stream-handlers.spec.ts b/web/app/components/share/text-generation/result/__tests__/workflow-stream-handlers.spec.ts index 35829dadf65..1ea6ffdd947 100644 --- a/web/app/components/share/text-generation/result/__tests__/workflow-stream-handlers.spec.ts +++ b/web/app/components/share/text-generation/result/__tests__/workflow-stream-handlers.spec.ts @@ -181,48 +181,51 @@ describe('workflow state transformations', () => { expect(second.resultText).toBe('Hello world') }) - it('updates pending forms by identity and retains submitted form metadata separately', () => { - let process = upsertWorkflowNode(undefined, createTrace())! - process = updateHumanInputRequired(process, createHumanInput()) - process = updateHumanInputRequired(process, createHumanInput({ expiration_time: 300 })) - process = updateHumanInputRequired( - process, - createHumanInput({ node_id: 'node-2', form_id: 'form-2' }), - ) - expect(process.tracing[0]?.status).toBe(NodeRunningStatus.Paused) - expect(process.humanInputFormDataList?.map((form) => form.node_id)).toEqual([ - 'node-1', - 'node-2', - ]) - expect(process.humanInputFormDataList?.[0]?.expiration_time).toBe(300) - process = updateHumanInputTimeout(process, { - form_id: 'form-2', - node_id: 'node-2', - node_title: 'Node', - expiration_time: 400, - }) - expect(process.humanInputFormDataList?.[1]?.expiration_time).toBe(400) - process = updateHumanInputFilled(process, { - form_id: 'form-1', - action_id: 'submit', - action_text: 'Submit', - node_id: 'node-1', - node_title: 'Node', - rendered_content: 'Done', - }) - expect(process.humanInputFormDataList?.map((form) => form.node_id)).toEqual(['node-2']) - expect(process.humanInputFilledFormDataList).toEqual([ - expect.objectContaining({ - node_id: 'node-1', + it.each(['node-1', 'node-2'])( + 'keeps pending forms independent when another form belongs to %s', + (secondNodeId) => { + let process = upsertWorkflowNode(undefined, createTrace())! + process = updateHumanInputRequired(process, createHumanInput()) + process = updateHumanInputRequired(process, createHumanInput({ expiration_time: 300 })) + process = updateHumanInputRequired( + process, + createHumanInput({ node_id: secondNodeId, form_id: 'form-2' }), + ) + expect(process.tracing[0]?.status).toBe(NodeRunningStatus.Paused) + expect(process.humanInputFormDataList?.map((form) => form.form_id)).toEqual([ + 'form-1', + 'form-2', + ]) + expect(process.humanInputFormDataList?.[0]?.expiration_time).toBe(300) + process = updateHumanInputTimeout(process, { + form_id: 'form-2', + node_id: secondNodeId, + node_title: 'Node', + expiration_time: 400, + }) + expect(process.humanInputFormDataList?.[1]?.expiration_time).toBe(400) + process = updateHumanInputFilled(process, { + form_id: 'form-1', + action_id: 'submit', action_text: 'Submit', - form_content: 'content', - inputs: [], - }), - ]) - const paused = applyWorkflowPaused(process) - expect(paused.status).toBe(WorkflowRunningStatus.Paused) - expect(paused.expand).toBe(false) - }) + node_id: 'node-1', + node_title: 'Node', + rendered_content: 'Done', + }) + expect(process.humanInputFormDataList?.map((form) => form.form_id)).toEqual(['form-2']) + expect(process.humanInputFilledFormDataList).toEqual([ + expect.objectContaining({ + node_id: 'node-1', + action_text: 'Submit', + form_content: 'content', + inputs: [], + }), + ]) + const paused = applyWorkflowPaused(process) + expect(paused.status).toBe(WorkflowRunningStatus.Paused) + expect(paused.expand).toBe(false) + }, + ) }) describe('createWorkflowStreamHandlers', () => { diff --git a/web/app/components/share/text-generation/result/hooks/__tests__/use-result-sender.hitl-v2.spec.ts b/web/app/components/share/text-generation/result/hooks/__tests__/use-result-sender.hitl-v2.spec.ts new file mode 100644 index 00000000000..96361f7ab9c --- /dev/null +++ b/web/app/components/share/text-generation/result/hooks/__tests__/use-result-sender.hitl-v2.spec.ts @@ -0,0 +1,425 @@ +import type { TextGenerationTranslate } from '../../../types' +import type { ResultInputValue } from '../../result-request' +import type { + createWorkflowEventsSubscription, + WorkflowEventsHandlers, +} from '../../workflow-events-subscription' +import type { createWorkflowStreamHandlers } from '../../workflow-stream-handlers' +import type { ResultRunStateController } from '../use-result-run-state' +import type { PromptConfig } from '@/models/debug' +import type { AppSourceType } from '@/service/share' +import type { VisionSettings } from '@/types/app' +import { act, renderHook } from '@testing-library/react' +import { AppSourceType as AppSourceTypeEnum } from '@/service/share' +import { withSelectorKey } from '@/test/i18n-mock' +import { Resolution, TransferMethod } from '@/types/app' +import { useResultSender } from '../use-result-sender' + +const releaseGate = vi.hoisted(() => ({ enabled: true })) +vi.mock('@/config', async (importOriginal) => ({ + ...(await importOriginal()), + get ENABLE_HITL_IM() { + return releaseGate.enabled + }, +})) + +const { + buildResultRequestDataMock, + createWorkflowEventsSubscriptionMock, + createWorkflowStreamHandlersMock, + mockTrackEvent, + mockWebAppState, + sendCompletionMessageMock, + sendWorkflowMessageMock, + sleepMock, + validateResultRequestMock, + workflowEventsSubscription, +} = vi.hoisted(() => ({ + buildResultRequestDataMock: vi.fn(), + createWorkflowEventsSubscriptionMock: vi.fn(), + createWorkflowStreamHandlersMock: vi.fn(), + mockTrackEvent: vi.fn(), + mockWebAppState: { + appInfo: { + mode: 'completion', + }, + }, + sendCompletionMessageMock: vi.fn(), + sendWorkflowMessageMock: vi.fn(), + sleepMock: vi.fn(), + validateResultRequestMock: vi.fn(), + workflowEventsSubscription: { + start: vi.fn(), + prepare: vi.fn(), + dispose: vi.fn(), + }, +})) + +vi.mock('@/app/components/base/amplitude/web-app-event', () => ({ + trackWebAppEvent: mockTrackEvent, +})) + +vi.mock('@/context/web-app-context', () => ({ + useWebAppStore: (selector: (state: typeof mockWebAppState) => unknown) => + selector(mockWebAppState), +})) + +vi.mock('@/service/share', async () => { + const actual = await vi.importActual('@/service/share') + return { + ...actual, + sendCompletionMessage: (...args: Parameters) => + sendCompletionMessageMock(...args), + sendWorkflowMessage: (...args: Parameters) => + sendWorkflowMessageMock(...args), + } +}) + +vi.mock('@/utils', async () => { + const actual = await vi.importActual('@/utils') + return { + ...actual, + sleep: (...args: Parameters) => sleepMock(...args), + } +}) + +vi.mock('../../result-request', () => ({ + buildResultRequestData: (...args: unknown[]) => buildResultRequestDataMock(...args), + validateResultRequest: (...args: unknown[]) => validateResultRequestMock(...args), +})) + +vi.mock('../../workflow-stream-handlers', async () => ({ + ...(await vi.importActual( + '../../workflow-stream-handlers', + )), + createWorkflowStreamHandlers: (...args: unknown[]) => createWorkflowStreamHandlersMock(...args), +})) + +vi.mock('../../workflow-events-subscription', () => ({ + createWorkflowEventsSubscription: (...args: unknown[]) => + createWorkflowEventsSubscriptionMock(...args), +})) + +type RunStateHarness = { + state: { + completionRes: string + currentTaskId: string | null + messageId: string | null + workflowProcessData: ResultRunStateController['workflowProcessData'] + } + runState: ResultRunStateController +} + +type WorkflowHandlerOptions = Parameters[0] + +const createRunStateHarness = (): RunStateHarness => { + const state: RunStateHarness['state'] = { + completionRes: '', + currentTaskId: null, + messageId: null, + workflowProcessData: undefined, + } + + const runState: ResultRunStateController = { + abortControllerRef: { current: null }, + clearMoreLikeThis: vi.fn(), + completionRes: '', + controlClearMoreLikeThis: 0, + currentTaskId: null, + feedback: { rating: null }, + getCompletionRes: vi.fn(() => state.completionRes), + getWorkflowProcessData: vi.fn(() => state.workflowProcessData), + handleFeedback: vi.fn(), + handleStop: vi.fn(), + isResponding: false, + isStopping: false, + messageId: null, + prepareForNewRun: vi.fn(() => { + state.completionRes = '' + state.currentTaskId = null + state.messageId = null + state.workflowProcessData = undefined + runState.completionRes = '' + runState.currentTaskId = null + runState.messageId = null + runState.workflowProcessData = undefined + }), + resetRunState: vi.fn(() => { + state.currentTaskId = null + runState.currentTaskId = null + runState.isStopping = false + }), + setCompletionRes: vi.fn((value: string) => { + state.completionRes = value + runState.completionRes = value + }), + setCurrentTaskId: vi.fn((value) => { + state.currentTaskId = typeof value === 'function' ? value(state.currentTaskId) : value + runState.currentTaskId = state.currentTaskId + }), + setIsStopping: vi.fn((value) => { + runState.isStopping = typeof value === 'function' ? value(runState.isStopping) : value + }), + setMessageId: vi.fn((value) => { + state.messageId = typeof value === 'function' ? value(state.messageId) : value + runState.messageId = state.messageId + }), + setRespondingFalse: vi.fn(() => { + runState.isResponding = false + }), + setRespondingTrue: vi.fn(() => { + runState.isResponding = true + }), + setWorkflowProcessData: vi.fn((value) => { + state.workflowProcessData = value + runState.workflowProcessData = value + }), + workflowProcessData: undefined, + } + + return { + state, + runState, + } +} + +const promptConfig: PromptConfig = { + prompt_template: 'template', + prompt_variables: [{ key: 'name', name: 'Name', type: 'string', required: true }], +} + +const visionConfig: VisionSettings = { + enabled: false, + number_limits: 2, + detail: Resolution.low, + transfer_methods: [TransferMethod.local_file], +} + +type RenderSenderOptions = { + appSourceType?: AppSourceType + controlRetry?: number + controlSend?: number + inputs?: Record + isPC?: boolean + isWorkflow?: boolean + runState?: ResultRunStateController + taskId?: number +} + +const renderSender = ({ + appSourceType = AppSourceTypeEnum.webApp, + controlRetry = 0, + controlSend = 0, + inputs = { name: 'Alice' }, + isPC = true, + isWorkflow = false, + runState, + taskId, +}: RenderSenderOptions = {}) => { + const notify = vi.fn() + const onCompleted = vi.fn() + const onRunStart = vi.fn() + const onShowRes = vi.fn() + + const hook = renderHook( + (props: { controlRetry: number; controlSend: number }) => + useResultSender({ + appId: 'app-1', + appSourceType, + completionFiles: [], + controlRetry: props.controlRetry, + controlSend: props.controlSend, + inputs, + isCallBatchAPI: false, + isPC, + isWorkflow, + notify, + onCompleted, + onRunStart, + onShowRes, + promptConfig, + runState: runState || createRunStateHarness().runState, + t: withSelectorKey((key: string) => key) as TextGenerationTranslate, + taskId, + visionConfig, + }), + { + initialProps: { + controlRetry, + controlSend, + }, + }, + ) + + return { + ...hook, + notify, + onCompleted, + onRunStart, + onShowRes, + } +} + +describe('useResultSender', () => { + beforeEach(() => { + releaseGate.enabled = true + vi.clearAllMocks() + mockWebAppState.appInfo.mode = 'completion' + validateResultRequestMock.mockReturnValue({ canSend: true }) + buildResultRequestDataMock.mockReturnValue({ inputs: { name: 'Alice' } }) + createWorkflowStreamHandlersMock.mockReturnValue({ onWorkflowFinished: vi.fn() }) + createWorkflowEventsSubscriptionMock.mockReturnValue(workflowEventsSubscription) + workflowEventsSubscription.prepare.mockResolvedValue(true) + sendCompletionMessageMock.mockResolvedValue(undefined) + sendWorkflowMessageMock.mockResolvedValue(undefined) + sleepMock.mockImplementation(() => new Promise(() => {})) + }) + + it.each([AppSourceTypeEnum.installedApp, AppSourceTypeEnum.tryApp])( + 'should keep %s workflow handlers on the existing non-public path', + async (appSourceType) => { + const harness = createRunStateHarness() + + const { result } = renderSender({ + appSourceType, + isWorkflow: true, + runState: harness.runState, + }) + + await act(async () => { + expect(await result.current.handleSend()).toBe(true) + }) + + expect(createWorkflowStreamHandlersMock).toHaveBeenCalledWith( + expect.objectContaining({ + isPublicAPI: false, + }), + ) + expect(sendWorkflowMessageMock).toHaveBeenCalledWith( + { inputs: { name: 'Alice' } }, + expect.any(Object), + appSourceType, + 'app-1', + ) + expect(mockTrackEvent).not.toHaveBeenCalled() + expect(createWorkflowEventsSubscriptionMock).not.toHaveBeenCalled() + }, + ) + + it('should keep a paused approval available after the generation timeout has elapsed', async () => { + const harness = createRunStateHarness() + let expireGenerationTimeout: (() => void) | undefined + let markPausedTransportEnded: (() => void) | undefined + let isTimedOut: (() => boolean) | undefined + + sleepMock.mockImplementation( + () => + new Promise((resolve) => { + expireGenerationTimeout = resolve + }), + ) + createWorkflowStreamHandlersMock.mockImplementation((options: WorkflowHandlerOptions) => { + markPausedTransportEnded = options.markEnded + isTimedOut = options.isTimedOut + return {} + }) + const { result, onCompleted } = renderSender({ + isWorkflow: true, + runState: harness.runState, + }) + + await act(async () => { + await result.current.handleSend() + markPausedTransportEnded?.() + expireGenerationTimeout?.() + await Promise.resolve() + }) + + expect(isTimedOut?.()).toBe(false) + expect(harness.runState.resetRunState).not.toHaveBeenCalled() + expect(onCompleted).not.toHaveBeenCalled() + expect(workflowEventsSubscription.dispose).not.toHaveBeenCalled() + }) + + it('should release a public workflow subscription before a new run and on unmount', async () => { + const harness = createRunStateHarness() + let onPaused: ((workflowRunId: string, handlers: WorkflowEventsHandlers) => void) | undefined + createWorkflowStreamHandlersMock.mockImplementation((options: WorkflowHandlerOptions) => { + onPaused = options.onPaused + return { isPublicAPI: options.isPublicAPI } + }) + const { result, unmount } = renderSender({ + isWorkflow: true, + runState: harness.runState, + }) + + await act(async () => { + await result.current.handleSend() + }) + expect(createWorkflowEventsSubscriptionMock).toHaveBeenCalledOnce() + const streamHandlers: WorkflowEventsHandlers = { isPublicAPI: true } + onPaused?.('run-1', streamHandlers) + expect(workflowEventsSubscription.start).toHaveBeenCalledWith( + 'run-1', + expect.objectContaining({ + isPublicAPI: true, + getAbortController: expect.any(Function), + }), + ) + expect(await result.current.prepareHumanInputSubmission()).toBe(true) + expect(workflowEventsSubscription.prepare).toHaveBeenCalledOnce() + + harness.runState.isResponding = false + await act(async () => { + await result.current.handleSend() + }) + expect(workflowEventsSubscription.dispose).toHaveBeenCalledOnce() + expect(createWorkflowEventsSubscriptionMock).toHaveBeenCalledTimes(2) + + unmount() + expect(workflowEventsSubscription.dispose).toHaveBeenCalledTimes(2) + }) + + it('releases a stopped public run and lets the user start another run', async () => { + const harness = createRunStateHarness() + let onAbort: (() => void) | undefined + createWorkflowEventsSubscriptionMock.mockImplementation( + (options: Parameters[0]) => { + onAbort = options?.onAbort + return workflowEventsSubscription + }, + ) + const { result, onCompleted } = renderSender({ isWorkflow: true, runState: harness.runState }) + await act(async () => { + await result.current.handleSend() + }) + await act(async () => { + onAbort?.() + }) + expect(harness.runState.isResponding).toBe(false) + expect(harness.runState.getWorkflowProcessData()).toMatchObject({ + status: 'stopped', + humanInputFormDataList: [], + }) + expect(onCompleted).toHaveBeenCalledWith('', undefined, false) + await act(async () => { + expect(await result.current.handleSend()).toBe(true) + }) + expect(sendWorkflowMessageMock).toHaveBeenCalledTimes(2) + }) + it('uses the legacy public stream without creating an IM subscription when disabled', async () => { + releaseGate.enabled = false + const harness = createRunStateHarness() + const { result } = renderSender({ isWorkflow: true, runState: harness.runState }) + await act(async () => { + await result.current.handleSend() + }) + expect(createWorkflowStreamHandlersMock).toHaveBeenCalledWith( + expect.objectContaining({ isPublicAPI: true }), + ) + expect(createWorkflowStreamHandlersMock.mock.calls[0]?.[0].onPaused).toBeUndefined() + expect(createWorkflowEventsSubscriptionMock).not.toHaveBeenCalled() + expect(await result.current.prepareHumanInputSubmission()).toBe(true) + expect(workflowEventsSubscription.prepare).not.toHaveBeenCalled() + }) +}) diff --git a/web/app/components/share/text-generation/result/hooks/__tests__/use-webapp-workflow-events.spec.ts b/web/app/components/share/text-generation/result/hooks/__tests__/use-webapp-workflow-events.spec.ts new file mode 100644 index 00000000000..ff136f9dfe7 --- /dev/null +++ b/web/app/components/share/text-generation/result/hooks/__tests__/use-webapp-workflow-events.spec.ts @@ -0,0 +1,166 @@ +import type { TextGenerationTranslate } from '../../../types' +import type { WorkflowEventsHandlers } from '../../workflow-events-subscription' +import type { createWorkflowStreamHandlers } from '../../workflow-stream-handlers' +import type { WorkflowProcess } from '@/app/components/base/chat/types' +import type { + HumanInputFormData, + WorkflowFinishedResponse, + WorkflowPausedResponse, +} from '@/types/workflow' +import { act, renderHook } from '@testing-library/react' +import { WorkflowRunningStatus } from '@/app/components/workflow/types' +import { withSelectorKey } from '@/test/i18n-mock' +import { useWebAppWorkflowEvents } from '../use-webapp-workflow-events' + +const sseGetMock = vi.hoisted(() => + vi.fn<(url: string, options: object, handlers: WorkflowEventsHandlers) => Promise>(), +) +vi.mock('@/service/base', async () => ({ + ...(await vi.importActual('@/service/base')), + sseGet: sseGetMock, +})) + +const form: HumanInputFormData = { + form_id: 'form-1', + node_id: 'node-1', + node_title: 'Approval', + form_content: 'Please review', + inputs: [], + actions: [], + form_token: 'token-1', + resolved_default_values: {}, + display_in_ui: true, + expiration_time: 100, +} +const paused: WorkflowPausedResponse = { + event: 'workflow_paused', + task_id: 'task-1', + workflow_run_id: 'run-1', + data: { workflow_run_id: 'run-1', outputs: {}, paused_nodes: ['node-1'], reasons: [] }, +} +const finished: WorkflowFinishedResponse = { + event: 'workflow_finished', + task_id: 'task-1', + workflow_run_id: 'run-1', + data: { + id: 'run-1', + workflow_id: 'workflow-1', + status: 'succeeded', + outputs: { answer: 'Approved' }, + error: '', + elapsed_time: 1, + total_tokens: 0, + total_steps: 3, + created_at: 0, + finished_at: 1, + created_by: { id: 'user-1', name: 'User', email: 'user@example.com' }, + }, +} + +const continuation = () => { + const call = sseGetMock.mock.calls[0] + if (!call) throw new Error('Missing workflow continuation') + return call[2] +} + +const setup = () => { + let completionRes = '' + let process: WorkflowProcess | undefined + const abortControllerRef: { current: AbortController | null } = { current: null } + const setCompletionRes = vi.fn((value: string) => { + completionRes = value + }) + const setWorkflowProcessData = vi.fn((value: WorkflowProcess | undefined) => { + process = value + }) + const options: Parameters[0] = { + getCompletionRes: () => completionRes, + getWorkflowProcessData: () => process, + isPublicAPI: true, + isTimedOut: () => false, + markEnded: vi.fn(), + notify: vi.fn(), + onCompleted: vi.fn(), + resetRunState: vi.fn(), + setCompletionRes, + setCurrentTaskId: vi.fn(), + setIsStopping: vi.fn(), + setMessageId: vi.fn(), + setRespondingFalse: vi.fn(), + setWorkflowProcessData, + t: withSelectorKey((key: string) => key) as TextGenerationTranslate, + taskId: 3, + } + const hook = renderHook(() => useWebAppWorkflowEvents({ abortControllerRef, isResponding: true })) + const handlers = hook.result.current.createStreamHandlers(options) + act(() => { + handlers.onHumanInputRequired?.({ + event: 'human_input_required', + task_id: 'task-1', + workflow_run_id: 'run-1', + data: form, + }) + handlers.onWorkflowPaused?.(paused) + }) + return { ...hook, abortControllerRef, options, process: () => process } +} + +describe('Webapp Workflow approval owner', () => { + beforeEach(() => { + sseGetMock.mockResolvedValue(undefined) + }) + + it('keeps the pending approval while pausing the generation timeout', () => { + const subject = setup() + expect(subject.process()).toEqual( + expect.objectContaining({ + status: WorkflowRunningStatus.Paused, + humanInputFormDataList: [form], + }), + ) + expect(subject.options.markEnded).toHaveBeenCalledOnce() + expect(subject.options.onCompleted).not.toHaveBeenCalled() + expect(subject.options.resetRunState).not.toHaveBeenCalled() + expect(subject.options.setRespondingFalse).not.toHaveBeenCalled() + expect(sseGetMock).toHaveBeenCalledOnce() + subject.unmount() + }) + + it('applies a finished snapshot without a filled event and clears the stale approval', async () => { + const subject = setup() + const preparation = subject.result.current.prepareHumanInputSubmission() + act(() => continuation().onWorkflowFinished?.(finished)) + expect(await preparation).toBe(false) + expect(subject.process()).toEqual( + expect.objectContaining({ + status: WorkflowRunningStatus.Succeeded, + resultText: 'Approved', + humanInputFormDataList: [], + }), + ) + expect(subject.options.setMessageId).toHaveBeenCalledWith('run-1') + expect(subject.options.onCompleted).toHaveBeenCalledWith('{"answer":"Approved"}', 3, true) + expect(subject.options.setRespondingFalse).toHaveBeenCalledOnce() + expect(subject.options.resetRunState).toHaveBeenCalledOnce() + subject.unmount() + }) + + it('finishes the owner when the user aborts the continuation', async () => { + const subject = setup() + const controller = new AbortController() + act(() => continuation().getAbortController?.(controller)) + expect(subject.abortControllerRef.current).toBe(controller) + act(() => controller.abort()) + expect(subject.process()).toEqual( + expect.objectContaining({ + status: WorkflowRunningStatus.Stopped, + humanInputFormDataList: [], + }), + ) + expect(subject.options.onCompleted).toHaveBeenCalledWith('', 3, false) + expect(subject.options.setRespondingFalse).toHaveBeenCalledOnce() + expect(subject.options.resetRunState).toHaveBeenCalledOnce() + expect(await subject.result.current.prepareHumanInputSubmission()).toBe(false) + subject.unmount() + }) +}) diff --git a/web/app/components/share/text-generation/result/hooks/use-result-sender.ts b/web/app/components/share/text-generation/result/hooks/use-result-sender.ts index ce16701f165..283cc2cbd5e 100644 --- a/web/app/components/share/text-generation/result/hooks/use-result-sender.ts +++ b/web/app/components/share/text-generation/result/hooks/use-result-sender.ts @@ -5,7 +5,7 @@ import type { PromptConfig } from '@/models/debug' import type { VisionFile, VisionSettings } from '@/types/app' import { useCallback, useEffect, useRef } from 'react' import { trackWebAppEvent } from '@/app/components/base/amplitude/web-app-event' -import { TEXT_GENERATION_TIMEOUT_MS } from '@/config' +import { ENABLE_HITL_IM, TEXT_GENERATION_TIMEOUT_MS } from '@/config' import { useWebAppStore } from '@/context/web-app-context' import { captureAppAccessScope, @@ -16,6 +16,7 @@ import { AppSourceType, sendCompletionMessage, sendWorkflowMessage } from '@/ser import { sleep } from '@/utils' import { buildResultRequestData, validateResultRequest } from '../result-request' import { createWorkflowStreamHandlers } from '../workflow-stream-handlers' +import { useWebAppWorkflowEvents } from './use-webapp-workflow-events' type Notify = (payload: { type: 'error' | 'info' | 'warning'; message: string }) => void type UseResultSenderOptions = { @@ -66,6 +67,11 @@ export const useResultSender = ({ }: UseResultSenderOptions) => { const { clearMoreLikeThis } = runState const appMode = useWebAppStore((state) => state.appInfo?.mode) + const { + createStreamHandlers: createWebAppStreamHandlers, + prepareHumanInputSubmission, + reset: resetWebAppWorkflowEvents, + } = useWebAppWorkflowEvents(runState) const handleSend = useCallback(async () => { if (runState.isResponding) { @@ -96,6 +102,7 @@ export const useResultSender = ({ }) const appAccessScope = appSourceType === AppSourceType.webApp ? captureAppAccessScope() : null + resetWebAppWorkflowEvents() runState.prepareForNewRun() if (!isPC) { @@ -136,7 +143,11 @@ export const useResultSender = ({ })() if (isWorkflow) { - const otherOptions = createWorkflowStreamHandlers({ + const createStreamHandlers = + ENABLE_HITL_IM && appSourceType === AppSourceType.webApp + ? createWebAppStreamHandlers + : createWorkflowStreamHandlers + const otherOptions = createStreamHandlers({ getCompletionRes: runState.getCompletionRes, getWorkflowProcessData: runState.getWorkflowProcessData, isPublicAPI: appSourceType === AppSourceType.webApp, @@ -226,6 +237,7 @@ export const useResultSender = ({ appMode, appSourceType, completionFiles, + createWebAppStreamHandlers, inputs, isCallBatchAPI, isPC, @@ -235,6 +247,7 @@ export const useResultSender = ({ onRunStart, onShowRes, promptConfig, + resetWebAppWorkflowEvents, runState, t, taskId, @@ -262,5 +275,6 @@ export const useResultSender = ({ return { handleSend, + prepareHumanInputSubmission, } } diff --git a/web/app/components/share/text-generation/result/hooks/use-webapp-workflow-events.ts b/web/app/components/share/text-generation/result/hooks/use-webapp-workflow-events.ts new file mode 100644 index 00000000000..521080b460e --- /dev/null +++ b/web/app/components/share/text-generation/result/hooks/use-webapp-workflow-events.ts @@ -0,0 +1,72 @@ +import type { ResultRunStateController } from './use-result-run-state' +import { useCallback, useEffect, useRef } from 'react' +import { WorkflowRunningStatus } from '@/app/components/workflow/types' +import { createWorkflowEventsSubscription } from '../workflow-events-subscription' +import { + applyWorkflowFinishedState, + createWorkflowStreamHandlers, +} from '../workflow-stream-handlers' + +export const useWebAppWorkflowEvents = ({ + abortControllerRef, + isResponding, +}: Pick) => { + const subscriptionRef = useRef | null>(null) + + useEffect(() => { + if (!isResponding) subscriptionRef.current?.dispose() + }, [isResponding]) + useEffect(() => () => subscriptionRef.current?.dispose(), []) + + const reset = useCallback(() => { + subscriptionRef.current?.dispose() + subscriptionRef.current = null + }, []) + const prepareHumanInputSubmission = useCallback( + () => subscriptionRef.current?.prepare() ?? Promise.resolve(true), + [], + ) + + const createStreamHandlers = useCallback( + (options: Parameters[0]) => { + const subscription = createWorkflowEventsSubscription({ + onAbort: () => { + options.markEnded() + options.setWorkflowProcessData({ + ...applyWorkflowFinishedState( + options.getWorkflowProcessData(), + WorkflowRunningStatus.Stopped, + ), + humanInputFormDataList: [], + }) + options.setRespondingFalse() + options.resetRunState() + options.onCompleted(options.getCompletionRes(), options.taskId, false) + }, + }) + subscriptionRef.current = subscription + const handlers = createWorkflowStreamHandlers({ + ...options, + onPaused: (runId, streamHandlers) => { + options.markEnded() + subscription.start(runId, { + ...streamHandlers, + getAbortController: (controller) => { + abortControllerRef.current = controller + }, + }) + }, + }) + const onWorkflowFinished = handlers.onWorkflowFinished + handlers.onWorkflowFinished = (event) => { + const current = options.getWorkflowProcessData() + if (current) options.setWorkflowProcessData({ ...current, humanInputFormDataList: [] }) + onWorkflowFinished?.(event) + } + return handlers + }, + [abortControllerRef], + ) + + return { createStreamHandlers, prepareHumanInputSubmission, reset } +} diff --git a/web/app/components/share/text-generation/result/index.tsx b/web/app/components/share/text-generation/result/index.tsx index e4a4876b9b9..f3805d0cb4f 100644 --- a/web/app/components/share/text-generation/result/index.tsx +++ b/web/app/components/share/text-generation/result/index.tsx @@ -85,7 +85,7 @@ const Result: FC = ({ notify, onRunControlChange, }) - const { handleSend } = useResultSender({ + const { handleSend, prepareHumanInputSubmission } = useResultSender({ appId, appSourceType, completionFiles, @@ -127,6 +127,7 @@ const Result: FC = ({ workflowProcessData={runState.workflowProcessData} isError={isError} onRetry={handleSend} + prepareHumanInputSubmission={prepareHumanInputSubmission} content={runState.completionRes} messageId={runState.messageId} isInWebApp diff --git a/web/app/components/share/text-generation/result/workflow-events-subscription.ts b/web/app/components/share/text-generation/result/workflow-events-subscription.ts new file mode 100644 index 00000000000..0426d78c816 --- /dev/null +++ b/web/app/components/share/text-generation/result/workflow-events-subscription.ts @@ -0,0 +1,150 @@ +// oxlint-disable-next-line no-restricted-imports -- Keep subscription handlers tied to the SSE transport's callbacks and WebApp authentication options. +import type { IOtherOptions } from '@/service/base' +// oxlint-disable-next-line no-restricted-imports -- Workflow continuation is an existing SSE transport, not a generated JSON API. +import { sseGet } from '@/service/base' + +export type WorkflowEventsHandlers = IOtherOptions + +export const createWorkflowEventsSubscription = ({ onAbort }: { onAbort?: () => void } = {}) => { + let workflowRunId: string | null = null + let handlers: WorkflowEventsHandlers = {} + let abortController: AbortController | null = null + let active = false + let ready = false + let paused = false + let terminal = false + let disposed = false + let retriedDisconnect = false + let generation = 0 + let waiters: Array<(ready: boolean) => void> = [] + + const resolveWaiters = (isReady: boolean) => { + const pending = waiters + waiters = [] + pending.forEach((resolve) => resolve(isReady)) + } + + const dispose = () => { + if (disposed) return + disposed = true + generation += 1 + active = false + ready = false + resolveWaiters(false) + abortController?.abort() + abortController = null + } + + const subscribe = () => { + if (active || terminal || disposed || !workflowRunId) return + active = true + ready = false + const currentGeneration = ++generation + const isCurrent = () => !disposed && generation === currentGeneration && active + const guard = (handler: ((...args: T) => unknown) | undefined) => + handler + ? (...args: T) => { + if (isCurrent()) handler(...args) + } + : undefined + + const onDisconnected = () => { + if (!isCurrent()) return + active = false + ready = false + if (!terminal && !paused && !retriedDisconnect) { + retriedDisconnect = true + subscribe() + } else { + resolveWaiters(false) + } + } + + const options: WorkflowEventsHandlers = { + ...handlers, + onWorkflowStarted: guard(handlers.onWorkflowStarted), + onIterationStart: guard(handlers.onIterationStart), + onIterationNext: guard(handlers.onIterationNext), + onIterationFinish: guard(handlers.onIterationFinish), + onLoopStart: guard(handlers.onLoopStart), + onLoopNext: guard(handlers.onLoopNext), + onLoopFinish: guard(handlers.onLoopFinish), + onNodeStarted: guard(handlers.onNodeStarted), + onNodeFinished: guard(handlers.onNodeFinished), + onTextChunk: guard(handlers.onTextChunk), + onTextReplace: guard(handlers.onTextReplace), + onHumanInputRequired: guard(handlers.onHumanInputRequired), + onHumanInputFormFilled: guard(handlers.onHumanInputFormFilled), + onHumanInputFormTimeout: guard(handlers.onHumanInputFormTimeout), + onWorkflowPaused: (event) => { + if (!isCurrent()) return + paused = true + ready = true + handlers.onWorkflowPaused?.(event) + resolveWaiters(true) + }, + onWorkflowFinished: (event) => { + if (!isCurrent()) return + terminal = true + ready = false + handlers.onWorkflowFinished?.(event) + resolveWaiters(false) + }, + getAbortController: (controller) => { + if (!isCurrent()) { + controller.abort() + return + } + abortController = controller + controller.signal.addEventListener( + 'abort', + () => { + if (disposed) return + dispose() + onAbort?.() + }, + { once: true }, + ) + handlers.getAbortController?.(controller) + }, + onCompleted: onDisconnected, + onError: () => { + // A failed read before the pause snapshot must block the write and be retryable. + if (waiters.length) retriedDisconnect = true + onDisconnected() + }, + } + // oxlint-disable-next-line typescript/no-deprecated -- Generated events contracts return a complete string and omit the pause/snapshot query; this subscription needs incremental SSE with WebApp passport auth and abort control. + void sseGet( + `/workflow/${workflowRunId}/events?include_state_snapshot=true&continue_on_pause=true`, + {}, + options, + ).catch(options.onError) + } + + return { + start: (runId: string, options: WorkflowEventsHandlers) => { + workflowRunId = runId + handlers = options + subscribe() + }, + prepare: async () => { + if (disposed || terminal || !workflowRunId) return false + retriedDisconnect = false + if (ready) { + paused = false + return true + } + const isReady = await new Promise((resolve) => { + waiters.push(resolve) + subscribe() + }) + if (isReady && active && ready) { + paused = false + return true + } + return false + }, + dispose, + } +} diff --git a/web/app/components/share/text-generation/result/workflow-stream-handlers.ts b/web/app/components/share/text-generation/result/workflow-stream-handlers.ts index f7dc8aaa8b4..cf05cff4ece 100644 --- a/web/app/components/share/text-generation/result/workflow-stream-handlers.ts +++ b/web/app/components/share/text-generation/result/workflow-stream-handlers.ts @@ -27,6 +27,7 @@ type CreateWorkflowStreamHandlersParams = { markEnded: () => void notify: Notify onCompleted: (completionRes: string, taskId?: number, success?: boolean) => void + onPaused?: (workflowRunId: string, handlers: IOtherOptions) => void resetRunState: () => void setCompletionRes: (res: string) => void setCurrentTaskId: Dispatch> @@ -252,6 +253,7 @@ export const createWorkflowStreamHandlers = ({ markEnded, notify, onCompleted, + onPaused, resetRunState, setCompletionRes, setCurrentTaskId, @@ -407,6 +409,11 @@ export const createWorkflowStreamHandlers = ({ }, onWorkflowPaused: ({ data }) => { tempMessageId = data.workflow_run_id + if (onPaused) { + setWorkflowProcessData(applyWorkflowPaused(getWorkflowProcessData())) + onPaused(data.workflow_run_id, otherOptions) + return + } if (!hasStartedResumeStream) { hasStartedResumeStream = true void sseGet( diff --git a/web/app/components/snippets/components/snippet-main.tsx b/web/app/components/snippets/components/snippet-main.tsx index a9e35baba6c..966fc73ccbf 100644 --- a/web/app/components/snippets/components/snippet-main.tsx +++ b/web/app/components/snippets/components/snippet-main.tsx @@ -54,6 +54,7 @@ type SnippetMainContentProps = { const unsupportedSnippetBlockTypes = new Set([ BlockEnum.HumanInput, + BlockEnum.HumanInputV2, BlockEnum.End, BlockEnum.KnowledgeRetrieval, BlockEnum.KnowledgeRetrievalV2, @@ -168,6 +169,7 @@ const SnippetMain = ({ draftPayload, draftNodes, draftEdges, draftViewport }: Sn const { [BlockEnum.HumanInput]: _humanInput, + [BlockEnum.HumanInputV2]: _humanInputV2, [BlockEnum.End]: _end, [BlockEnum.KnowledgeRetrieval]: _knowledgeRetrieval, [BlockEnum.KnowledgeRetrievalV2]: _knowledgeRetrievalV2, diff --git a/web/app/components/workflow-app/components/workflow-main.tsx b/web/app/components/workflow-app/components/workflow-main.tsx index b9578fd05fe..1022f52909e 100644 --- a/web/app/components/workflow-app/components/workflow-main.tsx +++ b/web/app/components/workflow-app/components/workflow-main.tsx @@ -19,6 +19,7 @@ import { useSetWorkflowVarsWithValue } from '@/app/components/workflow/hooks/use import { useInspectVarsCrudCommon as useInspectVarsCrud } from '@/app/components/workflow/hooks/use-inspect-vars-crud-common' import { useWorkflowUpdate } from '@/app/components/workflow/hooks/use-workflow-update' import { useWorkflowNodeCatalog } from '@/app/components/workflow/node-catalog' +import HumanInputMigrationProvider from '@/app/components/workflow/nodes/human-input-v2/migration/provider' import { useWorkflowStore } from '@/app/components/workflow/store' import { SupportUploadFileTypes } from '@/app/components/workflow/types' import { workspacePermissionKeysAtom } from '@/context/permission-state' @@ -540,7 +541,9 @@ const WorkflowMain = ({ nodes, edges, viewport }: WorkflowMainProps) => { myUserId={myUserId} onlineUsers={onlineUsers} > - + + + {isCollaborationEnabled && (collaborationGraphState.appId !== appId || !collaborationGraphState.isReady) && ( diff --git a/web/app/components/workflow/__tests__/node-catalog.spec.ts b/web/app/components/workflow/__tests__/node-catalog.spec.ts index a3b03a274dc..395a8e29950 100644 --- a/web/app/components/workflow/__tests__/node-catalog.spec.ts +++ b/web/app/components/workflow/__tests__/node-catalog.spec.ts @@ -1,7 +1,10 @@ -import { renderWorkflowHook } from '@/app/components/workflow/__tests__/workflow-test-env' +import { act } from '@testing-library/react' +import { renderWorkflowCanvasHook } from '@/app/components/workflow/__tests__/workflow-test-env' import { useWorkflowNodeCatalog } from '@/app/components/workflow/node-catalog' -import { BlockEnum } from '@/app/components/workflow/types' +import { createDefaultFormInputByType } from '@/app/components/workflow/nodes/human-input/types' +import { BlockEnum, InputVarType } from '@/app/components/workflow/types' import { createConsoleQueryWrapper } from '@/test/console/query-data' +import { createNode } from './fixtures' // The node catalog reads KnowledgeFS availability from the console features query. const catalogQueryClient = ({ enabled = true } = {}) => @@ -9,6 +12,14 @@ const catalogQueryClient = ({ enabled = true } = {}) => const mockIsAgentV2Enabled = vi.hoisted(() => vi.fn(() => true)) const mockIsAgentV2InChatflowEnabled = vi.hoisted(() => vi.fn(() => false)) +const releaseGate = vi.hoisted(() => ({ enabled: true })) + +vi.mock('@/config', async (importOriginal) => ({ + ...(await importOriginal()), + get ENABLE_HITL_IM() { + return releaseGate.enabled + }, +})) vi.mock('@/features/agent-v2/feature-flag', () => ({ isAgentV2Enabled: () => mockIsAgentV2Enabled(), @@ -24,10 +35,39 @@ describe('useWorkflowNodeCatalog', () => { vi.clearAllMocks() mockIsAgentV2Enabled.mockReturnValue(true) mockIsAgentV2InChatflowEnabled.mockReturnValue(false) + releaseGate.enabled = true }) + it.each(['workflow', 'advanced-chat'] as const)( + 'offers the original Human Input candidate in %s when the release gate is off', + (mode) => { + releaseGate.enabled = false + const { result, setCanvas } = renderWorkflowCanvasHook(() => useWorkflowNodeCatalog(), { + queryClient: catalogQueryClient(), + resource: { kind: 'app', appId: 'app-1', mode }, + }) + const candidate = () => + result.current.nodes.filter( + (node) => + node.metaData.type === BlockEnum.HumanInput || + node.metaData.type === BlockEnum.HumanInputV2, + ) + + expect(candidate().map((node) => node.metaData.type)).toEqual([BlockEnum.HumanInput]) + expect(candidate()[0]?.defaultValue).toMatchObject({ delivery_methods: [], timeout: 3 }) + expect(candidate()[0]?.defaultValue).not.toHaveProperty('version') + + act(() => setCanvas({ nodes: [createNode({ data: { type: BlockEnum.HumanInput } })] })) + expect(candidate().map((node) => node.metaData.type)).toEqual([BlockEnum.HumanInput]) + expect(result.current.nodesMap?.[BlockEnum.HumanInputV2]?.defaultValue).toMatchObject({ + type: BlockEnum.HumanInput, + version: '2', + }) + }, + ) + it('should include chat-specific nodes and make the start node undeletable in chat mode', () => { - const { result } = renderWorkflowHook(() => useWorkflowNodeCatalog(), { + const { result } = renderWorkflowCanvasHook(() => useWorkflowNodeCatalog(), { queryClient: catalogQueryClient(), resource: { kind: 'app', @@ -49,7 +89,7 @@ describe('useWorkflowNodeCatalog', () => { }) it('should expose only legacy Agent in chat mode by default even when Agent v2 is enabled', () => { - const { result } = renderWorkflowHook(() => useWorkflowNodeCatalog(), { + const { result } = renderWorkflowCanvasHook(() => useWorkflowNodeCatalog(), { queryClient: catalogQueryClient(), resource: { kind: 'app', @@ -68,7 +108,7 @@ describe('useWorkflowNodeCatalog', () => { it('should expose Agent v2 in chat mode when the Chatflow gate is enabled', () => { mockIsAgentV2InChatflowEnabled.mockReturnValue(true) - const { result } = renderWorkflowHook(() => useWorkflowNodeCatalog(), { + const { result } = renderWorkflowCanvasHook(() => useWorkflowNodeCatalog(), { queryClient: catalogQueryClient(), resource: { kind: 'app', @@ -88,7 +128,7 @@ describe('useWorkflowNodeCatalog', () => { mockIsAgentV2Enabled.mockReturnValue(false) mockIsAgentV2InChatflowEnabled.mockReturnValue(true) - const { result } = renderWorkflowHook(() => useWorkflowNodeCatalog(), { + const { result } = renderWorkflowCanvasHook(() => useWorkflowNodeCatalog(), { queryClient: catalogQueryClient(), resource: { kind: 'app', @@ -105,7 +145,7 @@ describe('useWorkflowNodeCatalog', () => { }) it('should include workflow-specific trigger and end nodes outside chat mode', () => { - const { result } = renderWorkflowHook(() => useWorkflowNodeCatalog(), { + const { result } = renderWorkflowCanvasHook(() => useWorkflowNodeCatalog(), { queryClient: catalogQueryClient(), resource: { kind: 'app', @@ -125,10 +165,85 @@ describe('useWorkflowNodeCatalog', () => { type: BlockEnum.Start, title: 'workflow.blocks.start', }) + expect(result.current.nodesMap?.[BlockEnum.HumanInput]).toBeDefined() + expect(result.current.nodesMap?.[BlockEnum.HumanInputV2]?.defaultValue).toMatchObject({ + type: BlockEnum.HumanInput, + version: '2', + recipients_spec: [], + }) }) + it('exposes one v2-backed Human Input candidate while retaining legacy routing metadata', () => { + const { result } = renderWorkflowCanvasHook(() => useWorkflowNodeCatalog(), { + queryClient: catalogQueryClient(), + }) + const candidates = result.current.nodes.filter( + (node) => + node.metaData.type === BlockEnum.HumanInput || + node.metaData.type === BlockEnum.HumanInputV2, + ) + + expect(candidates).toHaveLength(1) + expect(candidates[0]?.metaData).toMatchObject({ + type: BlockEnum.HumanInputV2, + title: 'workflow.blocks.human-input', + }) + expect(candidates[0]?.defaultValue).toMatchObject({ + type: BlockEnum.HumanInput, + version: '2', + }) + expect(result.current.nodesMap?.[BlockEnum.HumanInput]?.defaultValue).toHaveProperty( + 'delivery_methods', + ) + }) + + it.each([InputVarType.select, InputVarType.singleFile, InputVarType.multiFiles])( + 'keeps supported legacy %s fields on the v2 migration path', + (type) => { + const legacy = createNode({ + data: { + type: BlockEnum.HumanInput, + inputs: [createDefaultFormInputByType(type, 'decision')], + }, + }) + const { result, setCanvas } = renderWorkflowCanvasHook(() => useWorkflowNodeCatalog(), { + nodes: [legacy], + queryClient: catalogQueryClient(), + }) + const humanInputCandidates = () => + result.current.nodes.filter( + (node) => + node.metaData.type === BlockEnum.HumanInput || + node.metaData.type === BlockEnum.HumanInputV2, + ) + + expect(humanInputCandidates().map((node) => node.metaData.type)).toEqual([ + BlockEnum.HumanInputV2, + ]) + expect(humanInputCandidates()[0]?.defaultValue).toMatchObject({ version: '2' }) + + act(() => + setCanvas({ + nodes: [ + createNode({ + ...legacy, + data: { + ...legacy.data, + version: '2', + }, + }), + ], + }), + ) + + expect(humanInputCandidates().map((node) => node.metaData.type)).toEqual([ + BlockEnum.HumanInputV2, + ]) + }, + ) + it('should use explicit docs pages and skip nodes without generated docs pages', () => { - const { result } = renderWorkflowHook(() => useWorkflowNodeCatalog(), { + const { result } = renderWorkflowCanvasHook(() => useWorkflowNodeCatalog(), { queryClient: catalogQueryClient(), resource: { kind: 'app', @@ -151,7 +266,7 @@ describe('useWorkflowNodeCatalog', () => { }) it('should hide legacy Agent from the node picker but retain its validator when Agent v2 is enabled', () => { - const { result } = renderWorkflowHook(() => useWorkflowNodeCatalog(), { + const { result } = renderWorkflowCanvasHook(() => useWorkflowNodeCatalog(), { queryClient: catalogQueryClient(), resource: { kind: 'app', @@ -173,7 +288,7 @@ describe('useWorkflowNodeCatalog', () => { it('should expose only legacy Agent while retaining Agent v2 metadata when Agent v2 is disabled', () => { mockIsAgentV2Enabled.mockReturnValue(false) - const { result } = renderWorkflowHook(() => useWorkflowNodeCatalog(), { + const { result } = renderWorkflowCanvasHook(() => useWorkflowNodeCatalog(), { queryClient: catalogQueryClient(), resource: { kind: 'app', @@ -193,7 +308,7 @@ describe('useWorkflowNodeCatalog', () => { }) it('should hide Knowledge Retrieval v2 when KnowledgeFS is disabled', () => { - const { result } = renderWorkflowHook(() => useWorkflowNodeCatalog(), { + const { result } = renderWorkflowCanvasHook(() => useWorkflowNodeCatalog(), { queryClient: catalogQueryClient({ enabled: false }), resource: { kind: 'app', appId: 'app-1', mode: 'workflow' }, }) diff --git a/web/app/components/workflow/block-icon.tsx b/web/app/components/workflow/block-icon.tsx index c8257f266f7..d9967b873e4 100644 --- a/web/app/components/workflow/block-icon.tsx +++ b/web/app/components/workflow/block-icon.tsx @@ -59,6 +59,7 @@ const DEFAULT_ICON_CLASS_MAP: Record = { [BlockEnum.TriggerWebhook]: 'i-custom-vender-workflow-webhook-line', [BlockEnum.TriggerPlugin]: 'i-custom-vender-workflow-variable-x', [BlockEnum.HumanInput]: 'i-custom-vender-workflow-human-in-loop', + [BlockEnum.HumanInputV2]: 'i-custom-vender-workflow-human-in-loop', } const getIcon = (type: BlockEnum, className: string) => { @@ -103,6 +104,7 @@ const ICON_CONTAINER_BG_COLOR_MAP: Record = { [BlockEnum.Agent]: 'bg-util-colors-indigo-indigo-500', [BlockEnum.AgentV2]: 'bg-util-colors-indigo-indigo-500', [BlockEnum.HumanInput]: 'bg-util-colors-cyan-cyan-500', + [BlockEnum.HumanInputV2]: 'bg-util-colors-cyan-cyan-500', [BlockEnum.KnowledgeBase]: 'bg-util-colors-warning-warning-500', [BlockEnum.DataSource]: 'bg-components-icon-bg-midnight-solid', [BlockEnum.TriggerSchedule]: 'bg-util-colors-violet-violet-500', diff --git a/web/app/components/workflow/block-selector/__tests__/blocks.spec.tsx b/web/app/components/workflow/block-selector/__tests__/blocks.spec.tsx index 3e93d3792fb..db76ced26fb 100644 --- a/web/app/components/workflow/block-selector/__tests__/blocks.spec.tsx +++ b/web/app/components/workflow/block-selector/__tests__/blocks.spec.tsx @@ -2,7 +2,7 @@ import type { AgentInviteOptionResponse, AgentInviteOptionsResponse, } from '@dify/contracts/api/console/agent/types.gen' -import type { ReactElement } from 'react' +import type { ReactElement, ReactNode } from 'react' import type { Node as FlowNode } from 'reactflow' import type { NodeDefault } from '../../types' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' @@ -12,6 +12,7 @@ import ReactFlow, { ReactFlowProvider, useNodes } from 'reactflow' import { EditorResourceScope } from '../../editor-resource/provider' import { HooksStoreContext } from '../../hooks-store/provider' import { createHooksStore } from '../../hooks-store/store' +import { HumanInputMigrationContext } from '../../nodes/human-input-v2/migration/context' import { BlockEnum } from '../../types' import Blocks from '../blocks' import { BlockClassification } from '../types' @@ -185,6 +186,123 @@ describe('Blocks', () => { expect(screen.getByText('workflow.tabs.noResult')).toBeInTheDocument() }) + it('keeps Human Input searchable but disables it while a legacy node exists', async () => { + const user = userEvent.setup() + const onSelect = vi.fn() + const openMigrationDialog = vi.fn() + const contextValue = { + policy: { + hasLegacyHumanInput: true, + canAddHumanInputV2: false, + canAddLegacyHumanInput: false, + candidateCount: 1 as const, + }, + canEdit: true, + pending: false, + openMigrationDialog, + } + runtimeState.nodes = [ + { + id: 'legacy', + position: { x: 0, y: 0 }, + data: { type: BlockEnum.HumanInput, title: 'Human Input', desc: '' }, + }, + ] + + const { rerender } = render( + + + , + ) + + const humanInputButton = screen.getByRole('button', { name: 'Human Input' }) + expect(humanInputButton).toHaveAttribute('aria-disabled', 'true') + expect(humanInputButton).toHaveAccessibleDescription( + expect.stringContaining('workflow.nodes.humanInputMigration.disabledReason'), + ) + expect(screen.getByText('workflow.nodes.humanInputMigration.disabledBadge')).toBeInTheDocument() + + await user.click(humanInputButton) + expect(onSelect).not.toHaveBeenCalled() + await user.hover(humanInputButton) + const migrateNow = await screen.findByRole('button', { + name: /workflow.nodes.humanInputMigration.action.migrateNow/, + }) + expect( + screen.getByText('workflow.nodes.humanInputMigration.preview.author'), + ).toBeInTheDocument() + expect( + screen.getByText('workflow.nodes.humanInputMigration.preview.description'), + ).toBeInTheDocument() + await user.click(migrateNow) + expect(openMigrationDialog).toHaveBeenCalledTimes(1) + + runtimeState.nodes = [] + rerender( + + + , + ) + const enabledButton = screen.getByRole('button', { name: 'Human Input' }) + expect(enabledButton).toHaveAttribute('aria-disabled', 'false') + await user.click(enabledButton) + expect(onSelect).toHaveBeenCalledWith(BlockEnum.HumanInputV2) + }) + + it('keeps migration preview guidance read-only when editing is unavailable', async () => { + const user = userEvent.setup() + runtimeState.nodes = [ + { + id: 'legacy', + position: { x: 0, y: 0 }, + data: { type: BlockEnum.HumanInput, title: 'Human Input', desc: '' }, + }, + ] + + render( + + + , + ) + + await user.hover(screen.getByRole('button', { name: 'Human Input' })) + expect( + await screen.findByText('workflow.nodes.humanInputMigration.preview.description'), + ).toBeInTheDocument() + expect( + screen.queryByRole('button', { + name: /workflow.nodes.humanInputMigration.action.migrateNow/, + }), + ).not.toBeInTheDocument() + }) + it('opens the agent selector on Agent block hover', async () => { const user = userEvent.setup() mockInviteOptionsResponse([]) @@ -450,18 +568,20 @@ describe('Blocks', () => { }) }) -function ReadyBlocks({ children }: { children: ReactElement }) { +function ReadyBlocks({ children }: { children: ReactNode }) { const nodes = useNodes() return nodes.length === runtimeState.nodes.length ? children : null } function render(ui: ReactElement) { - return baseRender( - - - - {ui} - - , - ) + return baseRender(ui, { + wrapper: ({ children }) => ( + + + + {children} + + + ), + }) } diff --git a/web/app/components/workflow/block-selector/blocks.tsx b/web/app/components/workflow/block-selector/blocks.tsx index 14c290ac3f0..552429360ce 100644 --- a/web/app/components/workflow/block-selector/blocks.tsx +++ b/web/app/components/workflow/block-selector/blocks.tsx @@ -1,5 +1,7 @@ import type { NodeDefault, OnSelectBlock } from '../types' import type { BlockClassification } from './types' +import { Button } from '@langgenius/dify-ui/button' +import { cn } from '@langgenius/dify-ui/cn' import { createPreviewCardHandle, PreviewCard, @@ -11,6 +13,8 @@ import { useTranslation } from 'react-i18next' import { useStoreApi } from 'reactflow' import Badge from '@/app/components/base/badge' import BlockIcon from '../block-icon' +import { useHumanInputMigration } from '../nodes/human-input-v2/migration/context' +import { getHumanInputCreationPolicy } from '../nodes/human-input-v2/migration/policy' import { BlockEnum } from '../types' import { AgentBlockItem } from './agent-selector' import { BLOCK_CLASSIFICATIONS } from './constants' @@ -25,6 +29,7 @@ type BlocksProps = { } type BlockPreviewPayload = { block: NodeDefault + disabled: boolean } const Blocks = ({ @@ -98,6 +103,7 @@ const Blocks = ({ }) const { getNodes } = store.getState() const nodes = getNodes() + const humanInputPolicy = getHumanInputCreationPolicy(nodes, true) const hasKnowledgeBaseNode = nodes.some((node) => node.data.type === BlockEnum.KnowledgeBase) const filteredList = list.filter((block) => { if (hasKnowledgeBaseNode) return block.metaData.type !== BlockEnum.KnowledgeBase @@ -143,6 +149,12 @@ const Blocks = ({ const previewDescriptionId = block.metaData.description ? `${previewDescriptionBaseId}-${block.metaData.type}` : undefined + const isHumanInputDisabled = + block.metaData.type === BlockEnum.HumanInputV2 && humanInputPolicy.hasLegacyHumanInput + const disabledReasonId = isHumanInputDisabled + ? `${previewDescriptionBaseId}-${block.metaData.type}-disabled` + : undefined + const describedBy = [previewDescriptionId, disabledReasonId].filter(Boolean).join(' ') return ( @@ -150,30 +162,52 @@ const Blocks = ({ delay={150} closeDelay={150} handle={previewCardHandle} - payload={{ block }} + payload={{ block, disabled: isHumanInputDisabled }} render={ @@ -184,6 +218,11 @@ const Blocks = ({ {block.metaData.description} )} + {disabledReasonId && ( + + {t(($) => $['nodes.humanInputMigration.disabledReason'], { ns: 'workflow' })} + + )} ) })} @@ -213,9 +252,55 @@ type BlockPreviewCardProps = { } function BlockPreviewCard({ payload }: BlockPreviewCardProps) { + const { t } = useTranslation(['workflow']) + const migration = useHumanInputMigration() if (!payload) return null - const { block } = payload + const { block, disabled } = payload + + if (disabled) { + return ( + +
+
+
+ +
{block.metaData.title}
+
+
+ {block.metaData.description} +
+
+ {t(($) => $['nodes.humanInputMigration.preview.author'], { ns: 'workflow' })} +
+
+
+
+ +
+ {t(($) => $['nodes.humanInputMigration.preview.description'], { ns: 'workflow' })} +
+
+ {migration?.canEdit && ( + + )} +
+
+
+ ) + } return ( diff --git a/web/app/components/workflow/block-selector/snippets/__tests__/use-insert-snippet.hitl-v2.spec.tsx b/web/app/components/workflow/block-selector/snippets/__tests__/use-insert-snippet.hitl-v2.spec.tsx new file mode 100644 index 00000000000..5729e8c3a29 --- /dev/null +++ b/web/app/components/workflow/block-selector/snippets/__tests__/use-insert-snippet.hitl-v2.spec.tsx @@ -0,0 +1,213 @@ +import type { SnippetWorkflowResponse } from '@dify/contracts/api/console/snippets/types.gen' +import type { QueryClient } from '@tanstack/react-query' +import { act, renderHook } from '@testing-library/react' +import { consoleQuery } from '@/service/console' +import { createQueryClientWrapper } from '@/test/console/query-client' +import { createTestQueryClient } from '@/test/query-client' +import HumanInputDefault from '../../../nodes/human-input/default' +import { createDefaultFormInputByType } from '../../../nodes/human-input/types' +import { InputVarType } from '../../../types' +import { useInsertSnippet } from '../use-insert-snippet' + +const mockRequest = vi.hoisted(() => vi.fn()) +const releaseGate = vi.hoisted(() => ({ enabled: true })) + +vi.mock('@/config', async (importOriginal) => ({ + ...(await importOriginal()), + get ENABLE_HITL_IM() { + return releaseGate.enabled + }, +})) +const mockHandleSyncWorkflowDraft = vi.fn() +const mockSaveStateToHistory = vi.fn() +const mockToastError = vi.fn() +const mockToastWarning = vi.fn() +const mockGetNodes = vi.fn() +const mockSetNodes = vi.fn() +const mockSetEdges = vi.fn() +const mockIncrementSnippetUseCount = vi.fn() +let mockEdges: unknown[] = [{ id: 'existing-edge', source: 'old', target: 'old-2' }] + +vi.mock('@/service/base', () => ({ + request: (...args: unknown[]) => mockRequest(...args), + sseGeneratorPost: vi.fn(), +})) + +vi.mock('reactflow', () => ({ + useStoreApi: () => ({ + getState: () => ({ + getNodes: mockGetNodes, + setNodes: mockSetNodes, + edges: mockEdges, + setEdges: mockSetEdges, + }), + }), +})) + +vi.mock('../../../hooks/use-nodes-sync-draft', () => ({ + useNodesSyncDraft: () => ({ + handleSyncWorkflowDraft: mockHandleSyncWorkflowDraft, + }), +})) + +vi.mock('../../../hooks/use-workflow-history', () => ({ + useWorkflowHistory: () => ({ + saveStateToHistory: mockSaveStateToHistory, + }), + WorkflowHistoryEvent: { + NodePaste: 'NodePaste', + }, +})) + +vi.mock('@/app/notifications', () => ({ + toast: { + error: (...args: unknown[]) => mockToastError(...args), + warning: (...args: unknown[]) => mockToastWarning(...args), + }, +})) + +vi.mock('@/service/use-snippets', () => ({ + useIncrementSnippetUseCountMutation: () => ({ + mutate: mockIncrementSnippetUseCount, + }), +})) + +type PublishedWorkflow = Pick + +const seedPublishedWorkflow = (queryClient: QueryClient, workflow: PublishedWorkflow) => { + const queryOptions = consoleQuery.snippets.bySnippetId.workflows.publish.get.queryOptions({ + input: { + params: { snippet_id: 'snippet-1' }, + }, + }) + + queryClient.setQueryData(queryOptions.queryKey, workflow) +} + +describe('useInsertSnippet', () => { + let queryClient: QueryClient + const renderUseInsertSnippet = () => + renderHook(() => useInsertSnippet(), { + wrapper: createQueryClientWrapper(queryClient), + }) + + beforeEach(() => { + vi.clearAllMocks() + releaseGate.enabled = true + queryClient = createTestQueryClient() + mockEdges = [{ id: 'existing-edge', source: 'old', target: 'old-2' }] + mockGetNodes.mockReturnValue([ + { + id: 'existing-node', + position: { x: 0, y: 0 }, + data: { selected: true }, + }, + ]) + }) + + afterEach(() => { + queryClient.clear() + }) + + describe('Insert Flow', () => { + it.each([undefined, '2'])( + 'allows only legacy snippet approvals (version %s) when the release gate is off', + async (version) => { + releaseGate.enabled = false + const data = { + ...HumanInputDefault.defaultValue, + type: 'human-input', + title: 'Approval', + ...(version ? { version } : {}), + } + mockGetNodes.mockReturnValue([ + { + id: 'existing-legacy', + type: 'custom', + position: { x: 0, y: 0 }, + data: version + ? { type: 'code', title: 'Existing code' } + : { ...HumanInputDefault.defaultValue, type: 'human-input' }, + }, + ]) + seedPublishedWorkflow(queryClient, { + graph: { + nodes: [{ id: 'snippet-approval', type: 'custom', position: { x: 10, y: 20 }, data }], + edges: [], + }, + }) + const { result } = renderUseInsertSnippet() + + await act(async () => result.current.handleInsertSnippet('snippet-1')) + + if (version) { + expect(mockToastWarning).toHaveBeenCalledWith('workflow.nodes.humanInputV2.disabled') + expect(mockSetNodes).not.toHaveBeenCalled() + expect(mockHandleSyncWorkflowDraft).not.toHaveBeenCalled() + } else { + expect(mockSetNodes).toHaveBeenCalledTimes(1) + expect(mockSetNodes.mock.lastCall![0]).toHaveLength(2) + expect(mockHandleSyncWorkflowDraft).toHaveBeenCalledTimes(1) + } + }, + ) + + it('keeps legacy-only snippets usable when existing legacy fields cannot migrate', async () => { + const data = { + ...HumanInputDefault.defaultValue, + inputs: [createDefaultFormInputByType(InputVarType.select, 'decision')], + } + mockGetNodes.mockReturnValue([ + { id: 'legacy-human-input', type: 'custom', position: { x: 0, y: 0 }, data }, + ]) + seedPublishedWorkflow(queryClient, { + graph: { + nodes: [{ id: 'snippet-human-input', type: 'custom', position: { x: 10, y: 20 }, data }], + edges: [], + }, + }) + const { result } = renderUseInsertSnippet() + + await act(async () => result.current.handleInsertSnippet('snippet-1')) + + expect(mockToastWarning).not.toHaveBeenCalled() + expect(mockSetNodes).toHaveBeenCalledTimes(1) + expect(mockSetNodes.mock.lastCall![0]).toHaveLength(2) + expect(mockHandleSyncWorkflowDraft).toHaveBeenCalledTimes(1) + }) + + it('blocks snippets containing Human Input while the draft has a legacy node', async () => { + mockGetNodes.mockReturnValue([ + { + id: 'legacy-human-input', + position: { x: 0, y: 0 }, + data: { type: 'human-input' }, + }, + ]) + seedPublishedWorkflow(queryClient, { + graph: { + nodes: [ + { + id: 'snippet-human-input', + position: { x: 10, y: 20 }, + data: { type: 'human-input', version: '2' }, + }, + ], + edges: [], + }, + }) + const { result } = renderUseInsertSnippet() + + await act(async () => { + expect(await result.current.handleInsertSnippet('snippet-1')).toBe(false) + }) + + expect(mockToastWarning).toHaveBeenCalledWith( + 'workflow.nodes.humanInputMigration.disabledReason', + ) + expect(mockSetNodes).not.toHaveBeenCalled() + expect(mockSetEdges).not.toHaveBeenCalled() + expect(mockHandleSyncWorkflowDraft).not.toHaveBeenCalled() + }) + }) +}) diff --git a/web/app/components/workflow/block-selector/snippets/use-insert-snippet.ts b/web/app/components/workflow/block-selector/snippets/use-insert-snippet.ts index e7bdd62c9e5..78e34c8d061 100644 --- a/web/app/components/workflow/block-selector/snippets/use-insert-snippet.ts +++ b/web/app/components/workflow/block-selector/snippets/use-insert-snippet.ts @@ -7,6 +7,7 @@ import { useCallback } from 'react' import { useTranslation } from 'react-i18next' import { useStoreApi } from 'reactflow' import { toast } from '@/app/notifications' +import { ENABLE_HITL_IM } from '@/config' import { consoleQuery } from '@/service/console' import { useIncrementSnippetUseCountMutation } from '@/service/use-snippets' import { CUSTOM_EDGE, NESTED_ELEMENT_Z_INDEX, NODE_WIDTH_X_OFFSET, X_OFFSET } from '../../constants' @@ -15,6 +16,7 @@ import { useWorkflowHistory, WorkflowHistoryEvent } from '../../hooks/use-workfl import { getNodeUsedVars, updateNodeVars } from '../../nodes/_base/components/variable/utils' import { hasAgentV2OutputRoutes } from '../../nodes/agent-v2/types' import { AssignerNodeInputType } from '../../nodes/assigner/types' +import { canInsertHumanInputNodes } from '../../nodes/human-input-v2/migration/policy' import { BlockEnum } from '../../types' import { getNodesConnectedSourceOrTargetHandleIdsMap } from '../../utils' @@ -404,7 +406,7 @@ const createBoundaryEdges = ({ } export const useInsertSnippet = () => { - const { t } = useTranslation(['snippet']) + const { t } = useTranslation(['snippet', 'workflow']) const queryClient = useQueryClient() const store = useStoreApi() const { handleSyncWorkflowDraft } = useNodesSyncDraft() @@ -427,6 +429,20 @@ export const useInsertSnippet = () => { const { getNodes, setNodes, edges, setEdges } = store.getState() const currentNodes = getNodes() + if (!canInsertHumanInputNodes(currentNodes, snippetNodes)) { + toast.warning( + t( + ($) => + $[ + ENABLE_HITL_IM + ? 'nodes.humanInputMigration.disabledReason' + : 'nodes.humanInputV2.disabled' + ], + { ns: 'workflow' }, + ), + ) + return false + } const remappedGraph = remapSnippetGraph( currentNodes, snippetNodes, diff --git a/web/app/components/workflow/constants.ts b/web/app/components/workflow/constants.ts index f8edbda9377..d0644678a54 100644 --- a/web/app/components/workflow/constants.ts +++ b/web/app/components/workflow/constants.ts @@ -123,6 +123,7 @@ export const SUPPORT_OUTPUT_VARS_NODE: BlockEnum[] = [ BlockEnum.AgentV2, BlockEnum.DataSource, BlockEnum.HumanInput, + BlockEnum.HumanInputV2, ] export const AGENT_OUTPUT_STRUCT: Var[] = [ diff --git a/web/app/components/workflow/constants/node.ts b/web/app/components/workflow/constants/node.ts index f8b86da9079..24d1b2f5d17 100644 --- a/web/app/components/workflow/constants/node.ts +++ b/web/app/components/workflow/constants/node.ts @@ -4,6 +4,7 @@ import assignerDefault from '@/app/components/workflow/nodes/assigner/default' import codeDefault from '@/app/components/workflow/nodes/code/default' import documentExtractorDefault from '@/app/components/workflow/nodes/document-extractor/default' import httpRequestDefault from '@/app/components/workflow/nodes/http/default' +import humanInputV2Default from '@/app/components/workflow/nodes/human-input-v2/default' import humanInputDefault from '@/app/components/workflow/nodes/human-input/default' import ifElseDefault from '@/app/components/workflow/nodes/if-else/default' import iterationStartDefault from '@/app/components/workflow/nodes/iteration-start/default' @@ -44,4 +45,5 @@ export const WORKFLOW_COMMON_NODES = [ listOperatorDefault, toolDefault, humanInputDefault, + humanInputV2Default, ] diff --git a/web/app/components/workflow/header/__tests__/history-status-title.spec.tsx b/web/app/components/workflow/header/__tests__/history-status-title.spec.tsx new file mode 100644 index 00000000000..26fcf2ef25d --- /dev/null +++ b/web/app/components/workflow/header/__tests__/history-status-title.spec.tsx @@ -0,0 +1,40 @@ +import { screen } from '@testing-library/react' +import { ReactFlowProvider } from 'reactflow' +import { renderWorkflowComponent } from '../../__tests__/workflow-test-env' +import Record from '../../panel/record' +import RunningTitle from '../running-title' + +vi.mock('../../hooks/use-workflow', () => ({ + useIsChatMode: () => false, +})) + +vi.mock('@/app/components/workflow/run', () => ({ + default: () => null, +})) + +describe('Paused workflow history titles', () => { + it.each([ + { name: 'history header', Component: RunningTitle }, + { name: 'run detail panel', Component: Record }, + ])('shows the persisted paused state in the $name', ({ Component }) => { + renderWorkflowComponent( + + + , + { + initialStoreState: { + historyWorkflowData: { id: 'paused-run', status: 'paused' }, + }, + hooksStoreProps: { + getWorkflowRunAndTraceUrl: () => ({ + runUrl: '/runs/paused-run', + traceUrl: '/runs/paused-run/trace', + }), + }, + }, + ) + + expect(screen.getByText('Test Run (Paused)')).toBeInTheDocument() + expect(screen.queryByText('Test Run (Running)')).not.toBeInTheDocument() + }) +}) diff --git a/web/app/components/workflow/header/running-title.tsx b/web/app/components/workflow/header/running-title.tsx index 420176c64e4..1e493a2e629 100644 --- a/web/app/components/workflow/header/running-title.tsx +++ b/web/app/components/workflow/header/running-title.tsx @@ -14,7 +14,7 @@ const RunningTitle = () => {
{isChatflow @@ -22,7 +22,7 @@ const RunningTitle = () => { : `Test Run${formatWorkflowRunIdentifier(historyWorkflowData?.finished_at, historyWorkflowData?.status)}`} · - + {t(($) => $['common.viewOnly'], { ns: 'workflow' })}
diff --git a/web/app/components/workflow/hooks/__tests__/use-available-blocks.spec.ts b/web/app/components/workflow/hooks/__tests__/use-available-blocks.spec.ts index e9827e35196..bb58626560c 100644 --- a/web/app/components/workflow/hooks/__tests__/use-available-blocks.spec.ts +++ b/web/app/components/workflow/hooks/__tests__/use-available-blocks.spec.ts @@ -17,6 +17,7 @@ const mockNodeTypes = [ BlockEnum.DataSource, BlockEnum.KnowledgeBase, BlockEnum.HumanInput, + BlockEnum.HumanInputV2, BlockEnum.LoopEnd, ] @@ -52,6 +53,7 @@ const outside = [ BlockEnum.DataSource, BlockEnum.KnowledgeBase, BlockEnum.HumanInput, + BlockEnum.HumanInputV2, ] const inside = [ BlockEnum.Start, @@ -60,6 +62,7 @@ const inside = [ BlockEnum.IfElse, BlockEnum.Tool, BlockEnum.HumanInput, + BlockEnum.HumanInputV2, BlockEnum.LoopEnd, ] diff --git a/web/app/components/workflow/hooks/__tests__/use-checklist.spec.tsx b/web/app/components/workflow/hooks/__tests__/use-checklist.spec.tsx index eebd3c7caa1..e94a9ae507c 100644 --- a/web/app/components/workflow/hooks/__tests__/use-checklist.spec.tsx +++ b/web/app/components/workflow/hooks/__tests__/use-checklist.spec.tsx @@ -38,6 +38,15 @@ import { useStore } from '../../store' import { BlockEnum, PromptRole, VarType } from '../../types' import { useChecklist, useChecklistBeforePublish, useWorkflowRunValidation } from '../use-checklist' +const mockHitlIm = vi.hoisted(() => ({ enabled: true })) + +vi.mock('@/config', async (importOriginal) => ({ + ...(await importOriginal()), + get ENABLE_HITL_IM() { + return mockHitlIm.enabled + }, +})) + const marketplace = vi.hoisted(() => vi.fn< (url: string, options: unknown) => Promise<{ data: PluginsFromMarketplaceByInfoResponse }> @@ -84,15 +93,15 @@ function createChecklistWrapper({ nodes, edges }: Graph, options: ChecklistOptio - - - + + + {children} - - - + + + @@ -116,6 +125,7 @@ function renderChecklist(nodes: Node[], edges: Edge[], options: ChecklistOptions } beforeEach(() => { + mockHitlIm.enabled = true resetFixtureCounters() marketplace.mockReset().mockImplementation((url: string) => { throw new Error(`Unexpected marketplace request: ${url}`) @@ -681,3 +691,64 @@ describe('useWorkflowRunValidation', () => { expect(screen.queryByText('workflow.panel.checklistTip')).not.toBeInTheDocument() }) }) + +describe('HITL release gate in workflow validation', () => { + it.each([ + { version: '1', enabled: false, allowed: true }, + { version: '2', enabled: false, allowed: false }, + { version: '2', enabled: true, allowed: true }, + ])( + 'validates V$version with enabled=$enabled before running and publishing', + async ({ version, enabled, allowed }) => { + mockHitlIm.enabled = enabled + const start = createInputNode() + const humanInput = createNode({ + id: 'review', + data: { + type: BlockEnum.HumanInput, + title: 'Review', + version, + form_content: 'Please approve', + inputs: [], + user_actions: [{ id: 'approve', title: 'Approve', button_style: 'primary' }], + timeout: 36, + timeout_unit: 'hour', + ...(version === '1' + ? { delivery_methods: [{ id: 'webapp', type: 'webapp', enabled: true }] } + : { + recipients_spec: [{ type: 'initiator' }], + message_template: { subject: 'Review request', body: 'Please review {{#url#}}' }, + debug_mode: { enabled: false, channels: [] }, + }), + }, + }) + const { wrapper } = createChecklistWrapper({ + nodes: [start, humanInput], + edges: [createEdge({ source: 'start', target: 'review' })], + }) + const { result } = renderHook( + () => ({ + run: useWorkflowRunValidation(), + publish: useChecklistBeforePublish(), + }), + { wrapper }, + ) + + expect(result.current.run.hasValidationErrors).toBe(!allowed) + if (!allowed) { + expect(result.current.run.warningNodes).toEqual([ + expect.objectContaining({ + id: 'review', + errorMessages: ['workflow.nodes.humanInputV2.disabled'], + }), + ]) + } + act(() => { + expect(result.current.run.validateBeforeRun()).toBe(allowed) + }) + await act(async () => { + expect(await result.current.publish.handleCheckBeforePublish()).toBe(allowed) + }) + }, + ) +}) diff --git a/web/app/components/workflow/hooks/__tests__/use-fetch-workflow-inspect-vars.spec.ts b/web/app/components/workflow/hooks/__tests__/use-fetch-workflow-inspect-vars.spec.ts index cb2c4cc3c77..ba9f22df390 100644 --- a/web/app/components/workflow/hooks/__tests__/use-fetch-workflow-inspect-vars.spec.ts +++ b/web/app/components/workflow/hooks/__tests__/use-fetch-workflow-inspect-vars.spec.ts @@ -62,7 +62,7 @@ const variableQueryKeys = [ consoleQuery.apps.byAppId.workflows.draft.systemVariables.get, ].map((endpoint) => endpoint.queryKey({ input: { params: { app_id: 'app-1' } } })) -function setup(canvasNodes = nodes) { +function setup(canvasNodes = nodes, appId = 'app-1') { const { queryClient } = createConsoleQueryWrapper() seedToolProviders(queryClient, { builtInTools: [], @@ -84,7 +84,12 @@ function setup(canvasNodes = nodes) { ...useSetWorkflowVarsWithValue(), getNodes: useReactFlow().getNodes, }), - { nodes: canvasNodes, queryClient, initialStoreState: { dataSourceList: [] } }, + { + nodes: canvasNodes, + queryClient, + resource: { kind: 'app', appId, mode: 'workflow' }, + initialStoreState: { dataSourceList: [] }, + }, ), queryClient, } @@ -168,6 +173,50 @@ describe('use-fetch-workflow-inspect-vars', () => { schemaType: undefined, }) }) + + it('registers a first human-input node from persisted variables when no cache entry exists', async () => { + mockFetchAllInspectVars.mockResolvedValue([ + createInspectVar({ + id: 'action-result', + name: '__action_id', + selector: ['human-input-node', '__action_id'], + value: 'approve', + }), + ]) + const { result, store } = setup( + [ + createNode({ + id: 'human-input-node', + data: { + type: BlockEnum.HumanInput, + title: 'Human Input', + desc: '', + inputs: [], + user_actions: [], + }, + }), + ], + 'flow-id', + ) + expect(store.getState().nodesWithInspectVars).toEqual([]) + await act(async () => { + await result.current.fetchInspectVars({}) + }) + expect(mockFetchAllInspectVars).toHaveBeenCalledWith({ + kind: 'app', + appId: 'flow-id', + mode: 'workflow', + }) + expect(store.getState().nodesWithInspectVars).toEqual([ + expect.objectContaining({ + nodeId: 'human-input-node', + nodeType: BlockEnum.HumanInput, + vars: [ + expect.objectContaining({ id: 'action-result', name: '__action_id', value: 'approve' }), + ], + }), + ]) + }) }) it('derives schema enrichment from the passed-in tool catalog and schema definition', async () => { diff --git a/web/app/components/workflow/hooks/__tests__/use-nodes-interactions.hitl-v2.spec.ts b/web/app/components/workflow/hooks/__tests__/use-nodes-interactions.hitl-v2.spec.ts new file mode 100644 index 00000000000..e65c3590e93 --- /dev/null +++ b/web/app/components/workflow/hooks/__tests__/use-nodes-interactions.hitl-v2.spec.ts @@ -0,0 +1,480 @@ +import type { HumanInputV2NodeType } from '../../nodes/human-input-v2/types' +import type { Edge, Node } from '../../types' +import { act } from '@testing-library/react' +import { createEdge, createNode } from '../../__tests__/fixtures' +import { resetReactFlowMockState, rfState } from '../../__tests__/reactflow-mock-state' +import { renderWorkflowHook } from '../../__tests__/workflow-test-env' +import { BlockEnum } from '../../types' +import { useNodesInteractions } from '../use-nodes-interactions' + +const mockHandleSyncWorkflowDraft = vi.hoisted(() => vi.fn()) +const mockSaveStateToHistory = vi.hoisted(() => vi.fn()) +const mockUndo = vi.hoisted(() => vi.fn()) +const mockRedo = vi.hoisted(() => vi.fn()) +const mockHandleNodeIterationChildrenCopy = vi.hoisted(() => + vi.fn(() => ({ + copyChildren: [], + newIdMapping: {}, + })), +) +const mockHandleNodeLoopChildrenCopy = vi.hoisted(() => + vi.fn(() => ({ + copyChildren: [], + newIdMapping: {}, + })), +) +const mockCreateInlineAgentBinding = vi.hoisted(() => vi.fn()) +const runtimeNodesMetaDataMap = vi.hoisted(() => ({ + value: {} as Record, +})) + +const runtimeState = vi.hoisted(() => ({ + isChatMode: false, + nodesReadOnly: false, + workflowReadOnly: false, +})) + +vi.mock('@/config', async (importOriginal) => ({ + ...(await importOriginal()), + ENABLE_HITL_IM: true, +})) + +let currentNodes: Node[] = [] +let currentEdges: Edge[] = [] + +vi.mock('reactflow', async () => + (await import('../../__tests__/reactflow-mock-state')).createReactFlowModuleMock(), +) + +vi.mock('../use-workflow', () => ({ + useIsChatMode: () => runtimeState.isChatMode, + useWorkflow: () => ({ + getAfterNodesInSameBranch: () => [], + }), + useNodesReadOnly: () => ({ + getNodesReadOnly: () => runtimeState.nodesReadOnly, + }), + useWorkflowReadOnly: () => ({ + getWorkflowReadOnly: () => runtimeState.workflowReadOnly, + }), +})) + +vi.mock('../use-helpline', () => ({ + useHelpline: () => ({ + handleSetHelpline: () => ({ + showHorizontalHelpLineNodes: [], + showVerticalHelpLineNodes: [], + }), + }), +})) + +vi.mock('../use-nodes-meta-data', () => ({ + useNodesMetaData: () => ({ + nodesMap: runtimeNodesMetaDataMap.value, + }), +})) + +vi.mock('../use-nodes-sync-draft', () => ({ + useNodesSyncDraft: () => ({ + handleSyncWorkflowDraft: mockHandleSyncWorkflowDraft, + }), +})) + +vi.mock('../use-auto-generate-webhook-url', () => ({ + useAutoGenerateWebhookUrl: () => vi.fn(), +})) + +vi.mock('../use-inspect-vars-crud', () => ({ + default: () => ({ + deleteNodeInspectorVars: vi.fn(), + }), +})) + +vi.mock('../../nodes/agent-v2/hooks', () => ({ + useCreateInlineAgentBinding: () => ({ + createInlineAgentBinding: mockCreateInlineAgentBinding, + }), +})) + +vi.mock('../../nodes/iteration/use-interactions', () => ({ + useNodeIterationInteractions: () => ({ + handleNodeIterationChildDrag: () => ({ restrictPosition: {} }), + handleNodeIterationChildrenCopy: mockHandleNodeIterationChildrenCopy, + }), +})) + +vi.mock('../../nodes/loop/use-interactions', () => ({ + useNodeLoopInteractions: () => ({ + handleNodeLoopChildDrag: () => ({ restrictPosition: {} }), + handleNodeLoopChildrenCopy: mockHandleNodeLoopChildrenCopy, + }), +})) + +vi.mock('../use-workflow-history', async (importOriginal) => ({ + ...(await importOriginal()), + useWorkflowHistory: () => ({ + saveStateToHistory: mockSaveStateToHistory, + undo: mockUndo, + redo: mockRedo, + }), +})) + +describe('useNodesInteractions', () => { + beforeEach(() => { + vi.clearAllMocks() + resetReactFlowMockState() + runtimeState.isChatMode = false + runtimeState.nodesReadOnly = false + runtimeState.workflowReadOnly = false + mockCreateInlineAgentBinding.mockImplementation( + ( + _nodeId: string, + options?: { + onSuccess?: (binding: { + binding_type: 'inline_agent' + agent_id: string + current_snapshot_id: string + }) => void + }, + ) => { + options?.onSuccess?.({ + binding_type: 'inline_agent', + agent_id: 'inline-agent-1', + current_snapshot_id: 'inline-snapshot-1', + }) + }, + ) + currentNodes = [ + createNode({ + id: 'node-1', + position: { x: 10, y: 20 }, + data: { + type: BlockEnum.Code, + title: 'Code', + desc: '', + }, + }), + ] + currentEdges = [ + createEdge({ + id: 'edge-1', + source: 'node-1', + target: 'node-2', + }), + ] + rfState.nodes = currentNodes as unknown as typeof rfState.nodes + rfState.edges = currentEdges as unknown as typeof rfState.edges + runtimeNodesMetaDataMap.value = {} + }) + + it.each([undefined, BlockEnum.Iteration, BlockEnum.Loop])( + 'creates Human Input v2 with persisted type in container %s', + (containerType) => { + const parentId = containerType ? 'container-node' : undefined + currentNodes = [ + createNode({ + id: 'node-1', + parentId, + width: 100, + data: { type: BlockEnum.Code, title: 'Code', desc: '' }, + }), + ] + if (containerType) { + currentNodes.unshift( + createNode({ + id: parentId, + width: 500, + height: 400, + data: { type: containerType, title: 'Container', desc: '' }, + }), + ) + } + rfState.nodes = currentNodes as unknown as typeof rfState.nodes + rfState.edges = [] + rfState.setNodes.mockImplementation((nextNodes) => { + rfState.nodes = nextNodes + }) + runtimeNodesMetaDataMap.value = { + [BlockEnum.HumanInputV2]: { + defaultValue: { + type: BlockEnum.HumanInput, + title: 'Human Input v2', + desc: '', + version: '2', + recipients_spec: [], + message_template: { subject: '', body: '' }, + debug_mode: { enabled: false, channels: [] }, + form_content: '', + inputs: [], + user_actions: [], + timeout: 36, + timeout_unit: 'hour', + }, + metaData: { isSingleton: false }, + }, + } + + const { result } = renderWorkflowHook(() => useNodesInteractions(), { + historyStore: { nodes: currentNodes, edges: [] }, + }) + + act(() => { + result.current.handleNodeAdd({ nodeType: BlockEnum.HumanInputV2 }, { prevNodeId: 'node-1' }) + }) + + const node = rfState.nodes.find((node) => node.data.version === '2') + expect(node?.data).toMatchObject({ + type: BlockEnum.HumanInput, + version: '2', + recipients_spec: [], + timeout: 36, + timeout_unit: 'hour', + }) + expect(node?.data).not.toHaveProperty('delivery_methods') + expect(node?.data._connectedSourceHandleIds).toEqual([]) + expect(node?.parentId).toBe(parentId) + if (containerType) { + expect(node?.data).toMatchObject({ + isInIteration: containerType === BlockEnum.Iteration, + isInLoop: containerType === BlockEnum.Loop, + [containerType === BlockEnum.Iteration ? 'iteration_id' : 'loop_id']: parentId, + }) + } + }, + ) + + it('blocks quick-add Human Input while a legacy node remains in the draft', () => { + currentNodes = [ + createNode({ + id: 'legacy-human-input', + data: { + type: BlockEnum.HumanInput, + title: 'Legacy Human Input', + desc: '', + delivery_methods: [], + }, + }), + createNode({ id: 'node-1', data: { type: BlockEnum.Code, title: 'Code', desc: '' } }), + ] + rfState.nodes = currentNodes as unknown as typeof rfState.nodes + runtimeNodesMetaDataMap.value = { + [BlockEnum.HumanInputV2]: { + defaultValue: { + type: BlockEnum.HumanInput, + version: '2', + title: 'Human Input', + desc: '', + }, + metaData: { isSingleton: false }, + }, + } + const { result } = renderWorkflowHook(() => useNodesInteractions(), { + historyStore: { nodes: currentNodes, edges: [] }, + }) + + act(() => { + result.current.handleNodeAdd({ nodeType: BlockEnum.HumanInputV2 }, { prevNodeId: 'node-1' }) + }) + + expect(rfState.setNodes).not.toHaveBeenCalled() + expect(mockHandleSyncWorkflowDraft).not.toHaveBeenCalled() + expect(mockSaveStateToHistory).not.toHaveBeenCalled() + }) + + it('blocks pasted Human Input while a legacy node remains in the draft', async () => { + const legacyNode = createNode({ + id: 'legacy-human-input', + data: { + type: BlockEnum.HumanInput, + title: 'Legacy Human Input', + desc: '', + delivery_methods: [], + }, + }) + const copiedV2Node = createNode({ + id: 'copied-v2', + data: { + type: BlockEnum.HumanInput, + version: '2', + title: 'Human Input', + desc: '', + }, + }) + currentNodes = [legacyNode] + rfState.nodes = currentNodes as unknown as typeof rfState.nodes + runtimeNodesMetaDataMap.value = { + [BlockEnum.HumanInput]: { + defaultValue: { type: BlockEnum.HumanInput, delivery_methods: [] }, + metaData: { isSingleton: false }, + }, + [BlockEnum.HumanInputV2]: { + defaultValue: { type: BlockEnum.HumanInput, version: '2' }, + metaData: { isSingleton: false }, + }, + } + const { result, store } = renderWorkflowHook(() => useNodesInteractions(), { + historyStore: { nodes: currentNodes, edges: [] }, + }) + store.setState({ + clipboardElements: [copiedV2Node] as never, + clipboardEdges: [] as never, + mousePosition: { pageX: 60, pageY: 80 } as never, + }) + + await act(async () => { + await result.current.handleNodesPaste() + }) + + expect(rfState.setNodes).not.toHaveBeenCalled() + expect(mockHandleSyncWorkflowDraft).not.toHaveBeenCalled() + expect(mockSaveStateToHistory).not.toHaveBeenCalled() + }) + + it('blocks duplicated Human Input while a legacy node remains in the draft', async () => { + const legacyNode = createNode({ + id: 'legacy-human-input', + data: { + type: BlockEnum.HumanInput, + title: 'Legacy Human Input', + desc: '', + delivery_methods: [], + }, + }) + currentNodes = [legacyNode] + rfState.nodes = currentNodes as unknown as typeof rfState.nodes + runtimeNodesMetaDataMap.value = { + [BlockEnum.HumanInput]: { + defaultValue: { type: BlockEnum.HumanInput, delivery_methods: [] }, + metaData: { isSingleton: false }, + }, + } + const readTextSpy = vi.spyOn(navigator.clipboard, 'readText').mockResolvedValue('') + const writeTextSpy = vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue() + const { result } = renderWorkflowHook(() => useNodesInteractions(), { + historyStore: { nodes: currentNodes, edges: [] }, + }) + + act(() => { + result.current.handleNodesDuplicate('legacy-human-input') + }) + await vi.waitFor(() => expect(readTextSpy).toHaveBeenCalled()) + + expect(writeTextSpy).toHaveBeenCalled() + expect(rfState.setNodes).not.toHaveBeenCalled() + expect(mockHandleSyncWorkflowDraft).not.toHaveBeenCalled() + expect(mockSaveStateToHistory).not.toHaveBeenCalled() + readTextSpy.mockRestore() + writeTextSpy.mockRestore() + }) + + it('pastes Human Input v2 without losing wire data and remaps copied dependencies', async () => { + currentNodes = [ + createNode({ id: 'existing-node', data: { type: BlockEnum.Code, title: 'Code', desc: '' } }), + ] + rfState.nodes = currentNodes as unknown as typeof rfState.nodes + rfState.edges = [] + runtimeNodesMetaDataMap.value = { + [BlockEnum.Code]: { + defaultValue: { + type: BlockEnum.Code, + title: 'Code', + desc: '', + variables: [], + code_language: 'python3', + code: '', + outputs: {}, + }, + metaData: { isSingleton: false }, + }, + [BlockEnum.HumanInputV2]: { + defaultValue: { + type: BlockEnum.HumanInput, + version: '2', + recipients_spec: [], + message_template: { subject: '', body: '' }, + debug_mode: { enabled: false, channels: [] }, + form_content: '', + inputs: [], + user_actions: [], + timeout: 36, + timeout_unit: 'hour', + }, + metaData: { isSingleton: false }, + }, + } + const copiedSourceNode = createNode({ + id: 'source-copy', + data: { + type: BlockEnum.Code, + title: 'Email source', + desc: '', + variables: [], + code_language: 'python3', + code: '', + outputs: { email: { type: 'string', children: null } }, + }, + }) + const copiedNode = createNode({ + id: 'human-input-v2-copy', + data: { + type: BlockEnum.HumanInput, + version: '2', + title: 'Review request', + desc: '', + recipients_spec: [ + { type: 'initiator' }, + { type: 'dynamic_email', selector: ['source-copy', 'email'] }, + { type: 'onetime_email', email: 'owner@example.com' }, + ], + message_template: { + subject: 'Review {{#source-copy.email#}}', + body: 'Body {{#source-copy.email#}}', + }, + debug_mode: { enabled: true, channels: ['email', 'slack'] }, + form_content: '{{#source-copy.email#}}', + inputs: [], + user_actions: [], + timeout: 36, + timeout_unit: 'hour', + }, + }) + const { result, store } = renderWorkflowHook(() => useNodesInteractions(), { + historyStore: { nodes: currentNodes, edges: [] }, + }) + store.setState({ + clipboardElements: [copiedSourceNode, copiedNode] as never, + clipboardEdges: [] as never, + mousePosition: { pageX: 60, pageY: 80 } as never, + }) + + await act(async () => { + await result.current.handleNodesPaste() + }) + + const pastedNodes = rfState.setNodes.mock.calls.at(-1)?.[0] as Node[] + const pasted = pastedNodes.find((node) => (node.data as { version?: unknown }).version === '2') + const pastedData = pasted?.data as HumanInputV2NodeType + const copiedData = copiedNode.data as HumanInputV2NodeType + const pastedSource = pastedNodes.find( + (node) => node.data.type === BlockEnum.Code && node.id !== 'existing-node', + ) + expect(pastedData.type).toBe(BlockEnum.HumanInput) + expect(pastedData.recipients_spec).toEqual([ + { type: 'initiator' }, + { type: 'dynamic_email', selector: [pastedSource?.id, 'email'] }, + { type: 'onetime_email', email: 'owner@example.com' }, + ]) + expect(pastedData.message_template).toEqual({ + subject: `Review {{#${pastedSource?.id}.email#}}`, + body: `Body {{#${pastedSource?.id}.email#}}`, + }) + expect(pastedData.form_content).toBe(`{{#${pastedSource?.id}.email#}}`) + expect(pastedData.debug_mode).toEqual(copiedData.debug_mode) + }) + + // Paste title handling should preserve original names until the destination canvas conflicts. + + // A copied container can still be selected on the source canvas during same-canvas paste. + + // Nested container paste behavior should stay aligned with available block filtering. +}) diff --git a/web/app/components/workflow/hooks/__tests__/use-nodes-interactions.spec.ts b/web/app/components/workflow/hooks/__tests__/use-nodes-interactions.spec.ts index 051a7e4f5e5..dcf9125b6de 100644 --- a/web/app/components/workflow/hooks/__tests__/use-nodes-interactions.spec.ts +++ b/web/app/components/workflow/hooks/__tests__/use-nodes-interactions.spec.ts @@ -1,3 +1,4 @@ +import type { BlockDefaultValue } from '../../block-selector/types' import type { AvailableNodesMetaData } from '../../hooks-store' import type { Edge, Node } from '../../types' import type { consoleClient } from '@/service/console' @@ -13,14 +14,23 @@ import { renderWorkflowFlowHook } from '../../__tests__/workflow-test-env' import { WORKFLOW_COMMON_NODES } from '../../constants/node' import { HooksStoreContext } from '../../hooks-store' import { useWorkflowNodeCatalog } from '../../node-catalog' +import AgentV2Default from '../../nodes/agent-v2/default' import endDefault from '../../nodes/end/default' -import { BlockEnum } from '../../types' +import { createDefaultFormInputByType } from '../../nodes/human-input/types' +import { BlockEnum, InputVarType } from '../../types' import { writeWorkflowClipboard } from '../../utils/clipboard' import { useNodesInteractions } from '../use-nodes-interactions' const createAgent = vi.hoisted(() => vi.fn(), ) +const releaseGate = vi.hoisted(() => ({ enabled: true })) +vi.mock('@/config', async (importOriginal) => ({ + ...(await importOriginal()), + get ENABLE_HITL_IM() { + return releaseGate.enabled + }, +})) vi.mock('@/service/console', async (importOriginal) => { const actual = await importOriginal() const { withConsoleResponses } = await import('@/test/console/service-responses') @@ -107,10 +117,94 @@ async function paste( return test } beforeEach(async () => { + releaseGate.enabled = true createAgent.mockReset() await navigator.clipboard.writeText('') }) +it.each(['add', 'replace'] as const)( + 'restores legacy approval %s without migration when the release gate is off', + (command) => { + releaseGate.enabled = false + const legacy = workflowNode(BlockEnum.HumanInput, 'legacy') + const { result } = setup({ nodes: [legacy, codeNode('target')] }) + + act(() => { + if (command === 'add') + result.current.handleNodeAdd({ nodeType: BlockEnum.HumanInput }, { prevNodeId: 'legacy' }) + else result.current.handleNodeChange('target', BlockEnum.HumanInput, 'source') + }) + + const approvals = result.current.nodes.filter((node) => node.data.type === BlockEnum.HumanInput) + expect(approvals).toHaveLength(2) + expect(approvals.every((node) => !('version' in node.data))).toBe(true) + expect( + approvals.every( + (node) => 'delivery_methods' in node.data && Array.isArray(node.data.delivery_methods), + ), + ).toBe(true) + }, +) + +it.each(['paste', 'duplicate'] as const)( + 'restores ordinary legacy approval %s when the release gate is off', + async (command) => { + releaseGate.enabled = false + const legacy = workflowNode(BlockEnum.HumanInput, 'legacy') + const test = + command === 'paste' + ? await paste([legacy], [], { nodes: [legacy] }) + : setup({ nodes: [legacy] }) + if (command === 'duplicate') { + await act(async () => test.result.current.handleNodesDuplicate('legacy')) + await waitFor(() => expect(test.result.current.nodes).toHaveLength(2)) + } + + expect(test.result.current.nodes).toHaveLength(2) + expect(test.result.current.nodes.every((node) => !('version' in node.data))).toBe(true) + }, +) + +it.each(['add', 'replace'] as const)( + 'rejects %s overrides that smuggle V2 data through the legacy catalog when the release gate is off', + (command) => { + releaseGate.enabled = false + const { result, sync } = setup({ nodes: [codeNode('target')] }) + const before = result.current.nodes + const override = { + ...AgentV2Default.defaultValue, + agent_binding: { binding_type: 'inline_agent' }, + agent_node_kind: 'dify_agent', + version: '2', + } satisfies BlockDefaultValue + + act(() => { + if (command === 'add') + result.current.handleNodeAdd( + { nodeType: BlockEnum.HumanInput, pluginDefaultValue: override }, + {}, + ) + else result.current.handleNodeChange('target', BlockEnum.HumanInput, 'source', override) + }) + + expect(result.current.nodes).toEqual(before) + expect(sync).not.toHaveBeenCalled() + }, +) + +it('rejects pasted V2 approvals without changing existing wire data when the release gate is off', async () => { + releaseGate.enabled = false + const v2 = workflowNode(BlockEnum.HumanInputV2, 'v2', { + data: { type: BlockEnum.HumanInput, version: '2', recipients_spec: [{ type: 'initiator' }] }, + }) + const before = structuredClone(v2) + const { result, sync } = await paste([v2], [], { nodes: [codeNode('target')] }) + + expect(result.current.nodes.map((node) => node.id)).toEqual(['target']) + expect(v2).toEqual(before) + expect(sync).not.toHaveBeenCalled() +}) + it('selects a different node for inspection and clears the inspector selection', () => { const { result } = setup({ nodes: [codeNode('a', { selected: true, data: { selected: true } }), codeNode('b')], @@ -393,6 +487,61 @@ it.each([BlockEnum.Iteration, BlockEnum.Loop])( }, ) +it.each([InputVarType.select, InputVarType.singleFile, InputVarType.multiFiles])( + 'requires migration before duplicating a legacy approval with a supported %s field', + async (type) => { + const input = createDefaultFormInputByType(type, 'decision') + const legacy = workflowNode(BlockEnum.HumanInput, 'legacy', { data: { inputs: [input] } }) + const { result } = setup({ nodes: [legacy] }) + + await act(async () => result.current.handleNodesDuplicate('legacy')) + + expect(result.current.nodes).toHaveLength(1) + expect(result.current.nodes[0]?.data).toMatchObject({ inputs: [input] }) + }, +) + +it('duplicates a legacy approval without changing its input when the release gate is off', async () => { + releaseGate.enabled = false + const input = createDefaultFormInputByType(InputVarType.select, 'decision') + const legacy = workflowNode(BlockEnum.HumanInput, 'legacy', { data: { inputs: [input] } }) + const { result } = setup({ nodes: [legacy] }) + + await act(async () => result.current.handleNodesDuplicate('legacy')) + await waitFor(() => expect(result.current.nodes).toHaveLength(2)) + + expect(result.current.nodes.find((node) => node.id !== legacy.id)?.data).toMatchObject({ + type: BlockEnum.HumanInput, + inputs: [input], + }) +}) + +it.each(['add', 'replace'] as const)( + 'allows legacy %s while blocking v2 when the release gate is off', + (command) => { + releaseGate.enabled = false + const legacy = workflowNode(BlockEnum.HumanInput, 'legacy', { + data: { inputs: [createDefaultFormInputByType(InputVarType.select, 'decision')] }, + }) + const { result } = setup({ nodes: [legacy, codeNode('target')] }) + + act(() => { + if (command === 'add') { + result.current.handleNodeAdd({ nodeType: BlockEnum.HumanInput }, { prevNodeId: 'legacy' }) + } else { + result.current.handleNodeChange('target', BlockEnum.HumanInput, 'source') + } + }) + expect( + result.current.nodes.filter((node) => node.data.type === BlockEnum.HumanInput), + ).toHaveLength(2) + const count = result.current.nodes.length + + act(() => result.current.handleNodeAdd({ nodeType: BlockEnum.HumanInputV2 }, {})) + expect(result.current.nodes).toHaveLength(count) + }, +) + it('creates an inline Agent through the service boundary and resolves the pending graph node', async () => { const request = createDeferred>>() createAgent.mockReturnValue(request.promise) diff --git a/web/app/components/workflow/hooks/use-nodes-interactions.ts b/web/app/components/workflow/hooks/use-nodes-interactions.ts index fec73d34681..bcab6b88d83 100644 --- a/web/app/components/workflow/hooks/use-nodes-interactions.ts +++ b/web/app/components/workflow/hooks/use-nodes-interactions.ts @@ -21,6 +21,7 @@ import { useTranslation } from 'react-i18next' import { getConnectedEdges, getOutgoers, useReactFlow } from 'reactflow' import { isChatflowAtom } from '@/app/components/workflow/editor-resource/state' import { toast } from '@/app/notifications' +import { ENABLE_HITL_IM } from '@/config' import { consoleQuery } from '@/service/console' import { collaborationManager } from '../collaboration/core/collaboration-manager' import { @@ -32,13 +33,17 @@ import { X_OFFSET, Y_OFFSET, } from '../constants' -import { getNodeUsedVars } from '../nodes/_base/components/variable/utils' +import { getNodeUsedVars, updateNodeVars } from '../nodes/_base/components/variable/utils' import { useCreateInlineAgentBinding } from '../nodes/agent-v2/hooks' import { hasAgentV2OutputRoutes, isAgentV2NodeData, needsInlineAgentBindingCreation, } from '../nodes/agent-v2/types' +import { + canInsertHumanInput, + canInsertHumanInputNodes, +} from '../nodes/human-input-v2/migration/policy' import { CUSTOM_ITERATION_START_NODE } from '../nodes/iteration-start/constants' import { useNodeIterationInteractions } from '../nodes/iteration/use-interactions' import { CUSTOM_LOOP_START_NODE } from '../nodes/loop-start/constants' @@ -845,6 +850,25 @@ export const useNodesInteractions = () => { const nodeMetaData = nodesMetaDataMap?.[nodeType] if (!nodeMetaData) return const { defaultValue } = nodeMetaData + if ( + !canInsertHumanInput(nodes, nodeType, { + ...(defaultValue as Node['data']), + ...pluginDefaultValue, + }) + ) { + toast.warning( + t( + ($) => + $[ + ENABLE_HITL_IM + ? 'nodes.humanInputMigration.disabledReason' + : 'nodes.humanInputV2.disabled' + ], + { ns: 'workflow' }, + ), + ) + return + } const nodesWithSameType = getNodesWithSameDefaultDataType(nodes, nodeType, defaultValue) const shouldCreateInlineAgentBinding = nodeType === BlockEnum.AgentV2 && @@ -1024,7 +1048,8 @@ export const useNodesInteractions = () => { nodeType !== BlockEnum.IfElse && nodeType !== BlockEnum.QuestionClassifier && !hasAgentV2OutputRoutes(newNode.data) && - nodeType !== BlockEnum.HumanInput + nodeType !== BlockEnum.HumanInput && + nodeType !== BlockEnum.HumanInputV2 ) { newNode.data._connectedSourceHandleIds = [sourceHandle] } @@ -1060,6 +1085,7 @@ export const useNodesInteractions = () => { nodeType !== BlockEnum.QuestionClassifier && !hasAgentV2OutputRoutes(newNode.data) && nodeType !== BlockEnum.HumanInput && + nodeType !== BlockEnum.HumanInputV2 && nodeType !== BlockEnum.LoopEnd ) { newEdge = { @@ -1222,6 +1248,7 @@ export const useNodesInteractions = () => { nodeType !== BlockEnum.QuestionClassifier && !hasAgentV2OutputRoutes(newNode.data) && nodeType !== BlockEnum.HumanInput && + nodeType !== BlockEnum.HumanInputV2 && nodeType !== BlockEnum.LoopEnd ) { newNextEdge = { @@ -1342,6 +1369,7 @@ export const useNodesInteractions = () => { workflowStore, getAfterNodesInSameBranch, nodesMetaDataMap, + t, ], ) @@ -1355,11 +1383,30 @@ export const useNodesInteractions = () => { if (getNodesReadOnly()) return const { nodes, setNodes, edges, setEdges } = collaborativeWorkflow.getState() - const currentNode = nodes.find((node) => node.id === currentNodeId)! - const connectedEdges = getConnectedEdges([currentNode], edges) const nodeMetaData = nodesMetaDataMap?.[nodeType] if (!nodeMetaData) return const { defaultValue } = nodeMetaData + if ( + !canInsertHumanInput(nodes, nodeType, { + ...(defaultValue as Node['data']), + ...pluginDefaultValue, + }) + ) { + toast.warning( + t( + ($) => + $[ + ENABLE_HITL_IM + ? 'nodes.humanInputMigration.disabledReason' + : 'nodes.humanInputV2.disabled' + ], + { ns: 'workflow' }, + ), + ) + return + } + const currentNode = nodes.find((node) => node.id === currentNodeId)! + const connectedEdges = getConnectedEdges([currentNode], edges) const nodesWithSameType = getNodesWithSameDefaultDataType(nodes, nodeType, defaultValue) const shouldCreateInlineAgentBinding = nodeType === BlockEnum.AgentV2 && @@ -1551,6 +1598,7 @@ export const useNodesInteractions = () => { saveStateToHistory, nodesMetaDataMap, autoGenerateWebhookUrl, + t, ], ) @@ -1715,6 +1763,20 @@ export const useNodesInteractions = () => { if (!validatedClipboardElements.length) return const { nodes, setNodes, edges, setEdges } = collaborativeWorkflow.getState() + if (!canInsertHumanInputNodes(nodes, validatedClipboardElements)) { + toast.warning( + t( + ($) => + $[ + ENABLE_HITL_IM + ? 'nodes.humanInputMigration.disabledReason' + : 'nodes.humanInputV2.disabled' + ], + { ns: 'workflow' }, + ), + ) + return + } const reservedNodeTitles = new Set( nodes .map((node) => node.data.title) @@ -2089,6 +2151,18 @@ export const useNodesInteractions = () => { } }) + nodesToPaste.forEach((node, index) => { + const remappedNode = getNodeUsedVars(node).reduce((currentNode, selector) => { + if (!Array.isArray(selector) || !selector.length) return currentNode + const mappedNodeId = idMapping[selector[0]!] + if (!mappedNodeId) return currentNode + + return updateNodeVars(currentNode, selector, [mappedNodeId, ...selector.slice(1)]) + }, node) + nodesToPaste[index] = remappedNode + pastedNodesMap[remappedNode.id] = remappedNode + }) + const sourceEdges = validatedClipboardEdges sourceEdges.forEach((edge) => { diff --git a/web/app/components/workflow/hooks/use-workflow-history.ts b/web/app/components/workflow/hooks/use-workflow-history.ts index 16c23524c78..9a679856621 100644 --- a/web/app/components/workflow/hooks/use-workflow-history.ts +++ b/web/app/components/workflow/hooks/use-workflow-history.ts @@ -29,6 +29,7 @@ export const WorkflowHistoryEvent = { NoteChange: 'NoteChange', NoteDelete: 'NoteDelete', LayoutOrganize: 'LayoutOrganize', + HumanInputMigration: 'HumanInputMigration', } as const export type WorkflowHistoryEventT = keyof typeof WorkflowHistoryEvent @@ -97,6 +98,7 @@ export const useWorkflowHistory = () => { case WorkflowHistoryEvent.NoteAdd: case WorkflowHistoryEvent.LayoutOrganize: case WorkflowHistoryEvent.NoteDelete: + case WorkflowHistoryEvent.HumanInputMigration: saveStateToHistoryRef.current(event, meta) break default: @@ -140,6 +142,8 @@ export const useWorkflowHistory = () => { return t(($) => $['changeHistory.noteChange'], { ns: 'workflow' }) case WorkflowHistoryEvent.NoteDelete: return t(($) => $['changeHistory.noteDelete'], { ns: 'workflow' }) + case WorkflowHistoryEvent.HumanInputMigration: + return t(($) => $['changeHistory.humanInputMigration'], { ns: 'workflow' }) default: return 'Unknown Event' } diff --git a/web/app/components/workflow/hooks/use-workflow-run-event/__tests__/use-workflow-node-human-input-required.spec.ts b/web/app/components/workflow/hooks/use-workflow-run-event/__tests__/use-workflow-node-human-input-required.spec.ts index 7cfbda46b6a..e975b6ebb39 100644 --- a/web/app/components/workflow/hooks/use-workflow-run-event/__tests__/use-workflow-node-human-input-required.spec.ts +++ b/web/app/components/workflow/hooks/use-workflow-run-event/__tests__/use-workflow-node-human-input-required.spec.ts @@ -71,5 +71,16 @@ it.each([BlockEnum.HumanInput, BlockEnum.Tool])( const updated = form('n1', { form_content: 'Updated instructions' }) send(updated) expect(store.getState().workflowRunningData!.humanInputFormDataList).toEqual([updated, second]) + + const parallel = form('n1', { + form_id: 'parallel-form-n1', + form_content: 'Approval for another iteration item', + }) + send(parallel) + expect(store.getState().workflowRunningData!.humanInputFormDataList).toEqual([ + updated, + second, + parallel, + ]) }, ) diff --git a/web/app/components/workflow/node-catalog.ts b/web/app/components/workflow/node-catalog.ts index 37ca83e9406..67a3e9b1314 100644 --- a/web/app/components/workflow/node-catalog.ts +++ b/web/app/components/workflow/node-catalog.ts @@ -4,16 +4,21 @@ import { useSuspenseQuery } from '@tanstack/react-query' import { useAtomValue } from 'jotai' import { useMemo } from 'react' import { useTranslation } from 'react-i18next' +import { useStore } from 'reactflow' import { WORKFLOW_COMMON_NODES } from '@/app/components/workflow/constants/node' import { isChatflowAtom } from '@/app/components/workflow/editor-resource/state' import AnswerDefault from '@/app/components/workflow/nodes/answer/default' import EndDefault from '@/app/components/workflow/nodes/end/default' +import { getHumanInputCreationPolicy } from '@/app/components/workflow/nodes/human-input-v2/migration/policy' +import HumanInputDefault from '@/app/components/workflow/nodes/human-input/default' import StartPlaceholderDefault from '@/app/components/workflow/nodes/start-placeholder/default' import StartDefault from '@/app/components/workflow/nodes/start/default' import TriggerPluginDefault from '@/app/components/workflow/nodes/trigger-plugin/default' import TriggerScheduleDefault from '@/app/components/workflow/nodes/trigger-schedule/default' import TriggerWebhookDefault from '@/app/components/workflow/nodes/trigger-webhook/default' import { BlockEnum } from '@/app/components/workflow/types' +import { getNodePersistedType } from '@/app/components/workflow/utils/node' +import { ENABLE_HITL_IM } from '@/config' import { useDocLink } from '@/context/i18n' import { isAgentV2Enabled, isAgentV2InChatflowEnabled } from '@/features/agent-v2/feature-flag' import { consoleQuery } from '@/service/console' @@ -39,6 +44,10 @@ export const useWorkflowNodeCatalog = () => { select: (features) => features.enable_knowledge_fs, }), ) + const canContinueLegacyHumanInput = useStore( + (state) => getHumanInputCreationPolicy(state.getNodes(), true).canAddLegacyHumanInput, + ) + const useLegacyHumanInput = !ENABLE_HITL_IM || canContinueLegacyHumanInput const startNodeMetaData = useMemo( () => ({ @@ -75,8 +84,10 @@ export const useWorkflowNodeCatalog = () => { () => mergedNodesMetaData.map((node) => { const { metaData } = node - const title = t(($) => $[`blocks.${metaData.type}`], { ns: 'workflow' }) - const description = t(($) => $[`blocksAbout.${metaData.type}`], { ns: 'workflow' }) + const titleKey = + metaData.type === BlockEnum.HumanInputV2 ? BlockEnum.HumanInput : metaData.type + const title = t(($) => $[`blocks.${titleKey}`], { ns: 'workflow' }) + const description = t(($) => $[`blocksAbout.${titleKey}`], { ns: 'workflow' }) const helpLinkPath = getNodeHelpLinkPath(metaData.helpLinkUri) return { ...node, @@ -88,7 +99,7 @@ export const useWorkflowNodeCatalog = () => { }, defaultValue: { ...node.defaultValue, - type: metaData.type === BlockEnum.AgentV2 ? BlockEnum.Agent : metaData.type, + type: getNodePersistedType(metaData.type), title, }, } @@ -96,14 +107,33 @@ export const useWorkflowNodeCatalog = () => { [mergedNodesMetaData, t, docLink], ) + const legacyHumanInputMetaData = useMemo(() => { + const { metaData } = HumanInputDefault + const title = t(($) => $[`blocks.${BlockEnum.HumanInput}`], { ns: 'workflow' }) + const description = t(($) => $[`blocksAbout.${BlockEnum.HumanInput}`], { ns: 'workflow' }) + + return { + ...HumanInputDefault, + metaData: { ...metaData, title, description }, + defaultValue: { + ...HumanInputDefault.defaultValue, + type: BlockEnum.HumanInput, + title, + }, + } + }, [t]) + const availableNodesMetaData = useMemo( () => - nodesMetaData.filter((node) => - shouldUseAgentV2 - ? node.metaData.type !== BlockEnum.Agent - : node.metaData.type !== BlockEnum.AgentV2, + nodesMetaData.filter( + (node) => + node.metaData.type !== + (useLegacyHumanInput ? BlockEnum.HumanInputV2 : BlockEnum.HumanInput) && + (shouldUseAgentV2 + ? node.metaData.type !== BlockEnum.Agent + : node.metaData.type !== BlockEnum.AgentV2), ), - [nodesMetaData, shouldUseAgentV2], + [nodesMetaData, shouldUseAgentV2, useLegacyHumanInput], ) const nodesMetaDataMap = useMemo( @@ -113,9 +143,11 @@ export const useWorkflowNodeCatalog = () => { acc![node.metaData.type] = node return acc }, - {} as AvailableNodesMetaData['nodesMap'], + { + [BlockEnum.HumanInput]: legacyHumanInputMetaData, + } as unknown as AvailableNodesMetaData['nodesMap'], ), - [nodesMetaData], + [nodesMetaData, legacyHumanInputMetaData], ) return useMemo( diff --git a/web/app/components/workflow/nodes/__tests__/index.hitl-v2.spec.tsx b/web/app/components/workflow/nodes/__tests__/index.hitl-v2.spec.tsx new file mode 100644 index 00000000000..fc3cabd5f7d --- /dev/null +++ b/web/app/components/workflow/nodes/__tests__/index.hitl-v2.spec.tsx @@ -0,0 +1,91 @@ +import type { ReactElement } from 'react' +import type { Node as WorkflowNode } from '../../types' +import { render, screen } from '@testing-library/react' +import { CUSTOM_NODE } from '../../constants' +import { BlockEnum } from '../../types' +import Panel from '../panel' + +vi.mock('../components', () => ({ + NodeComponentMap: { + [BlockEnum.Start]: () =>
start-node-component
, + [BlockEnum.StartPlaceholder]: () =>
start-placeholder-node-component
, + }, +})) + +vi.mock('../panel-components', () => ({ + PanelComponentMap: { + [BlockEnum.Start]: () =>
start-panel-component
, + [BlockEnum.StartPlaceholder]: () =>
start-placeholder-panel-component
, + [BlockEnum.HumanInput]: () =>
human-input-panel-component
, + }, +})) + +vi.mock('../_base/node', () => ({ + __esModule: true, + default: ({ + id, + data, + children, + }: { + id: string + data: { type: BlockEnum } + children: ReactElement + }) => ( +
+
{`base-node:${id}:${data.type}`}
+ {children} +
+ ), +})) + +vi.mock('../_base/components/workflow-panel', async () => { + const React = await vi.importActual('react') + + const MockWorkflowPanel = ({ + id, + data, + children, + }: { + id: string + data: { type: BlockEnum; version?: string } + children: ReactElement + }) => { + const [initialType] = React.useState(data.type) + const [initialVersion] = React.useState(data.version ?? '1') + + return ( +
+
{`base-panel:${id}:${data.type}`}
+
{`base-panel-initial:${initialType}`}
+
{`base-panel-initial-version:${initialVersion}`}
+ {children} +
+ ) + } + + return { + __esModule: true, + default: MockWorkflowPanel, + } +}) + +const createNodeData = (): WorkflowNode['data'] => ({ + title: 'Start', + desc: '', + type: BlockEnum.Start, +}) + +describe('workflow nodes index', () => { + it('remounts the open panel when HITL migration or undo changes only the version', () => { + const legacy = { ...createNodeData(), type: BlockEnum.HumanInput } + const migrated = { ...legacy, version: '2' } + const { rerender } = render() + expect(screen.getByText('base-panel-initial-version:1')).toBeInTheDocument() + + rerender() + expect(screen.getByText('base-panel-initial-version:2')).toBeInTheDocument() + + rerender() + expect(screen.getByText('base-panel-initial-version:1')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/workflow/nodes/_base/components/before-run-form/index.tsx b/web/app/components/workflow/nodes/_base/components/before-run-form/index.tsx index 1ae6e776bc6..40ba917cfaa 100644 --- a/web/app/components/workflow/nodes/_base/components/before-run-form/index.tsx +++ b/web/app/components/workflow/nodes/_base/components/before-run-form/index.tsx @@ -39,11 +39,12 @@ export type BeforeRunFormProps = { existVarValuesInForms: Record[] filteredExistVarForms: FormProps[] showGeneratedForm?: boolean + isGeneratingForm?: boolean handleShowGeneratedForm?: (data: Record) => void handleHideGeneratedForm?: () => void formData?: HumanInputFormData handleSubmitHumanInputForm?: (data: any) => Promise - handleAfterHumanInputStepRun?: () => void + handleAfterHumanInputStepRun?: () => void | Promise } & Partial const BeforeRunForm: FC = ({ @@ -55,6 +56,7 @@ const BeforeRunForm: FC = ({ filteredExistVarForms, existVarValuesInForms, showGeneratedForm = false, + isGeneratingForm = false, handleShowGeneratedForm, handleHideGeneratedForm, formData, @@ -88,7 +90,7 @@ const BeforeRunForm: FC = ({ const handleHumanInputFormSubmit = async (data: any) => { await handleSubmitHumanInputForm?.(data) - handleAfterHumanInputStepRun?.() + await handleAfterHumanInputStepRun?.() } const hasRun = useRef(false) @@ -129,7 +131,7 @@ const BeforeRunForm: FC = ({
{!isHumanInput && (