gabriel / muse public
test_core_agent_slots.py python
582 lines 22.1 KB
Raw
sha256:c06a9b9b9fee26c68ea725b44d54b2c0a171301ce9de746d5b656617b4463a9a fix: repair four test failures from post-migration audit Sonnet 4.6 patch 28 days ago
1 """Comprehensive tests for ``muse.core.agent_slots``.
2
3 Covers all eight categories:
4 1. Unit — _toml_escape, _load_raw, _dump round-trip
5 2. Integration — get_next_account, register_slot, list_slots, peek_next_account
6 3. E2E — full read-write cycle through public API with real tmp files
7 4. Stress — 500 sequential accounts, 100-slot registry
8 5. Data integrity — monotonic counter, slot persistence, msign_path format
9 6. Performance — slot operations complete within budget
10 7. Security — symlink guard on write, 0o600 file mode, lock file created
11 8. Docstrings — all public callables and the module have docstrings
12 """
13
14 from __future__ import annotations
15
16 import pathlib
17 import types
18 import stat
19 import time
20 from typing import Any
21
22 import pytest
23
24 # ---------------------------------------------------------------------------
25 # Constants
26 # ---------------------------------------------------------------------------
27
28 _TEST_HUB = "https://localhost:1337"
29 _TEST_HOSTNAME = "localhost:1337"
30 _TEST_HUB2 = "https://staging.musehub.ai"
31 _TEST_HOSTNAME2 = "staging.musehub.ai"
32
33
34 # ---------------------------------------------------------------------------
35 # Fixtures
36 # ---------------------------------------------------------------------------
37
38
39 @pytest.fixture()
40 def slots_dir(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path:
41 """Redirect the agent-slots store to a temp directory."""
42 fake_dir = tmp_path / "agent_slots"
43 fake_dir.mkdir()
44 fake_file = fake_dir / "agent-slots.toml"
45
46 monkeypatch.setattr("muse.core.agent_slots._SLOTS_DIR", fake_dir)
47 monkeypatch.setattr("muse.core.agent_slots._SLOTS_FILE", fake_file)
48
49 return fake_dir
50
51
52 @pytest.fixture()
53 def slots_file(slots_dir: pathlib.Path) -> pathlib.Path:
54 """Return the path to the isolated agent-slots.toml."""
55 return slots_dir / "agent-slots.toml"
56
57
58 # ---------------------------------------------------------------------------
59 # 1. Unit — pure helpers
60 # ---------------------------------------------------------------------------
61
62
63 class TestTomlEscape:
64 """Unit tests for _toml_escape."""
65
66 def test_escapes_backslash(self) -> None:
67 from muse.core.agent_slots import _toml_escape
68 assert _toml_escape("a\\b") == "a\\\\b"
69
70 def test_escapes_double_quote(self) -> None:
71 from muse.core.agent_slots import _toml_escape
72 assert _toml_escape('say "hello"') == 'say \\"hello\\"'
73
74 def test_plain_string_unchanged(self) -> None:
75 from muse.core.agent_slots import _toml_escape
76 assert _toml_escape("localhost:1337") == "localhost:1337"
77
78 def test_both_special_chars(self) -> None:
79 from muse.core.agent_slots import _toml_escape
80 raw = 'path\\to\\"file"'
81 escaped = _toml_escape(raw)
82 assert "\\\\" in escaped
83 assert '\\"' in escaped
84
85
86 class TestLoadRaw:
87 """Unit tests for _load_raw."""
88
89 def test_returns_empty_dict_when_absent(self, tmp_path: pathlib.Path) -> None:
90 from muse.core.agent_slots import _load_raw
91 assert _load_raw(tmp_path / "nonexistent.toml") == {}
92
93 def test_returns_empty_dict_on_corrupt_file(self, tmp_path: pathlib.Path) -> None:
94 from muse.core.agent_slots import _load_raw
95 p = tmp_path / "bad.toml"
96 p.write_bytes(b"\xff\xfe corrupt")
97 assert _load_raw(p) == {}
98
99 def test_loads_valid_toml(self, tmp_path: pathlib.Path) -> None:
100 from muse.core.agent_slots import _load_raw
101 p = tmp_path / "slots.toml"
102 p.write_text(
103 '["localhost:1337"]\nnext_account = 3\n',
104 encoding="utf-8",
105 )
106 data = _load_raw(p)
107 assert data["localhost:1337"]["next_account"] == 3
108
109
110 class TestDump:
111 """Unit tests for _dump round-trip."""
112
113 def test_empty_dict_produces_empty_string(self) -> None:
114 from muse.core.agent_slots import _dump
115 assert _dump({}) == ""
116
117 def test_round_trip_preserves_next_account(self) -> None:
118 from muse.core.agent_slots import _dump, _load_raw
119 import tempfile, pathlib as pl
120 data = {"localhost:1337": {"next_account": 7, "slots": {"orchestra": 1}}}
121 text = _dump(data)
122 with tempfile.NamedTemporaryFile(
123 mode="w", suffix=".toml", delete=False, encoding="utf-8"
124 ) as f:
125 f.write(text)
126 tmp = pl.Path(f.name)
127 try:
128 loaded = _load_raw(tmp)
129 assert loaded["localhost:1337"]["next_account"] == 7
130 assert loaded["localhost:1337"]["slots"]["orchestra"] == 1
131 finally:
132 tmp.unlink(missing_ok=True)
133
134 def test_slots_sorted_in_output(self) -> None:
135 from muse.core.agent_slots import _dump
136 data = {
137 "localhost:1337": {
138 "next_account": 5,
139 "slots": {"zzz": 3, "aaa": 1},
140 }
141 }
142 text = _dump(data)
143 pos_aaa = text.index("aaa")
144 pos_zzz = text.index("zzz")
145 assert pos_aaa < pos_zzz, "slots should be alphabetically sorted"
146
147 def test_hostnames_sorted_in_output(self) -> None:
148 from muse.core.agent_slots import _dump
149 data = {
150 "z.example.com": {"next_account": 1},
151 "a.example.com": {"next_account": 2},
152 }
153 text = _dump(data)
154 assert text.index("a.example.com") < text.index("z.example.com")
155
156
157 # ---------------------------------------------------------------------------
158 # 2. Integration — public API with isolated store
159 # ---------------------------------------------------------------------------
160
161
162 class TestGetNextAccount:
163 """Integration tests for get_next_account."""
164
165 def test_first_call_returns_1(self, slots_dir: pathlib.Path) -> None:
166 from muse.core.agent_slots import get_next_account
167 assert get_next_account(_TEST_HUB) == 1
168
169 def test_increments_on_each_call(self, slots_dir: pathlib.Path) -> None:
170 from muse.core.agent_slots import get_next_account
171 a = get_next_account(_TEST_HUB)
172 b = get_next_account(_TEST_HUB)
173 c = get_next_account(_TEST_HUB)
174 assert b == a + 1
175 assert c == b + 1
176
177 def test_independent_per_hub(self, slots_dir: pathlib.Path) -> None:
178 from muse.core.agent_slots import get_next_account
179 a1 = get_next_account(_TEST_HUB)
180 b1 = get_next_account(_TEST_HUB2)
181 a2 = get_next_account(_TEST_HUB)
182 b2 = get_next_account(_TEST_HUB2)
183 assert a1 == 1
184 assert b1 == 1
185 assert a2 == 2
186 assert b2 == 2
187
188 def test_minimum_account_is_1(self, slots_dir: pathlib.Path, slots_file: pathlib.Path) -> None:
189 """Even if the TOML contains 0, we clamp to 1."""
190 from muse.core.agent_slots import get_next_account
191 slots_file.write_text(
192 '["localhost:1337"]\nnext_account = 0\n', encoding="utf-8"
193 )
194 assert get_next_account(_TEST_HUB) == 1
195
196
197 class TestPeekNextAccount:
198 """Integration tests for peek_next_account."""
199
200 def test_does_not_increment(self, slots_dir: pathlib.Path) -> None:
201 from muse.core.agent_slots import peek_next_account, get_next_account
202 v1 = peek_next_account(_TEST_HUB)
203 v2 = peek_next_account(_TEST_HUB)
204 assert v1 == v2 == 1
205 # Now increment and verify peek catches up
206 actual = get_next_account(_TEST_HUB)
207 assert actual == 1
208 assert peek_next_account(_TEST_HUB) == 2
209
210 def test_returns_1_when_file_absent(self, slots_dir: pathlib.Path) -> None:
211 from muse.core.agent_slots import peek_next_account
212 assert peek_next_account(_TEST_HUB) == 1
213
214 def test_reflects_current_state(self, slots_dir: pathlib.Path) -> None:
215 from muse.core.agent_slots import get_next_account, peek_next_account
216 for _ in range(5):
217 get_next_account(_TEST_HUB)
218 assert peek_next_account(_TEST_HUB) == 6
219
220
221 class TestRegisterSlot:
222 """Integration tests for register_slot."""
223
224 def test_register_returns_slot_with_correct_fields(
225 self, slots_dir: pathlib.Path
226 ) -> None:
227 from muse.core.agent_slots import register_slot
228 slot = register_slot(_TEST_HUB, "orchestra", 1)
229 assert slot["name"] == "orchestra"
230 assert slot["account"] == 1
231 assert slot["hub"] == _TEST_HOSTNAME
232 assert "msign_path" in slot
233
234 def test_msign_path_contains_account(self, slots_dir: pathlib.Path) -> None:
235 from muse.core.agent_slots import register_slot
236 slot = register_slot(_TEST_HUB, "mixer", 7)
237 assert "7'" in slot["msign_path"]
238
239 def test_msign_path_starts_with_m(self, slots_dir: pathlib.Path) -> None:
240 from muse.core.agent_slots import register_slot
241 slot = register_slot(_TEST_HUB, "test", 2)
242 assert slot["msign_path"].startswith("m/")
243
244 def test_overwrite_updates_account(self, slots_dir: pathlib.Path) -> None:
245 from muse.core.agent_slots import register_slot, list_slots
246 register_slot(_TEST_HUB, "alpha", 1)
247 register_slot(_TEST_HUB, "alpha", 99)
248 slots = list_slots(_TEST_HUB)
249 matched = [s for s in slots if s["name"] == "alpha"]
250 assert len(matched) == 1
251 assert matched[0]["account"] == 99
252
253 def test_persists_to_file(self, slots_dir: pathlib.Path, slots_file: pathlib.Path) -> None:
254 from muse.core.agent_slots import register_slot
255 register_slot(_TEST_HUB, "persistent", 3)
256 assert slots_file.exists()
257 content = slots_file.read_text(encoding="utf-8")
258 assert "persistent" in content
259
260
261 class TestListSlots:
262 """Integration tests for list_slots."""
263
264 def test_empty_when_no_file(self, slots_dir: pathlib.Path) -> None:
265 from muse.core.agent_slots import list_slots
266 assert list_slots(_TEST_HUB) == []
267
268 def test_returns_registered_slots(self, slots_dir: pathlib.Path) -> None:
269 from muse.core.agent_slots import register_slot, list_slots
270 register_slot(_TEST_HUB, "a", 1)
271 register_slot(_TEST_HUB, "b", 2)
272 slots = list_slots(_TEST_HUB)
273 assert len(slots) == 2
274
275 def test_sorted_by_account_ascending(self, slots_dir: pathlib.Path) -> None:
276 from muse.core.agent_slots import register_slot, list_slots
277 register_slot(_TEST_HUB, "z", 10)
278 register_slot(_TEST_HUB, "a", 3)
279 register_slot(_TEST_HUB, "m", 7)
280 slots = list_slots(_TEST_HUB)
281 accounts = [s["account"] for s in slots]
282 assert accounts == sorted(accounts)
283
284 def test_isolated_per_hub(self, slots_dir: pathlib.Path) -> None:
285 from muse.core.agent_slots import register_slot, list_slots
286 register_slot(_TEST_HUB, "hub1-agent", 1)
287 register_slot(_TEST_HUB2, "hub2-agent", 2)
288 slots1 = list_slots(_TEST_HUB)
289 slots2 = list_slots(_TEST_HUB2)
290 assert len(slots1) == 1 and slots1[0]["name"] == "hub1-agent"
291 assert len(slots2) == 1 and slots2[0]["name"] == "hub2-agent"
292
293 def test_all_fields_present(self, slots_dir: pathlib.Path) -> None:
294 from muse.core.agent_slots import register_slot, list_slots
295 register_slot(_TEST_HUB, "full-check", 5)
296 slot = list_slots(_TEST_HUB)[0]
297 assert "name" in slot
298 assert "account" in slot
299 assert "hub" in slot
300 assert "msign_path" in slot
301
302
303 # ---------------------------------------------------------------------------
304 # 3. E2E — full read-write cycle with real tmp files
305 # ---------------------------------------------------------------------------
306
307
308 class TestE2EReadWriteCycle:
309 """End-to-end: write through public API, read back through public API."""
310
311 def test_get_register_list_roundtrip(self, slots_dir: pathlib.Path) -> None:
312 from muse.core.agent_slots import get_next_account, register_slot, list_slots
313
314 acct = get_next_account(_TEST_HUB)
315 register_slot(_TEST_HUB, "roundtrip", acct)
316 slots = list_slots(_TEST_HUB)
317 assert any(s["name"] == "roundtrip" and s["account"] == acct for s in slots)
318
319 def test_peek_before_and_after_register(self, slots_dir: pathlib.Path) -> None:
320 from muse.core.agent_slots import get_next_account, peek_next_account, register_slot
321
322 before = peek_next_account(_TEST_HUB)
323 acct = get_next_account(_TEST_HUB)
324 assert acct == before
325 register_slot(_TEST_HUB, "e2e", acct)
326 after = peek_next_account(_TEST_HUB)
327 assert after == acct + 1
328
329 def test_multiple_hubs_in_same_file(self, slots_dir: pathlib.Path) -> None:
330 from muse.core.agent_slots import register_slot, list_slots, get_next_account
331
332 register_slot(_TEST_HUB, "local-agent", get_next_account(_TEST_HUB))
333 register_slot(_TEST_HUB2, "staging-agent", get_next_account(_TEST_HUB2))
334
335 local = list_slots(_TEST_HUB)
336 staging = list_slots(_TEST_HUB2)
337 assert local[0]["name"] == "local-agent"
338 assert staging[0]["name"] == "staging-agent"
339
340 def test_file_contains_both_hubs(self, slots_dir: pathlib.Path, slots_file: pathlib.Path) -> None:
341 from muse.core.agent_slots import register_slot
342
343 register_slot(_TEST_HUB, "a", 1)
344 register_slot(_TEST_HUB2, "b", 1)
345 content = slots_file.read_text(encoding="utf-8")
346 assert _TEST_HOSTNAME in content
347 assert _TEST_HOSTNAME2 in content
348
349
350 # ---------------------------------------------------------------------------
351 # 4. Stress — many accounts and slots
352 # ---------------------------------------------------------------------------
353
354
355 class TestStress:
356 """Stress tests."""
357
358 def test_500_sequential_get_next_account(self, slots_dir: pathlib.Path) -> None:
359 from muse.core.agent_slots import get_next_account
360 accounts = [get_next_account(_TEST_HUB) for _ in range(500)]
361 assert accounts == list(range(1, 501))
362
363 def test_100_slots_registered_and_listed(self, slots_dir: pathlib.Path) -> None:
364 from muse.core.agent_slots import register_slot, list_slots
365 for i in range(1, 101):
366 register_slot(_TEST_HUB, f"agent-{i:03d}", i)
367 slots = list_slots(_TEST_HUB)
368 assert len(slots) == 100
369
370 def test_repeated_peek_is_stable(self, slots_dir: pathlib.Path) -> None:
371 from muse.core.agent_slots import peek_next_account
372 values = {peek_next_account(_TEST_HUB) for _ in range(100)}
373 assert values == {1}
374
375 def test_interleaved_hubs_stay_independent(self, slots_dir: pathlib.Path) -> None:
376 from muse.core.agent_slots import get_next_account
377 for i in range(50):
378 get_next_account(_TEST_HUB)
379 get_next_account(_TEST_HUB2)
380 from muse.core.agent_slots import peek_next_account
381 assert peek_next_account(_TEST_HUB) == 51
382 assert peek_next_account(_TEST_HUB2) == 51
383
384
385 # ---------------------------------------------------------------------------
386 # 5. Data integrity
387 # ---------------------------------------------------------------------------
388
389
390 class TestDataIntegrity:
391 """Data integrity tests."""
392
393 def test_counter_is_monotonic(self, slots_dir: pathlib.Path) -> None:
394 from muse.core.agent_slots import get_next_account
395 prev = 0
396 for _ in range(20):
397 cur = get_next_account(_TEST_HUB)
398 assert cur > prev
399 prev = cur
400
401 def test_peek_never_exceeds_next(self, slots_dir: pathlib.Path) -> None:
402 from muse.core.agent_slots import get_next_account, peek_next_account
403 for _ in range(10):
404 get_next_account(_TEST_HUB)
405 assert peek_next_account(_TEST_HUB) == 11
406
407 def test_slot_hub_field_is_hostname_not_url(self, slots_dir: pathlib.Path) -> None:
408 from muse.core.agent_slots import register_slot
409 slot = register_slot(_TEST_HUB, "test", 1)
410 assert slot["hub"] == _TEST_HOSTNAME
411 assert "http://" not in slot["hub"]
412
413 def test_msign_path_schema(self, slots_dir: pathlib.Path) -> None:
414 """msign_path must be m/purpose'/domain'/entity_agent'/account'."""
415 from muse.core.agent_slots import register_slot
416 from muse.core.hdkeys import DOMAIN_IDENTITY, ENTITY_AGENT, MUSE_PURPOSE
417 slot = register_slot(_TEST_HUB, "schema-check", 4)
418 expected = f"m/{MUSE_PURPOSE}'/{DOMAIN_IDENTITY}'/{ENTITY_AGENT}'/4'"
419 assert slot["msign_path"] == expected
420
421 def test_file_permissions_after_register(
422 self, slots_dir: pathlib.Path, slots_file: pathlib.Path
423 ) -> None:
424 from muse.core.agent_slots import register_slot
425 register_slot(_TEST_HUB, "perm", 1)
426 mode = stat.S_IMODE(slots_file.stat().st_mode)
427 assert mode == 0o600
428
429 def test_next_account_survives_restart(self, slots_dir: pathlib.Path) -> None:
430 """Simulate process restart: counter must be read back from file."""
431 from muse.core.agent_slots import get_next_account, _SLOTS_FILE, _load_raw
432 for _ in range(5):
433 get_next_account(_TEST_HUB)
434 # Reload from file as a fresh process would
435 data = _load_raw(_SLOTS_FILE)
436 assert data[_TEST_HOSTNAME]["next_account"] == 6
437
438
439 # ---------------------------------------------------------------------------
440 # 6. Performance
441 # ---------------------------------------------------------------------------
442
443
444 class TestPerformance:
445 """Performance tests."""
446
447 def test_100_get_next_account_under_3_seconds(
448 self, slots_dir: pathlib.Path
449 ) -> None:
450 from muse.core.agent_slots import get_next_account
451 start = time.monotonic()
452 for _ in range(100):
453 get_next_account(_TEST_HUB)
454 elapsed = time.monotonic() - start
455 assert elapsed < 3.0, f"100 increments took {elapsed:.3f}s"
456
457 def test_100_register_slot_under_3_seconds(
458 self, slots_dir: pathlib.Path
459 ) -> None:
460 from muse.core.agent_slots import register_slot
461 start = time.monotonic()
462 for i in range(1, 101):
463 register_slot(_TEST_HUB, f"perf-{i}", i)
464 elapsed = time.monotonic() - start
465 assert elapsed < 3.0, f"100 register_slot calls took {elapsed:.3f}s"
466
467 def test_list_100_slots_under_1_second(self, slots_dir: pathlib.Path) -> None:
468 from muse.core.agent_slots import register_slot, list_slots
469 for i in range(1, 101):
470 register_slot(_TEST_HUB, f"agent-{i}", i)
471 start = time.monotonic()
472 slots = list_slots(_TEST_HUB)
473 elapsed = time.monotonic() - start
474 assert len(slots) == 100
475 assert elapsed < 1.0, f"list_slots for 100 entries took {elapsed:.3f}s"
476
477
478 # ---------------------------------------------------------------------------
479 # 7. Security
480 # ---------------------------------------------------------------------------
481
482
483 class TestSecurity:
484 """Security tests."""
485
486 def test_symlink_guard_blocks_write(
487 self, slots_dir: pathlib.Path, slots_file: pathlib.Path
488 ) -> None:
489 """Writing must refuse if agent-slots.toml is a symlink."""
490 decoy = slots_dir / "decoy.toml"
491 decoy.write_text("", encoding="utf-8")
492 slots_file.symlink_to(decoy)
493
494 from muse.core.agent_slots import register_slot
495 with pytest.raises(OSError, match="symlink"):
496 register_slot(_TEST_HUB, "malicious", 1)
497
498 def test_file_mode_is_0600(
499 self, slots_dir: pathlib.Path, slots_file: pathlib.Path
500 ) -> None:
501 from muse.core.agent_slots import register_slot
502 register_slot(_TEST_HUB, "mode-check", 1)
503 mode = stat.S_IMODE(slots_file.stat().st_mode)
504 assert mode == 0o600, f"Expected 0o600, got {oct(mode)}"
505
506 def test_lock_file_created_in_slots_dir(
507 self, slots_dir: pathlib.Path
508 ) -> None:
509 from muse.core.agent_slots import get_next_account
510 get_next_account(_TEST_HUB)
511 lock = slots_dir / ".agent-slots.lock"
512 assert lock.exists()
513
514 def test_concurrent_writes_no_data_loss(self, slots_dir: pathlib.Path) -> None:
515 """Two threads incrementing the counter must not produce duplicates."""
516 import threading
517 from muse.core.agent_slots import get_next_account
518
519 results: list[int] = []
520 lock = threading.Lock()
521
522 def worker() -> None:
523 for _ in range(25):
524 v = get_next_account(_TEST_HUB)
525 with lock:
526 results.append(v)
527
528 threads = [threading.Thread(target=worker) for _ in range(4)]
529 for t in threads:
530 t.start()
531 for t in threads:
532 t.join()
533
534 assert len(results) == 100
535 assert len(set(results)) == 100, "Concurrent increments produced duplicates!"
536 assert sorted(results) == list(range(1, 101))
537
538 def test_toml_injection_in_hub_hostname_escaped(
539 self, slots_dir: pathlib.Path
540 ) -> None:
541 """Hub hostnames with TOML special chars must be escaped in output."""
542 from muse.core.agent_slots import register_slot, list_slots, _dump, _load_raw, _SLOTS_FILE
543 # Use a hostname containing a double-quote (pathological but valid to escape)
544 tricky_hub = 'host"with"quotes:8080'
545 register_slot(tricky_hub, "injector", 1)
546 # File must be loadable (no TOML parse error)
547 data = _load_raw(_SLOTS_FILE)
548 assert tricky_hub in data
549
550
551 # ---------------------------------------------------------------------------
552 # 8. Docstrings
553 # ---------------------------------------------------------------------------
554
555
556 class TestDocstrings:
557 """Verify every public callable in agent_slots.py has a docstring."""
558
559 def _public_names(self) -> list[tuple[str, types.FunctionType | type]]:
560 import inspect
561 import muse.core.agent_slots as mod
562 return [
563 (name, obj)
564 for name, obj in inspect.getmembers(mod)
565 if not name.startswith("_")
566 and (inspect.isfunction(obj) or inspect.isclass(obj))
567 and obj.__module__ == mod.__name__
568 ]
569
570 def test_all_public_functions_have_docstrings(self) -> None:
571 for name, obj in self._public_names():
572 assert obj.__doc__, (
573 f"muse.core.agent_slots.{name} is missing a docstring"
574 )
575
576 def test_module_has_docstring(self) -> None:
577 import muse.core.agent_slots as mod
578 assert mod.__doc__, "muse.core.agent_slots module is missing a docstring"
579
580 def test_agent_slot_typed_dict_has_docstring(self) -> None:
581 from muse.core.agent_slots import AgentSlot
582 assert AgentSlot.__doc__
File History 2 commits
sha256:c06a9b9b9fee26c68ea725b44d54b2c0a171301ce9de746d5b656617b4463a9a fix: repair four test failures from post-migration audit Sonnet 4.6 patch 28 days ago
sha256:1900655993c83c4107067375548a7be823e471d2515830842f1a12cba4bd3cdf fix: unified object store migration — idempotent writes, JS… Sonnet 4.6 minor 29 days ago