gioser

tawasuyu/brahman

Rust 582TOML 222Markdown 73JSON 198635 Definiciones

feat(nakui-ui): EntityRef validation en parse_field_value (UUID al submit)

Antes: parse_field_value(EntityRef, raw) devolvía Ok(json!(raw))
blindly. Garbage entraba al log/store si el field se usaba como
seed o param (sólo el path de morphism inputs validaba UUID).

Ahora: Uuid::parse_str(raw.trim()) → error claro si falla, value
trimmed si pasa. El selector clickable garantiza happy path; este
check es defensivo contra paste manual o garbage tipeado.

5 tests nuevos: happy path, trim de whitespace, rechazo de garbage,
rechazo de empty, propagación de error a resolve_param_value con
label del FieldSpec en el mensaje.

35 tests verdes. E2E del morphism real intacto (sus inputs van por
path dedicado, no por parse_field_value).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

bcccf3b95332e125cbb9e0be796ad3a69e1a3605 · por Sergio · (hace 5 meses) · padre 7e2be96a5 · ver el árbol en este commit

Delta semántico · 7

2 archivos · +104 −9

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7a0613b..f313089 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,52 @@ ratio/diff ver `git show <sha>`.
 
 ## 2026-05-09
 
+### feat(nakui-ui): EntityRef validation en parse_field_value (UUID al submit)
+Cierra otro pendiente: `parse_field_value(FieldKind::EntityRef, raw)`
+devolvía `Ok(json!(raw))` blindly — el value entraba al log/store
+incluso si era basura. La validación de UUID sólo ocurría cuando el
+field se usaba como **input** del morphism (línea ~540 de
+`commit_morphism`); como **seed field** o como **param**, garbage
+pasaba silenciosamente.
+
+Cambios:
+- **`parse_field_value(EntityRef, raw)`** ahora hace
+  `Uuid::parse_str(raw.trim())` y devuelve error claro si falla:
+  `"'<raw>' no es UUID válido (usá el selector de records)"`. En
+  caso de éxito, devuelve el UUID **trimmed** como string —
+  protege contra paste manual con whitespace.
+- **Doble cobertura**: este path cubre seed fields (commit_seed via
+  obj.insert) y morphism params (resolve_param_value lo invoca por
+  cada FieldSpec con kind=EntityRef). El path de morphism inputs
+  ya validaba antes con `Uuid::parse_str` directo — sigue intacto,
+  no hay double-validation.
+- **Selector clickable es happy path**: el dropdown setea valores
+  bien-formados, así que el usuario nunca debería ver el error en
+  uso normal. Sólo dispara con paste manual o si el usuario escribe
+  garbage en el input — defensivo.
+
+5 tests nuevos (reemplazan al obsoleto `parse_field_entity_ref_returns_string`):
+- `parse_field_entity_ref_accepts_valid_uuid` — happy path.
+- `parse_field_entity_ref_trims_whitespace` — `"  uuid\n"` → `"uuid"`.
+- `parse_field_entity_ref_rejects_non_uuid` — `"abc-123"` → error
+  con el value y la palabra "UUID" en el mensaje.
+- `parse_field_entity_ref_rejects_empty_string` — `""` → rebota.
+- `resolve_param_strict_entity_ref_propagates_error` — sanity de
+  que el wireup en resolve_param_value hereda el strict checking,
+  con label del FieldSpec en el mensaje.
+
+35 tests verdes (+4 net). El E2E del morphism real
+`morphism_pipeline_executes_real_sales_vender` sigue verde — sus
+inputs van por el path dedicado, no por parse_field_value.
+
+Pendientes restantes:
+- **Atajo Esc para Cancelar** del modal de delete.
+- **`FieldOp::Clear`** — para soportar borrar un value vía form.
+- **Snapshot durante runtime** (cada N writes, no sólo al startup).
+- **Validación cross-field** (ej: el UUID del EntityRef existe en
+  la entity referida) — hoy sólo validamos forma; un UUID válido
+  pero inexistente sí pasa.
+
 ### feat(nakui-ui): snapshot/compaction automático del event log al startup
 Cierra el último gran pendiente del round: el replay full cada
 startup escala lineal en el log. Con 60+ entries el costo de boot
diff --git a/crates/apps/nakui-ui/src/main.rs b/crates/apps/nakui-ui/src/main.rs
index f1c6ec8..c71dbea 100644
--- a/crates/apps/nakui-ui/src/main.rs
+++ b/crates/apps/nakui-ui/src/main.rs
@@ -865,9 +865,20 @@ fn parse_field_value(kind: FieldKind, raw: &str) -> Result<Value, String> {
     match kind {
         FieldKind::Text | FieldKind::Multiline | FieldKind::Date => Ok(json!(raw)),
         // EntityRef se almacena como string del UUID seleccionado.
-        // El commit_morphism luego lo parsea como Uuid para inputs;
-        // en seed_entity normal queda como string en el record.
-        FieldKind::EntityRef => Ok(json!(raw)),
+        // Validamos que parsee como UUID al submit — antes esto se
+        // chequeaba sólo para morphism inputs (línea ~540), pero un
+        // EntityRef como SEED field o como param de morphism caía
+        // de la heurística silenciosa. Ahora rebota con mensaje
+        // claro acá, antes de tocar el log o el morphism Rhai.
+        // El selector clickable garantiza UUIDs válidos en happy
+        // path; este check protege paste manual o garbage.
+        FieldKind::EntityRef => {
+            let trimmed = raw.trim();
+            Uuid::parse_str(trimmed).map_err(|_| {
+                format!("'{raw}' no es UUID válido (usá el selector de records)")
+            })?;
+            Ok(json!(trimmed))
+        }
         FieldKind::Boolean => match raw.to_ascii_lowercase().as_str() {
             "true" | "yes" | "1" | "on" | "y" => Ok(json!(true)),
             "" | "false" | "no" | "0" | "off" | "n" => Ok(json!(false)),
@@ -2024,12 +2035,50 @@ mod tests {
     }
 
     #[test]
-    fn parse_field_entity_ref_returns_string() {
-        // EntityRef se almacena como string del UUID. parse_field_value
-        // no lo valida como UUID — eso lo hace commit_morphism al
-        // resolver inputs.
-        let v = parse_field_value(FieldKind::EntityRef, "abc-123").unwrap();
-        assert_eq!(v, json!("abc-123"));
+    fn parse_field_entity_ref_accepts_valid_uuid() {
+        let id = Uuid::new_v4();
+        let v = parse_field_value(FieldKind::EntityRef, &id.to_string()).unwrap();
+        assert_eq!(v, json!(id.to_string()));
+    }
+
+    #[test]
+    fn parse_field_entity_ref_trims_whitespace() {
+        // El selector clickable garantiza el value pelado; este check
+        // protege contra paste manual con espacios accidentales.
+        let id = Uuid::new_v4();
+        let padded = format!("  {id}\n");
+        let v = parse_field_value(FieldKind::EntityRef, &padded).unwrap();
+        assert_eq!(v, json!(id.to_string()), "debería trimear y devolver el UUID limpio");
+    }
+
+    #[test]
+    fn parse_field_entity_ref_rejects_non_uuid() {
+        let err = parse_field_value(FieldKind::EntityRef, "abc-123").unwrap_err();
+        assert!(err.contains("'abc-123'"), "msg debe mencionar el value: {err}");
+        assert!(
+            err.contains("UUID") || err.contains("uuid"),
+            "msg debe mencionar UUID: {err}"
+        );
+    }
+
+    #[test]
+    fn parse_field_entity_ref_rejects_empty_string() {
+        // Un EntityRef vacío al submit: el form lo manda como ""
+        // si el usuario no clickeó nada. Debería rebotar acá en
+        // lugar de loguear "" como un record id basura.
+        let err = parse_field_value(FieldKind::EntityRef, "").unwrap_err();
+        assert!(err.contains("UUID"), "msg debe mencionar UUID: {err}");
+    }
+
+    #[test]
+    fn resolve_param_strict_entity_ref_propagates_error() {
+        // Sanity: resolve_param_value con kind=EntityRef invoca
+        // parse_field_value y propaga el error de UUID inválido,
+        // con el label del FieldSpec en el mensaje.
+        let s = spec("stock_ref", FieldKind::EntityRef, true);
+        let err = resolve_param_value("stock_ref", "not-a-uuid", Some(&s)).unwrap_err();
+        assert!(err.contains("stock_ref"), "msg debe incluir label: {err}");
+        assert!(err.contains("UUID"), "msg debe mencionar UUID: {err}");
     }
 
     #[test]