diff --git a/apps/backend/hub_platform/integrations/services.py b/apps/backend/hub_platform/integrations/services.py index 275c44c..6e635da 100644 --- a/apps/backend/hub_platform/integrations/services.py +++ b/apps/backend/hub_platform/integrations/services.py @@ -126,6 +126,19 @@ def _normalized_config(provider: str, config: dict) -> dict: return result +def _publish_web_widget(*, context: TenantContext, integration: Integration) -> None: + """Web-виджет проверяется локально, без внешнего API, — поэтому проверяем сразу + после сохранения. Иначе любая правка подключения оставляла бы виджет в DRAFT, а + подключение в UNCHECKED; раздача требует PUBLISHED + OK (webchat.views), и чат на + сайте молча падал бы в «Чат временно недоступен» до ручного «Проверить».""" + from hub_platform.webchat.widgets import ensure_widget + + # Отдельным вызовом — чтобы конфликт смены режима остался 400 на сохранении; + # внутри проверки та же ошибка превратилась бы в статус ERROR. + ensure_widget(integration) + test_integration(context=context, integration=integration) + + def _validate_provider(provider: str) -> str: if provider not in IntegrationProvider.values: raise ValidationError({"provider": "Unknown provider"}) @@ -157,9 +170,7 @@ def create_integration(*, context: TenantContext, data: IntegrationInput) -> Int integration.full_clean(exclude=["secret"]) integration.save() if integration.provider == IntegrationProvider.WEB: - from hub_platform.webchat.widgets import ensure_widget - - ensure_widget(integration) + _publish_web_widget(context=context, integration=integration) record_usage( context=context, quota_key=QuotaKey.CLIENT_CONNECTIONS, @@ -196,9 +207,7 @@ def update_integration( integration.full_clean(exclude=["secret"]) integration.save() if integration.provider == IntegrationProvider.WEB: - from hub_platform.webchat.widgets import ensure_widget - - ensure_widget(integration) + _publish_web_widget(context=context, integration=integration) return integration @@ -229,6 +238,11 @@ def _check_web(context: TenantContext, integration: Integration) -> tuple[bool, return False, "; ".join(error.messages), {} if widget is None: return False, "Конфигурация Web-виджета не создана", {} + # Пустой allowed_origins в проде запрещает вообще все домены (webchat.services. + # origin_allowed), и на сайте виджет молча показывает «Чат временно недоступен». + # Проверка обязана падать здесь, а не оставлять зелёный статус при мёртвом чате. + if not widget.allowed_origins: + return False, "Не заданы разрешённые домены — виджет будет недоступен на сайте", {} return True, f"Web-виджет активен · канал «{integration.channel.name}»", {} diff --git a/apps/backend/hub_platform/integrations/tests.py b/apps/backend/hub_platform/integrations/tests.py index 2fbee7b..ee10a4c 100644 --- a/apps/backend/hub_platform/integrations/tests.py +++ b/apps/backend/hub_platform/integrations/tests.py @@ -38,10 +38,15 @@ class WebIntegrationCheckTests(TestCase): self.channel = Channel.objects.create(organization=self.organization, code="edevs", name="Edevs — главный сайт") - def _web(self, name: str, channel=None) -> Integration: + def _web(self, name: str, channel=None, origins=("edevs.tech",)) -> Integration: return create_integration( context=self.context, - data=IntegrationInput(provider=IntegrationProvider.WEB, name=name, channel_id=channel.id if channel else None), + data=IntegrationInput( + provider=IntegrationProvider.WEB, + name=name, + channel_id=channel.id if channel else None, + config={"allowedOrigins": list(origins)}, + ), ) def test_web_without_channel_fails(self) -> None: @@ -75,6 +80,55 @@ class WebIntegrationCheckTests(TestCase): second.web_chat_widget.public_key, ) + def test_web_without_allowed_origins_fails(self) -> None: + """Пустой allowed_origins в проде запрещает все домены, и виджет молча + показывает «Чат временно недоступен» — проверка обязана это ловить.""" + integration = run_integration_test( + context=self.context, + integration=self._web("Виджет", channel=self.channel, origins=()), + ) + self.assertEqual(integration.status, IntegrationStatus.ERROR) + self.assertIn("Не заданы разрешённые домены", integration.last_error) + self.assertEqual(integration.web_chat_widget.status, "DRAFT") + + def test_allowed_origins_reach_the_widget(self) -> None: + integration = run_integration_test( + context=self.context, + integration=self._web( + "Виджет", channel=self.channel, origins=("edevs.tech", "*.edevs.tech") + ), + ) + self.assertEqual(integration.status, IntegrationStatus.OK) + self.assertEqual( + integration.web_chat_widget.allowed_origins, ["edevs.tech", "*.edevs.tech"] + ) + self.assertEqual( + integration_payload(integration)["config"]["allowedOrigins"], + ["edevs.tech", "*.edevs.tech"], + ) + + def test_saving_connection_keeps_the_widget_published(self) -> None: + """Регрессия: раздача виджета требует PUBLISHED + OK (webchat.views), а правка + подключения не должна ни ронять чат на сайте до ручного «Проверить», ни + обнулять домены — именно так виджеты уходили в «Чат временно недоступен».""" + integration = self._web("Виджет", channel=self.channel) + self.assertEqual(integration.status, IntegrationStatus.OK) + self.assertEqual(integration.web_chat_widget.status, "PUBLISHED") + + renamed = update_integration( + context=self.context, + integration=integration, + data=IntegrationInput( + provider=IntegrationProvider.WEB, + name="Виджет · переименован", + channel_id=self.channel.id, + config={"allowedOrigins": ["edevs.tech"]}, + ), + ) + self.assertEqual(renamed.status, IntegrationStatus.OK) + self.assertEqual(renamed.web_chat_widget.status, "PUBLISHED") + self.assertEqual(renamed.config["allowed_domains"], ["edevs.tech"]) + self.assertEqual(renamed.web_chat_widget.allowed_origins, ["edevs.tech"]) class ProxyConfigTests(TestCase): def setUp(self) -> None: diff --git a/apps/internal-ui/src/features/integrations/IntegrationForm.tsx b/apps/internal-ui/src/features/integrations/IntegrationForm.tsx index 80f25c5..ee581ad 100644 --- a/apps/internal-ui/src/features/integrations/IntegrationForm.tsx +++ b/apps/internal-ui/src/features/integrations/IntegrationForm.tsx @@ -6,7 +6,18 @@ import { Icon } from "../../shared/icons"; import { FormField, SelectField } from "../../shared/form-controls"; import { Button } from "../../shared/ui-controls"; import { EMAIL_CONFIG_DEFAULTS, EmailFields, emailConfigFromIntegration, emailConfigPayload } from "./EmailFields"; -import { fetchChannels, PROVIDERS, webWidgetSnippet, type ChannelOption, type Integration, type IntegrationKind, type IntegrationProvider } from "./model"; +import { + fetchChannels, + formatAllowedOrigins, + invalidAllowedOrigin, + parseAllowedOrigins, + PROVIDERS, + webWidgetSnippet, + type ChannelOption, + type Integration, + type IntegrationKind, + type IntegrationProvider, +} from "./model"; // Селектор «Тип» показывает только провайдеров рода активного таба (SPEC-HUB-0025 §2.2). function providerOptions(kind: IntegrationKind): Array<[string, string]> { @@ -24,6 +35,7 @@ export function IntegrationForm({ initial, kind, onClose, onSaved }: { initial: const [baseUrl, setBaseUrl] = useState(initial?.config.baseUrl ?? ""); const [defaultModel, setDefaultModel] = useState(initial?.config.defaultModel ?? ""); const [proxyUrl, setProxyUrl] = useState(initial?.config.proxyUrl ?? ""); + const [allowedOrigins, setAllowedOrigins] = useState(formatAllowedOrigins(initial?.config.allowedOrigins ?? [])); const [emailConfig, setEmailConfig] = useState(initial ? emailConfigFromIntegration(initial.config) : EMAIL_CONFIG_DEFAULTS); const [channelId, setChannelId] = useState(initial?.channel ? String(initial.channel.id) : ""); const [isNotifier, setIsNotifier] = useState(initial?.config.purpose === "notifications"); @@ -55,7 +67,12 @@ export function IntegrationForm({ initial, kind, onClose, onSaved }: { initial: }, [isMessenger]); const customReady = provider !== "CUSTOM" || (baseUrl.trim().length > 0 && defaultModel.trim().length > 0); const emailReady = !isEmail || Boolean(emailConfig.email.trim() && emailConfig.imapHost.trim() && emailConfig.smtpHost.trim()); - const ready = name.trim().length > 0 && customReady && emailReady && (isEdit || !meta.testable || secret.trim().length > 0); + // Без доменов виджет сохранится, но на сайте покажет «Чат временно недоступен»: + // origin_allowed на пустом списке запрещает всё. Поэтому поле обязательное. + const originList = parseAllowedOrigins(allowedOrigins); + const badOrigin = isWeb ? invalidAllowedOrigin(originList) : undefined; + const webReady = !isWeb || (originList.length > 0 && !badOrigin); + const ready = name.trim().length > 0 && customReady && emailReady && webReady && (isEdit || !meta.testable || secret.trim().length > 0); async function submit() { if (!ready) return; @@ -65,7 +82,7 @@ export function IntegrationForm({ initial, kind, onClose, onSaved }: { initial: ? emailConfigPayload(emailConfig) : isWeb ? { - allowedOrigins: initial?.config.allowedOrigins ?? [], + allowedOrigins: originList, title: initial?.config.title ?? "", accent: initial?.config.accent ?? "", greeting: initial?.config.greeting ?? "", @@ -118,7 +135,19 @@ export function IntegrationForm({ initial, kind, onClose, onSaved }: { initial: {isEmail && !isEdit && (