surshar / claude-code
Claude Code is an agentic coding tool that lives in your terminal, understands your codebase, and helps you code faster by executing routine tasks, explaining complex code, and handling git workflows - all through natural language commands.
Built by Anthropic · github.com/anthropics
| 1 | """Tests for error handling and fault tolerance in hookify. |
| 2 | |
| 3 | Tests cover: |
| 4 | - Graceful handling of missing files |
| 5 | - Invalid JSON/YAML handling |
| 6 | - Regex compilation errors |
| 7 | - Transcript file access errors |
| 8 | - Import failures |
| 9 | - Edge cases and boundary conditions |
| 10 | """ |
| 11 | |
| 12 | import pytest |
| 13 | import os |
| 14 | from pathlib import Path |
| 15 | from typing import Dict, Any |
| 16 | from unittest.mock import patch, mock_open |
| 17 | |
| 18 | from hookify.core.config_loader import load_rules, load_rule_file, extract_frontmatter |
| 19 | from hookify.core.rule_engine import RuleEngine, compile_regex |
| 20 | |
| 21 | |
| 22 | class TestTranscriptFileErrors: |
| 23 | """Tests for handling transcript file access errors.""" |
| 24 | |
| 25 | def test_missing_transcript_file(self, rule_engine: RuleEngine, temp_project_dir): |
| 26 | """Test handling when transcript file doesn't exist.""" |
| 27 | stop_input = { |
| 28 | "hook_event_name": "Stop", |
| 29 | "reason": "Done", |
| 30 | "transcript_path": "/nonexistent/transcript.txt", |
| 31 | } |
| 32 | |
| 33 | rules = [ |
| 34 | _make_rule( |
| 35 | name="check-transcript", |
| 36 | event="stop", |
| 37 | conditions=[{"field": "transcript", "operator": "contains", "pattern": "test"}], |
| 38 | action="warn", |
| 39 | message="Test message" |
| 40 | ), |
| 41 | ] |
| 42 | |
| 43 | # Should not crash, transcript returns empty string |
| 44 | result = rule_engine.evaluate_rules(rules, stop_input) |
| 45 | # Rule shouldn't match since transcript is empty |
| 46 | assert result == {} |
| 47 | |
| 48 | def test_unreadable_transcript_file(self, rule_engine: RuleEngine, temp_project_dir): |
| 49 | """Test handling when transcript file is unreadable.""" |
| 50 | # Create file and remove read permissions |
| 51 | transcript_file = temp_project_dir / "unreadable.txt" |
| 52 | transcript_file.write_text("content") |
| 53 | os.chmod(transcript_file, 0o000) |
| 54 | |
| 55 | stop_input = { |
| 56 | "hook_event_name": "Stop", |
| 57 | "reason": "Done", |
| 58 | "transcript_path": str(transcript_file), |
| 59 | } |
| 60 | |
| 61 | rules = [ |
| 62 | _make_rule( |
| 63 | name="check-transcript", |
| 64 | event="stop", |
| 65 | conditions=[{"field": "transcript", "operator": "contains", "pattern": "test"}], |
| 66 | action="warn", |
| 67 | message="Test" |
| 68 | ), |
| 69 | ] |
| 70 | |
| 71 | try: |
| 72 | # Should not crash |
| 73 | result = rule_engine.evaluate_rules(rules, stop_input) |
| 74 | assert result == {} # No match since transcript couldn't be read |
| 75 | finally: |
| 76 | # Restore permissions for cleanup |
| 77 | os.chmod(transcript_file, 0o644) |
| 78 | |
| 79 | |
| 80 | class TestRegexErrors: |
| 81 | """Tests for regex compilation and matching errors.""" |
| 82 | |
| 83 | def test_invalid_regex_pattern(self, rule_engine: RuleEngine): |
| 84 | """Test handling of invalid regex patterns.""" |
| 85 | input_data = { |
| 86 | "hook_event_name": "PreToolUse", |
| 87 | "tool_name": "Bash", |
| 88 | "tool_input": {"command": "ls -la"} |
| 89 | } |
| 90 | |
| 91 | rules = [ |
| 92 | _make_rule( |
| 93 | name="invalid-regex", |
| 94 | event="bash", |
| 95 | conditions=[{"field": "command", "operator": "regex_match", "pattern": "[unclosed"}], |
| 96 | action="block", |
| 97 | message="Should not match" |
| 98 | ), |
| 99 | ] |
| 100 | |
| 101 | # Should not crash, invalid regex returns False (no match) |
| 102 | result = rule_engine.evaluate_rules(rules, input_data) |
| 103 | assert result == {} |
| 104 | |
| 105 | def test_catastrophic_backtracking_regex(self, rule_engine: RuleEngine): |
| 106 | """Test handling of potentially slow regex patterns.""" |
| 107 | input_data = { |
| 108 | "hook_event_name": "PreToolUse", |
| 109 | "tool_name": "Bash", |
| 110 | "tool_input": {"command": "a" * 100} |
| 111 | } |
| 112 | |
| 113 | # This pattern could cause catastrophic backtracking in some engines |
| 114 | # Python's re module handles this reasonably well |
| 115 | rules = [ |
| 116 | _make_rule( |
| 117 | name="complex-regex", |
| 118 | event="bash", |
| 119 | conditions=[{"field": "command", "operator": "regex_match", "pattern": "(a+)+$"}], |
| 120 | action="warn", |
| 121 | message="Matched" |
| 122 | ), |
| 123 | ] |
| 124 | |
| 125 | # Should complete without hanging |
| 126 | result = rule_engine.evaluate_rules(rules, input_data) |
| 127 | assert "Matched" in result.get("systemMessage", "") |
| 128 | |
| 129 | def test_regex_cache(self): |
| 130 | """Test that regex patterns are cached.""" |
| 131 | pattern = r"test\s+pattern" |
| 132 | |
| 133 | # Compile same pattern twice |
| 134 | regex1 = compile_regex(pattern) |
| 135 | regex2 = compile_regex(pattern) |
| 136 | |
| 137 | # Should be the same object due to caching |
| 138 | assert regex1 is regex2 |
| 139 | |
| 140 | |
| 141 | class TestMalformedInput: |
| 142 | """Tests for handling malformed input data.""" |
| 143 | |
| 144 | def test_missing_tool_name(self, rule_engine: RuleEngine): |
| 145 | """Test handling input without tool_name.""" |
| 146 | input_data = { |
| 147 | "hook_event_name": "PreToolUse", |
| 148 | # Missing tool_name |
| 149 | "tool_input": {"command": "test"} |
| 150 | } |
| 151 | |
| 152 | rules = [ |
| 153 | _make_rule( |
| 154 | name="test-rule", |
| 155 | event="bash", |
| 156 | conditions=[{"field": "command", "operator": "contains", "pattern": "test"}], |
| 157 | action="warn", |
| 158 | message="Test" |
| 159 | ), |
| 160 | ] |
| 161 | |
| 162 | # Should not crash |
| 163 | result = rule_engine.evaluate_rules(rules, input_data) |
| 164 | # May or may not match depending on implementation |
| 165 | |
| 166 | def test_missing_tool_input(self, rule_engine: RuleEngine): |
| 167 | """Test handling input without tool_input.""" |
| 168 | input_data = { |
| 169 | "hook_event_name": "PreToolUse", |
| 170 | "tool_name": "Bash", |
| 171 | # Missing tool_input |
| 172 | } |
| 173 | |
| 174 | rules = [ |
| 175 | _make_rule( |
| 176 | name="test-rule", |
| 177 | event="bash", |
| 178 | conditions=[{"field": "command", "operator": "contains", "pattern": "test"}], |
| 179 | action="warn", |
| 180 | message="Test" |
| 181 | ), |
| 182 | ] |
| 183 | |
| 184 | # Should not crash |
| 185 | result = rule_engine.evaluate_rules(rules, input_data) |
| 186 | assert result == {} # No match with missing input |
| 187 | |
| 188 | def test_null_values_in_input(self, rule_engine: RuleEngine): |
| 189 | """Test handling None values in tool_input.""" |
| 190 | input_data = { |
| 191 | "hook_event_name": "PreToolUse", |
| 192 | "tool_name": "Bash", |
| 193 | "tool_input": { |
| 194 | "command": None |
| 195 | } |
| 196 | } |
| 197 | |
| 198 | rules = [ |
| 199 | _make_rule( |
| 200 | name="test-rule", |
| 201 | event="bash", |
| 202 | conditions=[{"field": "command", "operator": "contains", "pattern": "test"}], |
| 203 | action="warn", |
| 204 | message="Test" |
| 205 | ), |
| 206 | ] |
| 207 | |
| 208 | # Should not crash |
| 209 | result = rule_engine.evaluate_rules(rules, input_data) |
| 210 | |
| 211 | def test_non_string_field_values(self, rule_engine: RuleEngine): |
| 212 | """Test handling non-string values that get converted.""" |
| 213 | input_data = { |
| 214 | "hook_event_name": "PreToolUse", |
| 215 | "tool_name": "Bash", |
| 216 | "tool_input": { |
| 217 | "command": 123 # Number instead of string |
| 218 | } |
| 219 | } |
| 220 | |
| 221 | rules = [ |
| 222 | _make_rule( |
| 223 | name="test-rule", |
| 224 | event="bash", |
| 225 | conditions=[{"field": "command", "operator": "contains", "pattern": "123"}], |
| 226 | action="warn", |
| 227 | message="Found number" |
| 228 | ), |
| 229 | ] |
| 230 | |
| 231 | result = rule_engine.evaluate_rules(rules, input_data) |
| 232 | # Should convert to string and match |
| 233 | assert "Found number" in result.get("systemMessage", "") |
| 234 | |
| 235 | |
| 236 | class TestRuleFileErrors: |
| 237 | """Tests for rule file loading errors.""" |
| 238 | |
| 239 | def test_malformed_yaml(self, create_rule_file): |
| 240 | """Test handling of malformed YAML in frontmatter.""" |
| 241 | content = """--- |
| 242 | name: test |
| 243 | enabled: [unclosed bracket |
| 244 | --- |
| 245 | message |
| 246 | """ |
| 247 | rule_file = create_rule_file("malformed", content) |
| 248 | rule = load_rule_file(str(rule_file)) |
| 249 | |
| 250 | # Should handle gracefully (may return None or partial data) |
| 251 | # The custom YAML parser is lenient |
| 252 | |
| 253 | def test_unicode_errors(self, temp_project_dir): |
| 254 | """Test handling of files with invalid unicode.""" |
| 255 | rule_file = temp_project_dir / ".claude" / "hookify.unicode.local.md" |
| 256 | |
| 257 | # Write binary content that's not valid UTF-8 |
| 258 | with open(rule_file, 'wb') as f: |
| 259 | f.write(b"---\nname: test\n---\n\xff\xfe invalid unicode") |
| 260 | |
| 261 | rule = load_rule_file(str(rule_file)) |
| 262 | assert rule is None # Should return None for encoding errors |
| 263 | |
| 264 | def test_empty_file(self, create_rule_file): |
| 265 | """Test handling of empty rule file.""" |
| 266 | rule_file = create_rule_file("empty", "") |
| 267 | rule = load_rule_file(str(rule_file)) |
| 268 | |
| 269 | assert rule is None |
| 270 | |
| 271 | |
| 272 | class TestFieldExtractionErrors: |
| 273 | """Tests for field extraction edge cases.""" |
| 274 | |
| 275 | def test_unknown_field_name(self, rule_engine: RuleEngine): |
| 276 | """Test handling of unknown field names.""" |
| 277 | input_data = { |
| 278 | "hook_event_name": "PreToolUse", |
| 279 | "tool_name": "Bash", |
| 280 | "tool_input": {"command": "test"} |
| 281 | } |
| 282 | |
| 283 | rules = [ |
| 284 | _make_rule( |
| 285 | name="test-rule", |
| 286 | event="bash", |
| 287 | conditions=[{"field": "nonexistent_field", "operator": "contains", "pattern": "test"}], |
| 288 | action="warn", |
| 289 | message="Test" |
| 290 | ), |
| 291 | ] |
| 292 | |
| 293 | # Should not crash, unknown field returns None -> no match |
| 294 | result = rule_engine.evaluate_rules(rules, input_data) |
| 295 | assert result == {} |
| 296 | |
| 297 | def test_multiedit_with_empty_edits(self, rule_engine: RuleEngine): |
| 298 | """Test MultiEdit tool with empty edits array.""" |
| 299 | input_data = { |
| 300 | "hook_event_name": "PreToolUse", |
| 301 | "tool_name": "MultiEdit", |
| 302 | "tool_input": { |
| 303 | "file_path": "/test/file.py", |
| 304 | "edits": [] # Empty edits |
| 305 | } |
| 306 | } |
| 307 | |
| 308 | rules = [ |
| 309 | _make_rule( |
| 310 | name="test-rule", |
| 311 | event="file", |
| 312 | conditions=[{"field": "new_text", "operator": "contains", "pattern": "test"}], |
| 313 | action="warn", |
| 314 | message="Test" |
| 315 | ), |
| 316 | ] |
| 317 | |
| 318 | # Should not crash |
| 319 | result = rule_engine.evaluate_rules(rules, input_data) |
| 320 | assert result == {} |
| 321 | |
| 322 | def test_multiedit_with_malformed_edits(self, rule_engine: RuleEngine): |
| 323 | """Test MultiEdit tool with malformed edit entries.""" |
| 324 | input_data = { |
| 325 | "hook_event_name": "PreToolUse", |
| 326 | "tool_name": "MultiEdit", |
| 327 | "tool_input": { |
| 328 | "file_path": "/test/file.py", |
| 329 | "edits": [ |
| 330 | {"invalid": "entry"}, # Missing new_string |
| 331 | None, # Null entry |
| 332 | "not a dict" # Wrong type |
| 333 | ] |
| 334 | } |
| 335 | } |
| 336 | |
| 337 | rules = [ |
| 338 | _make_rule( |
| 339 | name="test-rule", |
| 340 | event="file", |
| 341 | conditions=[{"field": "new_text", "operator": "contains", "pattern": "test"}], |
| 342 | action="warn", |
| 343 | message="Test" |
| 344 | ), |
| 345 | ] |
| 346 | |
| 347 | # Should handle gracefully |
| 348 | result = rule_engine.evaluate_rules(rules, input_data) |
| 349 | |
| 350 | |
| 351 | class TestOperatorEdgeCases: |
| 352 | """Tests for operator edge cases.""" |
| 353 | |
| 354 | def test_unknown_operator(self, rule_engine: RuleEngine): |
| 355 | """Test handling of unknown operator.""" |
| 356 | input_data = { |
| 357 | "hook_event_name": "PreToolUse", |
| 358 | "tool_name": "Bash", |
| 359 | "tool_input": {"command": "test"} |
| 360 | } |
| 361 | |
| 362 | rules = [ |
| 363 | _make_rule( |
| 364 | name="test-rule", |
| 365 | event="bash", |
| 366 | conditions=[{"field": "command", "operator": "unknown_op", "pattern": "test"}], |
| 367 | action="warn", |
| 368 | message="Test" |
| 369 | ), |
| 370 | ] |
| 371 | |
| 372 | # Unknown operator returns False -> no match |
| 373 | result = rule_engine.evaluate_rules(rules, input_data) |
| 374 | assert result == {} |
| 375 | |
| 376 | def test_empty_pattern(self, rule_engine: RuleEngine): |
| 377 | """Test handling of empty pattern.""" |
| 378 | input_data = { |
| 379 | "hook_event_name": "PreToolUse", |
| 380 | "tool_name": "Bash", |
| 381 | "tool_input": {"command": "test"} |
| 382 | } |
| 383 | |
| 384 | rules = [ |
| 385 | _make_rule( |
| 386 | name="test-rule", |
| 387 | event="bash", |
| 388 | conditions=[{"field": "command", "operator": "contains", "pattern": ""}], |
| 389 | action="warn", |
| 390 | message="Empty pattern" |
| 391 | ), |
| 392 | ] |
| 393 | |
| 394 | result = rule_engine.evaluate_rules(rules, input_data) |
| 395 | # Empty string is contained in any string |
| 396 | assert "Empty pattern" in result.get("systemMessage", "") |
| 397 | |
| 398 | def test_special_characters_in_pattern(self, rule_engine: RuleEngine): |
| 399 | """Test patterns with special regex characters when using 'contains'.""" |
| 400 | input_data = { |
| 401 | "hook_event_name": "PreToolUse", |
| 402 | "tool_name": "Bash", |
| 403 | "tool_input": {"command": "echo $HOME"} |
| 404 | } |
| 405 | |
| 406 | rules = [ |
| 407 | _make_rule( |
| 408 | name="test-rule", |
| 409 | event="bash", |
| 410 | conditions=[{"field": "command", "operator": "contains", "pattern": "$HOME"}], |
| 411 | action="warn", |
| 412 | message="Found $HOME" |
| 413 | ), |
| 414 | ] |
| 415 | |
| 416 | result = rule_engine.evaluate_rules(rules, input_data) |
| 417 | # 'contains' does literal string matching, not regex |
| 418 | assert "Found $HOME" in result.get("systemMessage", "") |
| 419 | |
| 420 | |
| 421 | class TestConcurrentRuleEvaluation: |
| 422 | """Tests for multiple rules with various states.""" |
| 423 | |
| 424 | def test_mixed_match_states(self, rule_engine: RuleEngine): |
| 425 | """Test evaluation with mix of matching and non-matching rules.""" |
| 426 | input_data = { |
| 427 | "hook_event_name": "PreToolUse", |
| 428 | "tool_name": "Bash", |
| 429 | "tool_input": {"command": "ls -la"} |
| 430 | } |
| 431 | |
| 432 | rules = [ |
| 433 | _make_rule( |
| 434 | name="match-ls", |
| 435 | event="bash", |
| 436 | conditions=[{"field": "command", "operator": "contains", "pattern": "ls"}], |
| 437 | action="warn", |
| 438 | message="Found ls" |
| 439 | ), |
| 440 | _make_rule( |
| 441 | name="no-match-rm", |
| 442 | event="bash", |
| 443 | conditions=[{"field": "command", "operator": "contains", "pattern": "rm"}], |
| 444 | action="block", |
| 445 | message="Found rm" |
| 446 | ), |
| 447 | _make_rule( |
| 448 | name="match-dash", |
| 449 | event="bash", |
| 450 | conditions=[{"field": "command", "operator": "contains", "pattern": "-"}], |
| 451 | action="warn", |
| 452 | message="Found dash" |
| 453 | ), |
| 454 | ] |
| 455 | |
| 456 | result = rule_engine.evaluate_rules(rules, input_data) |
| 457 | |
| 458 | # Should have warnings from matching rules |
| 459 | assert "Found ls" in result.get("systemMessage", "") |
| 460 | assert "Found dash" in result.get("systemMessage", "") |
| 461 | # Should not have blocking (rm rule didn't match) |
| 462 | assert "hookSpecificOutput" not in result |
| 463 | |
| 464 | def test_empty_rules_list(self, rule_engine: RuleEngine): |
| 465 | """Test evaluation with empty rules list.""" |
| 466 | input_data = { |
| 467 | "hook_event_name": "PreToolUse", |
| 468 | "tool_name": "Bash", |
| 469 | "tool_input": {"command": "ls"} |
| 470 | } |
| 471 | |
| 472 | result = rule_engine.evaluate_rules([], input_data) |
| 473 | assert result == {} |
| 474 | |
| 475 | |
| 476 | # Helper function to create rules for tests |
| 477 | def _make_rule(name, event, conditions, action="warn", message="Test", enabled=True, tool_matcher=None): |
| 478 | """Helper to create Rule objects.""" |
| 479 | from hookify.core.config_loader import Rule, Condition |
| 480 | |
| 481 | cond_objects = [ |
| 482 | Condition( |
| 483 | field=c.get("field", ""), |
| 484 | operator=c.get("operator", "regex_match"), |
| 485 | pattern=c.get("pattern", "") |
| 486 | ) |
| 487 | for c in conditions |
| 488 | ] |
| 489 | return Rule( |
| 490 | name=name, |
| 491 | enabled=enabled, |
| 492 | event=event, |
| 493 | conditions=cond_objects, |
| 494 | action=action, |
| 495 | message=message, |
| 496 | tool_matcher=tool_matcher |
| 497 | ) |