diff --git a/main.go b/main.go index d4795b6..5e0bfc2 100755 --- a/main.go +++ b/main.go @@ -198,6 +198,7 @@ func main() { migrations.SeedPagosExternos() migrations.SeedAutomatizacionIA() migrations.SeedUmind() + migrations.SeedDocumentacion() // Va antes de MigrarUmindAgentes: libera las columnas huérfanas que // dejó el refactor multi-agente. Sin esto no se puede insertar nada en // uMind (ver el comentario de la función). @@ -228,6 +229,20 @@ func main() { defer services.DetenerCron() // Cargar rutas routes.LoadRoutes(app.Http.Server.App) + + // Con las rutas ya montadas se puede avisar de los ítems del menú que + // apuntan a una URL inexistente — el síntoma es un 404 que parece un + // problema de permisos. + rutasGET := map[string]bool{} + for _, capa := range app.Http.Server.Stack() { + for _, r := range capa { + if r.Method == "GET" { + rutasGET[r.Path] = true + } + } + } + migrations.VerificarUrlsDeSubmodulos(rutasGET) + app.Http.Route404() log.Fatal(app.Http.Server.ServeWithGraceFullShutdown()) } diff --git a/migrations/migrate.go b/migrations/migrate.go index 95ab203..ec91af1 100755 --- a/migrations/migrate.go +++ b/migrations/migrate.go @@ -3,6 +3,7 @@ package migrations import ( "fmt" "log" + "strings" "time" "github.com/sujit-baniya/fiber-boilerplate/app" @@ -239,9 +240,15 @@ func SeedStatuspage() { } entries := []struct{ title, desc, url string }{ - {"Statuspage", "Monitoreo de estado de servicios vía Atlassian Statuspage", "/app/statuspage"}, + // La página real es la pública /status (rest/routes/publicas.go): + // /app/statuspage nunca existió y el ítem daba 404 al entrar. + {"Statuspage", "Monitoreo de estado de servicios vía Atlassian Statuspage", "/status"}, } + // La URL vieja apuntaba a /app/statuspage, que no existe: se corrige el + // registro ya creado en vez de dejar un ítem de menú que da 404. + db.Model(&models.Submodules{}).Where("url = ?", "/app/statuspage").Update("url", "/status") + var insertados []models.Submodules for _, e := range entries { var sub models.Submodules @@ -1533,3 +1540,85 @@ func LiberarColumnasHuerfanasUmind() { log.Printf("[MIGRACION] %s.%s ya no es NOT NULL (columna huérfana del refactor multi-agente)", h.Tabla, h.Columna) } } + +// VerificarUrlsDeSubmodulos avisa en el arranque de los ítems del menú cuya +// URL no corresponde a ninguna ruta registrada. +// +// Un submódulo es una URL escrita a mano (en un seed o desde /app/submodules). +// Si no coincide con una ruta real, el ítem igual aparece en el menú y se le +// puede asignar permiso a un rol — y recién al hacer clic aparece un 404, sin +// nada que explique por qué. Esto lo convierte en una línea de log al +// arrancar, en vez de un misterio en producción. +// +// Solo informa: no borra ni modifica nada, porque una URL "rota" puede ser una +// ruta servida por otro lado. +func VerificarUrlsDeSubmodulos(rutasGET map[string]bool) { + var subs []models.Submodules + if err := app.Http.Database.DB.Find(&subs).Error; err != nil { + log.Printf("[MENU] no se pudieron revisar las URLs de los submódulos: %v", err) + return + } + + rotos := 0 + for _, s := range subs { + url := strings.TrimSpace(s.Url) + if url == "" { + log.Printf("[MENU] el submódulo %q (ID %d) no tiene URL", s.Title, s.ID) + rotos++ + continue + } + if !rutasGET[url] { + log.Printf("[MENU] el submódulo %q (ID %d) apunta a %q, que no es ninguna ruta: va a dar 404", s.Title, s.ID, url) + rotos++ + } + } + if rotos == 0 { + log.Printf("[MENU] %d submódulos revisados, todas las URLs resuelven", len(subs)) + } else { + log.Printf("[MENU] %d de %d submódulos apuntan a una URL inexistente (ver líneas anteriores)", rotos, len(subs)) + } +} + +// SeedDocumentacion registra los submódulos de Documentación. +// +// Existían las rutas y las vistas pero nunca el seed, así que había que crear +// el ítem del menú a mano desde /app/submodules — y una URL mal tipeada ahí es +// indistinguible de un permiso mal asignado: en los dos casos se ve un 404. +func SeedDocumentacion() { + db := app.Http.Database.DB + + var modulo models.Modules + if err := db.Where("title = ?", "Documentación").First(&modulo).Error; err != nil { + modulo = models.Modules{ + Title: "Documentación", + Description: "Páginas y categorías de documentación interna", + ModifiedAt: time.Now(), + } + if err := db.Create(&modulo).Error; err != nil { + log.Printf("[SEED] Error creando módulo Documentación: %v", err) + return + } + } + + entries := []struct{ title, desc, url string }{ + {"Doc: Páginas", "Páginas de documentación", "/app/doc/paginas"}, + {"Doc: Categorías", "Categorías de documentación", "/app/doc/categorias"}, + } + for _, e := range entries { + var sub models.Submodules + if err := db.Where("url = ?", e.url).First(&sub).Error; err != nil { + sub = models.Submodules{ + Title: e.title, + Description: e.desc, + Url: e.url, + ModuleId: modulo.ID, + ModifiedAt: time.Now(), + } + if err := db.Create(&sub).Error; err != nil { + log.Printf("[SEED] Error creando submódulo '%s': %v", e.title, err) + continue + } + log.Printf("[SEED] Submódulo '%s' creado (ID %d)", e.title, sub.ID) + } + } +} diff --git a/rest/middlewares/menu.go b/rest/middlewares/menu.go index d563e99..1aa6bb0 100755 --- a/rest/middlewares/menu.go +++ b/rest/middlewares/menu.go @@ -87,25 +87,15 @@ func MenuMiddleware(c *fiber.Ctx) error { return c.Next() } - // Verificar si la URL de la solicitud está en la lista de URLs permitidas - requestURL := c.Path() - - // Obtener el índice del último '/' en la URL de la solicitud - lastSlashIndex := strings.LastIndex(requestURL, "/") - if lastSlashIndex != -1 { - // Obtener solo la parte de la URL después del último '/' - requestURL = requestURL[lastSlashIndex+1:] - } - - // Verificar si la URL modificada está en la lista de URLs permitidas - urlAllowed := false - for _, url := range urls { - // Comparar solo la parte de la URL después del último '/' - if requestURL == url[strings.LastIndex(url, "/")+1:] { - urlAllowed = true - break - } - } + // Se compara la ruta COMPLETA contra las URLs permitidas. + // + // Antes se comparaba solo el último segmento (lo que va después del último + // '/'), y eso rompía en las dos direcciones: dos rutas distintas que + // terminan igual quedaban indistinguibles — con permiso sobre + // /app/doc/categorias se entraba a cualquier otra ruta terminada en + // "categorias" — y a la vez el permiso parecía no aplicarse donde sí + // debía, porque el segmento coincidía por casualidad. + urlAllowed := PuedeVerRuta(c.Path(), urls) // Si la URL no está permitida, devolver un error if !urlAllowed { @@ -118,3 +108,28 @@ func MenuMiddleware(c *fiber.Ctx) error { // Continuar con la siguiente función de middleware o manejador return c.Next() } + +// PuedeVerRuta decide si una ruta está cubierta por alguna de las URLs que el +// rol tiene asignadas. +// +// Coincide la ruta exacta o cualquier ruta por debajo de ella: quien tiene +// /app/doc/paginas también puede entrar a /app/doc/paginas/7, que es la misma +// pantalla con un detalle. No coincide /app/doc/paginas-privadas, porque el +// corte se hace en el separador y no en el prefijo de texto — si no, un +// permiso abriría rutas vecinas que solo comparten el comienzo del nombre. +func PuedeVerRuta(ruta string, permitidas []string) bool { + ruta = strings.TrimRight(ruta, "/") + if ruta == "" { + ruta = "/" + } + for _, u := range permitidas { + u = strings.TrimRight(strings.TrimSpace(u), "/") + if u == "" { + continue + } + if ruta == u || strings.HasPrefix(ruta, u+"/") { + return true + } + } + return false +} diff --git a/rest/middlewares/menu_test.go b/rest/middlewares/menu_test.go new file mode 100644 index 0000000..c9d98f0 --- /dev/null +++ b/rest/middlewares/menu_test.go @@ -0,0 +1,49 @@ +package middlewares + +import "testing" + +// El permiso comparaba solo el último segmento de la URL, así que dos rutas +// distintas terminadas igual eran indistinguibles. Estos casos fijan que la +// comparación es sobre la ruta completa y corta en el separador. +func TestPuedeVerRuta(t *testing.T) { + permisos := []string{"/app/doc/paginas", "/app/clientes", "/status"} + + casos := []struct { + ruta string + esperado bool + porque string + }{ + {"/app/doc/paginas", true, "coincidencia exacta"}, + {"/app/doc/paginas/7", true, "el detalle cuelga de la pantalla permitida"}, + {"/app/doc/paginas/", true, "la barra final no cambia nada"}, + {"/app/clientes", true, "otra pantalla permitida"}, + {"/status", true, "una ruta pública también puede ser un ítem del menú"}, + + {"/app/doc/categorias", false, "otra pantalla del mismo módulo"}, + {"/app/umind", false, "sin permiso"}, + {"/app/doc", false, "el padre no se hereda del hijo"}, + + // El bug original: comparaba solo lo que va después del último '/'. + {"/app/otro/paginas", false, "termina igual pero es otra ruta"}, + // El corte va en el separador, no en el prefijo de texto. + {"/app/doc/paginas-privadas", false, "solo comparte el comienzo del nombre"}, + {"/app/clientes-vip", false, "prefijo parecido, ruta distinta"}, + } + + for _, cas := range casos { + if got := PuedeVerRuta(cas.ruta, permisos); got != cas.esperado { + t.Errorf("PuedeVerRuta(%q) = %v, esperaba %v — %s", cas.ruta, got, cas.esperado, cas.porque) + } + } +} + +// Sin permisos no se entra a ningún lado (los administradores se resuelven +// antes, con user.IsAdmin). +func TestPuedeVerRutaSinPermisos(t *testing.T) { + if PuedeVerRuta("/app/clientes", nil) { + t.Error("un rol sin submódulos no debería acceder a nada") + } + if PuedeVerRuta("/app/clientes", []string{"", " "}) { + t.Error("una URL vacía no debería habilitar nada") + } +} diff --git a/rest/routes/submodulos_url_test.go b/rest/routes/submodulos_url_test.go new file mode 100644 index 0000000..496acce --- /dev/null +++ b/rest/routes/submodulos_url_test.go @@ -0,0 +1,90 @@ +package routes + +import ( + "os" + "regexp" + "sort" + "strings" + "testing" + + "github.com/gofiber/fiber/v2" +) + +// Un submódulo del menú es una URL escrita a mano en el seed. Si no coincide +// con ninguna ruta registrada, el ítem aparece en el menú, se le puede asignar +// permiso a un rol, y al hacer clic da 404 — que es exactamente el síntoma +// reportado en /app/doc/paginas. El compilador no ve nada de esto porque son +// cadenas sueltas en dos archivos distintos. +func TestUrlsDeSubmodulosTienenRuta(t *testing.T) { + rutas := rutasGETRegistradas(t) + + urls, err := urlsSembradas() + if err != nil { + t.Skipf("no se pudo leer el seed: %v", err) + } + if len(urls) == 0 { + t.Fatal("no se encontró ninguna URL de submódulo en el seed") + } + + var rotas []string + for _, u := range urls { + if !rutas[u] { + rotas = append(rotas, u) + } + } + sort.Strings(rotas) + for _, u := range rotas { + t.Errorf("el submódulo %q no tiene ruta registrada: el menú lo muestra y da 404 al entrar", u) + } +} + +// rutasGETRegistradas devuelve el conjunto de rutas GET que sirven una página. +func rutasGETRegistradas(t *testing.T) map[string]bool { + t.Helper() + // LoadRoutes monta todos los grupos: un submódulo puede apuntar a una ruta + // pública (ej. /status) y no solo a /app/*. + app := fiber.New() + LoadRoutes(app) + + out := map[string]bool{} + for _, capa := range app.Stack() { + for _, r := range capa { + if r.Method == "GET" { + out[r.Path] = true + } + } + } + return out +} + +// Solo las tuplas {título, descripción, url} de los slices de entries y las +// asignaciones Url:. Buscar cualquier "/app/..." suelto daba falsos positivos +// con las líneas que corrigen una URL vieja en la base. +var reURLSubmodulo = regexp.MustCompile(`\{"[^"]*",\s*"[^"]*",\s*"(/[a-z0-9/_-]+)"\}|Url:\s*"(/[a-z0-9/_-]+)"`) + +// urlsSembradas saca las URLs de submódulo del seed. Se lee el fuente porque +// los seeds son literales en el código, no datos que se puedan consultar. +func urlsSembradas() ([]string, error) { + b, err := os.ReadFile("../../migrations/migrate.go") + if err != nil { + return nil, err + } + texto := string(b) + + vistas := map[string]bool{} + var out []string + for _, m := range reURLSubmodulo.FindAllStringSubmatch(texto, -1) { + u := m[1] + if u == "" { + u = m[2] + } + // Solo interesan las que se usan como Url de un submódulo; las de + // redirección o comparación quedan cubiertas igual y no molestan. + if vistas[u] || strings.Contains(u, "//") { + continue + } + vistas[u] = true + out = append(out, u) + } + return out, nil +}