mirror of
https://github.com/usnistgov/macos_security.git
synced 2026-09-28 14:21:36 +01:00
366 lines
13 KiB
Python
366 lines
13 KiB
Python
"""Tests for the --markdown-tree output mode.
|
|
|
|
Covers:
|
|
- `create_slug`: slug normalisation
|
|
- `mdx_escape`: entity-encoding outside fenced blocks / inline code spans
|
|
- `render_references_md`: GFM-table-safe cell rendering
|
|
- `_frontmatter`: YAML single-quote escaping
|
|
- Integration: generate_markdown_tree writes a valid, SSG-safe tree from a
|
|
real bundled baseline (cis_lvl1_macos_26.0).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import re
|
|
import textwrap
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from mscp.generate.guidance_support.markdown_tree import (
|
|
_frontmatter,
|
|
create_slug,
|
|
mdx_escape,
|
|
render_references_md,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# create_slug
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestCreateSlug:
|
|
def test_spaces_to_hyphens(self):
|
|
assert create_slug("System Settings") == "system-settings"
|
|
|
|
def test_lowercase(self):
|
|
assert create_slug("Auditing") == "auditing"
|
|
|
|
def test_strips_punctuation(self):
|
|
assert create_slug("Password/Policy") == "passwordpolicy"
|
|
|
|
def test_collapses_multiple_hyphens(self):
|
|
assert create_slug("foo -- bar") == "foo-bar"
|
|
|
|
def test_strips_leading_trailing_hyphens(self):
|
|
assert create_slug("-foo-") == "foo"
|
|
|
|
def test_underscore_becomes_hyphen(self):
|
|
assert create_slug("os_ssh_fips") == "os-ssh-fips"
|
|
|
|
def test_empty_string(self):
|
|
assert create_slug("") == ""
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# mdx_escape
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestMdxEscape:
|
|
def test_braces_encoded_in_prose(self):
|
|
result = mdx_escape("value is {foo}")
|
|
assert "{" in result
|
|
assert "}" in result
|
|
assert "{" not in result
|
|
|
|
def test_braces_untouched_inside_fence(self):
|
|
src = "```bash\necho {hello}\n```"
|
|
assert mdx_escape(src) == src
|
|
|
|
def test_braces_untouched_in_inline_code(self):
|
|
src = "use `{key}` here"
|
|
result = mdx_escape(src)
|
|
assert "`{key}`" in result
|
|
|
|
def test_bare_lt_encoded_in_prose(self):
|
|
result = mdx_escape("plist has <dict> and <key>")
|
|
assert "<dict>" in result or "<" in result
|
|
# '<' in prose must be entity-encoded
|
|
assert re.search(r"<(?!br|hr|img|/)", result) is None
|
|
|
|
def test_known_html_tags_preserved(self):
|
|
result = mdx_escape("line<br />next")
|
|
assert "<br />" in result
|
|
|
|
def test_br_normalized_to_self_closing(self):
|
|
result = mdx_escape("line<br>next")
|
|
assert "<br />" in result
|
|
assert "<br>" not in result
|
|
|
|
def test_br_idempotent(self):
|
|
result = mdx_escape("line<br />next")
|
|
assert result.count("<br />") == 1
|
|
|
|
def test_fence_with_plist(self):
|
|
src = "```xml\n<dict><key>foo</key></dict>\n```"
|
|
assert mdx_escape(src) == src
|
|
|
|
def test_empty_string(self):
|
|
assert mdx_escape("") == ""
|
|
|
|
def test_none_passthrough(self):
|
|
# mdx_escape returns value unchanged when falsy
|
|
assert mdx_escape(None) is None # type: ignore[arg-type]
|
|
|
|
def test_multiple_fences(self):
|
|
src = "intro {x}\n```bash\nif {a}; then\n```\noutro {y}"
|
|
result = mdx_escape(src)
|
|
assert "{x}" in result # prose encoded
|
|
assert "```bash\nif {a}; then\n```" in result # fence unchanged
|
|
assert "{y}" in result
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# render_references_md
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestRenderReferencesMd:
|
|
def test_empty_returns_empty_string(self):
|
|
assert render_references_md([]) == ""
|
|
assert render_references_md(None) == ""
|
|
|
|
def test_single_dict(self):
|
|
result = render_references_md([{"SRG": "SRG-OS-000001"}])
|
|
assert "**SRG**" in result
|
|
assert "SRG-OS-000001" in result
|
|
|
|
def test_pipe_escaped(self):
|
|
result = render_references_md([{"Key": "a|b"}])
|
|
assert r"\|" in result
|
|
|
|
def test_list_value_joined(self):
|
|
result = render_references_md([{"IDs": ["ID-1", "ID-2"]}])
|
|
assert "ID-1" in result
|
|
assert "ID-2" in result
|
|
|
|
def test_multiple_dicts_joined_with_br(self):
|
|
refs = [{"A": "1"}, {"B": "2"}]
|
|
result = render_references_md(refs)
|
|
assert "<br />" in result
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _frontmatter
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestFrontmatter:
|
|
def test_basic(self):
|
|
fm = _frontmatter({"title": "Hello"})
|
|
assert fm.startswith("---")
|
|
assert fm.endswith("---")
|
|
assert "title: 'Hello'" in fm
|
|
|
|
def test_single_quote_escaped(self):
|
|
fm = _frontmatter({"title": "It's a title"})
|
|
assert "title: 'It''s a title'" in fm
|
|
|
|
def test_non_string_bare(self):
|
|
fm = _frontmatter({"position": 3})
|
|
assert "position: 3" in fm
|
|
|
|
def test_slug_field(self):
|
|
fm = _frontmatter({"title": "A Rule", "slug": "a-rule"})
|
|
assert "slug: 'a-rule'" in fm
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Integration: generate_markdown_tree
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _get_baseline_path() -> Path:
|
|
"""Return a bundled baseline YAML for testing."""
|
|
root = Path(__file__).parent.parent
|
|
candidates = sorted((root / "src/mscp/data/baselines/macos").glob("cis_lvl1_macos_*.yaml"))
|
|
for p in candidates:
|
|
if p.exists():
|
|
return p
|
|
pytest.fail(f"Bundled baseline not found; checked: {[str(p) for p in candidates]}")
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def markdown_tree_output(tmp_path_factory):
|
|
"""Generate the markdown tree for cis_lvl1 into a temp dir."""
|
|
from mscp.classes import Baseline
|
|
from mscp.common_utils import get_version_data, mscp_data
|
|
from mscp.generate.guidance_support.markdown_tree import generate_markdown_tree
|
|
|
|
baseline_path = _get_baseline_path()
|
|
tmp = tmp_path_factory.mktemp("tree_output")
|
|
|
|
baseline = Baseline.from_yaml(baseline_path)
|
|
version_data = get_version_data(
|
|
baseline.platform["os"],
|
|
float(baseline.platform["version"]),
|
|
mscp_data,
|
|
)
|
|
|
|
generate_markdown_tree(
|
|
build_path=tmp,
|
|
baseline=baseline,
|
|
os_name=baseline.platform["os"],
|
|
version_info=version_data,
|
|
show_all_tags=False,
|
|
language="en",
|
|
)
|
|
return tmp / "markdown_tree"
|
|
|
|
|
|
class TestMarkdownTreeIntegration:
|
|
def test_output_dir_exists(self, markdown_tree_output):
|
|
assert markdown_tree_output.is_dir()
|
|
|
|
def test_root_index_exists(self, markdown_tree_output):
|
|
assert (markdown_tree_output / "index.md").is_file()
|
|
|
|
def test_root_index_has_frontmatter(self, markdown_tree_output):
|
|
content = (markdown_tree_output / "index.md").read_text()
|
|
assert content.startswith("---\n")
|
|
assert "title:" in content
|
|
|
|
def test_section_dirs_exist(self, markdown_tree_output):
|
|
section_dirs = [d for d in markdown_tree_output.iterdir() if d.is_dir()]
|
|
assert len(section_dirs) > 0
|
|
|
|
def test_section_dirs_have_nn_prefix(self, markdown_tree_output):
|
|
section_dirs = [d for d in markdown_tree_output.iterdir() if d.is_dir()]
|
|
for d in section_dirs:
|
|
assert re.match(r"^\d{2}-", d.name), (
|
|
f"Section dir '{d.name}' missing NN- prefix"
|
|
)
|
|
|
|
def test_section_dirs_sorted_correctly(self, markdown_tree_output):
|
|
section_dirs = sorted(d for d in markdown_tree_output.iterdir() if d.is_dir())
|
|
prefixes = [int(d.name[:2]) for d in section_dirs]
|
|
assert prefixes == sorted(prefixes)
|
|
|
|
def test_each_section_has_index(self, markdown_tree_output):
|
|
for section_dir in markdown_tree_output.iterdir():
|
|
if section_dir.is_dir():
|
|
assert (section_dir / "index.md").is_file(), (
|
|
f"Missing index.md in {section_dir.name}"
|
|
)
|
|
|
|
def test_rule_files_have_nn_prefix(self, markdown_tree_output):
|
|
for section_dir in markdown_tree_output.iterdir():
|
|
if not section_dir.is_dir():
|
|
continue
|
|
rule_files = [
|
|
f for f in section_dir.iterdir() if f.is_file() and f.name != "index.md"
|
|
]
|
|
for rf in rule_files:
|
|
assert re.match(r"^\d{2}-", rf.name), (
|
|
f"Rule file '{rf.name}' missing NN- prefix"
|
|
)
|
|
|
|
def test_rule_files_are_md_not_mdx(self, markdown_tree_output):
|
|
for section_dir in markdown_tree_output.iterdir():
|
|
if not section_dir.is_dir():
|
|
continue
|
|
for f in section_dir.iterdir():
|
|
assert f.suffix == ".md", f"Expected .md extension, got '{f.name}'"
|
|
|
|
def test_rule_frontmatter_parses(self, markdown_tree_output):
|
|
"""All rule files must have valid YAML frontmatter with title only."""
|
|
for section_dir in markdown_tree_output.iterdir():
|
|
if not section_dir.is_dir():
|
|
continue
|
|
for rf in section_dir.iterdir():
|
|
if rf.name == "index.md":
|
|
continue
|
|
content = rf.read_text()
|
|
assert content.startswith("---\n"), f"{rf.name}: missing frontmatter"
|
|
# Extract frontmatter block
|
|
end = content.index("---\n", 4)
|
|
fm_block = content[4:end]
|
|
assert "title:" in fm_block, f"{rf.name}: missing title"
|
|
|
|
def test_no_raw_braces_outside_fences(self, markdown_tree_output):
|
|
"""No unescaped { or } should appear outside fenced blocks."""
|
|
for md_file in markdown_tree_output.rglob("*.md"):
|
|
content = md_file.read_text()
|
|
# Strip fenced code blocks
|
|
stripped = re.sub(r"```.*?```", "", content, flags=re.DOTALL)
|
|
# Strip frontmatter
|
|
if stripped.startswith("---\n"):
|
|
end = stripped.index("---\n", 4)
|
|
stripped = stripped[end + 4 :]
|
|
assert "{" not in stripped, (
|
|
f"{md_file.name}: raw '{{' found outside fenced block"
|
|
)
|
|
assert "}" not in stripped, (
|
|
f"{md_file.name}: raw '}}' found outside fenced block"
|
|
)
|
|
|
|
def test_balanced_fences(self, markdown_tree_output):
|
|
"""Fenced code blocks must be properly opened and closed."""
|
|
for md_file in markdown_tree_output.rglob("*.md"):
|
|
content = md_file.read_text()
|
|
fences = re.findall(r"^```", content, flags=re.MULTILINE)
|
|
assert len(fences) % 2 == 0, (
|
|
f"{md_file.name}: unbalanced fences ({len(fences)} markers)"
|
|
)
|
|
|
|
def test_no_category_json_files(self, markdown_tree_output):
|
|
"""No Docusaurus-specific _category_.json files should be present."""
|
|
category_files = list(markdown_tree_output.rglob("_category_.json"))
|
|
assert category_files == [], f"Found _category_.json files: {category_files}"
|
|
|
|
def test_single_file_markdown_unchanged(self, tmp_path):
|
|
"""The single-file --markdown output must not be affected by the tree changes.
|
|
|
|
Verifies that rendering without markdown_tree=True in context still
|
|
produces the HTML-table remediation block (not the heading-based one).
|
|
"""
|
|
# This is a template-level smoke test: render the rule template
|
|
# directly without markdown_tree context and confirm HTML table
|
|
# structure is present.
|
|
from jinja2 import Environment, DictLoader
|
|
|
|
# Minimal template that exercises only the remediation branch
|
|
template_src = textwrap.dedent("""\
|
|
{% set check_tags = ["permanent", "inherent", "n_a", "not_applicable"] %}
|
|
{% set additional_info = none %}
|
|
{% set check_shell = none %}
|
|
{% set fix_shell = none %}
|
|
{% if not markdown_tree | default(false) %}
|
|
### {{ rule.title }}
|
|
{% endif %}
|
|
{{ rule.discussion }}
|
|
{% if not rule.tags | select('in', check_tags) | list %}
|
|
{% if markdown_tree | default(false) %}
|
|
## Remediation Description
|
|
{% else %}
|
|
<table class="remediation">remediation</table>
|
|
{% endif %}
|
|
{% endif %}
|
|
""")
|
|
|
|
env = Environment(loader=DictLoader({"rule.md.jinja": template_src}))
|
|
env.filters["default"] = lambda v, d=None: v if v else d
|
|
|
|
rule = {
|
|
"title": "Test Rule",
|
|
"discussion": "Some text.",
|
|
"tags": ["other"],
|
|
"mechanism": "automated",
|
|
"fix": "sudo defaults write ...",
|
|
}
|
|
|
|
# Without markdown_tree - should use HTML table remediation
|
|
result_std = env.get_template("rule.md.jinja").render(rule=rule)
|
|
assert "<table" in result_std
|
|
assert "## Remediation" not in result_std
|
|
|
|
# With markdown_tree=True - should use heading-based remediation
|
|
result_tree = env.get_template("rule.md.jinja").render(
|
|
rule=rule, markdown_tree=True
|
|
)
|
|
assert "## Remediation" in result_tree
|
|
assert "<table" not in result_tree
|