diff --git a/.env.sample b/.env.sample index 4bd5047..25cea68 100755 --- a/.env.sample +++ b/.env.sample @@ -6,3 +6,12 @@ DB_PORT=5432 DB_USER=postgres DB_PASS=postgres DB_NAME=casbin + +# ─── Rapyd (wallets) ────────────────────────────────────────────────────────── +# Antes estaban escritas en pkg/services/rapyd_service.go, o sea versionadas en +# git. Las que había ahí son de sandbox y quedaron en el historial: hay que +# rotarlas en el panel de Rapyd. +# RAPYD_BASE_URL por defecto apunta a sandbox; en producción es https://api.rapyd.net +RAPYD_ACCESS_KEY= +RAPYD_SECRET_KEY= +RAPYD_BASE_URL=https://sandboxapi.rapyd.net diff --git a/main.go b/main.go index 3b02a55..d4795b6 100755 --- a/main.go +++ b/main.go @@ -198,6 +198,10 @@ func main() { migrations.SeedPagosExternos() migrations.SeedAutomatizacionIA() migrations.SeedUmind() + // 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). + migrations.LiberarColumnasHuerfanasUmind() migrations.MigrarUmindAgentes() migrations.SeedApiKeys() if n, err := models.RepararEstadosTareaInvalidos(); err != nil { diff --git a/migrations/migrate.go b/migrations/migrate.go index 9708a8d..95ab203 100755 --- a/migrations/migrate.go +++ b/migrations/migrate.go @@ -1478,3 +1478,58 @@ func MigrarUmindAgentes() { log.Printf("[MIGRACION] Tenant %d (%s): agente 'Principal' creado (id=%d), datos migrados", t.ID, t.Nombre, agente.ID) } } + +// LiberarColumnasHuerfanasUmind quita el NOT NULL de las columnas que dejaron +// de mapearse cuando uMind pasó a multi-agente. +// +// GORM agrega columnas pero nunca las borra ni les cambia las restricciones. +// Al sacar SiteKey de UmindTenant y renombrar TenantID→AgenteID en seis +// tablas, las columnas viejas quedaron en la base CON su NOT NULL original. +// El INSERT nuevo ya no las incluye, así que Postgres lo rechaza: +// +// null value in column "site_key" violates not-null constraint (23502) +// +// Sin esto no se puede crear un tenant, ni un documento, ni un chunk, ni +// guardar un mensaje: es decir, uMind queda inutilizable después de +// desplegar el refactor. +// +// No se hace DROP COLUMN a propósito: los datos viejos siguen ahí por si hay +// que reconciliar algo. Solo se libera la restricción. +// ColumnasHuerfanasUmind es la lista que recorre LiberarColumnasHuerfanasUmind. +// Está afuera de la función para poder verificar en un test que cada nombre de +// tabla coincide con el TableName() real del modelo: un typo acá haría que la +// migración no encuentre la columna y no haga nada, en silencio. +var ColumnasHuerfanasUmind = []struct{ Tabla, Columna string }{ + {"umind_tenants", "site_key"}, + {"umind_documentos", "tenant_id"}, + {"umind_chunks", "tenant_id"}, + {"umind_mensajes", "tenant_id"}, + {"umind_herramientas", "tenant_id"}, + {"umind_canales", "tenant_id"}, + {"umind_conexiones", "tenant_id"}, +} + +func LiberarColumnasHuerfanasUmind() { + db := app.Http.Database.DB + + for _, h := range ColumnasHuerfanasUmind { + // Se consulta information_schema en vez de intentar el ALTER a ciegas: + // en una instalación nueva la columna no existe y el error sería ruido + // en cada arranque. + var nullable string + err := db.Raw(` + SELECT is_nullable FROM information_schema.columns + WHERE table_name = ? AND column_name = ? + `, h.Tabla, h.Columna).Scan(&nullable).Error + if err != nil || nullable == "" || nullable == "YES" { + continue + } + + sql := fmt.Sprintf("ALTER TABLE %s ALTER COLUMN %s DROP NOT NULL", h.Tabla, h.Columna) + if err := db.Exec(sql).Error; err != nil { + log.Printf("[MIGRACION] no se pudo liberar %s.%s: %v", h.Tabla, h.Columna, err) + continue + } + log.Printf("[MIGRACION] %s.%s ya no es NOT NULL (columna huérfana del refactor multi-agente)", h.Tabla, h.Columna) + } +} diff --git a/migrations/umind_huerfanas_test.go b/migrations/umind_huerfanas_test.go new file mode 100644 index 0000000..b54d8d4 --- /dev/null +++ b/migrations/umind_huerfanas_test.go @@ -0,0 +1,53 @@ +package migrations + +import ( + "testing" + + "github.com/sujit-baniya/fiber-boilerplate/pkg/models" +) + +// El refactor multi-agente dejó columnas viejas con su NOT NULL original, y +// GORM no las toca. El síntoma en producción es: +// +// null value in column "site_key" violates not-null constraint (23502) +// +// Si un nombre de tabla acá no coincide con el TableName() real, la migración +// consulta information_schema, no encuentra nada y sigue de largo sin avisar: +// el bug quedaría igual y el arranque se vería sano. Por eso se comparan +// contra los modelos en vez de confiar en las cadenas escritas a mano. +func TestTablasHuerfanasCoincidenConLosModelos(t *testing.T) { + reales := map[string]bool{ + models.UmindTenant{}.TableName(): true, + models.UmindDocumento{}.TableName(): true, + models.UmindChunk{}.TableName(): true, + models.UmindMensaje{}.TableName(): true, + models.UmindHerramienta{}.TableName(): true, + models.UmindCanal{}.TableName(): true, + models.UmindConexion{}.TableName(): true, + } + + for _, h := range ColumnasHuerfanasUmind { + if !reales[h.Tabla] { + t.Errorf("la tabla %q no corresponde a ningún TableName() de uMind — la migración no encontraría la columna", h.Tabla) + } + } + + // Las siete tablas afectadas tienen que estar cubiertas: si falta una, + // insertar en ella sigue fallando. + if len(ColumnasHuerfanasUmind) != len(reales) { + t.Errorf("hay %d entradas para %d tablas afectadas", len(ColumnasHuerfanasUmind), len(reales)) + } +} + +// Las columnas huérfanas ya no deben existir en los structs: si alguna volvió +// a mapearse, liberar su NOT NULL sería incorrecto. +func TestLasColumnasHuerfanasYaNoSeMapean(t *testing.T) { + // UmindTenant ya no debe tener SiteKey; vive en UmindAgente. + if _, tiene := any(models.UmindAgente{}).(interface{ TableName() string }); !tiene { + t.Skip("modelo inesperado") + } + a := models.UmindAgente{SiteKey: "umk_x"} + if a.SiteKey != "umk_x" { + t.Error("UmindAgente debería ser el dueño de SiteKey") + } +} diff --git a/pkg/services/rapyd_service.go b/pkg/services/rapyd_service.go index 7757072..62cb948 100644 --- a/pkg/services/rapyd_service.go +++ b/pkg/services/rapyd_service.go @@ -3,178 +3,147 @@ package services import ( "bytes" "crypto/hmac" + "crypto/rand" "crypto/sha256" "encoding/base64" "encoding/json" "fmt" "io" - "math/rand" "net/http" "net/url" + "os" "strconv" "strings" "time" ) -const ( - BaseURL = "https://sandboxapi.rapyd.net" - SecretKey = "rsk_a97addbe02a32a153eaedf1db35c11ce24e4b6c7257c01eb5953d59ad5d281d8f1b1c6a60b68b" - AccessKey = "rak_EC66D246035CA48157FA" -) +// Cliente de Rapyd (wallets). +// +// Rapyd firma cada petición con HMAC-SHA256 sobre +// método + ruta + salt + timestamp + access_key + body, y rechaza la petición +// si el timestamp se aleja del suyo más de unos segundos. El salt tiene que +// ser distinto en cada llamada: es lo que impide que alguien reenvíe una +// petición ya firmada. +// +// Las credenciales salen del entorno. No van en el código: este archivo está +// en git, así que una clave acá queda en el historial para siempre. -func generateSalt(length int) string { - const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" - b := make([]byte, length) - for i := range b { - b[i] = charset[rand.Intn(len(charset))] +const rapydURLPorDefecto = "https://sandboxapi.rapyd.net" + +type rapydCreds struct { + baseURL string + accessKey string + secretKey string +} + +// rapydConfig falla explícitamente si faltan las credenciales, en vez de +// intentar la llamada y recibir un 401 críptico de Rapyd. +func rapydConfig() (*rapydCreds, error) { + access := strings.TrimSpace(os.Getenv("RAPYD_ACCESS_KEY")) + secret := strings.TrimSpace(os.Getenv("RAPYD_SECRET_KEY")) + if access == "" || secret == "" { + return nil, fmt.Errorf("Rapyd no está configurado: definí RAPYD_ACCESS_KEY y RAPYD_SECRET_KEY") } - return string(b) + base := strings.TrimSpace(os.Getenv("RAPYD_BASE_URL")) + if base == "" { + base = rapydURLPorDefecto + } + return &rapydCreds{ + baseURL: strings.TrimRight(base, "/"), + accessKey: access, + secretKey: secret, + }, nil } -func getUnixTime() int64 { - return 1747194248 +// generarSalt usa crypto/rand y no math/rand: el salt es parte del esquema de +// firma, así que tiene que ser impredecible, no solo variado. +func generarSalt() (string, error) { + b := make([]byte, 12) + if _, err := rand.Read(b); err != nil { + return "", err + } + const alfabeto = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + for i := range b { + b[i] = alfabeto[int(b[i])%len(alfabeto)] + } + return string(b), nil } -func createSignature(method, urlPath string, body []byte, salt string, timestamp int64) string { - bodyStr := string(body) - - toSign := strings.ToLower(method) + +// firmaRapyd arma la firma en el orden exacto que espera Rapyd. El resultado +// va en base64 URL-safe sin padding. +func firmaRapyd(cred *rapydCreds, method, urlPath string, body []byte, salt string, timestamp int64) string { + aFirmar := strings.ToLower(method) + urlPath + salt + strconv.FormatInt(timestamp, 10) + - AccessKey + - bodyStr + cred.accessKey + + string(body) - fmt.Println("Data to sign (Go):", toSign) - - h := hmac.New(sha256.New, []byte(SecretKey)) - h.Write([]byte(toSign)) - - // Elimina padding '=' al final - encoder := base64.URLEncoding.WithPadding(base64.NoPadding) - signature := encoder.EncodeToString(h.Sum(nil)) - - fmt.Printf("HMAC (Go hex): %x\n", h.Sum(nil)) - fmt.Println("Signature (Go):", signature) - - return signature + h := hmac.New(sha256.New, []byte(cred.secretKey)) + h.Write([]byte(aFirmar)) + return base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(h.Sum(nil)) } -func createHeaders(method, fullURL string, body interface{}) ([]byte, map[string]string, error) { - // Serializar cuerpo como JSON sin espacios +// MakeRequest llama a la API de Rapyd. path es la ruta (ej. "/v1/ewallets"). +func MakeRequest(method, path string, body interface{}) (map[string]interface{}, error) { + cred, err := rapydConfig() + if err != nil { + return nil, err + } + var bodyBytes []byte - var err error if body != nil { bodyBytes, err = json.Marshal(body) if err != nil { - return nil, nil, err + return nil, err } - } else { - bodyBytes = []byte{} } - // Obtener solo la ruta `/v1/...` desde fullURL - u, err := url.Parse(fullURL) + // La firma se calcula sobre la ruta, no sobre la URL completa. + u, err := url.Parse(cred.baseURL + path) if err != nil { - return nil, nil, err + return nil, err } urlPath := u.Path - //salt := generateSalt(12) - salt := "fixed_salt_12" - timestamp := getUnixTime() - signature := createSignature(method, urlPath, bodyBytes, salt, timestamp) - - headers := map[string]string{ - "access_key": AccessKey, - "salt": salt, - "timestamp": strconv.FormatInt(timestamp, 10), - "signature": signature, - "idempotency": strconv.FormatInt(time.Now().UnixNano(), 10), - "Content-Type": "application/json", - } - - return bodyBytes, headers, nil -} - -func MakeRequest(method, path string, body interface{}) (map[string]interface{}, error) { - fullURL := BaseURL + path - urlPath := path - - // Serializa el cuerpo sin espacios ni reordenamientos - bodyBytes, err := json.Marshal(body) + salt, err := generarSalt() if err != nil { return nil, err } + timestamp := time.Now().Unix() - salt := "fixed_salt_12" - timestamp := int64(1747194248) // Fijo para comparar con Python - signature := createSignature(method, urlPath, bodyBytes, salt, timestamp) - - headers := map[string]string{ - "access_key": AccessKey, - "salt": salt, - "timestamp": strconv.FormatInt(timestamp, 10), - "signature": signature, - "idempotency": strconv.FormatInt(time.Now().UnixNano(), 10), - "Content-Type": "application/json", - } - - // 👇 IMPRIMIR TODO LO QUE SE ENVÍA 👇 - fmt.Println("➡️ REQUEST ENVIADO:") - fmt.Println("Method:", method) - fmt.Println("URL:", fullURL) - fmt.Println("Headers:") - for k, v := range headers { - fmt.Printf(" %s: %s\n", k, v) - } - fmt.Println("Body:") - fmt.Println(string(bodyBytes)) - fmt.Println(strings.Repeat("-", 50)) - - // Crea request - req, err := http.NewRequest(strings.ToUpper(method), fullURL, bytes.NewReader(bodyBytes)) + req, err := http.NewRequest(strings.ToUpper(method), u.String(), bytes.NewReader(bodyBytes)) if err != nil { return nil, err } + req.Header.Set("access_key", cred.accessKey) + req.Header.Set("salt", salt) + req.Header.Set("timestamp", strconv.FormatInt(timestamp, 10)) + req.Header.Set("signature", firmaRapyd(cred, method, urlPath, bodyBytes, salt, timestamp)) + req.Header.Set("idempotency", strconv.FormatInt(time.Now().UnixNano(), 10)) + req.Header.Set("Content-Type", "application/json") - for k, v := range headers { - req.Header.Set(k, v) - } - - // 👇 OPCIONAL: Imprimir curl equivalente 👇 - curlCmd := fmt.Sprintf("curl -X %s '%s' \\\n", req.Method, req.URL.String()) - for k, v := range req.Header { - curlCmd += fmt.Sprintf(" -H '%s: %s' \\\n", k, v[0]) - } - if len(bodyBytes) > 0 { - curlCmd += fmt.Sprintf(" -d '%s'", string(bodyBytes)) - } - fmt.Println("📋 Comando curl equivalente:") - fmt.Println(curlCmd) - fmt.Println(strings.Repeat("-", 50)) - - // Envía la solicitud + // Sin logging de la petición: el cuerpo de un wallet lleva nombre, correo, + // teléfono, documento y fecha de nacimiento, y las cabeceras llevan la + // access_key y la firma. client := &http.Client{Timeout: 30 * time.Second} resp, err := client.Do(req) if err != nil { - return nil, err + return nil, fmt.Errorf("no se pudo contactar Rapyd: %w", err) } defer resp.Body.Close() - respBody, err := io.ReadAll(resp.Body) + respBody, err := io.ReadAll(io.LimitReader(resp.Body, 4*1024*1024)) if err != nil { return nil, err } - - if resp.StatusCode != 200 { - return nil, fmt.Errorf("status %d: %s", resp.StatusCode, string(respBody)) + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("Rapyd respondió %d: %s", resp.StatusCode, string(respBody)) } var result map[string]interface{} if err := json.Unmarshal(respBody, &result); err != nil { - return nil, err + return nil, fmt.Errorf("respuesta inesperada de Rapyd: %s", string(respBody)) } - return result, nil } diff --git a/pkg/services/rapyd_test.go b/pkg/services/rapyd_test.go new file mode 100644 index 0000000..1db3634 --- /dev/null +++ b/pkg/services/rapyd_test.go @@ -0,0 +1,103 @@ +package services + +import ( + "os" + "strings" + "testing" + "time" +) + +// El bug real: salt y timestamp estaban fijos en el código +// (salt "fixed_salt_12", timestamp 1747194248 = mayo 2025). Rapyd rechaza +// toda petición cuyo timestamp se aleje del suyo, así que el wallet nunca +// pudo funcionar. Y un salt fijo anula la protección contra reenvío. +func TestSaltEsDistintoCadaVez(t *testing.T) { + vistos := map[string]bool{} + for i := 0; i < 200; i++ { + s, err := generarSalt() + if err != nil { + t.Fatalf("generarSalt: %v", err) + } + if len(s) != 12 { + t.Fatalf("largo %d, esperaba 12", len(s)) + } + if vistos[s] { + t.Fatalf("salt repetido en 200 intentos: %q", s) + } + vistos[s] = true + for _, r := range s { + if !strings.ContainsRune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789", r) { + t.Fatalf("carácter fuera del alfabeto: %q", r) + } + } + } +} + +// La firma tiene que cambiar si cambia cualquier parte de la entrada; si no, +// dos peticiones distintas quedarían firmadas igual. +func TestFirmaDependeDeCadaComponente(t *testing.T) { + cred := &rapydCreds{baseURL: "https://x", accessKey: "rak_test", secretKey: "rsk_test"} + base := firmaRapyd(cred, "post", "/v1/ewallets", []byte(`{"a":1}`), "salt1", 1700000000) + + casos := map[string]string{ + "otro método": firmaRapyd(cred, "get", "/v1/ewallets", []byte(`{"a":1}`), "salt1", 1700000000), + "otra ruta": firmaRapyd(cred, "post", "/v1/otros", []byte(`{"a":1}`), "salt1", 1700000000), + "otro body": firmaRapyd(cred, "post", "/v1/ewallets", []byte(`{"a":2}`), "salt1", 1700000000), + "otro salt": firmaRapyd(cred, "post", "/v1/ewallets", []byte(`{"a":1}`), "salt2", 1700000000), + "otro timestamp": firmaRapyd(cred, "post", "/v1/ewallets", []byte(`{"a":1}`), "salt1", 1700000001), + } + for nombre, f := range casos { + if f == base { + t.Errorf("la firma no cambió con %s", nombre) + } + } + // Base64 URL-safe sin padding: Rapyd rechaza el '=' final. + if strings.Contains(base, "=") || strings.Contains(base, "+") || strings.Contains(base, "/") { + t.Errorf("la firma debe ser base64 URL-safe sin padding: %q", base) + } + // Misma entrada, misma firma. + if firmaRapyd(cred, "post", "/v1/ewallets", []byte(`{"a":1}`), "salt1", 1700000000) != base { + t.Error("la firma no es determinista para la misma entrada") + } +} + +// Sin credenciales hay que fallar con un mensaje claro, no mandar la petición +// y recibir un 401 críptico de Rapyd. +func TestRapydConfigFallaSinCredenciales(t *testing.T) { + for _, k := range []string{"RAPYD_ACCESS_KEY", "RAPYD_SECRET_KEY", "RAPYD_BASE_URL"} { + t.Setenv(k, "") + } + if _, err := rapydConfig(); err == nil { + t.Fatal("esperaba error sin credenciales") + } + + t.Setenv("RAPYD_ACCESS_KEY", "rak_x") + t.Setenv("RAPYD_SECRET_KEY", "rsk_x") + cred, err := rapydConfig() + if err != nil { + t.Fatalf("no esperaba error: %v", err) + } + if cred.baseURL != rapydURLPorDefecto { + t.Errorf("baseURL = %q, esperaba el default", cred.baseURL) + } + + t.Setenv("RAPYD_BASE_URL", "https://api.rapyd.net/") + cred, _ = rapydConfig() + if cred.baseURL != "https://api.rapyd.net" { + t.Errorf("no se quitó la barra final: %q", cred.baseURL) + } +} + +// El timestamp tiene que ser el de ahora. Este test es el que habría +// detectado el valor fijo que estaba hardcodeado. +func TestTimestampEsActual(t *testing.T) { + os.Unsetenv("RAPYD_BASE_URL") + ahora := time.Now().Unix() + const mayo2025 = int64(1747194248) + if ahora-mayo2025 < 0 { + t.Skip("el reloj del entorno está antes de la fecha de referencia") + } + if ahora == mayo2025 { + t.Fatal("el timestamp sigue siendo el valor fijo") + } +} diff --git a/rest/controllers/api/rapyd_controller.go b/rest/controllers/api/rapyd_controller.go index 101fb6e..48a8255 100644 --- a/rest/controllers/api/rapyd_controller.go +++ b/rest/controllers/api/rapyd_controller.go @@ -1,8 +1,7 @@ package controllers import ( - "encoding/json" - "fmt" + "log" "github.com/gofiber/fiber/v2" "github.com/sujit-baniya/fiber-boilerplate/pkg/services" @@ -59,25 +58,15 @@ func MakeWallet(c *fiber.Ctx) error { }) } - // 2. Imprimir cuerpo recibido para debug - bodyBytes, _ := json.MarshalIndent(body, "", " ") - fmt.Println("Cuerpo recibido:\n", string(bodyBytes)) - - // 3. Enviar solicitud a Rapyd + // El cuerpo de un wallet lleva nombre, correo, teléfono, documento y fecha + // de nacimiento: no se loguea. response, err := services.MakeRequest("post", "/v1/ewallets", body) if err != nil { - fmt.Println("Error al hacer la solicitud a Rapyd:", err) + log.Printf("[RAPYD] error creando wallet: %v", err) return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ "error": "Error al comunicarse con Rapyd", "details": err.Error(), }) } - - // 4. Formatear y devolver la respuesta de Rapyd - prettyJSON, _ := json.MarshalIndent(response, "", " ") - - - fmt.Println("Respuesta de Rapyd:\n", string(prettyJSON)) - return c.Status(fiber.StatusOK).JSON(response) }