Auditando el aislamiento apareció el agujero al revés del que se buscaba: no un cliente leyendo datos de otro, sino la cuenta de IA de un cliente pagando trabajo nuestro. GetAiConfigForService recorre las configs activas y devuelve la primera sin módulo asignado. Las configs de cliente no llevan módulo — ninguna lo lleva, es parte del diseño — así que caían justo en ese fallback. Con un cliente que hubiera conectado su cuenta, su clave terminaba clasificando correos de soporte, importando plantillas o atendiendo la vCard. Ninguno de los dos se enteraba: la respuesta llegaba igual y la factura le llegaba a él. Todos los resolvedores globales filtran ahora tenant_id IS NULL. Un test lo verifica sobre el código de cada uno, porque son consultas a base y acá no hay una. El de embeddings además no podía ser de cliente por otra razón: los vectores de todos los agentes tienen que salir del mismo modelo o la similitud coseno entre ellos no significa nada. Un cliente con su propio modelo de embeddings rompía su propia búsqueda sin un solo error visible. Del alcance entre clientes, que era lo que se auditaba: los 39 handlers de uMind validan, y el CRUD de espacios y planes ni siquiera se monta en las rutas del portal. Lo que faltaba era prueba: UmindScopeDe distingue "staff" de "cliente sin espacios" por nil contra slice vacío, y esa diferencia no tenía un solo test. Ahora la cubre uno que además falla si se invierte el fail-closed de una ruta sin scope — probado inyectando las dos fugas. Y dos cosas que quedaban colgando: En /app/ai-config toda config de cliente se mostraba como "Global", que es justo lo que no es. Ahora dice de qué espacio es, por nombre. El consumo de una cuenta propia se registraba con el costo del plan. Se sigue midiendo —el cliente quiere ver cuánto usa su asistente— pero con costo cero y marcado como cuenta propia: cobrarlo también sería cobrar dos veces lo mismo. En la pantalla de consumo aparece "va por tu cuenta de IA" en vez de un "$0" que parecería un error. El OCR y la transcripción siguen costando: son servicios nuestros, los use quien los use. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
101 lines
3.0 KiB
Go
101 lines
3.0 KiB
Go
package middlewares
|
|
|
|
import (
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"github.com/gofiber/fiber/v2"
|
|
)
|
|
|
|
// Este archivo prueba la única pieza que separa los datos de un cliente de los
|
|
// de otro. Un fallo acá no es un bug de pantalla: es el cliente A leyendo las
|
|
// conversaciones, el conocimiento y las claves del cliente B.
|
|
|
|
// Se prueba a través de una petición real en vez de fabricar un contexto a
|
|
// mano: es el mismo camino que recorre el middleware en producción.
|
|
func permiteVer(t *testing.T, scope interface{}, poner bool, tenantID uint) bool {
|
|
t.Helper()
|
|
app := fiber.New()
|
|
var permitido bool
|
|
app.Get("/x", func(c *fiber.Ctx) error {
|
|
if poner {
|
|
c.Locals(umindScopeKey, scope)
|
|
}
|
|
permitido = UmindPuedeVerTenant(c, tenantID)
|
|
return c.SendStatus(200)
|
|
})
|
|
req := httptest.NewRequest("GET", "/x", nil)
|
|
if _, err := app.Test(req); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return permitido
|
|
}
|
|
|
|
func scopeResuelto(t *testing.T, scope interface{}) ([]uint, bool) {
|
|
t.Helper()
|
|
app := fiber.New()
|
|
var ids []uint
|
|
var esStaff bool
|
|
app.Get("/x", func(c *fiber.Ctx) error {
|
|
c.Locals(umindScopeKey, scope)
|
|
ids, esStaff = UmindScopeDe(c)
|
|
return c.SendStatus(200)
|
|
})
|
|
if _, err := app.Test(httptest.NewRequest("GET", "/x", nil)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return ids, esStaff
|
|
}
|
|
|
|
func TestUnClienteNoVeElEspacioDeOtro(t *testing.T) {
|
|
casos := []struct {
|
|
nombre string
|
|
scope interface{}
|
|
poner bool
|
|
tenant uint
|
|
permitir bool
|
|
}{
|
|
// El caso que importa: alcance sobre 1 y 7, pregunta por el 9.
|
|
{"espacio ajeno", []uint{1, 7}, true, 9, false},
|
|
{"espacio propio", []uint{1, 7}, true, 7, true},
|
|
{"otro espacio propio", []uint{1, 7}, true, 1, true},
|
|
|
|
// Un cliente sin espacios no ve absolutamente nada.
|
|
{"cliente sin espacios", []uint{}, true, 1, false},
|
|
|
|
// nil = staff, ve todo. Es la distinción más delicada del archivo:
|
|
// confundir "lista vacía" con nil le daría acceso total a un cliente.
|
|
{"staff", []uint(nil), true, 999, true},
|
|
|
|
// Ruta sin middleware de scope: fail-closed. Que se rompa visible en
|
|
// vez de servir los datos de todos por omisión.
|
|
{"sin scope en el contexto", nil, false, 1, false},
|
|
|
|
// Un valor de otro tipo en la clave tampoco puede abrir la puerta.
|
|
{"scope corrupto", "todos", true, 1, false},
|
|
}
|
|
|
|
for _, cs := range casos {
|
|
got := permiteVer(t, cs.scope, cs.poner, cs.tenant)
|
|
if got != cs.permitir {
|
|
t.Errorf("%s: UmindPuedeVerTenant(%d) = %v, esperaba %v", cs.nombre, cs.tenant, got, cs.permitir)
|
|
}
|
|
}
|
|
}
|
|
|
|
// La diferencia entre nil y slice vacío es la que decide si alguien es staff.
|
|
// Si un cliente sin espacios quedara como nil, pasaría a ver todo.
|
|
func TestSliceVacioNoEsStaff(t *testing.T) {
|
|
ids, esStaff := scopeResuelto(t, []uint{})
|
|
if esStaff {
|
|
t.Error("un cliente sin espacios NO puede resolver como staff")
|
|
}
|
|
if len(ids) != 0 {
|
|
t.Errorf("esperaba lista vacía, dio %v", ids)
|
|
}
|
|
|
|
if _, esStaff := scopeResuelto(t, []uint(nil)); !esStaff {
|
|
t.Error("nil tiene que resolver como staff: es como lo deja UmindScopeStaff")
|
|
}
|
|
}
|