916 lines
34 KiB
Python
916 lines
34 KiB
Python
"""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.handler_automation._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()
|