diff --git a/tests/test_task_recovery.py b/tests/test_task_recovery.py index 56846b50..ded3c923 100644 --- a/tests/test_task_recovery.py +++ b/tests/test_task_recovery.py @@ -940,8 +940,9 @@ class TaskRecoveryTests(unittest.TestCase): def test_local_precheck_without_cached_ref_allows_comparable_forward_target(self): """Cached refs/remotes/origin/main is absent; origin advertises - the task's R0 (or a forward descendant that is locally - available). Local precheck must continue to advertise + a forward descendant of the task's R0 (the descendant is + locally available so ancestry can be compared against the + registered baseline). Local precheck must continue to advertise LOCAL_READY — the explicit missing-tracking-ref continuation contract — because the registered task context is the same as the cached ref would have represented, and the ls-remote @@ -957,11 +958,20 @@ class TaskRecoveryTests(unittest.TestCase): f = self.fixture head_before = f.git(self.wt, "rev-parse", "HEAD").stdout r0 = f.git(self.wt, "rev-parse", "origin/main").stdout.strip() + # Advance the bare remote so the advertised target is a real + # forward descendant of R0 (not R0 itself). The descendant must + # be locally available so the wrapper can advertise it AND the + # production `cat-file -e` check inside target_history() succeeds + # when the descendant is compared against the registered R0. + f.advance_remote() + forward = f.git(self.wt, "rev-parse", "origin/main").stdout.strip() + self.assertNotEqual(forward, r0) + self.assertEqual(f.git(f.repo, "merge-base", r0, forward).stdout.strip(), r0) f.git(self.wt, "branch", "--unset-upstream", "feature/test") f.git(self.wt, "update-ref", "-d", "refs/remotes/origin/main") bin_dir, wrapper_log = self.write_no_cache_wrapper( - f.root / "no-cache-comparable-bin", r0) + f.root / "no-cache-comparable-bin", forward) path_env = f"{bin_dir}{os.pathsep}{f.env.get('PATH', os.environ['PATH'])}" wrapper_env = f.env | {"PATH": path_env} @@ -969,12 +979,12 @@ class TaskRecoveryTests(unittest.TestCase): env=wrapper_env | {"TASK_WORK_MODE": "local"}) log = wrapper_log.read_text() - self.assertIn(f"configured advertised-target={r0}", log) + self.assertIn(f"configured advertised-target={forward}", log) self.assertIn("fetch-intercepted", log) # Reachable advertised cases must verify the wrapper actually - # served the expected ls-remote response (the registered R0), not - # only that the wrapper binary was loaded. - self.assertIn(f"ls-remote-served advertised-target={r0}", log) + # served the expected ls-remote response (a forward descendant of + # the registered R0), not only that the wrapper binary was loaded. + self.assertIn(f"ls-remote-served advertised-target={forward}", log) self.assertEqual(local.returncode, 0, local.stdout) self.assertIn("[verdict] LOCAL_READY", local.stdout) @@ -1203,6 +1213,83 @@ class TaskRecoveryTests(unittest.TestCase): "the registered task card must not embed the receipt sha256 " "when the resume refuses to import the receipt") + def test_local_precheck_rejects_reachable_auth_failure(self): + """Standalone: capture a valid TASK_WORK_MODE=local precheck + receipt against the real bare remote (origin is reachable and + serves refs/heads/main normally), then point the worktree at a + disposable wrapper that makes `fetch` and + `ls-remote origin refs/heads/main` both fail with explicit + "Authentication failed (HTTP 401)" and run `task precheck` in + local mode. + + The wrapper proves the wrapper was actually used (configured + marker, fetch-auth-failure, ls-remote-auth-failure). The + precheck must NOT yield the local-continuation positive signal: + it must refuse with REMOTE_UNVERIFIED, must not advertise + LOCAL_READY, and HEAD must remain untouched. + + Currently RED on current code: when fetch and ls-remote both + fail (auth error), `target_history` sees an empty listing and + short-circuits to "ok", so the unreachable-fallback branch falls + through to LOCAL_READY even though the authoritative target + head cannot be confirmed. This test records the expected red + result so the post-fix implementation refuses the auth-failure + scenario instead of silently promoting the registered task to + local continuation. + + The test owns its own disposable git wrapper so the auth + failure scenario does not leak into the wrapper shared with + the no-cache tests; the existing + test_local_resume_rejects_reachable_auth_failure (unreachable + origin under resume) and test_local_child_resume_rejects_missing_parent_sha + (unreachable origin with a child task missing parent_head_sha) + are preserved.""" + f = self.fixture + head_before = f.git(self.wt, "rev-parse", "HEAD").stdout + # (1) Build the disposable auth-failure wrapper so the wrapper + # intercepts fetch (128, Authentication failed) and + # ls-remote origin refs/heads/main (128, Authentication failed, + # no value served) while everything else delegates to the real + # git. The wrapper's own marker line proves it was loaded. + bin_dir, wrapper_log = self.write_no_cache_wrapper( + f.root / "auth-failure-precheck-bin", "", + log_name="auth-failure-precheck.log", + auth_fail=True) + path_env = f"{bin_dir}{os.pathsep}{f.env.get('PATH', os.environ['PATH'])}" + wrapper_env = (f.env | {"PATH": path_env} + | {"TASK_WORK_MODE": "local"}) + # (2) Local precheck under the auth-failure wrapper must refuse. + local = f.task(self.wt, "precheck", check=False, env=wrapper_env) + # Proof the wrapper was actually used. + log = wrapper_log.read_text() + self.assertIn("configured auth-failure-mode", log) + self.assertIn("fetch-auth-failure", log) + self.assertIn("ls-remote-auth-failure", log) + # Auth-failure scenarios must assert the wrapper actually + # served the expected (auth) response, not only that the + # wrapper initialized. + self.assertNotEqual(local.returncode, 0, + f"local precheck must reject when fetch and ls-remote both " + f"return Authentication failed (HTTP 401); current code " + f"falls through target_history's empty-listing short-" + f"circuit and reaches LOCAL_READY, returning 0; " + f"stdout={local.stdout}") + self.assertIn("[verdict] REMOTE_UNVERIFIED", local.stdout, + "the precheck must surface REMOTE_UNVERIFIED when the reachable " + "origin returns Authentication failed (HTTP 401) instead of an " + "advertised target head; the post-fix implementation must not " + "promote the registered task to local continuation under an " + f"authoritative-but-unverifiable remote; stdout={local.stdout}") + self.assertNotIn("[verdict] LOCAL_READY", local.stdout, + "the precheck must NOT advertise LOCAL_READY when the " + "reachable origin returns Authentication failed (HTTP 401); " + "accepting the registered task would advertise local " + "continuation for a target head that cannot be confirmed " + f"against origin; stdout={local.stdout}") + self.assertEqual(f.git(self.wt, "rev-parse", "HEAD").stdout, head_before, + "the task HEAD must not move when the precheck refuses to " + "advertise LOCAL_READY") + if __name__ == "__main__": unittest.main()