test_update_ref_supercharge.py
python
sha256:81ae324db5ad375fbfe4834c6fcb378312cafad3cc92dec5d3e5c427306621a2
fix: remove commit_exists filter from have anchors — server…
Sonnet 4.6
patch
20 days ago
| 1 | """SUPERCHARGE tests for ``muse update-ref``. |
| 2 | |
| 3 | Coverage tiers |
| 4 | -------------- |
| 5 | - U (Unit): duration_ms / exit_code in every JSON success path |
| 6 | - E (Error): JSON errors → stdout when --json; stderr in text mode |
| 7 | - S (Schema): error payload has {error, message, duration_ms, exit_code} |
| 8 | - D (Data): exit_code semantics, previous/deleted field accuracy |
| 9 | - CAS: compare-and-swap timing and error payloads |
| 10 | - P (Perf): duration_ms stays under a sane ceiling |
| 11 | - Sec (Security): no traceback on any error path; path traversal rejected |
| 12 | - C (Concurrency): independent branches updated safely in parallel threads |
| 13 | |
| 14 | Utilities used |
| 15 | -------------- |
| 16 | - ``long_id(hex)`` — ``sha256:<64-hex>`` from a bare hex string |
| 17 | - ``short_id(id)`` — ``sha256:<12-hex>`` abbreviated form |
| 18 | - ``blob_id(data)`` — ``sha256:<hex>`` of arbitrary bytes (unique IDs) |
| 19 | """ |
| 20 | from __future__ import annotations |
| 21 | from collections.abc import Mapping |
| 22 | |
| 23 | import datetime |
| 24 | import json |
| 25 | import pathlib |
| 26 | import threading |
| 27 | from unittest import mock |
| 28 | |
| 29 | import pytest |
| 30 | |
| 31 | from muse.core.errors import ExitCode |
| 32 | from muse.core.paths import muse_dir, ref_path |
| 33 | from muse.core.ids import hash_commit as compute_commit_id, hash_snapshot as compute_snapshot_id |
| 34 | from muse.core.commits import ( |
| 35 | CommitRecord, |
| 36 | write_commit, |
| 37 | ) |
| 38 | from muse.core.snapshots import ( |
| 39 | SnapshotRecord, |
| 40 | write_snapshot, |
| 41 | ) |
| 42 | from muse.core.types import blob_id, long_id, short_id |
| 43 | from tests.cli_test_helper import CliRunner, InvokeResult |
| 44 | |
| 45 | runner = CliRunner() |
| 46 | |
| 47 | _SNAP_ID: str = compute_snapshot_id({}) |
| 48 | _COMMITTED_AT: datetime.datetime = datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc) |
| 49 | |
| 50 | |
| 51 | # --------------------------------------------------------------------------- |
| 52 | # Helpers |
| 53 | # --------------------------------------------------------------------------- |
| 54 | |
| 55 | |
| 56 | def _make_repo(tmp_path: pathlib.Path) -> pathlib.Path: |
| 57 | repo = tmp_path / "repo" |
| 58 | dot_muse = muse_dir(repo) |
| 59 | for sub in ("objects", "commits", "snapshots", "refs/heads"): |
| 60 | (dot_muse / sub).mkdir(parents=True) |
| 61 | (dot_muse / "HEAD").write_text("ref: refs/heads/main") |
| 62 | (dot_muse / "repo.json").write_text(json.dumps({"repo_id": "test-repo", "domain": "code"})) |
| 63 | return repo |
| 64 | |
| 65 | |
| 66 | def _snap(repo: pathlib.Path) -> str: |
| 67 | write_snapshot(repo, SnapshotRecord( |
| 68 | snapshot_id=_SNAP_ID, |
| 69 | manifest={}, |
| 70 | created_at=_COMMITTED_AT, |
| 71 | )) |
| 72 | return _SNAP_ID |
| 73 | |
| 74 | |
| 75 | def _commit(repo: pathlib.Path, message: str = "test") -> str: |
| 76 | snap_id = _snap(repo) |
| 77 | commit_id = compute_commit_id( |
| 78 | parent_ids=[], |
| 79 | snapshot_id=snap_id, |
| 80 | message=message, |
| 81 | committed_at_iso=_COMMITTED_AT.isoformat(), |
| 82 | ) |
| 83 | write_commit(repo, CommitRecord( |
| 84 | commit_id=commit_id, |
| 85 | branch="main", |
| 86 | snapshot_id=snap_id, |
| 87 | message=message, |
| 88 | committed_at=_COMMITTED_AT, |
| 89 | )) |
| 90 | return commit_id |
| 91 | |
| 92 | |
| 93 | def _write_ref(repo: pathlib.Path, branch: str, commit_id: str) -> None: |
| 94 | branch_ref = ref_path(repo, branch) |
| 95 | branch_ref.parent.mkdir(parents=True, exist_ok=True) |
| 96 | branch_ref.write_text(commit_id) |
| 97 | |
| 98 | |
| 99 | def _ur(repo: pathlib.Path, *args: str) -> InvokeResult: |
| 100 | from muse.cli.app import main as cli |
| 101 | return runner.invoke( |
| 102 | cli, |
| 103 | ["update-ref", *args], |
| 104 | env={"MUSE_REPO_ROOT": str(repo)}, |
| 105 | ) |
| 106 | |
| 107 | |
| 108 | # --------------------------------------------------------------------------- |
| 109 | # U — Unit: duration_ms and exit_code in every success JSON path |
| 110 | # --------------------------------------------------------------------------- |
| 111 | |
| 112 | |
| 113 | class TestElapsedMsExitCode: |
| 114 | """U1–U8: every successful JSON response carries duration_ms and exit_code.""" |
| 115 | |
| 116 | def test_u1_create_ref_has_duration_ms(self, tmp_path: pathlib.Path) -> None: |
| 117 | """U1: creating a new ref emits duration_ms.""" |
| 118 | repo = _make_repo(tmp_path) |
| 119 | cid = _commit(repo) |
| 120 | r = _ur(repo, "feat/alpha", cid, "--json") |
| 121 | assert r.exit_code == 0 |
| 122 | data = json.loads(r.output) |
| 123 | assert "duration_ms" in data, "duration_ms missing from create-ref JSON" |
| 124 | |
| 125 | def test_u2_create_ref_has_exit_code_zero(self, tmp_path: pathlib.Path) -> None: |
| 126 | """U2: creating a new ref has exit_code 0.""" |
| 127 | repo = _make_repo(tmp_path) |
| 128 | cid = _commit(repo) |
| 129 | r = _ur(repo, "feat/beta", cid, "--json") |
| 130 | assert r.exit_code == 0 |
| 131 | data = json.loads(r.output) |
| 132 | assert data["exit_code"] == 0 |
| 133 | |
| 134 | def test_u3_update_ref_has_duration_ms(self, tmp_path: pathlib.Path) -> None: |
| 135 | """U3: updating an existing ref emits duration_ms.""" |
| 136 | repo = _make_repo(tmp_path) |
| 137 | old = _commit(repo, "old") |
| 138 | new = _commit(repo, "new") |
| 139 | _write_ref(repo, "main", old) |
| 140 | r = _ur(repo, "main", new, "--json") |
| 141 | assert r.exit_code == 0 |
| 142 | data = json.loads(r.output) |
| 143 | assert "duration_ms" in data |
| 144 | |
| 145 | def test_u4_update_ref_has_exit_code_zero(self, tmp_path: pathlib.Path) -> None: |
| 146 | """U4: updating an existing ref has exit_code 0.""" |
| 147 | repo = _make_repo(tmp_path) |
| 148 | old = _commit(repo, "old") |
| 149 | new = _commit(repo, "new") |
| 150 | _write_ref(repo, "main", old) |
| 151 | r = _ur(repo, "main", new, "--json") |
| 152 | assert r.exit_code == 0 |
| 153 | assert json.loads(r.output)["exit_code"] == 0 |
| 154 | |
| 155 | def test_u5_delete_ref_has_duration_ms(self, tmp_path: pathlib.Path) -> None: |
| 156 | """U5: deleting a ref emits duration_ms.""" |
| 157 | repo = _make_repo(tmp_path) |
| 158 | cid = long_id("a" * 64) |
| 159 | _write_ref(repo, "to-del", cid) |
| 160 | r = _ur(repo, "--delete", "to-del", "--json") |
| 161 | assert r.exit_code == 0 |
| 162 | data = json.loads(r.output) |
| 163 | assert "duration_ms" in data, "duration_ms missing from delete-ref JSON" |
| 164 | |
| 165 | def test_u6_delete_ref_has_exit_code_zero(self, tmp_path: pathlib.Path) -> None: |
| 166 | """U6: deleting a ref has exit_code 0.""" |
| 167 | repo = _make_repo(tmp_path) |
| 168 | cid = long_id("b" * 64) |
| 169 | _write_ref(repo, "to-del2", cid) |
| 170 | r = _ur(repo, "--delete", "to-del2", "--json") |
| 171 | assert r.exit_code == 0 |
| 172 | assert json.loads(r.output)["exit_code"] == 0 |
| 173 | |
| 174 | def test_u7_duration_ms_is_float(self, tmp_path: pathlib.Path) -> None: |
| 175 | """U7: duration_ms is a float (not int, not string).""" |
| 176 | repo = _make_repo(tmp_path) |
| 177 | cid = _commit(repo) |
| 178 | r = _ur(repo, "timing-branch", cid, "--json") |
| 179 | assert r.exit_code == 0 |
| 180 | val = json.loads(r.output)["duration_ms"] |
| 181 | assert isinstance(val, float), f"expected float, got {type(val)}" |
| 182 | |
| 183 | def test_u8_duration_ms_non_negative(self, tmp_path: pathlib.Path) -> None: |
| 184 | """U8: duration_ms >= 0.""" |
| 185 | repo = _make_repo(tmp_path) |
| 186 | cid = _commit(repo) |
| 187 | r = _ur(repo, "timing-branch2", cid, "--json") |
| 188 | assert r.exit_code == 0 |
| 189 | assert json.loads(r.output)["duration_ms"] >= 0.0 |
| 190 | |
| 191 | def test_u9_no_verify_success_has_duration_ms(self, tmp_path: pathlib.Path) -> None: |
| 192 | """U9: --no-verify path also emits duration_ms.""" |
| 193 | repo = _make_repo(tmp_path) |
| 194 | cid = long_id("f" * 64) # not in store — valid format, skip verification |
| 195 | r = _ur(repo, "--no-verify", "staging", cid, "--json") |
| 196 | assert r.exit_code == 0 |
| 197 | data = json.loads(r.output) |
| 198 | assert "duration_ms" in data |
| 199 | |
| 200 | def test_u10_short_id_in_output_is_not_expected(self, tmp_path: pathlib.Path) -> None: |
| 201 | """U10: commit_id in JSON output is the full long_id (not short_id).""" |
| 202 | repo = _make_repo(tmp_path) |
| 203 | cid = _commit(repo) |
| 204 | r = _ur(repo, "full-id-branch", cid, "--json") |
| 205 | assert r.exit_code == 0 |
| 206 | data = json.loads(r.output) |
| 207 | # Full id must be present — short_id (12 hex chars) would be truncated |
| 208 | assert data["commit_id"] == cid |
| 209 | assert len(data["commit_id"]) == 71 # sha256: + 64 hex |
| 210 | |
| 211 | |
| 212 | # --------------------------------------------------------------------------- |
| 213 | # E — Error routing: JSON errors → stdout when --json, stderr in text mode |
| 214 | # --------------------------------------------------------------------------- |
| 215 | |
| 216 | |
| 217 | class TestJsonErrorsToStdout: |
| 218 | """E1–E7: all error paths emit JSON to stdout when --json is active.""" |
| 219 | |
| 220 | def test_e1_invalid_branch_json_error_on_stdout(self, tmp_path: pathlib.Path) -> None: |
| 221 | """E1: invalid branch name → JSON error on stdout, stderr empty.""" |
| 222 | repo = _make_repo(tmp_path) |
| 223 | r = _ur(repo, "branch\x00null", long_id("a" * 64), "--json") |
| 224 | assert r.exit_code != 0 |
| 225 | assert r.stderr.strip() == "", f"stderr should be empty: {r.stderr!r}" |
| 226 | data = json.loads(r.output) |
| 227 | assert "error" in data |
| 228 | |
| 229 | def test_e2_commit_not_found_json_error_on_stdout(self, tmp_path: pathlib.Path) -> None: |
| 230 | """E2: commit not in store → JSON error on stdout, stderr empty.""" |
| 231 | repo = _make_repo(tmp_path) |
| 232 | cid = long_id("9" * 64) # not in store, valid format |
| 233 | r = _ur(repo, "main", cid, "--json") |
| 234 | assert r.exit_code != 0 |
| 235 | assert r.stderr.strip() == "", f"stderr should be empty: {r.stderr!r}" |
| 236 | data = json.loads(r.output) |
| 237 | assert "error" in data |
| 238 | |
| 239 | def test_e3_invalid_commit_id_json_error_on_stdout(self, tmp_path: pathlib.Path) -> None: |
| 240 | """E3: malformed commit ID → JSON error on stdout, stderr empty.""" |
| 241 | repo = _make_repo(tmp_path) |
| 242 | r = _ur(repo, "main", "not-a-valid-id", "--json") |
| 243 | assert r.exit_code != 0 |
| 244 | assert r.stderr.strip() == "", f"stderr should be empty: {r.stderr!r}" |
| 245 | data = json.loads(r.output) |
| 246 | assert "error" in data |
| 247 | |
| 248 | def test_e4_delete_nonexistent_json_error_on_stdout(self, tmp_path: pathlib.Path) -> None: |
| 249 | """E4: --delete on nonexistent ref → JSON error on stdout.""" |
| 250 | repo = _make_repo(tmp_path) |
| 251 | r = _ur(repo, "--delete", "ghost-branch", "--json") |
| 252 | assert r.exit_code != 0 |
| 253 | assert r.stderr.strip() == "", f"stderr should be empty: {r.stderr!r}" |
| 254 | data = json.loads(r.output) |
| 255 | assert "error" in data |
| 256 | |
| 257 | def test_e5_cas_mismatch_json_error_on_stdout(self, tmp_path: pathlib.Path) -> None: |
| 258 | """E5: CAS mismatch → JSON error on stdout with current/expected fields.""" |
| 259 | repo = _make_repo(tmp_path) |
| 260 | actual = _commit(repo, "actual") |
| 261 | new_id = _commit(repo, "new") |
| 262 | other = blob_id(b"other-id") # valid ID, not the actual value |
| 263 | _write_ref(repo, "main", actual) |
| 264 | r = _ur(repo, "--old-value", other, "main", new_id, "--json") |
| 265 | assert r.exit_code != 0 |
| 266 | assert r.stderr.strip() == "", f"stderr should be empty: {r.stderr!r}" |
| 267 | data = json.loads(r.output) |
| 268 | assert "error" in data |
| 269 | |
| 270 | def test_e6_no_commit_id_json_error_on_stdout(self, tmp_path: pathlib.Path) -> None: |
| 271 | """E6: missing commit_id (no --delete) → JSON error on stdout.""" |
| 272 | repo = _make_repo(tmp_path) |
| 273 | r = _ur(repo, "main", "--json") |
| 274 | assert r.exit_code != 0 |
| 275 | assert r.stderr.strip() == "", f"stderr should be empty: {r.stderr!r}" |
| 276 | data = json.loads(r.output) |
| 277 | assert "error" in data |
| 278 | |
| 279 | def test_e7_text_mode_errors_on_stderr(self, tmp_path: pathlib.Path) -> None: |
| 280 | """E7: text mode errors go to stderr (stdout_bytes empty).""" |
| 281 | repo = _make_repo(tmp_path) |
| 282 | r = _ur(repo, "branch\x00bad", long_id("a" * 64), "--format", "text") |
| 283 | assert r.exit_code != 0 |
| 284 | assert r.stdout_bytes == b"", f"stdout_bytes should be empty in text mode: {r.stdout_bytes!r}" |
| 285 | assert r.stderr.strip() != "", "stderr should have error text in text mode" |
| 286 | |
| 287 | def test_e8_write_failure_json_error_on_stdout(self, tmp_path: pathlib.Path) -> None: |
| 288 | """E8: OSError from write_branch_ref → exit 3, JSON error on stdout.""" |
| 289 | repo = _make_repo(tmp_path) |
| 290 | cid = _commit(repo) |
| 291 | with mock.patch( |
| 292 | "muse.cli.commands.update_ref.write_branch_ref", |
| 293 | side_effect=OSError("disk full"), |
| 294 | ): |
| 295 | r = _ur(repo, "main", cid, "--json") |
| 296 | assert r.exit_code == ExitCode.INTERNAL_ERROR |
| 297 | assert r.stderr.strip() == "", f"stderr should be empty: {r.stderr!r}" |
| 298 | data = json.loads(r.output) |
| 299 | assert "error" in data |
| 300 | assert "disk full" in data.get("message", "") |
| 301 | |
| 302 | |
| 303 | # --------------------------------------------------------------------------- |
| 304 | # S — Schema completeness for error payloads |
| 305 | # --------------------------------------------------------------------------- |
| 306 | |
| 307 | |
| 308 | class TestErrorJsonSchema: |
| 309 | """S1–S5: every JSON error payload has {error, message, duration_ms, exit_code}.""" |
| 310 | |
| 311 | def _parse_error(self, r: InvokeResult) -> Mapping[str, object]: |
| 312 | return json.loads(r.output) |
| 313 | |
| 314 | def _required_keys(self) -> set[str]: |
| 315 | return {"error", "message", "duration_ms", "exit_code"} |
| 316 | |
| 317 | def test_s1_invalid_branch_error_schema(self, tmp_path: pathlib.Path) -> None: |
| 318 | """S1: invalid branch name error has all required keys.""" |
| 319 | repo = _make_repo(tmp_path) |
| 320 | r = _ur(repo, "bad\x00branch", long_id("a" * 64), "--json") |
| 321 | data = self._parse_error(r) |
| 322 | missing = self._required_keys() - data.keys() |
| 323 | assert not missing, f"missing keys: {missing}" |
| 324 | |
| 325 | def test_s2_commit_not_found_error_schema(self, tmp_path: pathlib.Path) -> None: |
| 326 | """S2: commit-not-found error has all required keys.""" |
| 327 | repo = _make_repo(tmp_path) |
| 328 | r = _ur(repo, "main", long_id("e" * 64), "--json") |
| 329 | data = self._parse_error(r) |
| 330 | missing = self._required_keys() - data.keys() |
| 331 | assert not missing, f"missing keys: {missing}" |
| 332 | |
| 333 | def test_s3_cas_mismatch_error_schema(self, tmp_path: pathlib.Path) -> None: |
| 334 | """S3: CAS mismatch error has all required keys.""" |
| 335 | repo = _make_repo(tmp_path) |
| 336 | actual = _commit(repo, "actual") |
| 337 | new_id = _commit(repo, "new") |
| 338 | wrong = blob_id(b"wrong-old-value") |
| 339 | _write_ref(repo, "main", actual) |
| 340 | r = _ur(repo, "--old-value", wrong, "main", new_id, "--json") |
| 341 | data = self._parse_error(r) |
| 342 | missing = self._required_keys() - data.keys() |
| 343 | assert not missing, f"missing keys: {missing}" |
| 344 | |
| 345 | def test_s4_write_failure_error_schema(self, tmp_path: pathlib.Path) -> None: |
| 346 | """S4: write-failure error has all required keys.""" |
| 347 | repo = _make_repo(tmp_path) |
| 348 | cid = _commit(repo) |
| 349 | with mock.patch( |
| 350 | "muse.cli.commands.update_ref.write_branch_ref", |
| 351 | side_effect=OSError("ENOSPC"), |
| 352 | ): |
| 353 | r = _ur(repo, "main", cid, "--json") |
| 354 | data = self._parse_error(r) |
| 355 | missing = self._required_keys() - data.keys() |
| 356 | assert not missing, f"missing keys: {missing}" |
| 357 | |
| 358 | def test_s5_error_duration_ms_is_float_non_negative(self, tmp_path: pathlib.Path) -> None: |
| 359 | """S5: duration_ms in error JSON is a float >= 0.""" |
| 360 | repo = _make_repo(tmp_path) |
| 361 | r = _ur(repo, "bad\x00name", long_id("a" * 64), "--json") |
| 362 | data = self._parse_error(r) |
| 363 | assert isinstance(data["duration_ms"], float) |
| 364 | assert data["duration_ms"] >= 0.0 |
| 365 | |
| 366 | def test_s6_error_exit_code_matches_process_exit(self, tmp_path: pathlib.Path) -> None: |
| 367 | """S6: exit_code in JSON matches actual process exit code.""" |
| 368 | repo = _make_repo(tmp_path) |
| 369 | r = _ur(repo, "bad\x00name", long_id("a" * 64), "--json") |
| 370 | data = self._parse_error(r) |
| 371 | assert data["exit_code"] == r.exit_code |
| 372 | |
| 373 | def test_s7_success_json_all_fields_present(self, tmp_path: pathlib.Path) -> None: |
| 374 | """S7: create-ref success JSON has branch, commit_id, previous, duration_ms, exit_code.""" |
| 375 | repo = _make_repo(tmp_path) |
| 376 | cid = _commit(repo) |
| 377 | r = _ur(repo, "schema-check", cid, "--json") |
| 378 | assert r.exit_code == 0 |
| 379 | data = json.loads(r.output) |
| 380 | for key in ("branch", "commit_id", "previous", "duration_ms", "exit_code"): |
| 381 | assert key in data, f"missing key {key!r} in success JSON" |
| 382 | |
| 383 | def test_s8_delete_json_all_fields_present(self, tmp_path: pathlib.Path) -> None: |
| 384 | """S8: delete-ref success JSON has branch, deleted, duration_ms, exit_code.""" |
| 385 | repo = _make_repo(tmp_path) |
| 386 | cid = long_id("d" * 64) |
| 387 | _write_ref(repo, "del-schema", cid) |
| 388 | r = _ur(repo, "--delete", "del-schema", "--json") |
| 389 | assert r.exit_code == 0 |
| 390 | data = json.loads(r.output) |
| 391 | for key in ("branch", "deleted", "duration_ms", "exit_code"): |
| 392 | assert key in data, f"missing key {key!r} in delete JSON" |
| 393 | |
| 394 | |
| 395 | # --------------------------------------------------------------------------- |
| 396 | # D — Data integrity |
| 397 | # --------------------------------------------------------------------------- |
| 398 | |
| 399 | |
| 400 | class TestDataIntegrity: |
| 401 | """D1–D8: output values are semantically correct.""" |
| 402 | |
| 403 | def test_d1_previous_is_none_for_new_ref(self, tmp_path: pathlib.Path) -> None: |
| 404 | """D1: previous is null when no ref existed before.""" |
| 405 | repo = _make_repo(tmp_path) |
| 406 | cid = _commit(repo) |
| 407 | data = json.loads(_ur(repo, "fresh-branch", cid, "--json").output) |
| 408 | assert data["previous"] is None |
| 409 | |
| 410 | def test_d2_previous_matches_old_commit(self, tmp_path: pathlib.Path) -> None: |
| 411 | """D2: previous matches the commit_id that was there before.""" |
| 412 | repo = _make_repo(tmp_path) |
| 413 | old = _commit(repo, "old commit") |
| 414 | new = _commit(repo, "new commit") |
| 415 | _write_ref(repo, "main", old) |
| 416 | data = json.loads(_ur(repo, "main", new, "--json").output) |
| 417 | assert data["previous"] == old |
| 418 | assert data["commit_id"] == new |
| 419 | |
| 420 | def test_d3_branch_field_matches_arg(self, tmp_path: pathlib.Path) -> None: |
| 421 | """D3: branch field in JSON matches the branch argument.""" |
| 422 | repo = _make_repo(tmp_path) |
| 423 | cid = _commit(repo) |
| 424 | data = json.loads(_ur(repo, "my-feature", cid, "--json").output) |
| 425 | assert data["branch"] == "my-feature" |
| 426 | |
| 427 | def test_d4_deleted_true_on_delete(self, tmp_path: pathlib.Path) -> None: |
| 428 | """D4: deleted field is boolean true on successful delete.""" |
| 429 | repo = _make_repo(tmp_path) |
| 430 | _write_ref(repo, "ephemeral", long_id("e" * 64)) |
| 431 | data = json.loads(_ur(repo, "--delete", "ephemeral", "--json").output) |
| 432 | assert data["deleted"] is True |
| 433 | |
| 434 | def test_d5_exit_code_1_for_user_errors(self, tmp_path: pathlib.Path) -> None: |
| 435 | """D5: user-visible errors (bad branch, not-found commit) use exit_code 1.""" |
| 436 | repo = _make_repo(tmp_path) |
| 437 | r = _ur(repo, "main", long_id("9" * 64), "--json") # not in store |
| 438 | data = json.loads(r.output) |
| 439 | assert data["exit_code"] == ExitCode.USER_ERROR |
| 440 | |
| 441 | def test_d6_exit_code_3_for_write_failure(self, tmp_path: pathlib.Path) -> None: |
| 442 | """D6: write failure uses exit_code 3 (internal error).""" |
| 443 | repo = _make_repo(tmp_path) |
| 444 | cid = _commit(repo) |
| 445 | with mock.patch( |
| 446 | "muse.cli.commands.update_ref.write_branch_ref", |
| 447 | side_effect=OSError("ENOSPC"), |
| 448 | ): |
| 449 | r = _ur(repo, "main", cid, "--json") |
| 450 | data = json.loads(r.output) |
| 451 | assert data["exit_code"] == ExitCode.INTERNAL_ERROR |
| 452 | |
| 453 | def test_d7_cas_mismatch_includes_current_and_expected(self, tmp_path: pathlib.Path) -> None: |
| 454 | """D7: CAS error JSON includes current ref value and what was expected.""" |
| 455 | repo = _make_repo(tmp_path) |
| 456 | actual = _commit(repo, "actual-commit") |
| 457 | new_id = _commit(repo, "new-commit") |
| 458 | wrong_old = blob_id(b"wrong-expected-value") |
| 459 | _write_ref(repo, "main", actual) |
| 460 | r = _ur(repo, "--old-value", wrong_old, "main", new_id, "--json") |
| 461 | assert r.exit_code == ExitCode.USER_ERROR |
| 462 | data = json.loads(r.output) |
| 463 | # current and expected give agents enough context to retry correctly |
| 464 | assert "current" in data, "CAS error must include current ref value" |
| 465 | assert "expected" in data or wrong_old in str(data), "CAS error must include expected value" |
| 466 | |
| 467 | def test_d8_blob_id_produces_unique_valid_ids(self, tmp_path: pathlib.Path) -> None: |
| 468 | """D8: blob_id() always produces distinct valid sha256-prefixed IDs.""" |
| 469 | ids = {blob_id(__import__("os").urandom(32)) for _ in range(20)} |
| 470 | assert len(ids) == 20, "blob_id must produce unique IDs" |
| 471 | for bid in ids: |
| 472 | assert bid.startswith("sha256:") |
| 473 | assert len(bid) == 71 |
| 474 | |
| 475 | def test_d9_long_id_produces_correct_prefix(self, tmp_path: pathlib.Path) -> None: |
| 476 | """D9: long_id() round-trips correctly through update-ref.""" |
| 477 | repo = _make_repo(tmp_path) |
| 478 | bare = "c" * 64 |
| 479 | cid = long_id(bare) |
| 480 | assert cid == f"sha256:{bare}" |
| 481 | # Use it as a ref value (bypassing store check) |
| 482 | r = _ur(repo, "--no-verify", "long-id-test", cid, "--json") |
| 483 | assert r.exit_code == 0 |
| 484 | data = json.loads(r.output) |
| 485 | assert data["commit_id"] == cid |
| 486 | |
| 487 | def test_d10_short_id_is_prefix_of_long_id(self) -> None: |
| 488 | """D10: short_id is the first 19 chars of long_id (sha256: + 12 hex).""" |
| 489 | cid = long_id("abcdef1234567890" * 4) |
| 490 | sid = short_id(cid) |
| 491 | assert cid.startswith(sid) |
| 492 | assert sid.startswith("sha256:") |
| 493 | assert len(sid) == 19 # "sha256:" (7) + 12 hex chars |
| 494 | |
| 495 | |
| 496 | # --------------------------------------------------------------------------- |
| 497 | # CAS — compare-and-swap error payloads and timing |
| 498 | # --------------------------------------------------------------------------- |
| 499 | |
| 500 | |
| 501 | class TestCASSchema: |
| 502 | """CAS-specific: error routing and schema when CAS fires.""" |
| 503 | |
| 504 | def test_cas1_null_guard_mismatch_has_duration_ms(self, tmp_path: pathlib.Path) -> None: |
| 505 | """CAS1: --old-value null mismatch (ref exists) error has duration_ms.""" |
| 506 | repo = _make_repo(tmp_path) |
| 507 | existing = _commit(repo, "existing") |
| 508 | new_id = _commit(repo, "new") |
| 509 | _write_ref(repo, "contested", existing) |
| 510 | r = _ur(repo, "--old-value", "null", "contested", new_id, "--json") |
| 511 | assert r.exit_code != 0 |
| 512 | data = json.loads(r.output) |
| 513 | assert "duration_ms" in data |
| 514 | |
| 515 | def test_cas2_mismatch_error_to_stdout_not_stderr(self, tmp_path: pathlib.Path) -> None: |
| 516 | """CAS2: CAS mismatch with --json → error on stdout, stderr empty.""" |
| 517 | repo = _make_repo(tmp_path) |
| 518 | actual = _commit(repo, "actual") |
| 519 | new_id = _commit(repo, "new") |
| 520 | wrong = blob_id(b"wrong-cas-value") |
| 521 | _write_ref(repo, "main", actual) |
| 522 | r = _ur(repo, "--old-value", wrong, "main", new_id, "--json") |
| 523 | assert r.exit_code != 0 |
| 524 | assert r.stderr.strip() == "" |
| 525 | data = json.loads(r.output) |
| 526 | assert "error" in data |
| 527 | assert "duration_ms" in data |
| 528 | assert "exit_code" in data |
| 529 | |
| 530 | def test_cas3_success_has_duration_ms(self, tmp_path: pathlib.Path) -> None: |
| 531 | """CAS3: successful CAS also emits duration_ms.""" |
| 532 | repo = _make_repo(tmp_path) |
| 533 | old = _commit(repo, "old") |
| 534 | new = _commit(repo, "new") |
| 535 | _write_ref(repo, "main", old) |
| 536 | r = _ur(repo, "--old-value", old, "main", new, "--json") |
| 537 | assert r.exit_code == 0 |
| 538 | data = json.loads(r.output) |
| 539 | assert "duration_ms" in data |
| 540 | |
| 541 | def test_cas4_invalid_old_value_format_error_on_stdout(self, tmp_path: pathlib.Path) -> None: |
| 542 | """CAS4: bare hex (missing sha256: prefix) in --old-value → JSON error on stdout.""" |
| 543 | repo = _make_repo(tmp_path) |
| 544 | cid = _commit(repo) |
| 545 | bare_hex = "a" * 64 # missing sha256: prefix |
| 546 | r = _ur(repo, "--old-value", bare_hex, "main", cid, "--json") |
| 547 | assert r.exit_code != 0 |
| 548 | assert r.stderr.strip() == "" |
| 549 | data = json.loads(r.output) |
| 550 | assert "error" in data |
| 551 | |
| 552 | |
| 553 | # --------------------------------------------------------------------------- |
| 554 | # P — Performance |
| 555 | # --------------------------------------------------------------------------- |
| 556 | |
| 557 | |
| 558 | class TestPerformance: |
| 559 | """P1–P3: duration_ms is a realistic duration.""" |
| 560 | |
| 561 | def test_p1_single_update_under_2000ms(self, tmp_path: pathlib.Path) -> None: |
| 562 | """P1: a single ref update finishes in < 2 seconds.""" |
| 563 | repo = _make_repo(tmp_path) |
| 564 | cid = _commit(repo) |
| 565 | r = _ur(repo, "perf-branch", cid, "--json") |
| 566 | assert r.exit_code == 0 |
| 567 | assert json.loads(r.output)["duration_ms"] < 2000.0 |
| 568 | |
| 569 | def test_p2_delete_under_2000ms(self, tmp_path: pathlib.Path) -> None: |
| 570 | """P2: a ref delete finishes in < 2 seconds.""" |
| 571 | repo = _make_repo(tmp_path) |
| 572 | _write_ref(repo, "perf-del", long_id("f" * 64)) |
| 573 | r = _ur(repo, "--delete", "perf-del", "--json") |
| 574 | assert r.exit_code == 0 |
| 575 | assert json.loads(r.output)["duration_ms"] < 2000.0 |
| 576 | |
| 577 | def test_p3_200_sequential_updates_all_have_duration_ms(self, tmp_path: pathlib.Path) -> None: |
| 578 | """P3: 200 sequential updates all emit duration_ms.""" |
| 579 | repo = _make_repo(tmp_path) |
| 580 | cid = _commit(repo) |
| 581 | for i in range(200): |
| 582 | r = _ur(repo, "perf-stress", cid, "--json") |
| 583 | assert r.exit_code == 0, f"failed at iteration {i}" |
| 584 | assert "duration_ms" in json.loads(r.output), f"missing duration_ms at iteration {i}" |
| 585 | |
| 586 | |
| 587 | # --------------------------------------------------------------------------- |
| 588 | # Sec — Security: no traceback on any error path |
| 589 | # --------------------------------------------------------------------------- |
| 590 | |
| 591 | |
| 592 | class TestSecurity: |
| 593 | """Sec1–Sec5: error paths never produce raw Python tracebacks.""" |
| 594 | |
| 595 | def test_sec1_no_traceback_invalid_branch_json_mode(self, tmp_path: pathlib.Path) -> None: |
| 596 | """Sec1: invalid branch name with --json → no Traceback.""" |
| 597 | repo = _make_repo(tmp_path) |
| 598 | r = _ur(repo, "bad\x00branch", long_id("a" * 64), "--json") |
| 599 | assert r.exit_code != 0 |
| 600 | assert "Traceback" not in r.output |
| 601 | assert "Traceback" not in r.stderr |
| 602 | |
| 603 | def test_sec2_no_traceback_write_failure_json_mode(self, tmp_path: pathlib.Path) -> None: |
| 604 | """Sec2: mocked write failure with --json → no Traceback.""" |
| 605 | repo = _make_repo(tmp_path) |
| 606 | cid = _commit(repo) |
| 607 | with mock.patch( |
| 608 | "muse.cli.commands.update_ref.write_branch_ref", |
| 609 | side_effect=OSError("permission denied"), |
| 610 | ): |
| 611 | r = _ur(repo, "main", cid, "--json") |
| 612 | assert r.exit_code != 0 |
| 613 | assert "Traceback" not in r.output |
| 614 | assert "Traceback" not in r.stderr |
| 615 | |
| 616 | def test_sec3_no_traceback_write_failure_text_mode(self, tmp_path: pathlib.Path) -> None: |
| 617 | """Sec3: mocked write failure in text mode → no Traceback.""" |
| 618 | repo = _make_repo(tmp_path) |
| 619 | cid = _commit(repo) |
| 620 | with mock.patch( |
| 621 | "muse.cli.commands.update_ref.write_branch_ref", |
| 622 | side_effect=OSError("permission denied"), |
| 623 | ): |
| 624 | r = _ur(repo, "main", cid, "--format", "text") |
| 625 | assert r.exit_code != 0 |
| 626 | assert "Traceback" not in r.output |
| 627 | assert "Traceback" not in r.stderr |
| 628 | |
| 629 | def test_sec4_path_traversal_in_branch_rejected(self, tmp_path: pathlib.Path) -> None: |
| 630 | """Sec4: branch names with ../ path traversal are rejected.""" |
| 631 | repo = _make_repo(tmp_path) |
| 632 | cid = _commit(repo) |
| 633 | r = _ur(repo, "../../../etc/cron.d/malicious", cid, "--json") |
| 634 | assert r.exit_code != 0 |
| 635 | assert r.stderr.strip() == "" # error in stdout (json mode) |
| 636 | data = json.loads(r.output) |
| 637 | assert "error" in data |
| 638 | |
| 639 | def test_sec5_ansi_in_branch_rejected_json_mode(self, tmp_path: pathlib.Path) -> None: |
| 640 | """Sec5: ANSI escape codes in branch name are rejected; error to stdout.""" |
| 641 | repo = _make_repo(tmp_path) |
| 642 | r = _ur(repo, "\x1b[31mbranch", long_id("a" * 64), "--json") |
| 643 | assert r.exit_code != 0 |
| 644 | assert r.stderr.strip() == "" |
| 645 | data = json.loads(r.output) |
| 646 | assert "error" in data |
| 647 | |
| 648 | |
| 649 | # --------------------------------------------------------------------------- |
| 650 | # C — Concurrency: parallel updates to independent branches |
| 651 | # --------------------------------------------------------------------------- |
| 652 | |
| 653 | |
| 654 | class TestConcurrency: |
| 655 | """C1: N threads each update a distinct branch — no cross-contamination.""" |
| 656 | |
| 657 | def test_c1_parallel_independent_branch_updates(self, tmp_path: pathlib.Path) -> None: |
| 658 | """C1: 16 threads each write to their own branch — all succeed.""" |
| 659 | repo = _make_repo(tmp_path) |
| 660 | cid = _commit(repo, "shared commit") |
| 661 | N = 16 |
| 662 | results: list[InvokeResult | None] = [None] * N |
| 663 | errors: list[str] = [] |
| 664 | |
| 665 | def _worker(idx: int) -> None: |
| 666 | branch = f"concurrent-branch-{idx}" |
| 667 | r = _ur(repo, "--no-verify", branch, cid, "--json") |
| 668 | results[idx] = r |
| 669 | if r.exit_code != 0: |
| 670 | errors.append(f"thread {idx} failed: exit_code={r.exit_code}") |
| 671 | |
| 672 | threads = [threading.Thread(target=_worker, args=(i,)) for i in range(N)] |
| 673 | for t in threads: |
| 674 | t.start() |
| 675 | for t in threads: |
| 676 | t.join() |
| 677 | |
| 678 | assert not errors, "\n".join(errors) |
| 679 | for i, r in enumerate(results): |
| 680 | assert r is not None |
| 681 | assert r.exit_code == 0, f"thread {i} non-zero exit" |
| 682 | data = json.loads(r.output) |
| 683 | assert data["branch"] == f"concurrent-branch-{i}" |
| 684 | assert "duration_ms" in data |
File History
4 commits
sha256:81ae324db5ad375fbfe4834c6fcb378312cafad3cc92dec5d3e5c427306621a2
fix: remove commit_exists filter from have anchors — server…
Sonnet 4.6
patch
20 days ago
sha256:36c3cb3e76619d4c30a6d9bf81b5ec4ff148e30dcfed913e3114ca7b43b81c7e
fix: rename objects→blobs in push client and all stale test…
Sonnet 4.6
patch
22 days ago
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
⚠
28 days ago