Files changed: - .gitea/workflows/ci.yml - CHANGES.md - INSTALL.md - README.md - VERSION - docs/why-gates-are-code.md - instructions/bootstrap.md - instructions/bug-report.md - instructions/preflight.md - instructions/setup-instance.md - instructions/upgrade-instance.md - tools/CONTRACT.md - tools/README.md - tools/bugreport.py - tools/chemenu/cli.py - tools/chemenu/commands/dist_cmd.py - tools/chemenu/commands/doctor.py - tools/chemenu/prerequisites.py - tools/chemenu/tests/conftest.py - tools/chemenu/tests/test_bugreport.py - tools/chemenu/tests/test_doctor.py - tools/chemenu/tests/test_preflight.py - tools/chemenu/tests/test_preflight_pwsh.py - tools/chemenu/toolpaths.py - tools/preflight.ps1 - tools/wikitool - tools/wikitool.ps1
560 lines
24 KiB
Python
560 lines
24 KiB
Python
"""`tools/bugreport.py`, the bug-report collector.
|
|
|
|
The collector is a standalone script - standard library only, no `chemenu` import -
|
|
so these tests import it the way `test_trace_ingest.py` imports its script, and run
|
|
it against a small fake checkout with a fake `tools/wikitool` launcher.
|
|
"""
|
|
import ast
|
|
import json
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
import zipfile
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
import bugreport
|
|
|
|
SCRIPT = Path(bugreport.__file__)
|
|
|
|
TOKEN = "s3cr3t-tok3n-value-9F2"
|
|
PASSWORD = "hunter2-hunter2"
|
|
TITLE = "Vertraulicher Titel Alpha"
|
|
OTHER_TITLE = "Zweite Seite Beta"
|
|
|
|
LAUNCHER = """#!{python}
|
|
import os, sys
|
|
args = sys.argv[1:]
|
|
print("args:", " ".join(args))
|
|
print("session:", os.environ.get("WIKITOOL_SESSION_ID", "<unset>"))
|
|
print("token-in-env:", os.environ.get("MY_API_TOKEN", "<unset>"))
|
|
print("see [[{title}]] and kb/Concepts/{title}.md and kb/Concepts/CONTRACT.md")
|
|
if args[:2] == ["version", "show"] and os.environ.get("FAKE_NOT_STARTING"):
|
|
sys.stderr.write("ModuleNotFoundError: No module named 'typer'\\n")
|
|
sys.exit(1)
|
|
if os.environ.get("FAKE_SLEEP") and args[0] == "doctor":
|
|
import time
|
|
time.sleep(30)
|
|
"""
|
|
|
|
|
|
def git(root: Path, *args: str) -> None:
|
|
subprocess.run(
|
|
["git", "-c", "user.name=Fixture", "-c", "user.email=f@example.invalid", *args],
|
|
cwd=root, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def checkout(tmp_path: Path, monkeypatch) -> Path:
|
|
root = tmp_path / "checkout"
|
|
(root / "kb" / "Concepts").mkdir(parents=True)
|
|
(root / "raw" / "articles").mkdir(parents=True)
|
|
(root / "tools").mkdir()
|
|
(root / "kb" / "Concepts" / f"{TITLE}.md").write_text("body of the page\n")
|
|
(root / "kb" / "Concepts" / f"{OTHER_TITLE}.md").write_text("more\n")
|
|
(root / "kb" / "Concepts" / "CONTRACT.md").write_text("stack file\n")
|
|
(root / "raw" / "articles" / "Quelle Gamma.md").write_text("source\n")
|
|
(root / "VERSION").write_text("1.2.3\n")
|
|
(root / ".wikitool-upload.json").write_text(
|
|
json.dumps({"schema": 1, "api_token": TOKEN, "enabled": True})
|
|
)
|
|
(root / ".wikitool-tools.json").write_text(
|
|
json.dumps({"schema": 1, "complete": True, "tools": {"python": "/definitely/not/here/python"}})
|
|
)
|
|
launcher = root / "tools" / "wikitool"
|
|
launcher.write_text(LAUNCHER.format(python=sys.executable, title=TITLE))
|
|
launcher.chmod(0o755)
|
|
git(root, "init", "-q", "-b", "main")
|
|
git(root, "remote", "add", "origin", f"https://oauth:{PASSWORD}@git.example.invalid/x/y.git")
|
|
git(root, "add", "-A")
|
|
git(root, "commit", "-q", "-m", "seed")
|
|
(root / "kb" / "Concepts" / f"{TITLE}.md").write_text("changed\n")
|
|
monkeypatch.delenv("WIKI_TRACE_DIR", raising=False)
|
|
monkeypatch.setenv("MY_API_TOKEN", TOKEN)
|
|
monkeypatch.setenv("PATH", os.environ["PATH"])
|
|
return root
|
|
|
|
|
|
def collect(root: Path, tmp_path: Path, *extra: str) -> Path:
|
|
out = tmp_path / "out"
|
|
code = bugreport.main(["--root", str(root), "--out", str(out), *extra])
|
|
assert code == 0
|
|
bundles = [p for p in out.iterdir() if p.is_dir()]
|
|
assert len(bundles) == 1
|
|
return bundles[0]
|
|
|
|
|
|
def all_text(bundle: Path) -> str:
|
|
return "\n".join(
|
|
p.read_text(encoding="utf-8", errors="replace") for p in bundle.rglob("*") if p.is_file()
|
|
)
|
|
|
|
|
|
def test_the_script_is_standard_library_only_and_python38_syntax():
|
|
source = SCRIPT.read_text(encoding="utf-8")
|
|
tree = ast.parse(source, feature_version=(3, 8))
|
|
imported = set()
|
|
for node in ast.walk(tree):
|
|
if isinstance(node, ast.Import):
|
|
imported.update(alias.name.split(".")[0] for alias in node.names)
|
|
elif isinstance(node, ast.ImportFrom) and node.module:
|
|
imported.add(node.module.split(".")[0])
|
|
assert "chemenu" not in imported
|
|
assert imported <= set(sys.stdlib_module_names) if hasattr(sys, "stdlib_module_names") else True
|
|
|
|
|
|
def test_the_script_starts_on_a_bare_interpreter():
|
|
proc = subprocess.run(
|
|
[sys.executable, "-I", "-S", str(SCRIPT), "--help"], capture_output=True, text=True
|
|
)
|
|
assert proc.returncode == 0
|
|
assert "--no-trace" in proc.stdout
|
|
|
|
|
|
def test_a_bundle_and_an_archive_are_written_with_a_manifest(checkout, tmp_path):
|
|
bundle = collect(checkout, tmp_path, "--no-trace")
|
|
for name in ("MANIFEST.md", "environment.json", "stack.json", "tree-structure.json"):
|
|
assert (bundle / name).is_file(), name
|
|
assert (bundle / "wikitool" / "doctor.txt").is_file()
|
|
manifest = (bundle / "MANIFEST.md").read_text()
|
|
assert "not pseudonymised" in manifest
|
|
assert "no chronology was supplied" in manifest
|
|
archive = bundle.with_name(bundle.name + ".zip")
|
|
with zipfile.ZipFile(archive) as zf:
|
|
names = zf.namelist()
|
|
assert f"{bundle.name}/MANIFEST.md" in names
|
|
|
|
|
|
def test_secrets_are_removed_everywhere_including_the_archive(checkout, tmp_path):
|
|
bundle = collect(checkout, tmp_path, "--no-trace")
|
|
text = all_text(bundle)
|
|
assert TOKEN not in text
|
|
assert PASSWORD not in text
|
|
stack = json.loads((bundle / "stack.json").read_text())
|
|
assert stack["config_files"][".wikitool-upload.json"]["api_token"] == "<removed>"
|
|
archive = bundle.with_name(bundle.name + ".zip")
|
|
with zipfile.ZipFile(archive) as zf:
|
|
for name in zf.namelist():
|
|
data = zf.read(name).decode("utf-8", errors="replace")
|
|
assert TOKEN not in data and PASSWORD not in data, name
|
|
|
|
|
|
def test_a_secret_from_a_config_file_is_also_scrubbed_from_a_chronology(checkout, tmp_path):
|
|
chron = tmp_path / "chron.md"
|
|
chron.write_text(f"1. Setup failed with {TOKEN} in the header\n")
|
|
bundle = collect(checkout, tmp_path, "--no-trace", "--chronology", str(chron))
|
|
assert TOKEN not in (bundle / "CHRONOLOGY.md").read_text()
|
|
assert "1. Setup failed" in (bundle / "CHRONOLOGY.md").read_text()
|
|
|
|
|
|
def test_environment_records_every_name_but_values_only_for_the_allowlist(
|
|
checkout, tmp_path, monkeypatch
|
|
):
|
|
monkeypatch.setenv("SOMETHING_PRIVATE", "very-private-value")
|
|
monkeypatch.setenv("WIKI_TRACE", "1")
|
|
monkeypatch.setenv("WIKITOOL_UPDATE_TOKEN", "allowlisted-but-secret")
|
|
bundle = collect(checkout, tmp_path, "--no-trace")
|
|
env = json.loads((bundle / "environment.json").read_text())["env"]
|
|
assert env["SOMETHING_PRIVATE"] == "<not collected>"
|
|
assert env["MY_API_TOKEN"] == "<not collected>"
|
|
assert env["WIKI_TRACE"] == "1"
|
|
assert env["WIKITOOL_UPDATE_TOKEN"] == "<removed>"
|
|
assert "PATH" in env and env["PATH"] == os.environ["PATH"]
|
|
text = all_text(bundle)
|
|
assert "very-private-value" not in text
|
|
assert "allowlisted-but-secret" not in text
|
|
|
|
|
|
def test_page_titles_are_kept_out_by_default_and_kept_with_titles(checkout, tmp_path):
|
|
bundle = collect(checkout, tmp_path, "--no-trace")
|
|
text = all_text(bundle)
|
|
assert TITLE not in text
|
|
assert OTHER_TITLE not in text
|
|
assert "Quelle Gamma" not in text
|
|
assert not (bundle / "tree-paths.txt").exists()
|
|
assert "kb/Concepts/CONTRACT.md" in text # stack-generated names are not titles
|
|
|
|
kept = collect(checkout, tmp_path / "second", "--no-trace", "--titles")
|
|
assert TITLE in all_text(kept)
|
|
assert TITLE in (kept / "tree-paths.txt").read_text()
|
|
|
|
|
|
def test_wikilinks_in_captured_output_lose_their_title(checkout, tmp_path):
|
|
bundle = collect(checkout, tmp_path, "--no-trace")
|
|
doctor = (bundle / "wikitool" / "doctor.txt").read_text()
|
|
assert f"[[{TITLE}]]" not in doctor
|
|
assert "[[<title" in doctor
|
|
|
|
|
|
def test_counting_calls_run_under_the_collectors_own_session_inherited_ones_do_not(
|
|
checkout, tmp_path, monkeypatch
|
|
):
|
|
monkeypatch.setenv("WIKITOOL_SESSION_ID", "callers-session")
|
|
bundle = collect(checkout, tmp_path, "--no-trace")
|
|
own = f"bugreport-{bundle.name.split('bugreport-', 1)[1]}"
|
|
for name in ("instructions-verify", "docs-verify"):
|
|
assert f"session: {own}" in (bundle / "wikitool" / f"{name}.txt").read_text()
|
|
for name in ("version-show", "doctor", "budget-status"):
|
|
assert "session: callers-session" in (bundle / "wikitool" / f"{name}.txt").read_text()
|
|
manifest = (bundle / "MANIFEST.md").read_text()
|
|
assert "callers-session (from WIKITOOL_SESSION_ID)" in manifest
|
|
|
|
|
|
def test_the_callers_trace_is_copied_and_no_trace_leaves_it_out(
|
|
checkout, tmp_path, monkeypatch
|
|
):
|
|
monkeypatch.setenv("WIKITOOL_SESSION_ID", "callers-session")
|
|
trace_dir = checkout / "reports" / "telemetry" / "callers-session"
|
|
trace_dir.mkdir(parents=True)
|
|
(trace_dir / "trace.jsonl").write_text('{"event": "x"}\n')
|
|
other = checkout / "reports" / "telemetry" / "other-session"
|
|
other.mkdir()
|
|
(other / "trace.jsonl").write_text('{"event": "y"}\n')
|
|
|
|
with_trace = collect(checkout, tmp_path / "a")
|
|
assert (with_trace / "trace.jsonl").read_text() == '{"event": "x"}\n'
|
|
assert "may contain page content and titles" in (with_trace / "MANIFEST.md").read_text()
|
|
|
|
without = collect(checkout, tmp_path / "b", "--no-trace")
|
|
assert not (without / "trace.jsonl").exists()
|
|
|
|
|
|
def test_a_missing_trace_lists_the_sessions_that_exist(checkout, tmp_path, monkeypatch):
|
|
monkeypatch.setenv("WIKITOOL_SESSION_ID", "nobody-wrote-this")
|
|
other = checkout / "reports" / "telemetry" / "some-session"
|
|
other.mkdir(parents=True)
|
|
(other / "trace.jsonl").write_text("12345")
|
|
bundle = collect(checkout, tmp_path)
|
|
assert not (bundle / "trace.jsonl").exists()
|
|
manifest = (bundle / "MANIFEST.md").read_text()
|
|
assert "some-session" in manifest and "5 bytes" in manifest
|
|
|
|
|
|
def test_a_bugreport_bucket_is_never_offered_as_a_trace(checkout, monkeypatch):
|
|
monkeypatch.setenv("WIKITOOL_SESSION_ID", "missing")
|
|
base = checkout / "reports" / "telemetry"
|
|
(base / "bugreport-20260101T000000Z").mkdir(parents=True)
|
|
(base / "real-session").mkdir()
|
|
trace = bugreport.collect_trace(checkout, None)
|
|
assert [item["session_directory"] for item in trace["listing"]] == ["real-session"]
|
|
|
|
|
|
def test_a_wikitool_that_does_not_start_still_yields_a_report(checkout, tmp_path, monkeypatch):
|
|
monkeypatch.setenv("FAKE_NOT_STARTING", "1")
|
|
bundle = collect(checkout, tmp_path, "--no-trace")
|
|
assert (bundle / "wikitool" / "version-show.txt").is_file()
|
|
assert not (bundle / "wikitool" / "doctor.txt").exists()
|
|
assert "ModuleNotFoundError" in (bundle / "wikitool" / "version-show.txt").read_text()
|
|
manifest = (bundle / "MANIFEST.md").read_text()
|
|
assert "did not start" in manifest
|
|
assert (bundle / "environment.json").is_file()
|
|
|
|
|
|
def test_no_launcher_at_all_still_yields_a_report(checkout, tmp_path):
|
|
(checkout / "tools" / "wikitool").unlink()
|
|
bundle = collect(checkout, tmp_path, "--no-trace")
|
|
assert not (bundle / "wikitool").exists()
|
|
assert "no launcher" in (bundle / "MANIFEST.md").read_text()
|
|
|
|
|
|
def test_a_hanging_wikitool_call_is_cut_off(checkout, tmp_path, monkeypatch):
|
|
monkeypatch.setattr(bugreport, "TIMEOUT_SECONDS", 1)
|
|
monkeypatch.setenv("FAKE_SLEEP", "1")
|
|
bundle = collect(checkout, tmp_path, "--no-trace")
|
|
assert "timed out after 1 s" in (bundle / "wikitool" / "doctor.txt").read_text()
|
|
assert (bundle / "wikitool" / "docs-verify.txt").is_file()
|
|
|
|
|
|
def test_the_tools_config_path_check_reports_what_is_missing(checkout, tmp_path):
|
|
bundle = collect(checkout, tmp_path, "--no-trace")
|
|
config = json.loads((bundle / "environment.json").read_text())["tools_config"]
|
|
assert config["status"] == "present"
|
|
assert config["content"]["complete"] is True
|
|
assert config["path_checks"] == [
|
|
{"at": "tools.python", "path": "/definitely/not/here/python", "exists": False}
|
|
]
|
|
|
|
|
|
def test_a_missing_chronology_file_is_an_error_and_writes_nothing(checkout, tmp_path):
|
|
out = tmp_path / "out"
|
|
code = bugreport.main(
|
|
["--root", str(checkout), "--out", str(out), "--chronology", str(tmp_path / "nope.md")]
|
|
)
|
|
assert code == 1
|
|
assert not out.exists() or not list(out.iterdir())
|
|
|
|
|
|
def test_session_and_no_trace_exclude_each_other(checkout, tmp_path):
|
|
with pytest.raises(SystemExit):
|
|
bugreport.main(["--root", str(checkout), "--session", "x", "--no-trace"])
|
|
|
|
|
|
def test_transcripts_are_added_and_scrubbed(checkout, tmp_path):
|
|
transcript = tmp_path / "t.jsonl"
|
|
transcript.write_text(f'{{"msg": "token {TOKEN}"}}\n')
|
|
bundle = collect(checkout, tmp_path, "--no-trace", "--transcript", str(transcript))
|
|
copied = (bundle / "transcripts" / "t.jsonl").read_text()
|
|
assert TOKEN not in copied
|
|
|
|
|
|
def test_two_bundles_in_the_same_second_do_not_collide(checkout, tmp_path, monkeypatch):
|
|
monkeypatch.setattr(bugreport, "stamp_now", lambda: "20260101T000000Z")
|
|
out = tmp_path / "out"
|
|
assert bugreport.main(["--root", str(checkout), "--out", str(out), "--no-trace"]) == 0
|
|
assert bugreport.main(["--root", str(checkout), "--out", str(out), "--no-trace"]) == 0
|
|
assert sorted(p.name for p in out.iterdir() if p.is_dir()) == [
|
|
"bugreport-20260101T000000Z", "bugreport-20260101T000000Z-2",
|
|
]
|
|
|
|
|
|
def test_the_collector_leaves_the_checkout_untouched(checkout, tmp_path):
|
|
def snapshot():
|
|
return sorted(
|
|
(p.relative_to(checkout).as_posix(), p.stat().st_mtime_ns)
|
|
for p in checkout.rglob("*")
|
|
if p.is_file() and ".git/" not in p.as_posix()
|
|
)
|
|
|
|
before = snapshot()
|
|
collect(checkout, tmp_path, "--no-trace")
|
|
assert snapshot() == before
|
|
|
|
|
|
def test_the_collector_ships_with_a_distribution():
|
|
from chemenu.commands import dist_cmd
|
|
|
|
assert "tools/bugreport.py" in dist_cmd.build_plan()
|
|
|
|
|
|
# ------------------------------------------------------------------ pseudonymisation
|
|
|
|
REMOTE = "C:\\Users\\Max.Muster\\OneDrive - Beispiel GmbH\\x.git"
|
|
REPO_ROOT = Path(SCRIPT).resolve().parent.parent
|
|
|
|
|
|
def machine(checkout: Path, monkeypatch, user: str = "Max") -> None:
|
|
for name in ("USER", "USERNAME", "LOGNAME"):
|
|
monkeypatch.setenv(name, user)
|
|
monkeypatch.setenv("COMPUTERNAME", "DESKTOP-Zx91Q")
|
|
monkeypatch.setenv("USERDOMAIN", "AB")
|
|
monkeypatch.setattr(bugreport.socket, "gethostname", lambda: "Rechner-Delta")
|
|
git(checkout, "config", "user.name", "Erika Mustermann")
|
|
git(checkout, "config", "user.email", "erika@beispiel-firma.example")
|
|
remotes = subprocess.run(["git", "remote"], cwd=checkout, capture_output=True, text=True).stdout
|
|
if "corp" not in remotes.split():
|
|
git(checkout, "remote", "add", "corp", REMOTE)
|
|
|
|
|
|
def pseudonymised(checkout, tmp_path, monkeypatch, *extra, user="Max", name="out"):
|
|
machine(checkout, monkeypatch, user)
|
|
out = tmp_path / name
|
|
assert bugreport.main(
|
|
["--root", str(checkout), "--out", str(out), "--no-trace", "--pseudonymise", *extra]
|
|
) == 0
|
|
return next(p for p in out.iterdir() if p.is_dir())
|
|
|
|
|
|
def zip_text(bundle: Path) -> str:
|
|
archive = bundle.with_name(bundle.name + ".zip")
|
|
with zipfile.ZipFile(archive) as zf:
|
|
return "\n".join(zf.read(n).decode("utf-8", errors="replace") for n in zf.namelist())
|
|
|
|
|
|
def mapping_of(bundle: Path) -> Path:
|
|
return bundle.with_name(bundle.name + ".pseudonyms.json")
|
|
|
|
|
|
def snapshot(bundle: Path) -> dict:
|
|
return {p.relative_to(bundle).as_posix(): p.read_bytes() for p in bundle.rglob("*") if p.is_file()}
|
|
|
|
|
|
def test_a_remote_keeps_its_shape_and_loses_its_names(checkout, tmp_path, monkeypatch):
|
|
bundle = pseudonymised(checkout, tmp_path, monkeypatch)
|
|
assert "Max.Muster" not in all_text(bundle) and "Max.Muster" not in zip_text(bundle)
|
|
assert "Beispiel" not in all_text(bundle)
|
|
remotes = json.loads((bundle / "environment.json").read_text())["git"]["remotes"]
|
|
line = next(r for r in remotes if r.startswith("corp"))
|
|
original = f"corp\t{REMOTE} (fetch)".split("\\")
|
|
shaped = line.split("\\")
|
|
assert len(shaped) == len(original)
|
|
assert [len(part) for part in shaped] == [len(part) for part in original]
|
|
assert shaped[1] == "Users"
|
|
assert shaped[2][3] == "." and shaped[2] != "Max.Muster"
|
|
assert shaped[3].startswith("OneDrive - ") and shaped[3].endswith(" GmbH")
|
|
assert shaped[4].endswith(".git (fetch)")
|
|
|
|
|
|
def test_one_identity_gets_one_placeholder_in_every_file_and_form(checkout, tmp_path, monkeypatch):
|
|
name = "Müller-Lüdenscheidt"
|
|
monkeypatch.setenv("PATH", "/home/%s/bin%s%s" % (name, os.pathsep, os.environ["PATH"]))
|
|
trace_dir = checkout / "reports" / "telemetry" / "callers-session"
|
|
trace_dir.mkdir(parents=True)
|
|
(trace_dir / "trace.jsonl").write_text(json.dumps({"p": "/home/" + name}) + "\n")
|
|
monkeypatch.setenv("WIKITOOL_SESSION_ID", "callers-session")
|
|
chron = tmp_path / "chron.md"
|
|
chron.write_text("user %s and %s\n" % (name, name.upper()))
|
|
machine(checkout, monkeypatch, user=name)
|
|
out = tmp_path / "out"
|
|
assert bugreport.main(["--root", str(checkout), "--out", str(out), "--pseudonymise",
|
|
"--chronology", str(chron)]) == 0
|
|
bundle = next(p for p in out.iterdir() if p.is_dir())
|
|
env_entry = json.loads((bundle / "environment.json").read_text())["path_entries"][0]["entry"]
|
|
from_env = env_entry.split("/")[2]
|
|
from_trace = json.loads((bundle / "trace.jsonl").read_text())["p"].split("/")[2]
|
|
lines = (bundle / "CHRONOLOGY.md").read_text().split()
|
|
assert from_env == from_trace == lines[1]
|
|
assert lines[3] == from_env.upper()
|
|
assert from_env != name and len(from_env) == len(name)
|
|
for original, shaped in zip(name, from_env):
|
|
assert (original == "-") == (shaped == "-")
|
|
assert original.isascii() == shaped.isascii()
|
|
assert original.isupper() == shaped.isupper()
|
|
assert name not in all_text(bundle) and "M\\u00fcller" not in all_text(bundle)
|
|
|
|
|
|
def test_placeholders_keep_length_classes_and_separators_and_are_injective():
|
|
pz = bugreport.Pseudonymiser(salt="00" * 16)
|
|
assert pz.add_identity("Müller-Lüdenscheidt 42", "user name")
|
|
pz.finish()
|
|
placeholder = pz.entries[0]["placeholder"]
|
|
original = pz.entries[0]["original"]
|
|
assert len(placeholder) == len(original)
|
|
for a, b in zip(original, placeholder):
|
|
assert a.isalpha() == b.isalpha() and a.isdigit() == b.isdigit()
|
|
assert a.isascii() == b.isascii() and a.isupper() == b.isupper()
|
|
if not a.isalnum():
|
|
assert a == b
|
|
assert pz.pseudo_text("Users GmbH") == "Users GmbH"
|
|
words = ["name%03d" % i for i in range(300)]
|
|
shaped = {pz.pseudo_word(w) for w in words}
|
|
assert len(shaped) == len(words)
|
|
assert not shaped & set(words)
|
|
|
|
|
|
def test_an_identity_matches_whole_words_only(checkout, tmp_path, monkeypatch):
|
|
chron = tmp_path / "chron.md"
|
|
chron.write_text("Maximum and Max_1 and Max here, Maxine too\n")
|
|
bundle = pseudonymised(checkout, tmp_path, monkeypatch, "--chronology", str(chron))
|
|
text = (bundle / "CHRONOLOGY.md").read_text()
|
|
assert "Maximum" in text and "Maxine" in text
|
|
assert text.count("Max") == 2 # "Maximum" and "Maxine" contain none at a word end; Max_1 and Max are replaced
|
|
|
|
|
|
def test_stage_two_applies_candidates_with_the_same_word_and_is_idempotent(
|
|
checkout, tmp_path, monkeypatch, capsys
|
|
):
|
|
chron = tmp_path / "chron.md"
|
|
chron.write_text("Firma Beispiel GmbH hat Zugriff. Kunde Acme Corp. Acme Corp zahlt.\n")
|
|
bundle = pseudonymised(checkout, tmp_path, monkeypatch, "--chronology", str(chron))
|
|
assert "Beispiel GmbH" in (bundle / "CHRONOLOGY.md").read_text()
|
|
remotes = json.loads((bundle / "environment.json").read_text())["git"]["remotes"]
|
|
stage_one_word = next(r for r in remotes if r.startswith("corp")).split("\\")[3].split()[2]
|
|
candidates = tmp_path / "candidates.txt"
|
|
candidates.write_text("# from the model\nBeispiel GmbH\nAcme Corp # customer\nab\nUsers\nnowhere at all\n")
|
|
capsys.readouterr()
|
|
assert bugreport.main(["--bundle", str(bundle), "--candidates", str(candidates)]) == 0
|
|
printed = capsys.readouterr().out
|
|
for needle in ("not applied: ab", "not applied: Users", "not applied: nowhere at all",
|
|
"100 KB"):
|
|
assert needle in printed
|
|
chronology = (bundle / "CHRONOLOGY.md").read_text()
|
|
assert "Acme" not in chronology and "Beispiel" not in chronology
|
|
assert chronology.startswith("Firma %s GmbH hat" % stage_one_word)
|
|
assert "Acme" not in zip_text(bundle) and "Beispiel" not in zip_text(bundle)
|
|
first = snapshot(bundle)
|
|
mapping_before = mapping_of(bundle).read_bytes()
|
|
assert bugreport.main(["--bundle", str(bundle), "--candidates", str(candidates)]) == 0
|
|
assert snapshot(bundle) == first
|
|
assert mapping_of(bundle).read_bytes() == mapping_before
|
|
|
|
|
|
def test_the_mapping_review_and_candidates_stay_beside_the_bundle_and_stage_two_needs_the_mapping(
|
|
checkout, tmp_path, monkeypatch, capsys
|
|
):
|
|
bundle = pseudonymised(checkout, tmp_path, monkeypatch)
|
|
review = bundle.with_name(bundle.name + ".review.txt")
|
|
assert mapping_of(bundle).is_file() and review.is_file()
|
|
assert "Max.Muster" in mapping_of(bundle).read_text()
|
|
archive = bundle.with_name(bundle.name + ".zip")
|
|
with zipfile.ZipFile(archive) as zf:
|
|
assert not [n for n in zf.namelist() if "pseudonyms" in n or "review" in n]
|
|
assert not [p for p in bundle.rglob("*") if "pseudonyms" in p.name or "review" in p.name]
|
|
|
|
candidates = tmp_path / "candidates.txt"
|
|
candidates.write_text("Erika\n")
|
|
mapping_of(bundle).unlink()
|
|
before, archive_before = snapshot(bundle), archive.read_bytes()
|
|
assert bugreport.main(["--bundle", str(bundle), "--candidates", str(candidates)]) == 1
|
|
assert "collect the report again" in capsys.readouterr().err
|
|
assert snapshot(bundle) == before and archive.read_bytes() == archive_before
|
|
|
|
|
|
def test_the_manifest_has_three_privacy_versions_and_never_an_original(
|
|
checkout, tmp_path, monkeypatch
|
|
):
|
|
plain = collect(checkout, tmp_path / "plain", "--no-trace")
|
|
assert "not pseudonymised" in (plain / "MANIFEST.md").read_text()
|
|
assert "## Pseudonyms" not in (plain / "MANIFEST.md").read_text()
|
|
|
|
bundle = pseudonymised(checkout, tmp_path, monkeypatch)
|
|
manifest = (bundle / "MANIFEST.md").read_text()
|
|
assert "stage 1 applied, stage 2 not yet applied" in manifest
|
|
assert "| Placeholder | Kind | Stage | Occurrences |" in manifest
|
|
for original in ("Max.Muster", "Beispiel", "Rechner-Delta", "Mustermann", "beispiel-firma"):
|
|
assert original not in manifest
|
|
|
|
candidates = tmp_path / "candidates.txt"
|
|
candidates.write_text("Erika\n")
|
|
assert bugreport.main(["--bundle", str(bundle), "--candidates", str(candidates)]) == 0
|
|
manifest = (bundle / "MANIFEST.md").read_text()
|
|
assert "stage 1 and stage 2 applied" in manifest
|
|
assert "judgement of a model" in manifest and "100 KB" in manifest
|
|
assert "Erika" not in manifest
|
|
instruction = (REPO_ROOT / "instructions" / "bug-report.md").read_text()
|
|
assert "100 KB" in instruction and "residual" in instruction.lower()
|
|
|
|
|
|
def test_two_bundles_of_one_machine_use_different_placeholders(checkout, tmp_path, monkeypatch):
|
|
monkeypatch.setattr(bugreport, "stamp_now", lambda: "20260101T000000Z")
|
|
first = pseudonymised(checkout, tmp_path, monkeypatch, name="a")
|
|
second = pseudonymised(checkout, tmp_path, monkeypatch, name="b")
|
|
|
|
def shaped(bundle):
|
|
remotes = json.loads((bundle / "environment.json").read_text())["git"]["remotes"]
|
|
return next(r for r in remotes if r.startswith("corp")).split("\\")[2]
|
|
|
|
assert shaped(first) != shaped(second)
|
|
|
|
|
|
def test_the_public_origin_short_and_vocabulary_identities_stay_readable(
|
|
checkout, tmp_path, monkeypatch
|
|
):
|
|
git(checkout, "remote", "add", "pub", "https://gitea.nehmer.net/torben/chemenu.git")
|
|
chron = tmp_path / "chron.md"
|
|
chron.write_text("torben was here; AB and DESKTOP stay\n")
|
|
bundle = pseudonymised(checkout, tmp_path, monkeypatch, "--chronology", str(chron), user="torben")
|
|
text = (bundle / "environment.json").read_text()
|
|
assert "gitea.nehmer.net/torben/chemenu.git" in text
|
|
chronology = (bundle / "CHRONOLOGY.md").read_text()
|
|
assert chronology.startswith("torben was") is False
|
|
assert "AB and DESKTOP stay" in chronology
|
|
manifest = (bundle / "MANIFEST.md").read_text()
|
|
assert int(manifest.split(" identities were left unchanged")[0].split()[-1]) >= 2
|
|
|
|
|
|
def test_bundle_and_candidates_exclude_the_collection_options(checkout, tmp_path):
|
|
for extra in (["--no-trace"], ["--titles"], ["--pseudonymise"], ["--root", str(checkout)]):
|
|
with pytest.raises(SystemExit):
|
|
bugreport.main(["--bundle", "b", "--candidates", "c", *extra])
|
|
with pytest.raises(SystemExit):
|
|
bugreport.main(["--bundle", "b"])
|
|
with pytest.raises(SystemExit):
|
|
bugreport.main(["--candidates", "c"])
|
|
|
|
|
|
def test_without_the_flag_nothing_but_the_bundle_and_its_archive_is_written(checkout, tmp_path):
|
|
collect(checkout, tmp_path, "--no-trace")
|
|
assert sorted(p.suffix for p in (tmp_path / "out").iterdir()) == ["", ".zip"]
|