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 `. ## 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: + `"'' 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 { 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]