Revisión de coherencia de todo lo que entró. Cinco cosas mal, ninguna
detectable compilando:
1. LA PUERTA DE TOOLS DECIDÍA AL REVÉS. executeUmindTool encolaba antes de
clasificar, así que en un canal público CUALQUIER nombre de herramienta
terminaba como pendiente de aprobación — incluidos los que el modelo
inventa y los internos, cuyos guards ("solo el dueño") quedaban
inalcanzables porque vivían más abajo en el flujo. Un visitante del widget
podía llenarle al dueño la bandeja de solicitudes para ejecutar cosas que
no existen.
Ahora se clasifica primero: lectura ejecuta, interna se rechaza si el
pedido es público, acción se encola, desconocida se rechaza. Verificado
inyectando la falla: el test falla y vuelve a pasar al arreglarlo.
2. El enlace "Ver el documento" de la bandeja estaba muerto: archivo_id no lo
escribía nadie. Ahora la acción aprobada engancha el PDF que produjo.
3. Los PDF generados esquivaban la cuota de almacenamiento. El tope del plan
se evitaba pidiéndole documentos al agente en vez de subiendo archivos.
4. La vista de Consumo no conocía el tipo "documento" — el rubro nuevo salía
sin nombre ni color. Y el icono que le puse no existía en el set, así que
habría quedado invisible: el mismo bug de los iconos de hace unas semanas,
que compila y se ve vacío.
5. Las seis lecturas nuevas se registraron con w() en vez de r(), exigiendo
SoloAdmin para leer. No era una fuga —era más estricto— pero un usuario
staff sin admin veía la lista de agentes y 403 en sus avisos.
Y el guard que evita el próximo: TestTodoHandlerUmindValidaAlcance recorre los
65 handlers de uMind y exige que cada uno valide el alcance del tenant, o esté
en una lista de exenciones con el motivo escrito. Los mismos handlers se montan
para staff y para clientes; el middleware de scope deja los tenants permitidos
en el contexto pero no mira el :id de la URL — eso lo hace cada handler, y el
que se olvide compila igual. Al escribirlo el propio test encontró que mi lista
de exenciones sobraba en dos, y verifiqué con un handler con fuga deliberada
que lo detecta.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
141 lines
5.2 KiB
Go
141 lines
5.2 KiB
Go
package services
|
|
|
|
import (
|
|
"os"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// La regla de aprobación es lo que separa un asistente de una máquina de spam
|
|
// con el dominio del cliente. Si alguien la afloja, esto tiene que fallar.
|
|
func TestRequiereAprobacion(t *testing.T) {
|
|
casos := []struct {
|
|
nombre string
|
|
interna bool
|
|
tool string
|
|
requiere bool
|
|
}{
|
|
// Canal público: cualquiera escribe. Todo lo que sale, espera.
|
|
{"público no puede mandar correo", false, "enviar_correo", true},
|
|
{"público no puede llamar una herramienta", false, "avisar_stock", true},
|
|
{"público no puede generar documentos", false, "generar_documento", true},
|
|
// Leer no saca nada del negocio.
|
|
{"público sí puede buscar en el conocimiento", false, "buscar_conocimiento", false},
|
|
{"público sí puede leer la bandeja", false, "leer_bandeja", false},
|
|
// Canal interno: el dueño ya autorizó al escribirlo. Mandarlo a
|
|
// aprobar su propio pedido es fricción sin seguridad.
|
|
{"el dueño manda correo directo", true, "enviar_correo", false},
|
|
{"el dueño ejecuta herramientas directo", true, "avisar_stock", false},
|
|
}
|
|
for _, c := range casos {
|
|
if got := RequiereAprobacion(c.interna, c.tool); got != c.requiere {
|
|
t.Errorf("%s: RequiereAprobacion(interna=%v, %q) = %v, quiero %v",
|
|
c.nombre, c.interna, c.tool, got, c.requiere)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Lo que el modelo le cuenta al cliente cuando algo queda esperando no puede
|
|
// filtrar la cocina del negocio.
|
|
func TestEncolarNoFiltraElProcesoInterno(t *testing.T) {
|
|
b, err := os.ReadFile("umind_aprobacion_service.go")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
s := string(b)
|
|
i := strings.Index(s, "func EncolarAccion")
|
|
j := strings.Index(s[i:], "\nfunc ")
|
|
cuerpo := s[i : i+j]
|
|
|
|
nota := cuerpo[strings.Index(cuerpo, `"nota":`):]
|
|
nota = nota[:strings.Index(nota, "\n")]
|
|
for _, prohibido := range []string{"rechaz", "aprobación interna"} {
|
|
if strings.Contains(strings.ToLower(nota), prohibido) {
|
|
t.Errorf("la nota al cliente menciona %q: %s", prohibido, nota)
|
|
}
|
|
}
|
|
if !strings.Contains(strings.ToLower(nota), "sin mencionar") {
|
|
t.Error("la nota tiene que instruir al modelo a no mencionar el proceso interno")
|
|
}
|
|
}
|
|
|
|
// El payload se ejecuta tal cual se aprobó: si al aprobar se le volviera a
|
|
// preguntar al modelo, se aprobaría una cosa y saldría otra.
|
|
func TestAccionAprobadaUsaElPayloadGuardado(t *testing.T) {
|
|
b, err := os.ReadFile("umind_aprobacion_service.go")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
s := string(b)
|
|
i := strings.Index(s, "func EjecutarAccionAprobada")
|
|
j := strings.Index(s[i:], "\n// avisarAccionPendiente")
|
|
cuerpo := s[i : i+j]
|
|
|
|
if !strings.Contains(cuerpo, "accion.ArgumentosJSON") {
|
|
t.Error("la ejecución tiene que partir de los argumentos guardados")
|
|
}
|
|
if strings.Contains(cuerpo, "callAI") || strings.Contains(cuerpo, "ProcessWidgetMessage") {
|
|
t.Error("aprobar no puede volver a llamar al modelo: se aprobaría una cosa y saldría otra")
|
|
}
|
|
// Dos personas mirando la misma bandeja pueden aprobar a la vez.
|
|
if !strings.Contains(cuerpo, "ReclamarAccion") {
|
|
t.Error("falta el reclamo: sin él, dos aprobaciones simultáneas ejecutan dos veces")
|
|
}
|
|
}
|
|
|
|
// La clasificación es lo que decide entre ejecutar, encolar y rechazar. Sin
|
|
// ella, todo lo que no fuera lectura se encolaba —incluidos los nombres que el
|
|
// modelo inventa—, y la bandeja del dueño se llenaba de solicitudes para
|
|
// ejecutar herramientas que nunca existieron.
|
|
func TestCategoriaTool(t *testing.T) {
|
|
casos := map[string]string{
|
|
"buscar_conocimiento": "lectura",
|
|
"leer_bandeja": "lectura",
|
|
"programar_aviso": "interna",
|
|
"cancelar_aviso": "interna",
|
|
"listar_avisos": "interna",
|
|
"crear_vigilancia": "interna",
|
|
"listar_vigilancias": "interna",
|
|
"enviar_correo": "accion",
|
|
"generar_documento": "accion",
|
|
}
|
|
for tool, quiero := range casos {
|
|
// agenteID 0: las tools con nombre fijo no consultan la base.
|
|
if got := CategoriaTool(0, tool); got != quiero {
|
|
t.Errorf("CategoriaTool(%q) = %q, quiero %q", tool, got, quiero)
|
|
}
|
|
}
|
|
}
|
|
|
|
// El camino completo desde un canal público, que es donde escribe cualquiera.
|
|
func TestPuertaDeToolsDesdeCanalPublico(t *testing.T) {
|
|
b, err := os.ReadFile("umind_agent_service.go")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
s := string(b)
|
|
i := strings.Index(s, "func executeUmindTool")
|
|
cuerpo := s[i:]
|
|
cuerpo = cuerpo[:strings.Index(cuerpo, "\nfunc ejecutarHerramienta")]
|
|
|
|
// La clasificación tiene que decidir ANTES de encolar.
|
|
cat := strings.Index(cuerpo, "CategoriaTool")
|
|
enc := strings.Index(cuerpo, "EncolarAccion")
|
|
if cat < 0 || enc < 0 || cat > enc {
|
|
t.Fatal("la clasificación tiene que ir antes de encolar")
|
|
}
|
|
// Un nombre inexistente se rechaza, no se encola.
|
|
desc := cuerpo[strings.Index(cuerpo, `case "desconocida":`):strings.Index(cuerpo, `case "interna":`)]
|
|
if strings.Contains(desc, "EncolarAccion") {
|
|
t.Error("una herramienta desconocida no puede encolarse")
|
|
}
|
|
// Las internas se rechazan desde un canal público, no se encolan.
|
|
inter := cuerpo[strings.Index(cuerpo, `case "interna":`):strings.Index(cuerpo, `case "accion":`)]
|
|
if strings.Contains(inter, "EncolarAccion") {
|
|
t.Error("las tools internas no pueden encolarse desde un canal público")
|
|
}
|
|
if !strings.Contains(inter, "!sesionInterna") {
|
|
t.Error("falta el rechazo de tools internas en canal público")
|
|
}
|
|
}
|