|
7 | 7 | COMPATIBILITY_MAX_LENGTH, |
8 | 8 | DESCRIPTION_MAX_LENGTH, |
9 | 9 | NAME_MAX_LENGTH, |
| 10 | + RESERVED_WORDS, |
10 | 11 | SKILL_LINE_THRESHOLD, |
11 | 12 | validate_skill_record, |
12 | 13 | ) |
@@ -135,6 +136,60 @@ def test_name_valid_hyphens(self): |
135 | 136 | hyphen_issues = [i for i in issues if "hyphen" in i.message.lower()] |
136 | 137 | assert len(hyphen_issues) == 0 |
137 | 138 |
|
| 139 | + @pytest.mark.parametrize("reserved", list(RESERVED_WORDS)) |
| 140 | + def test_name_reserved_word(self, reserved: str): |
| 141 | + """name containing reserved word → fatal.""" |
| 142 | + name = f"my-{reserved}-skill" |
| 143 | + issues = validate_skill_record( |
| 144 | + {"name": name, "description": "desc", "path": f"/skills/{name}"} |
| 145 | + ) |
| 146 | + fatal = [i for i in issues if i.severity == "fatal" and "reserved" in i.message.lower()] |
| 147 | + assert len(fatal) == 1 |
| 148 | + assert reserved in fatal[0].message |
| 149 | + |
| 150 | + def test_name_reserved_word_case_insensitive(self): |
| 151 | + """Reserved word check should be case-insensitive.""" |
| 152 | + # Note: name validation already fails on uppercase, but reserved check is independent |
| 153 | + issues = validate_skill_record( |
| 154 | + {"name": "my-skill", "description": "desc", "path": "/skills/my-skill"} |
| 155 | + ) |
| 156 | + reserved_issues = [i for i in issues if "reserved" in i.message.lower()] |
| 157 | + assert len(reserved_issues) == 0 |
| 158 | + |
| 159 | + def test_name_xml_tags(self): |
| 160 | + """name containing XML tags → fatal.""" |
| 161 | + issues = validate_skill_record( |
| 162 | + {"name": "<script>", "description": "desc", "path": "/skills/<script>"} |
| 163 | + ) |
| 164 | + fatal = [i for i in issues if i.severity == "fatal" and "xml" in i.message.lower()] |
| 165 | + assert len(fatal) == 1 |
| 166 | + |
| 167 | + def test_description_xml_tags(self): |
| 168 | + """description containing XML tags → fatal.""" |
| 169 | + issues = validate_skill_record( |
| 170 | + { |
| 171 | + "name": "my-skill", |
| 172 | + "description": "A skill with <script>alert('xss')</script> injection", |
| 173 | + "path": "/skills/my-skill", |
| 174 | + } |
| 175 | + ) |
| 176 | + fatal = [i for i in issues if i.severity == "fatal" and "xml" in i.message.lower()] |
| 177 | + assert len(fatal) == 1 |
| 178 | + assert "description" in fatal[0].field |
| 179 | + |
| 180 | + def test_description_no_xml_tags_ok(self): |
| 181 | + """description without XML tags → ok.""" |
| 182 | + issues = validate_skill_record( |
| 183 | + { |
| 184 | + "name": "my-skill", |
| 185 | + "description": "A valid description with math like 3 < 5 or x > y", |
| 186 | + "path": "/skills/my-skill", |
| 187 | + } |
| 188 | + ) |
| 189 | + xml_issues = [i for i in issues if "xml" in i.message.lower()] |
| 190 | + # "<" and ">" used separately (not forming tags) should pass |
| 191 | + assert len(xml_issues) == 0 |
| 192 | + |
138 | 193 |
|
139 | 194 | class TestValidationWarning: |
140 | 195 | """Warning validation rules (exit code 0).""" |
@@ -405,6 +460,90 @@ def test_meta_none_skips_key_check(self): |
405 | 460 | key_missing = [i for i in issues if "key is missing" in i.message] |
406 | 461 | assert len(key_missing) == 0 |
407 | 462 |
|
| 463 | + def test_name_not_string_in_frontmatter(self): |
| 464 | + """Non-string 'name' → fatal (e.g., name: yes → True).""" |
| 465 | + issues = validate_skill_record( |
| 466 | + {"name": True, "description": "desc", "path": "/skills/test"}, |
| 467 | + meta={"name": True, "description": "desc"}, # YAML: name: yes |
| 468 | + ) |
| 469 | + fatal = [ |
| 470 | + i for i in issues if i.severity == "fatal" and "must be a string" in i.message |
| 471 | + ] |
| 472 | + assert len(fatal) == 1 |
| 473 | + assert "name" in fatal[0].field |
| 474 | + assert "bool" in fatal[0].message |
| 475 | + |
| 476 | + def test_description_not_string_in_frontmatter(self): |
| 477 | + """Non-string 'description' → fatal.""" |
| 478 | + issues = validate_skill_record( |
| 479 | + {"name": "test", "description": ["item1", "item2"], "path": "/skills/test"}, |
| 480 | + meta={"name": "test", "description": ["item1", "item2"]}, # YAML list |
| 481 | + ) |
| 482 | + fatal = [ |
| 483 | + i for i in issues if i.severity == "fatal" and "must be a string" in i.message |
| 484 | + ] |
| 485 | + assert len(fatal) == 1 |
| 486 | + assert "description" in fatal[0].field |
| 487 | + assert "list" in fatal[0].message |
| 488 | + |
| 489 | + def test_non_string_description_no_crash(self): |
| 490 | + """Non-string description in skill dict should not crash on XML check.""" |
| 491 | + # This tests the case where skill dict has non-string values |
| 492 | + # (e.g., from index or malformed input) |
| 493 | + issues = validate_skill_record( |
| 494 | + {"name": "test", "description": ["item1", "item2"], "path": "/skills/test"}, |
| 495 | + meta={"name": "test", "description": ["item1", "item2"]}, |
| 496 | + ) |
| 497 | + # Should not raise TypeError, should return type error issue |
| 498 | + fatal = [i for i in issues if i.severity == "fatal"] |
| 499 | + assert len(fatal) >= 1 |
| 500 | + assert any("must be a string" in i.message for i in fatal) |
| 501 | + |
| 502 | + def test_non_string_name_no_crash(self): |
| 503 | + """Non-string name in skill dict should not crash on validation.""" |
| 504 | + issues = validate_skill_record( |
| 505 | + {"name": True, "description": "desc", "path": "/skills/test"}, |
| 506 | + meta={"name": True, "description": "desc"}, |
| 507 | + ) |
| 508 | + # Should not raise TypeError |
| 509 | + fatal = [i for i in issues if i.severity == "fatal"] |
| 510 | + assert len(fatal) >= 1 |
| 511 | + assert any("must be a string" in i.message for i in fatal) |
| 512 | + |
| 513 | + def test_non_string_without_meta(self): |
| 514 | + """Type check works even without meta (e.g., index-based validation).""" |
| 515 | + # This is the critical case: validate via index without meta |
| 516 | + issues = validate_skill_record( |
| 517 | + {"name": True, "description": ["a", "b"], "path": "/skills/test"}, |
| 518 | + meta=None, # No meta provided |
| 519 | + ) |
| 520 | + fatal = [i for i in issues if i.severity == "fatal"] |
| 521 | + # Should detect both type errors |
| 522 | + type_errors = [i for i in fatal if "must be a string" in i.message] |
| 523 | + assert len(type_errors) == 2 |
| 524 | + assert any("name" in i.field for i in type_errors) |
| 525 | + assert any("description" in i.field for i in type_errors) |
| 526 | + |
| 527 | + def test_falsy_non_string_name_detected(self): |
| 528 | + """Falsy non-string name (null, [], False) should be detected as type error.""" |
| 529 | + for falsy_value in [None, [], False, 0]: |
| 530 | + issues = validate_skill_record( |
| 531 | + {"name": falsy_value, "description": "desc", "path": "/skills/test"}, |
| 532 | + ) |
| 533 | + fatal = [i for i in issues if i.severity == "fatal" and i.field == "name"] |
| 534 | + assert len(fatal) >= 1, f"Failed for name={falsy_value!r}" |
| 535 | + assert any("must be a string" in i.message for i in fatal), f"Failed for name={falsy_value!r}" |
| 536 | + |
| 537 | + def test_falsy_non_string_description_detected(self): |
| 538 | + """Falsy non-string description (null, [], False) should be detected as type error.""" |
| 539 | + for falsy_value in [None, [], False, 0]: |
| 540 | + issues = validate_skill_record( |
| 541 | + {"name": "test", "description": falsy_value, "path": "/skills/test"}, |
| 542 | + ) |
| 543 | + fatal = [i for i in issues if i.severity == "fatal" and i.field == "description"] |
| 544 | + assert len(fatal) >= 1, f"Failed for description={falsy_value!r}" |
| 545 | + assert any("must be a string" in i.message for i in fatal), f"Failed for description={falsy_value!r}" |
| 546 | + |
408 | 547 |
|
409 | 548 | class TestStrictMode: |
410 | 549 | """strict mode behavior tests.""" |
|
0 commit comments