From e567e076ae6409458822875334c48e26d49a566d Mon Sep 17 00:00:00 2001 From: Andrey Date: Sun, 30 Aug 2026 12:57:13 +0300 Subject: [PATCH] =?UTF-8?q?:bug:=20fix(webchat):=20=D1=87=D0=B8=D0=BD?= =?UTF-8?q?=D0=B8=D1=82=20=D0=BC=D0=BE=D0=BB=D1=87=D0=B0=D0=BB=D0=B8=D0=B2?= =?UTF-8?q?=D0=BE=D0=B5=20=C2=AB=D0=A7=D0=B0=D1=82=20=D0=B2=D1=80=D0=B5?= =?UTF-8?q?=D0=BC=D0=B5=D0=BD=D0=BD=D0=BE=20=D0=BD=D0=B5=D0=B4=D0=BE=D1=81?= =?UTF-8?q?=D1=82=D1=83=D0=BF=D0=B5=D0=BD=C2=BB=20=D1=83=20Web-=D0=B2?= =?UTF-8?q?=D0=B8=D0=B4=D0=B6=D0=B5=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Разрешённые домены виджета нельзя было задать из интерфейса: поля не было, а показанное для WEB поле «Base URL» в конфиг Web-виджета не уходило. Любое новое подключение сохранялось с пустым allowed_origins, а пустой список в проде запрещает вообще все origin'ы (origin_allowed) — виджет на сайте молча отвечал «Чат временно недоступен», при зелёном статусе подключения в CRM. - поле «Разрешённые домены» в форме подключения: разбор через запятую/перенос, дедупликация и валидация трёх форм, которые понимает origin_allowed (example.com, *.example.com, https://example.com:8443); сохранение заблокировано, пока не введён хотя бы один валидный домен - мёртвое поле «Base URL» для WEB убрано - «Проверить» больше не даёт зелёный статус при пустом списке доменов, а пишет причину в строку подключения - проверка Web-виджета запускается сразу после сохранения: раньше любая правка роняла подключение в UNCHECKED, а виджет в DRAFT, и чат на сайте умирал до ручного нажатия «Проверить» (раздача требует PUBLISHED + OK) Co-Authored-By: Claude Opus 5 --- .../hub_platform/integrations/services.py | 26 +++++++-- .../hub_platform/integrations/tests.py | 58 ++++++++++++++++++- .../features/integrations/IntegrationForm.tsx | 37 ++++++++++-- .../src/features/integrations/model.test.ts | 43 ++++++++++++++ .../src/features/integrations/model.ts | 26 +++++++++ 5 files changed, 178 insertions(+), 12 deletions(-) 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 && (
Для Gmail и Яндекс используйте пароль приложения, не основной пароль аккаунта
)} - {!isEmail && } + {!isEmail && !isWeb && } + {isWeb && ( + <> + +
Сайты, на которых виджету разрешено открываться: домен, поддомены через «*.» или полный origin с портом. На остальных чат ответит «Чат временно недоступен»
+ + )} {!isWeb && !isEmail && ( )} diff --git a/apps/internal-ui/src/features/integrations/model.test.ts b/apps/internal-ui/src/features/integrations/model.test.ts index a93c64e..b78a701 100644 --- a/apps/internal-ui/src/features/integrations/model.test.ts +++ b/apps/internal-ui/src/features/integrations/model.test.ts @@ -37,6 +37,49 @@ describe("webWidgetSnippet", () => { }); }); +describe("parseAllowedOrigins", () => { + it("splits on commas, semicolons and newlines and trims each entry", async () => { + const { parseAllowedOrigins } = await import("./model"); + + expect(parseAllowedOrigins(` edevs.tech , +*.edevs.tech; foxray.pro `)).toEqual([ + "edevs.tech", + "*.edevs.tech", + "foxray.pro", + ]); + }); + + it("drops empty entries, trailing slashes and case-insensitive duplicates", async () => { + const { parseAllowedOrigins } = await import("./model"); + + expect(parseAllowedOrigins("edevs.tech/, ,, EDEVS.TECH, edevs.tech")).toEqual(["edevs.tech"]); + }); + + it("returns an empty list for blank input so the form can require a domain", async () => { + const { parseAllowedOrigins } = await import("./model"); + + expect(parseAllowedOrigins(` + `)).toEqual([]); + }); +}); + +describe("invalidAllowedOrigin", () => { + it("accepts the three forms origin_allowed understands", async () => { + const { invalidAllowedOrigin } = await import("./model"); + + expect( + invalidAllowedOrigin(["edevs.tech", "*.edevs.tech", "https://app.custocrm.ru", "localhost:5173"]), + ).toBeUndefined(); + }); + + it("reports the first entry that is not a domain", async () => { + const { invalidAllowedOrigin } = await import("./model"); + + expect(invalidAllowedOrigin(["edevs.tech", "https://edevs.tech/chat"])).toBe("https://edevs.tech/chat"); + expect(invalidAllowedOrigin(["не домен"])).toBe("не домен"); + }); +}); + // Гарантия отсутствия build-time привязки: сниппет выводится от текущего origin в // рантайме, поэтому один frontend-образ работает на любом домене без пересборки // (ADR-HUB-0028 §10). diff --git a/apps/internal-ui/src/features/integrations/model.ts b/apps/internal-ui/src/features/integrations/model.ts index c0bd7fc..2374e11 100644 --- a/apps/internal-ui/src/features/integrations/model.ts +++ b/apps/internal-ui/src/features/integrations/model.ts @@ -98,6 +98,32 @@ export const fetchLlmProviders = () => response.items.filter((item) => item.kind === "LLM_PROVIDER") ); +// Разрешённые домены Web-виджета (SPEC-HUB-0010 §7.1). Пустой список в проде +// запрещает все origin'ы, поэтому домены вводятся руками и обязательны. +// Принимаем три формы, которые понимает backend (webchat.services.origin_allowed): +// `example.com`, `*.example.com` и `https://example.com:8443`; `localhost` — для разработки. +const ORIGIN_RULE = /^(?:https?:\/\/)?(?:\*\.)?[a-z0-9-]+(?:\.[a-z0-9-]+)*(?::\d{1,5})?$/i; + +// Ввод — свободный текст: домены разделяются запятой, точкой с запятой или переносом. +export function parseAllowedOrigins(input: string): string[] { + const seen = new Set(); + const result: string[] = []; + for (const raw of input.split(/[\s,;]+/)) { + const item = raw.trim().replace(/\/+$/, ""); + const key = item.toLowerCase(); + if (!item || seen.has(key)) continue; + seen.add(key); + result.push(item); + } + return result; +} + +export const formatAllowedOrigins = (origins: string[]): string => origins.join(", "); + +// Возвращает первый непонятный домен — форма показывает его в ошибке поля. +export const invalidAllowedOrigin = (origins: string[]): string | undefined => + origins.find((item) => !ORIGIN_RULE.test(item)); + // Публичный домен Hub для встраивания Web-виджета (SPEC-HUB-0003 §3). // Один frontend-образ работает на любом домене (ADR-HUB-0028 §runtime frontend): // сниппет генерируется от текущего origin в рантайме, а не от build-time аргумента.