|
| 1 | +"""Re-saving an export must not re-identify its findings. |
| 2 | +
|
| 3 | +WHAT IS AT STAKE. A finding's fingerprint decides whether this scan's result is |
| 4 | +the SAME defect as last scan's. If it moves, the old finding is retired and a new |
| 5 | +one raised: the age resets, the MTTR is wrong, the acceptance and the ticket |
| 6 | +reference are orphaned, and the mitigation journey restarts — silently, because |
| 7 | +both scans look perfectly healthy and the counts barely change. |
| 8 | +
|
| 9 | +WHY THESE VARIANTS. Row order was one dimension of "the answer must not depend |
| 10 | +on something incidental", and it found four fail-opens in one day. These are the |
| 11 | +other dimensions of the same question, all of them things that happen to a CSV |
| 12 | +between the system and the upload without a single SAP fact changing: |
| 13 | +
|
| 14 | + bom Excel adds a UTF-8 byte-order mark on save |
| 15 | + crlf Windows line endings |
| 16 | + trailing a trailing space after every value |
| 17 | + lower_header column names lower-cased (BNAME -> bname) |
| 18 | + quoted every field quoted, as some exporters do |
| 19 | + cp1252 saved in the codepage the loader already falls back to |
| 20 | +
|
| 21 | +Not hypothetical: a scan of the bundled corpus reports files "decoded only via a |
| 22 | +fallback encoding (cp1252)" today. |
| 23 | +
|
| 24 | +THE RESULT WHEN THIS WAS WRITTEN was that the product is already right — 418 of |
| 25 | +419 fingerprints identical across every variant. That is worth a test anyway: it |
| 26 | +is a property nothing currently enforces, one careless `strip()` removed from |
| 27 | +`norm_name` would break it, and the failure is invisible in every number a |
| 28 | +reader looks at. |
| 29 | +
|
| 30 | +THE ONE DIFFERENCE, and why it is excluded rather than asserted away. The |
| 31 | +Export Integrity checks report on the FILE — "these were not valid UTF-8" — so |
| 32 | +re-saving an export as UTF-8 genuinely fixes what EXPORT-002 reports and it |
| 33 | +correctly stops firing. That is a fact this rewrite really does change, unlike |
| 34 | +every other fact in the corpus. It is the same distinction the domain taxonomy |
| 35 | +draws when it keeps Export Integrity outside the twelve domains: a statement |
| 36 | +about the evidence rather than about SAP. The last test here proves that |
| 37 | +exclusion is not covering a broken check. |
| 38 | +
|
| 39 | +WHICH LINE ACTUALLY DEFENDS THIS, established by mutation rather than by |
| 40 | +reading. `DataLoader` normalises every row with |
| 41 | +
|
| 42 | + k.strip().upper().replace(" ", "_"): (v or "").strip() |
| 43 | +
|
| 44 | +and that one line carries two of these variants: drop the value `.strip()` and |
| 45 | +`trailing` fails; drop the key `.upper()` and `lower_header` fails. Each |
| 46 | +mutation fails exactly its own variant and nothing else. |
| 47 | +
|
| 48 | +`norm_name` in server/identity.py also strips, and removing ITS strip changes |
| 49 | +nothing end-to-end — the loader has already done it. That is belt and braces |
| 50 | +rather than a defect, but it is worth knowing which of the two is load-bearing |
| 51 | +before trusting the other. The remaining variants (bom, crlf, quoted, cp1252) |
| 52 | +guard the decode-and-parse path rather than a single line, and are not claimed |
| 53 | +here to have an equivalent one-line mutation. |
| 54 | +""" |
| 55 | +from __future__ import annotations |
| 56 | + |
| 57 | +import contextlib |
| 58 | +import csv |
| 59 | +import importlib |
| 60 | +import io |
| 61 | +import shutil |
| 62 | +import sys |
| 63 | +from pathlib import Path |
| 64 | + |
| 65 | +import pytest |
| 66 | + |
| 67 | +ROOT = Path(__file__).resolve().parents[1] |
| 68 | +if str(ROOT) not in sys.path: |
| 69 | + sys.path.insert(0, str(ROOT)) |
| 70 | + |
| 71 | +from modules import data_loader # noqa: E402 |
| 72 | +from server import identity # noqa: E402 |
| 73 | +from server.ingest import AUDITORS # noqa: E402 |
| 74 | + |
| 75 | +#: Checks that report on the export FILE rather than on the SAP system. Their |
| 76 | +#: subject is the thing these variants deliberately change, so they are the one |
| 77 | +#: family whose behaviour is expected to differ. |
| 78 | +FILE_CHECKS = ("EXPORT-",) |
| 79 | + |
| 80 | +VARIANTS = ("bom", "crlf", "trailing", "lower_header", "quoted", "cp1252") |
| 81 | + |
| 82 | + |
| 83 | +def _scan(directory: Path): |
| 84 | + with contextlib.redirect_stdout(io.StringIO()): |
| 85 | + data = data_loader.DataLoader(Path(directory)).load_all() |
| 86 | + out = [] |
| 87 | + for name, cls in AUDITORS: |
| 88 | + auditor_cls = getattr(importlib.import_module("modules." + name), cls) |
| 89 | + auditor = None |
| 90 | + for args in ((data,), (data, None), (data, None, {})): |
| 91 | + try: |
| 92 | + auditor = auditor_cls(*args) |
| 93 | + break |
| 94 | + except TypeError: |
| 95 | + continue |
| 96 | + if auditor is None: |
| 97 | + continue |
| 98 | + try: |
| 99 | + out.extend(auditor.run_all_checks() or []) |
| 100 | + except Exception: # noqa: BLE001 |
| 101 | + # A module that raises is out of scope here; the row-order |
| 102 | + # invariant is where a module raising in one arrangement and not |
| 103 | + # another is caught. |
| 104 | + pass |
| 105 | + return out |
| 106 | + |
| 107 | + |
| 108 | +def fingerprints(directory: Path) -> set: |
| 109 | + got = set() |
| 110 | + for f in _scan(directory): |
| 111 | + check = str(f.get("check_id") or "") |
| 112 | + if check.startswith(FILE_CHECKS): |
| 113 | + continue |
| 114 | + fingerprint, basis = identity.fingerprint_finding( |
| 115 | + f, system="PRD", client="100") |
| 116 | + got.add((check, basis, fingerprint)) |
| 117 | + return got |
| 118 | + |
| 119 | + |
| 120 | +def _rewrite(path: Path, how: str) -> None: |
| 121 | + """Re-save one CSV the `how` way, preserving every fact in it.""" |
| 122 | + raw = path.read_bytes() |
| 123 | + try: |
| 124 | + text = raw.decode("utf-8-sig") |
| 125 | + except UnicodeDecodeError: |
| 126 | + text = raw.decode("cp1252", errors="replace") |
| 127 | + rows = list(csv.reader(io.StringIO(text))) |
| 128 | + if not rows: |
| 129 | + return |
| 130 | + if how == "lower_header": |
| 131 | + rows[0] = [c.lower() for c in rows[0]] |
| 132 | + elif how == "trailing": |
| 133 | + rows = [rows[0]] + [[c + " " for c in r] for r in rows[1:]] |
| 134 | + buf = io.StringIO() |
| 135 | + csv.writer(buf, |
| 136 | + lineterminator="\r\n" if how == "crlf" else "\n", |
| 137 | + quoting=csv.QUOTE_ALL if how == "quoted" |
| 138 | + else csv.QUOTE_MINIMAL).writerows(rows) |
| 139 | + body = buf.getvalue() |
| 140 | + if how == "cp1252": |
| 141 | + path.write_bytes(body.encode("cp1252", errors="replace")) |
| 142 | + elif how == "bom": |
| 143 | + path.write_bytes(b"\xef\xbb\xbf" + body.encode("utf-8")) |
| 144 | + else: |
| 145 | + path.write_bytes(body.encode("utf-8")) |
| 146 | + |
| 147 | + |
| 148 | +@pytest.fixture(scope="module") |
| 149 | +def baseline(): |
| 150 | + return fingerprints(ROOT / "sample_data") |
| 151 | + |
| 152 | + |
| 153 | +def test_the_baseline_is_not_empty(baseline): |
| 154 | + """Every assertion below passes vacuously against an empty set.""" |
| 155 | + assert len(baseline) > 200, len(baseline) |
| 156 | + |
| 157 | + |
| 158 | +@pytest.mark.parametrize("how", VARIANTS) |
| 159 | +def test_re_saving_the_export_does_not_re_identify_anything(how, baseline, tmp_path): |
| 160 | + estate = tmp_path / how |
| 161 | + shutil.copytree(ROOT / "sample_data", estate) |
| 162 | + for path in estate.rglob("*.csv"): |
| 163 | + _rewrite(path, how) |
| 164 | + |
| 165 | + got = fingerprints(estate) |
| 166 | + lost, gained = baseline - got, got - baseline |
| 167 | + assert not lost and not gained, ( |
| 168 | + "Saving the same facts as %r changed finding identity.\n" |
| 169 | + " retired (age and MTTR reset): %s\n" |
| 170 | + " raised as new: %s\n" |
| 171 | + "Nothing about the SAP system differs between these two exports." |
| 172 | + % (how, sorted(x[0] for x in lost)[:8], |
| 173 | + sorted(x[0] for x in gained)[:8])) |
| 174 | + |
| 175 | + |
| 176 | +def test_a_file_check_is_allowed_to_notice_the_re_save(tmp_path): |
| 177 | + """The exclusion above must not be hiding a broken check. |
| 178 | +
|
| 179 | + EXPORT-002 reports files that were not valid UTF-8. Re-saving the corpus as |
| 180 | + UTF-8 really does fix that, so it must STOP firing — and re-saving as |
| 181 | + cp1252 must keep it firing. If neither happened, the exclusion would be |
| 182 | + covering a check that had simply stopped working. |
| 183 | + """ |
| 184 | + def export_checks(estate): |
| 185 | + return {str(f.get("check_id")) for f in _scan(estate) |
| 186 | + if str(f.get("check_id") or "").startswith(FILE_CHECKS)} |
| 187 | + |
| 188 | + before = export_checks(ROOT / "sample_data") |
| 189 | + if "EXPORT-002" not in before: |
| 190 | + pytest.skip("the bundled corpus no longer needs a fallback encoding") |
| 191 | + |
| 192 | + as_utf8 = tmp_path / "utf8" |
| 193 | + shutil.copytree(ROOT / "sample_data", as_utf8) |
| 194 | + for path in as_utf8.rglob("*.csv"): |
| 195 | + _rewrite(path, "bom") |
| 196 | + assert "EXPORT-002" not in export_checks(as_utf8), ( |
| 197 | + "the corpus was re-saved as valid UTF-8 and the fallback-encoding " |
| 198 | + "finding still fires") |
| 199 | + |
| 200 | + as_cp1252 = tmp_path / "cp1252" |
| 201 | + shutil.copytree(ROOT / "sample_data", as_cp1252) |
| 202 | + for path in as_cp1252.rglob("*.csv"): |
| 203 | + _rewrite(path, "cp1252") |
| 204 | + assert "EXPORT-002" in export_checks(as_cp1252), ( |
| 205 | + "the corpus was re-saved in cp1252 and the fallback-encoding finding " |
| 206 | + "did not fire") |
0 commit comments