jobbsok/tests/test_sak_status_cli.py
Kjell Tore Guttormsen 12125c1932 feat(m2): add deterministic sak_status cli with golden output
--workspace is required with no environment fall-back: this process runs
unsandboxed, and a status run that quietly picked a workspace is one nobody
can audit. --check exits 1 on a stale cache, distinct from 2 for a failed run.

Byte-stability is measured, not asserted: the CLI is run twice under two
PYTHONHASHSEED values and the bytes compared, in both output formats. The
write boundary gets two digest tests rather than one -- without --oppdater
every byte stays put, with it exactly the diverging case's four cached keys
move and logg.jsonl, the body and every other file are byte-identical.

Co-Authored-By: Claude <claude-opus-5>
2026-09-05 21:40:32 +02:00

154 lines
6.2 KiB
Python

"""The sak_status command line, and what it is allowed to write (plan Step 20).
Two properties are asserted here that no unit test can reach, because both are
about the process rather than the function.
**Byte-stability.** The golden file is only evidence if the same workspace and
the same `--today` produce the same bytes every time, including under a
different `PYTHONHASHSEED`. Python randomises string hashing per process, so a
dictionary or a set that reached stdout would reorder between runs and the
golden would fail intermittently -- the worst way for a determinism bug to
present itself. Running the CLI twice under two seeds and comparing bytes is
what turns "no dict iteration order reaches stdout" from an intention into a
measurement.
**The write boundary.** The read paths write nothing, and `--oppdater` is the
one exception -- not a caveat, a named mode. So there are two digest tests,
not one: a run without the flag leaves every byte in the workspace where it
was, and a run with it changes exactly the four cached frontmatter keys and
leaves `logg.jsonl`, the `sak.md` body and every other file byte-identical.
Saying "the script writes nothing" without that second test is what let the
write-back go unnoticed in the first place.
Style note: this file follows tests/test_sak_status_silence.py.
"""
import hashlib
import json
import os
import subprocess
import sys
import pytest
import sak_status
from jobbsok_lib import frontmatter as frontmatter_lib
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
CLI = os.path.join(REPO, "scripts", "sak_status.py")
KORPUS = os.path.join(REPO, "tests", "fixtures", "workspace")
GOLDEN = os.path.join(REPO, "tests", "golden", "sak-status-2026-09-15.json")
TODAY = "2026-09-15"
#: The case whose cached frontmatter is deliberately wrong (Step 19).
UENIG = "2026-09-havbris-energi-as-losningsarkitekt"
def kjor(*argv, **kwargs):
"""Run the CLI as a process and return (exit code, stdout, stderr) bytes."""
env = dict(os.environ)
env.pop("JOBBSOK_WORKSPACE", None)
env.pop("CLAUDE_PLUGIN_DATA", None)
env.update(kwargs.get("env", {}))
proc = subprocess.run(
[sys.executable, CLI] + list(argv),
stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env,
)
return proc.returncode, proc.stdout, proc.stderr
def digest(root):
"""Every file under ``root`` mapped to the sha256 of its bytes."""
ut = {}
for dirpath, dirnames, filenames in os.walk(root):
dirnames.sort()
for navn in sorted(filenames):
full = os.path.join(dirpath, navn)
with open(full, "rb") as handle:
ut[os.path.relpath(full, root)] = hashlib.sha256(handle.read()).hexdigest()
return ut
def test_the_json_output_matches_the_golden_file(golden):
kode, ut, feil = kjor("--workspace", KORPUS, "--today", TODAY, "--format", "json")
assert kode == 0, feil.decode("utf-8")
golden(GOLDEN, ut.decode("utf-8"))
def test_two_consecutive_runs_are_byte_identical():
_kode, forste, _ = kjor("--workspace", KORPUS, "--today", TODAY, "--format", "json")
_kode, andre, _ = kjor("--workspace", KORPUS, "--today", TODAY, "--format", "json")
assert forste == andre
def test_two_different_hash_seeds_produce_the_same_bytes():
kwargs = ("--workspace", KORPUS, "--today", TODAY, "--format", "json")
_kode, null, _ = kjor(*kwargs, env={"PYTHONHASHSEED": "0"})
_kode, mange, _ = kjor(*kwargs, env={"PYTHONHASHSEED": "1048573"})
assert null == mange, (
"output moved with PYTHONHASHSEED, so a dict or a set reached stdout"
)
# And the text form too, which is the one the operator actually reads.
tekst = ("--workspace", KORPUS, "--today", TODAY)
_kode, a, _ = kjor(*tekst, env={"PYTHONHASHSEED": "0"})
_kode, b, _ = kjor(*tekst, env={"PYTHONHASHSEED": "1048573"})
assert a == b
def test_omitting_workspace_is_an_error_and_never_a_guess_at_home():
kode, ut, feil = kjor("--today", TODAY)
assert kode != 0
assert b"--workspace" in feil
assert os.path.expanduser("~").encode("utf-8") not in ut + feil, (
"the refusal leaked a home directory; there is no default workspace"
)
def test_a_traversing_case_identifier_is_refused():
kode, _ut, feil = kjor(
"--workspace", KORPUS, "--today", TODAY, "--sak", "../../etc/passwd"
)
assert kode != 0
assert b"../../etc/passwd" in feil
def test_a_run_without_oppdater_leaves_every_byte_where_it_was(workspace):
forst = digest(workspace)
kode, _ut, feil = kjor("--workspace", workspace, "--today", TODAY, "--format", "json")
assert kode == 0, feil.decode("utf-8")
assert digest(workspace) == forst, "the read path wrote something"
def test_a_run_with_oppdater_changes_only_the_four_cached_keys(workspace):
sti = os.path.join(workspace, "saker", UENIG, "sak.md")
with open(sti, "r", encoding="utf-8") as handle:
for_meta, for_body = frontmatter_lib.parse(handle.read())
forst = digest(workspace)
kode, _ut, feil = kjor("--workspace", workspace, "--today", TODAY, "--oppdater")
assert kode == 0, feil.decode("utf-8")
etter = digest(workspace)
endrede = sorted(navn for navn in forst if forst[navn] != etter[navn])
assert endrede == [os.path.join("saker", UENIG, "sak.md")], (
"--oppdater touched %r; only the diverging case's sak.md may move"
% (endrede,)
)
assert sorted(etter) == sorted(forst), "--oppdater added or removed a file"
with open(sti, "r", encoding="utf-8") as handle:
etter_meta, etter_body = frontmatter_lib.parse(handle.read())
assert etter_body == for_body
flyttet = sorted(k for k in for_meta if for_meta[k] != etter_meta.get(k))
assert flyttet == ["status", "ventende_part"]
assert set(flyttet) <= set(sak_status.CACHE_KEYS)
def test_check_exits_non_zero_on_the_deliberately_divergent_fixture(workspace):
kode, ut, _feil = kjor("--workspace", KORPUS, "--today", TODAY, "--check")
assert kode != 0
assert UENIG.encode("utf-8") in ut, "the check must name the case that diverged"
kjor("--workspace", workspace, "--today", TODAY, "--oppdater")
kode, _ut, _feil = kjor("--workspace", workspace, "--today", TODAY, "--check")
assert kode == 0, "a workspace that has just been refreshed must check clean"