From 4995c5fca5b7fd045b5204f5813d87d0c37cfc09 Mon Sep 17 00:00:00 2001 From: Lizandro Guarnizo <77708265+lizandrogd@users.noreply.github.com> Date: Thu, 13 Aug 2026 12:06:42 -0500 Subject: [PATCH] fix(widget): deja de mostrar los errores del servidor como respuestas del bot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit El widget hacía `data.respuesta || data.message` sin mirar el status, así que un 403/404/500 se pintaba en una burbuja como si lo hubiera dicho el agente. Imposible distinguir "el bot contestó raro" de "el widget está siendo rechazado", que es justo el lazo en el que se puede quedar alguien diagnosticando esto. Además los rechazos del middleware ocurren ANTES del motor, así que no dejaban rastro en ningún lado: ni en la auditoría del agente ni en el navegador. De ahí el síntoma "el chat de prueba anda pero el widget no". - El widget mira r.ok, manda el motivo real a la consola y al visitante le muestra un mensaje neutro. - AuthUmindWidget distingue las tres causas que el chat de prueba NO tiene (agente inactivo, tenant inactivo, dominio no autorizado) y las registra en la auditoría del agente con el detalle accionable — incluyendo qué dominio llamó y cuáles están permitidos. - GetUmindAgentePorSiteKey busca sin filtrar por activo, para poder decir "está apagado" en vez de "no existe". - Test del allowlist de dominios: es fail-closed y www.ejemplo.com NO matchea ejemplo.com, la causa más probable de este síntoma. Co-Authored-By: Claude Sonnet 5 --- pkg/models/umind_agente.go | 12 ++++ .../api/umind_widget_controller.go | 36 ++++++++++-- rest/middlewares/auth_umind_widget.go | 34 ++++++++++- rest/middlewares/auth_umind_widget_test.go | 56 +++++++++++++++++++ 4 files changed, 131 insertions(+), 7 deletions(-) create mode 100644 rest/middlewares/auth_umind_widget_test.go diff --git a/pkg/models/umind_agente.go b/pkg/models/umind_agente.go index c8274cb..3e32e85 100644 --- a/pkg/models/umind_agente.go +++ b/pkg/models/umind_agente.go @@ -70,3 +70,15 @@ func UpdateUmindAgente(id uint, updates map[string]interface{}) error { func DeleteUmindAgente(id uint) error { return app.Http.Database.DB.Delete(&UmindAgente{}, id).Error } + +// GetUmindAgentePorSiteKey resuelve el agente por site_key SIN filtrar por +// activo, para que el llamador pueda distinguir "no existe" de "está +// apagado" y decirlo. GetUmindAgenteBySiteKey (que sí filtra) queda para +// quien solo necesita el camino feliz. +func GetUmindAgentePorSiteKey(siteKey string) (*UmindAgente, error) { + var a UmindAgente + if err := app.Http.Database.DB.Where("site_key = ?", siteKey).First(&a).Error; err != nil { + return nil, err + } + return &a, nil +} diff --git a/rest/controllers/api/umind_widget_controller.go b/rest/controllers/api/umind_widget_controller.go index d44ee81..bae9e2d 100644 --- a/rest/controllers/api/umind_widget_controller.go +++ b/rest/controllers/api/umind_widget_controller.go @@ -200,17 +200,37 @@ const umindWidgetJS = `(function () { msgsEl.scrollTop = msgsEl.scrollHeight; } + // El servidor responde los errores como {"error":true,"message":"..."} con + // un status 4xx/5xx. Antes esto se leía igual que una respuesta buena y el + // texto del error terminaba en una burbuja, como si lo hubiera dicho el + // agente: imposible distinguir "el bot contestó raro" de "el widget está + // rechazado". Ahora el motivo real va a la consola y al visitante se le + // muestra un mensaje neutro. + function leerRespuesta(r) { + return r.json().catch(function () { return {}; }).then(function (data) { + if (!r.ok || data.error) { + var motivo = data.message || ('HTTP ' + r.status); + console.error('[uMind] el asistente no pudo responder:', motivo, + '— revisá la pestaña Auditoría del agente en uMind Studio.'); + var e = new Error(motivo); + e.esDelServidor = true; + throw e; + } + return data; + }); + } + function iniciar() { sessionId = sessionStorage.getItem(storageKey) || null; fetch(apiBase + '/init') - .then(function (r) { return r.json(); }) + .then(leerRespuesta) .then(function (data) { if (!sessionId) { sessionId = data.session_id; sessionStorage.setItem(storageKey, sessionId); } if (data.nombre) panel.querySelector('#umind-title').textContent = data.nombre; aplicarColor(data.color); agregarMensaje(data.mensaje_bienvenida, 'bot'); }) - .catch(function () { agregarMensaje('No se pudo conectar el asistente.', 'bot'); }); + .catch(function () { agregarMensaje('El asistente no está disponible en este momento.', 'bot'); }); } iniciar(); // arranca ya, así el color/nombre reales están listos antes del primer click @@ -224,12 +244,18 @@ const umindWidgetJS = `(function () { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ session_id: sessionId, mensaje: texto }) }) - .then(function (r) { return r.json(); }) + .then(leerRespuesta) .then(function (data) { if (data.session_id) { sessionId = data.session_id; sessionStorage.setItem(storageKey, sessionId); } - agregarMensaje(data.respuesta || data.message || 'No pude responder eso.', 'bot'); + agregarMensaje(data.respuesta || 'No pude responder eso.', 'bot'); }) - .catch(function () { agregarMensaje('Error de conexión, intenta de nuevo.', 'bot'); }); + .catch(function (e) { + agregarMensaje( + e && e.esDelServidor + ? 'El asistente no está disponible en este momento.' + : 'Error de conexión, intenta de nuevo.', + 'bot'); + }); } function alternar() { diff --git a/rest/middlewares/auth_umind_widget.go b/rest/middlewares/auth_umind_widget.go index 81f7aa9..032fcc4 100644 --- a/rest/middlewares/auth_umind_widget.go +++ b/rest/middlewares/auth_umind_widget.go @@ -1,6 +1,8 @@ package middlewares import ( + "fmt" + "log" "net/url" "strings" @@ -19,12 +21,33 @@ func AuthUmindWidget(c *fiber.Ctx) error { if siteKey == "" { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": true, "message": "site_key requerida"}) } - agente, err := models.GetUmindAgenteBySiteKey(siteKey) + + // Se busca SIN filtrar por activo para poder distinguir "no existe" de + // "está apagado". Estos rechazos ocurren antes del motor del agente, así + // que sin registrarlos el síntoma es "el chat de prueba anda pero el + // widget no" sin ninguna pista de por qué — que es exactamente el lazo en + // el que se puede quedar alguien diagnosticando esto. + agente, err := models.GetUmindAgentePorSiteKey(siteKey) if err != nil { + log.Printf("[UMIND_WIDGET] site_key desconocida: %q (origen %q)", siteKey, c.Get("Origin")) return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": true, "message": "sitio no encontrado"}) } + if !agente.Activo { + models.RegistrarEventoUmind(agente.ID, "error", "widget", + "El widget fue rechazado porque el agente está inactivo", + "Activá el agente para que el widget vuelva a responder. El chat de prueba sí funciona con un agente inactivo, por eso la diferencia.") + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": true, "message": "sitio no encontrado"}) + } + tenant, err := models.GetUmindTenantByID(agente.TenantID) - if err != nil || !tenant.Activo { + if err != nil { + models.RegistrarEventoUmind(agente.ID, "error", "widget", "El widget no encontró el tenant del agente", err.Error()) + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": true, "message": "sitio no encontrado"}) + } + if !tenant.Activo { + models.RegistrarEventoUmind(agente.ID, "error", "widget", + "El widget fue rechazado porque el tenant está inactivo", + fmt.Sprintf("Activá el tenant %q para que el widget vuelva a responder.", tenant.Nombre)) return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": true, "message": "sitio no encontrado"}) } @@ -34,6 +57,13 @@ func AuthUmindWidget(c *fiber.Ctx) error { } host := hostDeOrigen(origen) if !tenant.DominioPermitido(host) { + detalle := fmt.Sprintf("Llamada desde %q. Dominios permitidos del tenant: %q.", host, tenant.DominiosPermitidos) + if strings.TrimSpace(tenant.DominiosPermitidos) == "" { + detalle = fmt.Sprintf("Llamada desde %q, pero el tenant no tiene ningún dominio permitido configurado (la lista vacía no permite nada).", host) + } else if host == "" { + detalle = "La petición llegó sin Origin ni Referer, así que no se pudo verificar de qué sitio viene." + } + models.RegistrarEventoUmind(agente.ID, "error", "widget", "El widget fue rechazado por dominio no autorizado", detalle) return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": true, "message": "dominio no autorizado para este sitio"}) } diff --git a/rest/middlewares/auth_umind_widget_test.go b/rest/middlewares/auth_umind_widget_test.go new file mode 100644 index 0000000..c58feff --- /dev/null +++ b/rest/middlewares/auth_umind_widget_test.go @@ -0,0 +1,56 @@ +package middlewares + +import ( + "testing" + + "github.com/sujit-baniya/fiber-boilerplate/pkg/models" +) + +// El widget valida el dominio; el chat de prueba no. Cuando alguien reporta +// "el chat de prueba anda pero el widget no", esta comparación es casi siempre +// la causa, así que la regla queda fijada acá. +func TestDominioPermitidoEsFailClosed(t *testing.T) { + casos := []struct { + nombre string + lista string + host string + permitido bool + }{ + {"lista vacía no permite nada", "", "ejemplo.com", false}, + {"sin host no permite", "ejemplo.com", "", false}, + {"dominio exacto", "ejemplo.com", "ejemplo.com", true}, + {"dominio distinto", "ejemplo.com", "otro.com", false}, + {"www no entra por el dominio pelado", "ejemplo.com", "www.ejemplo.com", false}, + {"www listado explícitamente", "ejemplo.com,www.ejemplo.com", "www.ejemplo.com", true}, + {"comodín cubre subdominio", "*.ejemplo.com", "app.ejemplo.com", true}, + {"comodín no cubre otro dominio", "*.ejemplo.com", "app.otro.com", false}, + {"espacios alrededor no molestan", " ejemplo.com , otro.com ", "otro.com", true}, + {"mayúsculas no molestan", "Ejemplo.COM", "ejemplo.com", true}, + } + + for _, cas := range casos { + t.Run(cas.nombre, func(t *testing.T) { + tenant := &models.UmindTenant{DominiosPermitidos: cas.lista} + if got := tenant.DominioPermitido(cas.host); got != cas.permitido { + t.Errorf("DominioPermitido(%q) con lista %q = %v, esperaba %v", + cas.host, cas.lista, got, cas.permitido) + } + }) + } +} + +// hostDeOrigen recibe el header Origin o Referer, no un host pelado. +func TestHostDeOrigen(t *testing.T) { + casos := map[string]string{ + "https://ejemplo.com": "ejemplo.com", + "https://ejemplo.com/una/pagina": "ejemplo.com", + "https://ejemplo.com:8443": "ejemplo.com", // el puerto no cuenta + "http://WWW.Ejemplo.com": "www.ejemplo.com", + "": "", + } + for entrada, esperado := range casos { + if got := hostDeOrigen(entrada); got != esperado { + t.Errorf("hostDeOrigen(%q) = %q, esperaba %q", entrada, got, esperado) + } + } +}