gabriel / muse public
test_knowtation_events.py python
553 lines 19.8 KB
Raw
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 26 days ago
1 """Tests for muse/plugins/knowtation/events.py — Phase 4.1.
2
3 Test tiers (per Rule #0 and plan §0.2)
4 --------------------------------------
5
6 1. **Unit** — Every public symbol; positive and negative paths;
7 every validation rule in
8 :meth:`MemoryEventRecord.__post_init__`.
9 2. **Integration** — Round-trip identity, frozen-instance enforcement,
10 forward-compat extra-key handling, hash determinism
11 across re-imports.
12 3. **End-to-end** — Simulated commit-metadata embed → JSON dump →
13 re-parse pipeline.
14 4. **Stress** — 10 000-record construction and 10 000-dict parse
15 loops complete inside a budget.
16 5. **Data-integrity** — Python ``MemoryEventKind`` value set is byte-equal
17 to the array literal in
18 :file:`knowtation/lib/memory-event.mjs`; the module
19 export ``EVENTS_SCHEMA_HASH`` matches an
20 independently computed reference.
21 6. **Performance** — 1 000 ``from_dict + to_dict`` round-trips in under
22 200 ms.
23 7. **Security** — Sensitive-key rejection across the case-insensitive
24 pattern surface; depth-limit boundary; oversized /
25 malicious inputs are rejected (or accepted) without
26 crashing.
27
28 The full ``tests/test_knowtation_*.py`` suite must continue to pass alongside
29 this module — see the runner step in the Phase 4.1 commit notes.
30 """
31
32 from __future__ import annotations
33
34 import dataclasses
35 import json
36 import pathlib
37 import re
38 import subprocess
39 import sys
40 import time
41 from typing import Any
42
43 import pytest
44
45 from muse.plugins.knowtation.events import (
46 EVENTS_SCHEMA_HASH,
47 EVENTS_SCHEMA_VERSION,
48 MemoryEventKind,
49 MemoryEventRecord,
50 from_dict,
51 is_valid_event_kind,
52 to_dict,
53 )
54
55 # ────────────────────────────────────────────────────────────────────────────
56 # Constants and helpers
57 # ────────────────────────────────────────────────────────────────────────────
58
59 #: The 15 canonical kind values, hard-coded here to act as a static reference
60 #: that tests compare the runtime enum against. Re-used by Tier 1 and 5.
61 _EXPECTED_KIND_VALUES: frozenset[str] = frozenset(
62 {
63 "search",
64 "export",
65 "write",
66 "import",
67 "index",
68 "propose",
69 "agent_interaction",
70 "capture",
71 "error",
72 "session_summary",
73 "user",
74 "consolidation",
75 "consolidation_pass",
76 "maintenance",
77 "insight",
78 }
79 )
80
81 #: Path to the JS source-of-truth file, used by the data-integrity test.
82 _JS_SOURCE = pathlib.Path(
83 "/Users/aaronrenecarvajal/knowtation/lib/memory-event.mjs"
84 )
85
86
87 def _minimal_valid_dict(**overrides: Any) -> dict[str, Any]:
88 """Build a minimal valid event dict; allow per-test overrides."""
89 base: dict[str, Any] = {
90 "event_id": "mem_0123456789ab",
91 "kind": "search",
92 "ts": "2026-05-13T13:57:00+00:00",
93 "vault_id": "default",
94 "data": {"query": "anything"},
95 }
96 base.update(overrides)
97 return base
98
99
100 def _minimal_record(**overrides: Any) -> MemoryEventRecord:
101 """Build a minimal valid :class:`MemoryEventRecord`."""
102 base = {
103 "event_id": "mem_0123456789ab",
104 "kind": MemoryEventKind.SEARCH,
105 "ts": "2026-05-13T13:57:00+00:00",
106 "vault_id": "default",
107 "data": {"query": "anything"},
108 }
109 base.update(overrides)
110 return MemoryEventRecord(**base) # type: ignore[arg-type]
111
112
113 # ============================================================================
114 # Tier 1 — Unit
115 # ============================================================================
116
117
118 class TestKindEnumMembership:
119 """Every canonical kind is present and addressable."""
120
121 @pytest.mark.parametrize("expected", sorted(_EXPECTED_KIND_VALUES))
122 def test_each_canonical_value_is_a_member(self, expected: str) -> None:
123 assert MemoryEventKind(expected).value == expected
124
125 def test_enum_has_exactly_fifteen_members(self) -> None:
126 assert len(list(MemoryEventKind)) == 15
127
128 def test_str_enum_string_equality(self) -> None:
129 assert MemoryEventKind.SEARCH == "search"
130 assert str(MemoryEventKind.IMPORT) == "import"
131
132
133 class TestIsValidEventKind:
134 """Positive and negative coverage for the predicate."""
135
136 @pytest.mark.parametrize("kind", sorted(_EXPECTED_KIND_VALUES))
137 def test_returns_true_for_canonical_values(self, kind: str) -> None:
138 assert is_valid_event_kind(kind) is True
139
140 @pytest.mark.parametrize(
141 "kind",
142 [
143 "recall",
144 "store",
145 "consolidate",
146 "summarize",
147 "note_read",
148 "note_write",
149 "agent_decision",
150 ],
151 )
152 def test_returns_false_for_non_canonical_strings(self, kind: str) -> None:
153 assert is_valid_event_kind(kind) is False
154
155 def test_returns_false_for_non_strings(self) -> None:
156 assert is_valid_event_kind(123) is False # type: ignore[arg-type]
157 assert is_valid_event_kind(None) is False # type: ignore[arg-type]
158 assert is_valid_event_kind(["search"]) is False # type: ignore[arg-type]
159
160
161 class TestSchemaConstants:
162 def test_schema_version_is_one_zero_zero(self) -> None:
163 assert EVENTS_SCHEMA_VERSION == "1.0.0"
164
165 def test_schema_hash_is_64_lowercase_hex(self) -> None:
166 assert isinstance(EVENTS_SCHEMA_HASH, str)
167 assert len(EVENTS_SCHEMA_HASH) == 64
168 assert re.fullmatch(r"[0-9a-f]{64}", EVENTS_SCHEMA_HASH) is not None
169
170
171 class TestFromDictMinimal:
172 def test_roundtrips_minimal_dict(self) -> None:
173 d = _minimal_valid_dict()
174 record = from_dict(d)
175 assert record.event_id == d["event_id"]
176 assert record.kind == MemoryEventKind.SEARCH
177 assert record.ts == d["ts"]
178 assert record.vault_id == d["vault_id"]
179 assert record.data == d["data"]
180 assert record.status == "success"
181 assert record.agent_id is None
182
183
184 class TestToDictOmitsNone:
185 def test_omits_none_optional_fields(self) -> None:
186 record = _minimal_record()
187 out = to_dict(record)
188 assert "agent_id" not in out
189 assert "model_id" not in out
190 assert "ttl" not in out
191 assert "air_id" not in out
192 assert out["event_id"] == record.event_id
193 assert out["kind"] == "search"
194 assert out["status"] == "success"
195
196 def test_includes_set_optional_fields(self) -> None:
197 record = _minimal_record(
198 agent_id="agent-x",
199 model_id="claude-sonnet-4-6",
200 ttl="P30D",
201 air_id="air_abc123",
202 )
203 out = to_dict(record)
204 assert out["agent_id"] == "agent-x"
205 assert out["model_id"] == "claude-sonnet-4-6"
206 assert out["ttl"] == "P30D"
207 assert out["air_id"] == "air_abc123"
208
209
210 class TestFromDictRejectsBadInputs:
211 def test_missing_event_id(self) -> None:
212 d = _minimal_valid_dict()
213 del d["event_id"]
214 with pytest.raises(ValueError, match="missing required field"):
215 from_dict(d)
216
217 def test_missing_kind(self) -> None:
218 d = _minimal_valid_dict()
219 del d["kind"]
220 with pytest.raises(ValueError, match="missing required field"):
221 from_dict(d)
222
223 def test_missing_ts(self) -> None:
224 d = _minimal_valid_dict()
225 del d["ts"]
226 with pytest.raises(ValueError, match="missing required field"):
227 from_dict(d)
228
229 def test_missing_vault_id(self) -> None:
230 d = _minimal_valid_dict()
231 del d["vault_id"]
232 with pytest.raises(ValueError, match="missing required field"):
233 from_dict(d)
234
235 def test_unknown_kind(self) -> None:
236 with pytest.raises(ValueError, match="not one of the 15"):
237 from_dict(_minimal_valid_dict(kind="recall"))
238
239 def test_malformed_event_id_too_short(self) -> None:
240 with pytest.raises(ValueError, match="event_id must match"):
241 from_dict(_minimal_valid_dict(event_id="mem_abc"))
242
243 def test_malformed_event_id_wrong_prefix(self) -> None:
244 with pytest.raises(ValueError, match="event_id must match"):
245 from_dict(_minimal_valid_dict(event_id="evt_0123456789ab"))
246
247 def test_malformed_event_id_uppercase_hex(self) -> None:
248 # The JS regex emits lowercase hex only; we lock the same constraint.
249 with pytest.raises(ValueError, match="event_id must match"):
250 from_dict(_minimal_valid_dict(event_id="mem_ABCDEF012345"))
251
252 def test_non_parseable_timestamp(self) -> None:
253 with pytest.raises(ValueError, match="ts must be parseable"):
254 from_dict(_minimal_valid_dict(ts="not-a-timestamp"))
255
256 def test_empty_vault_id(self) -> None:
257 with pytest.raises(ValueError, match="vault_id must be a non-empty string"):
258 from_dict(_minimal_valid_dict(vault_id=""))
259
260 def test_top_level_not_a_dict(self) -> None:
261 with pytest.raises(ValueError, match="from_dict expects a dict"):
262 from_dict("not-a-dict") # type: ignore[arg-type]
263
264 def test_data_must_be_dict(self) -> None:
265 with pytest.raises(ValueError, match="data must be a dict"):
266 from_dict(_minimal_valid_dict(data=["not", "a", "dict"]))
267
268 def test_kind_not_string(self) -> None:
269 with pytest.raises(ValueError, match="kind must be a string"):
270 from_dict(_minimal_valid_dict(kind=42))
271
272
273 class TestPostInitSensitiveData:
274 @pytest.mark.parametrize(
275 "key",
276 ["password", "api_key", "TOKEN", "secret"],
277 )
278 def test_construction_rejects_sensitive_keys(self, key: str) -> None:
279 with pytest.raises(ValueError, match="sensitive key patterns"):
280 _minimal_record(data={key: "x"})
281
282
283 # ============================================================================
284 # Tier 2 — Integration
285 # ============================================================================
286
287
288 class TestRoundTripIdentity:
289 @pytest.mark.parametrize(
290 "extras",
291 [
292 {},
293 {"agent_id": "agent-x"},
294 {"agent_id": "a", "model_id": "m"},
295 {"ttl": "P1D"},
296 {"air_id": "air_xyz", "status": "failed"},
297 {
298 "agent_id": "a",
299 "model_id": "m",
300 "ttl": "P30D",
301 "air_id": "air_zzz",
302 "data": {"nested": {"safe_field": [1, 2, {"another": "value"}]}},
303 },
304 ],
305 )
306 def test_from_to_dict_roundtrip(self, extras: dict[str, Any]) -> None:
307 record = _minimal_record(**extras)
308 d = to_dict(record)
309 rebuilt = from_dict(d)
310 assert rebuilt == record
311
312
313 class TestForwardCompat:
314 def test_unknown_keys_are_ignored(self) -> None:
315 d = _minimal_valid_dict()
316 d["future_field"] = "x"
317 d["another_future"] = {"nested": True}
318 record = from_dict(d)
319 assert record.kind == MemoryEventKind.SEARCH
320
321
322 class TestFrozenness:
323 def test_setattr_raises_frozen_instance_error(self) -> None:
324 record = _minimal_record()
325 with pytest.raises(dataclasses.FrozenInstanceError):
326 record.event_id = "mem_aaaaaaaaaaaa" # type: ignore[misc]
327
328 def test_data_payload_is_isolated_after_to_dict(self) -> None:
329 record = _minimal_record(data={"k": [1, 2, 3]})
330 out = to_dict(record)
331 out["data"]["k"].append(4)
332 assert record.data == {"k": [1, 2, 3]}
333
334
335 class TestSchemaHashStability:
336 def test_hash_is_deterministic_across_fresh_interpreter(self) -> None:
337 """Spawn a subprocess to prove the hash is not interpreter-state-dependent.
338
339 ``importlib.reload`` is not a safe substitute here because reloading
340 rebuilds the dataclass and enum classes in place, which breaks
341 ``isinstance`` checks for any record already imported by other tests.
342 A subprocess gives a completely clean state.
343 """
344 code = (
345 "from muse.plugins.knowtation.events import EVENTS_SCHEMA_HASH; "
346 "print(EVENTS_SCHEMA_HASH)"
347 )
348 result = subprocess.check_output(
349 [sys.executable, "-c", code], text=True
350 ).strip()
351 assert result == EVENTS_SCHEMA_HASH
352
353
354 # ============================================================================
355 # Tier 3 — End-to-end
356 # ============================================================================
357
358
359 class TestCommitMetadataPipeline:
360 """Simulate the full commit-metadata round trip."""
361
362 def test_record_embedded_in_commit_meta_roundtrips(self) -> None:
363 record = _minimal_record(
364 agent_id="@knowtation-daemon",
365 model_id="claude-sonnet-4-6",
366 data={"path": "vault/notes/example.md", "topic": "alpha"},
367 )
368
369 commit_meta: dict[str, Any] = {
370 "schema_version": EVENTS_SCHEMA_VERSION,
371 "schema_hash": EVENTS_SCHEMA_HASH,
372 "memory_event": to_dict(record),
373 "other_unrelated_field": "preserved",
374 }
375
376 wire = json.dumps(commit_meta)
377 decoded = json.loads(wire)
378
379 assert decoded["schema_version"] == "1.0.0"
380 assert decoded["schema_hash"] == EVENTS_SCHEMA_HASH
381
382 rebuilt = from_dict(decoded["memory_event"])
383 assert rebuilt == record
384
385
386 # ============================================================================
387 # Tier 4 — Stress
388 # ============================================================================
389
390
391 class TestStress:
392 def test_construct_10k_records_under_2s(self) -> None:
393 kinds = list(MemoryEventKind)
394 start = time.perf_counter()
395 for i in range(10_000):
396 MemoryEventRecord(
397 event_id=f"mem_{i:012x}",
398 kind=kinds[i % len(kinds)],
399 ts="2026-05-13T13:57:00+00:00",
400 vault_id="default",
401 data={"i": i},
402 )
403 elapsed = time.perf_counter() - start
404 assert elapsed < 2.0, f"10k constructions took {elapsed:.3f}s (budget 2s)"
405
406 def test_parse_10k_event_dicts_under_2s(self) -> None:
407 kinds = sorted(_EXPECTED_KIND_VALUES)
408 dicts = [
409 {
410 "event_id": f"mem_{i:012x}",
411 "kind": kinds[i % len(kinds)],
412 "ts": "2026-05-13T13:57:00+00:00",
413 "vault_id": "default",
414 "data": {"i": i},
415 }
416 for i in range(10_000)
417 ]
418 start = time.perf_counter()
419 for d in dicts:
420 from_dict(d)
421 elapsed = time.perf_counter() - start
422 assert elapsed < 2.0, f"10k parses took {elapsed:.3f}s (budget 2s)"
423
424
425 # ============================================================================
426 # Tier 5 — Data-integrity
427 # ============================================================================
428
429
430 class TestDataIntegrity:
431 """Cross-language parity with lib/memory-event.mjs."""
432
433 def test_python_kinds_match_js_array(self) -> None:
434 assert _JS_SOURCE.exists(), (
435 f"Cannot find JS source-of-truth at {_JS_SOURCE}; "
436 "this test must run with the knowtation repo checked out."
437 )
438 text = _JS_SOURCE.read_text(encoding="utf-8")
439 match = re.search(
440 r"MEMORY_EVENT_TYPES\s*=\s*Object\.freeze\(\s*\[(.+?)\]",
441 text,
442 flags=re.DOTALL,
443 )
444 assert match is not None, (
445 "Could not locate MEMORY_EVENT_TYPES literal in memory-event.mjs"
446 )
447 js_kinds = set(re.findall(r"'([^']+)'", match.group(1)))
448 py_kinds = {k.value for k in MemoryEventKind}
449 assert js_kinds == py_kinds, (
450 f"Drift detected — JS {sorted(js_kinds - py_kinds)!r} not in Python; "
451 f"Python {sorted(py_kinds - js_kinds)!r} not in JS."
452 )
453
454 def test_schema_hash_independent_recompute_matches(self) -> None:
455 sorted_values = sorted(k.value for k in MemoryEventKind)
456 canonical = json.dumps(sorted_values, sort_keys=True, separators=(",", ":"))
457 import hashlib
458
459 expected = hashlib.sha256(canonical.encode("utf-8")).hexdigest()
460 assert expected == EVENTS_SCHEMA_HASH
461
462
463 # ============================================================================
464 # Tier 6 — Performance
465 # ============================================================================
466
467
468 class TestPerformance:
469 def test_1k_roundtrips_under_200ms(self) -> None:
470 record = _minimal_record(
471 agent_id="agent-x",
472 model_id="model-y",
473 data={"a": 1, "b": [1, 2, 3], "c": {"d": "e"}},
474 )
475 start = time.perf_counter()
476 for _ in range(1_000):
477 from_dict(to_dict(record))
478 elapsed_ms = (time.perf_counter() - start) * 1_000.0
479 assert elapsed_ms < 200.0, (
480 f"1000 round-trips took {elapsed_ms:.1f}ms (budget 200ms)"
481 )
482
483
484 # ============================================================================
485 # Tier 7 — Security
486 # ============================================================================
487
488
489 class TestSecurityRejectsSensitiveKeys:
490 @pytest.mark.parametrize(
491 "data",
492 [
493 {"password": "x"},
494 {"api_key": "x"},
495 {"api-key": "x"},
496 {"PRIVATE_KEY": "x"},
497 {"Authorization": "Bearer xyz"},
498 {"BEARER": "x"},
499 {"my_secret": "x"},
500 {"some_token": "x"},
501 {"credential": "x"},
502 ],
503 )
504 def test_top_level_sensitive_keys_rejected(self, data: dict[str, Any]) -> None:
505 with pytest.raises(ValueError, match="sensitive key patterns"):
506 from_dict(_minimal_valid_dict(data=data))
507
508 def test_recursive_sensitive_key_rejected(self) -> None:
509 data = {"nested": {"api_key": "secret"}}
510 with pytest.raises(ValueError, match="sensitive key patterns"):
511 from_dict(_minimal_valid_dict(data=data))
512
513 def test_sensitive_key_inside_list_rejected(self) -> None:
514 data = {"items": [{"safe": 1}, {"private_key": "pk"}]}
515 with pytest.raises(ValueError, match="sensitive key patterns"):
516 from_dict(_minimal_valid_dict(data=data))
517
518
519 class TestSecurityDepthLimit:
520 def test_sensitive_key_beyond_depth_8_is_not_scanned(self) -> None:
521 # Build a structure where the sensitive key sits at nesting depth 9.
522 # Mirrors the JS hasSensitiveKeys depth > 8 short-circuit.
523 deep: dict[str, Any] = {"api_key": "x"}
524 for _ in range(9):
525 deep = {"a": deep}
526 # Sanity: the sensitive key is buried 9 nests below the root.
527 cur: Any = deep
528 steps = 0
529 while isinstance(cur, dict) and "api_key" not in cur:
530 cur = cur["a"]
531 steps += 1
532 assert steps == 9, f"expected api_key buried 9 nests deep, got {steps}"
533
534 record = from_dict(_minimal_valid_dict(data=deep))
535 assert record.data is deep or record.data == deep
536
537
538 class TestSecurityAdversarialInputs:
539 def test_oversized_kind_string_rejected_cleanly(self) -> None:
540 oversized = "x" * 10_000
541 with pytest.raises(ValueError):
542 from_dict(_minimal_valid_dict(kind=oversized))
543
544 def test_10k_safe_keys_accepted_under_1s(self) -> None:
545 big_data: dict[str, Any] = {f"safe_field_{i}": i for i in range(10_000)}
546 d = _minimal_valid_dict(data=big_data)
547 start = time.perf_counter()
548 record = from_dict(d)
549 elapsed = time.perf_counter() - start
550 assert elapsed < 1.0, (
551 f"parsing 10k safe-key data took {elapsed:.3f}s (budget 1s)"
552 )
553 assert len(record.data) == 10_000
File History 2 commits
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 26 days ago
sha256:d8316ffae901be06347e16ab55be11868eb519dd16ade3e8aa16a99e662f7e62 baseline: rc14 re-baseline after rc3 store corruption recovery Human patch 26 days ago