diff --git a/cloud/tests/test_v2cov_marker_pure.py b/cloud/tests/test_v2cov_marker_pure.py new file mode 100644 index 0000000..1a62d8b --- /dev/null +++ b/cloud/tests/test_v2cov_marker_pure.py @@ -0,0 +1,150 @@ +"""Coverage batch v2 (E2): cloud/marker_tools.py pure helpers -- +timezone fallback, first-run parsing, int coercion, spawn-default +parsing, heartbeat/progress formatting, todo/task lines. +Safety buffer on top of E1; all direct calls. +""" + +import sys +import time +import unittest +from pathlib import Path +from unittest import mock + +REPO_ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(REPO_ROOT)) + +from cloud.marker_tools import ( + _as_int, + _heartbeat_age_text, + _model_from_match, + _progress_lines, + _shanghai_tz, + format_snapshot, + format_task, + format_todo, + parse_first_run_at, + parse_spawn_defaults, + row_get, +) + + +class FakeMatch: + def __init__(self, token): + self._token = token + + def group(self, n): + return self._token + + def end(self, n): + return len(self._token) + + +class MarkerPureTestCase(unittest.TestCase): + def test_shanghai_tz_zoneinfo_raises(self): + with mock.patch("cloud.marker_tools.ZoneInfo", + side_effect=Exception("no tz")): + tz = _shanghai_tz() + self.assertEqual(tz.utcoffset(None).total_seconds(), 8 * 3600) + + def test_shanghai_tz_no_zoneinfo(self): + with mock.patch("cloud.marker_tools.ZoneInfo", None): + tz = _shanghai_tz() + self.assertEqual(tz.utcoffset(None).total_seconds(), 8 * 3600) + + def test_first_run_blank(self): + self.assertIsNone(parse_first_run_at(" ")) + + def test_first_run_negative(self): + self.assertIsNone(parse_first_run_at(-5)) + + def test_first_run_zero(self): + self.assertIsNone(parse_first_run_at(0)) + + def test_first_run_valid(self): + self.assertEqual(parse_first_run_at(1700000000), 1700000000.0) + self.assertIsNotNone(parse_first_run_at("2026-01-01T00:00:00Z")) + + def test_row_get_fallback(self): + self.assertIsNone(row_get(None, "x")) + self.assertIsNone(row_get({}, "missing")) + + def test_as_int_bool_and_float(self): + self.assertEqual(_as_int(True, 7), 7) + self.assertEqual(_as_int(3.5, 7), 7) + self.assertEqual(_as_int(3.0, 7), 3) + self.assertEqual(_as_int(4, 7), 4) + + def test_model_from_match_empty_token(self): + self.assertEqual(_model_from_match("x", FakeMatch("."), 1), + (None, None)) + + def test_model_from_match_effort_word(self): + self.assertEqual(_model_from_match("x", FakeMatch("max"), 1), + (None, "max")) + + def test_spawn_defaults_empty(self): + self.assertEqual(parse_spawn_defaults(""), {}) + self.assertEqual(parse_spawn_defaults(None), {}) + + def test_spawn_defaults_zh(self): + out = parse_spawn_defaults("子任务用 gpt-6-luna max") + self.assertEqual(out.get("model"), "gpt-6-luna") + + def test_heartbeat_none(self): + self.assertEqual(_heartbeat_age_text({}), "") + + def test_heartbeat_bad(self): + self.assertEqual( + _heartbeat_age_text({"last_heartbeat": "bad"}), "") + + def test_heartbeat_future(self): + now = time.time() + self.assertEqual( + _heartbeat_age_text({"last_heartbeat": now + 100}, now=now), + "0 秒前") + + def test_heartbeat_seconds(self): + now = time.time() + self.assertEqual( + _heartbeat_age_text({"last_heartbeat": now - 30}, now=now), + "30 秒前") + + def test_progress_lines_full(self): + now = time.time() + lines = _progress_lines( + {"executor": "docker", "actual_model": "m1", + "worktree_host": "h1", "last_heartbeat": now - 5, + "turn_id": "t9"}, now=now) + self.assertEqual(len(lines), 5) + + def test_snapshot_terminal_empty(self): + text = format_snapshot( + {"id": "t1", "status": "completed", "result_text": "", + "error_text": ""}, 100) + self.assertIn("结果:(空)", text) + + def test_snapshot_timed_out(self): + text = format_snapshot( + {"id": "t1", "status": "running", "result_text": "", + "error_text": ""}, 100, timed_out=True) + self.assertIn("等待超时", text) + + def test_snapshot_cancelled(self): + text = format_snapshot( + {"id": "t1", "status": "running", "result_text": "", + "error_text": ""}, 100, cancelled=True) + self.assertIn("被取消", text) + + def test_todo_bad_due_and_task(self): + line = format_todo({"status": "open", "title": "t", "id": "d1", + "priority": "high", "due_at": "bad", + "task_id": "t1"}) + self.assertIn("task_id=t1", line) + self.assertNotIn("due=", line) + + def test_task_bad_created(self): + line = format_task({"status": "completed", "id": "t1", + "created_by": "u1", "prompt": "do it", + "created_at": "bad"}) + self.assertIn("do it", line) + self.assertNotIn("created=", line) diff --git a/cloud/tests/test_v2cov_scorecard.py b/cloud/tests/test_v2cov_scorecard.py new file mode 100644 index 0000000..f54dd7b --- /dev/null +++ b/cloud/tests/test_v2cov_scorecard.py @@ -0,0 +1,407 @@ +"""Coverage batch v2: cloud/audit_scorecard.py branch sweep. + +The baseline left 55 lines uncovered, almost all of them the +second/third branches of the seven _score_* scorers (caps, unknown +returns, untested kinds) plus the threshold-source fallbacks. These +tests drive every branch with real evidence dicts and assert the +pinned scores, statuses and overall rule (average is never the pass +criterion). Threshold values used here are the module's own test +defaults; nothing here presents them as production-approved. +""" + +import unittest + +from cloud.audit_scorecard import ( + DEFAULT_THRESHOLDS, + build_scorecard, + example_scorecard_current_state, + _result, + _round1, + _score_higher, + _score_lower, + _t, + _thresholds_source, +) + + +def _delivery(**over): + ev = { + "first_pass_rate": 0.97, "defect_rate": 0.01, "rework_rate": 0.02, + "first_pass_known": True, "defect_known": True, + "rework_known": True, "attempts_present": True, + "evidence_kind": "real", + } + ev.update(over) + return ev + + +def _gate(**over): + ev = { + "hard_gate_enforced": True, "gate_items_passed": 100, + "gate_items_total": 100, "merge_blocked_verified": True, + "loop_end_to_end_verified": True, + "sampling_misjudgment_rate": 0.0, "evidence_kind": "real", + } + ev.update(over) + return ev + + +def _arch(**over): + ev = { + "contract_frozen": True, "arch_violations": 0, + "directory_violations": 0, "import_violations": 0, + "stack_changed_without_approval": False, + } + ev.update(over) + return ev + + +def _test(**over): + ev = { + "prd_coverage_rate": 1.0, "cross_matrix_coverage_rate": 1.0, + "coverage_pct": 0.95, "cross_matrix_kind": "real", + "real_pg": True, "real_service": True, + "agent_six_complete": True, "lint_items": 0, "mypy_items": 0, + "bandit_high": 0, + } + ev.update(over) + return ev + + +def _security(**over): + ev = { + "ledger_verify_ok": True, "ledger_coverage_rate": 1.0, + "shell_whitelist_pass_rate": 1.0, "branch_protection": "protected", + "anchor_check_recent": True, + } + ev.update(over) + return ev + + +def _cost(**over): + ev = { + "cost_records_complete": True, "unpriced_as_zero": False, + "per_creator_present": True, "quota_cutoff_verified": True, + } + ev.update(over) + return ev + + +def _ops(**over): + ev = { + "ci_green_rate": 1.0, "deploy_success_rate": 1.0, + "rollback_drill_done": True, "ha_status": "second_host", + "parallel_scale_proven": 40, + } + ev.update(over) + return ev + + +def _all_good(): + return { + "delivery_quality": _delivery(), + "gate_audit": _gate(), + "arch_contract": _arch(), + "test_coverage": _test(), + "security_ledger": _security(), + "cost_quota": _cost(), + "ops_scale": _ops(), + } + + +class HelpersTestCase(unittest.TestCase): + def test_thresholds_source_branches(self): + self.assertEqual(_thresholds_source(None), "test_default_unapproved") + self.assertEqual(_thresholds_source({}), "test_default_unapproved") + approved = {"a": {"value": 1, "source": "user_approved"}} + self.assertEqual(_thresholds_source(approved), "user_approved") + # Non-dict entry: falls into the else branch, and a lone + # non-approved source falls through to the final default. + self.assertEqual(_thresholds_source({"a": 5}), "test_default_unapproved") + mixed = {"a": {"value": 1, "source": "user_approved"}, + "b": {"value": 2, "source": "test_default_unapproved"}} + self.assertEqual(_thresholds_source(mixed), "test_default_unapproved") + + def test_t_branches(self): + self.assertEqual(_t(None, "coverage_nine"), + DEFAULT_THRESHOLDS["coverage_nine"]["value"]) + self.assertEqual(_t({"coverage_nine": {"value": 0.77}}, + "coverage_nine"), 0.77) + self.assertEqual(_t({"coverage_nine": 0.66}, "coverage_nine"), 0.66) + self.assertEqual(_t({"other": 1}, "coverage_nine"), + DEFAULT_THRESHOLDS["coverage_nine"]["value"]) + + def test_score_higher_segments(self): + self.assertIsNone(_score_higher(None, 0.9, 0.95, 0.5)) + self.assertEqual(_score_higher(1.0, 0.9, 0.95, 0.5), 10.0) + self.assertAlmostEqual(_score_higher(0.92, 0.9, 0.95, 0.5), 9.4) + self.assertAlmostEqual(_score_higher(0.7, 0.9, 0.95, 0.5), 7.0) + self.assertAlmostEqual(_score_higher(0.2, 0.9, 0.95, 0.5), 2.0) + self.assertEqual(_score_higher(-1.0, 0.9, 0.95, 0.0), 0.0) + # Degenerate bands still return a number instead of dividing + # by zero; the floor-band edge (v == floor) scores exactly 5. + self.assertEqual(_score_higher(0.9, 0.9, 0.9, 0.5), 10.0) + self.assertEqual(_score_higher(0.5, 0.9, 0.95, 0.5), 5.0) + + def test_score_lower_segments(self): + self.assertIsNone(_score_lower(None, 0.02, 0.05, 0.20)) + self.assertEqual(_score_lower(0.01, 0.02, 0.05, 0.20), 10.0) + self.assertAlmostEqual(_score_lower(0.03, 0.02, 0.05, 0.20), + 9.0 + (0.05 - 0.03) / 0.03) + self.assertAlmostEqual(_score_lower(0.10, 0.02, 0.05, 0.20), + 5.0 + 4.0 * (0.20 - 0.10) / 0.15) + self.assertAlmostEqual(_score_lower(0.40, 0.02, 0.05, 0.20), 2.5) + self.assertEqual(_score_lower(0.02, 0.05, 0.05, 0.20), 10.0) + self.assertEqual(_score_lower(0.05, 0.02, 0.05, 0.20), 9.0) + + def test_result_statuses_and_round1(self): + self.assertEqual(_result("x", None, [], ["m"], [])["status"], "unknown") + self.assertEqual(_result("x", 9.0, [], [], [])["status"], "pass") + self.assertEqual(_result("x", 4.0, [], [], [])["status"], "fail") + self.assertEqual(_round1(9.04), 9.0) + + +class DeliveryTestCase(unittest.TestCase): + def _score(self, **over): + return build_scorecard({"delivery_quality": _delivery(**over)}, + {})["dimensions"][0] + + def test_happy(self): + dim = self._score() + self.assertEqual(dim["score"], 10.0) + self.assertEqual(dim["status"], "pass") + + def test_missing_and_untested(self): + dim = self._score(first_pass_rate=None, defect_known=False, + attempts_present=False) + self.assertIsNone(dim["score"]) + self.assertIn("first_pass_rate", dim["missing_items"]) + self.assertIn("defect_known_not_known", dim["missing_items"]) + self.assertIn("attempts_present_false_or_missing", + dim["missing_items"]) + dim = self._score(evidence_kind="fixture") + self.assertIsNone(dim["score"]) + self.assertTrue(any("fixture" in u for u in dim["untested_items"])) + dim = self._score(evidence_kind="estimated") + self.assertIsNone(dim["score"]) + + def test_mid_scores(self): + dim = self._score(first_pass_rate=0.7, defect_rate=0.10, + rework_rate=0.30) + self.assertLess(dim["score"], 9.0) + self.assertEqual(dim["status"], "fail") + + +class GateTestCase(unittest.TestCase): + def _score(self, **over): + return build_scorecard({"gate_audit": _gate(**over)}, + {})["dimensions"][1] + + def test_happy(self): + self.assertEqual(self._score()["score"], 10.0) + + def test_unknown_and_conditional(self): + dim = self._score(hard_gate_enforced=None) + self.assertIsNone(dim["score"]) + self.assertIn("hard_gate_enforced_unknown", dim["missing_items"]) + dim = self._score(gate_items_passed=None) + self.assertIsNone(dim["score"]) + dim = self._score(audit_verdict="conditional") + self.assertIsNone(dim["score"]) + self.assertTrue(any("conditional" in u + for u in dim["untested_items"])) + dim = self._score(evidence_kind="dry_run") + self.assertIsNone(dim["score"]) + + def test_caps(self): + self.assertEqual(self._score(hard_gate_enforced=False)["score"], 4.0) + self.assertEqual(self._score(merge_blocked_verified=False)["score"], 4.0) + self.assertEqual(self._score(loop_end_to_end_verified=False)["score"], 4.0) + + def test_misjudgment_unknown_and_scored(self): + dim = self._score(sampling_misjudgment_rate=None) + self.assertIsNone(dim["score"]) + self.assertIn("sampling_misjudgment_rate_unknown", + dim["missing_items"]) + dim = self._score(sampling_misjudgment_rate=0.10) + self.assertLess(dim["score"], 10.0) + dim = self._score(gate_items_total=0, gate_items_passed=0, + sampling_misjudgment_rate=0.0) + self.assertEqual(dim["score"], 0.0) + + +class ArchTestCase(unittest.TestCase): + def _score(self, **over): + return build_scorecard({"arch_contract": _arch(**over)}, + {})["dimensions"][2] + + def test_happy_and_unknown(self): + self.assertEqual(self._score()["score"], 10.0) + dim = self._score(arch_violations=None, contract_frozen=None) + self.assertIsNone(dim["score"]) + self.assertIn("arch_violations_unknown", dim["missing_items"]) + self.assertIn("contract_frozen_unknown", dim["missing_items"]) + + def test_caps(self): + self.assertEqual(self._score(contract_frozen=False)["score"], 4.0) + self.assertEqual( + self._score(stack_changed_without_approval=True)["score"], 4.0) + self.assertEqual(self._score(arch_violations=2)["score"], 5.0) + self.assertEqual(self._score(directory_violations=1)["score"], 5.0) + + +class TestDimTestCase(unittest.TestCase): + def _score(self, **over): + return build_scorecard({"test_coverage": _test(**over)}, + {})["dimensions"][3] + + def test_happy(self): + self.assertEqual(self._score()["score"], 10.0) + + def test_unknown_and_untested(self): + dim = self._score(prd_coverage_rate=None) + self.assertIsNone(dim["score"]) + dim = self._score(cross_matrix_kind="viewport_substitute", + real_pg=True, real_service=True) + self.assertIsNone(dim["score"]) + self.assertTrue(any("viewport_substitute" in u + for u in dim["untested_items"])) + self.assertTrue(any("cross_matrix_not_real" in u + for u in dim["untested_items"])) + dim = self._score(cross_matrix_kind="code_review_only") + self.assertIsNone(dim["score"]) + dim = self._score(real_pg=False, real_service=True) + self.assertIsNone(dim["score"]) + dim = self._score(real_service=False, real_pg=True) + self.assertIsNone(dim["score"]) + + def test_agent_six_cap_and_static(self): + self.assertEqual(self._score(agent_six_complete=False)["score"], 4.0) + dim = self._score(lint_items=None) + self.assertIsNone(dim["score"]) + self.assertIn("lint_items_missing", dim["missing_items"]) + dim = self._score(lint_items=3) + self.assertEqual(dim["score"], 8.0) + dim = self._score(bandit_high=1) + self.assertEqual(dim["score"], 8.0) + + +class SecurityTestCase(unittest.TestCase): + def _score(self, **over): + return build_scorecard({"security_ledger": _security(**over)}, + {})["dimensions"][4] + + def test_happy_and_unknown(self): + self.assertEqual(self._score()["score"], 10.0) + dim = self._score(ledger_verify_ok=None, branch_protection="unknown") + self.assertIsNone(dim["score"]) + self.assertIn("ledger_verify_ok_unknown", dim["missing_items"]) + self.assertIn("branch_protection_unknown", dim["missing_items"]) + dim = self._score(ledger_coverage_rate=None, branch_protection=None) + self.assertIsNone(dim["score"]) + + def test_caps(self): + self.assertEqual(self._score(branch_protection="unprotected")["score"], 4.0) + self.assertEqual(self._score(ledger_verify_ok=False)["score"], 4.0) + self.assertEqual(self._score(anchor_check_recent=False)["score"], 8.0) + + def test_no_whitelist_rate(self): + dim = self._score(shell_whitelist_pass_rate=None) + self.assertEqual(dim["score"], 10.0) + + +class CostTestCase(unittest.TestCase): + def _score(self, **over): + return build_scorecard({"cost_quota": _cost(**over)}, + {})["dimensions"][5] + + def test_happy_and_unknown(self): + self.assertEqual(self._score()["score"], 10.0) + dim = self._score(cost_records_complete=False) + self.assertIsNone(dim["score"]) + dim = self._score(unpriced_as_zero=None) + self.assertIsNone(dim["score"]) + + def test_caps(self): + self.assertEqual(self._score(unpriced_as_zero=True)["score"], 0.0) + self.assertEqual(self._score(quota_cutoff_verified=False)["score"], 4.0) + self.assertEqual(self._score(per_creator_present=False)["score"], 8.0) + + +class OpsTestCase(unittest.TestCase): + def _score(self, **over): + return build_scorecard({"ops_scale": _ops(**over)}, + {})["dimensions"][6] + + def test_happy_and_unknown(self): + self.assertEqual(self._score()["score"], 10.0) + dim = self._score(ci_green_rate=None, rollback_drill_done=None, + ha_status=None) + self.assertIsNone(dim["score"]) + self.assertTrue(any(m.startswith("ci_green_rate_unknown") + for m in dim["missing_items"])) + self.assertIn("rollback_drill_done_unknown", dim["missing_items"]) + self.assertIn("ha_status_unknown", dim["missing_items"]) + + def test_caps_and_scale(self): + self.assertEqual(self._score(rollback_drill_done=False)["score"], 4.0) + self.assertEqual( + self._score(ha_status="single_host_unaccepted")["score"], 5.0) + dim = self._score(parallel_scale_proven=None) + self.assertIsNone(dim["score"]) + self.assertIn("parallel_scale_proven_unknown", dim["missing_items"]) + dim = self._score(parallel_scale_proven=None, + parallel_scale_accepted_alternative=True) + self.assertEqual(dim["score"], 10.0) + dim = self._score(parallel_scale_proven=18) + self.assertEqual(dim["score"], 4.5) + dim = self._score(deploy_success_rate=None) + self.assertEqual(dim["score"], 10.0) + dim = self._score(ha_status="single_host_accepted", + parallel_scale_proven=40) + self.assertEqual(dim["score"], 10.0) + + +class OverallTestCase(unittest.TestCase): + def test_all_pass(self): + card = build_scorecard(_all_good()) + self.assertEqual(card["overall"], "pass") + self.assertTrue(card["overall_pass"]) + self.assertEqual(card["average_score"], 10.0) + self.assertTrue(card["average_is_not_pass_criterion"]) + self.assertEqual(card["thresholds_source"], "test_default_unapproved") + self.assertEqual(set(card["thresholds"].keys()), + set(DEFAULT_THRESHOLDS.keys())) + + def test_average_is_not_pass_criterion(self): + # Six dims at 10, one at 4: average 9.1 but overall not pass. + evidence = _all_good() + evidence["cost_quota"] = _cost(quota_cutoff_verified=False) + card = build_scorecard(evidence) + self.assertGreaterEqual(card["average_score"], 9.0) + self.assertEqual(card["overall"], "not_pass") + self.assertFalse(card["overall_pass"]) + + def test_empty_evidence(self): + card = build_scorecard({}) + self.assertEqual(card["overall"], "not_pass") + self.assertIsNone(card["average_score"]) + self.assertTrue(all(d["score"] is None for d in card["dimensions"])) + self.assertGreater(len(card["missing_items"]), 0) + card_none = build_scorecard(None) + self.assertEqual(card_none["overall"], "not_pass") + + def test_user_approved_thresholds_source(self): + approved = {k: {"value": v["value"], "source": "user_approved"} + for k, v in DEFAULT_THRESHOLDS.items()} + card = build_scorecard(_all_good(), approved) + self.assertEqual(card["thresholds_source"], "user_approved") + + def test_example_current_state_is_not_pass(self): + card = example_scorecard_current_state() + self.assertEqual(card["overall"], "not_pass") + self.assertTrue(card["missing_items"] or card["untested_items"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/cloud/tests/test_v2cov_server_handlers.py b/cloud/tests/test_v2cov_server_handlers.py new file mode 100644 index 0000000..3db018c --- /dev/null +++ b/cloud/tests/test_v2cov_server_handlers.py @@ -0,0 +1,553 @@ +"""Coverage batch v2: cloud/server.py handler branches. + +Covers the baseline gaps in _full_auto_branch_protection_ok, +_full_auto_github_request, _admin_cost_by_creator, +_workers_heartbeat, _admin_backup and _task_trace_stream. + +Stubbing discipline: the method under test is never stubbed. Its +sinks and predicate collaborators are: _send_json (captured output), +_read_json (request body), _is_global_admin via the real +CODEX_GLOBAL_ADMINS env, _user_exists via a real users row, +_cost_by_creator_payload (fixed payload / ValueError for the month +branch), _registry_heartbeat (ValueError case only), backup_postgres +(a real temp file, pg_dump itself not run), _fetch_task / +_trace_snapshot / _auth_store for the SSE stream (the stream loop +and emit-failure logic under test stays real). +""" + +import io +import json +import os +import sqlite3 +import sys +import tempfile +import time +import unittest +import urllib.error +from pathlib import Path +from types import SimpleNamespace +from unittest import mock + +REPO_ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(REPO_ROOT)) + +import cloud.server as server_mod +from cloud.server import ( + CloudControlPlaneHandler, + FullAutoError, + _full_auto_branch_protection_ok, + _full_auto_github_request, + init_db, +) + + +def _handler(db_path): + h = CloudControlPlaneHandler.__new__(CloudControlPlaneHandler) + h.server = SimpleNamespace(db_path=db_path) + sent = [] + h._send_json = lambda status, obj, extra_headers=None: sent.append( + (status, obj)) + h.sent = sent + return h + + +def _task_row(status="running", task_id="T1"): + return { + "id": task_id, "project_id": "p1", "prompt": "do work", + "status": status, "idempotency_key": None, "depends_on": "[]", + "priority": 0, "created_by": "tester", "created_at": 0.0, + "updated_at": 0.0, "last_heartbeat": None, "result_text": None, + "result_text_full": None, "error_text": None, + "actual_model": None, "started_at": None, "finished_at": None, + } + + +class FullAutoHelpersTestCase(unittest.TestCase): + def _valid_protection(self, checks=("verify",)): + return { + "required_status_checks": {"strict": True, + "contexts": list(checks)}, + "enforce_admins": True, + "required_pull_request_reviews": { + "required_approving_review_count": 1}, + "allow_force_pushes": False, + "allow_deletions": False, + } + + def test_branch_protection_ok(self): + self.assertTrue(_full_auto_branch_protection_ok( + self._valid_protection(), ["verify"])) + self.assertTrue(_full_auto_branch_protection_ok( + self._valid_protection(("verify", "lint")), + ["verify"])) + + def test_branch_protection_failures(self): + self.assertFalse(_full_auto_branch_protection_ok( + "not-a-dict", ["verify"])) + self.assertFalse(_full_auto_branch_protection_ok( + self._valid_protection(), [])) + bad = self._valid_protection() + bad["required_status_checks"] = {"strict": False, + "contexts": ["verify"]} + self.assertFalse(_full_auto_branch_protection_ok(bad, ["verify"])) + bad = self._valid_protection() + bad["required_status_checks"] = ["verify"] + self.assertFalse(_full_auto_branch_protection_ok(bad, ["verify"])) + bad = self._valid_protection() + bad["required_status_checks"] = {"strict": True, + "contexts": ["other"]} + self.assertFalse(_full_auto_branch_protection_ok(bad, ["verify"])) + bad = self._valid_protection() + bad["enforce_admins"] = False + self.assertFalse(_full_auto_branch_protection_ok(bad, ["verify"])) + bad = self._valid_protection() + bad["required_pull_request_reviews"] = None + self.assertFalse(_full_auto_branch_protection_ok(bad, ["verify"])) + bad = self._valid_protection() + bad["allow_force_pushes"] = True + self.assertFalse(_full_auto_branch_protection_ok(bad, ["verify"])) + bad = self._valid_protection() + bad["allow_deletions"] = True + self.assertFalse(_full_auto_branch_protection_ok(bad, ["verify"])) + + def test_github_request_fake_client(self): + def fake(method, url, headers, body): + return 201, {"ok": True} + + self.assertEqual(_full_auto_github_request( + fake, "POST", "https://example.invalid/x", {}, None), + (201, {"ok": True})) + with self.assertRaises(FullAutoError): + _full_auto_github_request( + lambda m, u, h, b: {"not": "a-tuple"}, + "GET", "https://example.invalid/x", {}, None) + + def test_github_request_urllib_paths(self): + class _Resp: + def __init__(self, raw, status=200): + self._raw = raw + self.status = status + + def read(self): + return self._raw + + def __enter__(self): + return self + + def __exit__(self, *args): + return False + + with mock.patch("urllib.request.urlopen", + return_value=_Resp(b'{"a": 1}', 200)): + self.assertEqual(_full_auto_github_request( + None, "GET", "https://example.invalid/x", {}, None), + (200, {"a": 1})) + err = urllib.error.HTTPError( + "https://example.invalid/x", 404, "Not Found", {}, + io.BytesIO(b'{"error": "nope"}')) + with mock.patch("urllib.request.urlopen", side_effect=err): + self.assertEqual(_full_auto_github_request( + None, "GET", "https://example.invalid/x", {}, None), + (404, {"error": "nope"})) + with mock.patch("urllib.request.urlopen", + return_value=_Resp(b'not json', 200)): + self.assertEqual(_full_auto_github_request( + None, "GET", "https://example.invalid/x", {}, None), + (200, {})) + with mock.patch("urllib.request.urlopen", + return_value=_Resp(b'[1, 2]', 200)): + self.assertEqual(_full_auto_github_request( + None, "GET", "https://example.invalid/x", {}, None), + (200, {})) + + +class AdminCostTestCase(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.db_path = os.path.join(self.tmp.name, "control.db") + init_db(self.db_path) + conn = sqlite3.connect(self.db_path) + try: + conn.execute( + "CREATE TABLE IF NOT EXISTS users(id TEXT PRIMARY KEY," + " username TEXT UNIQUE, api_key_hash TEXT," + " created_at REAL)") + conn.execute( + "INSERT OR IGNORE INTO users(id, username, api_key_hash," + " created_at) VALUES('u1', 'u1', '', 0)") + conn.commit() + finally: + conn.close() + + def tearDown(self): + self.tmp.cleanup() + + def _handler(self): + h = _handler(self.db_path) + audits = [] + h._audit_admin = lambda u, a, d: audits.append((u, a, d)) + h.audits = audits + return h + + def test_forbidden_for_non_admin(self): + h = self._handler() + with mock.patch.dict(os.environ, {"CODEX_GLOBAL_ADMINS": "admin1"}, + clear=False): + h._admin_cost_by_creator("nobody", {"user_id": ["u1"]}) + self.assertEqual(h.sent[0][0], 403) + self.assertTrue(any(a == "admin_quota_denied" for _u, a, _d in h.audits)) + + def test_target_validation(self): + h = self._handler() + with mock.patch.dict(os.environ, {"CODEX_GLOBAL_ADMINS": "admin1"}): + h._admin_cost_by_creator("admin1", {}) + self.assertEqual(h.sent[0][0], 400) + h.sent.clear() + h._admin_cost_by_creator("admin1", {"user_id": ["ghost"]}) + self.assertEqual(h.sent[0][0], 404) + + def test_month_value_error_and_success(self): + h = self._handler() + h._cost_by_creator_payload = mock.Mock( + side_effect=ValueError("bad month")) + with mock.patch.dict(os.environ, {"CODEX_GLOBAL_ADMINS": "admin1"}): + h._admin_cost_by_creator( + "admin1", {"user_id": ["u1"], "month": ["2026-13"]}) + self.assertEqual(h.sent[0][0], 400) + self.assertIn("YYYY-MM", h.sent[0][1]["error"]) + h.sent.clear() + h._cost_by_creator_payload = mock.Mock( + return_value={"user_id": "u1", "month": "2026-11"}) + with mock.patch.dict(os.environ, {"CODEX_GLOBAL_ADMINS": "admin1"}): + h._admin_cost_by_creator( + "admin1", {"user_id": ["u1"], "month": ["2026-11"]}) + self.assertEqual(h.sent[0][0], 200) + self.assertEqual(h.sent[0][1]["user_id"], "u1") + + +class WorkersHeartbeatTestCase(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.db_path = os.path.join(self.tmp.name, "control.db") + init_db(self.db_path) + + def tearDown(self): + self.tmp.cleanup() + + def _call(self, body=None, err=None): + h = _handler(self.db_path) + h._read_json = lambda: (body, err) + h._workers_heartbeat("u1") + return h.sent + + def test_read_error(self): + sent = self._call(body=None, err="invalid json") + self.assertEqual(sent[0][0], 400) + + def test_validation_errors(self): + self.assertEqual(self._call({})[0][1]["error"], + "worker_id required") + self.assertEqual(self._call({"worker_id": "w1", + "capacity": "many"})[0][1]["error"], + "capacity must be an integer") + self.assertEqual(self._call({"worker_id": "w1", + "running": "many"})[0][1]["error"], + "running must be an integer") + self.assertEqual(self._call({"worker_id": "w1", + "running": -1})[0][1]["error"], + "running must be >= 0") + self.assertEqual(self._call({"worker_id": "w1", + "host": 123})[0][1]["error"], + "host must be a string") + self.assertEqual(self._call({"worker_id": "w1", + "pid": "12"})[0][1]["error"], + "pid must be an integer") + self.assertEqual(self._call({"worker_id": "w1", + "pid": 0})[0][1]["error"], + "pid must be > 0") + self.assertEqual(self._call({"worker_id": "w1", + "address": 5})[0][1]["error"], + "address must be a string") + + def test_registry_value_error(self): + h = _handler(self.db_path) + h._read_json = lambda: ({"worker_id": "w1"}, None) + with mock.patch("cloud.server._registry_heartbeat", + side_effect=ValueError("worker_id required")): + h._workers_heartbeat("u1") + self.assertEqual(h.sent[0][0], 400) + self.assertIn("worker_id required", h.sent[0][1]["error"]) + + def test_success_registers(self): + sent = self._call({"worker_id": "w1", "capacity": 2, + "running": 0, "host": "h1", "pid": 123, + "address": "10.0.0.1"}) + self.assertEqual(sent[0][0], 200) + self.assertTrue(sent[0][1]["alive"]) + self.assertEqual(sent[0][1]["worker_id"], "w1") + + +class AdminBackupTestCase(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.db_path = os.path.join(self.tmp.name, "control.db") + init_db(self.db_path) + self.out_root = os.path.join(self.tmp.name, "backups") + os.makedirs(self.out_root) + + def tearDown(self): + self.tmp.cleanup() + + def _handler(self): + h = _handler(self.db_path) + h._audit_admin = lambda u, a, d: None + return h + + def test_read_error_and_out_dir_type(self): + h = self._handler() + h._read_json = lambda: (None, "invalid json") + with mock.patch.dict(os.environ, {"CODEX_GLOBAL_ADMINS": "admin1"}): + h._admin_backup("admin1") + self.assertEqual(h.sent[0][0], 400) + h.sent.clear() + h._read_json = lambda: ({"out_dir": 123}, None) + with mock.patch.dict(os.environ, {"CODEX_GLOBAL_ADMINS": "admin1"}): + h._admin_backup("admin1") + self.assertEqual(h.sent[0][0], 400) + self.assertIn("out_dir must be a string", h.sent[0][1]["error"]) + + def test_forbidden_and_outside_root(self): + h = self._handler() + h._read_json = lambda: ({"out_dir": self.out_root}, None) + with mock.patch.dict(os.environ, {"CODEX_GLOBAL_ADMINS": "admin1"}): + h._admin_backup("nobody") + self.assertEqual(h.sent[0][0], 403) + h.sent.clear() + h._read_json = lambda: ({"out_dir": "/etc"}, None) + with mock.patch.dict(os.environ, + {"CODEX_GLOBAL_ADMINS": "admin1", + "CLOUD_BACKUP_ROOT": self.out_root}): + h._admin_backup("admin1") + self.assertEqual(h.sent[0][0], 400) + + def test_sqlite_backup_with_fallback_root(self): + # No out_dir and no CLOUD_BACKUP_ROOT: the CLOUD_BACKUP_DIR + # fallback root supplies roots[0] as the candidate. + h = self._handler() + h._read_json = lambda: ({}, None) + env = {"CODEX_GLOBAL_ADMINS": "admin1", + "CLOUD_BACKUP_DIR": self.out_root} + with mock.patch.dict(os.environ, env): + os.environ.pop("CLOUD_BACKUP_ROOT", None) + os.environ.pop("CLOUD_DB_BACKEND", None) + h._admin_backup("admin1") + self.assertEqual(h.sent[0][0], 200, h.sent) + self.assertTrue(h.sent[0][1]["path"].startswith(self.out_root)) + + def test_postgres_backup_branch_with_fake_dump(self): + dest = os.path.join(self.out_root, "pg.dump") + Path(dest).write_bytes(b"fake-dump") + h = self._handler() + # _db() resolves the backend from env and refuses PG without + # a driver; the backup branch under test is the selection + + # success response, so the count query runs on the real + # SQLite file through a plain connection here. + h._db = lambda: sqlite3.connect(self.db_path) + h._read_json = lambda: ({"out_dir": self.out_root}, None) + env = {"CODEX_GLOBAL_ADMINS": "admin1", + "CLOUD_BACKUP_ROOT": self.out_root, + "CLOUD_DB_BACKEND": "postgres"} + import cloud.backup as backup_mod + with mock.patch.dict(os.environ, env): + with mock.patch.object(backup_mod, "backup_postgres", + return_value=dest): + h._admin_backup("admin1") + self.assertEqual(h.sent[0][0], 200, h.sent) + self.assertEqual(h.sent[0][1]["backend"], "postgres") + self.assertEqual(h.sent[0][1]["path"], dest) + + def test_chmod_failure_swallowed_and_backup_failure_500(self): + h = self._handler() + h._read_json = lambda: ({"out_dir": self.out_root}, None) + env = {"CODEX_GLOBAL_ADMINS": "admin1", + "CLOUD_BACKUP_ROOT": self.out_root} + with mock.patch.dict(os.environ, env): + with mock.patch("os.chmod", side_effect=OSError("chmod down")): + h._admin_backup("admin1") + self.assertEqual(h.sent[0][0], 200, h.sent) + h.sent.clear() + import cloud.backup as backup_mod + with mock.patch.dict(os.environ, env): + with mock.patch.object(backup_mod, "backup_db", + side_effect=RuntimeError("dump down")): + h._admin_backup("admin1") + self.assertEqual(h.sent[0][0], 500) + self.assertIn("backup failed", h.sent[0][1]["error"]) + + +class _AuthCtx: + def __enter__(self): + return SimpleNamespace(check_permission=lambda *args: True) + + def __exit__(self, *args): + return False + + +class _Writer: + def __init__(self, fail_after=None): + self.chunks = [] + self.calls = 0 + self.fail_after = fail_after + + def write(self, data): + self.calls += 1 + if self.fail_after is not None and self.calls > self.fail_after: + raise BrokenPipeError("pipe closed") + self.chunks.append(data.decode("utf-8") + if isinstance(data, bytes) else data) + + def flush(self): + pass + + +def _trace_handler(db_path, fetch, entries, writer=None, + headers_raise=False): + h = _handler(db_path) + h._fetch_task = fetch + h._trace_snapshot = lambda task: (entries, "") + h.send_response = lambda *a: (_ for _ in ()).throw( + BrokenPipeError("gone")) if headers_raise else None + h.send_header = lambda *a: None + h.end_headers = lambda: None + h.wfile = writer if writer is not None else _Writer() + return h + + +class TraceStreamTestCase(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.db_path = os.path.join(self.tmp.name, "control.db") + init_db(self.db_path) + + def tearDown(self): + self.tmp.cleanup() + + def _run(self, handler): + with mock.patch("cloud.server._auth_store", + lambda db_path: _AuthCtx()): + handler._task_trace_stream("u1", "T1") + return "".join(handler.wfile.chunks) + + def test_headers_broken_pipe_returns(self): + h = _trace_handler(self.db_path, lambda tid: _task_row(), + [{"id": "e1"}], headers_raise=True) + out = self._run(h) + self.assertEqual(out, "") + + def test_snapshot_emit_failure_returns(self): + writer = _Writer(fail_after=0) + h = _trace_handler(self.db_path, lambda tid: _task_row(), + [{"id": "e1"}], writer=writer) + out = self._run(h) + self.assertEqual(out, "") + self.assertEqual(writer.calls, 1) + + def test_poll_timeout(self): + h = _trace_handler(self.db_path, lambda tid: _task_row(), + [{"id": "e1"}]) + with mock.patch.object(server_mod, "TRACE_STREAM_MAX_SECONDS", + 0.05), \ + mock.patch.object(server_mod, "TRACE_STREAM_POLL_SECONDS", + 0.01): + out = self._run(h) + self.assertIn('"type": "snapshot"', out) + self.assertIn('"reason": "timeout"', out) + + def test_poll_deleted(self): + calls = [0] + + def _fetch(tid): + calls[0] += 1 + return _task_row() if calls[0] == 1 else None + + h = _trace_handler(self.db_path, _fetch, [{"id": "e1"}]) + with mock.patch.object(server_mod, "TRACE_STREAM_MAX_SECONDS", + 60), \ + mock.patch.object(server_mod, "TRACE_STREAM_POLL_SECONDS", + 0.01): + out = self._run(h) + self.assertIn('"status": "deleted"', out) + + def test_poll_new_entry_then_terminal(self): + calls = [0] + + def _fetch(tid): + calls[0] += 1 + if calls[0] == 1: + return _task_row(status="running") + return _task_row(status="completed") + + def _snapshot(task): + if task["status"] == "completed": + return [{"id": "e1"}, {"id": "e2"}], "" + return [{"id": "e1"}], "" + + h = _trace_handler(self.db_path, _fetch, [{"id": "e1"}]) + h._trace_snapshot = _snapshot + with mock.patch.object(server_mod, "TRACE_STREAM_MAX_SECONDS", + 60), \ + mock.patch.object(server_mod, "TRACE_STREAM_POLL_SECONDS", + 0.01): + out = self._run(h) + self.assertIn('"type": "entry"', out) + self.assertIn('"status": "completed"', out) + + def test_poll_emit_failure_returns(self): + writer = _Writer(fail_after=1) + calls = [0] + + def _fetch(tid): + calls[0] += 1 + if calls[0] == 1: + return _task_row(status="running") + return _task_row(status="completed") + + def _snapshot(task): + if task["status"] == "completed": + return [{"id": "e1"}, {"id": "e2"}], "" + return [{"id": "e1"}], "" + + h = _trace_handler(self.db_path, _fetch, [{"id": "e1"}], + writer=writer) + h._trace_snapshot = _snapshot + with mock.patch.object(server_mod, "TRACE_STREAM_MAX_SECONDS", + 60), \ + mock.patch.object(server_mod, "TRACE_STREAM_POLL_SECONDS", + 0.01): + out = self._run(h) + # Snapshot write ok, loop entry write fails -> stream ends + # without a done event. + self.assertIn('"type": "snapshot"', out) + self.assertNotIn('"type": "done"', out) + + def test_poll_fetch_broken_pipe_returns(self): + calls = [0] + + def _fetch(tid): + calls[0] += 1 + if calls[0] == 1: + return _task_row(status="running") + raise BrokenPipeError("gone") + + h = _trace_handler(self.db_path, _fetch, [{"id": "e1"}]) + with mock.patch.object(server_mod, "TRACE_STREAM_MAX_SECONDS", + 60), \ + mock.patch.object(server_mod, "TRACE_STREAM_POLL_SECONDS", + 0.01): + out = self._run(h) + self.assertIn('"type": "snapshot"', out) + self.assertNotIn('"reason": "timeout"', out) + + +if __name__ == "__main__": + unittest.main() diff --git a/cloud/tests/test_v2cov_server_handlers2.py b/cloud/tests/test_v2cov_server_handlers2.py new file mode 100644 index 0000000..b4c2e9f --- /dev/null +++ b/cloud/tests/test_v2cov_server_handlers2.py @@ -0,0 +1,915 @@ +"""Coverage batch v2 (D2): cloud/server.py handler validation branches. + +Covers baseline gaps in _create_project, _create_environment, +_create_upload, _automation_create, _automation_bind, +_automation_heartbeat, _merge_task, _loop_run, _audit_loop_confirm, +_full_auto_execute and _create_task (gateway / attachment / +idempotency). + +Stubbing discipline: the method under test is never stubbed. Its +sinks and predicate collaborators are: _send_json (captured output), +_read_json (request body), _db (a proxy connection that delegates +every statement except the one under test, for the TOCTOU +IntegrityError simulations -- the same pattern as the worker retry +rowcount-0 test), _validate_sub2api_key / _pushback_block / +_loop_verdict / _loop_confirm (collaborator error injection for the +handler's own error mapping), and _loop_ledger_store / +_loop_create_subtask (close/subtask failure injection). All other +logic -- validation, permission checks via the real AuthStore, +quota gates, driver result shaping -- runs for real. +""" + +import json +import os +import sqlite3 +import sys +import tempfile +import time +import unittest +from pathlib import Path +from types import SimpleNamespace +from unittest import mock + +REPO_ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(REPO_ROOT)) + +import cloud.server as server_mod +from cloud.server import CloudControlPlaneHandler, _auth_store, init_db +from cloud.sub2api import Sub2apiError as _Sub2apiError + + +def _handler(db_path, upload_dir=None): + h = CloudControlPlaneHandler.__new__(CloudControlPlaneHandler) + h.server = SimpleNamespace(db_path=db_path, upload_dir=upload_dir) + sent = [] + h._send_json = lambda status, obj, extra_headers=None: sent.append( + (status, obj)) + h.sent = sent + return h + + +def _conn(db_path): + c = sqlite3.connect(db_path) + c.row_factory = sqlite3.Row + return c + + +class _ProxyConn: + """Delegate everything except the statements a test targets.""" + + def __init__(self, real, fail_insert_prefix=None, + fail_insert_exc=None, first_select_none=None, + close_exc=None): + self._real = real + self._fail_insert_prefix = fail_insert_prefix + self._fail_insert_exc = fail_insert_exc + self._first_select_none = first_select_none + self._seen_select = 0 + self._close_exc = close_exc + + def execute(self, sql, params=()): + text = sql.strip() + if (self._fail_insert_prefix + and text.startswith(self._fail_insert_prefix)): + raise self._fail_insert_exc + if (self._first_select_none + and self._first_select_none in text + and text.startswith("SELECT")): + self._seen_select += 1 + if self._seen_select == 1: + class _Empty: + def fetchone(self): + return None + + def fetchall(self): + return [] + return _Empty() + return self._real.execute(sql, params) + + def close(self): + if self._close_exc is not None: + raise self._close_exc + return self._real.close() + + def __getattr__(self, name): + return getattr(self._real, name) + + +class _Base(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.db_path = os.path.join(self.tmp.name, "test.db") + init_db(self.db_path) + self.upload_dir = os.path.join(self.tmp.name, "uploads") + os.makedirs(self.upload_dir, exist_ok=True) + + def tearDown(self): + self.tmp.cleanup() + + def _h(self, body=None, err=None): + h = _handler(self.db_path, upload_dir=self.upload_dir) + h._read_json = lambda: (body, err) + return h + + def _project(self, pid="p1", owner="u1", repo_url=None): + c = _conn(self.db_path) + c.execute( + "INSERT INTO projects(id, name, owner_id, created_at," + " repo_url, auto_pr) VALUES(?, ?, ?, ?, ?, 0)", + (pid, "P", owner, time.time(), repo_url)) + c.commit() + c.close() + with _auth_store(self.db_path) as store: + store.create_project(pid, owner) + + def _member(self, pid, uid, role): + c = _conn(self.db_path) + c.execute( + "INSERT OR REPLACE INTO project_members(project_id," + " user_id, role) VALUES(?, ?, ?)", (pid, uid, role)) + c.commit() + c.close() + + def _task(self, tid="t1", pid="p1", status="completed", + created_by="u1", **extra): + cols = ["id", "project_id", "prompt", "status", "created_by", + "created_at", "updated_at"] + vals = [tid, pid, "do work", status, created_by, time.time(), + time.time()] + for key, val in extra.items(): + cols.append(key) + vals.append(val) + c = _conn(self.db_path) + c.execute( + "INSERT INTO tasks(%s) VALUES(%s)" + % (", ".join(cols), ", ".join("?" * len(cols))), vals) + c.commit() + c.close() + + def _last(self, h): + self.assertTrue(h.sent, "handler sent no response") + return h.sent[-1] + + +class ProjectEnvUploadTestCase(_Base): + def test_create_project_read_error(self): + h = self._h(err="bad json") + h._create_project("u1") + self.assertEqual(self._last(h)[0], 400) + + def test_create_project_name_required(self): + h = self._h(body={}) + h._create_project("u1") + code, payload = self._last(h) + self.assertEqual(code, 400) + self.assertIn("name required", payload["error"]) + + def test_create_project_id_must_be_string(self): + h = self._h(body={"name": "P", "id": 42}) + h._create_project("u1") + code, payload = self._last(h) + self.assertEqual(code, 400) + self.assertIn("id must be a string", payload["error"]) + + def test_create_project_auto_pr_bool(self): + # Bool branch (True -> 1): the 3351 line. + h = self._h(body={"name": "P", "auto_pr": True}) + h._create_project("u1") + code, payload = self._last(h) + self.assertEqual(code, 201) + self.assertEqual(payload["auto_pr"], 1) + + def test_create_project_existing_409(self): + self._project() + h = self._h(body={"name": "P2", "id": "p1"}) + h._create_project("u1") + self.assertEqual(self._last(h)[0], 409) + + def test_create_project_integrity_error_409(self): + # TOCTOU simulation: SELECT finds nothing, INSERT races and + # loses; the handler must map it to the same 409. Each _db() + # call gets a fresh proxy so closing one connection cannot + # poison the next. + h = self._h(body={"name": "P", "id": "p9"}) + h._db = lambda: _ProxyConn( + _conn(self.db_path), + fail_insert_prefix="INSERT INTO projects", + fail_insert_exc=sqlite3.IntegrityError("race")) + h._create_project("u1") + self.assertEqual(self._last(h)[0], 409) + + def test_create_environment_read_error(self): + h = self._h(err="bad json") + h._create_environment("u1") + self.assertEqual(self._last(h)[0], 400) + + def test_create_environment_project_id_required(self): + h = self._h(body={}) + h._create_environment("u1") + code, payload = self._last(h) + self.assertEqual(code, 400) + self.assertIn("project_id required", payload["error"]) + + def test_create_environment_name_too_long(self): + self._project(repo_url="https://example.com/r.git") + h = self._h(body={"project_id": "p1", "name": "x" * 101}) + h._create_environment("u1") + code, payload = self._last(h) + self.assertEqual(code, 400) + self.assertIn("name too long", payload["error"]) + + def test_create_environment_integrity_error_409(self): + self._project(repo_url="https://example.com/r.git") + h = self._h(body={"project_id": "p1", "name": "env1"}) + h._db = lambda: _ProxyConn( + _conn(self.db_path), + fail_insert_prefix="INSERT INTO environments", + fail_insert_exc=sqlite3.IntegrityError("race")) + h._create_environment("u1") + self.assertEqual(self._last(h)[0], 409) + + def _multipart(self, parts): + boundary = "BOUNDARY" + out = b"" + for headers, data in parts: + out += ("--%s\r\n" % boundary).encode() + for key, val in headers: + out += ("%s: %s\r\n" % (key, val)).encode() + out += b"\r\n" + data + b"\r\n" + out += ("--%s--\r\n" % boundary).encode() + return out, "multipart/form-data; boundary=" + boundary + + def test_create_upload_content_length_value_error(self): + h = self._h() + h.headers = {"Content-Type": "multipart/form-data; boundary=X", + "Content-Length": "not-a-number"} + h._read_body = lambda: b"" + h._create_upload("u1") + code, payload = self._last(h) + # Length falls back to 0, then the empty body fails parsing. + self.assertEqual(code, 400) + + def test_create_upload_too_large(self): + from cloud.uploads import MAX_RAW_BYTES + h = self._h() + h.headers = {"Content-Type": "multipart/form-data; boundary=X", + "Content-Length": str(MAX_RAW_BYTES + 1)} + h._read_body = lambda: b"x" + h._create_upload("u1") + self.assertEqual(self._last(h)[0], 413) + + def test_create_upload_parse_error(self): + raw, ctype = self._multipart([]) + h = self._h() + h.headers = {"Content-Type": ctype, + "Content-Length": str(len(b"garbage"))} + h._read_body = lambda: b"garbage" + h._create_upload("u1") + self.assertEqual(self._last(h)[0], 400) + + def test_create_upload_no_files(self): + # Zero parts: fields and files are both empty, so the paths + # check passes vacuously and the no-files branch fires. + raw, ctype = self._multipart([]) + h = self._h() + h.headers = {"Content-Type": ctype, + "Content-Length": str(len(raw))} + h._read_body = lambda: raw + h._create_upload("u1") + code, payload = self._last(h) + self.assertEqual(code, 400) + self.assertIn("no files", payload["error"]) + + def test_create_upload_paths_mismatch(self): + raw, ctype = self._multipart([ + ([("Content-Disposition", + 'form-data; name="f1"; filename="a.txt"'), + ("Content-Type", "text/plain")], b"hello"), + ([("Content-Disposition", + 'form-data; name="f2"; filename="b.txt"'), + ("Content-Type", "text/plain")], b"world"), + ([("Content-Disposition", 'form-data; name="paths"')], + b'["a.txt"]')]) + h = self._h() + h.headers = {"Content-Type": ctype, + "Content-Length": str(len(raw))} + h._read_body = lambda: raw + h._create_upload("u1") + code, payload = self._last(h) + self.assertEqual(code, 400) + self.assertIn("数量不一致", payload["error"]) + + +class AutomationTestCase(_Base): + def _auto(self, name="auto1", project_id="p1", prompt="do it"): + from cloud.automations import create_automation + c = _conn(self.db_path) + auto = create_automation(c, project_id, name, prompt, "u1") + c.close() + return auto + + def _bind(self, aid, tid, mode="heartbeat"): + from cloud.lifecycle import bind_automation + c = _conn(self.db_path) + binding = bind_automation(c, aid, "p1", tid, mode) + c.close() + return binding + + def test_automation_create_read_error(self): + h = self._h(err="bad json") + h._automation_create("u1", "p1") + self.assertEqual(self._last(h)[0], 400) + + def test_automation_create_interval_none_defaults(self): + self._project() + h = self._h(body={"name": "a1", "prompt": "p", + "interval_seconds": None}) + h._automation_create("u1", "p1") + code, payload = self._last(h) + self.assertEqual(code, 201) + self.assertEqual(payload["interval_seconds"], 86400) + + def test_automation_create_bad_first_run(self): + self._project() + h = self._h(body={"name": "a1", "prompt": "p", + "first_run_at": "not-a-time"}) + h._automation_create("u1", "p1") + code, payload = self._last(h) + self.assertEqual(code, 400) + self.assertIn("first_run_at", payload["error"]) + + def test_automation_create_run_once_must_be_bool(self): + self._project() + h = self._h(body={"name": "a1", "prompt": "p", + "run_once": "yes"}) + h._automation_create("u1", "p1") + code, payload = self._last(h) + self.assertEqual(code, 400) + self.assertIn("run_once", payload["error"]) + + def test_automation_create_generic_error_502(self): + self._project() + h = self._h(body={"name": "a1", "prompt": "p"}) + with mock.patch("cloud.server._auto_create", + side_effect=RuntimeError("db boom")): + h._automation_create("u1", "p1") + code, payload = self._last(h) + self.assertEqual(code, 502) + self.assertIn("db boom", payload["error"]) + + def test_automation_bind_read_error(self): + h = self._h(err="bad json") + h._automation_bind("u1", "p1", "a1") + self.assertEqual(self._last(h)[0], 400) + + def test_automation_bind_missing_404(self): + h = self._h(body={"task_id": "t1"}) + h._automation_bind("u1", "p1", "nope") + self.assertEqual(self._last(h)[0], 404) + + def test_automation_bind_cancelled_409(self): + auto = self._auto() + c = _conn(self.db_path) + c.execute("UPDATE automations SET status='cancelled' WHERE id=?", + (auto["id"],)) + c.commit() + c.close() + self._task() + h = self._h(body={"task_id": "t1"}) + h._automation_bind("u1", "p1", auto["id"]) + self.assertEqual(self._last(h)[0], 409) + + def test_automation_bind_target_missing_404(self): + auto = self._auto() + h = self._h(body={"task_id": "ghost"}) + h._automation_bind("u1", "p1", auto["id"]) + self.assertEqual(self._last(h)[0], 404) + + def test_automation_bind_target_cross_project_404(self): + auto = self._auto() + self._project(pid="p2", owner="u2") + self._task(tid="t2", pid="p2", created_by="u2") + h = self._h(body={"task_id": "t2"}) + h._automation_bind("u1", "p1", auto["id"]) + self.assertEqual(self._last(h)[0], 404) + + def test_automation_bind_bad_mode_400(self): + auto = self._auto() + self._task() + h = self._h(body={"task_id": "t1", "mode": "bogus"}) + h._automation_bind("u1", "p1", auto["id"]) + code, payload = self._last(h) + self.assertEqual(code, 400) + self.assertIn("heartbeat or cron", payload["error"]) + + def test_automation_heartbeat_read_error(self): + h = self._h(err="bad json") + h._automation_heartbeat("u1", "p1", "a1") + self.assertEqual(self._last(h)[0], 400) + + def test_automation_heartbeat_missing_404(self): + h = self._h(body={}) + h._automation_heartbeat("u1", "p1", "nope") + self.assertEqual(self._last(h)[0], 404) + + def test_automation_heartbeat_no_binding_409(self): + auto = self._auto() + h = self._h(body={}) + h._automation_heartbeat("u1", "p1", auto["id"]) + code, payload = self._last(h) + self.assertEqual(code, 409) + self.assertIn("no heartbeat binding", payload["error"]) + + def test_automation_heartbeat_cron_binding_409(self): + auto = self._auto() + self._task() + self._bind(auto["id"], "t1", mode="cron") + h = self._h(body={}) + h._automation_heartbeat("u1", "p1", auto["id"]) + self.assertEqual(self._last(h)[0], 409) + + def test_automation_heartbeat_bound_task_missing_404(self): + auto = self._auto() + self._task() + self._bind(auto["id"], "t1") + c = _conn(self.db_path) + c.execute("DELETE FROM tasks WHERE id='t1'") + c.commit() + c.close() + h = self._h(body={}) + h._automation_heartbeat("u1", "p1", auto["id"]) + self.assertEqual(self._last(h)[0], 404) + + def test_automation_heartbeat_bound_task_cross_project_404(self): + auto = self._auto() + self._task() + self._bind(auto["id"], "t1") + c = _conn(self.db_path) + c.execute("UPDATE tasks SET project_id='p2' WHERE id='t1'") + c.commit() + c.close() + h = self._h(body={}) + h._automation_heartbeat("u1", "p1", auto["id"]) + self.assertEqual(self._last(h)[0], 404) + + def test_automation_heartbeat_quota_reject_returns(self): + # The bare return after the quota gate: quota lookup failure + # makes the gate answer 503, and heartbeat must return + # without delivering anything. + auto = self._auto() + self._task() + self._bind(auto["id"], "t1") + h = self._h(body={}) + h._quota_status = mock.Mock(side_effect=RuntimeError("down")) + h._automation_heartbeat("u1", "p1", auto["id"]) + self.assertEqual(len(h.sent), 1) + self.assertEqual(h.sent[0][0], 503) + + def test_automation_heartbeat_message_must_be_string(self): + auto = self._auto() + self._task() + self._bind(auto["id"], "t1") + h = self._h(body={"message": 123}) + h._automation_heartbeat("u1", "p1", auto["id"]) + code, payload = self._last(h) + self.assertEqual(code, 400) + self.assertIn("message required", payload["error"]) + + def test_automation_heartbeat_message_too_long(self): + auto = self._auto() + self._task() + self._bind(auto["id"], "t1") + h = self._h(body={"message": "x" * 20001}) + h._automation_heartbeat("u1", "p1", auto["id"]) + code, payload = self._last(h) + self.assertEqual(code, 400) + self.assertIn("message too long", payload["error"]) + + +class MergeLoopTestCase(_Base): + def _state(self): + return SimpleNamespace( + module_id="m", contract_version="v1", pushback_round=0, + failure_count=0, audit_fail_count=0, + acceptance_fail_count=0, rebuild_no=0, targeted_used=False, + status="open", pending_confirmation=False, retained_tests=[], + retained_passed_items=[], previous_artifacts=[], history=[]) + + def _loop_result(self, subtask=None): + return { + "next_state": self._state(), "action_kind": "verdict", + "reason": "r", "merge_blocked": False, + "release_blocked": False, "requires_reaudit": False, + "escalation_grants_user_acceptance": False, + "notification": None, "subtask": subtask} + + def _confirm_result(self, subtask=None): + result = self._loop_result(subtask) + result.pop("requires_reaudit") + result.pop("escalation_grants_user_acceptance") + return result + + def _closing_db(self): + # First _db() call serves _fetch_task (real); later calls + # serve the loop connection whose close() raises, so the + # finally-block except-pass lines run. + calls = {"n": 0} + + def _db(): + calls["n"] += 1 + if calls["n"] == 1: + return _conn(self.db_path) + return _ProxyConn(_conn(self.db_path), + close_exc=RuntimeError("close boom")) + return _db + + def _closing_store(self): + class _Store: + def close(self): + raise RuntimeError("store close boom") + return _Store() + + def test_merge_read_error(self): + self._project() + self._task(status="completed") + h = self._h(err="bad json") + h._merge_task("u1", "t1") + self.assertEqual(self._last(h)[0], 400) + + def test_merge_files_changed_must_be_list(self): + self._project() + self._task(status="completed") + h = self._h(body={"files_changed": "x"}) + h._merge_task("u1", "t1") + code, payload = self._last(h) + self.assertEqual(code, 400) + self.assertIn("files_changed must be a list", payload["error"]) + + def test_merge_pushback_unavailable_409(self): + self._project() + self._task(status="completed") + h = self._h(body={}) + with mock.patch.dict(os.environ, + {"CLOUD_MERGE_GATE_MODE": "off"}), \ + mock.patch("cloud.server._pushback_block", + side_effect=RuntimeError("chain down")): + h._merge_task("u1", "t1") + code, payload = self._last(h) + self.assertEqual(code, 409) + self.assertEqual(payload["error"], + "audit pushback state unavailable") + self.assertTrue(payload["audit_pushback"]["merge_blocked"]) + + def test_merge_pushback_unavailable_audit_fails_too(self): + # The audit best-effort except-pass: even when the audit + # write itself raises, the handler still answers the same 409. + self._project() + self._task(status="completed") + h = self._h(body={}) + real_auth = server_mod._auth_store + calls = {"n": 0} + + def _auth_store(db_path): + calls["n"] += 1 + if calls["n"] == 1: + return real_auth(db_path) + raise RuntimeError("audit down") + with mock.patch.dict(os.environ, + {"CLOUD_MERGE_GATE_MODE": "off"}), \ + mock.patch("cloud.server._pushback_block", + side_effect=RuntimeError("chain down")), \ + mock.patch("cloud.server._auth_store", + side_effect=_auth_store): + h._merge_task("u1", "t1") + code, payload = self._last(h) + self.assertEqual(code, 409) + self.assertEqual(payload["error"], + "audit pushback state unavailable") + + def test_loop_run_task_missing_404(self): + h = self._h() + h._loop_run("u1", "ghost", {}, "verdict") + self.assertEqual(self._last(h)[0], 404) + + def test_loop_run_forbidden_403(self): + self._project() + self._task() + h = self._h() + h._loop_run("u9", "t1", {}, "verdict") + self.assertEqual(self._last(h)[0], 403) + + def test_loop_run_verdict_body_400(self): + self._project() + self._task() + h = self._h() + h._loop_run("u1", "t1", {}, "verdict") + code, payload = self._last(h) + self.assertEqual(code, 400) + self.assertIn("verdict dict", payload["error"]) + + def test_loop_run_fix_body_400(self): + self._project() + self._task() + h = self._h() + h._loop_run("u1", "t1", {}, "fix") + self.assertEqual(self._last(h)[0], 400) + + def test_loop_run_correction_body_400(self): + self._project() + self._task() + h = self._h() + h._loop_run("u1", "t1", {}, "correction") + self.assertEqual(self._last(h)[0], 400) + + def test_loop_run_unknown_kind_400(self): + self._project() + self._task() + h = self._h() + h._loop_run("u1", "t1", {}, "bogus") + code, payload = self._last(h) + self.assertEqual(code, 400) + self.assertIn("unknown loop kind", payload["error"]) + + def test_loop_run_driver_value_error_400(self): + self._project() + self._task() + h = self._h() + with mock.patch("cloud.server._loop_verdict", + side_effect=ValueError("driver nope")): + h._loop_run("u1", "t1", {"verdict": {"verdict": "fail"}}, + "verdict") + self.assertEqual(self._last(h)[0], 400) + + def test_loop_run_driver_exception_503(self): + self._project() + self._task() + h = self._h() + with mock.patch("cloud.server._loop_verdict", + side_effect=RuntimeError("ledger down")): + h._loop_run("u1", "t1", {"verdict": {"verdict": "fail"}}, + "verdict") + code, payload = self._last(h) + self.assertEqual(code, 503) + self.assertEqual(payload["error"], "audit_loop_unavailable") + + def test_loop_run_close_failures_swallowed_200(self): + self._project() + self._task() + h = self._h() + h._db = self._closing_db() + h._loop_ledger_store = lambda: self._closing_store() + with mock.patch("cloud.server._loop_verdict", + return_value=self._loop_result()): + h._loop_run("u1", "t1", {"verdict": {"verdict": "fail"}}, + "verdict") + self.assertEqual(self._last(h)[0], 200) + + def test_loop_run_subtask_error_503(self): + self._project() + self._task() + h = self._h() + h._loop_create_subtask = mock.Mock( + side_effect=RuntimeError("insert boom")) + with mock.patch("cloud.server._loop_verdict", + return_value=self._loop_result( + subtask={"kind": "rebuild"})): + h._loop_run("u1", "t1", {"verdict": {"verdict": "fail"}}, + "verdict") + code, payload = self._last(h) + self.assertEqual(code, 503) + self.assertIn("subtask_error", payload) + self.assertIn("subtask_note", payload) + + def test_confirm_task_missing_404(self): + h = self._h(body={}) + h._audit_loop_confirm("u1", "ghost") + self.assertEqual(self._last(h)[0], 404) + + def test_confirm_forbidden_403(self): + self._project() + self._task() + h = self._h(body={}) + h._audit_loop_confirm("u9", "t1") + self.assertEqual(self._last(h)[0], 403) + + def test_confirm_read_error(self): + self._project() + self._task() + h = self._h(err="bad json") + h._audit_loop_confirm("u1", "t1") + self.assertEqual(self._last(h)[0], 400) + + def test_confirm_driver_value_error_400(self): + self._project() + self._task() + h = self._h(body={"user_confirmed": True, "confirmed_by": "u1"}) + with mock.patch("cloud.server._loop_confirm", + side_effect=ValueError("driver nope")): + h._audit_loop_confirm("u1", "t1") + self.assertEqual(self._last(h)[0], 400) + + def test_confirm_driver_exception_503(self): + self._project() + self._task() + h = self._h(body={"user_confirmed": True, "confirmed_by": "u1"}) + with mock.patch("cloud.server._loop_confirm", + side_effect=RuntimeError("ledger down")): + h._audit_loop_confirm("u1", "t1") + code, payload = self._last(h) + self.assertEqual(code, 503) + self.assertEqual(payload["error"], "audit_loop_unavailable") + + def test_confirm_close_failures_swallowed_200(self): + self._project() + self._task() + h = self._h(body={"user_confirmed": True, "confirmed_by": "u1"}) + h._db = self._closing_db() + h._loop_ledger_store = lambda: self._closing_store() + with mock.patch("cloud.server._loop_confirm", + return_value=self._confirm_result()): + h._audit_loop_confirm("u1", "t1") + self.assertEqual(self._last(h)[0], 200) + + def test_confirm_subtask_error_503(self): + self._project() + self._task() + h = self._h(body={"user_confirmed": True, "confirmed_by": "u1"}) + h._loop_create_subtask = mock.Mock( + side_effect=RuntimeError("insert boom")) + with mock.patch("cloud.server._loop_confirm", + return_value=self._confirm_result( + subtask={"kind": "rebuild"})): + h._audit_loop_confirm("u1", "t1") + code, payload = self._last(h) + self.assertEqual(code, 503) + self.assertIn("subtask_error", payload) + + +class FullAutoCreateTaskTestCase(_Base): + def _full_auto_h(self, body, err=None, admin=False): + h = self._h(body, err) + h._audit_admin = lambda *args: None + return h + + def test_full_auto_read_error(self): + h = self._full_auto_h(None, err="bad json") + h._full_auto_execute("u1") + self.assertEqual(self._last(h)[0], 400) + + def test_full_auto_plan_error(self): + h = self._full_auto_h({}) + h._full_auto_execute("u1") + code, payload = self._last(h) + self.assertEqual(code, 400) + self.assertIn("repo must be", payload["error"]) + + def test_full_auto_not_admin_denied(self): + h = self._full_auto_h({"repo": "o/r"}) + with mock.patch.dict(os.environ, {}, clear=False): + os.environ.pop("CODEX_GLOBAL_ADMINS", None) + h._full_auto_execute("u1") + code, payload = self._last(h) + self.assertEqual(code, 403) + self.assertEqual(payload["reason"], "not_global_admin") + + def test_full_auto_action_must_be_string(self): + h = self._full_auto_h({"repo": "o/r", "action": 123}) + with mock.patch.dict(os.environ, {"CODEX_GLOBAL_ADMINS": "u1"}): + h._full_auto_execute("u1") + code, payload = self._last(h) + self.assertEqual(code, 400) + self.assertIn("action must be a string", payload["error"]) + + def test_full_auto_disabled_denied(self): + h = self._full_auto_h({"repo": "o/r"}) + with mock.patch.dict(os.environ, {"CODEX_GLOBAL_ADMINS": "u1"}): + h._full_auto_execute("u1") + code, payload = self._last(h) + self.assertEqual(code, 403) + self.assertEqual(payload["reason"], "full_auto_disabled") + + def _install(self, repo="o/r"): + from cloud.github_app import get_app_config + c = _conn(self.db_path) + get_app_config(c) + c.execute( + "INSERT OR REPLACE INTO github_app_config(id, app_id, slug," + " client_id, webhook_secret, pem, html_url, created_at)" + " VALUES(1, '1', 'app', 'cid', 'sec', NULL, NULL, ?)", + (time.time(),)) + from cloud.github_app import _ensure_installations_table + _ensure_installations_table(c) + c.execute( + "INSERT INTO github_app_installations(installation_id," + " repo_full_name, created_at) VALUES(?, ?, ?)", + (123, repo, time.time())) + c.commit() + c.close() + + def test_full_auto_token_missing_denied(self): + self._install() + h = self._full_auto_h({"repo": "o/r"}) + h.server.full_auto_token_resolver = lambda db_path, repo: None + with mock.patch.dict(os.environ, {"CODEX_GLOBAL_ADMINS": "u1", + "FULL_AUTO_ENABLED": "1"}): + h._full_auto_execute("u1") + code, payload = self._last(h) + self.assertEqual(code, 403) + self.assertEqual(payload["reason"], "token_missing") + + def test_full_auto_protection_request_error_denied(self): + self._install() + h = self._full_auto_h({"repo": "o/r"}) + h.server.full_auto_token_resolver = lambda db_path, repo: "tok" + from cloud.full_auto import FullAutoError + h.server.full_auto_http_client = mock.Mock( + side_effect=FullAutoError("net down", reason="net")) + with mock.patch.dict(os.environ, {"CODEX_GLOBAL_ADMINS": "u1", + "FULL_AUTO_ENABLED": "1"}): + h._full_auto_execute("u1") + code, payload = self._last(h) + self.assertEqual(code, 403) + self.assertEqual(payload["reason"], + "branch_protection_unverified") + + def test_full_auto_protection_not_200_denied(self): + self._install() + h = self._full_auto_h({"repo": "o/r"}) + h.server.full_auto_token_resolver = lambda db_path, repo: "tok" + h.server.full_auto_http_client = lambda m, u, hd, b: (404, {}) + with mock.patch.dict(os.environ, {"CODEX_GLOBAL_ADMINS": "u1", + "FULL_AUTO_ENABLED": "1"}): + h._full_auto_execute("u1") + code, payload = self._last(h) + self.assertEqual(code, 403) + self.assertEqual(payload["reason"], + "branch_protection_unverified") + + def test_create_task_gateway_sub2api_error_502(self): + self._project() + h = self._h(body={"project_id": "p1", "prompt": "hello"}) + with mock.patch.dict(os.environ, {"CLOUD_GATEWAY": "sub2api"}), \ + mock.patch("cloud.server._validate_sub2api_key", + side_effect=_Sub2apiError("key dead")): + h._create_task("u1") + code, payload = self._last(h) + self.assertEqual(code, 502) + self.assertIn("key dead", payload["error"]) + + def test_create_task_gateway_generic_error_502(self): + self._project() + h = self._h(body={"project_id": "p1", "prompt": "hello"}) + with mock.patch.dict(os.environ, {"CLOUD_GATEWAY": "sub2api"}), \ + mock.patch("cloud.server._validate_sub2api_key", + side_effect=RuntimeError("net down")): + h._create_task("u1") + self.assertEqual(self._last(h)[0], 502) + + def _upload_row(self, uid="up1"): + c = _conn(self.db_path) + c.execute( + "INSERT INTO uploads(id, created_by, created_at, status)" + " VALUES(?, ?, ?, 'pending')", + (uid, "u1", time.time())) + c.commit() + c.close() + + def test_create_task_attachment_dir_missing_400(self): + self._project() + self._upload_row() + h = self._h(body={"project_id": "p1", "prompt": "hello", + "upload_id": "up1"}) + h._create_task("u1") + code, payload = self._last(h) + self.assertEqual(code, 400) + self.assertIn("暂存目录缺失", payload["error"]) + + def test_create_task_attachment_empty_400(self): + self._project() + self._upload_row() + os.makedirs(os.path.join(self.upload_dir, "up1"), exist_ok=True) + h = self._h(body={"project_id": "p1", "prompt": "hello", + "upload_id": "up1"}) + h._create_task("u1") + code, payload = self._last(h) + self.assertEqual(code, 400) + self.assertIn("附件包为空", payload["error"]) + + def test_create_task_idempotency_race_returns_existing(self): + self._project() + self._task(tid="t-old", idempotency_key="k1") + h = self._h(body={"project_id": "p1", "prompt": "hello", + "idempotency_key": "k1"}) + h._db = lambda: _ProxyConn( + _conn(self.db_path), + fail_insert_prefix="INSERT INTO tasks", + fail_insert_exc=sqlite3.IntegrityError("race"), + first_select_none="idempotency_key = ?") + h._create_task("u1") + code, payload = self._last(h) + self.assertEqual(code, 200) + self.assertEqual(payload["id"], "t-old") + + +if __name__ == "__main__": + unittest.main() diff --git a/cloud/tests/test_v2cov_server_handlers3.py b/cloud/tests/test_v2cov_server_handlers3.py new file mode 100644 index 0000000..ac1e98a --- /dev/null +++ b/cloud/tests/test_v2cov_server_handlers3.py @@ -0,0 +1,1248 @@ +"""Coverage batch v2 (D3): cloud/server.py handler validation branches, +second sweep. + +Same stubbing discipline as D2: the method under test is never +stubbed; _send_json / _read_json / _read_body are captured input, +_db is a delegating proxy only for the TOCTOU / failure-injection +cases, and collaborator functions (request_kill, _auto_process_due, +_pushback_block, write_control, read_state, start_oauth_login, +build_authorization_url, handle_oauth_callback, run_plan, +auto_config_plan, _validate_sub2api_key, _rotate_sub2api_key, +WebhookDeliveryStore) are stubbed only to drive the handler's own +error mapping. All validation, permission, quota and state logic +runs for real. +""" + +import hashlib +import hmac +import json +import os +import sqlite3 +import sys +import tempfile +import time +import unittest +from pathlib import Path +from types import SimpleNamespace +from unittest import mock + +REPO_ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(REPO_ROOT)) + +import cloud.server as server_mod +from cloud.server import ( + CloudControlPlaneHandler, + _auth_store, + init_db, +) +from cloud.sub2api import Sub2apiError as _Sub2apiError + + +def _handler(db_path, upload_dir=None): + h = CloudControlPlaneHandler.__new__(CloudControlPlaneHandler) + h.server = SimpleNamespace(db_path=db_path, upload_dir=upload_dir) + sent = [] + h._send_json = lambda status, obj, extra_headers=None: sent.append( + (status, obj)) + h.sent = sent + return h + + +def _conn(db_path): + c = sqlite3.connect(db_path) + c.row_factory = sqlite3.Row + return c + + +class _RowcountCursor: + def __init__(self, rowcount): + self.rowcount = rowcount + + def fetchone(self): + return None + + def fetchall(self): + return [] + + +class _ProxyConn: + """Delegate everything except the statements a test targets.""" + + def __init__(self, real, fail_insert_prefix=None, + fail_insert_exc=None, fail_select_substr=None, + fail_select_exc=None, rowcount_zero_prefix=None, + close_exc=None): + self._real = real + self._fail_insert_prefix = fail_insert_prefix + self._fail_insert_exc = fail_insert_exc + self._fail_select_substr = fail_select_substr + self._fail_select_exc = fail_select_exc + self._rowcount_zero_prefix = rowcount_zero_prefix + self._close_exc = close_exc + + def execute(self, sql, params=()): + text = sql.strip() + if (self._fail_insert_prefix + and text.startswith(self._fail_insert_prefix)): + raise self._fail_insert_exc + if (self._fail_select_substr + and text.startswith("SELECT") + and self._fail_select_substr in text): + raise self._fail_select_exc + if (self._rowcount_zero_prefix + and text.startswith(self._rowcount_zero_prefix)): + return _RowcountCursor(0) + return self._real.execute(sql, params) + + def close(self): + if self._close_exc is not None: + raise self._close_exc + return self._real.close() + + def __getattr__(self, name): + return getattr(self._real, name) + + +class _Base(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.db_path = os.path.join(self.tmp.name, "test.db") + init_db(self.db_path) + self.upload_dir = os.path.join(self.tmp.name, "uploads") + os.makedirs(self.upload_dir, exist_ok=True) + + def tearDown(self): + self.tmp.cleanup() + + def _h(self, body=None, err=None): + h = _handler(self.db_path, upload_dir=self.upload_dir) + h._read_json = lambda: (body, err) + return h + + def _project(self, pid="p1", owner="u1", repo_url=None): + c = _conn(self.db_path) + c.execute( + "INSERT INTO projects(id, name, owner_id, created_at," + " repo_url, auto_pr) VALUES(?, ?, ?, ?, ?, 0)", + (pid, "P", owner, time.time(), repo_url)) + c.commit() + c.close() + with _auth_store(self.db_path) as store: + store.create_project(pid, owner) + + def _task(self, tid="t1", pid="p1", status="completed", + created_by="u1", **extra): + cols = ["id", "project_id", "prompt", "status", "created_by", + "created_at", "updated_at"] + vals = [tid, pid, "do work", status, created_by, time.time(), + time.time()] + for key, val in extra.items(): + cols.append(key) + vals.append(val) + c = _conn(self.db_path) + c.execute( + "INSERT INTO tasks(%s) VALUES(%s)" + % (", ".join(cols), ", ".join("?" * len(cols))), vals) + c.commit() + c.close() + + def _last(self, h): + self.assertTrue(h.sent, "handler sent no response") + return h.sent[-1] + + +class DispatchTransitionTestCase(_Base): + def test_dispatch_empty_project_404(self): + h = self._h() + h._dispatch_project("u1", "GET", "/projects/", {}) + self.assertEqual(self._last(h)[0], 404) + + def test_dispatch_identity_supersede_wrong_method_404(self): + h = self._h() + h._dispatch_project("u1", "GET", + "/projects/p1/identity-memories/m1/supersede", + {}) + self.assertEqual(self._last(h)[0], 404) + + def test_dispatch_identity_delete_wrong_method_404(self): + h = self._h() + h._dispatch_project("u1", "GET", + "/projects/p1/identity-memories/m1", {}) + self.assertEqual(self._last(h)[0], 404) + + def test_dispatch_memory_supersede_wrong_method_404(self): + h = self._h() + h._dispatch_project("u1", "GET", + "/projects/p1/memory/m1/supersede", {}) + self.assertEqual(self._last(h)[0], 404) + + def test_dispatch_push_subscriptions_wrong_method_404(self): + # POST creates and GET lists; any other method hits the + # else branch (3934). + h = self._h() + h._dispatch_project("u1", "PUT", + "/projects/p1/push/subscriptions", {}) + self.assertEqual(self._last(h)[0], 404) + + def test_dispatch_todos_wrong_method_404(self): + h = self._h() + h._dispatch_project("u1", "PUT", "/projects/p1/todos/t1", {}) + self.assertEqual(self._last(h)[0], 404) + + def test_dispatch_mcp_servers_wrong_method_404(self): + h = self._h() + h._dispatch_project("u1", "POST", "/projects/p1/mcp-servers", {}) + self.assertEqual(self._last(h)[0], 404) + + def test_cancel_missing_404(self): + h = self._h() + h._cancel_task("u1", "ghost") + self.assertEqual(self._last(h)[0], 404) + + def test_cancel_forbidden_403(self): + self._project() + self._task(status="running") + h = self._h() + h._cancel_task("u9", "t1") + self.assertEqual(self._last(h)[0], 403) + + def test_cancel_kill_failure_swallowed_200(self): + self._project() + self._task(status="running") + h = self._h() + with mock.patch("cloud.server.request_kill", + side_effect=RuntimeError("kill down")): + h._cancel_task("u1", "t1") + code, payload = self._last(h) + self.assertEqual(code, 200) + self.assertEqual(payload["status"], "interrupted") + + def test_cancel_bundle_lookup_failure_swallowed_200(self): + self._project() + self._task(status="running") + h = self._h() + h._db = lambda: _ProxyConn( + _conn(self.db_path), + fail_select_substr="FROM task_attachments", + fail_select_exc=RuntimeError("lookup down")) + h._cancel_task("u1", "t1") + self.assertEqual(self._last(h)[0], 200) + + def test_pause_missing_404(self): + h = self._h() + h._pause_task("u1", "ghost") + self.assertEqual(self._last(h)[0], 404) + + def test_pause_forbidden_403(self): + self._project() + self._task(status="running") + h = self._h() + h._pause_task("u9", "t1") + self.assertEqual(self._last(h)[0], 403) + + def test_pause_terminal_400(self): + self._project() + self._task(status="completed") + h = self._h() + h._pause_task("u1", "t1") + code, payload = self._last(h) + self.assertEqual(code, 400) + self.assertIn("terminal", payload["error"]) + + def test_pause_step_insert_failure_swallowed_200(self): + self._project() + self._task(status="running") + h = self._h() + h._db = lambda: _ProxyConn( + _conn(self.db_path), + fail_insert_prefix="INSERT INTO task_steps", + fail_insert_exc=RuntimeError("steps down")) + h._pause_task("u1", "t1") + code, payload = self._last(h) + self.assertEqual(code, 200) + self.assertEqual(payload["status"], "interrupted") + + def test_release_missing_404(self): + h = self._h(body={}) + h._release_task("u1", "ghost") + self.assertEqual(self._last(h)[0], 404) + + def test_release_forbidden_403(self): + self._project() + self._task(status="merged") + h = self._h(body={}) + h._audit_admin = lambda *args: None + with mock.patch.dict(os.environ, {}, clear=False): + os.environ.pop("CODEX_GLOBAL_ADMINS", None) + h._release_task("u1", "t1") + self.assertEqual(self._last(h)[0], 403) + + def test_release_read_error(self): + self._project() + self._task(status="merged") + h = self._h(err="bad json") + h._audit_admin = lambda *args: None + with mock.patch.dict(os.environ, {"CODEX_GLOBAL_ADMINS": "u1"}): + h._release_task("u1", "t1") + self.assertEqual(self._last(h)[0], 400) + + def test_release_files_changed_must_be_list(self): + self._project() + self._task(status="merged") + h = self._h(body={"files_changed": "x"}) + h._audit_admin = lambda *args: None + with mock.patch.dict(os.environ, {"CODEX_GLOBAL_ADMINS": "u1"}): + h._release_task("u1", "t1") + code, payload = self._last(h) + self.assertEqual(code, 400) + self.assertIn("files_changed must be a list", payload["error"]) + + def test_continue_merged_409(self): + self._project() + self._task(status="merged") + h = self._h(body={"message": "again"}) + h._continue_task("u1", "t1") + self.assertEqual(self._last(h)[0], 409) + + def test_continue_nonterminal_409(self): + # A status outside {queued, running, merged} and outside + # TERMINAL_STATUSES: only reachable with an unknown status + # string in the row (defensive branch, not a normal state). + self._project() + self._task(status="bogus-status") + h = self._h(body={"message": "again"}) + h._continue_task("u1", "t1") + code, payload = self._last(h) + self.assertEqual(code, 409) + self.assertIn("only terminal", payload["error"]) + + def test_continue_read_error(self): + self._project() + self._task(status="completed") + h = self._h(err="bad json") + h._continue_task("u1", "t1") + self.assertEqual(self._last(h)[0], 400) + + def test_continue_quota_reject_returns(self): + # _continue_task has two quota gates: the early + # _reject_if_over_quota_for_task (8881) and the late + # _reject_if_over_quota before the write (8964). The early + # gate is stubbed to pass so the late gate's bare return + # (8966) is the one under test. + self._project() + self._task(status="completed") + h = self._h(body={"message": "again"}) + h._reject_if_over_quota_for_task = lambda *a: False + h._quota_status = mock.Mock(side_effect=RuntimeError("down")) + h._continue_task("u1", "t1") + self.assertEqual(len(h.sent), 1) + self.assertEqual(h.sent[0][0], 503) + + def test_continue_status_changed_409(self): + # CAS rowcount 0: the task row changed under the handler, + # so no message is persisted and the caller must refresh. + self._project() + self._task(status="completed") + h = self._h(body={"message": "again"}) + h._db = lambda: _ProxyConn( + _conn(self.db_path), + rowcount_zero_prefix="UPDATE tasks SET") + h._continue_task("u1", "t1") + code, payload = self._last(h) + self.assertEqual(code, 409) + self.assertIn("状态已变化", payload["error"]) + c = _conn(self.db_path) + row = c.execute( + "SELECT status FROM tasks WHERE id='t1'").fetchone() + c.close() + self.assertEqual(row[0], "completed") + + +class TodosAutomationLoopTestCase(_Base): + def _auto(self, name="auto1", project_id="p1", prompt="do it"): + from cloud.automations import create_automation + c = _conn(self.db_path) + auto = create_automation(c, project_id, name, prompt, "u1") + c.close() + return auto + + def _todo(self, tid="todo1", pid="p1"): + from cloud.todos import create_todo + c = _conn(self.db_path) + todo = create_todo(c, pid, "buy milk", "u1") + c.close() + return todo + + def test_todos_update_read_error(self): + h = self._h(err="bad json") + h._todos_update("u1", "p1", "todo1") + self.assertEqual(self._last(h)[0], 400) + + def test_todos_update_value_error_400(self): + todo = self._todo() + h = self._h(body={"status": "bogus"}) + h._todos_update("u1", "p1", todo["id"]) + self.assertEqual(self._last(h)[0], 400) + + def test_todos_update_generic_error_502(self): + todo = self._todo() + h = self._h(body={"title": "new"}) + with mock.patch("cloud.server._todo_update", + side_effect=RuntimeError("db down")): + h._todos_update("u1", "p1", todo["id"]) + self.assertEqual(self._last(h)[0], 502) + + def test_todos_update_missing_404(self): + h = self._h(body={"title": "new"}) + h._todos_update("u1", "p1", "ghost") + self.assertEqual(self._last(h)[0], 404) + + def test_todos_update_cross_project_404(self): + todo = self._todo() + h = self._h(body={"title": "new"}) + h._todos_update("u1", "p2", todo["id"]) + self.assertEqual(self._last(h)[0], 404) + + def test_automation_update_read_error(self): + h = self._h(err="bad json") + h._automation_update("u1", "p1", "a1") + self.assertEqual(self._last(h)[0], 400) + + def test_automation_update_bad_first_run(self): + auto = self._auto() + h = self._h(body={"first_run_at": "not-a-time"}) + h._automation_update("u1", "p1", auto["id"]) + code, payload = self._last(h) + self.assertEqual(code, 400) + self.assertIn("first_run_at", payload["error"]) + + def test_automation_update_good_first_run(self): + auto = self._auto() + h = self._h(body={"first_run_at": "2030-01-01T00:00:00Z"}) + h._automation_update("u1", "p1", auto["id"]) + self.assertEqual(self._last(h)[0], 200) + + def test_automation_update_no_fields_400(self): + auto = self._auto() + h = self._h(body={}) + h._automation_update("u1", "p1", auto["id"]) + self.assertEqual(self._last(h)[0], 400) + + def test_audit_loop_get_missing_404(self): + h = self._h() + h._audit_loop_get("u1", "ghost", {}) + self.assertEqual(self._last(h)[0], 404) + + def test_audit_loop_get_forbidden_403(self): + self._project() + self._task() + h = self._h() + h._audit_loop_get("u9", "t1", {}) + self.assertEqual(self._last(h)[0], 403) + + def test_audit_loop_get_query_required_400(self): + self._project() + self._task() + h = self._h() + h._audit_loop_get("u1", "t1", {}) + self.assertEqual(self._last(h)[0], 400) + + def test_audit_loop_get_no_state_404(self): + self._project() + self._task() + h = self._h() + h._audit_loop_get("u1", "t1", {"module_id": ["m"], + "contract_version": ["v1"]}) + self.assertEqual(self._last(h)[0], 404) + + def test_audit_loop_get_pushback_unavailable_503(self): + self._project() + self._task() + from cloud.audit_loop_wiring import ( + ensure_loop_state_table, + save_loop_state, + ) + from cloud.audit_loop_driver import LoopState + state = LoopState(module_id="m", contract_version="v1") + c = _conn(self.db_path) + save_loop_state(c, "t1", state) + c.commit() + c.close() + h = self._h() + with mock.patch("cloud.server._pushback_block", + side_effect=RuntimeError("chain down")): + h._audit_loop_get("u1", "t1", {"module_id": ["m"], + "contract_version": ["v1"]}) + code, payload = self._last(h) + self.assertEqual(code, 503) + self.assertEqual(payload["error"], + "audit_pushback_state_unavailable") + + def test_audit_loop_get_success_200(self): + self._project() + self._task() + from cloud.audit_loop_wiring import save_loop_state + from cloud.audit_loop_driver import LoopState + state = LoopState(module_id="m", contract_version="v1") + c = _conn(self.db_path) + save_loop_state(c, "t1", state) + c.commit() + c.close() + h = self._h() + h._audit_loop_get("u1", "t1", {"module_id": ["m"], + "contract_version": ["v1"]}) + code, payload = self._last(h) + self.assertEqual(code, 200) + self.assertEqual(payload["loop_state"]["module_id"], "m") + + def test_auto_memory_run_read_error(self): + h = self._h(err="bad json") + h._auto_memory_run("u1") + self.assertEqual(self._last(h)[0], 400) + + def test_auto_memory_run_bad_force(self): + h = self._h(body={"force": "yes"}) + h._auto_memory_run("u1") + code, payload = self._last(h) + self.assertEqual(code, 400) + self.assertIn("force must be a boolean", payload["error"]) + + def test_auto_memory_run_extracted_audits(self): + h = self._h(body={}) + with mock.patch("cloud.server._auto_process_due", + return_value=[{"status": "extracted", + "task_id": "t1"}, + {"status": "skipped", + "task_id": "t2"}]): + h._auto_memory_run("u1") + code, payload = self._last(h) + self.assertEqual(code, 200) + self.assertEqual(len(payload["results"]), 2) + + def test_kb_index_read_error(self): + h = self._h(err="bad json") + h._kb_index("u1", "p1") + self.assertEqual(self._last(h)[0], 400) + + def test_kb_index_content_required(self): + h = self._h(body={"path": "a.txt"}) + h._kb_index("u1", "p1") + code, payload = self._last(h) + self.assertEqual(code, 400) + self.assertIn("content required", payload["error"]) + + def test_kb_index_bad_kind_400(self): + # KBStore.index_file rejects an unknown kind with ValueError; + # path traversal alone is not rejected by the store. + h = self._h(body={"path": "a.txt", "content": "x", + "kind": "bogus"}) + h._kb_index("u1", "p1") + code, payload = self._last(h) + self.assertEqual(code, 400) + self.assertIn("kind must be", payload["error"]) + + def test_kb_index_success_201(self): + self._project() + h = self._h(body={"path": "docs/a.txt", "content": "hello"}) + h._kb_index("u1", "p1") + code, payload = self._last(h) + self.assertEqual(code, 201) + self.assertEqual(payload["path"], "docs/a.txt") + + +class ThreadDeliverTestCase(_Base): + def _read_h(self, task_status="running", **task_extra): + self._project() + self._task(status=task_status, **task_extra) + h = self._h() + h._trace_snapshot = lambda task: ([], None) + return h + + def test_thread_read_task_missing(self): + h = self._h() + h._task_thread_read("u1", "ghost") + code, payload = self._last(h) + self.assertEqual(code, 404) + + def test_thread_read_heartbeat_age(self): + h = self._read_h(last_heartbeat=time.time() - 30) + h._trace_snapshot = lambda task: ( + [{"kind": "message", "text": "hello", "id": "e1"}], None) + h._thread_task_guard = lambda task: ("g", "guarded") + h._task_thread_read("u1", "t1") + code, payload = self._last(h) + self.assertEqual(code, 200) + self.assertIsNotNone(payload["progress"]["heartbeat_age_seconds"]) + self.assertEqual(payload["progress"]["last_trace"]["kind"], + "message") + self.assertEqual(payload["note"], "guarded") + + def test_thread_read_resolve_error_note(self): + h = self._read_h() + h._thread_task_guard = lambda task: None + h._resolve_task_thread = lambda task: ( + None, None, None, (502, "resolve failed"), None) + h._task_thread_read("u1", "t1") + code, payload = self._last(h) + self.assertEqual(code, 200) + self.assertEqual(payload["note"], "resolve failed") + + def test_thread_read_agent_chain_failure_swallowed(self): + h = self._read_h() + h._thread_task_guard = lambda task: None + h._resolve_task_thread = lambda task: ( + {"id": "th1"}, "/tmp", "/tmp", None, None) + with mock.patch("cloud.server._run_host_cli", + return_value=(False, "no host")), \ + mock.patch("cloud.server._build_agent_chain", + side_effect=RuntimeError("chain down")): + h._task_thread_read("u1", "t1") + code, payload = self._last(h) + self.assertEqual(code, 200) + self.assertEqual(payload["agents"], []) + + def test_thread_read_attention_reasons(self): + h = self._read_h(task_status="completed", error_text="boom") + h._thread_task_guard = lambda task: ("g", "guarded") + from cloud.server import _appr_ensure, _peer_ensure_followups + c = _conn(self.db_path) + _appr_ensure(c) + c.execute( + "INSERT INTO task_approvals(id, task_id, project_id, kind," + " summary, status, created_at) VALUES(?, ?, ?, ?, ?, ?, ?)", + ("ap1", "t1", "p1", "deploy", "ok?", "pending", + time.time())) + _peer_ensure_followups(c) + c.execute( + "INSERT INTO task_followups(id, task_id, project_id, body," + " mode, status, created_by, created_at, updated_at)" + " VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)", + ("fu1", "t1", "p1", "later", "queued", "pending", "u1", + time.time(), time.time())) + c.commit() + c.close() + h._task_thread_read("u1", "t1") + code, payload = self._last(h) + self.assertEqual(code, 200) + reasons = payload["attention"]["reasons"] + self.assertIn("pending approvals need a decision", reasons) + self.assertIn("pending followups need processing", reasons) + self.assertIn("terminal task can be continued" + " with a new instruction", reasons) + self.assertIn("task has an error_text", reasons) + + def test_thread_message_read_error(self): + h = self._h(err="bad json") + h._task_thread_message("u1", "t1") + self.assertEqual(self._last(h)[0], 400) + + def test_thread_message_task_missing(self): + h = self._h(body={"body": "hi"}) + h._task_thread_message("u1", "ghost") + self.assertEqual(self._last(h)[0], 404) + + def test_thread_message_quota_reject_returns(self): + self._project() + self._task(status="running") + h = self._h(body={"body": "hi"}) + h._quota_status = mock.Mock(side_effect=RuntimeError("down")) + h._task_thread_message("u1", "t1") + self.assertEqual(len(h.sent), 1) + self.assertEqual(h.sent[0][0], 503) + + def test_thread_message_body_required(self): + self._project() + self._task(status="running") + h = self._h(body={"body": " "}) + h._task_thread_message("u1", "t1") + code, payload = self._last(h) + self.assertEqual(code, 400) + self.assertIn("body required", payload["error"]) + + def test_thread_message_body_too_long(self): + self._project() + self._task(status="running") + h = self._h(body={"body": "x" * 20001}) + h._task_thread_message("u1", "t1") + code, payload = self._last(h) + self.assertEqual(code, 400) + self.assertIn("body too long", payload["error"]) + + def _deliver_task(self, status="running", **extra): + return {"id": "t1", "project_id": "p1", "status": status, + "created_by": "u1", **extra} + + def test_deliver_goal_error(self): + self._project() + h = self._h() + code, payload = h._deliver_to_thread( + "u1", self._deliver_task(), "/goal", "queued") + self.assertEqual(code, 400) + + def test_deliver_goal_convert_queued_success(self): + self._project() + self._task(status="queued") + h = self._h() + code, payload = h._deliver_to_thread( + "u1", self._deliver_task(status="queued"), + "/goal 做目标", "queued") + self.assertEqual(code, 201) + self.assertEqual(payload["delivery"], "goal_converted") + + def test_deliver_goal_convert_queued_race_409(self): + self._project() + h = self._h() + h._goal_convert_queued = lambda tid, target: None + code, payload = h._deliver_to_thread( + "u1", self._deliver_task(status="queued"), + "/goal 做目标", "queued") + self.assertEqual(code, 409) + self.assertIn("状态已变化", payload["error"]) + + def test_deliver_goal_revive_race_409(self): + self._project() + h = self._h() + h._goal_revive_terminal = lambda tid, target: None + code, payload = h._deliver_to_thread( + "u1", self._deliver_task(status="completed"), + "/goal 做目标", "queued") + self.assertEqual(code, 409) + self.assertIn("pending resume message", payload["error"]) + + def test_deliver_terminal_cas_race_409(self): + self._project() + self._task(status="completed", resume_message="old") + h = self._h() + code, payload = h._deliver_to_thread( + "u1", self._deliver_task(status="completed", + resume_message="old"), + "fix it", "queued") + self.assertEqual(code, 409) + self.assertIn("pending resume message", payload["error"]) + + def test_deliver_running_goal_queued(self): + self._project() + self._task(status="running") + h = self._h() + code, payload = h._deliver_to_thread( + "u1", self._deliver_task(status="running"), + "/goal 做目标", "queued") + self.assertEqual(code, 201) + self.assertEqual(payload["delivery"], "goal_queued") + self.assertEqual(payload["goal"], "做目标") + + +class OauthGatewayTestCase(_Base): + def test_oauth_login_unknown_provider_404(self): + # _oauth_provider returns None only for a malformed path + # (not /oauth/{provider}/login); any segment in the provider + # slot is passed through to the login flow. + h = self._h() + h.path = "/login" + h._oauth_login("/login") + self.assertEqual(self._last(h)[0], 404) + + def test_oauth_login_missing_params_400(self): + h = self._h() + h.path = "/oauth/github/login" + h._oauth_login("/oauth/github/login") + code, payload = self._last(h) + self.assertEqual(code, 400) + self.assertIn("client_id and redirect_uri", payload["error"]) + + def test_oauth_login_oauth_error_400(self): + from cloud.oauth import OAuthError + h = self._h() + h.path = ("/oauth/github/login?client_id=c" + "&redirect_uri=https://example.com/cb") + err = OAuthError("denied", reason="bad_client") + with mock.patch("cloud.server.start_oauth_login", + side_effect=err): + h._oauth_login("/oauth/github/login") + code, payload = self._last(h) + self.assertEqual(code, 400) + self.assertEqual(payload["reason"], "bad_client") + + def test_oauth_login_value_error_400(self): + h = self._h() + h.path = ("/oauth/github/login?client_id=c" + "&redirect_uri=https://example.com/cb") + with mock.patch("cloud.server.start_oauth_login", + side_effect=ValueError("bad scope")): + h._oauth_login("/oauth/github/login") + self.assertEqual(self._last(h)[0], 400) + + def test_oauth_bind_unauthorized_401(self): + h = self._h(body={}) + h._authenticate = lambda: None + h._oauth_bind_initiate("github") + self.assertEqual(self._last(h)[0], 401) + + def test_oauth_bind_oauth_error_400(self): + from cloud.oauth import OAuthError + h = self._h(body={"client_id": "c", + "redirect_uri": "https://example.com/cb"}) + h._authenticate = lambda: "u1" + err = OAuthError("denied", reason="bad_client") + with mock.patch("cloud.server.build_authorization_url", + side_effect=err): + h._oauth_bind_initiate("github") + code, payload = self._last(h) + self.assertEqual(code, 400) + self.assertEqual(payload["reason"], "bad_client") + + def test_oauth_bind_value_error_400(self): + h = self._h(body={"client_id": "c", + "redirect_uri": "https://example.com/cb"}) + h._authenticate = lambda: "u1" + with mock.patch("cloud.server.build_authorization_url", + side_effect=ValueError("bad scope")): + h._oauth_bind_initiate("github") + self.assertEqual(self._last(h)[0], 400) + + def test_oauth_bind_store_close_failure_swallowed(self): + h = self._h(body={"client_id": "c", + "redirect_uri": "https://example.com/cb"}) + h._authenticate = lambda: "u1" + + class _Store: + def __init__(self, db_path): + self.conn = mock.Mock() + self.conn.close.side_effect = RuntimeError("close down") + + def create_state(self, provider, redirect_uri, client_id=None, + bind_user_id=None): + return "st1" + with mock.patch("cloud.server.OAuthStateStore", _Store), \ + mock.patch("cloud.server.build_authorization_url", + return_value="https://example.com/auth"): + h._oauth_bind_initiate("github") + code, payload = self._last(h) + self.assertEqual(code, 200) + self.assertEqual(payload["state"], "st1") + + def _finish_h(self, identity): + h = self._h() + h._oauth_clients = lambda provider: (None, None) + patcher = mock.patch("cloud.server.handle_oauth_callback", + return_value=dict(identity)) + patcher.start() + self.addCleanup(patcher.stop) + return h + + def test_oauth_finish_bind_branch_quota(self): + with _auth_store(self.db_path) as store: + uid, _key = store.create_user("u9") + h = self._finish_h({"bind_user_id": uid, "external_id": "ext1"}) + result = h._oauth_finish("github", "code", "st", "c", "r") + self.assertEqual(result[0], uid) + self.assertTrue(result[5]) + c = _conn(self.db_path) + row = c.execute( + "SELECT quota_usd_micros FROM user_quotas WHERE user_id=?", + (uid,)).fetchone() + c.close() + self.assertIsNotNone(row) + + def test_oauth_finish_bound_branch_quota(self): + with _auth_store(self.db_path) as store: + uid, _key = store.create_user("u9") + from cloud.oauth import bind_external_identity + with _auth_store(self.db_path) as auth: + bind_external_identity(auth, "github", + {"external_id": "ext1"}, uid) + h = self._finish_h({"external_id": "ext1"}) + result = h._oauth_finish("github", "code", "st", "c", "r") + self.assertEqual(result[0], uid) + self.assertFalse(result[1]) + self.assertIsNotNone(result[2]) + + def test_oauth_finish_new_user_gateway_provision_fails(self): + # Best-effort gateway provisioning: the failure is audited + # and account creation still succeeds (no network call: the + # gateway collaborator itself raises). + h = self._finish_h({"external_id": "ext-new"}) + with mock.patch.dict(os.environ, + {"SUB2API_BASE_URL": "https://gw.example"}), \ + mock.patch("cloud.server._ensure_sub2api_key", + side_effect=RuntimeError("gw down")): + result = h._oauth_finish("github", "code", "st", "c", "r") + self.assertTrue(result[1]) + self.assertFalse(result[5]) + c = _conn(self.db_path) + row = c.execute( + "SELECT action FROM audit_log" + " WHERE action='sub2api_provision_failed'").fetchone() + c.close() + self.assertIsNotNone(row) + + def test_oauth_finish_state_store_close_failure_swallowed(self): + # The finally-block except-pass (10265-66): the OAuth state + # store's close raises, the finish flow still proceeds. + with _auth_store(self.db_path) as store: + uid, _key = store.create_user("u9") + h = self._h() + h._oauth_clients = lambda provider: (None, None) + + class _Store: + def __init__(self, db_path): + self.conn = mock.Mock() + self.conn.close.side_effect = RuntimeError("close down") + with mock.patch("cloud.server.OAuthStateStore", _Store), \ + mock.patch("cloud.server.handle_oauth_callback", + return_value={"bind_user_id": uid, + "external_id": "ext1"}): + result = h._oauth_finish("github", "code", "st", "c", "r") + self.assertEqual(result[0], uid) + self.assertTrue(result[5]) + + def test_gateway_rotate_unauthorized_401(self): + h = self._h() + h._authenticate = lambda: None + h._user_gateway_rotate() + self.assertEqual(self._last(h)[0], 401) + + def test_gateway_rotate_sub2api_error_502(self): + h = self._h() + h._authenticate = lambda: "u1" + with mock.patch.dict(os.environ, {"CLOUD_GATEWAY": "sub2api"}), \ + mock.patch("cloud.server._rotate_sub2api_key", + side_effect=_Sub2apiError("gw down")): + h._user_gateway_rotate() + code, payload = self._last(h) + self.assertEqual(code, 502) + self.assertIn("gw down", payload["error"]) + + def test_gateway_rotate_generic_error_502(self): + h = self._h() + h._authenticate = lambda: "u1" + with mock.patch.dict(os.environ, {"CLOUD_GATEWAY": "sub2api"}), \ + mock.patch("cloud.server._rotate_sub2api_key", + side_effect=RuntimeError("net down")): + h._user_gateway_rotate() + self.assertEqual(self._last(h)[0], 502) + + +class WebhookBrowserFullAutoTestCase(_Base): + def _gh_headers(self, secret, raw, event, delivery="d1"): + sig = "sha256=" + hmac.new( + secret.encode(), raw, hashlib.sha256).hexdigest() + return {"X-Hub-Signature-256": sig, "X-GitHub-Event": event, + "X-GitHub-Delivery": delivery} + + def _gh_h(self, raw, event, delivery="d1"): + h = self._h() + h.headers = self._gh_headers("s3cr3t", raw, event, delivery) + h._read_body = lambda: raw + return h + + def test_github_installation_unparsed_200(self): + raw = json.dumps({"action": "opened", + "installation": {"id": 7}}).encode() + h = self._gh_h(raw, "installation") + with mock.patch.dict(os.environ, + {"GITHUB_WEBHOOK_SECRET": "s3cr3t"}): + h._handle_github_webhook() + code, payload = self._last(h) + self.assertEqual(code, 200) + self.assertIsNone(payload["result"]) + + def test_github_installation_removed_200(self): + raw = json.dumps({ + "action": "removed", + "installation": {"id": 7, "account": {"login": "octo"}}, + "repositories_removed": [{"full_name": "o/r"}]}).encode() + h = self._gh_h(raw, "installation") + with mock.patch.dict(os.environ, + {"GITHUB_WEBHOOK_SECRET": "s3cr3t"}): + h._handle_github_webhook() + self.assertEqual(self._last(h)[0], 200) + + def test_github_installation_deleted_200(self): + raw = json.dumps({ + "action": "deleted", + "installation": {"id": 7, "account": {"login": "octo"}}}).encode() + h = self._gh_h(raw, "installation") + with mock.patch.dict(os.environ, + {"GITHUB_WEBHOOK_SECRET": "s3cr3t"}): + h._handle_github_webhook() + self.assertEqual(self._last(h)[0], 200) + + def test_github_delivery_store_close_failure_swallowed(self): + raw = json.dumps({"action": "opened", + "installation": {"id": 7}}).encode() + h = self._gh_h(raw, "installation") + + class _Store: + def __init__(self, db_path): + pass + + def claim(self, delivery_id, event=None, repo=None): + return True + + def close(self): + raise RuntimeError("close down") + with mock.patch.dict(os.environ, + {"GITHUB_WEBHOOK_SECRET": "s3cr3t"}), \ + mock.patch("cloud.server.WebhookDeliveryStore", _Store): + h._handle_github_webhook() + self.assertEqual(self._last(h)[0], 200) + + def _slack_h(self, raw, content_type, secret="slack-secret"): + h = self._h() + ts = str(int(time.time())) + sig = "v0=" + hmac.new( + secret.encode(), ("v0:%s:" % ts).encode() + raw, + hashlib.sha256).hexdigest() + h.headers = {"X-Slack-Signature": sig, + "X-Slack-Request-Timestamp": ts, + "Content-Type": content_type} + h._read_body = lambda: raw + return h + + def test_slack_no_secret_503(self): + h = self._h() + h._read_body = lambda: b"" + h.headers = {} + with mock.patch.dict(os.environ, {}, clear=False): + os.environ.pop("SLACK_SIGNING_SECRET", None) + h._handle_slack_webhook() + self.assertEqual(self._last(h)[0], 503) + + def test_slack_form_text_200(self): + raw = b"text=%2Ftasks+list&command=%2Ftasks" + h = self._slack_h(raw, "application/x-www-form-urlencoded") + with mock.patch.dict(os.environ, + {"SLACK_SIGNING_SECRET": "slack-secret"}): + h._handle_slack_webhook() + self.assertEqual(self._last(h)[0], 200) + + def test_slack_json_dict_text_200(self): + raw = json.dumps({"text": "hello"}).encode() + h = self._slack_h(raw, "application/json") + with mock.patch.dict(os.environ, + {"SLACK_SIGNING_SECRET": "slack-secret"}): + h._handle_slack_webhook() + self.assertEqual(self._last(h)[0], 200) + + def test_slack_json_list_text_200(self): + raw = b"[1, 2]" + h = self._slack_h(raw, "application/json") + with mock.patch.dict(os.environ, + {"SLACK_SIGNING_SECRET": "slack-secret"}): + h._handle_slack_webhook() + self.assertEqual(self._last(h)[0], 200) + + def test_slack_bad_json_text_200(self): + raw = b"not json{{{" + h = self._slack_h(raw, "application/json") + with mock.patch.dict(os.environ, + {"SLACK_SIGNING_SECRET": "slack-secret"}): + h._handle_slack_webhook() + self.assertEqual(self._last(h)[0], 200) + + def test_browser_control_worktree_error(self): + h = self._h() + h._browser_worktree_for = lambda *a: (None, (404, "nope")) + h._browser_control("u1", "t1", {}) + self.assertEqual(self._last(h)[0], 404) + + def test_browser_control_value_error_400(self): + h = self._h() + h._browser_worktree_for = lambda *a: (self.tmp.name, None) + with mock.patch("cloud.playwright_remote.write_control", + side_effect=ValueError("bad holder")): + h._browser_control("u1", "t1", {}) + self.assertEqual(self._last(h)[0], 400) + + def test_browser_control_os_error_503(self): + h = self._h() + h._browser_worktree_for = lambda *a: (self.tmp.name, None) + with mock.patch("cloud.playwright_remote.write_control", + side_effect=OSError("unreachable")): + h._browser_control("u1", "t1", {}) + code, payload = self._last(h) + self.assertEqual(code, 503) + self.assertIn("browser control unreachable", payload["error"]) + + def _stream_h(self): + h = self._h() + h.send_response = lambda *a: None + h.send_header = lambda *a: None + h.end_headers = lambda: None + h.close_connection = False + return h + + def test_browser_stream_worktree_error(self): + h = self._stream_h() + h._browser_worktree_for = lambda *a: (None, (404, "nope")) + h._browser_stream("u1", "t1") + self.assertEqual(self._last(h)[0], 404) + + def test_browser_stream_read_error(self): + h = self._stream_h() + h._browser_worktree_for = lambda *a: (self.tmp.name, None) + with mock.patch("cloud.playwright_remote.read_state", + return_value=(None, (502, "no session"))): + h._browser_stream("u1", "t1") + code, payload = self._last(h) + self.assertEqual(code, 502) + + def test_browser_stream_task_row_gone_breaks(self): + h = self._stream_h() + h._browser_worktree_for = lambda *a: (self.tmp.name, None) + h._fetch_task = lambda tid: None + h.wfile = mock.Mock() + # First read (pre-loop) must succeed so the loop starts; + # the second read reports no session, then the status check + # finds the task row gone and breaks. + with mock.patch("cloud.playwright_remote.read_state", + side_effect=[({"url": "", "title": ""}, None), + (None, (503, "waiting"))]): + h._browser_stream("u1", "t1") + self.assertTrue(h.close_connection) + + def test_browser_stream_frame_missing_broken_pipe(self): + h = self._stream_h() + h._browser_worktree_for = lambda *a: (self.tmp.name, None) + + class _WFile: + def write(self, data): + raise BrokenPipeError("client gone") + + def flush(self): + pass + h.wfile = _WFile() + with mock.patch("cloud.playwright_remote.read_state", + return_value=({"url": "", "title": ""}, None)): + h._browser_stream("u1", "t1") + self.assertTrue(h.close_connection) + + def test_browser_stream_abandoned_watch_breaks(self): + h = self._stream_h() + h._browser_worktree_for = lambda *a: (self.tmp.name, None) + h._fetch_task = lambda tid: {"status": "running"} + h.wfile = mock.Mock() + # Pre-loop read succeeds; later reads report no session + # while the task stays alive, so staleness grows until the + # 600s abandoned-watch break fires (clock stubbed forward). + reads = [({"url": "", "title": ""}, None), + (None, (503, "waiting")), + (None, (503, "waiting")), + (None, (503, "waiting"))] + with mock.patch("cloud.playwright_remote.read_state", + side_effect=reads), \ + mock.patch("time.time", + side_effect=[1000000.0, 1000601.0, 1001202.0]): + h._browser_stream("u1", "t1") + self.assertTrue(h.close_connection) + + def _full_auto_install(self, repo="o/r"): + from cloud.github_app import ( + _ensure_installations_table, + get_app_config, + ) + c = _conn(self.db_path) + get_app_config(c) + c.execute( + "INSERT OR REPLACE INTO github_app_config(id, app_id, slug," + " client_id, webhook_secret, pem, html_url, created_at)" + " VALUES(1, '1', 'app', 'cid', 'sec', NULL, NULL, ?)", + (time.time(),)) + _ensure_installations_table(c) + c.execute( + "INSERT INTO github_app_installations(installation_id," + " repo_full_name, created_at) VALUES(?, ?, ?)", + (123, repo, time.time())) + c.commit() + c.close() + + def _full_auto_h(self, body): + h = self._h(body) + h._audit_admin = lambda *args: None + return h + + def _protection_client(self): + from cloud.full_auto import branch_protection_payload + payload = branch_protection_payload(["verify"]) + return lambda m, u, hd, b: (200, payload) + + def test_full_auto_resolve_path_token_missing(self): + # No test resolver: the _resolve_app_token plumbing lines + # run (conn open/close), and a None token denies. + self._full_auto_install() + h = self._full_auto_h({"repo": "o/r"}) + with mock.patch.dict(os.environ, {"CODEX_GLOBAL_ADMINS": "u1", + "FULL_AUTO_ENABLED": "1"}), \ + mock.patch("cloud.server._resolve_app_token", + return_value=None): + h._full_auto_execute("u1") + code, payload = self._last(h) + self.assertEqual(code, 403) + self.assertEqual(payload["reason"], "token_missing") + + def test_full_auto_plan_error_400(self): + self._full_auto_install() + h = self._full_auto_h({"repo": "o/r"}) + h.server.full_auto_token_resolver = lambda db_path, repo: "tok" + h.server.full_auto_http_client = self._protection_client() + from cloud.full_auto import FullAutoError + with mock.patch.dict(os.environ, {"CODEX_GLOBAL_ADMINS": "u1", + "FULL_AUTO_ENABLED": "1"}), \ + mock.patch("cloud.server.auto_config_plan", + side_effect=FullAutoError( + "bad plan", reason="bad")): + h._full_auto_execute("u1") + code, payload = self._last(h) + self.assertEqual(code, 400) + self.assertEqual(payload["reason"], "bad") + + def test_full_auto_plan_denied(self): + self._full_auto_install() + h = self._full_auto_h({"repo": "o/r"}) + h.server.full_auto_token_resolver = lambda db_path, repo: "tok" + h.server.full_auto_http_client = self._protection_client() + from cloud.full_auto import FullAutoDenied + with mock.patch.dict(os.environ, {"CODEX_GLOBAL_ADMINS": "u1", + "FULL_AUTO_ENABLED": "1"}), \ + mock.patch("cloud.server.auto_config_plan", + return_value=[{"step": "s1"}]), \ + mock.patch("cloud.server.run_plan", + side_effect=FullAutoDenied( + "nope", reason="step_denied")): + h._full_auto_execute("u1") + code, payload = self._last(h) + self.assertEqual(code, 403) + self.assertEqual(payload["reason"], "step_denied") + + def test_full_auto_plan_error_502(self): + self._full_auto_install() + h = self._full_auto_h({"repo": "o/r"}) + h.server.full_auto_token_resolver = lambda db_path, repo: "tok" + h.server.full_auto_http_client = self._protection_client() + from cloud.full_auto import FullAutoError + with mock.patch.dict(os.environ, {"CODEX_GLOBAL_ADMINS": "u1", + "FULL_AUTO_ENABLED": "1"}), \ + mock.patch("cloud.server.auto_config_plan", + return_value=[{"step": "s1"}]), \ + mock.patch("cloud.server.run_plan", + side_effect=FullAutoError( + "boom", reason="plan")): + h._full_auto_execute("u1") + code, payload = self._last(h) + self.assertEqual(code, 502) + self.assertEqual(payload["reason"], "plan") + self.assertEqual(payload["steps"], []) + + def test_create_task_idempotency_race_no_row_raises(self): + # The other side of the D2 race: the INSERT loses and the + # re-read finds nothing, so the IntegrityError propagates. + self._project() + h = self._h(body={"project_id": "p1", "prompt": "hello", + "idempotency_key": "k1"}) + h._db = lambda: _ProxyConn( + _conn(self.db_path), + fail_insert_prefix="INSERT INTO tasks", + fail_insert_exc=sqlite3.IntegrityError("race")) + with self.assertRaises(sqlite3.IntegrityError): + h._create_task("u1") diff --git a/cloud/tests/test_v2cov_server_handlers4.py b/cloud/tests/test_v2cov_server_handlers4.py new file mode 100644 index 0000000..ac84e39 --- /dev/null +++ b/cloud/tests/test_v2cov_server_handlers4.py @@ -0,0 +1,496 @@ +"""Coverage batch v2 (D4): cloud/server.py small handler clusters. + +Same stubbing discipline as D2/D3: the method under test is never +stubbed; _send_json / _read_json are captured input, store classes +are faked only where the test target is the close-swallow itself +(the fake still performs the business call), and collaborator +error injection covers only the handler's own error mapping. +""" + +import os +import sqlite3 +import sys +import tempfile +import time +import unittest +from pathlib import Path +from types import SimpleNamespace +from unittest import mock + +REPO_ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(REPO_ROOT)) + +from cloud.server import ( + CloudControlPlaneHandler, + _auth_store, + init_db, +) + + +def _handler(db_path, upload_dir=None): + h = CloudControlPlaneHandler.__new__(CloudControlPlaneHandler) + h.server = SimpleNamespace(db_path=db_path, upload_dir=upload_dir) + sent = [] + h._send_json = lambda status, obj, extra_headers=None: sent.append( + (status, obj)) + h.sent = sent + return h + + +def _conn(db_path): + c = sqlite3.connect(db_path) + c.row_factory = sqlite3.Row + return c + + +class _Base(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.db_path = os.path.join(self.tmp.name, "test.db") + init_db(self.db_path) + self.upload_dir = os.path.join(self.tmp.name, "uploads") + os.makedirs(self.upload_dir, exist_ok=True) + + def tearDown(self): + self.tmp.cleanup() + + def _h(self, body=None, err=None): + h = _handler(self.db_path, upload_dir=self.upload_dir) + h._read_json = lambda: (body, err) + return h + + def _project(self, pid="p1", owner="u1", repo_url=None): + c = _conn(self.db_path) + c.execute( + "INSERT INTO projects(id, name, owner_id, created_at," + " repo_url, auto_pr) VALUES(?, ?, ?, ?, ?, 0)", + (pid, "P", owner, time.time(), repo_url)) + c.commit() + c.close() + with _auth_store(self.db_path) as store: + store.create_project(pid, owner) + + def _member(self, pid, uid, role): + c = _conn(self.db_path) + c.execute( + "INSERT OR REPLACE INTO project_members(project_id," + " user_id, role) VALUES(?, ?, ?)", (pid, uid, role)) + c.commit() + c.close() + + def _task(self, tid="t1", pid="p1", status="completed", + created_by="u1", **extra): + cols = ["id", "project_id", "prompt", "status", "created_by", + "created_at", "updated_at"] + vals = [tid, pid, "do work", status, created_by, time.time(), + time.time()] + for key, val in extra.items(): + cols.append(key) + vals.append(val) + c = _conn(self.db_path) + c.execute( + "INSERT INTO tasks(%s) VALUES(%s)" + % (", ".join(cols), ", ".join("?" * len(cols))), vals) + c.commit() + c.close() + + def _env(self, eid="e1", pid="p1"): + c = _conn(self.db_path) + c.execute( + "INSERT INTO environments(id, project_id, name, branch," + " executor, status, error_text, repo_url, created_by," + " created_at, updated_at) VALUES(?, ?, ?, ?, ?, ?, ?, ?," + " ?, ?, ?)", + (eid, pid, "env", "main", "local", "ready", None, + "https://example.com/r.git", "u1", time.time(), + time.time())) + c.commit() + c.close() + + def _last(self, h): + self.assertTrue(h.sent, "handler sent no response") + return h.sent[-1] + + +class _ClosingConn: + def __init__(self): + self.conn = mock.Mock() + self.conn.close.side_effect = RuntimeError("close down") + + +class _ClosingStore: + def __init__(self): + self.conn = _ClosingConn().conn + + def close(self): + raise RuntimeError("close down") + + +class EnvProjectMemberTestCase(_Base): + def test_get_environment_check_error(self): + self._project() + self._env() + h = self._h() + h._get_environment("u9", "e1") + code, payload = self._last(h) + self.assertEqual(code, 403) + + def test_archive_environment_missing_404(self): + h = self._h() + h._archive_environment("u1", "ghost") + self.assertEqual(self._last(h)[0], 404) + + def test_archive_environment_check_error(self): + self._project() + self._env() + h = self._h() + h._archive_environment("u9", "e1") + self.assertEqual(self._last(h)[0], 403) + + def test_update_project_read_error(self): + self._project() + h = self._h(err="bad json") + h._update_project("u1", "p1") + self.assertEqual(self._last(h)[0], 400) + + def test_resolve_invite_user_id_must_be_string(self): + h = self._h() + target, status, err = h._resolve_invite_target({"user_id": 42}) + self.assertEqual(status, 400) + self.assertIsNone(target) + + def test_resolve_invite_username_must_be_string(self): + h = self._h() + target, status, err = h._resolve_invite_target({"username": 42}) + self.assertEqual(status, 400) + self.assertIsNone(target) + + def test_member_invite_project_missing_404(self): + h = self._h(body={}) + h._project_member_invite("u1", "ghost") + self.assertEqual(self._last(h)[0], 404) + + def test_member_invite_read_error(self): + self._project() + h = self._h(err="bad json") + h._project_member_invite("u1", "p1") + self.assertEqual(self._last(h)[0], 400) + + def test_member_patch_project_missing_404(self): + h = self._h(body={}) + h._project_member_patch("u1", "ghost", "u2") + self.assertEqual(self._last(h)[0], 404) + + def test_member_patch_read_error(self): + self._project() + h = self._h(err="bad json") + h._project_member_patch("u1", "p1", "u2") + self.assertEqual(self._last(h)[0], 400) + + def test_member_patch_unknown_role_400(self): + self._project() + h = self._h(body={"role": "superuser"}) + h._project_member_patch("u1", "p1", "u2") + code, payload = self._last(h) + self.assertEqual(code, 400) + self.assertIn("unknown role", payload["error"]) + + def test_push_sub_delete_wrong_method_404(self): + h = self._h() + h._project_push_subscription_delete("u1", "GET", "p1", + ["p1", "push", + "subscriptions", "s1"]) + self.assertEqual(self._last(h)[0], 404) + + def _sub(self, sid="s1", pid="p1", uid="u2"): + from cloud.push import init_push_db + init_push_db(self.db_path) + c = _conn(self.db_path) + c.execute( + "INSERT INTO push_subscriptions(id, project_id, user_id," + " channel, target, preference, created_at)" + " VALUES(?, ?, ?, ?, ?, ?, ?)", + (sid, pid, uid, "webhook", "https://example.com/hook", + "all", time.time())) + c.commit() + c.close() + + def test_push_sub_delete_missing_404(self): + self._project() + h = self._h() + h._project_push_subscription_delete("u1", "DELETE", "p1", + ["p1", "push", + "subscriptions", "ghost"]) + self.assertEqual(self._last(h)[0], 404) + + def test_push_sub_delete_forbidden_403(self): + self._project(owner="owner1") + self._member("p1", "u1", "contributor") + self._sub() + h = self._h() + h._project_push_subscription_delete("u1", "DELETE", "p1", + ["p1", "push", + "subscriptions", "s1"]) + self.assertEqual(self._last(h)[0], 403) + + +class MemoryIdentityTestCase(_Base): + def test_memory_create_read_error(self): + h = self._h(err="bad json") + h._memory_create("u1", "p1") + self.assertEqual(self._last(h)[0], 400) + + def test_memory_create_goal_required(self): + h = self._h(body={"conclusion": "done"}) + h._memory_create("u1", "p1") + code, payload = self._last(h) + self.assertEqual(code, 400) + self.assertIn("goal required", payload["error"]) + + def test_memory_create_close_failure_swallowed(self): + class _Store: + def __init__(self, db_path): + self.conn = _ClosingConn().conn + + def add_summary(self, *a, **k): + return 99 + h = self._h(body={"goal": "g", "conclusion": "c"}) + with mock.patch("cloud.server.MemoryStore", _Store): + h._memory_create("u1", "p1") + code, payload = self._last(h) + self.assertEqual(code, 201) + self.assertEqual(payload["id"], 99) + + def test_memory_list_close_failure_swallowed(self): + class _Store: + def __init__(self, db_path): + self.conn = _ClosingConn().conn + + def list_summaries(self, project_id): + return [] + + def build_prompt_context(self, project_id, top_k=5): + return "" + h = self._h() + with mock.patch("cloud.server.MemoryStore", _Store): + h._memory_list("p1", {}) + self.assertEqual(self._last(h)[0], 200) + + def test_memory_supersede_close_failure_swallowed(self): + from cloud.server import MemoryStore + store = MemoryStore(self.db_path) + try: + mid = store.add_summary("p1", "", "g", "c") + finally: + store.conn.close() + + class _Store: + def __init__(self, db_path): + self.conn = _ClosingConn().conn + + def deprecate(self, mid): + pass + h = self._h() + with mock.patch("cloud.server.MemoryStore", _Store): + h._memory_supersede("u1", "p1", str(mid)) + code, payload = self._last(h) + self.assertEqual(code, 200) + self.assertEqual(payload["status"], "deprecated") + + def test_identity_close_swallow(self): + h = self._h() + h._identity_close(_ClosingStore()) + h._auto_memory_close(_ClosingStore()) + + def test_identity_profile_upsert_read_error(self): + h = self._h(err="bad json") + h._identity_profile_upsert("u1") + self.assertEqual(self._last(h)[0], 400) + + def test_identity_profile_upsert_bad_preferences_400(self): + h = self._h(body={"preferences": "not-an-object"}) + h._identity_profile_upsert("u1") + code, payload = self._last(h) + self.assertEqual(code, 400) + self.assertIn("must be an object", payload["error"]) + + def test_identity_user_memory_create_read_error(self): + h = self._h(err="bad json") + h._identity_user_memory_create("u1") + self.assertEqual(self._last(h)[0], 400) + + def test_identity_user_memory_create_bad_kind_400(self): + h = self._h(body={"kind": "bogus", "content": "x"}) + h._identity_user_memory_create("u1") + self.assertEqual(self._last(h)[0], 400) + + def test_identity_user_memory_supersede_read_error_body_404(self): + # Read error does not abort supersede: body defaults to {} + # (4294) and the missing-memory check still answers 404. + h = self._h(err="bad json") + h._identity_user_memory_supersede("u1", "ghost") + self.assertEqual(self._last(h)[0], 404) + + def test_identity_user_memory_supersede_key_error_404(self): + # Row check passes, then the supersede itself loses a race + # (KeyError) -- the handler maps it to the same 404. + class _Store: + def __init__(self, db_path): + self.conn = mock.Mock() + + def get_memory(self, mem_id): + return {"scope": "user", "owner_user_id": "u1"} + + def supersede_memory(self, *a, **k): + raise KeyError("gone") + h = self._h(body={}) + with mock.patch("cloud.server.IdentityStore", _Store): + h._identity_user_memory_supersede("u1", "m1") + self.assertEqual(self._last(h)[0], 404) + + def test_identity_user_memory_delete_key_error_404(self): + class _Store: + def __init__(self, db_path): + self.conn = mock.Mock() + + def get_memory(self, mem_id): + return {"scope": "user", "owner_user_id": "u1"} + + def delete_memory(self, mem_id, user_id): + raise KeyError("gone") + h = self._h() + with mock.patch("cloud.server.IdentityStore", _Store): + h._identity_user_memory_delete("u1", "m1") + self.assertEqual(self._last(h)[0], 404) + + def test_identity_user_memory_supersede_bad_kind_400(self): + h = self._h(body={"kind": "fact", "content": "likes tea"}) + h._identity_user_memory_create("u1") + code, payload = self._last(h) + self.assertEqual(code, 201) + mem_id = payload["id"] + h2 = self._h(body={"replacement_kind": "bogus", + "replacement_content": "x"}) + h2._identity_user_memory_supersede("u1", mem_id) + self.assertEqual(self._last(h2)[0], 400) + + def test_identity_user_memory_delete_missing_404(self): + h = self._h() + h._identity_user_memory_delete("u1", "ghost") + self.assertEqual(self._last(h)[0], 404) + + def test_identity_user_memory_delete_forbidden_404(self): + h = self._h(body={"kind": "fact", "content": "likes tea"}) + h._identity_user_memory_create("u1") + mem_id = self._last(h)[1]["id"] + h2 = self._h() + h2._identity_user_memory_delete("u2", mem_id) + self.assertEqual(self._last(h2)[0], 404) + + def test_identity_context_with_project(self): + self._project() + h = self._h() + h._identity_context("u1", {"project_id": ["p1"]}) + code, payload = self._last(h) + self.assertEqual(code, 200) + self.assertEqual(payload["project_id"], "p1") + + def test_identity_context_close_failure_swallowed(self): + self._project() + + class _Store: + def __init__(self, db_path): + self.conn = _ClosingConn().conn + + def build_prompt_context(self, project_id): + return "" + h = self._h() + with mock.patch("cloud.server.MemoryStore", _Store): + h._identity_context("u1", {"project_id": ["p1"]}) + self.assertEqual(self._last(h)[0], 200) + + def test_auto_memory_settings_read_error(self): + h = self._h(err="bad json") + h._auto_memory_settings_upsert("u1") + self.assertEqual(self._last(h)[0], 400) + + def test_auto_memory_settings_body_must_be_object(self): + h = self._h(body=["not", "an", "object"]) + h._auto_memory_settings_upsert("u1") + code, payload = self._last(h) + self.assertEqual(code, 400) + self.assertIn("must be an object", payload["error"]) + + def test_auto_memory_log_bad_limit(self): + h = self._h() + h._auto_memory_log("u1", {"limit": ["abc"]}) + code, payload = self._last(h) + self.assertEqual(code, 400) + self.assertIn("limit must be an integer", payload["error"]) + + def test_task_auto_memory_flags_read_error(self): + h = self._h(err="bad json") + h._task_auto_memory_flags("u1", "t1") + self.assertEqual(self._last(h)[0], 400) + + def test_task_auto_memory_flags_body_must_be_object(self): + h = self._h(body=["x"]) + h._task_auto_memory_flags("u1", "t1") + self.assertEqual(self._last(h)[0], 400) + + def test_task_auto_memory_flags_check_error(self): + self._project() + self._task() + h = self._h(body={}) + h._task_auto_memory_flags("u9", "t1") + self.assertEqual(self._last(h)[0], 403) + + def test_project_identity_memory_create_read_error(self): + h = self._h(err="bad json") + h._project_identity_memory_create("u1", "p1") + self.assertEqual(self._last(h)[0], 400) + + def test_project_identity_memories_list_bad_top_k(self): + h = self._h() + h._project_identity_memories_list("p1", {"top_k": ["abc"]}) + code, payload = self._last(h) + self.assertEqual(code, 400) + self.assertIn("top_k must be an integer", payload["error"]) + + def test_project_identity_memory_supersede_none_body_404(self): + h = self._h(body=None) + h._project_identity_memory_supersede("u1", "p1", "ghost") + self.assertEqual(self._last(h)[0], 404) + + def test_project_identity_memory_supersede_read_error_body_404(self): + # Same read-error defaulting as the user-memory variant. + h = self._h(err="bad json") + h._project_identity_memory_supersede("u1", "p1", "ghost") + self.assertEqual(self._last(h)[0], 404) + + def test_project_identity_memory_supersede_key_error_404(self): + class _Store: + def __init__(self, db_path): + self.conn = mock.Mock() + + def get_memory(self, mem_id): + return {"scope": "project", "project_id": "p1"} + + def supersede_memory(self, *a, **k): + raise PermissionError("not yours") + h = self._h(body={}) + with mock.patch("cloud.server.IdentityStore", _Store): + h._project_identity_memory_supersede("u1", "p1", "m1") + self.assertEqual(self._last(h)[0], 404) + + def test_project_identity_memory_supersede_bad_kind_400(self): + self._project() + h = self._h(body={"kind": "fact", "content": "likes tea"}) + h._project_identity_memory_create("u1", "p1") + code, payload = self._last(h) + self.assertEqual(code, 201) + mem_id = payload["id"] + h2 = self._h(body={"replacement_kind": "bogus", + "replacement_content": "x"}) + h2._project_identity_memory_supersede("u1", "p1", mem_id) + self.assertEqual(self._last(h2)[0], 400) diff --git a/cloud/tests/test_v2cov_server_handlers5.py b/cloud/tests/test_v2cov_server_handlers5.py new file mode 100644 index 0000000..53d6a83 --- /dev/null +++ b/cloud/tests/test_v2cov_server_handlers5.py @@ -0,0 +1,648 @@ +"""Coverage batch v2 (D4b): cloud/server.py small handler clusters, +second half -- costs/relay, task lists, followups, threads, files, +browser helpers, peer messages, reviews, skills/apps/MCP lists. + +Same stubbing discipline as D2-D4: the method under test is never +stubbed; host-CLI collaborators (_run_host_cli, _extract_*) are +stubbed only to drive the handler's own error mapping. +""" + +import os +import socket +import sqlite3 +import sys +import tempfile +import time +import unittest +from pathlib import Path +from types import SimpleNamespace +from unittest import mock + +REPO_ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(REPO_ROOT)) + +from cloud.server import ( + CloudControlPlaneHandler, + _auth_store, + init_db, +) +from cloud.sub2api import Sub2apiError as _Sub2apiError + + +def _handler(db_path, upload_dir=None, tenants_root=None): + h = CloudControlPlaneHandler.__new__(CloudControlPlaneHandler) + h.server = SimpleNamespace(db_path=db_path, upload_dir=upload_dir, + tenants_root=tenants_root) + sent = [] + h._send_json = lambda status, obj, extra_headers=None: sent.append( + (status, obj)) + h.sent = sent + return h + + +def _conn(db_path): + c = sqlite3.connect(db_path) + c.row_factory = sqlite3.Row + return c + + +class _ProxyConn: + def __init__(self, real, fail_select_substr=None, fail_exc=None): + self._real = real + self._fail_select_substr = fail_select_substr + self._fail_exc = fail_exc + + def execute(self, sql, params=()): + text = sql.strip() + if (self._fail_select_substr and text.startswith("SELECT") + and self._fail_select_substr in text): + raise self._fail_exc + return self._real.execute(sql, params) + + def __getattr__(self, name): + return getattr(self._real, name) + + +class _Base(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.db_path = os.path.join(self.tmp.name, "test.db") + init_db(self.db_path) + self.upload_dir = os.path.join(self.tmp.name, "uploads") + os.makedirs(self.upload_dir, exist_ok=True) + + def tearDown(self): + self.tmp.cleanup() + + def _h(self, body=None, err=None): + h = _handler(self.db_path, upload_dir=self.upload_dir, + tenants_root=self.tmp.name) + h._read_json = lambda: (body, err) + return h + + def _project(self, pid="p1", owner="u1", repo_url=None): + c = _conn(self.db_path) + c.execute( + "INSERT INTO projects(id, name, owner_id, created_at," + " repo_url, auto_pr) VALUES(?, ?, ?, ?, ?, 0)", + (pid, "P", owner, time.time(), repo_url)) + c.commit() + c.close() + with _auth_store(self.db_path) as store: + store.create_project(pid, owner) + + def _member(self, pid, uid, role): + c = _conn(self.db_path) + c.execute( + "INSERT OR REPLACE INTO project_members(project_id," + " user_id, role) VALUES(?, ?, ?)", (pid, uid, role)) + c.commit() + c.close() + + def _task(self, tid="t1", pid="p1", status="completed", + created_by="u1", **extra): + cols = ["id", "project_id", "prompt", "status", "created_by", + "created_at", "updated_at"] + vals = [tid, pid, "do work", status, created_by, time.time(), + time.time()] + for key, val in extra.items(): + cols.append(key) + vals.append(val) + c = _conn(self.db_path) + c.execute( + "INSERT INTO tasks(%s) VALUES(%s)" + % (", ".join(cols), ", ".join("?" * len(cols))), vals) + c.commit() + c.close() + + def _last(self, h): + self.assertTrue(h.sent, "handler sent no response") + return h.sent[-1] + + +class _ClosingConn: + def __init__(self): + self.conn = mock.Mock() + self.conn.close.side_effect = RuntimeError("close down") + + +class CostRelayTestCase(_Base): + def test_kb_search_q_required(self): + h = self._h() + h._kb_search("p1", {}) + code, payload = self._last(h) + self.assertEqual(code, 400) + self.assertIn("q required", payload["error"]) + + def test_kb_search_bad_top_k(self): + h = self._h() + h._kb_search("p1", {"q": ["x"], "top_k": ["abc"]}) + self.assertEqual(self._last(h)[0], 400) + + def test_kb_search_close_failure_swallowed(self): + class _Store: + def __init__(self, db_path): + self.conn = _ClosingConn().conn + + def search(self, project_id, q, top_k=5): + return [] + h = self._h() + with mock.patch("cloud.server.KBStore", _Store): + h._kb_search("p1", {"q": ["x"]}) + self.assertEqual(self._last(h)[0], 200) + + def test_project_cost_bad_days(self): + h = self._h() + h._project_cost("p1", {"days": ["abc"]}) + code, payload = self._last(h) + self.assertEqual(code, 400) + self.assertIn("days must be an integer", payload["error"]) + + def test_project_cost_close_failure_swallowed(self): + class _Store: + def __init__(self, db_path): + self._conn = mock.Mock() + self._conn.close.side_effect = RuntimeError("down") + + def dual_column_report_by_project(self, project_id): + return {} + h = self._h() + with mock.patch("cloud.server.CostStore", _Store): + h._project_cost("p1", {}) + self.assertEqual(self._last(h)[0], 200) + + def test_task_cost_close_failure_swallowed(self): + self._project() + self._task() + class _Store: + def __init__(self, db_path): + self._conn = mock.Mock() + self._conn.close.side_effect = RuntimeError("down") + + def dual_column_report_by_task(self, task_id): + return {} + h = self._h() + with mock.patch("cloud.server.CostStore", _Store): + h._task_cost("u1", "t1") + self.assertEqual(self._last(h)[0], 200) + + def test_relay_models_sub2api_generic_error_502(self): + h = self._h() + with mock.patch.dict(os.environ, {"CLOUD_GATEWAY": "sub2api"}), \ + mock.patch("cloud.server._validate_sub2api_key", + side_effect=RuntimeError("net down")): + h._relay_models("u1") + self.assertEqual(self._last(h)[0], 502) + + def test_relay_models_sub2api_list_error_502(self): + h = self._h() + with mock.patch.dict(os.environ, {"CLOUD_GATEWAY": "sub2api"}), \ + mock.patch("cloud.server._validate_sub2api_key", + return_value=("ok", "key", "sub", "kid")), \ + mock.patch("cloud.server._list_sub2api_models", + side_effect=RuntimeError("gw down")): + h._relay_models("u1") + self.assertEqual(self._last(h)[0], 502) + + def test_models_overview_no_memberships_200(self): + self._project() + h = self._h() + h._models_overview("u9") + code, payload = self._last(h) + self.assertEqual(code, 200) + self.assertEqual(payload["model_stats"], []) + + def test_models_overview_close_failure_swallowed(self): + self._project() + conn = mock.Mock() + conn.execute.return_value.fetchall.return_value = [] + conn.close.side_effect = RuntimeError("down") + + class _Store: + def __init__(self, db_path): + self._conn = conn + h = self._h() + with mock.patch("cloud.server.CostStore", _Store): + h._models_overview("u1") + self.assertEqual(self._last(h)[0], 200) + + +class ListsFollowupsTestCase(_Base): + def test_list_tasks_project_required(self): + h = self._h() + h._list_tasks("u1", {}) + code, payload = self._last(h) + self.assertEqual(code, 400) + self.assertIn("project_id required", payload["error"]) + + def test_list_tasks_executor_filter_skips(self): + self._project() + self._task() + h = self._h() + h._list_tasks("u1", {"project_id": ["p1"], + "executor": ["docker"]}) + code, payload = self._last(h) + self.assertEqual(code, 200) + self.assertEqual(payload["tasks"], []) + + def test_goal_convert_queued_none(self): + self._project() + self._task(status="completed") + h = self._h() + self.assertIsNone(h._goal_convert_queued("t1", "做目标")) + + def test_goal_revive_terminal_none(self): + self._project() + self._task(status="completed", resume_message="old") + h = self._h() + self.assertIsNone(h._goal_revive_terminal("t1", "做目标")) + + def test_task_agents_remote_host_200(self): + self._project() + self._task(worktree_host="other-host") + h = self._h() + h._task_agents("u1", "t1") + code, payload = self._last(h) + self.assertEqual(code, 200) + self.assertEqual(payload["agents"], []) + + def test_create_followup_convert_race_409(self): + self._project() + self._task(status="queued") + h = self._h(body={"body": "/goal 做目标"}) + h._goal_convert_queued = lambda tid, target: None + h._create_followup("u1", "t1") + code, payload = self._last(h) + self.assertEqual(code, 409) + self.assertIn("状态已变化", payload["error"]) + + def test_create_followup_revive_race_409(self): + self._project() + self._task(status="completed") + h = self._h(body={"body": "/goal 做目标"}) + h._goal_revive_terminal = lambda tid, target: None + h._create_followup("u1", "t1") + code, payload = self._last(h) + self.assertEqual(code, 409) + self.assertIn("状态已变化", payload["error"]) + + def test_append_direct_trace_remote_host_returns(self): + h = self._h() + h._append_direct_trace({"id": "t1", "project_id": "p1", + "worktree_host": "other-host"}, + "fu1", "hi") + + def test_append_direct_trace_tenant_dir(self): + tenant_dir = os.path.join(self.tmp.name, "p1", "work", + "host-traces") + os.makedirs(tenant_dir, exist_ok=True) + open(os.path.join(tenant_dir, "t1.jsonl"), "w").close() + h = self._h() + h._append_direct_trace({"id": "t1", "project_id": "p1"}, + "fu1", "hello tenant") + with open(os.path.join(tenant_dir, "t1.jsonl")) as f: + content = f.read() + self.assertIn("followup-direct", content) + + def test_append_direct_trace_write_failure_swallowed(self): + h = self._h() + with mock.patch("os.makedirs", + side_effect=OSError("ro fs")): + h._append_direct_trace({"id": "t1", "project_id": "p1"}, + "fu1", "hi") + + def test_list_followups_missing_404(self): + h = self._h() + h._list_followups("u1", "ghost") + self.assertEqual(self._last(h)[0], 404) + + def test_list_followups_forbidden_403(self): + self._project() + self._task() + h = self._h() + h._list_followups("u9", "t1") + self.assertEqual(self._last(h)[0], 403) + + def test_task_timeline_forbidden_403(self): + self._project() + self._task() + h = self._h() + h._task_timeline("u9", "t1") + self.assertEqual(self._last(h)[0], 403) + + def test_task_files_200_cap(self): + self._project() + self._task() + worktree = os.path.join(self.tmp.name, "p1", "work", + "host-worktrees", "t1") + os.makedirs(worktree, exist_ok=True) + for i in range(201): + with open(os.path.join(worktree, "f%03d.txt" % i), "w") as f: + f.write("x") + h = self._h() + h._task_files("u1", "t1", {}) + code, payload = self._last(h) + self.assertEqual(code, 200) + self.assertEqual(len(payload["entries"]), 200) + + def test_browser_worktree_for_cloud_409(self): + self._project() + self._task(executor="cloud") + h = self._h() + worktree, err = h._browser_worktree_for("u1", "t1") + self.assertIsNone(worktree) + self.assertEqual(err[0], 409) + + def test_browser_body_dict_none_and_bad(self): + h = self._h() + self.assertIsNone(h._browser_body_dict(None)) + self.assertIsNone(h._browser_body_dict(b"not json")) + self.assertEqual(h._browser_body_dict({"a": 1}), {"a": 1}) + + def test_browser_input_worktree_error(self): + h = self._h() + h._browser_worktree_for = lambda *a: (None, (404, "nope")) + h._browser_input("u1", "t1", {}) + self.assertEqual(self._last(h)[0], 404) + + def test_browser_input_state_error(self): + h = self._h() + h._browser_worktree_for = lambda *a: (self.tmp.name, None) + with mock.patch("cloud.playwright_remote.read_state", + return_value=(None, (502, "no session"))): + h._browser_input("u1", "t1", {}) + self.assertEqual(self._last(h)[0], 502) + + def test_browser_input_write_os_error_503(self): + h = self._h() + h._browser_worktree_for = lambda *a: (self.tmp.name, None) + with mock.patch("cloud.playwright_remote.read_state", + return_value=({"url": ""}, None)), \ + mock.patch("cloud.playwright_remote.effective_holder", + return_value="user"), \ + mock.patch("cloud.playwright_remote.append_input", + side_effect=OSError("unreachable")): + h._browser_input("u1", "t1", {"type": "type", "text": "hi"}) + code, payload = self._last(h) + self.assertEqual(code, 503) + self.assertIn("browser input unreachable", payload["error"]) + + def test_chat_history_forbidden_403(self): + self._project() + self._task() + h = self._h() + h._task_chat_history("u9", "t1") + self.assertEqual(self._last(h)[0], 403) + + def test_chat_history_legacy_query_failure_none(self): + self._project() + self._task() + h = self._h() + h._trace_snapshot = lambda task: ([], None) + h._db = lambda: _ProxyConn( + _conn(self.db_path), + fail_select_substr="FROM audit_log", + fail_exc=RuntimeError("audit down")) + h._task_chat_history("u1", "t1") + code, payload = self._last(h) + self.assertEqual(code, 200) + self.assertIsNone(payload["legacy_continue_count"]) + + def test_peer_message_read_error(self): + h = self._h(err="bad json") + h._create_peer_message("u1", "t1") + self.assertEqual(self._last(h)[0], 400) + + def test_peer_message_sender_forbidden_403(self): + self._project() + self._project(pid="p2", owner="u2") + self._task(tid="t1", pid="p1") + self._task(tid="t2", pid="p2", created_by="u2") + h = self._h(body={"from_task_id": "t2", "body": "hi"}) + h._create_peer_message("u1", "t1") + self.assertEqual(self._last(h)[0], 403) + + def test_peer_message_body_too_long(self): + self._project() + self._task(tid="t1", pid="p1") + self._task(tid="t2", pid="p1") + h = self._h(body={"from_task_id": "t2", "body": "x" * 20001}) + h._create_peer_message("u1", "t1") + code, payload = self._last(h) + self.assertEqual(code, 400) + self.assertIn("body too long", payload["error"]) + + def test_list_peer_messages_missing_404(self): + h = self._h() + h._list_peer_messages("u1", "ghost") + self.assertEqual(self._last(h)[0], 404) + + def test_create_review_read_error(self): + h = self._h(err="bad json") + h._create_review("u1", "t1") + self.assertEqual(self._last(h)[0], 400) + + def test_update_review_findings_read_error(self): + h = self._h(err="bad json") + h._update_review_findings("u1", "t1", "r1") + self.assertEqual(self._last(h)[0], 400) + + def test_update_review_findings_missing_404(self): + h = self._h(body={}) + h._update_review_findings("u1", "ghost", "r1") + self.assertEqual(self._last(h)[0], 404) + + +class ThreadListsTestCase(_Base): + def test_thread_guard_docker_409(self): + h = self._h() + code, msg = h._thread_task_guard({"executor": "docker"}) + self.assertEqual(code, 409) + + def test_thread_guard_remote_host_409(self): + h = self._h() + code, msg = h._thread_task_guard( + {"executor": None, "worktree_host": "other-host"}) + self.assertEqual(code, 409) + + def test_list_threads_no_tenant_home_502(self): + h = self._h() + threads, home, cwd, err, provider = h._list_task_threads( + {"id": "t1", "project_id": None}) + self.assertEqual(err[0], 502) + + def test_list_threads_extract_none_502(self): + h = self._h() + with mock.patch("cloud.server._run_host_cli", + return_value=(True, "garbage")), \ + mock.patch("cloud.server._extract_thread_list", + return_value=None): + threads, home, cwd, err, provider = h._list_task_threads( + {"id": "t1", "project_id": "p1"}) + self.assertEqual(err[0], 502) + self.assertIn("no thread list", err[1]) + + def test_task_thread_guard_409(self): + self._project() + self._task(executor="docker") + h = self._h() + h._task_thread("u1", "t1", {}) + self.assertEqual(self._last(h)[0], 409) + + def test_task_thread_cli_failure_502(self): + self._project() + self._task() + h = self._h() + h._resolve_task_thread = lambda task: ( + {"id": "th1"}, "/tmp", "/tmp", None, None) + with mock.patch("cloud.server._run_host_cli", + return_value=(False, "host down")): + h._task_thread("u1", "t1", {}) + code, payload = self._last(h) + self.assertEqual(code, 502) + self.assertIn("host down", payload["error"]) + + def test_task_thread_no_thread_502(self): + self._project() + self._task() + h = self._h() + h._resolve_task_thread = lambda task: ( + {"id": "th1"}, "/tmp", "/tmp", None, None) + with mock.patch("cloud.server._run_host_cli", + return_value=(True, "garbage")): + h._task_thread("u1", "t1", {}) + code, payload = self._last(h) + self.assertEqual(code, 502) + self.assertIn("no thread", payload["error"]) + + def test_task_thread_mutate_task_error(self): + h = self._h() + h._task_thread_mutate("u1", "ghost", ["archive"], + "thread_archived", "detail") + self.assertEqual(self._last(h)[0], 404) + + def test_task_thread_rename_read_error(self): + self._project() + self._task() + h = self._h(err="bad json") + h._task_thread_rename("u1", "t1") + self.assertEqual(self._last(h)[0], 400) + + def test_task_thread_rename_name_too_long(self): + self._project() + self._task() + h = self._h(body={"name": "x" * 201}) + h._task_thread_rename("u1", "t1") + code, payload = self._last(h) + self.assertEqual(code, 400) + self.assertIn("name too long", payload["error"]) + + def test_project_host_home_none(self): + h = self._h() + self.assertEqual(h._project_host_home(None), (None, None)) + + def _list_case(self, method): + h = self._h() + method(h, None) + return h + + def test_skills_list_home_none_502(self): + h = self._h() + h._skills_list(None) + self.assertEqual(self._last(h)[0], 502) + + def test_skills_list_cli_failure_502(self): + h = self._h() + with mock.patch("cloud.server._run_host_cli", + return_value=(False, "host down")): + h._skills_list("p1") + self.assertEqual(self._last(h)[0], 502) + + def test_skills_list_no_list_502(self): + h = self._h() + with mock.patch("cloud.server._run_host_cli", + return_value=(True, "garbage")): + h._skills_list("p1") + self.assertEqual(self._last(h)[0], 502) + + def test_apps_list_home_none_502(self): + h = self._h() + h._apps_list(None) + self.assertEqual(self._last(h)[0], 502) + + def test_apps_list_cli_failure_502(self): + h = self._h() + with mock.patch("cloud.server._run_host_cli", + return_value=(False, "host down")): + h._apps_list("p1") + self.assertEqual(self._last(h)[0], 502) + + def test_apps_list_no_list_502(self): + h = self._h() + with mock.patch("cloud.server._run_host_cli", + return_value=(True, "garbage")): + h._apps_list("p1") + self.assertEqual(self._last(h)[0], 502) + + def test_mcp_list_home_none_502(self): + h = self._h() + h._mcp_servers_list(None) + self.assertEqual(self._last(h)[0], 502) + + def test_mcp_list_cli_failure_502(self): + h = self._h() + with mock.patch("cloud.server._run_host_cli", + return_value=(False, "host down")): + h._mcp_servers_list("p1") + self.assertEqual(self._last(h)[0], 502) + + def test_mcp_list_no_list_502(self): + h = self._h() + with mock.patch("cloud.server._run_host_cli", + return_value=(True, "garbage")): + h._mcp_servers_list("p1") + self.assertEqual(self._last(h)[0], 502) + + def test_mcp_list_next_cursor(self): + h = self._h() + with mock.patch("cloud.server._run_host_cli", + return_value=(True, {"servers": [], + "nextCursor": "n1"})): + h._mcp_servers_list("p1") + code, payload = self._last(h) + self.assertEqual(code, 200) + self.assertEqual(payload["nextCursor"], "n1") + + def test_mcp_reload_home_none_502(self): + h = self._h() + h._mcp_servers_reload("u1", None) + self.assertEqual(self._last(h)[0], 502) + + def test_mcp_reload_cli_failure_502(self): + h = self._h() + with mock.patch("cloud.server._run_host_cli", + return_value=(False, "host down")): + h._mcp_servers_reload("u1", "p1") + self.assertEqual(self._last(h)[0], 502) + + def test_mcp_oauth_login_read_error(self): + h = self._h(err="bad json") + h._mcp_server_oauth_login("u1", "p1", "srv") + self.assertEqual(self._last(h)[0], 400) + + def test_mcp_oauth_login_timeout_argv(self): + h = self._h(body={"timeoutSecs": 30}) + with mock.patch("cloud.server._run_host_cli", + return_value=(True, {"authorizationUrl": "u", + "loginId": "l"})) as cli: + h._mcp_server_oauth_login("u1", "p1", "srv") + argv = cli.call_args[0][0] + self.assertIn("--timeout-secs", argv) + self.assertEqual(self._last(h)[0], 200) + + def test_mcp_oauth_login_cli_failure_502(self): + h = self._h(body={}) + with mock.patch("cloud.server._run_host_cli", + return_value=(False, "host down")): + h._mcp_server_oauth_login("u1", "p1", "srv") + self.assertEqual(self._last(h)[0], 502) diff --git a/cloud/tests/test_v2cov_server_handlers6.py b/cloud/tests/test_v2cov_server_handlers6.py new file mode 100644 index 0000000..72511b8 --- /dev/null +++ b/cloud/tests/test_v2cov_server_handlers6.py @@ -0,0 +1,1202 @@ +"""Coverage batch v2 (D6): cloud/server.py third handler cluster -- +handoff/worktree/refs, sleep/wait/fork, loop helpers, approvals, +goal-resume/retry, quota/admin, metrics, oauth/github-app, full-auto, +keys, push, automation/todos, triage, dispatch routes. + +Discipline as in D2-D5: the method under test is never stubbed. +Collaborators (quota gates, host-CLI runners, diag builders, store +close paths) are stubbed only to drive the handler's own mapping. +""" + +import io +import os +import sqlite3 +import subprocess +import sys +import tempfile +import time +import unittest +from pathlib import Path +from types import SimpleNamespace +from unittest import mock + +REPO_ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(REPO_ROOT)) + +from cloud.server import ( + CloudControlPlaneHandler, + _auth_store, + init_db, +) +from cloud.full_auto import FullAutoError +from cloud.oauth import BindError +from cloud.wait_ops import create_handoff as _handoff_create + + +def _handler(db_path, upload_dir=None, tenants_root=None): + h = CloudControlPlaneHandler.__new__(CloudControlPlaneHandler) + h.server = SimpleNamespace(db_path=db_path, upload_dir=upload_dir, + tenants_root=tenants_root) + sent = [] + h._send_json = lambda status, obj, extra_headers=None: sent.append( + (status, obj)) + html = [] + h._send_html = lambda status, body: html.append((status, body)) + h.sent = sent + h.html = html + return h + + +def _conn(db_path): + c = sqlite3.connect(db_path) + c.row_factory = sqlite3.Row + return c + + +class _NoneCursor: + def fetchone(self): + return None + + def fetchall(self): + return [] + + +class _RowcountCursor: + def __init__(self, rowcount=0): + self.rowcount = rowcount + + def fetchone(self): + return None + + def fetchall(self): + return [] + + +class _ProxyConn: + """Selective _db proxy: fail matching SELECTs, or rowcount-0 the + matching UPDATEs; everything else delegates to a real connection. + A fresh real connection is opened per _db() call by the caller. + """ + + def __init__(self, real, fail_select_substr=None, fail_exc=None, + fail_select_nth=None, rowcount_zero_prefix=None, + none_select_substr=None): + self._real = real + self._fail_select_substr = fail_select_substr + self._fail_exc = fail_exc + self._fail_select_nth = fail_select_nth + self._select_seen = 0 + self._rowcount_zero_prefix = rowcount_zero_prefix + self._none_select_substr = none_select_substr + + def execute(self, sql, params=()): + text = sql.strip() + if (self._rowcount_zero_prefix + and text.startswith(self._rowcount_zero_prefix)): + return _RowcountCursor(0) + if text.startswith("SELECT"): + if (self._none_select_substr + and self._none_select_substr in text): + return _NoneCursor() + if (self._fail_select_substr + and self._fail_select_substr in text): + if self._fail_exc is not None: + raise self._fail_exc + if self._fail_select_nth: + substr, nth = self._fail_select_nth + if substr in text: + self._select_seen += 1 + if self._select_seen >= nth: + return _NoneCursor() + return self._real.execute(sql, params) + + def __getattr__(self, name): + return getattr(self._real, name) + + +class _FailAllConn: + def __init__(self, real): + self._real = real + + def execute(self, sql, params=()): + raise RuntimeError("db down") + + def __getattr__(self, name): + return getattr(self._real, name) + + +class _Base(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.db_path = os.path.join(self.tmp.name, "test.db") + init_db(self.db_path) + self.upload_dir = os.path.join(self.tmp.name, "uploads") + os.makedirs(self.upload_dir, exist_ok=True) + + def tearDown(self): + self.tmp.cleanup() + + def _h(self, body=None, err=None): + h = _handler(self.db_path, upload_dir=self.upload_dir, + tenants_root=self.tmp.name) + h._read_json = lambda: (body, err) + return h + + def _project(self, pid="p1", owner="u1", repo_url=None): + c = _conn(self.db_path) + c.execute( + "INSERT INTO projects(id, name, owner_id, created_at," + " repo_url, auto_pr) VALUES(?, ?, ?, ?, ?, 0)", + (pid, "P", owner, time.time(), repo_url)) + c.commit() + c.close() + with _auth_store(self.db_path) as store: + store.create_project(pid, owner) + + def _member(self, pid, uid, role): + c = _conn(self.db_path) + c.execute( + "INSERT OR REPLACE INTO project_members(project_id," + " user_id, role) VALUES(?, ?, ?)", (pid, uid, role)) + c.commit() + c.close() + + def _task(self, tid="t1", pid="p1", status="completed", + created_by="u1", **extra): + cols = ["id", "project_id", "prompt", "status", "created_by", + "created_at", "updated_at"] + vals = [tid, pid, "do work", status, created_by, time.time(), + time.time()] + for key, val in extra.items(): + cols.append(key) + vals.append(val) + c = _conn(self.db_path) + c.execute( + "INSERT INTO tasks(%s) VALUES(%s)" + % (", ".join(cols), ", ".join("?" * len(cols))), vals) + c.commit() + c.close() + + def _env(self, eid="e1", pid="p1", status="ready"): + c = _conn(self.db_path) + c.execute( + "INSERT INTO environments(id, project_id, name, status," + " created_at) VALUES(?, ?, ?, ?, ?)", + (eid, pid, "env", status, time.time())) + c.commit() + c.close() + + def _user_row(self, uid): + with _auth_store(self.db_path): + pass + c = _conn(self.db_path) + c.execute("INSERT OR IGNORE INTO users(id) VALUES(?)", (uid,)) + c.commit() + c.close() + + def _last(self, h): + self.assertTrue(h.sent, "handler sent no response") + return h.sent[-1] + + +class DispatchReadTestCase(_Base): + def test_read_body_bad_length(self): + h = self._h() + h.headers = {"Content-Length": "abc"} + h.rfile = io.BytesIO(b"") + self.assertEqual(h._read_body(), b"") + + def test_read_json_non_object(self): + h = self._h() + del h._read_json + h._read_body = lambda: b"[1, 2]" + body, err = h._read_json() + self.assertIsNone(body) + self.assertEqual(err, "body must be a JSON object") + + def test_dispatch_console_os_error_404(self): + h = self._h() + h.path = "/console" + real_open = open + + def _open(path, *a, **k): + if str(path).endswith("console.html"): + raise OSError("gone") + return real_open(path, *a, **k) + with mock.patch("builtins.open", side_effect=_open): + h._dispatch("GET") + self.assertEqual(self._last(h)[0], 404) + + def test_dispatch_oauth_bind_wrong_method_404(self): + h = self._h() + h.headers = {} + h.path = "/oauth/github/bindings" + h._dispatch("PUT") + code, payload = self._last(h) + self.assertEqual(code, 404) + self.assertEqual(payload["error"], "not found") + + def test_dispatch_environments_else_404(self): + h = self._h() + h.headers = {} + h.client_address = ("127.0.0.1", 1234) + h.path = "/environments/xyz/extra" + h._authenticate = lambda: "u1" + h._dispatch("GET") + self.assertEqual(self._last(h)[0], 404) + + def test_dispatch_admin_cost_by_creator_route(self): + self._project() + self._user_row("u1") + h = self._h() + h.headers = {} + h.client_address = ("127.0.0.1", 1234) + h.path = "/admin/cost-by-creator?user_id=u1&month=2026-11" + h._authenticate = lambda: "u1" + with mock.patch.dict(os.environ, {"CODEX_GLOBAL_ADMINS": "u1"}): + h._dispatch("GET") + self.assertEqual(self._last(h)[0], 200) + + +class HandoffWorktreeTestCase(_Base): + def test_handoff_create_read_error(self): + h = self._h(err="bad json") + h._task_handoff_create("u1", "t1") + self.assertEqual(self._last(h)[0], 400) + + def test_handoff_create_ghost_404(self): + h = self._h(body={}) + h._task_handoff_create("u1", "ghost") + self.assertEqual(self._last(h)[0], 404) + + def test_handoff_get_ghost_404(self): + h = self._h() + h._task_handoff_get("u1", "ghost") + self.assertEqual(self._last(h)[0], 404) + + def test_handoff_report_read_error(self): + h = self._h(err="bad json") + h._task_handoff_report("u1", "t1", "h1") + self.assertEqual(self._last(h)[0], 400) + + def test_handoff_report_ghost_task_404(self): + h = self._h(body={}) + h._task_handoff_report("u1", "ghost", "h1") + self.assertEqual(self._last(h)[0], 404) + + def test_handoff_report_bad_status_400(self): + self._project() + self._task() + c = _conn(self.db_path) + handoff = _handoff_create(c, "t1", "p1", "host-b", "u1") + c.commit() + c.close() + h = self._h(body={"status": "bogus"}) + h._task_handoff_report("u1", "t1", handoff["id"]) + code, payload = self._last(h) + self.assertEqual(code, 400) + self.assertIn("status must be one of", payload["error"]) + + def test_handoff_report_race_not_found_404(self): + self._project() + self._task() + c = _conn(self.db_path) + handoff = _handoff_create(c, "t1", "p1", "host-b", "u1") + c.commit() + c.close() + h = self._h(body={"status": "running"}) + # First task_handoffs SELECT (the get) sees the row; the + # second (inside report_handoff) sees nothing -> the + # TOCTOU "handoff not found" mapping. + h._db = lambda: _ProxyConn( + _conn(self.db_path), + fail_select_nth=("FROM task_handoffs", 2)) + h._task_handoff_report("u1", "t1", handoff["id"]) + code, payload = self._last(h) + self.assertEqual(code, 404) + self.assertEqual(payload["error"], "handoff not found") + + def _git_repo(self, name="t1", commit=False): + repo = os.path.join(self.tmp.name, "wt", name) + os.makedirs(repo, exist_ok=True) + subprocess.run(["git", "init", "-q", repo], check=True) + if commit: + with open(os.path.join(repo, "a.txt"), "w") as f: + f.write("x") + subprocess.run(["git", "-C", repo, "add", "."], + check=True) + subprocess.run( + ["git", "-C", repo, "-c", "user.email=a@b.c", + "-c", "user.name=t", "commit", "-qm", "init"], + check=True) + return repo + + def test_worktree_summary_empty_repo_branch_none(self): + repo = self._git_repo(commit=True) + subprocess.run(["git", "-C", repo, "checkout", "-q", "--detach", + "HEAD"], check=True) + h = self._h() + h.server.host_worktrees_dir = os.path.join(self.tmp.name, "wt") + summary = h._worktree_summary({"id": "t1", "project_id": "p1", + "status": "completed"}) + self.assertTrue(summary["exists"]) + self.assertTrue(summary["is_git"]) + self.assertIsNone(summary["branch"]) + + def test_worktree_summary_branch_command_failure(self): + self._git_repo() + h = self._h() + h.server.host_worktrees_dir = os.path.join(self.tmp.name, "wt") + with mock.patch("cloud.server.subprocess.run", + side_effect=RuntimeError("git down")): + summary = h._worktree_summary( + {"id": "t1", "project_id": "p1", "status": "completed"}) + self.assertIsNone(summary["branch"]) + + def test_worktree_summary_env_status(self): + self._git_repo() + self._env() + h = self._h() + h.server.host_worktrees_dir = os.path.join(self.tmp.name, "wt") + summary = h._worktree_summary( + {"id": "t1", "project_id": "p1", "status": "completed", + "environment_id": "e1"}) + self.assertEqual(summary["environment_id"], "e1") + + def test_task_bindings_env_block(self): + self._project() + self._task(environment_id="e1") + self._env() + h = self._h() + h._task_bindings("u1", "t1") + self.assertEqual(self._last(h)[0], 200) + + def test_refs_list_ghost(self): + h = self._h() + h._task_refs_list("u1", "ghost") + self.assertEqual(self._last(h)[0], 404) + + def test_refs_create_read_error(self): + self._project() + self._task() + h = self._h(err="bad json") + h._task_refs_create("u1", "t1") + self.assertEqual(self._last(h)[0], 400) + + def test_refs_create_ghost(self): + h = self._h(body={}) + h._task_refs_create("u1", "ghost") + self.assertEqual(self._last(h)[0], 404) + + def test_refs_delete_ghost(self): + h = self._h() + h._task_refs_delete("u1", "ghost", "r1") + self.assertEqual(self._last(h)[0], 404) + + def test_git_text_success_slice(self): + repo = self._git_repo(commit=True) + h = self._h() + out = h._git_text(repo, ["rev-parse", "HEAD"], 7) + self.assertEqual(len(out), 7) + + def test_git_text_command_failure_empty(self): + h = self._h() + with mock.patch("cloud.server.subprocess.run", + side_effect=RuntimeError("git down")): + self.assertEqual(h._git_text("/tmp", ["status"], 100), "") + + def test_worktree_archive_ghost(self): + h = self._h() + h._task_worktree_archive("u1", "ghost") + self.assertEqual(self._last(h)[0], 404) + + def test_worktree_archive_remote_409(self): + self._project() + self._task(worktree_host="other-host") + h = self._h() + h._task_worktree_archive("u1", "t1") + code, payload = self._last(h) + self.assertEqual(code, 409) + self.assertIn("other-host", payload["error"]) + + def test_worktree_snapshots_ghost(self): + h = self._h() + h._task_worktree_snapshots("u1", "ghost") + self.assertEqual(self._last(h)[0], 404) + + def test_worktree_restore_ghost(self): + h = self._h() + h._task_worktree_restore("u1", "ghost") + self.assertEqual(self._last(h)[0], 404) + + def test_worktree_restore_no_snapshot_409(self): + self._project() + self._task() + h = self._h() + h._task_worktree_restore("u1", "t1") + code, payload = self._last(h) + self.assertEqual(code, 409) + self.assertIn("no snapshot", payload["error"]) + + def test_worktree_status_ghost(self): + h = self._h() + h._task_worktree_status("u1", "ghost") + self.assertEqual(self._last(h)[0], 404) + + def test_pr_checks_ghost(self): + h = self._h() + h._task_pr_checks("u1", "ghost") + self.assertEqual(self._last(h)[0], 404) + + def test_pr_checks_git_head_fallback(self): + self._project(repo_url="https://github.com/o/r") + self._task(pr_number=7, pr_url="https://github.com/o/r/pull/7") + + def _runner(cmd, cwd=None): + if cmd and cmd[0] == "git": + return SimpleNamespace(returncode=0, + stdout="deadbeef\n", stderr="") + return SimpleNamespace(returncode=0, stdout="", stderr="") + h = self._h() + h.server.pr_gh_runner = _runner + diag = {"checks": [], "all_pass": True, "failed": [], + "diagnostics": [], "check_runs": {"available": False}} + with mock.patch("cloud.server._wait_build_diag", + return_value=diag): + h._task_pr_checks("u1", "t1") + code, payload = self._last(h) + self.assertEqual(code, 200) + self.assertEqual(payload["head_sha"], "deadbeef") + + def test_pr_checks_diag_runtime_error_502(self): + self._project(repo_url="https://github.com/o/r") + self._task(pr_number=7) + h = self._h() + h.server.pr_gh_runner = lambda cmd, cwd=None: SimpleNamespace( + returncode=0, stdout="", stderr="") + with mock.patch("cloud.server._wait_build_diag", + side_effect=RuntimeError("diag boom")): + h._task_pr_checks("u1", "t1") + code, payload = self._last(h) + self.assertEqual(code, 502) + self.assertIn("diag boom", payload["error"]) + + +class SleepWaitForkTestCase(_Base): + def test_sleep_read_error(self): + h = self._h(err="bad json") + h._task_sleep("u1", "t1") + self.assertEqual(self._last(h)[0], 400) + + def test_sleep_ghost(self): + h = self._h(body={}) + h._task_sleep("u1", "ghost") + self.assertEqual(self._last(h)[0], 404) + + def test_sleep_seconds_not_number(self): + self._project() + self._task() + h = self._h(body={"seconds": "soon"}) + h._task_sleep("u1", "t1") + code, payload = self._last(h) + self.assertEqual(code, 400) + self.assertIn("seconds must be a number", payload["error"]) + + def test_wait_read_error(self): + h = self._h(err="bad json") + h._task_wait("u1", "t1") + self.assertEqual(self._last(h)[0], 400) + + def test_wait_ghost(self): + h = self._h(body={}) + h._task_wait("u1", "ghost") + self.assertEqual(self._last(h)[0], 404) + + def test_wait_child_vanishes_not_found(self): + self._project() + self._task(tid="anchor") + self._task(tid="c1", status="completed", parent_task_id="anchor") + # _db calls: anchor check, child list, child check are real; + # every later _db (the poll fetches) sees no child row. + calls = {"n": 0} + real_db = _conn + + def _db(): + calls["n"] += 1 + if calls["n"] <= 3: + return real_db(self.db_path) + return _ProxyConn(real_db(self.db_path), + none_select_substr="SELECT * FROM tasks") + h = self._h(body={"task_ids": ["c1"], "timeout_seconds": 0}) + h._db = _db + h._task_wait("u1", "anchor") + code, payload = self._last(h) + self.assertEqual(code, 200) + self.assertEqual(payload["progress"][0]["status"], "not_found") + + def test_fork_read_error(self): + h = self._h(err="bad json") + h._fork_task("u1", "t1") + self.assertEqual(self._last(h)[0], 400) + + def test_fork_forbidden(self): + self._project() + self._task() + h = self._h(body={"message": "go"}) + h._fork_task("u9", "t1") + self.assertEqual(self._last(h)[0], 403) + + def test_fork_message_too_long(self): + self._project() + self._task() + h = self._h(body={"message": "x" * 20001}) + h._fork_task("u1", "t1") + code, payload = self._last(h) + self.assertEqual(code, 400) + self.assertIn("message too long", payload["error"]) + + def test_loop_root_broken_chain(self): + h = self._h() + root = h._loop_root_task_id({"id": "kid", + "forked_from": "ghost-parent"}) + self.assertEqual(root, "kid") + + def test_loop_root_cycle_20_steps(self): + self._project() + self._task(tid="a", forked_from="b") + self._task(tid="b", forked_from="a") + h = self._h() + root = h._loop_root_task_id({"id": "a", "forked_from": "b"}) + self.assertIn(root, ("a", "b")) + + def test_loop_create_subtask_targeted(self): + self._project() + h = self._h() + sub = h._loop_create_subtask( + {"kind": "fix", "module_id": "m1", "contract_version": "v2", + "parent_task_id": "p0", "scope": "s", + "targeted_items": ["item-a"], + "pushback_package": {"failures": ["f1"], + "reaudit_criteria": ["c1"]}}, + {"id": "p0", "project_id": "p1", "created_by": "u1"}, "u1") + self.assertIn("Targeted items only", sub["prompt"]) + self.assertEqual(sub["status"], "queued") + + def test_audit_loop_verdict_read_error(self): + h = self._h(err="bad json") + h._audit_loop_verdict("u1", "t1") + self.assertEqual(self._last(h)[0], 400) + + def test_audit_loop_fix_read_error(self): + h = self._h(err="bad json") + h._audit_loop_fix("u1", "t1") + self.assertEqual(self._last(h)[0], 400) + + def test_audit_loop_correction_read_error(self): + h = self._h(err="bad json") + h._audit_loop_correction("u1", "t1") + self.assertEqual(self._last(h)[0], 400) + + +class ApprovalsResumeRetryTestCase(_Base): + def test_create_approval_ghost_404(self): + h = self._h(body={}) + h._create_approval("u1", "ghost") + self.assertEqual(self._last(h)[0], 404) + + def test_create_approval_read_error(self): + self._project() + self._task() + h = self._h(err="bad json") + h._create_approval("u1", "t1") + self.assertEqual(self._last(h)[0], 400) + + def test_create_approval_detail_not_object(self): + self._project() + self._task() + h = self._h(body={"summary": "s", "detail": 123}) + h._create_approval("u1", "t1") + code, payload = self._last(h) + self.assertEqual(code, 400) + self.assertIn("detail must be an object", payload["error"]) + + def test_decide_approval_bad_prefix_404(self): + h = self._h() + h._decide_approval("u1", "nonsense", "approve") + self.assertEqual(self._last(h)[0], 404) + + def test_decide_approval_ghost_task_404(self): + h = self._h() + h._decide_approval("u1", "ghost/approvals/a1", "approve") + self.assertEqual(self._last(h)[0], 404) + + def test_goal_resume_ghost_404(self): + h = self._h() + h._goal_resume_task("u1", "ghost") + self.assertEqual(self._last(h)[0], 404) + + def test_goal_resume_quota_bare_return(self): + self._project() + self._task(status="interrupted", goal_mode=1) + h = self._h() + h._reject_if_over_quota = lambda *a, **k: True + h._goal_resume_task("u1", "t1") + self.assertEqual(h.sent, []) + + def test_goal_resume_cas_race_409(self): + self._project() + self._task(status="interrupted", goal_mode=1) + h = self._h() + h._db = lambda: _ProxyConn( + _conn(self.db_path), + rowcount_zero_prefix="UPDATE tasks SET status = 'queued'," + " goal_resume_pending") + h._goal_resume_task("u1", "t1") + code, payload = self._last(h) + self.assertEqual(code, 409) + self.assertIn("status changed", payload["error"]) + + def test_retry_cas_race_409(self): + self._project() + self._task(status="failed") + h = self._h() + h._db = lambda: _ProxyConn( + _conn(self.db_path), + rowcount_zero_prefix="UPDATE tasks SET status = 'queued'," + " result_text") + h._retry_task("u1", "t1") + code, payload = self._last(h) + self.assertEqual(code, 409) + self.assertIn("status changed", payload["error"]) + + +class QuotaAdminTestCase(_Base): + def test_quota_snapshot_close_swallowed(self): + from cloud.cost import CostStore as _RealCostStore + _RealCostStore(self.db_path) + h = self._h() + + class _Store: + def __init__(self, db_path): + self._conn = mock.Mock() + self._conn.close.side_effect = RuntimeError("down") + with mock.patch("cloud.server.CostStore", _Store): + quota, used, unpriced, month = h._quota_snapshot("u1") + self.assertIsNotNone(month) + + def test_coordinator_reserve_quota_none(self): + h = self._h() + h._quota_status = lambda uid: (None, 0, "2026-11") + self.assertTrue(h._check_coordinator_reserve("u1", 3)) + + def test_coordinator_reserve_sufficient(self): + h = self._h() + h._quota_status = lambda uid: (10_000_000, 0, "2026-11") + self.assertTrue(h._check_coordinator_reserve("u1", 2)) + + def test_audit_admin_failure_swallowed(self): + h = self._h() + with mock.patch("cloud.server._auth_store", + side_effect=RuntimeError("store down")): + self.assertIsNone(h._audit_admin("u1", "x", "y")) + + def test_is_global_maintainer_true(self): + h = self._h() + with mock.patch.dict(os.environ, {"CODEX_GLOBAL_ADMINS": "u1"}): + self.assertTrue(h._is_global_maintainer("u1")) + + def test_cost_by_creator_close_swallowed(self): + from cloud.cost import CostStore as _RealCostStore + _RealCostStore(self.db_path) + h = self._h() + + class _Store: + def __init__(self, db_path): + self._conn = mock.Mock() + self._conn.close.side_effect = RuntimeError("down") + + def dual_column_report_by_creator(self, target, month=None): + return {} + with mock.patch("cloud.server.CostStore", _Store): + payload = h._cost_by_creator_payload("u1", None) + self.assertEqual(payload["user_id"], "u1") + + def test_admin_quota_get_user_not_found(self): + self._project() + h = self._h() + with mock.patch.dict(os.environ, {"CODEX_GLOBAL_ADMINS": "u1"}): + h._admin_quota_get("u1", {"user_id": ["ghost"]}) + self.assertEqual(self._last(h)[0], 404) + + def test_admin_ledger_export_non_admin_403(self): + h = self._h() + h._admin_ledger_export("u9", {}) + self.assertEqual(self._last(h)[0], 403) + + def test_admin_quota_set_read_error(self): + h = self._h(err="bad json") + with mock.patch.dict(os.environ, {"CODEX_GLOBAL_ADMINS": "u1"}): + h._admin_quota_set("u1") + self.assertEqual(self._last(h)[0], 400) + + def test_admin_quota_set_bad_quota(self): + self._project() + self._user_row("u2") + h = self._h(body={"user_id": "u2", "quota_usd_micros": "abc"}) + with mock.patch.dict(os.environ, {"CODEX_GLOBAL_ADMINS": "u1"}): + h._admin_quota_set("u1") + code, payload = self._last(h) + self.assertEqual(code, 400) + self.assertIn("must be an integer or null", payload["error"]) + + def test_admin_quota_set_insert_branch(self): + self._project() + self._user_row("u2") + h = self._h(body={"user_id": "u2", "quota_usd_micros": 5000}) + with mock.patch.dict(os.environ, {"CODEX_GLOBAL_ADMINS": "u1"}): + h._admin_quota_set("u1") + self.assertEqual(self._last(h)[0], 200) + c = _conn(self.db_path) + row = c.execute( + "SELECT quota_usd_micros FROM user_quotas WHERE user_id = ?", + ("u2",)).fetchone() + c.close() + self.assertEqual(row[0], 5000) + + def test_notify_pr_event_dispatch_raises(self): + h = self._h() + with mock.patch("cloud.server._push_dispatch", + side_effect=RuntimeError("push down")): + self.assertIsNone(h._notify_pr_event("p1", "merged", {})) + + def test_notify_pr_event_dispatch_errors(self): + h = self._h() + with mock.patch("cloud.server._push_dispatch", + return_value={"errors": ["one failed"]}): + self.assertIsNone(h._notify_pr_event("p1", "merged", {})) + + def test_task_pr_ghost_404(self): + h = self._h() + h._task_pr("u1", "ghost") + self.assertEqual(self._last(h)[0], 404) + + def test_heartbeat_ghost_404(self): + h = self._h() + h._heartbeat_task("u1", "ghost") + self.assertEqual(self._last(h)[0], 404) + + def test_heartbeat_forbidden_403(self): + self._project() + self._task() + h = self._h() + h._heartbeat_task("u9", "t1") + self.assertEqual(self._last(h)[0], 403) + + def test_admin_sweep_read_error(self): + h = self._h(err="bad json") + with mock.patch.dict(os.environ, {"CODEX_GLOBAL_ADMINS": "u1"}): + h._admin_sweep("u1") + self.assertEqual(self._last(h)[0], 400) + + def test_admin_sweep_bad_threshold(self): + h = self._h(body={"threshold_seconds": "soon"}) + with mock.patch.dict(os.environ, {"CODEX_GLOBAL_ADMINS": "u1"}): + h._admin_sweep("u1") + code, payload = self._last(h) + self.assertEqual(code, 400) + self.assertIn("must be an integer", payload["error"]) + + +class MetricsOauthAppTestCase(_Base): + def _cost_rows(self): + from cloud.cost import CostStore + CostStore(self.db_path) + c = _conn(self.db_path) + c.execute( + "INSERT INTO cost_records(task_id, project_id, model," + " total_tokens, usd_micros, source, fallback_count)" + " VALUES(?, ?, ?, ?, ?, ?, ?)", + ("t1", "p1", "m1", 100, 5000, "actual", 0)) + c.execute( + "INSERT INTO cost_records(task_id, project_id, model," + " total_tokens, usd_micros, source, fallback_count)" + " VALUES(?, ?, ?, ?, ?, ?, ?)", + ("t9", "p2", "m1", 50, 100, "actual", 0)) + c.execute( + "INSERT INTO cost_records(task_id, project_id, model," + " total_tokens, usd_micros, source, fallback_count)" + " VALUES(?, ?, ?, ?, ?, ?, ?)", + ("t1", "p1", "m1", 70, None, "actual", 0)) + c.commit() + c.close() + + def test_metrics_non_member_skip_and_priced(self): + self._project() + self._project(pid="p2", owner="u2") + self._task() + self._cost_rows() + h = self._h() + h._metrics("u1") + code, payload = self._last(h) + self.assertEqual(code, 200) + self.assertEqual(payload["cost"]["actual_usd_micros"], 5000) + + def test_metrics_approval_timeout_failure_none(self): + self._project() + h = self._h() + with mock.patch("cloud.server._appr_timeout_stats", + side_effect=RuntimeError("stats down")): + h._metrics("u1") + code, payload = self._last(h) + self.assertEqual(code, 200) + self.assertIsNone(payload["approval_timeout"]) + + def test_metrics_workers_failure_empty(self): + self._project() + h = self._h() + with mock.patch("cloud.server._registry_list", + side_effect=RuntimeError("registry down")): + h._metrics("u1") + code, payload = self._last(h) + self.assertEqual(code, 200) + self.assertEqual(payload["workers"]["total"], 0) + + def test_metrics_report_unpriced_and_skip(self): + self._project() + self._project(pid="p2", owner="u2") + self._task() + self._cost_rows() + h = self._h() + h._metrics_report("u1") + self.assertEqual(self._last(h)[0], 200) + + def test_oauth_callback_provider_none(self): + h = self._h() + h._oauth_callback("/oauth/callback") + self.assertEqual(self._last(h)[0], 404) + + def test_oauth_callback_read_error(self): + h = self._h(err="bad json") + h._oauth_callback("/oauth/github/callback") + self.assertEqual(self._last(h)[0], 400) + + def test_oauth_callback_get_provider_none(self): + h = self._h() + h.path = "/oauth/callback" + h._oauth_callback_get("/oauth/callback") + self.assertEqual(h.html[0][0], 404) + + def test_oauth_callback_get_missing_params(self): + h = self._h() + h.path = "/oauth/github/callback?state=x" + h._oauth_callback_get("/oauth/github/callback") + self.assertEqual(h.html[0][0], 400) + + def test_oauth_callback_get_bind_error(self): + h = self._h() + h.path = "/oauth/github/callback?code=c&state=s" + h._oauth_finish = mock.Mock( + side_effect=BindError("taken", reason="identity_bound")) + h._oauth_callback_get("/oauth/github/callback") + self.assertEqual(h.html[0][0], 409) + + def test_oauth_bindings_get_unauthorized(self): + h = self._h() + h._authenticate = lambda: None + h._oauth_bindings_get("github") + self.assertEqual(self._last(h)[0], 401) + + def test_oauth_bindings_delete_unauthorized(self): + h = self._h() + h._authenticate = lambda: None + h._oauth_bindings_delete("github") + self.assertEqual(self._last(h)[0], 401) + + def test_github_app_setup_state_body_default(self): + h = self._h(err="bad json") + h._authenticate = lambda: "u1" + with mock.patch.dict(os.environ, { + "CODEX_GLOBAL_ADMINS": "u1", + "GITHUB_APP_PUBLIC_BASE": "https://api.example.test"}): + h._github_app_setup_state() + code, payload = self._last(h) + self.assertEqual(code, 200) + self.assertIn("setup_url", payload) + + def test_github_app_setup_state_close_swallowed(self): + h = self._h(body={}) + h._authenticate = lambda: "u1" + + class _Store: + def __init__(self, db_path): + self.conn = mock.Mock() + self.conn.close.side_effect = RuntimeError("down") + + def create_state(self, issued_by=None): + return "st1" + with mock.patch.dict(os.environ, { + "CODEX_GLOBAL_ADMINS": "u1", + "GITHUB_APP_PUBLIC_BASE": "https://api.example.test"}), \ + mock.patch("cloud.server.SetupStateStore", _Store): + h._github_app_setup_state() + self.assertEqual(self._last(h)[0], 200) + + def test_github_app_setup_state_org_url(self): + h = self._h(body={"org": "acme"}) + h._authenticate = lambda: "u1" + with mock.patch.dict(os.environ, { + "CODEX_GLOBAL_ADMINS": "u1", + "GITHUB_APP_PUBLIC_BASE": "https://api.example.test"}): + h._github_app_setup_state() + code, payload = self._last(h) + self.assertEqual(code, 200) + self.assertIn("org=acme", payload["setup_url"]) + + def test_github_app_setup_page_not_configured(self): + h = self._h() + h.path = "/github-app/setup?state=st1" + with mock.patch.dict(os.environ, {}, clear=False): + os.environ.pop("GITHUB_APP_PUBLIC_BASE", None) + h._github_app_setup_page() + self.assertEqual(h.html[0][0], 400) + + def test_github_app_callback_missing_params(self): + h = self._h() + h.path = "/github-app/callback" + h._github_app_callback() + self.assertEqual(h.html[0][0], 400) + + def test_github_app_callback_exchange_error(self): + h = self._h() + h.path = "/github-app/callback?code=c&state=st1" + + class _Store: + def __init__(self, db_path): + self.conn = mock.Mock() + self.conn.close.side_effect = RuntimeError("down") + + def state_issuer(self, state): + return "u1" + + def consume_state(self, state, allowed_issuers=None): + return True + from cloud.github_app import AppError + with mock.patch("cloud.server.SetupStateStore", _Store), \ + mock.patch("cloud.server.exchange_manifest_code", + side_effect=AppError("bad code", + reason="exchange_failed")): + h._github_app_callback() + self.assertEqual(h.html[0][0], 400) + + def test_github_app_status_unauthorized(self): + h = self._h() + h._authenticate = lambda: None + h._github_app_status() + self.assertEqual(self._last(h)[0], 401) + + +class FullAutoKeysPushTestCase(_Base): + def test_full_auto_config_plan_error(self): + h = self._h() + with mock.patch("cloud.server.auto_config_plan", + side_effect=FullAutoError("boom", reason="x")): + h._full_auto_config_plan("u1", {"repo": ["o/r"]}) + code, payload = self._last(h) + self.assertEqual(code, 400) + self.assertEqual(payload["reason"], "x") + + def test_full_auto_validate_read_error(self): + h = self._h(err="bad json") + h._full_auto_validate_action("u1") + self.assertEqual(self._last(h)[0], 400) + + def test_full_auto_validate_action_required(self): + h = self._h(body={}) + h._full_auto_validate_action("u1") + code, payload = self._last(h) + self.assertEqual(code, 400) + self.assertIn("action required", payload["error"]) + + def test_full_auto_merge_gate_read_error(self): + h = self._h(err="bad json") + h._full_auto_merge_gate("u1") + self.assertEqual(self._last(h)[0], 400) + + def test_full_auto_ci_fix_read_error(self): + h = self._h(err="bad json") + h._full_auto_ci_fix("u1") + self.assertEqual(self._last(h)[0], 400) + + def test_full_auto_ci_fix_bad_attempt(self): + h = self._h(body={"attempt": "x"}) + h._full_auto_ci_fix("u1") + code, payload = self._last(h) + self.assertEqual(code, 400) + self.assertIn("attempt must be an integer", payload["error"]) + + def test_full_auto_release_read_error(self): + h = self._h(err="bad json") + h._full_auto_release_plan("u1") + self.assertEqual(self._last(h)[0], 400) + + def test_full_auto_release_tag_required(self): + h = self._h(body={}) + h._full_auto_release_plan("u1") + code, payload = self._last(h) + self.assertEqual(code, 400) + self.assertIn("tag required", payload["error"]) + + def test_full_auto_rollback_read_error(self): + h = self._h(err="bad json") + h._full_auto_rollback_plan("u1") + self.assertEqual(self._last(h)[0], 400) + + def test_full_auto_audit_limit_fallback(self): + h = self._h() + with mock.patch.dict(os.environ, {"CODEX_GLOBAL_ADMINS": "u1"}): + h._full_auto_audit_list("u1", {"limit": ["abc"]}) + self.assertEqual(self._last(h)[0], 200) + + def test_full_auto_audit_query_failure_empty(self): + h = self._h() + h._db = lambda: _ProxyConn( + _conn(self.db_path), + fail_select_substr="FROM audit_log", + fail_exc=RuntimeError("audit down")) + with mock.patch.dict(os.environ, {"CODEX_GLOBAL_ADMINS": "u1"}): + h._full_auto_audit_list("u1", {}) + code, payload = self._last(h) + self.assertEqual(code, 200) + self.assertEqual(payload["audit"], []) + + def test_user_keys_create_body_default(self): + self._user_row("u1") + h = self._h(err="bad json") + h._authenticate = lambda: "u1" + h._user_keys_create() + code, payload = self._last(h) + self.assertEqual(code, 201) + self.assertEqual(payload["label"], "api-key") + + def test_user_keys_revoke_unauthorized(self): + h = self._h() + h._authenticate = lambda: None + h._user_keys_revoke("k1") + self.assertEqual(self._last(h)[0], 401) + + def test_user_keys_revoke_others_unauthorized(self): + h = self._h() + h._authenticate = lambda: None + h._user_keys_revoke_others() + self.assertEqual(self._last(h)[0], 401) + + def test_push_create_read_error(self): + h = self._h(err="bad json") + h._push_create_endpoint("u1", "p1") + self.assertEqual(self._last(h)[0], 400) + + def test_push_create_bad_channel(self): + h = self._h(body={"channel": "bogus", "target": "t"}) + h._push_create_endpoint("u1", "p1") + self.assertEqual(self._last(h)[0], 400) + + def test_push_dispatch_read_error(self): + h = self._h(err="bad json") + h._push_dispatch_endpoint("u1") + self.assertEqual(self._last(h)[0], 400) + + def test_push_dispatch_missing_fields(self): + h = self._h(body={}) + h._push_dispatch_endpoint("u1") + code, payload = self._last(h) + self.assertEqual(code, 400) + self.assertIn("event_type and project_id required", + payload["error"]) + + +class AutomationTodosTestCase(_Base): + def _fail_db(self, h): + h._db = lambda: _FailAllConn(_conn(self.db_path)) + + def test_automation_list_store_failure_502(self): + h = self._h() + self._fail_db(h) + h._automation_list("p1") + self.assertEqual(self._last(h)[0], 502) + + def test_todos_list_store_failure_502(self): + h = self._h() + self._fail_db(h) + h._todos_list("p1", {}) + self.assertEqual(self._last(h)[0], 502) + + def test_todos_create_store_failure_502(self): + h = self._h(body={"title": "t"}) + self._fail_db(h) + h._todos_create("u1", "p1") + self.assertEqual(self._last(h)[0], 502) + + def test_todos_get_store_failure_502(self): + h = self._h() + self._fail_db(h) + h._todos_get("p1", "td1") + self.assertEqual(self._last(h)[0], 502) + + def test_todos_delete_store_failure_502(self): + h = self._h() + self._fail_db(h) + h._todos_delete("u1", "p1", "td1") + self.assertEqual(self._last(h)[0], 502) + + def test_automation_cancel_store_failure_502(self): + h = self._h() + self._fail_db(h) + h._automation_cancel("u1", "p1", "a1") + self.assertEqual(self._last(h)[0], 502) + + def test_automation_get_ghost_404(self): + h = self._h() + h._automation_get("u1", "p1", "ghost") + self.assertEqual(self._last(h)[0], 404) + + def test_automation_get_runs_failure_note(self): + from cloud.automations import create_automation + c = _conn(self.db_path) + auto = create_automation(c, "p1", "nightly", "do it", "u1") + c.close() + h = self._h() + h._db = lambda: _ProxyConn( + _conn(self.db_path), + fail_select_substr="FROM automation_runs", + fail_exc=RuntimeError("runs down")) + h._automation_get("u1", "p1", auto["id"]) + code, payload = self._last(h) + self.assertEqual(code, 200) + self.assertEqual(payload["runs_note"], "runs unavailable") + + def test_automation_bind_get_ghost_404(self): + h = self._h() + h._automation_bind_get("u1", "p1", "ghost") + self.assertEqual(self._last(h)[0], 404) + + def test_automation_unbind_ghost_404(self): + h = self._h() + h._automation_unbind("u1", "p1", "ghost") + self.assertEqual(self._last(h)[0], 404) + + def test_automation_unbind_no_binding_404(self): + from cloud.automations import create_automation + c = _conn(self.db_path) + auto = create_automation(c, "p1", "nightly", "do it", "u1") + c.close() + h = self._h() + h._automation_unbind("u1", "p1", auto["id"]) + code, payload = self._last(h) + self.assertEqual(code, 404) + self.assertIn("no binding", payload["error"]) + + def test_triage_store_failure_502(self): + h = self._h() + self._fail_db(h) + h._triage_inbox("p1") + self.assertEqual(self._last(h)[0], 502) diff --git a/cloud/tests/test_v2cov_server_handlers7.py b/cloud/tests/test_v2cov_server_handlers7.py new file mode 100644 index 0000000..6eb7d05 --- /dev/null +++ b/cloud/tests/test_v2cov_server_handlers7.py @@ -0,0 +1,615 @@ +"""Coverage batch v2 (D7): cloud/server.py module-level helpers and +last small handler branches -- rate limiter, heartbeat, quota helpers, +plan-params parsing, kill requests, gate validation, thread guards, +oauth/github-app leftovers, todos/automation error mappings. + +Discipline as before: the function under test is never stubbed. +""" + +import json +import os +import sqlite3 +import sys +import tempfile +import threading +import time +import unittest +from collections import deque +from pathlib import Path +from types import SimpleNamespace +from unittest import mock + +REPO_ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(REPO_ROOT)) + +from cloud.server import ( + CloudControlPlaneHandler, + RateLimiter, + _apply_default_quota, + _auth_store, + _backfill_default_quotas, + _classify_goal_followup, + _deployed_commit, + _default_executor, + _full_auto_installation_verified, + _full_auto_parse_plan_params, + _make_rate_limiter, + _read_agent_entries, + _read_agent_entries_multi, + _resolve_shared_home_mcp, + _run_host_cli, + _task_to_dict, + _upload_dir_for, + _validate_hard_gate_body, + _worker_heartbeat_status, + init_db, + request_kill, + start_rate_limit_sweeper, + sweep_rate_limit_hits, + DbRateLimiter, +) +from cloud.server import _auth_store as _auth_store_cm + + +def _handler(db_path, upload_dir=None, tenants_root=None): + h = CloudControlPlaneHandler.__new__(CloudControlPlaneHandler) + h.server = SimpleNamespace(db_path=db_path, upload_dir=upload_dir, + tenants_root=tenants_root) + sent = [] + h._send_json = lambda status, obj, extra_headers=None: sent.append( + (status, obj)) + html = [] + h._send_html = lambda status, body: html.append((status, body)) + h.sent = sent + h.html = html + return h + + +def _conn(db_path): + c = sqlite3.connect(db_path) + c.row_factory = sqlite3.Row + return c + + +class _Base(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.db_path = os.path.join(self.tmp.name, "test.db") + init_db(self.db_path) + self.upload_dir = os.path.join(self.tmp.name, "uploads") + os.makedirs(self.upload_dir, exist_ok=True) + + def tearDown(self): + self.tmp.cleanup() + + def _h(self, body=None, err=None): + h = _handler(self.db_path, upload_dir=self.upload_dir, + tenants_root=self.tmp.name) + h._read_json = lambda: (body, err) + return h + + def _project(self, pid="p1", owner="u1", repo_url=None): + c = _conn(self.db_path) + c.execute( + "INSERT INTO projects(id, name, owner_id, created_at," + " repo_url, auto_pr) VALUES(?, ?, ?, ?, ?, 0)", + (pid, "P", owner, time.time(), repo_url)) + c.commit() + c.close() + with _auth_store(self.db_path) as store: + store.create_project(pid, owner) + + def _task(self, tid="t1", pid="p1", status="completed", + created_by="u1", **extra): + cols = ["id", "project_id", "prompt", "status", "created_by", + "created_at", "updated_at"] + vals = [tid, pid, "do work", status, created_by, time.time(), + time.time()] + for key, val in extra.items(): + cols.append(key) + vals.append(val) + c = _conn(self.db_path) + c.execute( + "INSERT INTO tasks(%s) VALUES(%s)" + % (", ".join(cols), ", ".join("?" * len(cols))), vals) + c.commit() + c.close() + + def _last(self, h): + self.assertTrue(h.sent, "handler sent no response") + return h.sent[-1] + + +class HelpersTestCase(_Base): + def test_run_host_cli_timeout(self): + fake = os.path.join(self.tmp.name, "host.py") + with open(fake, "w") as f: + f.write("# fake\n") + import subprocess as _sp + with mock.patch("cloud.server._host_py_path", + return_value=fake), \ + mock.patch("cloud.server.subprocess.run", + side_effect=_sp.TimeoutExpired("host.py", 5)): + ok, err = _run_host_cli(["x"], "/tmp") + self.assertFalse(ok) + self.assertEqual(err, "host.py timed out") + + def test_run_host_cli_os_error(self): + fake = os.path.join(self.tmp.name, "host.py") + with open(fake, "w") as f: + f.write("# fake\n") + with mock.patch("cloud.server._host_py_path", + return_value=fake), \ + mock.patch("cloud.server.subprocess.run", + side_effect=OSError("no exec")): + ok, err = _run_host_cli(["x"], "/tmp") + self.assertFalse(ok) + self.assertIn("cannot run host.py", err) + + def test_shared_home_tenants_root_raises(self): + with mock.patch("cloud.server._local_worker_codex_home", + return_value=None), \ + mock.patch("cloud.server._tenants_root_for", + side_effect=RuntimeError("no root")): + self.assertIsNone(_resolve_shared_home_mcp(None)) + + def test_shared_home_tenants_root_empty(self): + with mock.patch("cloud.server._local_worker_codex_home", + return_value=None), \ + mock.patch("cloud.server._tenants_root_for", + return_value=""): + self.assertIsNone(_resolve_shared_home_mcp(None)) + + def test_read_agent_entries_mixed_lines(self): + path = os.path.join(self.tmp.name, "agents.jsonl") + with open(path, "w") as f: + f.write("\n") + f.write("not json\n") + f.write("[1, 2]\n") + f.write('{"thread": "t", "nope": 1}\n') + f.write('{"thread": "t", "id": "a", "state": "start"}\n') + f.write('{"thread": "t", "id": "a", "state": "done"}\n') + entries = _read_agent_entries(path) + self.assertEqual(len(entries), 1) + self.assertEqual(entries[0]["state"], "done") + self.assertEqual(_read_agent_entries( + os.path.join(self.tmp.name, "missing.jsonl")), []) + + def test_read_agent_entries_multi_overlap(self): + p1 = os.path.join(self.tmp.name, "a1.jsonl") + p2 = os.path.join(self.tmp.name, "a2.jsonl") + with open(p1, "w") as f: + f.write('{"thread": "t", "id": "a", "v": 1}\n') + with open(p2, "w") as f: + f.write('{"thread": "t", "id": "a", "v": 2}\n') + entries = _read_agent_entries_multi([p1, p2]) + self.assertEqual(len(entries), 1) + self.assertEqual(entries[0]["v"], 2) + + def test_rate_limiter_key_cleanup(self): + limiter = RateLimiter(per_minute=100, window_seconds=60, + time_fn=lambda: 1000.0) + limiter._hits = {("k%d" % i): deque() for i in range(10001)} + self.assertTrue(limiter.allow("new")) + self.assertEqual(len(limiter._hits), 10001) + + def test_db_limiter_ensure_twice(self): + limiter = DbRateLimiter(self.db_path) + c = _conn(self.db_path) + limiter._ensure_table(c) + limiter._ensure_table(c) + c.close() + self.assertTrue(limiter._ensured) + + def test_db_limiter_ensure_inner_return(self): + # Inner early return needs a thread that passed the outer + # check, then blocks on the lock while the main thread flips + # _ensured: deterministic, no timing luck beyond the block. + limiter = DbRateLimiter(self.db_path) + limiter._ensured = False + c = _conn(self.db_path) + limiter._ensure_lock.acquire() + done = [] + thread = threading.Thread( + target=lambda: done.append(limiter._ensure_table(c))) + thread.start() + time.sleep(0.2) + limiter._ensured = True + limiter._ensure_lock.release() + thread.join(timeout=5) + self.assertFalse(thread.is_alive()) + c.close() + + def test_make_rate_limiter_postgres(self): + with mock.patch.dict(os.environ, {"CLOUD_DB_BACKEND": "postgres"}): + limiter = _make_rate_limiter(self.db_path) + self.assertIsInstance(limiter, DbRateLimiter) + + def test_sweep_postgres_no_lock(self): + conn = mock.Mock() + conn.execute.return_value.fetchone.return_value = (0,) + with mock.patch.dict(os.environ, {"CLOUD_DB_BACKEND": "postgres"}), \ + mock.patch("cloud.server._db_connect", + return_value=conn): + self.assertFalse(sweep_rate_limit_hits(self.db_path, 60)) + + def test_sweeper_loop_runs_and_dies(self): + limiter = DbRateLimiter(self.db_path) + with mock.patch("time.sleep", + side_effect=[None, RuntimeError("stop")]), \ + mock.patch("cloud.server.sweep_rate_limit_hits", + side_effect=RuntimeError("sweep down")): + thread = start_rate_limit_sweeper(limiter, self.db_path) + self.assertIsNotNone(thread) + thread.join(timeout=5) + self.assertFalse(thread.is_alive()) + + def test_deployed_commit_missing_file(self): + self.assertIsNone(_deployed_commit()) + + def test_deployed_commit_success_path(self): + # Success path credits 1088/1091 in coverage's line map; + # the file lives in the repo root only for this test. + root = Path(__file__).resolve().parents[2] + target = root / "DEPLOYED_COMMIT" + self.assertFalse(target.exists(), + "DEPLOYED_COMMIT must not pre-exist") + try: + target.write_text(" abc123\n", encoding="utf-8") + self.assertEqual(_deployed_commit(), "abc123") + finally: + target.unlink(missing_ok=True) + + def test_worker_heartbeat_bad_override(self): + hb = os.path.join(self.tmp.name, "hb.json") + with open(hb, "w") as f: + json.dump({"last_seen": time.time()}, f) + server = SimpleNamespace( + db_path=None, worker_heartbeat_max_age_seconds="bogus", + worker_heartbeat_file=hb) + age, ok = _worker_heartbeat_status(server) + self.assertTrue(ok) + + def test_worker_heartbeat_bad_env(self): + hb = os.path.join(self.tmp.name, "hb.json") + with open(hb, "w") as f: + json.dump({"last_seen": time.time()}, f) + server = SimpleNamespace(db_path=None, worker_heartbeat_file=hb) + with mock.patch.dict( + os.environ, + {"CLOUD_WORKER_HEARTBEAT_MAX_AGE_SECONDS": "bogus"}): + age, ok = _worker_heartbeat_status(server) + self.assertTrue(ok) + + def test_upload_dir_fallback(self): + d = _upload_dir_for(SimpleNamespace()) + self.assertIsInstance(d, str) + self.assertTrue(d) + + def test_default_executor_local(self): + with mock.patch.dict(os.environ, + {"CLOUD_DEFAULT_EXECUTOR": "local"}): + self.assertEqual(_default_executor(), "local") + + def test_apply_default_quota_commit_failure(self): + c = _conn(self.db_path) + + class _CommitFail: + def __init__(self, real): + self._real = real + + def execute(self, *a, **k): + return self._real.execute(*a, **k) + + def commit(self): + raise RuntimeError("commit down") + + def __getattr__(self, name): + return getattr(self._real, name) + self.assertTrue(_apply_default_quota(_CommitFail(c), "u1")) + c.close() + + def test_installation_verified_config_none(self): + c = _conn(self.db_path) + with mock.patch("cloud.server.get_app_config", + return_value=None): + self.assertFalse(_full_auto_installation_verified(c)) + c.close() + + def test_installation_verified_config_error(self): + c = _conn(self.db_path) + with mock.patch("cloud.server.get_app_config", + side_effect=RuntimeError("config down")): + self.assertFalse(_full_auto_installation_verified(c)) + c.close() + + def test_installation_verified_repo(self): + c = _conn(self.db_path) + with mock.patch("cloud.server.get_app_config", + return_value={"id": 1}), \ + mock.patch("cloud.server.find_installation_for_repo", + return_value={"id": 9}): + self.assertTrue( + _full_auto_installation_verified(c, "o/r")) + c.close() + + def test_installation_verified_any(self): + c = _conn(self.db_path) + with mock.patch("cloud.server.get_app_config", + return_value={"id": 1}), \ + mock.patch("cloud.server.list_installations", + return_value=[{"id": 9}]): + self.assertTrue(_full_auto_installation_verified(c)) + c.close() + + def test_plan_params_checks_string(self): + params, err = _full_auto_parse_plan_params( + {}, {"repo": ["o/r"], "required_checks": ["a, b"]}) + self.assertIsNone(err) + self.assertEqual(params["required_checks"], ("a", "b")) + + def test_plan_params_checks_list(self): + params, err = _full_auto_parse_plan_params( + {"repo": "o/r", "required_checks": ["x", "y"]}, {}) + self.assertIsNone(err) + self.assertEqual(params["required_checks"], ("x", "y")) + + def test_plan_params_checks_bad_type(self): + params, err = _full_auto_parse_plan_params( + {"repo": "o/r", "required_checks": 5}, {}) + self.assertIsNone(params) + self.assertIn("must be a list or string", err) + + def test_plan_params_org_bad_type(self): + params, err = _full_auto_parse_plan_params( + {"repo": "o/r", "org": 123}, {}) + self.assertIsNone(params) + self.assertIn("org must be a string", err) + + def test_backfill_rollback_failure_zero(self): + c = _conn(self.db_path) + + class _Failing: + def __init__(self, real): + self._real = real + + def execute(self, sql, params=()): + if "INSERT OR IGNORE INTO user_quotas" in sql: + raise RuntimeError("insert down") + return self._real.execute(sql, params) + + def rollback(self): + raise RuntimeError("rollback down") + + def __getattr__(self, name): + return getattr(self._real, name) + self.assertEqual(_backfill_default_quotas(_Failing(c)), 0) + c.close() + + def test_auth_store_close_failure_swallowed(self): + class _FakeStore: + def __init__(self, db_path): + self.conn = mock.Mock() + self.conn.close.side_effect = RuntimeError("down") + with mock.patch("cloud.server.AuthStore", _FakeStore): + with _auth_store_cm(self.db_path) as store: + self.assertIsNotNone(store) + + def _row(self, depends_on): + return { + "id": "t1", "project_id": "p1", "prompt": "p", + "status": "completed", "idempotency_key": None, + "depends_on": depends_on, "priority": 0, + "created_by": "u1", "created_at": 1.0, "updated_at": 2.0, + "last_heartbeat": None, + } + + def test_task_to_dict_bad_depends_on(self): + d = _task_to_dict(self._row("not json")) + self.assertEqual(d["depends_on"], []) + + def test_task_to_dict_non_list_depends_on(self): + d = _task_to_dict(self._row('{"a": 1}')) + self.assertEqual(d["depends_on"], [{"a": 1}]) + + def test_request_kill_connect_failure(self): + with mock.patch("cloud.server._connect", + side_effect=RuntimeError("db down")): + self.assertFalse(request_kill(self.db_path, "t1", "u1")) + + def test_request_kill_insert_failure(self): + c = _conn(self.db_path) + + class _KillConn: + def __init__(self, real): + self._real = real + + def execute(self, sql, params=()): + if "INSERT INTO kill_requests" in sql: + raise RuntimeError("insert down") + return self._real.execute(sql, params) + + def rollback(self): + raise RuntimeError("rollback down") + + def __getattr__(self, name): + return getattr(self._real, name) + with mock.patch("cloud.server._connect", + return_value=_KillConn(c)): + self.assertFalse(request_kill(self.db_path, "t1", "u1")) + c.close() + + def test_gate_body_bad_entry(self): + self.assertIn("ci_checks entries must be objects", + _validate_hard_gate_body( + {"ci_checks": [{"name": 1}]})) + + def test_gate_body_bad_bool(self): + self.assertIn("must be a boolean", + _validate_hard_gate_body({"contract_frozen": "x"})) + + def test_gate_body_bad_version(self): + self.assertIn("must be a string", + _validate_hard_gate_body({"contract_version": 3})) + + def test_gate_body_bad_expected(self): + self.assertIn("must be a string", + _validate_hard_gate_body( + {"contract_expected_version": 4})) + + def test_classify_goal_followup_paused(self): + action, code, payload = _classify_goal_followup( + {"status": "paused"}, "做目标") + self.assertEqual(action, "error") + self.assertEqual(code, 409) + + +class HandlerLeftoversTestCase(_Base): + def test_kb_index_close_failure_swallowed(self): + h = self._h(body={"path": "a.md", "content": "hi"}) + + class _Store: + def __init__(self, db_path): + self.conn = mock.Mock() + self.conn.close.side_effect = RuntimeError("down") + + def index_file(self, project_id, path, content, kind="file"): + return "d1" + with mock.patch("cloud.server.KBStore", _Store): + h._kb_index("u1", "p1") + code, payload = self._last(h) + self.assertEqual(code, 201) + self.assertEqual(payload["id"], "d1") + + def test_create_followup_read_error(self): + self._project() + self._task() + h = self._h(err="bad json") + h._create_followup("u1", "t1") + self.assertEqual(self._last(h)[0], 400) + + def test_thread_guard_cloud(self): + h = self._h() + code, msg = h._thread_task_guard({"executor": "cloud"}) + self.assertEqual(code, 409) + + def test_thread_mutate_resolve_error(self): + self._project() + self._task() + h = self._h() + h._resolve_task_thread = lambda task, **k: ( + None, None, None, (404, "no thread"), None) + h._task_thread_mutate("u1", "t1", ["archive"], + "thread_archived", "archived %s") + self.assertEqual(self._last(h)[0], 404) + + def test_mcp_oauth_login_home_none(self): + h = self._h(body={}) + h._mcp_server_oauth_login("u1", None, "srv") + code, payload = self._last(h) + self.assertEqual(code, 502) + self.assertIn("cannot resolve tenant codex-home", + payload["error"]) + + def test_thread_read_no_thread_note(self): + self._project() + self._task() + h = self._h() + h._resolve_task_thread = lambda task, **k: ( + {"id": "th1"}, "/tmp", "/tmp", None, None) + with mock.patch("cloud.server._run_host_cli", + return_value=(True, "garbage")): + h._task_thread_read("u1", "t1") + code, payload = self._last(h) + self.assertEqual(code, 200) + self.assertIn("no thread", str(payload)) + + def test_release_hard_gate_error(self): + self._project() + self._task(status="merged") + h = self._h(body={"files_changed": [], "ci_checks": "x"}) + with mock.patch.dict(os.environ, {"CODEX_GLOBAL_ADMINS": "u1"}): + h._release_task("u1", "t1") + code, payload = self._last(h) + self.assertEqual(code, 400) + self.assertIn("must be a list", payload["error"]) + + def test_coordinator_reserve_single_part(self): + h = self._h() + self.assertTrue(h._check_coordinator_reserve("u1", 1)) + + def test_oauth_login_close_failure_swallowed(self): + from cloud.server import OAuthStateStore as _RealStore + + class _ClosingProxy: + def __init__(self, real): + self._real = real + + def close(self): + raise RuntimeError("close down") + + def __getattr__(self, name): + return getattr(self._real, name) + + class _Store(_RealStore): + def __init__(self, db_path): + super().__init__(db_path) + self.conn = _ClosingProxy(self.conn) + h = self._h() + h.path = ("/oauth/github/login?client_id=c" + "&redirect_uri=https://x.test/cb") + with mock.patch("cloud.server.OAuthStateStore", _Store): + h._oauth_login("/oauth/github/login") + # Either 200 (login started) or 400 (no client config): both + # pass through the close-failure swallow in the finally. + self.assertIn(self._last(h)[0], (200, 400)) + + def test_oauth_bind_initiate_read_error(self): + h = self._h(err="bad json") + h._authenticate = lambda: "u1" + h._oauth_bind_initiate("github") + self.assertEqual(self._last(h)[0], 400) + + def test_github_app_setup_page_org_action(self): + from cloud.github_app import SetupStateStore + store = SetupStateStore(self.db_path) + try: + state = store.create_state(issued_by="u1") + finally: + store.conn.close() + h = self._h() + h.path = ("/github-app/setup?state=%s&org=acme" % state) + with mock.patch.dict( + os.environ, + {"GITHUB_APP_PUBLIC_BASE": "https://api.example.test"}): + h._github_app_setup_page() + self.assertEqual(h.html[0][0], 200) + self.assertIn("organizations/acme", h.html[0][1]) + + def test_todos_create_read_error(self): + h = self._h(err="bad json") + h._todos_create("u1", "p1") + self.assertEqual(self._last(h)[0], 400) + + def test_todos_delete_ghost_404(self): + h = self._h() + h._todos_delete("u1", "p1", "ghost") + code, payload = self._last(h) + self.assertEqual(code, 404) + self.assertIn("todo not found", payload["error"]) + + def test_automation_update_generic_failure_502(self): + h = self._h(body={"name": "new"}) + + class _FailAll: + def __init__(self, real): + self._real = real + + def execute(self, *a, **k): + raise RuntimeError("db down") + + def __getattr__(self, name): + return getattr(self._real, name) + h._db = lambda: _FailAll(_conn(self.db_path)) + h._automation_update("u1", "p1", "a1") + self.assertEqual(self._last(h)[0], 502) diff --git a/cloud/tests/test_v2cov_server_handlers8.py b/cloud/tests/test_v2cov_server_handlers8.py new file mode 100644 index 0000000..30a873a --- /dev/null +++ b/cloud/tests/test_v2cov_server_handlers8.py @@ -0,0 +1,204 @@ +"""Coverage batch v2 (D8): GitHub webhook /fix-review branches -- +project with no owner, task-INSERT IntegrityError duplicate read, +and IntegrityError re-raise. + +The webhook handler runs end to end with a real HMAC signature +(env secret), a real delivery claim and a real fix-review claim; +only the task-table INSERT is faulted, via a counting _db proxy, +to drive the handler's own IntegrityError mapping. +""" + +import hashlib +import hmac +import io +import json +import os +import sqlite3 +import sys +import tempfile +import time +import unittest +from pathlib import Path +from types import SimpleNamespace +from unittest import mock + +REPO_ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(REPO_ROOT)) + +from cloud.server import ( + CloudControlPlaneHandler, + init_db, +) + +SECRET = "test-secret" + + +def _handler(db_path): + h = CloudControlPlaneHandler.__new__(CloudControlPlaneHandler) + h.server = SimpleNamespace(db_path=db_path, upload_dir=None, + tenants_root=None) + sent = [] + h._send_json = lambda status, obj, extra_headers=None: sent.append( + (status, obj)) + h.sent = sent + return h + + +def _conn(db_path): + c = sqlite3.connect(db_path) + c.row_factory = sqlite3.Row + return c + + +class _RowCursor: + def __init__(self, row): + self._row = row + + def fetchone(self): + return self._row + + def fetchall(self): + return [self._row] if self._row else [] + + +class _WebhookConn: + """Delegates everything except the task INSERT (raises + IntegrityError) and the idempotency re-read (faked row).""" + + def __init__(self, real, existing_row): + self._real = real + self._existing_row = existing_row + + def execute(self, sql, params=()): + text = sql.strip() + if text.startswith("INSERT INTO tasks"): + raise sqlite3.IntegrityError("duplicate delivery") + if "SELECT id FROM tasks WHERE project_id" in text: + return _RowCursor(self._existing_row) + return self._real.execute(sql, params) + + def __getattr__(self, name): + return getattr(self._real, name) + + +class WebhookFixReviewTestCase(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.db_path = os.path.join(self.tmp.name, "test.db") + init_db(self.db_path) + from cloud.cost import CostStore + CostStore(self.db_path) + + def tearDown(self): + self.tmp.cleanup() + + def _project_row(self, owner): + c = _conn(self.db_path) + c.execute( + "INSERT INTO projects(id, name, owner_id, created_at," + " repo_url, auto_pr) VALUES(?, ?, ?, ?, ?, 0)", + ("p1", "o/r", owner, time.time(), None)) + c.commit() + c.close() + + def _payload(self): + return { + "action": "created", + "comment": { + "id": 99, + "body": "/fix-review please fix this", + "user": {"login": "alice", + "author_association": "MEMBER"}, + }, + "issue": {"number": 7, "user": {"login": "bob"}}, + "repository": {"full_name": "o/r"}, + } + + def _h(self, raw, delivery="d1"): + h = _handler(self.db_path) + sig = "sha256=" + hmac.new( + SECRET.encode(), raw, hashlib.sha256).hexdigest() + h.rfile = io.BytesIO(raw) + h.headers = { + "Content-Length": str(len(raw)), + "X-Hub-Signature-256": sig, + "X-GitHub-Event": "issue_comment", + "X-GitHub-Delivery": delivery, + } + return h + + def test_project_no_owner(self): + self._project_row(None) + raw = json.dumps(self._payload()).encode() + h = self._h(raw) + with mock.patch.dict(os.environ, + {"GITHUB_WEBHOOK_SECRET": SECRET}): + h._handle_github_webhook() + code, payload = h.sent[-1] + self.assertEqual(code, 200) + self.assertIn("no owner for quota attribution", + payload["error"]) + + def _proxy_db(self, existing_row): + calls = {"n": 0} + + def _db(): + calls["n"] += 1 + if calls["n"] == 2: + return _WebhookConn(_conn(self.db_path), existing_row) + return _conn(self.db_path) + return _db + + def test_insert_conflict_reads_duplicate(self): + self._project_row("u1") + raw = json.dumps(self._payload()).encode() + h = self._h(raw) + h._db = self._proxy_db(("existing-task",)) + with mock.patch.dict(os.environ, + {"GITHUB_WEBHOOK_SECRET": SECRET}): + h._handle_github_webhook() + code, payload = h.sent[-1] + self.assertEqual(code, 200) + self.assertTrue(payload["duplicate"]) + self.assertEqual(payload["task_id"], "existing-task") + + def test_insert_conflict_reraises(self): + self._project_row("u1") + raw = json.dumps(self._payload()).encode() + h = self._h(raw) + h._db = self._proxy_db(None) + with mock.patch.dict(os.environ, + {"GITHUB_WEBHOOK_SECRET": SECRET}): + with self.assertRaises(sqlite3.IntegrityError): + h._handle_github_webhook() + + +class MainHarnessTestCase(unittest.TestCase): + def _fake_server(self): + server = SimpleNamespace( + rate_limiter=object(), server_address=("127.0.0.1", 18765)) + + def _serve_forever(): + raise KeyboardInterrupt + + server.serve_forever = _serve_forever + return server + + def test_main_shared_home_synced(self): + from cloud.server import main + with mock.patch("cloud.server.create_server", + return_value=self._fake_server()), \ + mock.patch("cloud.server._resolve_shared_home_mcp", + return_value="/tmp/x"), \ + mock.patch("cloud.server._sync_shared_home_mcp", + return_value={"copied": ["f"], + "refreshed": []}): + main(["--port", "18765"]) + + def test_main_shared_home_failure_swallowed(self): + from cloud.server import main + with mock.patch("cloud.server.create_server", + return_value=self._fake_server()), \ + mock.patch("cloud.server._resolve_shared_home_mcp", + side_effect=RuntimeError("resolve down")): + main(["--port", "18765"]) diff --git a/cloud/tests/test_v2cov_transplant.py b/cloud/tests/test_v2cov_transplant.py new file mode 100644 index 0000000..62484f3 --- /dev/null +++ b/cloud/tests/test_v2cov_transplant.py @@ -0,0 +1,636 @@ +"""Coverage batch v2: check_transplant.py in-process tests. + +Why this file exists: the existing test_skill_transplant_prep.py +drives the script through subprocess, which coverage cannot see -- +so the tool measured 11.4% (31/271) despite being behaviour-tested. +These tests import the module in-process and call the same code +paths directly, with real assertions on drafts and error strings +(the subprocess tests stay untouched and still pass). + +Fixtures are the same construction as test_skill_transplant_prep +(built from the script's own fragment constants, not copies of them), +plus small variants that trip each loud-failure branch. +""" + +import contextlib +import importlib.util +import io +import sys +import tempfile +import unittest +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[2] +SKILL_DIR = REPO_ROOT / "cloud" / "skills" / "software-engineering" +SCRIPT = SKILL_DIR / "tools" / "check_transplant.py" + + +def _load_script(): + spec = importlib.util.spec_from_file_location( + "check_transplant_v2cov", str(SCRIPT)) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +CT = _load_script() + + +def _source_fixture(): + m = CT + parts = [ + "---\nname: software-engineering\n---\n", + "## 角色线程矩阵\n", + "| 角色 | 线程数 | 负责目录 | 产出 |", + "| QA / 测试 | 1 | `tests/` | 测试用例 |", + "| 独立审计 | 1 | 只写审计记录 | 审计结论 |", + "| 验收 / 发布 | 协调员兼任 | `docs/release/` | 验收清单 |", + "## 失败、续跑与成本\n", + "- 成本控制:分阶段本身就是成本控制。", + "## 独立审计与自动推回\n", + "本节是审计加固第 3 步的技能机制,与代码 `cloud/audit_pushback.py`", + "(推回决策纯函数)双轨落地,只写文档不算落地。只补审计机制,", + "不改上面已有规则的语义:G5 缺陷回路、合并拆分、G6 用户验收", + "的原意都不变。" + m.AUDIT_WARNING, + "### 独立审计角色:不写代码,只独立重跑、抽查证据、单独签字\n", + "- 审计线程不写业务代码、不改待办、不给合并决定、不执行合并。", + "### 自动推回:审计不过关,系统自动推回,不等人工点头\n", + "- 推回上限与升级:推回修复最多三轮" + m.PUSHBACK_LIMIT_PAREN + "。", + "- 与已有回路的关系:G5 缺陷回路复用为修复执行通道,推回派生的修复子任务", + " 就按 G5 方式执行,只补“自动触发 + 合并发布阻塞”这两块缺失;", + m.G7_FULLAUTO_BLOCK, + "## 推倒重做:超过三次后的升级分支(不是并行回路)\n", + "- 与上一节自动推回的关系再写一遍以免混淆:自动推回(1–3 轮修补)", + " 在前、推倒重做(超过 3 次)在后,是同一链路的前后两段;", + " 与 G5 缺陷回路的关系是复用修复执行通道(重做任务本身仍按", + " 阶段派生、仍走 G5 回归复核)" + m.REBUILD_TAIL, + "## 三条收紧:分流、保留资产、审计的审计\n", + "- 收紧一:阈值不能死板,第三次失败先判根因。", + m.TIGHTEN3_NOTIFY, + "## QA 全链路:测试环节跑通各个和所有业务链路\n", + "本节是“独立审计与 QA”部分的补充验收标准,与自动推回、推倒重做、", + "三条收紧并列,不覆盖已有规则。" + m.QA_INTRO, + "### 每个客户端交付前自测:三项缺一不算自测完\n", + "- 单元测试 + 对照契约的集成测试 + 手工冒烟清单,三项缺一不算自测完。", + "## 诚实边界:原生客户端\n", + "- 在只有 Linux 主机的环境里,真原生 iOS 需要 Xcode。", + "## 校验(协调员每过一个门禁自查一次)\n", + "- 分阶段走了吗?G3.5 没过就开了 G4b / G4c 吗?", + "- 审计与推回走了吗?审计签字人是否独立于执行人、协调员批准人、", + " QA 测试人?安全签字是不是无条件通过?审计 fail 后有没有自动推回", + " 并阻塞合并/发布?模块失败超过 3 次后有没有按推倒重做分支处理", + " (先判根因、只推倒该模块、旧实现归档、避坑清单带进新包),", + " 而不是无限修补或悄悄放行?", + "- QA 全链路走了吗?每端自测三项齐了吗?PRD 验收标准全有对应用例吗?", + " agent 专属六项齐了吗?跨端矩阵覆盖全部已选端了吗,未测端写明原因了吗?", + " 未编译端是不是只写了“代码评审 + Web 核心验证”?QA 矩阵签字与", + " 独立审计签字两个签字位都在且不是同一人吗?推回包里失败链路齐了吗,", + " 重审重跑失败链路了吗?", + "有一项为否,就停在当前门禁,不进入下一阶段。", + "## 附录:角色模板(播种随附版)\n", + "下面是 `templates/roles/` 的随附全文。", + "---", + "## 附录角色:08-shared-core(来源 templates/roles/08-shared-core.md)\n", + "# 角色模板:共享核心\n", + "- 不在共享逻辑里写某端专属代码。", + "---", + "## 附录角色:09-auditor(独立审计,新增)\n", + "# 角色模板:独立审计(1 个线程)\n", + "你是本项目的独立审计线程。", + ] + return "\n".join(parts) + "\n" + + +def _target_fixture(target_id): + markers = " ".join(CT.MARKERS[target_id]) + last = ("## 附录角色:10-iteration(来源 templates/roles/10-iteration.md,v0.5 新增)" + if target_id == "v05" + else "## 附录角色:08-shared-core(来源 templates/roles/08-shared-core.md)") + parts = [ + "# software-engineering:多客户端软件工程编排\n", + "## 角色线程矩阵\n", + "| 角色 | 线程数 | 负责目录 | 产出 |", + "| QA / 测试 | 1 | `tests/` | 测试用例 |", + "| 验收 / 发布 | 协调员兼任 | `docs/release/` | 验收清单 |", + "## 失败、续跑与成本\n", + "- 成本控制:分阶段本身就是成本控制。", + "## 诚实边界:原生客户端\n", + "- 在只有 Linux 主机的环境里,真原生 iOS 需要 Xcode。", + "## 校验(协调员每过一个门禁自查一次)\n", + "- 分阶段走了吗?G3.5 没过就开了 G4b / G4c 吗?", + "- 标记行:" + markers, + "| G1 | PRD 冻结 | PRD 按 `templates/artifacts/prd-outline.md` 写完,用户故事有验收标准 | 回产品线程改 |", + "问卷模板在 `templates/artifacts/intake-questions.md`。最关键的六问:", + "- 验收清单模板在 `templates/artifacts/acceptance-checklist.md`。", + "按 `templates/artifacts/prd-outline.md` 写一份 PRD,至少包含:", + " 协议模板见 `templates/artifacts/` 下的 agent 任务契约模板。", + "1. 按 `templates/artifacts/acceptance-checklist.md` 生成本项目的验收清单,", + "有一项为否,就停在当前门禁,不进入下一阶段。", + "## 附录:角色模板(播种随附版)\n", + last + "\n", + "# 角色模板:共享核心\n", + "- 不在共享逻辑里写某端专属代码。", + ] + return "\n".join(parts) + "\n" + + +def _main_lines_ending_with_tighten3(): + """Main lines whose last paragraph is the tighten-3 notify text. + + Exercises transform_main's v04 else-branch (notify text without a + trailing newline), which the standard fixture cannot reach because + the QA section follows the notify paragraph there. Built as a + lines list directly: transform_main only needs main lines, while + extract_source would also demand matrix/checklist/appendix. + """ + m = CT + return [ + "## 独立审计与自动推回", + "前言。" + m.AUDIT_WARNING, + "### 独立审计角色:不写代码,只独立重跑、抽查证据、单独签字", + "### 自动推回:审计不过关,系统自动推回,不等人工点头", + "上限" + m.PUSHBACK_LIMIT_PAREN + "。", + m.G7_FULLAUTO_BLOCK, + "## 推倒重做:超过三次后的升级分支(不是并行回路)", + "关系" + m.REBUILD_TAIL, + "## 三条收紧:分流、保留资产、审计的审计", + m.QA_INTRO, + m.TIGHTEN3_NOTIFY, + ] + + +class ExtractTestCase(unittest.TestCase): + def test_extract_happy(self): + hunks = CT.extract_source(_source_fixture()) + self.assertEqual(hunks["matrix_row"], "| 独立审计 | 1 | 只写审计记录 | 审计结论 |") + self.assertEqual(hunks["main"][0], "## 独立审计与自动推回") + self.assertTrue(hunks["audit_item"][0].startswith("- 审计与推回走了吗?")) + self.assertTrue(hunks["audit_item"][-1].endswith("悄悄放行?")) + self.assertTrue(hunks["qa_item"][0].startswith("- QA 全链路走了吗?")) + self.assertTrue(hunks["qa_item"][-1].endswith("重审重跑失败链路了吗?")) + self.assertEqual(hunks["appendix_body"][0], "# 角色模板:独立审计(1 个线程)") + + def test_extract_matrix_zero_and_two(self): + base = _source_fixture() + no_matrix = base.replace("| 独立审计 | 1 | 只写审计记录 | 审计结论 |\n", "") + with self.assertRaises(ValueError) as ctx: + CT.extract_source(no_matrix) + self.assertIn("auditor matrix row", str(ctx.exception)) + two = base.replace("| 验收 / 发布 |", + "| 独立审计 | 1 | 只写审计记录 | 审计结论 |\n| 验收 / 发布 |") + with self.assertRaises(ValueError): + CT.extract_source(two) + + def test_extract_main_after_honesty(self): + base = _source_fixture() + # Move the single honesty heading to before the main heading: + # delete the original, then insert one copy ahead of main, so + # both _find_exact calls still find exactly one hit and only + # the order check can fire. + moved = base.replace("## 诚实边界:原生客户端\n", "", 1) + moved = moved.replace("## 独立审计与自动推回\n", + "## 诚实边界:原生客户端\n## 独立审计与自动推回\n", 1) + with self.assertRaises(ValueError) as ctx: + CT.extract_source(moved) + self.assertIn("does not precede", str(ctx.exception)) + + def test_extract_missing_headings_and_unterminated_item(self): + base = _source_fixture() + with self.assertRaises(ValueError): + CT.extract_source(base.replace("## 独立审计与自动推回\n", "## 其它节\n", 1)) + with self.assertRaises(ValueError): + CT.extract_source(base.replace("## 附录角色:09-auditor(独立审计,新增)\n", "", 1)) + with self.assertRaises(ValueError): + CT.extract_source(base.replace("- 审计与推回走了吗?", "- 审计走了吗?", 1)) + with self.assertRaises(ValueError): + CT.extract_source(base.replace("- QA 全链路走了吗?", "- QA 走了吗?", 1)) + unterminated = base.replace("而不是无限修补或悄悄放行?", + "而不是无限修补。") + with self.assertRaises(ValueError) as ctx: + CT.extract_source(unterminated) + self.assertIn("never ends", str(ctx.exception)) + + def test_extract_trims_trailing_blank_lines_of_main(self): + # Main slice ending in blank lines: the while-pop loop runs + # and the returned main has no trailing blank. + base = _source_fixture() + variant = base.replace("## 诚实边界:原生客户端\n", + "\n\n## 诚实边界:原生客户端\n", 1) + hunks = CT.extract_source(variant) + self.assertNotEqual(hunks["main"][-1], "") + self.assertEqual(hunks["main"][0], "## 独立审计与自动推回") + + def test_find_exact_and_lines_and_fail(self): + self.assertEqual(CT._lines("a\nb"), ["a", "b"]) + lines = ["x", "y", "x"] + with self.assertRaises(ValueError): + CT._find_exact(lines, lambda l: l == "z", "missing") + with self.assertRaises(ValueError): + CT._find_exact(lines, lambda l: l == "x", "duplicated") + err = io.StringIO() + with contextlib.redirect_stderr(err): + self.assertEqual(CT.fail("boom"), 1) + self.assertIn("FAIL: boom", err.getvalue()) + + +class TransformTestCase(unittest.TestCase): + def _main(self, source=None): + return CT.extract_source(source or _source_fixture())["main"] + + def test_transform_v03(self): + lines, stopped = CT.transform_main(self._main(), "v03") + text = "\n".join(lines) + self.assertEqual(stopped, []) + self.assertNotIn("G7 热修复回路并行", text) + self.assertNotIn("全自动 CI 自动修复", text) + self.assertIn("验收失败导致的推倒要先通知用户", text) + self.assertIn("## QA 全链路", text) + self.assertNotIn("移植警告", text) + self.assertIn("推回上限独立于其它修复回路", text) + self.assertTrue(text.rstrip().endswith("。") or "三项缺一不算自测完" in text) + + def test_transform_v04(self): + lines, stopped = CT.transform_main(self._main(), "v04") + text = "\n".join(lines) + self.assertEqual(len(stopped), 1) + self.assertIn("收紧三", stopped[0]) + self.assertNotIn("G7 热修复回路并行", text) + self.assertNotIn("验收失败导致的推倒要先通知用户", text) + self.assertIn("全自动 CI 自动修复并行", text) + self.assertIn("## QA 全链路", text) + + def test_transform_v05(self): + lines, stopped = CT.transform_main(self._main(), "v05") + text = "\n".join(lines) + self.assertEqual(len(stopped), 2) + self.assertNotIn("## QA 全链路", text) + self.assertNotIn("验收失败导致的推倒要先通知用户", text) + self.assertNotIn("移植警告", text) + self.assertIn("## 三条收紧", text) + + def test_transform_v04_tighten3_without_trailing_newline(self): + lines, stopped = CT.transform_main( + _main_lines_ending_with_tighten3(), "v04") + text = "\n".join(lines) + self.assertEqual(len(stopped), 1) + self.assertNotIn("验收失败导致的推倒要先通知用户", text) + self.assertNotIn("G7 热修复回路并行", text) + + def test_transform_unknown_target(self): + with self.assertRaises(ValueError): + CT.transform_main(self._main(), "v99") + + def test_transform_fragment_count_errors(self): + base = _source_fixture() + with self.assertRaises(ValueError): + CT.transform_main(CT.extract_source( + base.replace(CT.G7_FULLAUTO_BLOCK, ""))["main"], "v03") + with self.assertRaises(ValueError): + CT.transform_main(CT.extract_source( + base.replace(CT.PUSHBACK_LIMIT_PAREN, ""))["main"], "v03") + with self.assertRaises(ValueError): + CT.transform_main(CT.extract_source( + base.replace(CT.REBUILD_TAIL, ""))["main"], "v03") + with self.assertRaises(ValueError): + CT.transform_main(CT.extract_source( + base.replace(CT.G7_FULLAUTO_BLOCK, ""))["main"], "v04") + with self.assertRaises(ValueError): + CT.transform_main(CT.extract_source( + base.replace(CT.AUDIT_WARNING, ""))["main"], "v03") + + def test_transform_v05_qa_missing_and_tighten3_missing(self): + base = _source_fixture() + no_qa = base.replace("## QA 全链路:测试环节跑通各个和所有业务链路\n", "", 1) + with self.assertRaises(ValueError): + CT.transform_main(CT.extract_source(no_qa)["main"], "v05") + no_tighten = base.replace(CT.TIGHTEN3_NOTIFY, "") + with self.assertRaises(ValueError): + CT.transform_main(CT.extract_source(no_tighten)["main"], "v05") + with self.assertRaises(ValueError): + CT.transform_main(CT.extract_source(no_tighten)["main"], "v04") + + +class ApplyTestCase(unittest.TestCase): + def test_apply_all_targets_in_process(self): + for target_id in ("v03", "v04", "v05"): + with self.subTest(target_id=target_id): + draft, report = CT.apply_transplant( + _source_fixture(), _target_fixture(target_id), target_id) + self.assertNotIn("templates/artifacts", draft) + self.assertEqual(draft.count("| 独立审计 |"), 1) + self.assertEqual( + CT.check_transplanted(_target_fixture(target_id), + draft, target_id), []) + number = "11" if target_id == "v05" else "09" + self.assertIn("## 附录角色:%s-auditor" % number, draft) + self.assertTrue(any("matrix row" in line for line in report)) + if target_id == "v03": + self.assertIn("验收失败导致的推倒要先通知用户", draft) + self.assertNotIn("G7 热修复回路并行", draft) + if target_id == "v04": + self.assertIn("STOPPED", "\n".join(report)) + if target_id == "v05": + self.assertNotIn("## QA 全链路", draft) + self.assertNotIn("09-auditor", draft) + + def test_apply_error_branches(self): + base_t = _target_fixture("v03") + base_s = _source_fixture() + with self.assertRaises(ValueError): + CT.apply_transplant(base_s, base_t.replace( + "| QA / 测试 |", "| QA |"), "v03") + with self.assertRaises(ValueError): + CT.apply_transplant(base_s, base_t.replace( + "## 诚实边界:原生客户端", "## 诚实分界线", 1), "v03") + with self.assertRaises(ValueError): + CT.apply_transplant(base_s, base_t.replace( + "有一项为否", "有一项不对", 1), "v03") + already = base_t.replace( + "## 附录:角色模板(播种随附版)", + "## 附录角色:09-auditor(来源 x)\n## 附录:角色模板(播种随附版)") + with self.assertRaises(ValueError): + CT.apply_transplant(base_s, already, "v03") + duplicated = base_t.replace( + "问卷模板在 `templates/artifacts/intake-questions.md`。", + "问卷模板在 `templates/artifacts/intake-questions.md`。" + "问卷模板在 `templates/artifacts/intake-questions.md`。") + with self.assertRaises(ValueError): + CT.apply_transplant(base_s, duplicated, "v03") + missing_artifact = base_t.replace( + "问卷模板在 `templates/artifacts/intake-questions.md`。", + "问卷模板已删。") + with self.assertRaises(ValueError): + CT.apply_transplant(base_s, missing_artifact, "v03") + + +class AnchorsTestCase(unittest.TestCase): + def _anchors(self, source, target, target_id): + buf = io.StringIO() + with contextlib.redirect_stdout(buf): + errors = CT.check_anchors(source, target, target_id) + return errors, buf.getvalue() + + def test_anchors_happy_all_targets(self): + for target_id in ("v03", "v04", "v05"): + with self.subTest(target_id=target_id): + errors, out = self._anchors(_source_fixture(), + _target_fixture(target_id), + target_id) + self.assertEqual(errors, []) + self.assertIn("anchors:", out) + + def test_anchors_source_unextractable(self): + errors, _out = self._anchors("no hunks here", + _target_fixture("v03"), "v03") + self.assertEqual(len(errors), 1) + self.assertIn("not extractable", errors[0]) + + def test_anchors_each_error(self): + base_t = _target_fixture("v03") + base_s = _source_fixture() + cases = [ + (base_t.replace("| QA / 测试 |", "| QA |"), + "target QA matrix row"), + (base_t.replace("## 诚实边界:原生客户端", "## 诚实分界线", 1), + "target honesty heading"), + (base_t.replace("有一项为否", "有一项不对", 1), + "target checklist end"), + (base_t.replace("## 附录:角色模板(播种随附版)", + "## 附录模板", 1), + "target appendix intro"), + (base_t.replace("## 附录角色:08-shared-core", "## 附录角色:07-x", 1), + "lacks last chapter"), + (base_t.replace("## 附录:角色模板(播种随附版)", + "## 附录角色:09-auditor(来源 x)\n" + "## 附录:角色模板(播种随附版)"), + "already contains"), + (base_t.replace("单端复核", "单端检查", 1), + "preserved marker missing"), + ] + for target, needle in cases: + with self.subTest(needle=needle): + errors, _out = self._anchors(base_s, target, "v03") + self.assertTrue(any(needle in e for e in errors), + errors) + + +class CheckTransplantedTestCase(unittest.TestCase): + def _draft(self, target_id): + draft, _report = CT.apply_transplant( + _source_fixture(), _target_fixture(target_id), target_id) + return draft + + def test_check_happy_all_targets(self): + for target_id in ("v03", "v04", "v05"): + with self.subTest(target_id=target_id): + self.assertEqual( + CT.check_transplanted(_target_fixture(target_id), + self._draft(target_id), + target_id), []) + + def test_check_marker_and_matrix_errors(self): + draft = self._draft("v03") + target = _target_fixture("v03") + dropped = "\n".join(l for l in draft.split("\n") + if not l.startswith("- 标记行:")) + errors = CT.check_transplanted(target, dropped, "v03") + self.assertTrue(any("preserved marker lost" in e for e in errors)) + no_row = draft.replace("| 独立审计 | 1 | 只写审计记录 | 审计结论 |\n", "") + errors = CT.check_transplanted(target, no_row, "v03") + self.assertTrue(any("auditor matrix row" in e for e in errors)) + two_rows = draft.replace("| 独立审计 | 1 |", + "| 独立审计 | 1 |\n| 独立审计 | 1 |", 1) + errors = CT.check_transplanted(target, two_rows, "v03") + self.assertTrue(any("auditor matrix row" in e for e in errors)) + + def test_check_main_header_missing(self): + draft = self._draft("v03") + target = _target_fixture("v03") + for header in CT.MAIN_HEADERS: + with self.subTest(header=header): + mutated = draft.replace(header + "\n", "", 1) + errors = CT.check_transplanted(target, mutated, "v03") + self.assertTrue(any("main header missing" in e + for e in errors), errors) + + def test_check_stopped_paragraphs_per_target(self): + # v03: QA must be present, G7/full-auto absent, tighten3 present. + draft = self._draft("v03") + target = _target_fixture("v03") + no_qa = draft.replace("## QA 全链路:测试环节跑通各个和所有业务链路\n", "", 1) + self.assertTrue(any("QA section missing" in e for e in + CT.check_transplanted(target, no_qa, "v03"))) + with_g7 = draft + "\nG7 热修复回路并行\n" + self.assertTrue(any("STOPPED G7" in e for e in + CT.check_transplanted(target, with_g7, "v03"))) + with_fullauto = draft + "\n全自动 CI 自动修复\n" + self.assertTrue(any("STOPPED full-auto" in e for e in + CT.check_transplanted(target, with_fullauto, "v03"))) + no_tighten = draft.replace(CT.TIGHTEN3_NOTIFY, "") + self.assertTrue(any("tighten3 notify missing" in e for e in + CT.check_transplanted(target, no_tighten, "v03"))) + # v04: QA present, G7/tighten3 absent. + draft4 = self._draft("v04") + target4 = _target_fixture("v04") + no_qa4 = draft4.replace("## QA 全链路:测试环节跑通各个和所有业务链路\n", "", 1) + self.assertTrue(any("QA section missing" in e for e in + CT.check_transplanted(target4, no_qa4, "v04"))) + with_tighten4 = draft4 + "\n" + CT.TIGHTEN3_NOTIFY + "\n" + self.assertTrue(any("STOPPED tighten3" in e for e in + CT.check_transplanted(target4, with_tighten4, "v04"))) + with_g7_4 = draft4 + "\nG7 热修复回路并行\n" + self.assertTrue(any("STOPPED G7" in e for e in + CT.check_transplanted(target4, with_g7_4, "v04"))) + # v05: QA section/item and tighten3 must all be absent. + draft5 = self._draft("v05") + target5 = _target_fixture("v05") + with_qa5 = draft5 + "\n## QA 全链路:测试环节跑通各个和所有业务链路\n" + self.assertTrue(any("STOPPED QA section" in e for e in + CT.check_transplanted(target5, with_qa5, "v05"))) + with_qa_item5 = draft5 + "\n- QA 全链路走了吗?\n" + self.assertTrue(any("STOPPED QA checklist" in e for e in + CT.check_transplanted(target5, with_qa_item5, "v05"))) + with_tighten5 = draft5 + "\n" + CT.TIGHTEN3_NOTIFY + "\n" + self.assertTrue(any("STOPPED tighten3" in e for e in + CT.check_transplanted(target5, with_tighten5, "v05"))) + + def test_check_appendix_stale_artifacts_and_no_overwrite(self): + draft = self._draft("v03") + target = _target_fixture("v03") + no_heading = draft.replace( + "## 附录角色:09-auditor(来源 templates/roles/09-auditor.md)", + "## 附录角色:09-auditor(来源 templates/roles/09-auditor2.md)") + self.assertTrue(any("appendix heading" in e for e in + CT.check_transplanted(target, no_heading, "v03"))) + old_form = draft + "\n09-auditor(独立审计,新增)\n" + self.assertTrue(any("old appendix heading" in e for e in + CT.check_transplanted(target, old_form, "v03"))) + for stale in ("移植警告", "以生产 v0.2 技能为准", "技能正文 diff 全是加法"): + with self.subTest(stale=stale): + mutated = draft + "\n%s\n" % stale + self.assertTrue(any("stale transplant warning" in e for e in + CT.check_transplanted(target, mutated, "v03"))) + with_artifact = draft + "\n见 `templates/artifacts/x.md`。\n" + self.assertTrue(any("templates/artifacts" in e for e in + CT.check_transplanted(target, with_artifact, "v03"))) + # No-overwrite: drop one target line that no other check pins. + dropped_honesty = draft.replace("## 诚实边界:原生客户端\n", "", 1) + self.assertTrue(any("no-overwrite" in e for e in + CT.check_transplanted(target, dropped_honesty, "v03"))) + lines = draft.split("\n") + idx_a = lines.index("## 失败、续跑与成本") + idx_b = lines.index("## 诚实边界:原生客户端") + lines[idx_a], lines[idx_b] = lines[idx_b], lines[idx_a] + reordered = "\n".join(lines) + self.assertTrue(any("no-overwrite" in e for e in + CT.check_transplanted(target, reordered, "v03"))) + + +class MainCliTestCase(unittest.TestCase): + def _write(self, tmp, source, target, target_id="v03"): + src = Path(tmp) / "source.md" + tgt = Path(tmp) / "target.md" + src.write_text(source, encoding="utf-8") + tgt.write_text(target, encoding="utf-8") + return src, tgt + + def _run(self, argv): + out, err = io.StringIO(), io.StringIO() + with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err): + code = CT.main(argv) + return code, out.getvalue(), err.getvalue() + + def test_main_anchors_ok_and_failed(self): + with tempfile.TemporaryDirectory() as tmp: + src, tgt = self._write(tmp, _source_fixture(), + _target_fixture("v03")) + code, out, _err = self._run(["anchors", "--source244", str(src), + "--target", str(tgt), + "--target-id", "v03"]) + self.assertEqual(code, 0) + self.assertIn("anchors OK", out) + bad_tgt = Path(tmp) / "bad.md" + bad_tgt.write_text(_target_fixture("v03").replace( + "| QA / 测试 |", "| QA |"), encoding="utf-8") + code, _out, err = self._run(["anchors", "--source244", str(src), + "--target", str(bad_tgt), + "--target-id", "v03"]) + self.assertEqual(code, 1) + self.assertIn("FAILED", err) + + def test_main_apply_out_and_stdout(self): + with tempfile.TemporaryDirectory() as tmp: + src, tgt = self._write(tmp, _source_fixture(), + _target_fixture("v04")) + out_path = Path(tmp) / "draft.md" + code, _out, err = self._run(["apply", "--source244", str(src), + "--target", str(tgt), + "--target-id", "v04", + "--out", str(out_path)]) + self.assertEqual(code, 0) + self.assertIn("STOPPED", err) + self.assertIn("## 附录角色:09-auditor", out_path.read_text()) + code, out, _err = self._run(["apply", "--source244", str(src), + "--target", str(tgt), + "--target-id", "v04"]) + self.assertEqual(code, 0) + self.assertIn("## 附录角色:09-auditor", out) + + def test_main_check_transplanted_ok_and_failed(self): + with tempfile.TemporaryDirectory() as tmp: + src, tgt = self._write(tmp, _source_fixture(), + _target_fixture("v05")) + draft, _report = CT.apply_transplant(_source_fixture(), + _target_fixture("v05"), "v05") + draft_path = Path(tmp) / "draft.md" + draft_path.write_text(draft, encoding="utf-8") + code, out, _err = self._run(["check-transplanted", + "--source244", str(src), + "--target", str(tgt), + "--target-id", "v05", + "--transplanted", str(draft_path)]) + self.assertEqual(code, 0) + self.assertIn("check-transplanted OK", out) + draft_path.write_text(draft + "\n见 `templates/artifacts/x.md`。\n", + encoding="utf-8") + code, _out, err = self._run(["check-transplanted", + "--source244", str(src), + "--target", str(tgt), + "--target-id", "v05", + "--transplanted", str(draft_path)]) + self.assertEqual(code, 1) + self.assertIn("FAILED", err) + + def test_main_read_error_and_value_error_are_code_2(self): + with tempfile.TemporaryDirectory() as tmp: + src, tgt = self._write(tmp, _source_fixture(), + _target_fixture("v03")) + code, _out, err = self._run(["anchors", "--source244", + str(Path(tmp) / "missing.md"), + "--target", str(tgt), + "--target-id", "v03"]) + self.assertEqual(code, 2) + self.assertIn("cannot read input", err) + bad_src = Path(tmp) / "bad.md" + bad_src.write_text("no hunks here", encoding="utf-8") + code, _out, _err = self._run(["apply", "--source244", str(bad_src), + "--target", str(tgt), + "--target-id", "v03"]) + self.assertEqual(code, 2) + + def test_main_argparse_errors_exit_2(self): + # argparse writes usage to stderr itself; swallow it so the + # full-suite log stays readable. + with contextlib.redirect_stderr(io.StringIO()): + with self.assertRaises(SystemExit) as ctx: + CT.main(["apply", "--source244", "s", "--target", "t", + "--target-id", "v99"]) + self.assertEqual(ctx.exception.code, 2) + with contextlib.redirect_stderr(io.StringIO()): + with self.assertRaises(SystemExit) as ctx: + CT.main([]) + self.assertEqual(ctx.exception.code, 2) + + +if __name__ == "__main__": + unittest.main() diff --git a/cloud/tests/test_v2cov_wait_ops.py b/cloud/tests/test_v2cov_wait_ops.py new file mode 100644 index 0000000..400d932 --- /dev/null +++ b/cloud/tests/test_v2cov_wait_ops.py @@ -0,0 +1,240 @@ +"""Coverage batch v2 (E1): cloud/wait_ops.py pure helpers -- row +access, handoff validation, pr-check parsing, wait polling cores, +diagnostics assembly. All direct calls; no production code touched. +""" + +import base64 +import json +import sqlite3 +import sys +import threading +import unittest +from pathlib import Path +from unittest import mock + +REPO_ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(REPO_ROOT)) + +from cloud.wait_ops import ( + _normalize_json_checks, + _row_dict, + build_pr_diagnostics, + create_handoff, + decode_wait_cursor, + detect_significant_change, + get_handoff, + handoff_to_dict, + interruptible_sleep, + parse_pr_checks, + poll_for_first_change, + poll_task_statuses, + report_handoff, + row_get, + significant_now, + worktree_status, +) + + +class RowHelpersTestCase(unittest.TestCase): + def test_row_get_fallbacks(self): + self.assertIsNone(row_get(None, "x")) + self.assertIsNone(row_get({}, "missing")) + self.assertEqual(row_get({"a": 1}, "a"), 1) + + def test_row_dict_none_and_tuple(self): + self.assertIsNone(_row_dict(None, ["id", "x"])) + self.assertEqual(_row_dict(("a", "b"), ["id", "x"]), + {"id": "a", "x": "b"}) + + def test_handoff_progress_non_int_zero(self): + d = handoff_to_dict({"id": "h", "progress": "abc"}) + self.assertEqual(d["progress"], 0) + + def test_handoff_progress_clamped(self): + d = handoff_to_dict({"id": "h", "progress": 150}) + self.assertEqual(d["progress"], 100) + + +class HandoffValidationTestCase(unittest.TestCase): + def test_create_target_too_long(self): + with self.assertRaises(ValueError): + create_handoff(None, "t", "p", "x" * 201, "u") + + def test_create_detail_not_string(self): + with self.assertRaises(ValueError): + create_handoff(None, "t", "p", "host", "u", detail=123) + + def test_create_detail_too_long(self): + with self.assertRaises(ValueError): + create_handoff(None, "t", "p", "host", "u", + detail="x" * 2001) + + def test_create_valid(self): + conn = sqlite3.connect(":memory:") + d = create_handoff(conn, "t1", "p1", " host-b ", "u1") + self.assertEqual(d["status"], "pending") + self.assertEqual(d["target_host"], "host-b") + conn.close() + + def test_get_missing_none(self): + conn = sqlite3.connect(":memory:") + self.assertIsNone(get_handoff(conn, "ghost")) + conn.close() + + def test_report_bad_status(self): + ok, err = report_handoff(None, "h", "bogus") + self.assertFalse(ok) + self.assertIn("status must be one of", err) + + def test_report_progress_not_int(self): + ok, err = report_handoff(None, "h", "running", progress=True) + self.assertFalse(ok) + self.assertIn("must be an integer", err) + + def test_report_detail_not_string(self): + ok, err = report_handoff(None, "h", "running", detail=123) + self.assertFalse(ok) + self.assertIn("must be a string", err) + + def test_report_detail_too_long(self): + ok, err = report_handoff(None, "h", "running", + detail="x" * 2001) + self.assertFalse(ok) + self.assertIn("too long", err) + + +class ParseChecksTestCase(unittest.TestCase): + def test_parse_header_single_part_nomatch_fallback(self): + checks, all_pass, failed, pending = parse_pr_checks( + "name\tstatus\nonly\t\nhello world\npass\nci\tpassing") + names = [(c["name"], c["state"]) for c in checks] + self.assertIn(("pass", "pass"), names) + self.assertIn(("ci", "pass"), names) + self.assertTrue(all_pass) + + def test_worktree_status_unknown(self): + s = worktree_status("paused", "/tmp/x", False, False) + self.assertEqual(s["status"], "unknown") + + +class WaitCoresTestCase(unittest.TestCase): + def test_sleep_stop_event(self): + event = threading.Event() + event.set() + slept, interrupted = interruptible_sleep(5, event) + self.assertEqual(slept, 0.0) + self.assertTrue(interrupted) + + def test_sleep_full(self): + slept, interrupted = interruptible_sleep(0.05) + self.assertAlmostEqual(slept, 0.05, places=2) + self.assertFalse(interrupted) + + def test_poll_statuses_bad_timeout(self): + all_terminal, statuses, waited, timed_out = poll_task_statuses( + lambda: {}, timeout_seconds="bad") + self.assertTrue(all_terminal) + + def test_cursor_empty(self): + with self.assertRaises(ValueError): + decode_wait_cursor("") + + def test_cursor_not_dict(self): + cur = base64.urlsafe_b64encode(b"[1]").decode() + with self.assertRaises(ValueError): + decode_wait_cursor(cur) + + def test_cursor_value_not_dict(self): + cur = base64.urlsafe_b64encode( + json.dumps({"a": 1}).encode()).decode() + with self.assertRaises(ValueError): + decode_wait_cursor(cur) + + def test_significant_dirty_numbers(self): + self.assertIsNone(significant_now( + {"status": "running", "pending_approvals": "x", + "pending_input": "y", "pending_followups": "z"})) + + def test_significant_needs_approval(self): + self.assertEqual(significant_now( + {"status": "running", "pending_approvals": 1, + "pending_input": 0, "pending_followups": 0}), + "needs_approval") + + def test_significant_all_clear(self): + self.assertIsNone(significant_now( + {"status": "running", "pending_approvals": 0, + "pending_input": 0, "pending_followups": 0})) + + def test_detect_non_dict_current(self): + self.assertIsNone(detect_significant_change( + None, {"t1": "notadict"}, ["t1"])) + + def test_detect_needs_approval(self): + base = {"t1": {"status": "running", "pending_approvals": 0, + "pending_input": 0, "pending_followups": 0}} + cur = {"t1": {"status": "running", "pending_approvals": 1, + "pending_input": 0, "pending_followups": 0}} + task_id, kind, detail = detect_significant_change( + base, cur, ["t1"]) + self.assertEqual((task_id, kind), ("t1", "needs_approval")) + + def test_detect_needs_continue(self): + base = {"t1": {"status": "running", "pending_approvals": 0, + "pending_input": 0, "pending_followups": 0}} + cur = {"t1": {"status": "running", "pending_approvals": 0, + "pending_input": 0, "pending_followups": 2}} + task_id, kind, detail = detect_significant_change( + base, cur, ["t1"]) + self.assertEqual((task_id, kind), ("t1", "needs_continue")) + + def test_poll_first_bad_timeout_immediate(self): + fetch = lambda: {"t1": {"status": "completed", + "pending_approvals": 0, + "pending_input": 0, + "pending_followups": 0}} + changed, summaries, waited, timed_out = poll_for_first_change( + fetch, ["t1"], timeout_seconds="bad") + self.assertEqual(changed["kind"], "terminal") + self.assertFalse(timed_out) + + def test_poll_first_loop_finds_change(self): + seq = [{"t1": {"status": "running", "pending_approvals": 0, + "pending_input": 0, "pending_followups": 0}}, + {"t1": {"status": "completed", "pending_approvals": 0, + "pending_input": 0, "pending_followups": 0}}] + it = iter(seq) + fetch = lambda: next(it, seq[-1]) + changed, summaries, waited, timed_out = poll_for_first_change( + fetch, ["t1"], timeout_seconds=5, poll_seconds=0.01) + self.assertEqual(changed["kind"], "terminal") + self.assertFalse(timed_out) + + +class DiagnosticsTestCase(unittest.TestCase): + def test_normalize_non_list(self): + self.assertEqual(_normalize_json_checks("x"), []) + + def test_normalize_skips_nameless(self): + checks = _normalize_json_checks( + [{"state": "pass"}, {"name": "ci", "state": "pass"}]) + self.assertEqual([c["name"] for c in checks], ["ci"]) + + def test_build_both_routes_fail(self): + def run(cmd): + return (1, "", "boom") + with self.assertRaises(RuntimeError): + build_pr_diagnostics(run, 7) + + def test_build_check_runs_fetch_failed(self): + def run(cmd): + if "--json" in cmd: + return (0, json.dumps( + [{"name": "ci", "state": "fail"}]), "") + return (0, "", "") + with mock.patch("cloud.wait_ops._fetch_failed_check_runs", + side_effect=RuntimeError("api down")): + diag = build_pr_diagnostics(run, 7, owner_repo="o/r", + head_sha="deadbeef") + self.assertFalse(diag["check_runs"]["available"]) + self.assertIn("api down", diag["check_runs"]["note"]) diff --git a/cloud/tests/test_v2cov_worker_deliver.py b/cloud/tests/test_v2cov_worker_deliver.py new file mode 100644 index 0000000..95100c2 --- /dev/null +++ b/cloud/tests/test_v2cov_worker_deliver.py @@ -0,0 +1,706 @@ +"""Coverage batch v2: cloud/worker.py _deliver_marker_tools branches. + +Baseline left 93 lines of _deliver_marker_tools uncovered: quota / +DB exception paths, automation bind/create failures, control +fallbacks, todo/task/cost/quota/files branches, poll/wait +exceptions and the peer-message delivery error. The harness mirrors +test_marker_tools.WorkerDeliverTestCase (temp SQLite via init_db, +sender PM created by tester/maintainer). + +Fault injection notes (honest about what is stubbed and why): +- Collaborator helpers (_ensure_result_columns, _todo_list_db, + _poll_task_snapshot, _wait_task_targets, _db_connect, CostStore, + _marker_ancestry, get_task, cancel, _audit) are patched to + simulate a failure the real code must report, never to fake the + deliver report itself; every assertion reads the real followup + body or the real DB row afterwards. +- The quota owner-from-parent fallback (sender without created_by) + is defensive code: the real _marker_auth returns False for a + creator-less sender, so the fallback is only reachable when auth + is stubbed True. Those two tests say so in their docstrings. +- Root bypasses file permission bits, so the dir-list and file-read + failure branches are reached with a selective patch (listdir / + open raising for exactly the target path), not chmod. +""" + +import os +import sqlite3 +import sys +import tempfile +import time +import unittest +from pathlib import Path +from types import SimpleNamespace +from unittest import mock + +REPO_ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(REPO_ROOT)) + +from cloud.server import init_db +from cloud.todos import create_todo as _create_todo +from cloud.worker import Worker + +FAKE_ENV = {"XCM_API_KEY": "test-placeholder"} + + +def _base_result(**over): + result = { + "status": "completed", + "task_creates": [], "task_polls": [], "wait_threads": [], + "task_cancels": [], "task_retries": [], "todo_lists": [], + "todo_creates": [], "todo_updates": [], "task_lists": [], + "task_costs": [], "quota_status": [], "task_files": [], + "goal_converts": [], "automation_creates": [], + } + result.update(over) + return result + + +class DeliverBase(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.db_path = os.path.join(self.tmp.name, "control.db") + init_db(self.db_path) + self._insert("PM", status="running", created_by="tester") + self._member("tester", "maintainer") + + def tearDown(self): + self.tmp.cleanup() + + def _insert(self, task_id, status="queued", created_by="tester", + project_id="p1", parent=None, **cols): + now = time.time() + conn = sqlite3.connect(self.db_path) + try: + conn.execute( + "INSERT INTO tasks(id, project_id, prompt, status," + " idempotency_key, depends_on, priority, created_by," + " created_at, updated_at, last_heartbeat)" + " VALUES(?, ?, ?, ?, NULL, '[]', 0, ?, ?, ?, NULL)", + (task_id, project_id, "do work", status, created_by, + now, now)) + if parent is not None: + conn.execute( + "UPDATE tasks SET parent_task_id = ? WHERE id = ?", + (parent, task_id)) + for key, value in cols.items(): + conn.execute( + "UPDATE tasks SET %s = ? WHERE id = ?" % key, + (value, task_id)) + conn.commit() + finally: + conn.close() + + def _member(self, user_id, role): + conn = sqlite3.connect(self.db_path) + try: + conn.execute( + "CREATE TABLE IF NOT EXISTS users(id TEXT PRIMARY KEY," + " username TEXT UNIQUE, api_key_hash TEXT," + " created_at REAL)") + conn.execute( + "CREATE TABLE IF NOT EXISTS project_members(" + "project_id TEXT, user_id TEXT, role TEXT," + " PRIMARY KEY(project_id, user_id))") + conn.execute( + "INSERT OR REPLACE INTO project_members" + "(project_id, user_id, role) VALUES('p1', ?, ?)", + (user_id, role)) + conn.execute( + "INSERT OR IGNORE INTO users(id, username, api_key_hash," + " created_at) VALUES(?, ?, '', 0)", (user_id, user_id)) + conn.commit() + finally: + conn.close() + + def _worker(self, **kwargs): + return Worker(self.db_path, "/fake/host.py", env=FAKE_ENV, + **kwargs) + + def _sender(self, worker, task_id="PM"): + return worker.get_task(task_id) + + def _followup_bodies(self, task_id="PM"): + conn = sqlite3.connect(self.db_path) + try: + rows = conn.execute( + "SELECT body FROM task_followups" + " WHERE task_id = ?", (task_id,)).fetchall() + return [row[0] for row in rows] + finally: + conn.close() + + def _status(self, task_id): + conn = sqlite3.connect(self.db_path) + try: + row = conn.execute( + "SELECT status, result_text FROM tasks WHERE id = ?", + (task_id,)).fetchone() + return row[0], row[1] + finally: + conn.close() + + def _deliver(self, worker, sender, result, task_id="PM"): + worker._deliver_marker_tools(sender, result) + return self._followup_bodies(task_id) + + +class CreatesAndAutomationTestCase(DeliverBase): + def test_creates_quota_check_exception(self): + worker = self._worker() + sender = self._sender(worker) + with mock.patch.object(worker, "_quota_exceeded", + side_effect=RuntimeError("boom")): + bodies = self._deliver(worker, sender, _base_result( + task_creates=[{"name": "E1", "prompt": "do e1", + "model": None, "effort": None, + "executor": None}])) + self.assertTrue(any("quota_check_failed" in b for b in bodies)) + + def test_creates_db_exception(self): + worker = self._worker() + sender = self._sender(worker) + with mock.patch.object(worker, "_ensure_result_columns", + side_effect=RuntimeError("db down")): + bodies = self._deliver(worker, sender, _base_result( + task_creates=[{"name": "E1", "prompt": "do e1", + "model": None, "effort": None, + "executor": None}])) + self.assertTrue(any("创建失败" in b for b in bodies)) + + def test_automation_first_run_past_skipped(self): + worker = self._worker() + bodies = self._deliver(worker, self._sender(worker), _base_result( + automation_creates=[{"name": "auto1", "prompt": "do auto", + "interval_seconds": 3600, + "first_run_at": time.time() - 3600}])) + self.assertTrue(any("首次运行时刻已过去" in b for b in bodies)) + + def test_automation_duplicate_name_second_fails(self): + worker = self._worker() + bodies = self._deliver(worker, self._sender(worker), _base_result( + automation_creates=[ + {"name": "dup", "prompt": "do auto", + "interval_seconds": 3600}, + {"name": "dup", "prompt": "do auto again", + "interval_seconds": 3600}])) + joined = "\n".join(bodies) + self.assertIn("已创建自动化", joined) + self.assertIn("自动化创建失败", joined) + self.assertIn("already exists", joined) + + def test_automation_bind_bad_mode_reports_but_counts(self): + worker = self._worker() + bodies = self._deliver(worker, self._sender(worker), _base_result( + automation_creates=[{"name": "b1", "prompt": "do auto", + "interval_seconds": 3600, + "bind": "bogus"}])) + joined = "\n".join(bodies) + self.assertIn("绑定到本会话失败", joined) + self.assertIn("定义本身仍有效", joined) + conn = sqlite3.connect(self.db_path) + try: + row = conn.execute( + "SELECT name FROM automations WHERE name = 'b1'" + ).fetchone() + finally: + conn.close() + self.assertIsNotNone(row) + + def test_automation_bind_heartbeat_success(self): + worker = self._worker() + bodies = self._deliver(worker, self._sender(worker), _base_result( + automation_creates=[{"name": "hb1", "prompt": "do auto", + "interval_seconds": 3600, + "bind": "heartbeat"}])) + self.assertTrue(any("已绑到本会话唤醒" in b for b in bodies)) + + def test_automation_quota_exception(self): + worker = self._worker() + sender = self._sender(worker) + with mock.patch.object(worker, "_quota_exceeded", + side_effect=RuntimeError("boom")): + bodies = self._deliver(worker, sender, _base_result( + automation_creates=[{"name": "q1", "prompt": "do auto", + "interval_seconds": 3600}])) + joined = "\n".join(bodies) + self.assertIn("自动化创建跳过", joined) + self.assertIn("quota_check_failed", joined) + + +class ControlFallbackTestCase(DeliverBase): + def test_cancel_target_read_failure(self): + self._insert("E1", status="running", parent="PM") + worker = self._worker() + sender = self._sender(worker) + # _was_cancelled also calls get_task, so only the target read + # itself may fail; everything else delegates to the real one. + orig_get = worker.get_task + + def _flaky_get(task_id): + if task_id == "E1": + raise RuntimeError("db down") + return orig_get(task_id) + + with mock.patch.object(worker, "get_task", + side_effect=_flaky_get): + bodies = self._deliver(worker, sender, _base_result( + task_cancels=[{"task_id": "E1"}])) + self.assertTrue(any("读取失败" in b for b in bodies)) + self.assertEqual(self._status("E1")[0], "running") + + def test_cancel_ancestry_failure_falls_back_to_other(self): + self._insert("E1", status="running", parent="PM") + worker = self._worker() + with mock.patch("cloud.worker._marker_ancestry", + side_effect=RuntimeError("db down")): + bodies = self._deliver(worker, self._sender(worker), + _base_result( + task_cancels=[{"task_id": "E1"}])) + self.assertTrue(any("拒绝" in b for b in bodies)) + self.assertEqual(self._status("E1")[0], "running") + + def test_cancel_not_done_reports(self): + self._insert("E1", status="running", parent="PM") + worker = self._worker() + with mock.patch.object(worker, "cancel", return_value=False): + bodies = self._deliver(worker, self._sender(worker), + _base_result( + task_cancels=[{"task_id": "E1"}])) + self.assertTrue(any("未执行" in b for b in bodies)) + self.assertEqual(self._status("E1")[0], "running") + + def test_cancel_execution_exception(self): + self._insert("E1", status="running", parent="PM") + worker = self._worker() + with mock.patch.object(worker, "cancel", + side_effect=RuntimeError("boom")): + bodies = self._deliver(worker, self._sender(worker), + _base_result( + task_cancels=[{"task_id": "E1"}])) + self.assertTrue(any("执行失败" in b for b in bodies)) + + def test_retry_not_done_when_rowcount_zero(self): + self._insert("E1", status="failed", parent="PM") + worker = self._worker() + real_connect = sqlite3.connect + + class _CursorProxy: + def __init__(self, real): + self._real = real + self.rowcount = 0 + + def __getattr__(self, name): + return getattr(self._real, name) + + class _ConnProxy: + def __init__(self, real): + self._real = real + + def execute(self, sql, params=()): + if "UPDATE tasks SET status = 'queued'" in sql: + return _CursorProxy(self._real.execute(sql, params)) + return self._real.execute(sql, params) + + def __getattr__(self, name): + return getattr(self._real, name) + + import cloud.worker as worker_mod + real_db_connect = worker_mod._db_connect + + def _connect(path): + return _ConnProxy(real_db_connect(path)) + + with mock.patch("cloud.worker._db_connect", side_effect=_connect): + bodies = self._deliver(worker, self._sender(worker), + _base_result( + task_retries=[{"task_id": "E1"}])) + # The proxy cursor reports rowcount 0, so deliver must report + # "not executed" -- but the real UPDATE still ran through the + # proxy's delegate, so the test asserts the report, not the + # row, for this TOCTOU simulation. + self.assertTrue(any("未执行" in b for b in bodies)) + + def test_retry_execution_exception_via_audit(self): + self._insert("E1", status="failed", parent="PM") + worker = self._worker() + sender = self._sender(worker) + orig_audit = worker._audit + calls = [0] + + def _flaky_audit(project_id, action, detail=""): + calls[0] += 1 + if calls[0] == 1: + raise RuntimeError("audit down") + return orig_audit(project_id, action, detail) + + with mock.patch.object(worker, "_audit", side_effect=_flaky_audit): + bodies = self._deliver(worker, sender, _base_result( + task_retries=[{"task_id": "E1"}])) + self.assertTrue(any("执行失败" in b for b in bodies)) + + +class TodoTestCase(DeliverBase): + def test_todo_create_write_denied(self): + self._member("tester", "viewer") + worker = self._worker() + bodies = self._deliver(worker, self._sender(worker), _base_result( + todo_creates=[{"title": "T1"}])) + self.assertTrue(any("创建待办" in b and "拒绝" in b for b in bodies)) + + def test_todo_create_cross_project_task(self): + self._insert("X1", status="running", project_id="p2") + worker = self._worker() + bodies = self._deliver(worker, self._sender(worker), _base_result( + todo_creates=[{"title": "T1", "task_id": "X1"}])) + self.assertTrue(any("属于别的项目" in b for b in bodies)) + + def test_todo_create_validation_failure(self): + worker = self._worker() + bodies = self._deliver(worker, self._sender(worker), _base_result( + todo_creates=[{"title": ""}])) + self.assertTrue(any("创建待办" in b and "失败" in b for b in bodies)) + + def test_todo_update_cross_project_task(self): + self._insert("X1", status="running", project_id="p2") + conn = sqlite3.connect(self.db_path) + try: + todo = _create_todo(conn, "p1", "tester", "T1") + finally: + conn.close() + worker = self._worker() + bodies = self._deliver(worker, self._sender(worker), _base_result( + todo_updates=[{"id": todo["id"], + "fields": {"task_id": "X1"}}])) + self.assertTrue(any("属于别的项目" in b for b in bodies)) + + def test_todo_update_failure(self): + conn = sqlite3.connect(self.db_path) + try: + todo = _create_todo(conn, "p1", "tester", "T1") + finally: + conn.close() + worker = self._worker() + bodies = self._deliver(worker, self._sender(worker), _base_result( + todo_updates=[{"id": todo["id"], + "fields": {"status": "bogus"}}])) + self.assertTrue(any("更新待办" in b and "失败" in b for b in bodies)) + + def test_todo_list_failure(self): + worker = self._worker() + with mock.patch("cloud.worker._todo_list_db", + side_effect=RuntimeError("db down")): + bodies = self._deliver(worker, self._sender(worker), + _base_result(todo_lists=[{}])) + self.assertTrue(any("查待办失败" in b for b in bodies)) + + def test_todo_list_truncation(self): + conn = sqlite3.connect(self.db_path) + try: + for i in range(21): + _create_todo(conn, "p1", "tester", "T%d" % i) + finally: + conn.close() + worker = self._worker() + bodies = self._deliver(worker, self._sender(worker), + _base_result(todo_lists=[{}])) + self.assertTrue(any("还有 1 条未列出" in b for b in bodies)) + + def test_task_list_failure(self): + worker = self._worker() + sender = self._sender(worker) + # _was_cancelled (first _db_connect use) and the final + # followup insert must still work; only the task_lists query + # itself (second use) fails. + import cloud.worker as worker_mod + real_connect = worker_mod._db_connect + calls = [0] + + def _flaky_connect(path): + calls[0] += 1 + if calls[0] == 2: + raise RuntimeError("db down") + return real_connect(path) + + with mock.patch("cloud.worker._db_connect", + side_effect=_flaky_connect): + bodies = self._deliver(worker, sender, + _base_result(task_lists=[{"limit": 10}])) + self.assertTrue(any("查任务列表失败" in b for b in bodies)) + + +class CostQuotaFilesTestCase(DeliverBase): + def test_task_cost_read_denied(self): + # Viewer still has read permission, so read denial needs a + # sender with no project membership at all. + self._insert("OUT", status="running", created_by="outsider") + self._insert("E1", status="completed", parent="PM") + worker = self._worker() + bodies = self._deliver(worker, self._sender(worker, "OUT"), + _base_result( + task_costs=[{"task_id": "E1"}]), + task_id="OUT") + self.assertTrue(any("创建人无 read 权限" in b for b in bodies)) + + def test_task_cost_relation_reject(self): + self._insert("S1", status="running", created_by="other") + worker = self._worker() + bodies = self._deliver(worker, self._sender(worker), _base_result( + task_costs=[{"task_id": "S1"}])) + self.assertTrue(any("不在派生链内且创建人不同" in b for b in bodies)) + + def test_task_cost_ancestry_failure(self): + self._insert("E1", status="running", parent="PM") + worker = self._worker() + with mock.patch("cloud.worker._marker_ancestry", + side_effect=RuntimeError("db down")): + bodies = self._deliver(worker, self._sender(worker), + _base_result( + task_costs=[{"task_id": "E1"}])) + self.assertTrue(any("不在派生链内且创建人不同" in b for b in bodies)) + + def test_task_cost_store_close_failure_swallowed(self): + self._insert("E1", status="completed", parent="PM", + result_text="done") + worker = self._worker() + + class _FakeConn: + def close(self): + raise RuntimeError("close down") + + class _FakeStore: + def __init__(self, db_path): + self._conn = _FakeConn() + + def report_by_task(self, task_id): + return {"usd_micros": 1234567, "total_tokens": 100, + "actual_tokens": 80, "estimated_tokens": 20, + "turns": 2, "unpriced_actual_tokens": 5} + + with mock.patch("cloud.worker.CostStore", _FakeStore): + bodies = self._deliver(worker, self._sender(worker), + _base_result( + task_costs=[{"task_id": "E1"}])) + joined = "\n".join(bodies) + self.assertIn("成本:$1.2346", joined) + self.assertIn("无价 actual token 5", joined) + + def test_task_cost_report_failure(self): + self._insert("E1", status="completed", parent="PM") + worker = self._worker() + + class _FailingStore: + def __init__(self, db_path): + self._conn = SimpleNamespace( + close=lambda: None) + + def report_by_task(self, task_id): + raise RuntimeError("cost down") + + with mock.patch("cloud.worker.CostStore", _FailingStore): + bodies = self._deliver(worker, self._sender(worker), + _base_result( + task_costs=[{"task_id": "E1"}])) + self.assertTrue(any("成本失败" in b for b in bodies)) + + def test_quota_owner_from_parent_via_stubbed_auth(self): + # Defensive fallback only: the real _marker_auth returns + # False for a creator-less sender, so this branch is only + # reachable when auth is stubbed True. The test pins the + # fallback logic itself (owner resolved from the parent). + self._insert("ORPH", status="running", created_by=None, + parent="PM") + worker = self._worker() + worker._marker_auth = lambda task, action: (True, "") + sender = self._sender(worker, "ORPH") + bodies = self._deliver(worker, sender, + _base_result(quota_status=[{}]), + task_id="ORPH") + # Owner resolved from the parent (tester, no explicit quota + # row) so the report shows the default quota, not "unlimited". + self.assertTrue(any("配额" in b and "总额 $10.0000" in b + for b in bodies), bodies) + + def test_quota_no_owner_via_stubbed_auth(self): + self._insert("ORPH2", status="running", created_by=None) + worker = self._worker() + worker._marker_auth = lambda task, action: (True, "") + sender = self._sender(worker, "ORPH2") + bodies = self._deliver(worker, sender, + _base_result(quota_status=[{}]), + task_id="ORPH2") + self.assertTrue(any("发送方无创建人" in b for b in bodies)) + + def test_quota_unlimited_explicit_null_row(self): + conn = sqlite3.connect(self.db_path) + try: + conn.execute( + "CREATE TABLE IF NOT EXISTS user_quotas(" + "user_id TEXT PRIMARY KEY, quota_usd_micros BIGINT," + " updated_at DOUBLE PRECISION)") + conn.execute( + "INSERT OR REPLACE INTO user_quotas" + "(user_id, quota_usd_micros, updated_at)" + " VALUES('tester', NULL, 0)") + conn.commit() + finally: + conn.close() + worker = self._worker() + bodies = self._deliver(worker, self._sender(worker), + _base_result(quota_status=[{}])) + self.assertTrue(any("不限额" in b for b in bodies)) + + def test_quota_limit_failure(self): + worker = self._worker() + with mock.patch.object(worker, "_quota_limit_and_used", + side_effect=RuntimeError("quota down")): + bodies = self._deliver(worker, self._sender(worker), + _base_result(quota_status=[{}])) + self.assertTrue(any("查配额失败" in b for b in bodies)) + + def test_task_files_target_missing(self): + worker = self._worker() + bodies = self._deliver(worker, self._sender(worker), _base_result( + task_files=[{"task_id": "NOPE", "path": ""}])) + self.assertTrue(any("不存在(或属于别的项目)" in b for b in bodies)) + + def test_task_files_ancestry_failure(self): + self._insert("E1", status="running", parent="PM") + worker = self._worker() + with mock.patch("cloud.worker._marker_ancestry", + side_effect=RuntimeError("db down")): + bodies = self._deliver(worker, self._sender(worker), + _base_result( + task_files=[{"task_id": "E1", + "path": ""}])) + self.assertTrue(any("不在派生链内且创建人不同" in b for b in bodies)) + + +class TaskFilesWorktreeTestCase(DeliverBase): + def _worktree_worker(self): + worker = self._worker(tenants_root=self.tmp.name) + worktree = os.path.join(self.tmp.name, "p1", "work", + "host-worktrees", "E1") + os.makedirs(worktree) + return worker, worktree + + def test_task_files_dir_and_file_happy(self): + worker, worktree = self._worktree_worker() + Path(worktree, "a.txt").write_text("hello-file", encoding="utf-8") + self._insert("E1", status="running", parent="PM") + bodies = self._deliver(worker, self._sender(worker), _base_result( + task_files=[{"task_id": "E1", "path": ""}, + {"task_id": "E1", "path": "a.txt"}])) + joined = "\n".join(bodies) + self.assertIn("a.txt", joined) + self.assertIn("hello-file", joined) + + def test_task_files_dir_list_failure(self): + worker, worktree = self._worktree_worker() + Path(worktree, "a.txt").write_text("x", encoding="utf-8") + self._insert("E1", status="running", parent="PM") + with mock.patch("os.listdir", side_effect=OSError("list down")): + bodies = self._deliver(worker, self._sender(worker), + _base_result( + task_files=[{"task_id": "E1", + "path": ""}])) + self.assertTrue(any("列任务" in b and "目录失败" in b + for b in bodies)) + + def test_task_files_file_read_failure(self): + worker, worktree = self._worktree_worker() + target = Path(worktree, "a.txt") + target.write_text("secret", encoding="utf-8") + self._insert("E1", status="running", parent="PM") + real_open = open + + def _selective_open(path, *args, **kwargs): + if os.fspath(path) == str(target): + raise OSError("read down") + return real_open(path, *args, **kwargs) + + with mock.patch("builtins.open", side_effect=_selective_open): + bodies = self._deliver(worker, self._sender(worker), + _base_result( + task_files=[{"task_id": "E1", + "path": "a.txt"}])) + self.assertTrue(any("读任务" in b and "失败" in b for b in bodies)) + + def test_task_files_binary_rejected(self): + worker, worktree = self._worktree_worker() + Path(worktree, "a.bin").write_bytes(b"\x00\x01\x02abc") + self._insert("E1", status="running", parent="PM") + bodies = self._deliver(worker, self._sender(worker), _base_result( + task_files=[{"task_id": "E1", "path": "a.bin"}])) + self.assertTrue(any("二进制文件" in b for b in bodies)) + + def test_task_files_invalid_utf8_rejected(self): + worker, worktree = self._worktree_worker() + Path(worktree, "bad.bin").write_bytes(b"\xff\xfe\x00abc") + self._insert("E1", status="running", parent="PM") + bodies = self._deliver(worker, self._sender(worker), _base_result( + task_files=[{"task_id": "E1", "path": "bad.bin"}])) + self.assertTrue(any("二进制文件" in b for b in bodies)) + + def test_task_files_truncation(self): + worker, worktree = self._worktree_worker() + Path(worktree, "big.txt").write_text("x" * 21000, encoding="utf-8") + self._insert("E1", status="running", parent="PM") + bodies = self._deliver(worker, self._sender(worker), _base_result( + task_files=[{"task_id": "E1", "path": "big.txt"}])) + self.assertTrue(any("已截断前 20000 字" in b for b in bodies)) + + +class PollWaitGoalPeerTestCase(DeliverBase): + def test_poll_and_wait_exceptions(self): + worker = self._worker() + with mock.patch.object(worker, "_poll_task_snapshot", + side_effect=RuntimeError("poll down")), \ + mock.patch.object(worker, "_wait_task_targets", + side_effect=RuntimeError("wait down")): + bodies = self._deliver(worker, self._sender(worker), + _base_result( + task_polls=[{"task_id": "E1", + "interval_seconds": 5, + "timeout_seconds": 0, + "chars": 2000}], + wait_threads=[{"task_ids": ["E1"], + "interval_seconds": 5, + "timeout_seconds": 0, + "chars": 2000}])) + joined = "\n".join(bodies) + self.assertIn("轮询任务 E1 失败", joined) + self.assertIn("等待任务失败", joined) + + def test_goal_converts_queued_after_report(self): + worker = self._worker() + bodies = self._deliver(worker, self._sender(worker), _base_result( + goal_converts=[{"goal": "做目标"}])) + self.assertEqual(bodies, ["/goal 做目标"]) + + def test_peer_messages_delivery_error(self): + worker = self._worker() + sender = self._sender(worker) + with mock.patch("cloud.worker._deliver_peer_message", + side_effect=RuntimeError("peer down")): + worker._deliver_peer_messages(sender, { + "status": "completed", + "task_messages": [{"to_task_id": "E1", "body": "hi", + "mode": "followup"}]}) + conn = sqlite3.connect(self.db_path) + try: + tables = [r[0] for r in conn.execute( + "SELECT name FROM sqlite_master" + " WHERE type = 'table'").fetchall()] + if "audit_log" in tables: + rows = conn.execute( + "SELECT detail FROM audit_log" + " WHERE action = 'worker_peer_messages'").fetchall() + self.assertTrue(any("rejected=1" in (r[0] or "") + for r in rows), rows) + finally: + conn.close() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_v2cov_host_scan.py b/tests/test_v2cov_host_scan.py new file mode 100644 index 0000000..648f555 --- /dev/null +++ b/tests/test_v2cov_host_scan.py @@ -0,0 +1,229 @@ +"""Coverage batch v2: host.py shell scanner + classify branches. + +Baseline left 48 lines of _scan_shell_command uncovered (NUL, +dquote escapes/backtick, bare $, brace, #-in-word, [-glob, !, +&> / &>>, pending-redirect errors on && / | / ;, >> / >| / <) +plus three classify_verify_command lines (cwd missing, cwd escape +via symlink, script path unvalidated without cwd). Each case below +asserts the real segments/features/denial reason, not just that the +scanner did not crash. +""" + +import os +import sys +import tempfile +import unittest +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(REPO_ROOT)) + +import host +from host import _scan_shell_command, classify_verify_command + + +def _scan(cmd): + return _scan_shell_command(cmd) + + +def _denied(cmd): + segments, features, denied = _scan(cmd) + return denied + + +class ScanDenialsTestCase(unittest.TestCase): + def test_nul_and_newlines(self): + self.assertEqual(_denied("echo a\x00b"), "命令含 NUL 字节") + self.assertIn("换行", _denied("echo a\nb")) + self.assertIn("换行", _denied("echo a\rb")) + + def test_unclosed_and_command_substitution(self): + self.assertEqual(_denied("echo 'abc"), "引号未闭合") + self.assertEqual(_denied('echo "abc'), "引号未闭合") + self.assertIn("$(...)", _denied("echo $(whoami)")) + self.assertIn("反引号", _denied("echo `whoami`")) + self.assertIn("反引号", _denied('echo "a`whoami`"')) + self.assertIn("${ 未闭合", _denied("echo ${FOO")) + self.assertIn("子 shell", _denied("echo (foo)")) + self.assertIn("花括号", _denied("echo {a,b}")) + self.assertIn("花括号", _denied("echo }")) + self.assertIn("注释", _denied("# echo hi")) + self.assertIn("取反", _denied("! echo hi")) + self.assertIn("后台", _denied("pytest &")) + self.assertIn("here-doc", _denied("cat << EOF")) + + def test_pending_redirect_errors(self): + self.assertEqual(_denied("pytest >"), "重定向后缺目标") + self.assertEqual(_denied("pytest > && pytest"), "重定向后缺目标") + self.assertEqual(_denied("pytest > | pytest"), "重定向后缺目标") + self.assertEqual(_denied("pytest > ; pytest"), "重定向后缺目标") + self.assertEqual(_denied("pytest > || pytest"), "重定向后缺目标") + + +class ScanWordsTestCase(unittest.TestCase): + def test_squote_is_literal(self): + segments, features, denied = _scan("echo 'a$b`c'") + self.assertIsNone(denied) + self.assertEqual(segments[0]["words"][1]["text"], "a$b`c") + self.assertFalse(segments[0]["words"][1]["expand"]) + self.assertNotIn("env", features) + + def test_dquote_escape_and_dollar(self): + segments, features, denied = _scan('echo "a\\$b"') + self.assertIsNone(denied) + self.assertEqual(segments[0]["words"][1]["text"], "a$b") + segments, features, denied = _scan('echo "$FOO"') + self.assertIsNone(denied) + self.assertIn("env", features) + self.assertTrue(segments[0]["words"][1]["expand"]) + segments, features, denied = _scan('echo "${FOO}"') + self.assertIsNone(denied) + self.assertEqual(segments[0]["words"][1]["text"], "${FOO}") + self.assertIn("env", features) + + def test_normal_backslash_escape(self): + segments, _features, denied = _scan("echo a\\ b") + self.assertIsNone(denied) + self.assertEqual([w["text"] for w in segments[0]["words"]], + ["echo", "a b"]) + + def test_bare_dollar_is_literal(self): + segments, _features, denied = _scan("echo $%") + self.assertIsNone(denied) + self.assertEqual(segments[0]["words"][1]["text"], "$%") + segments, _features, denied = _scan("echo $") + self.assertIsNone(denied) + self.assertEqual(segments[0]["words"][1]["text"], "$") + + def test_hash_inside_word_is_literal(self): + segments, _features, denied = _scan("echo foo#bar") + self.assertIsNone(denied) + self.assertEqual(segments[0]["words"][1]["text"], "foo#bar") + + def test_bracket_glob_vs_test_command(self): + segments, features, denied = _scan("echo foo[ab]") + self.assertIsNone(denied) + self.assertIn("glob", features) + self.assertTrue(segments[0]["words"][1]["glob"]) + self.assertEqual(segments[0]["words"][1]["text"], "foo[ab]") + segments, features, denied = _scan("[ -f x ]") + self.assertIsNone(denied) + self.assertNotIn("glob", features) + self.assertEqual(segments[0]["words"][0]["text"], "[") + + def test_bang_inside_word_is_literal(self): + segments, _features, denied = _scan("echo foo!bar") + self.assertIsNone(denied) + self.assertEqual(segments[0]["words"][1]["text"], "foo!bar") + + +class ScanOperatorsTestCase(unittest.TestCase): + def test_dollar_error_inside_dquote(self): + self.assertIn("$(...)", _denied('echo "$(whoami)"')) + + def test_plain_and_pipe_and_semicolon(self): + segments, features, denied = _scan("pytest && pytest") + self.assertIsNone(denied) + self.assertIn("compound", features) + self.assertEqual(len(segments), 2) + segments, features, denied = _scan("pytest | pytest") + self.assertIsNone(denied) + self.assertIn("pipe", features) + self.assertEqual(len(segments), 2) + segments, features, denied = _scan("pytest ; pytest") + self.assertIsNone(denied) + self.assertIn("compound", features) + self.assertEqual(len(segments), 2) + + def test_compound_or(self): + segments, features, denied = _scan("pytest || pytest") + self.assertIsNone(denied) + self.assertIn("compound", features) + self.assertEqual(len(segments), 2) + + def test_and_redirect_and_append_and_clobber(self): + segments, features, denied = _scan("echo hi &> out.txt") + self.assertIsNone(denied) + self.assertIn("redirect", features) + self.assertEqual(segments[0]["redirects"][0]["target"], "out.txt") + self.assertFalse(segments[0]["redirects"][0]["fd"]) + segments, features, denied = _scan("echo hi &>> out.txt") + self.assertIsNone(denied) + self.assertIn("redirect", features) + self.assertEqual(segments[0]["redirects"][0]["target"], "out.txt") + segments, features, denied = _scan("pytest >> out.txt") + self.assertIsNone(denied) + self.assertEqual(segments[0]["redirects"][0]["target"], "out.txt") + segments, features, denied = _scan("pytest >| out.txt") + self.assertIsNone(denied) + self.assertEqual(segments[0]["redirects"][0]["target"], "out.txt") + segments, features, denied = _scan("cat < in.txt") + self.assertIsNone(denied) + self.assertEqual(segments[0]["redirects"][0]["target"], "in.txt") + self.assertFalse(segments[0]["redirects"][0]["fd"]) + + def test_fd_redirect(self): + segments, features, denied = _scan("pytest 2>&1") + self.assertIsNone(denied) + self.assertIn("redirect", features) + self.assertEqual(segments[0]["redirects"][0]["target"], "1") + self.assertTrue(segments[0]["redirects"][0]["fd"]) + segments, features, denied = _scan("pytest 2> err.txt") + self.assertIsNone(denied) + self.assertEqual(segments[0]["redirects"][0]["target"], "err.txt") + self.assertFalse(segments[0]["redirects"][0]["fd"]) + + def test_tilde_and_glob_features(self): + _segments, features, denied = _scan("echo ~/x") + self.assertIsNone(denied) + self.assertIn("env", features) + _segments, features, denied = _scan("echo *.py") + self.assertIsNone(denied) + self.assertIn("glob", features) + + +class ClassifyCwdTestCase(unittest.TestCase): + def test_script_path_unvalidated_without_cwd(self): + result = classify_verify_command("./run.sh", cwd=None) + self.assertTrue(any("未校验" in note for note in result["notes"]), + result) + + def test_script_path_cwd_missing(self): + result = classify_verify_command("./run.sh", + cwd="/nonexistent-xyz-abc") + self.assertEqual(result["kind"], "denied") + self.assertIn("cwd 不存在", result["reason"]) + + def test_script_path_escapes_cwd_via_symlink(self): + with tempfile.TemporaryDirectory() as tmp: + outside = os.path.join(tmp, "outside") + os.mkdir(outside) + target = os.path.join(outside, "x.sh") + Path(target).write_text("#!/bin/sh\n") + os.chmod(target, 0o755) + work = os.path.join(tmp, "work") + os.mkdir(work) + os.symlink(outside, os.path.join(work, "link")) + result = classify_verify_command("link/x.sh", cwd=work) + self.assertEqual(result["kind"], "denied") + self.assertIn("越出 cwd", result["reason"]) + + def test_script_path_inside_cwd_executable_passes(self): + with tempfile.TemporaryDirectory() as tmp: + script = os.path.join(tmp, "run.sh") + Path(script).write_text("#!/bin/sh\n") + os.chmod(script, 0o755) + result = classify_verify_command("./run.sh", cwd=tmp) + self.assertNotEqual(result["kind"], "denied") + + def test_absolute_and_dotdot_paths_denied(self): + self.assertEqual(classify_verify_command("/bin/pytest")["kind"], + "denied") + self.assertIn("绝对路径", + classify_verify_command("/bin/pytest")["reason"]) + self.assertEqual(classify_verify_command("../run.sh")["kind"], + "denied") + + +if __name__ == "__main__": + unittest.main()