⎇ d3649bfd1a80153652c2a028315369bb4b184024
crates/modules/nakui/core/tests/kernel_guards.rs
History
Who touched what
raw
refactor(nakui-core): KCL → Nickel — kcl_wrapper reemplazado por evaluación in-process
1//! Regression tests for the kernel's enforcement layers.
2//!
3//! Each test runs a deliberately-broken morphism that should be rejected by
4//! a *specific* layer of the executor pipeline. After every rejection we also
5//! assert the store is untouched — the kernel must never half-apply a delta.
6//!
7//! Layers exercised (in pipeline order):
8//! 1. CapabilityViolation (untracked write)
9//! 2. ConservationViolation (delta sum != 0)
10//! 3. SchemaPostCreate (created record fails its schema)
11
12use std::path::{Path, PathBuf};
13
14use nakui_core::executor::{ExecError, Executor};
15use nakui_core::graph::ManifestGraph;
16use nakui_core::manifest::{
17 ConserveRule, Invariants, Manifest, MorphismInput, MorphismSpec,
18};
19use nakui_core::rhai_executor::RhaiExecutor;
20use nakui_core::store::{MemoryStore, Store};
21use serde_json::{Value, json};
22use uuid::Uuid;
23
24fn workspace_root() -> PathBuf {
25 Path::new(env!("CARGO_MANIFEST_DIR"))
26 .parent()
27 .expect("workspace root above core/")
28 .to_path_buf()
29}
30
31fn fixtures_dir() -> PathBuf {
32 Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures")
33}
34
35fn build_executor(spec: MorphismSpec) -> Executor {
36 let manifest = Manifest {
37 module: "kernel_guards_test".into(),
38 schemas: vec![],
39 morphisms: vec![spec],
40 };
41 let graph = ManifestGraph::build(&manifest).expect("graph builds");
42 Executor {
43 manifest,
44 graph,
45 // module_dir is where script paths resolve; we point it at fixtures.
46 module_dir: fixtures_dir(),
47 // schema_path stays on the real treasury schema so we exercise the
48 // production check blocks. `owned_bundle: false` so Drop leaves it
49 // alone — it belongs to the source tree.
50 schema_path: workspace_root().join("modules/treasury/schema.ncl"),
51 rhai: RhaiExecutor::new_sandboxed(),
52 owned_bundle: false,
53 // Inline-built executors don't go through `load_module`, so they
54 // have no schema-hash cache. These guard tests don't write to a
55 // log, so verify_log never runs against this executor.
56 schema_hashes: std::collections::HashMap::new(),
57 schema_bundle_hash: [0u8; 32],
58 }
59}
60
61fn seed_caja(store: &mut MemoryStore, id: Uuid, name: &str, saldo: i64, currency: &str) {
62 store.seed(
63 "Caja",
64 id,
65 json!({
66 "id": id.to_string(),
67 "name": name,
68 "saldo": saldo,
69 "currency": currency,
70 }),
71 );
72}
73
74fn caja_saldo(store: &MemoryStore, id: Uuid) -> i64 {
75 store
76 .load("Caja", id)
77 .and_then(|v| v.get("saldo").and_then(Value::as_i64))
78 .expect("caja with saldo")
79}
80
81#[test]
82fn capability_violation_blocks_write_to_untracked_caja() {
83 let spec = MorphismSpec {
84 name: "evil_capability".into(),
85 inputs: vec![MorphismInput {
86 role: "caja".into(),
87 entity: "Caja".into(),
88 }],
89 reads: vec!["caja.saldo".into()],
90 writes: vec!["caja.saldo".into()],
91 invariants: Invariants::default(),
92 depends_on: vec![],
93 script: "capability_violation.rhai".into(),
94 };
95 let exec = build_executor(spec);
96
97 let mut store = MemoryStore::new();
98 let caja_id = Uuid::new_v4();
99 let phantom_id = Uuid::new_v4();
100 seed_caja(&mut store, caja_id, "tracked", 100_000, "USD");
101 seed_caja(&mut store, phantom_id, "phantom", 100_000, "USD");
102
103 let params = json!({ "phantom_id": phantom_id.to_string() });
104
105 let result = exec.run(&mut store, "evil_capability", &[("caja", caja_id)], params);
106
107 match result {
108 Err(ExecError::CapabilityViolation { token, .. }) => {
109 assert!(
110 token.contains("untracked"),
111 "expected token to flag untracked id, got `{}`",
112 token
113 );
114 }
115 other => panic!("expected CapabilityViolation, got {:?}", other),
116 }
117
118 // Neither caja moved.
119 assert_eq!(caja_saldo(&store, caja_id), 100_000);
120 assert_eq!(caja_saldo(&store, phantom_id), 100_000);
121}
122
123#[test]
124fn conservation_violation_blocks_unbalanced_transfer() {
125 let spec = MorphismSpec {
126 name: "evil_conservation".into(),
127 inputs: vec![
128 MorphismInput {
129 role: "source".into(),
130 entity: "Caja".into(),
131 },
132 MorphismInput {
133 role: "dest".into(),
134 entity: "Caja".into(),
135 },
136 ],
137 reads: vec![
138 "source.saldo".into(),
139 "source.currency".into(),
140 "dest.saldo".into(),
141 "dest.currency".into(),
142 ],
143 writes: vec!["source.saldo".into(), "dest.saldo".into()],
144 invariants: Invariants {
145 conserve: vec![ConserveRule {
146 entity: "Caja".into(),
147 field: "saldo".into(),
148 group_by: Some("currency".into()),
149 }],
150 },
151 depends_on: vec![],
152 script: "conservation_violation.rhai".into(),
153 };
154 let exec = build_executor(spec);
155
156 let mut store = MemoryStore::new();
157 let source = Uuid::new_v4();
158 let dest = Uuid::new_v4();
159 seed_caja(&mut store, source, "A", 200_000, "USD");
160 seed_caja(&mut store, dest, "B", 50_000, "USD");
161
162 let result = exec.run(
163 &mut store,
164 "evil_conservation",
165 &[("source", source), ("dest", dest)],
166 json!({}),
167 );
168
169 match result {
170 Err(ExecError::ConservationViolation {
171 entity,
172 field,
173 total,
174 ..
175 }) => {
176 assert_eq!(entity, "Caja");
177 assert_eq!(field, "saldo");
178 assert_eq!(total, -101, "expected Δ = -100 + -1 = -101");
179 }
180 other => panic!("expected ConservationViolation, got {:?}", other),
181 }
182
183 assert_eq!(caja_saldo(&store, source), 200_000);
184 assert_eq!(caja_saldo(&store, dest), 50_000);
185}
186
187#[test]
188fn capability_rejects_entity_mismatch_on_tracked_id() {
189 // The script writes `Stock.cantidad` using the Caja's UUID. The id is
190 // tracked (it's the caja role's id) but the entity differs — the
191 // capability layer must catch this regardless of UUID coincidence.
192 let spec = MorphismSpec {
193 name: "evil_entity_mismatch".into(),
194 inputs: vec![MorphismInput {
195 role: "caja".into(),
196 entity: "Caja".into(),
197 }],
198 reads: vec!["caja.saldo".into()],
199 writes: vec!["caja.saldo".into()],
200 invariants: Invariants::default(),
201 depends_on: vec![],
202 script: "entity_mismatch.rhai".into(),
203 };
204 let exec = build_executor(spec);
205
206 let mut store = MemoryStore::new();
207 let caja_id = Uuid::new_v4();
208 seed_caja(&mut store, caja_id, "tracked", 100_000, "USD");
209
210 let result = exec.run(&mut store, "evil_entity_mismatch", &[("caja", caja_id)], json!({}));
211
212 match result {
213 Err(ExecError::CapabilityViolation { token, .. }) => {
214 assert!(
215 token.contains("entity-mismatch"),
216 "expected entity-mismatch token, got `{}`",
217 token
218 );
219 }
220 other => panic!("expected CapabilityViolation, got {:?}", other),
221 }
222 assert_eq!(caja_saldo(&store, caja_id), 100_000);
223}
224
225#[test]
226fn delete_primary_skips_post_check_and_removes_record() {
227 // A morphism that deletes its primary input must succeed without the
228 // post-check running against a stale-then-stripped state.
229 let spec = MorphismSpec {
230 name: "delete_caja".into(),
231 inputs: vec![MorphismInput {
232 role: "caja".into(),
233 entity: "Caja".into(),
234 }],
235 reads: vec![],
236 writes: vec!["Caja".into()],
237 invariants: Invariants::default(),
238 depends_on: vec![],
239 script: "delete_primary.rhai".into(),
240 };
241 let exec = build_executor(spec);
242
243 let mut store = MemoryStore::new();
244 let caja_id = Uuid::new_v4();
245 seed_caja(&mut store, caja_id, "doomed", 100_000, "USD");
246
247 let ops = exec
248 .run(&mut store, "delete_caja", &[("caja", caja_id)], json!({}))
249 .expect("delete must succeed");
250
251 assert_eq!(ops.len(), 1);
252 assert!(matches!(&ops[0], nakui_core::delta::FieldOp::Delete { .. }));
253 assert!(
254 store.load("Caja", caja_id).is_none(),
255 "Caja must be gone after Delete"
256 );
257}
258
259#[test]
260fn bad_created_record_blocks_negative_movimiento() {
261 let spec = MorphismSpec {
262 name: "evil_create".into(),
263 inputs: vec![MorphismInput {
264 role: "caja".into(),
265 entity: "Caja".into(),
266 }],
267 reads: vec!["caja.saldo".into()],
268 writes: vec!["Movimiento".into()],
269 invariants: Invariants::default(),
270 depends_on: vec![],
271 script: "bad_created_record.rhai".into(),
272 };
273 let exec = build_executor(spec);
274
275 let mut store = MemoryStore::new();
276 let caja_id = Uuid::new_v4();
277 seed_caja(&mut store, caja_id, "A", 100_000, "USD");
278 let mov_id = Uuid::new_v4();
279
280 let params = json!({ "mov_id": mov_id.to_string() });
281
282 let result = exec.run(&mut store, "evil_create", &[("caja", caja_id)], params);
283
284 match result {
285 Err(ExecError::SchemaPostCreate { entity, .. }) => {
286 assert_eq!(entity, "Movimiento");
287 }
288 other => panic!("expected SchemaPostCreate, got {:?}", other),
289 }
290
291 // Caja unchanged, Movimiento never landed.
292 assert_eq!(caja_saldo(&store, caja_id), 100_000);
293 assert!(
294 store.load("Movimiento", mov_id).is_none(),
295 "Movimiento must not be persisted"
296 );
297}