"""The workspace path contract every later script depends on (plan Step 3). Two risks are being tested here, not one. Risk C6: the host-side server runs unsandboxed with caller-supplied paths, so a path that escapes the workspace escapes onto the operator's machine -- traversal and symlink escape are the two ways out and both are exercised below. Risk H8: macOS hands back filenames in NFD, so a name that looks identical to an NFC one is a different byte string; every generated path component is asserted to be pure ASCII rather than merely looking right. The third theme is refusing to guess. There is no implicit default workspace: if none of the three resolution sources answers, that is an error, never a silent write into the operator's home directory. Style note: this file follows tests/test_toolchain.py. """ import os import pytest from jobbsok_lib import paths def test_traversal_is_refused(tmp_path): root = str(tmp_path / "ws") os.makedirs(root) with pytest.raises(paths.WorkspaceEscape): paths.safe_join(root, "..", "..", "etc", "passwd") def test_symlink_escape_is_refused(tmp_path): root = str(tmp_path / "ws") outside = str(tmp_path / "outside") os.makedirs(root) os.makedirs(outside) # A link that lives inside the workspace but resolves outside it. A # lexical check on the joined string would call this safe; realpath does # not, which is the whole reason safe_join resolves before it compares. os.symlink(outside, os.path.join(root, "escape")) with pytest.raises(paths.WorkspaceEscape): paths.safe_join(root, "escape", "stolen.md") def test_missing_workspace_raises_rather_than_defaulting(tmp_path, monkeypatch): monkeypatch.delenv("JOBBSOK_WORKSPACE", raising=False) monkeypatch.setenv("CLAUDE_PLUGIN_DATA", str(tmp_path / "no-such-data-dir")) with pytest.raises(paths.WorkspaceUnresolved): paths.workspace_root(None) def test_slug_of_norwegian_place_name_is_pure_ascii(): # The three folds the contract names, each in a real place name. assert paths.slug("Tromsø og Bodø") == "tromso-og-bodo" assert paths.slug("Målselv") == "malselv" assert paths.slug("Ærøy Bærum") == "aeroy-baerum" # And the same word arriving decomposed (what macOS hands back) must # fold to the identical slug as the composed form -- risk H8. composed = "Målselv" decomposed = "Målselv" assert composed != decomposed assert paths.slug(composed) == paths.slug(decomposed) == "malselv" def test_every_generated_path_component_is_ascii(): messy = "Blåbær & Sønn AS — Seniør Rådgiver (København)" generated = paths.slug(messy) assert generated.isascii(), generated assert generated == generated.strip("-") assert "--" not in generated for component in paths.sak_dirname("2026-09", "Blåbær AS", "Seniør Rådgiver").split("/"): assert component.isascii(), component def test_colliding_slugs_get_distinct_suffixes(): taken = set() first = paths.slug("Bergen Kommune", taken=taken) taken.add(first) second = paths.slug("bergen kommune!", taken=taken) taken.add(second) third = paths.slug("BERGEN-KOMMUNE", taken=taken) assert first == "bergen-kommune" assert second == "bergen-kommune-2" assert third == "bergen-kommune-3" assert len({first, second, third}) == 3 def test_scaffold_creates_every_section_5_directory_and_gitignore(tmp_path): root = str(tmp_path / "jobbsok-workspace") paths.scaffold(root) for directory in paths.WORKSPACE_DIRS: assert os.path.isdir(os.path.join(root, directory)), directory for filename in paths.WORKSPACE_FILES: assert os.path.isfile(os.path.join(root, filename)), filename # beslutninger.jsonl is append-only and starts empty, not absent: a missing # file and an empty one read very differently at the first append. assert os.path.getsize(os.path.join(root, "beslutninger.jsonl")) == 0 gitignore = os.path.join(root, ".gitignore") with open(gitignore, "r", encoding="utf-8") as handle: assert "*" in handle.read() def test_scaffold_is_idempotent_over_a_populated_workspace(tmp_path): root = str(tmp_path / "jobbsok-workspace") paths.scaffold(root) # Populate it the way real use would, including the two files scaffold # itself creates -- an idempotent scaffold must not truncate them. with open(os.path.join(root, "beslutninger.jsonl"), "w", encoding="utf-8") as handle: handle.write('{"sak_id": "2026-09-eksempel-as-radgiver", "beslutning": "soker"}\n') with open(os.path.join(root, "referanser.md"), "w", encoding="utf-8") as handle: handle.write("# Referanser\n\nEn kontakt.\n") os.makedirs(os.path.join(root, "saker", "2026-09-eksempel-as-radgiver")) before = _snapshot(root) paths.scaffold(root) assert _snapshot(root) == before def _snapshot(root): """Every file under root as {relative path: bytes}.""" out = {} for dirpath, _dirnames, filenames in os.walk(root): for name in filenames: full = os.path.join(dirpath, name) with open(full, "rb") as handle: out[os.path.relpath(full, root)] = handle.read() return out