test_knowtation_hub_manifest.py
python
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6
chore(tests): add docstring to tests/__init__.py so rc14 tr…
Human
26 days ago
| 1 | """Tests for Phase 1.8 — Knowtation domain manifest for MuseHub registry. |
| 2 | |
| 3 | Test tiers covered |
| 4 | ------------------ |
| 5 | 1. Unit — static manifest.json: file exists, valid JSON, required top-level |
| 6 | fields, correct viewer_type, merge_semantics, version, six named dimensions. |
| 7 | 2. Unit — validate_manifest(): valid manifest passes, missing fields detected, |
| 8 | bad dimensions detected, unknown dimension names flagged. |
| 9 | 3. Unit — manifest_hash(): deterministic across runs, output format, identical |
| 10 | manifests produce same hash, different manifests produce different hashes. |
| 11 | 4. Unit — build_manifest(): required fields present, dimensions include all |
| 12 | six, descriptions from static are preserved, schema description used. |
| 13 | 5. Integration — assert_manifest_in_sync(): static and dynamic dimension names |
| 14 | match. |
| 15 | 6. Performance — manifest_hash() and build_manifest() complete in < 1 second. |
| 16 | 7. Security — manifest.json contains no credential-looking fields. |
| 17 | """ |
| 18 | |
| 19 | from __future__ import annotations |
| 20 | |
| 21 | import json |
| 22 | import time |
| 23 | from typing import Any |
| 24 | |
| 25 | import pytest |
| 26 | |
| 27 | from muse.plugins.knowtation.hub_manifest import ( |
| 28 | EXPECTED_DIMENSIONS, |
| 29 | MANIFEST_FILE, |
| 30 | assert_manifest_in_sync, |
| 31 | build_manifest, |
| 32 | load_manifest, |
| 33 | manifest_hash, |
| 34 | validate_manifest, |
| 35 | ) |
| 36 | |
| 37 | |
| 38 | # --------------------------------------------------------------------------- |
| 39 | # Helpers |
| 40 | # --------------------------------------------------------------------------- |
| 41 | |
| 42 | |
| 43 | def _valid_manifest() -> dict[str, Any]: |
| 44 | return load_manifest() |
| 45 | |
| 46 | |
| 47 | # ============================================================================ |
| 48 | # TestStaticManifestFile |
| 49 | # ============================================================================ |
| 50 | |
| 51 | |
| 52 | class TestStaticManifestFile: |
| 53 | def test_file_exists(self) -> None: |
| 54 | assert MANIFEST_FILE.exists(), f"manifest.json not found at {MANIFEST_FILE}" |
| 55 | |
| 56 | def test_file_is_valid_json(self) -> None: |
| 57 | content = MANIFEST_FILE.read_text(encoding="utf-8") |
| 58 | parsed = json.loads(content) |
| 59 | assert isinstance(parsed, dict) |
| 60 | |
| 61 | def test_slug_is_knowtation(self) -> None: |
| 62 | m = load_manifest() |
| 63 | assert m["slug"] == "knowtation" |
| 64 | |
| 65 | def test_viewer_type_is_knowledge(self) -> None: |
| 66 | m = load_manifest() |
| 67 | assert m["viewer_type"] == "knowledge" |
| 68 | |
| 69 | def test_merge_semantics_is_ot(self) -> None: |
| 70 | m = load_manifest() |
| 71 | assert m["merge_semantics"] == "ot" |
| 72 | |
| 73 | def test_version_present_and_semver(self) -> None: |
| 74 | m = load_manifest() |
| 75 | v = m["version"] |
| 76 | assert isinstance(v, str) |
| 77 | parts = v.split(".") |
| 78 | assert len(parts) == 3 |
| 79 | assert all(p.isdigit() for p in parts) |
| 80 | |
| 81 | def test_display_name_present(self) -> None: |
| 82 | m = load_manifest() |
| 83 | assert isinstance(m.get("display_name"), str) |
| 84 | assert len(m["display_name"]) > 0 |
| 85 | |
| 86 | def test_description_present(self) -> None: |
| 87 | m = load_manifest() |
| 88 | assert isinstance(m.get("description"), str) |
| 89 | assert len(m["description"]) > 0 |
| 90 | |
| 91 | def test_dimensions_is_list(self) -> None: |
| 92 | m = load_manifest() |
| 93 | assert isinstance(m.get("dimensions"), list) |
| 94 | |
| 95 | def test_exactly_six_dimensions(self) -> None: |
| 96 | m = load_manifest() |
| 97 | assert len(m["dimensions"]) == 6, ( |
| 98 | f"Expected 6 dimensions, got {len(m['dimensions'])}" |
| 99 | ) |
| 100 | |
| 101 | def test_expected_dimension_names_present(self) -> None: |
| 102 | m = load_manifest() |
| 103 | names = {d["name"] for d in m["dimensions"]} |
| 104 | assert names == EXPECTED_DIMENSIONS, ( |
| 105 | f"Dimension names mismatch: got {names}, expected {EXPECTED_DIMENSIONS}" |
| 106 | ) |
| 107 | |
| 108 | def test_all_dimensions_have_required_fields(self) -> None: |
| 109 | m = load_manifest() |
| 110 | required = {"name", "kind", "element_type", "description", "independent_merge"} |
| 111 | for i, dim in enumerate(m["dimensions"]): |
| 112 | for key in required: |
| 113 | assert key in dim, f"dimensions[{i}] missing '{key}'" |
| 114 | |
| 115 | def test_notes_dimension_not_independently_merged(self) -> None: |
| 116 | m = load_manifest() |
| 117 | notes_dims = [d for d in m["dimensions"] if d["name"] == "notes"] |
| 118 | assert notes_dims, "notes dimension missing" |
| 119 | assert notes_dims[0]["independent_merge"] is False |
| 120 | |
| 121 | def test_non_notes_dimensions_independently_merged(self) -> None: |
| 122 | m = load_manifest() |
| 123 | for dim in m["dimensions"]: |
| 124 | if dim["name"] != "notes": |
| 125 | assert dim["independent_merge"] is True, ( |
| 126 | f"Dimension '{dim['name']}' should have independent_merge=True" |
| 127 | ) |
| 128 | |
| 129 | def test_file_is_utf8(self) -> None: |
| 130 | MANIFEST_FILE.read_text(encoding="utf-8") |
| 131 | |
| 132 | def test_supported_commands_present(self) -> None: |
| 133 | m = load_manifest() |
| 134 | cmds = m.get("supported_commands", []) |
| 135 | assert isinstance(cmds, list) |
| 136 | assert "commit" in cmds |
| 137 | |
| 138 | |
| 139 | # ============================================================================ |
| 140 | # TestValidateManifest |
| 141 | # ============================================================================ |
| 142 | |
| 143 | |
| 144 | class TestValidateManifest: |
| 145 | def test_static_manifest_is_valid(self) -> None: |
| 146 | m = load_manifest() |
| 147 | errors = validate_manifest(m) |
| 148 | assert errors == [], f"Static manifest has validation errors: {errors}" |
| 149 | |
| 150 | def test_missing_required_field_detected(self) -> None: |
| 151 | m = dict(load_manifest()) |
| 152 | del m["viewer_type"] |
| 153 | errors = validate_manifest(m) |
| 154 | assert any("viewer_type" in e for e in errors) |
| 155 | |
| 156 | def test_missing_description_detected(self) -> None: |
| 157 | m = dict(load_manifest()) |
| 158 | del m["description"] |
| 159 | errors = validate_manifest(m) |
| 160 | assert any("description" in e for e in errors) |
| 161 | |
| 162 | def test_missing_dimensions_key(self) -> None: |
| 163 | m = {k: v for k, v in load_manifest().items() if k != "dimensions"} |
| 164 | errors = validate_manifest(m) |
| 165 | assert any("dimensions" in e for e in errors) |
| 166 | |
| 167 | def test_empty_dimensions_detected(self) -> None: |
| 168 | m = dict(load_manifest()) |
| 169 | m["dimensions"] = [] |
| 170 | errors = validate_manifest(m) |
| 171 | assert any("dimensions" in e for e in errors) |
| 172 | |
| 173 | def test_dimension_missing_name_detected(self) -> None: |
| 174 | m = dict(load_manifest()) |
| 175 | bad_dim = dict(m["dimensions"][0]) |
| 176 | del bad_dim["name"] |
| 177 | m["dimensions"] = [bad_dim] + m["dimensions"][1:] |
| 178 | errors = validate_manifest(m) |
| 179 | assert any("name" in e for e in errors) |
| 180 | |
| 181 | def test_returns_list(self) -> None: |
| 182 | errors = validate_manifest(load_manifest()) |
| 183 | assert isinstance(errors, list) |
| 184 | |
| 185 | |
| 186 | # ============================================================================ |
| 187 | # TestManifestHash |
| 188 | # ============================================================================ |
| 189 | |
| 190 | |
| 191 | class TestManifestHash: |
| 192 | def test_hash_format(self) -> None: |
| 193 | h = manifest_hash(load_manifest()) |
| 194 | assert h.startswith("sha256:") |
| 195 | assert len(h) == len("sha256:") + 64 |
| 196 | |
| 197 | def test_hash_is_deterministic(self) -> None: |
| 198 | m = load_manifest() |
| 199 | h1 = manifest_hash(m) |
| 200 | h2 = manifest_hash(m) |
| 201 | assert h1 == h2 |
| 202 | |
| 203 | def test_identical_dicts_same_hash(self) -> None: |
| 204 | m1 = load_manifest() |
| 205 | m2 = load_manifest() |
| 206 | assert manifest_hash(m1) == manifest_hash(m2) |
| 207 | |
| 208 | def test_different_dicts_different_hash(self) -> None: |
| 209 | m1 = load_manifest() |
| 210 | m2 = dict(load_manifest()) |
| 211 | m2["version"] = "9.9.9" |
| 212 | assert manifest_hash(m1) != manifest_hash(m2) |
| 213 | |
| 214 | def test_key_order_does_not_affect_hash(self) -> None: |
| 215 | m1 = load_manifest() |
| 216 | # Build reversed-key version |
| 217 | m2 = dict(reversed(list(m1.items()))) |
| 218 | assert manifest_hash(m1) == manifest_hash(m2) |
| 219 | |
| 220 | def test_hash_only_hex_after_prefix(self) -> None: |
| 221 | h = manifest_hash(load_manifest()) |
| 222 | hex_part = h[len("sha256:"):] |
| 223 | assert all(c in "0123456789abcdef" for c in hex_part) |
| 224 | |
| 225 | |
| 226 | # ============================================================================ |
| 227 | # TestBuildManifest |
| 228 | # ============================================================================ |
| 229 | |
| 230 | |
| 231 | class TestBuildManifest: |
| 232 | def test_build_returns_dict(self) -> None: |
| 233 | result = build_manifest() |
| 234 | assert isinstance(result, dict) |
| 235 | |
| 236 | def test_build_has_required_fields(self) -> None: |
| 237 | result = build_manifest() |
| 238 | for key in ("slug", "dimensions", "viewer_type", "merge_semantics", "version"): |
| 239 | assert key in result, f"build_manifest() missing '{key}'" |
| 240 | |
| 241 | def test_build_has_six_dimensions(self) -> None: |
| 242 | result = build_manifest() |
| 243 | assert len(result["dimensions"]) == 6 |
| 244 | |
| 245 | def test_build_dimension_names_match_expected(self) -> None: |
| 246 | result = build_manifest() |
| 247 | names = {d["name"] for d in result["dimensions"]} |
| 248 | assert names == EXPECTED_DIMENSIONS |
| 249 | |
| 250 | def test_build_notes_not_independently_merged(self) -> None: |
| 251 | result = build_manifest() |
| 252 | notes = next(d for d in result["dimensions"] if d["name"] == "notes") |
| 253 | assert notes["independent_merge"] is False |
| 254 | |
| 255 | def test_build_preserves_description_from_static(self) -> None: |
| 256 | result = build_manifest() |
| 257 | static = load_manifest() |
| 258 | # Descriptions come from static file — verify they match |
| 259 | static_descs = {d["name"]: d["description"] for d in static["dimensions"]} |
| 260 | for dim in result["dimensions"]: |
| 261 | assert dim["description"] == static_descs.get(dim["name"], ""), ( |
| 262 | f"Description mismatch for dimension '{dim['name']}'" |
| 263 | ) |
| 264 | |
| 265 | def test_build_uses_plugin_schema_description(self) -> None: |
| 266 | result = build_manifest() |
| 267 | # The description in build_manifest comes from schema(); it must be |
| 268 | # a non-empty string mentioning the domain. |
| 269 | assert isinstance(result.get("description"), str) |
| 270 | assert len(result["description"]) > 0 |
| 271 | |
| 272 | |
| 273 | # ============================================================================ |
| 274 | # TestAssertManifestInSync |
| 275 | # ============================================================================ |
| 276 | |
| 277 | |
| 278 | class TestAssertManifestInSync: |
| 279 | def test_static_and_dynamic_are_in_sync(self) -> None: |
| 280 | # Must not raise — dimension names should match exactly. |
| 281 | assert_manifest_in_sync() |
| 282 | |
| 283 | def test_raises_when_static_missing_dimension(self) -> None: |
| 284 | from muse.plugins.knowtation import hub_manifest as hm |
| 285 | original_load = hm.load_manifest |
| 286 | |
| 287 | def _fake_load() -> dict[str, Any]: |
| 288 | m = original_load() |
| 289 | m["dimensions"] = [d for d in m["dimensions"] if d["name"] != "links"] |
| 290 | return m |
| 291 | |
| 292 | hm.load_manifest = _fake_load |
| 293 | try: |
| 294 | with pytest.raises(AssertionError, match="out of sync"): |
| 295 | hm.assert_manifest_in_sync() |
| 296 | finally: |
| 297 | hm.load_manifest = original_load |
| 298 | |
| 299 | |
| 300 | # ============================================================================ |
| 301 | # TestManifestHashStability (integration) |
| 302 | # ============================================================================ |
| 303 | |
| 304 | |
| 305 | class TestManifestHashStability: |
| 306 | def test_hash_stable_on_five_consecutive_calls(self) -> None: |
| 307 | m = load_manifest() |
| 308 | hashes = {manifest_hash(m) for _ in range(5)} |
| 309 | assert len(hashes) == 1, "Hash must be identical across all runs" |
| 310 | |
| 311 | def test_build_manifest_hash_stable_on_three_calls(self) -> None: |
| 312 | hashes = {manifest_hash(build_manifest()) for _ in range(3)} |
| 313 | assert len(hashes) == 1 |
| 314 | |
| 315 | |
| 316 | # ============================================================================ |
| 317 | # TestPerformance |
| 318 | # ============================================================================ |
| 319 | |
| 320 | |
| 321 | class TestPerformance: |
| 322 | def test_manifest_hash_under_one_second(self) -> None: |
| 323 | m = load_manifest() |
| 324 | start = time.monotonic() |
| 325 | manifest_hash(m) |
| 326 | elapsed = time.monotonic() - start |
| 327 | assert elapsed < 1.0, f"manifest_hash took {elapsed:.3f}s" |
| 328 | |
| 329 | def test_build_manifest_under_one_second(self) -> None: |
| 330 | start = time.monotonic() |
| 331 | build_manifest() |
| 332 | elapsed = time.monotonic() - start |
| 333 | assert elapsed < 1.0, f"build_manifest took {elapsed:.3f}s" |
| 334 | |
| 335 | |
| 336 | # ============================================================================ |
| 337 | # TestSecurity |
| 338 | # ============================================================================ |
| 339 | |
| 340 | |
| 341 | class TestSecurity: |
| 342 | def test_no_credential_keys(self) -> None: |
| 343 | """manifest.json must not contain credential-looking keys.""" |
| 344 | sensitive = {"password", "token", "secret", "api_key", "private_key"} |
| 345 | content = MANIFEST_FILE.read_text(encoding="utf-8").lower() |
| 346 | for word in sensitive: |
| 347 | assert word not in content, f"manifest.json contains sensitive word '{word}'" |
| 348 | |
| 349 | def test_no_hardcoded_urls_with_credentials(self) -> None: |
| 350 | content = MANIFEST_FILE.read_text(encoding="utf-8") |
| 351 | assert "://" not in content or ( |
| 352 | "https://" not in content.replace("https://musehub.ai", "") |
| 353 | ), "manifest.json should not contain hardcoded URLs with credentials" |
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