feat(preflight): catch logged-out auth and treat empty result as failure
ORCH-044 closes two blind spots that let a single de-authenticated agent
stall the shared queue for all projects:
P1 — preflight auth gate. `claude --version` answers even when logged out,
so version-only preflight was blind to auth. Adds a token-free, network-free
check of <AGENT_HOME>/.claude/.credentials.json: missing/unreadable/no-oauth
or an expired `claudeAiOauth.expiresAt` (epoch ms, vs now + skew) => preflight
FAIL; absent expiry => OK (no false positives). Result is cached on the same
preflight_cache_ttl. Post-factum safety net: launcher detects auth markers
("not logged in" / "/login" / "unauthorized" / 401) in the run log and resets
the preflight cache so the next tick re-evaluates auth. Auth failure is a gate,
not a transient — it does not spin the circuit breaker. Emergency toggle
ORCH_PREFLIGHT_CHECK_AUTH=false restores version-only behaviour.
P3 — empty log / no result-JSON => job failed. exit_code==0 with an empty or
JSON-less run log no longer counts as success: a separate result_ok flag gates
stage advance + usage comments, fires a Telegram alert, and routes the job
through the normal transient/permanent failure path (exit_code integrity in
agent_runs preserved).
Scope: P2 (--effort) is intentionally excluded and tracked in ORCH-50.
New settings: ORCH_PREFLIGHT_CHECK_AUTH, ORCH_CLAUDE_CREDENTIALS_PATH,
ORCH_AUTH_EXPIRY_SKEW_SECONDS. Docs updated (INFRA.md, internals.md, CHANGELOG).
Refs: ORCH-044
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
298
tests/test_empty_log_failure.py
Normal file
298
tests/test_empty_log_failure.py
Normal file
@@ -0,0 +1,298 @@
|
||||
"""ORCH-044 (P3): empty run log / no result-JSON at exit 0 == failure.
|
||||
|
||||
claude can exit 0 yet leave an empty (or JSON-less) run log — e.g. it died fast
|
||||
because the session was logged out, or a flag silenced stdout. Before ORCH-044
|
||||
that looked identical to success: job -> done, stage auto-advanced. Now the
|
||||
launcher validates the result; only (exit 0 AND valid result-JSON) is a success.
|
||||
|
||||
No real claude/Popen is spawned. The git/usage/notify side effects of
|
||||
_monitor_agent are stubbed; DB is a fresh per-test sqlite.
|
||||
"""
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
_test_db = os.path.join(tempfile.gettempdir(), "test_orchestrator_empty_log.db")
|
||||
os.environ["ORCH_DB_PATH"] = _test_db
|
||||
os.environ["ORCH_REPOS_DIR"] = tempfile.gettempdir()
|
||||
os.environ["ORCH_GITEA_TOKEN"] = "test-token"
|
||||
os.environ["ORCH_PLANE_API_TOKEN"] = "test-token"
|
||||
|
||||
import src.db as db
|
||||
from src.db import init_db, enqueue_job, claim_next_job, get_job
|
||||
from src import preflight
|
||||
from src.agents.launcher import AgentLauncher
|
||||
|
||||
|
||||
VALID_RESULT_LOG = (
|
||||
"some preamble text from the agent run...\n"
|
||||
'{"type":"result","subtype":"success","usage":'
|
||||
'{"input_tokens":120,"output_tokens":45},"total_cost_usd":0.12}\n'
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def fresh_db(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(db.settings, "db_path", str(tmp_path / "res.db"))
|
||||
init_db()
|
||||
preflight.reset_cache()
|
||||
yield
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# _validate_result — the result-JSON contract (TR-3.1)
|
||||
# ===========================================================================
|
||||
class TestValidateResult:
|
||||
def test_missing_path(self):
|
||||
ok, reason = AgentLauncher._validate_result(None)
|
||||
assert ok is False
|
||||
|
||||
def test_missing_file(self, tmp_path):
|
||||
ok, reason = AgentLauncher._validate_result(str(tmp_path / "nope.log"))
|
||||
assert ok is False
|
||||
assert "missing" in reason.lower()
|
||||
|
||||
def test_empty_file(self, tmp_path):
|
||||
p = tmp_path / "empty.log"
|
||||
p.write_text("")
|
||||
ok, reason = AgentLauncher._validate_result(str(p))
|
||||
assert ok is False
|
||||
assert "empty" in reason.lower()
|
||||
|
||||
def test_whitespace_only(self, tmp_path):
|
||||
p = tmp_path / "ws.log"
|
||||
p.write_text(" \n\t\n")
|
||||
ok, _ = AgentLauncher._validate_result(str(p))
|
||||
assert ok is False
|
||||
|
||||
def test_no_json(self, tmp_path):
|
||||
p = tmp_path / "garbage.log"
|
||||
p.write_text("this is not json at all, just noise\n")
|
||||
ok, reason = AgentLauncher._validate_result(str(p))
|
||||
assert ok is False
|
||||
assert "json" in reason.lower()
|
||||
|
||||
def test_valid_result_json(self, tmp_path):
|
||||
p = tmp_path / "good.log"
|
||||
p.write_text(VALID_RESULT_LOG)
|
||||
ok, _ = AgentLauncher._validate_result(str(p))
|
||||
assert ok is True
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# _finalize_job — job state under result_ok (TC-12/13/15/16/17)
|
||||
# ===========================================================================
|
||||
class TestFinalizeJobResultOk:
|
||||
def _spy_telegram(self, monkeypatch):
|
||||
sent = []
|
||||
monkeypatch.setattr("src.notifications.send_telegram",
|
||||
lambda *a, **k: sent.append(a[0] if a else ""))
|
||||
return sent
|
||||
|
||||
# TC-15 / AC-13: valid result -> done (no regression).
|
||||
def test_valid_result_done(self, tmp_path, monkeypatch):
|
||||
self._spy_telegram(monkeypatch)
|
||||
log = tmp_path / "1.log"
|
||||
log.write_text(VALID_RESULT_LOG)
|
||||
jid = enqueue_job("developer", "r")
|
||||
claim_next_job()
|
||||
AgentLauncher()._finalize_job(jid, "developer", run_id=1, exit_code=0,
|
||||
output_path=str(log), result_ok=True)
|
||||
assert get_job(jid)["status"] == "done"
|
||||
|
||||
# TC-12 / AC-10: exit 0 + empty log -> NOT done; terminal failed + alert.
|
||||
def test_empty_log_exit0_terminal_failed_alerts(self, tmp_path, monkeypatch):
|
||||
sent = self._spy_telegram(monkeypatch)
|
||||
log = tmp_path / "2.log"
|
||||
log.write_text("") # 0 bytes
|
||||
# max_attempts=1 -> after the claim (attempts=1) the budget is spent ->
|
||||
# the permanent path goes straight to 'failed' and alerts.
|
||||
jid = enqueue_job("developer", "r", max_attempts=1)
|
||||
claim_next_job()
|
||||
AgentLauncher()._finalize_job(jid, "developer", run_id=2, exit_code=0,
|
||||
output_path=str(log), result_ok=False)
|
||||
job = get_job(jid)
|
||||
assert job["status"] == "failed"
|
||||
assert job["status"] != "done"
|
||||
assert "empty run log" in (job["error"] or "")
|
||||
assert sent, "a Telegram alert must be sent on terminal failure"
|
||||
|
||||
# TC-13 / AC-11: exit 0 + JSON-less log -> failure (here: requeue).
|
||||
def test_garbage_log_exit0_not_done(self, tmp_path, monkeypatch):
|
||||
self._spy_telegram(monkeypatch)
|
||||
log = tmp_path / "3.log"
|
||||
log.write_text("noise, no json here\n")
|
||||
jid = enqueue_job("developer", "r", max_attempts=2)
|
||||
claim_next_job()
|
||||
AgentLauncher()._finalize_job(jid, "developer", run_id=3, exit_code=0,
|
||||
output_path=str(log), result_ok=False)
|
||||
job = get_job(jid)
|
||||
assert job["status"] != "done"
|
||||
assert job["status"] == "queued" # retry budget remained
|
||||
assert "no result JSON" in (job["error"] or "")
|
||||
|
||||
# TC-16 / AC-14: exit 0 + empty log never leaves the job 'running'.
|
||||
def test_never_running_after_empty_result(self, tmp_path, monkeypatch):
|
||||
self._spy_telegram(monkeypatch)
|
||||
log = tmp_path / "4.log"
|
||||
log.write_text("")
|
||||
jid = enqueue_job("developer", "r", max_attempts=2)
|
||||
claim_next_job()
|
||||
assert get_job(jid)["status"] == "running" # claimed
|
||||
AgentLauncher()._finalize_job(jid, "developer", run_id=4, exit_code=0,
|
||||
output_path=str(log), result_ok=False)
|
||||
assert get_job(jid)["status"] in ("failed", "queued")
|
||||
|
||||
# TC-17 / TR-3.3: empty result defaults to permanent (no backoff, no
|
||||
# transient budget burn).
|
||||
def test_empty_result_defaults_permanent(self, tmp_path, monkeypatch):
|
||||
self._spy_telegram(monkeypatch)
|
||||
log = tmp_path / "5.log"
|
||||
log.write_text("") # no transient marker
|
||||
jid = enqueue_job("developer", "r", max_attempts=2)
|
||||
claim_next_job()
|
||||
AgentLauncher()._finalize_job(jid, "developer", run_id=5, exit_code=0,
|
||||
output_path=str(log), result_ok=False)
|
||||
job = get_job(jid)
|
||||
assert job["status"] == "queued"
|
||||
assert job["transient_attempts"] == 0 # NOT transient
|
||||
assert job["available_at"] is None # no backoff gate
|
||||
|
||||
# TC-17 / TR-3.3: a transient marker in the log routes to the transient path.
|
||||
def test_empty_result_with_transient_marker_goes_transient(self, tmp_path, monkeypatch):
|
||||
self._spy_telegram(monkeypatch)
|
||||
log = tmp_path / "6.log"
|
||||
log.write_text("overloaded_error: 429 rate limit. Retry-After: 12\n")
|
||||
jid = enqueue_job("developer", "r", max_attempts=2)
|
||||
claim_next_job()
|
||||
AgentLauncher()._finalize_job(jid, "developer", run_id=6, exit_code=0,
|
||||
output_path=str(log), result_ok=False)
|
||||
job = get_job(jid)
|
||||
assert job["status"] == "queued"
|
||||
assert job["transient_attempts"] == 1 # transient path taken
|
||||
assert job["available_at"] is not None # backoff gate set
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# _monitor_agent — success gating (TC-14/15) + auth-marker reset (P1b)
|
||||
# ===========================================================================
|
||||
class _FakeProc:
|
||||
def __init__(self, exit_code):
|
||||
self._ec = exit_code
|
||||
self.pid = 4242
|
||||
|
||||
def wait(self):
|
||||
return self._ec
|
||||
|
||||
|
||||
def _seed_task_and_run(repo, branch, agent="developer", work_item_id="ORCH-001"):
|
||||
conn = db.get_db()
|
||||
conn.execute(
|
||||
"INSERT INTO tasks (work_item_id, repo, branch, stage) VALUES (?,?,?,?)",
|
||||
(work_item_id, repo, branch, "development"),
|
||||
)
|
||||
cur = conn.execute(
|
||||
"INSERT INTO agent_runs (task_id, agent) VALUES ((SELECT id FROM tasks "
|
||||
"WHERE repo=? AND branch=?), ?)",
|
||||
(repo, branch, agent),
|
||||
)
|
||||
run_id = cur.lastrowid
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return run_id
|
||||
|
||||
|
||||
class TestMonitorAgentGating:
|
||||
def _patch_monitor_env(self, monkeypatch, tmp_path):
|
||||
"""Stub the heavy side effects of _monitor_agent (git/usage/notify)."""
|
||||
monkeypatch.setattr("src.agents.launcher.notify_agent_finished",
|
||||
lambda *a, **k: None)
|
||||
monkeypatch.setattr("src.agents.launcher.get_worktree_path",
|
||||
lambda repo, branch: str(tmp_path))
|
||||
|
||||
class _R:
|
||||
returncode = 0
|
||||
stdout = "" # "no changes to commit" -> skips git add/commit/push
|
||||
stderr = ""
|
||||
|
||||
monkeypatch.setattr("src.agents.launcher.subprocess.run",
|
||||
lambda *a, **k: _R())
|
||||
|
||||
def test_success_advances_and_comments(self, tmp_path, monkeypatch):
|
||||
self._patch_monitor_env(monkeypatch, tmp_path)
|
||||
run_id = _seed_task_and_run("r", "feature/x")
|
||||
log = tmp_path / f"{run_id}.log"
|
||||
log.write_text(VALID_RESULT_LOG)
|
||||
|
||||
spy = {"post": 0, "advance": 0, "finalize": None, "alert": 0}
|
||||
monkeypatch.setattr("src.notifications.send_telegram",
|
||||
lambda *a, **k: spy.__setitem__("alert", spy["alert"] + 1))
|
||||
|
||||
lr = AgentLauncher()
|
||||
monkeypatch.setattr(lr, "_post_usage_comments",
|
||||
lambda *a, **k: spy.__setitem__("post", spy["post"] + 1))
|
||||
monkeypatch.setattr(lr, "_try_advance_stage",
|
||||
lambda *a, **k: spy.__setitem__("advance", spy["advance"] + 1))
|
||||
monkeypatch.setattr(lr, "_finalize_job",
|
||||
lambda *a, **k: spy.__setitem__("finalize", k.get("result_ok")))
|
||||
|
||||
lr._monitor_agent(_FakeProc(0), run_id, "developer", "r", "feature/x",
|
||||
output_path=str(log), log_fh=None, job_id=99)
|
||||
|
||||
assert spy["post"] == 1
|
||||
assert spy["advance"] == 1
|
||||
assert spy["finalize"] is True
|
||||
assert spy["alert"] == 0 # no empty-result alert on a valid run
|
||||
|
||||
# TC-14 / AC-12: empty result -> no advance, no success comment, alert sent.
|
||||
def test_empty_result_suppresses_advance_and_comment(self, tmp_path, monkeypatch):
|
||||
self._patch_monitor_env(monkeypatch, tmp_path)
|
||||
run_id = _seed_task_and_run("r", "feature/y")
|
||||
log = tmp_path / f"{run_id}.log"
|
||||
log.write_text("") # empty -> invalid result
|
||||
|
||||
spy = {"post": 0, "advance": 0, "finalize": None, "alert": 0}
|
||||
monkeypatch.setattr("src.notifications.send_telegram",
|
||||
lambda *a, **k: spy.__setitem__("alert", spy["alert"] + 1))
|
||||
|
||||
lr = AgentLauncher()
|
||||
monkeypatch.setattr(lr, "_post_usage_comments",
|
||||
lambda *a, **k: spy.__setitem__("post", spy["post"] + 1))
|
||||
monkeypatch.setattr(lr, "_try_advance_stage",
|
||||
lambda *a, **k: spy.__setitem__("advance", spy["advance"] + 1))
|
||||
monkeypatch.setattr(lr, "_finalize_job",
|
||||
lambda *a, **k: spy.__setitem__("finalize", k.get("result_ok")))
|
||||
|
||||
lr._monitor_agent(_FakeProc(0), run_id, "developer", "r", "feature/y",
|
||||
output_path=str(log), log_fh=None, job_id=99)
|
||||
|
||||
assert spy["post"] == 0 # no success comment
|
||||
assert spy["advance"] == 0 # stage NOT advanced
|
||||
assert spy["finalize"] is False # finalize told the result was invalid
|
||||
assert spy["alert"] == 1 # empty-result alert fired
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# _handle_auth_marker — post-factum auth detection resets preflight cache (P1b)
|
||||
# ===========================================================================
|
||||
class TestAuthMarkerHandling:
|
||||
def test_auth_marker_resets_preflight_cache(self, tmp_path, monkeypatch):
|
||||
log = tmp_path / "auth.log"
|
||||
log.write_text("Error: Not logged in. Please run /login\n")
|
||||
reset = {"n": 0}
|
||||
monkeypatch.setattr(preflight, "reset_cache",
|
||||
lambda: reset.__setitem__("n", reset["n"] + 1))
|
||||
found = AgentLauncher()._handle_auth_marker(str(log))
|
||||
assert found is True
|
||||
assert reset["n"] == 1
|
||||
|
||||
def test_no_auth_marker_no_reset(self, tmp_path, monkeypatch):
|
||||
log = tmp_path / "plain.log"
|
||||
log.write_text("Traceback: ValueError somewhere\n")
|
||||
reset = {"n": 0}
|
||||
monkeypatch.setattr(preflight, "reset_cache",
|
||||
lambda: reset.__setitem__("n", reset["n"] + 1))
|
||||
found = AgentLauncher()._handle_auth_marker(str(log))
|
||||
assert found is False
|
||||
assert reset["n"] == 0
|
||||
246
tests/test_preflight_auth.py
Normal file
246
tests/test_preflight_auth.py
Normal file
@@ -0,0 +1,246 @@
|
||||
"""ORCH-044 (P1): token-free preflight auth gate.
|
||||
|
||||
`claude --version` answers even when claude is logged OUT, so version-only
|
||||
preflight was blind to auth. These tests cover the new local credentials check:
|
||||
missing / expired / valid token, broken JSON fail-safe, no network, caching,
|
||||
HOME-correct path resolution, and the queue-worker claim gate.
|
||||
|
||||
No real claude/Popen is spawned: `_run_version` is stubbed and credentials live
|
||||
in tmp files. DB is a fresh per-test sqlite (mirrors tests/test_resilience.py).
|
||||
"""
|
||||
import os
|
||||
import json
|
||||
import socket
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
_test_db = os.path.join(tempfile.gettempdir(), "test_orchestrator_preflight_auth.db")
|
||||
os.environ["ORCH_DB_PATH"] = _test_db
|
||||
os.environ["ORCH_REPOS_DIR"] = tempfile.gettempdir()
|
||||
os.environ["ORCH_GITEA_TOKEN"] = "test-token"
|
||||
os.environ["ORCH_PLANE_API_TOKEN"] = "test-token"
|
||||
|
||||
import src.db as db
|
||||
from src.db import init_db, enqueue_job, get_job, count_running_jobs
|
||||
from src import preflight
|
||||
from src.queue_worker import QueueWorker
|
||||
from src.agents.launcher import AgentLauncher
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def fresh_db(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(db.settings, "db_path", str(tmp_path / "res.db"))
|
||||
init_db()
|
||||
preflight.reset_cache()
|
||||
# auth check on by default; large TTL unless a test overrides it.
|
||||
monkeypatch.setattr(preflight.settings, "preflight_check_auth", True)
|
||||
yield
|
||||
|
||||
|
||||
def _fake_bin(monkeypatch, tmp_path):
|
||||
"""A bin path that exists + a --version that always succeeds (auth-agnostic)."""
|
||||
b = tmp_path / "claude"
|
||||
b.write_text("#!/bin/sh\necho v1\n")
|
||||
monkeypatch.setattr(preflight, "_claude_bin", lambda: str(b))
|
||||
monkeypatch.setattr(preflight, "_run_version", lambda b: (True, "1.2.3"))
|
||||
|
||||
|
||||
def _write_creds(tmp_path, *, expires_ms=None, access_token="tok", oauth=True,
|
||||
raw=None):
|
||||
path = tmp_path / ".credentials.json"
|
||||
if raw is not None:
|
||||
path.write_text(raw)
|
||||
return path
|
||||
body = {}
|
||||
if oauth:
|
||||
oa = {"accessToken": access_token}
|
||||
if expires_ms is not None:
|
||||
oa["expiresAt"] = expires_ms
|
||||
body["claudeAiOauth"] = oa
|
||||
path.write_text(json.dumps(body))
|
||||
return path
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-01 / AC-1: not logged in (no credentials file) -> FAIL
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_missing_credentials_fails(monkeypatch, tmp_path):
|
||||
_fake_bin(monkeypatch, tmp_path)
|
||||
monkeypatch.setattr(preflight, "_credentials_path",
|
||||
lambda: str(tmp_path / "nope.json"))
|
||||
ok, reason = preflight.check(force=True)
|
||||
assert ok is False
|
||||
assert "logged in" in reason.lower() or "credentials" in reason.lower()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-02 / AC-2: expired OAuth token -> FAIL
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_expired_token_fails(monkeypatch, tmp_path):
|
||||
_fake_bin(monkeypatch, tmp_path)
|
||||
past = (int(__import__("time").time()) - 3600) * 1000 # 1h ago, epoch ms
|
||||
creds = _write_creds(tmp_path, expires_ms=past)
|
||||
monkeypatch.setattr(preflight, "_credentials_path", lambda: str(creds))
|
||||
ok, reason = preflight.check(force=True)
|
||||
assert ok is False
|
||||
assert "expired" in reason.lower()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-03 / AC-3: valid login -> OK (no regression)
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_valid_login_ok(monkeypatch, tmp_path):
|
||||
_fake_bin(monkeypatch, tmp_path)
|
||||
future = (int(__import__("time").time()) + 3600) * 1000 # 1h ahead
|
||||
creds = _write_creds(tmp_path, expires_ms=future)
|
||||
monkeypatch.setattr(preflight, "_credentials_path", lambda: str(creds))
|
||||
ok, reason = preflight.check(force=True)
|
||||
assert ok is True
|
||||
|
||||
|
||||
def test_token_without_expiry_is_ok(monkeypatch, tmp_path):
|
||||
# accessToken present but no expiresAt -> cannot prove expiry -> OK (ADR §P1.5).
|
||||
_fake_bin(monkeypatch, tmp_path)
|
||||
creds = _write_creds(tmp_path, expires_ms=None)
|
||||
monkeypatch.setattr(preflight, "_credentials_path", lambda: str(creds))
|
||||
ok, _ = preflight.check(force=True)
|
||||
assert ok is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-04 / AC-1: broken / unreadable credentials JSON -> FAIL (no exception)
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_broken_json_fails_without_raising(monkeypatch, tmp_path):
|
||||
_fake_bin(monkeypatch, tmp_path)
|
||||
creds = _write_creds(tmp_path, raw="{ this is not valid json ")
|
||||
monkeypatch.setattr(preflight, "_credentials_path", lambda: str(creds))
|
||||
ok, reason = preflight.check(force=True) # must not raise
|
||||
assert ok is False
|
||||
assert "logged in" in reason.lower() or "unreadable" in reason.lower()
|
||||
|
||||
|
||||
def test_no_oauth_block_fails(monkeypatch, tmp_path):
|
||||
_fake_bin(monkeypatch, tmp_path)
|
||||
creds = _write_creds(tmp_path, oauth=False)
|
||||
monkeypatch.setattr(preflight, "_credentials_path", lambda: str(creds))
|
||||
ok, reason = preflight.check(force=True)
|
||||
assert ok is False
|
||||
assert "oauth" in reason.lower() or "logged in" in reason.lower()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-05 / AC-5: token-free — no network call in the auth path
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_auth_check_makes_no_network_call(monkeypatch, tmp_path):
|
||||
_fake_bin(monkeypatch, tmp_path)
|
||||
future = (int(__import__("time").time()) + 3600) * 1000
|
||||
creds = _write_creds(tmp_path, expires_ms=future)
|
||||
monkeypatch.setattr(preflight, "_credentials_path", lambda: str(creds))
|
||||
|
||||
def _no_net(*a, **k):
|
||||
raise AssertionError("token-free auth check must not open a socket")
|
||||
|
||||
monkeypatch.setattr(socket, "socket", _no_net)
|
||||
ok, _ = preflight.check(force=True)
|
||||
assert ok is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-06 / AC-6: auth result cached within preflight_cache_ttl
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_auth_result_cached_within_ttl(monkeypatch, tmp_path):
|
||||
_fake_bin(monkeypatch, tmp_path)
|
||||
monkeypatch.setattr(preflight.settings, "preflight_cache_ttl", 999)
|
||||
|
||||
calls = {"n": 0}
|
||||
real = preflight._check_auth
|
||||
|
||||
future = (int(__import__("time").time()) + 3600) * 1000
|
||||
creds = _write_creds(tmp_path, expires_ms=future)
|
||||
monkeypatch.setattr(preflight, "_credentials_path", lambda: str(creds))
|
||||
|
||||
def counting():
|
||||
calls["n"] += 1
|
||||
return real()
|
||||
|
||||
monkeypatch.setattr(preflight, "_check_auth", counting)
|
||||
preflight.reset_cache()
|
||||
preflight.check() # miss -> reads creds
|
||||
preflight.check() # cached -> no re-read
|
||||
preflight.check()
|
||||
assert calls["n"] == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-07 / TR-1.3: credentials path resolves from AGENT_HOME, not process env
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_credentials_path_follows_agent_home(monkeypatch, tmp_path):
|
||||
agent_home = tmp_path / "agent_home"
|
||||
agent_home.mkdir()
|
||||
monkeypatch.setattr(AgentLauncher, "AGENT_HOME", str(agent_home))
|
||||
monkeypatch.setattr(preflight.settings, "claude_credentials_path", "")
|
||||
# The orchestrator process HOME points somewhere else entirely.
|
||||
monkeypatch.setenv("HOME", str(tmp_path / "orchestrator_home"))
|
||||
|
||||
resolved = preflight._credentials_path()
|
||||
assert resolved == str(agent_home / ".claude" / ".credentials.json")
|
||||
assert str(tmp_path / "orchestrator_home") not in resolved
|
||||
|
||||
|
||||
def test_explicit_credentials_path_wins(monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(preflight.settings, "claude_credentials_path",
|
||||
str(tmp_path / "explicit.json"))
|
||||
assert preflight._credentials_path() == str(tmp_path / "explicit.json")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-08 / AC-4: auth-fail blocks the queue-worker claim
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_worker_does_not_claim_when_auth_fails(monkeypatch, tmp_path):
|
||||
_fake_bin(monkeypatch, tmp_path)
|
||||
monkeypatch.setattr(preflight, "_credentials_path",
|
||||
lambda: str(tmp_path / "missing.json")) # not logged in
|
||||
called = {"launch": False}
|
||||
monkeypatch.setattr("src.queue_worker.launcher.launch_job",
|
||||
lambda job: called.__setitem__("launch", True))
|
||||
|
||||
jid = enqueue_job("analyst", "r")
|
||||
w = QueueWorker(max_concurrency=1, poll_interval=0.01)
|
||||
w._drain_once()
|
||||
|
||||
assert called["launch"] is False
|
||||
assert get_job(jid)["status"] == "queued"
|
||||
assert count_running_jobs() == 0
|
||||
assert w.last_preflight_ok is False
|
||||
assert "logged in" in w.last_preflight_reason.lower() \
|
||||
or "credentials" in w.last_preflight_reason.lower()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Toggle off: preflight_check_auth=False keeps the old version-only behaviour
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_auth_toggle_off_skips_check(monkeypatch, tmp_path):
|
||||
_fake_bin(monkeypatch, tmp_path)
|
||||
monkeypatch.setattr(preflight.settings, "preflight_check_auth", False)
|
||||
monkeypatch.setattr(preflight, "_credentials_path",
|
||||
lambda: str(tmp_path / "missing.json"))
|
||||
ok, _ = preflight.check(force=True)
|
||||
assert ok is True # auth not consulted -> version-only pass
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# is_auth_failure_text: post-factum marker detection (P1b)
|
||||
# ---------------------------------------------------------------------------
|
||||
@pytest.mark.parametrize("text", [
|
||||
"Error: Not logged in. Please run /login",
|
||||
"401 Unauthorized",
|
||||
"invalid api key provided",
|
||||
])
|
||||
def test_is_auth_failure_text_positive(text):
|
||||
assert preflight.is_auth_failure_text(text) is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize("text", ["", "429 rate limit", "Traceback ValueError"])
|
||||
def test_is_auth_failure_text_negative(text):
|
||||
assert preflight.is_auth_failure_text(text) is False
|
||||
Reference in New Issue
Block a user