Error en producción al crear un tenant:
null value in column "site_key" of relation "umind_tenants"
violates not-null constraint (SQLSTATE 23502)
Es un bug que introduje yo. Al pasar uMind a multi-agente saqué SiteKey de
UmindTenant y renombré TenantID→AgenteID en seis tablas. GORM agrega
columnas pero nunca las borra ni les cambia las restricciones, así que las
viejas quedaron en la base CON su NOT NULL original — y el INSERT nuevo ya
no las incluye.
Al mirarlo, el alcance era mayor que el error reportado: no es solo
site_key. Las seis tablas renombradas tienen su tenant_id huérfano también
NOT NULL, así que fallaba insertar documentos, chunks, mensajes, tools,
canales y conexiones. En la práctica uMind quedaba inutilizable después de
desplegar el refactor: ni crear un tenant, ni ingestar conocimiento, ni
guardar un mensaje del chat.
La migración corre en cada arranque, antes de MigrarUmindAgentes, y
consulta information_schema para no intentar el ALTER a ciegas en una
instalación nueva donde la columna no existe.
No se hace DROP COLUMN a propósito: los datos viejos quedan por si hay que
reconciliar algo. Solo se libera la restricción.
El test compara los nombres de tabla contra el TableName() real de cada
modelo. Un typo ahí haría que la migración no encuentre la columna y siga
de largo: el bug seguiría vivo y el arranque se vería sano.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
104 lines
3.5 KiB
Go
104 lines
3.5 KiB
Go
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")
|
|
}
|
|
}
|