diff --git a/wizard-work/beam/test_manifest.py b/wizard-work/beam/test_manifest.py new file mode 100644 index 0000000..1bf9510 --- /dev/null +++ b/wizard-work/beam/test_manifest.py @@ -0,0 +1,243 @@ +#!/usr/bin/env python3 +"""Cells for manifest.py: the 0/1/2 contract, dir symlinks, and self-skip. + +Every cell runs the committed module through the CLI in a subprocess, so the +exit code is the thing under test, not an in-process return value. The module +path resolves from this file's own directory, never a host path. +""" +import json +import os +import shutil +import subprocess +import sys +import tempfile +import unittest + +MOD = os.path.join(os.path.dirname(os.path.abspath(__file__)), "manifest.py") + +FILES = { + "a.txt": b"hello", + "sub/b.bin": b"\x00\x01\x02", + "c.txt": b"world", +} + + +class ManifestTest(unittest.TestCase): + def setUp(self): + self.tmp = [] + + def tearDown(self): + for d in self.tmp: + shutil.rmtree(d, ignore_errors=True) + + def _scratch(self, prefix="manifest-test-"): + d = tempfile.mkdtemp(prefix=prefix) + self.tmp.append(d) + return d + + def _tree(self): + d = self._scratch("manifest-tree-") + for rel, data in FILES.items(): + full = os.path.join(d, rel) + os.makedirs(os.path.dirname(full), exist_ok=True) + with open(full, "wb") as f: + f.write(data) + return d + + def _run(self, *args): + return subprocess.run([sys.executable, MOD, *args], + capture_output=True, text=True) + + def _manifest(self, root): + out = os.path.join(self._scratch("manifest-out-"), "m.json") + r = self._run("make", root, out) + self.assertEqual(r.returncode, 0, r.stderr + r.stdout) + return out + + # -- 0: clean --------------------------------------------------------- + + def test_roundtrip_is_clean(self): + root = self._tree() + man = self._manifest(root) + r = self._run("check", root, man) + self.assertEqual(r.returncode, 0, r.stderr + r.stdout) + self.assertIn("OK: manifest matches", r.stdout) + + def test_make_counts_every_file(self): + root = self._tree() + man = self._manifest(root) + with open(man) as f: + doc = json.load(f) + self.assertEqual(sorted(e["path"] for e in doc["entries"]), sorted(FILES)) + self.assertTrue(all(e["type"] == "file" for e in doc["entries"])) + + # -- 1: graded fault -------------------------------------------------- + + def test_tampered_content_exits_1(self): + root = self._tree() + man = self._manifest(root) + with open(os.path.join(root, "a.txt"), "wb") as f: + f.write(b"tampered") + r = self._run("check", root, man) + self.assertEqual(r.returncode, 1, r.stderr) + self.assertIn("HASH_MISMATCH: a.txt", r.stdout) + + def test_extra_file_exits_1(self): + root = self._tree() + man = self._manifest(root) + with open(os.path.join(root, "extra.txt"), "w") as f: + f.write("x") + r = self._run("check", root, man) + self.assertEqual(r.returncode, 1, r.stderr) + self.assertIn("EXTRA: extra.txt", r.stdout) + + def test_missing_file_exits_1(self): + root = self._tree() + man = self._manifest(root) + os.remove(os.path.join(root, "sub", "b.bin")) + r = self._run("check", root, man) + self.assertEqual(r.returncode, 1, r.stderr) + self.assertIn("MISSING: sub/b.bin", r.stdout) + + # -- 1: directory symlinks are entries, not invisible ------------------ + + def test_dir_symlink_added_after_make_exits_1(self): + root = self._tree() + man = self._manifest(root) + outside = self._scratch("manifest-outside-") + with open(os.path.join(outside, "secret.txt"), "w") as f: + f.write("not in the manifest") + os.symlink(outside, os.path.join(root, "linked_dir")) + r = self._run("check", root, man) + self.assertEqual(r.returncode, 1, r.stderr) + self.assertIn("EXTRA: linked_dir", r.stdout) + self.assertNotIn("secret.txt", r.stdout) + + def test_dir_symlink_removed_after_make_exits_1(self): + root = self._tree() + os.symlink(self._scratch("manifest-outside-"), os.path.join(root, "linked_dir")) + man = self._manifest(root) + os.remove(os.path.join(root, "linked_dir")) + r = self._run("check", root, man) + self.assertEqual(r.returncode, 1, r.stderr) + self.assertIn("MISSING: linked_dir", r.stdout) + + def test_dir_symlink_recorded_with_target(self): + root = self._tree() + outside = self._scratch("manifest-outside-") + os.symlink(outside, os.path.join(root, "linked_dir")) + man = self._manifest(root) + with open(man) as f: + doc = json.load(f) + row = [e for e in doc["entries"] if e["path"] == "linked_dir"] + self.assertEqual(len(row), 1) + self.assertEqual(row[0]["type"], "symlink") + self.assertEqual(row[0]["target"], outside) + r = self._run("check", root, man) + self.assertEqual(r.returncode, 0, r.stderr + r.stdout) + + def test_dir_symlink_is_not_descended(self): + root = self._tree() + os.symlink(os.path.join(root, "sub"), os.path.join(root, "looplink")) + man = self._manifest(root) + with open(man) as f: + doc = json.load(f) + paths = [e["path"] for e in doc["entries"]] + self.assertIn("looplink", paths) + self.assertFalse([p for p in paths if p.startswith("looplink" + os.sep)]) + r = self._run("check", root, man) + self.assertEqual(r.returncode, 0, r.stderr + r.stdout) + + # -- 0: a manifest stored inside its own root ------------------------- + + def test_manifest_inside_root_is_not_an_entry(self): + root = self._tree() + man = os.path.join(root, "MANIFEST.json") + r = self._run("make", root, man) + self.assertEqual(r.returncode, 0, r.stderr) + with open(man) as f: + first = json.load(f) + self.assertNotIn("MANIFEST.json", [e["path"] for e in first["entries"]]) + self.assertEqual(len(first["entries"]), len(FILES)) + # A second make must not grow the entry set, nor change a byte. + with open(man, "rb") as f: + before = f.read() + r = self._run("make", root, man) + self.assertEqual(r.returncode, 0, r.stderr) + with open(man, "rb") as f: + self.assertEqual(f.read(), before) + with open(man) as f: + self.assertEqual(len(json.load(f)["entries"]), len(FILES)) + + def test_manifest_inside_root_checks_clean(self): + root = self._tree() + man = os.path.join(root, "MANIFEST.json") + self._run("make", root, man) + r = self._run("check", root, man) + self.assertEqual(r.returncode, 0, r.stderr + r.stdout) + self.assertIn("OK: manifest matches", r.stdout) + + # -- 2: ungradable ---------------------------------------------------- + + def test_missing_root_make_exits_2_and_writes_nothing(self): + out = os.path.join(self._scratch("manifest-out-"), "m.json") + r = self._run("make", "/nonexistent-root-manifest-test", out) + self.assertEqual(r.returncode, 2, r.stderr + r.stdout) + self.assertIn("ungradable:", r.stderr) + self.assertFalse(os.path.exists(out)) + + def test_missing_root_check_exits_2(self): + root = self._tree() + man = self._manifest(root) + r = self._run("check", "/nonexistent-root-manifest-test", man) + self.assertEqual(r.returncode, 2, r.stderr + r.stdout) + self.assertIn("ungradable:", r.stderr) + + def test_root_that_is_a_file_exits_2(self): + file_root = os.path.join(self._scratch("manifest-out-"), "not-a-dir") + with open(file_root, "w") as f: + f.write("x") + out = os.path.join(self._scratch("manifest-out-"), "m.json") + r = self._run("make", file_root, out) + self.assertEqual(r.returncode, 2, r.stderr + r.stdout) + self.assertFalse(os.path.exists(out)) + root = self._tree() + man = self._manifest(root) + r = self._run("check", file_root, man) + self.assertEqual(r.returncode, 2, r.stderr + r.stdout) + + def test_corrupt_manifest_exits_2(self): + root = self._tree() + bad = self._scratch("manifest-bad-") + cases = { + "not-json.json": "{not json", + "empty-object.json": "{}", + "entries-not-list.json": '{"entries": "nope"}', + "unknown-type.json": '{"entries": [{"path": "a.txt", "type": "weird"}]}', + "file-without-hash.json": '{"entries": [{"path": "a.txt", "type": "file"}]}', + "entry-not-object.json": '{"entries": ["a.txt"]}', + } + for name, body in cases.items(): + path = os.path.join(bad, name) + with open(path, "w") as f: + f.write(body) + r = self._run("check", root, path) + self.assertEqual(r.returncode, 2, "%s -> %d: %s" % (name, r.returncode, r.stdout)) + + def test_unreadable_manifest_exits_2(self): + root = self._tree() + r = self._run("check", root, os.path.join(self._scratch(), "no-such.json")) + self.assertEqual(r.returncode, 2, r.stderr + r.stdout) + self.assertIn("ungradable:", r.stderr) + + def test_bad_usage_exits_2(self): + r = self._run() + self.assertEqual(r.returncode, 2) + self.assertIn("usage:", r.stderr) + r = self._run("frobnicate", self._tree(), os.path.join(self._scratch(), "m.json")) + self.assertEqual(r.returncode, 2) + self.assertIn("unknown command", r.stderr) + + +if __name__ == "__main__": + unittest.main(verbosity=2)