Files
codex-app-server/cloud/tests/test_project_repo.py
T

464 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""项目级 repo_url 单测:校验、API、老库迁移、resolve、worker 派发。
只用标准库与假 runner/假执行器,不跑真实模型回合、不联网、
不用真实 git clone(clone 逻辑用 mock runner 模拟 git 的副作用:
建出目标目录 + .git;真实公开仓库 clone 列入真实环境验证清单)。
测试里出现的 URL 与密钥均为占位值,绝无真实凭证。
"""
import http.client
import json
import os
import sqlite3
import sys
import tempfile
import threading
import unittest
from pathlib import Path
from types import SimpleNamespace
try:
from cloud.auth import AuthStore
from cloud.repos import (
RepoError,
check_repo_access,
checkout_dir,
resolve_repo,
validate_repo_url,
)
from cloud.server import create_server, init_db
from cloud.worker import Worker
except ImportError: # discover -s cloud/tests 时把项目根目录补进 sys.path
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from cloud.auth import AuthStore
from cloud.repos import (
RepoError,
check_repo_access,
checkout_dir,
resolve_repo,
validate_repo_url,
)
from cloud.server import create_server, init_db
from cloud.worker import Worker
def _git_runner_factory(behavior, calls, side_effect=None):
"""behavior: dict,键为命令首段特征,值为 (returncode, stdout, stderr)。
clone 命令默认成功且模拟 git 副作用(建出目标目录 + .git);
调用方可覆写任意命令的行为。
"""
def runner(cmd):
calls.append(list(cmd))
if cmd[:2] == ["git", "clone"]:
key = "clone"
rc, out, err = behavior.get(
key, (0, "", ""))
if rc == 0 and side_effect is None:
target = cmd[-1]
os.makedirs(os.path.join(target, ".git"), exist_ok=True)
return SimpleNamespace(returncode=rc, stdout=out, stderr=err)
if cmd[:3] == ["git", "-C", cmd[2]] and "remote" in cmd:
rc, out, err = behavior.get(
"get-url", (0, behavior.get("origin", ""), ""))
return SimpleNamespace(returncode=rc, stdout=out, stderr=err)
if "fetch" in cmd:
rc, out, err = behavior.get("fetch", (0, "", ""))
return SimpleNamespace(returncode=rc, stdout=out, stderr=err)
return SimpleNamespace(returncode=0, stdout="", stderr="")
return runner
class ValidateRepoUrlTestCase(unittest.TestCase):
def test_valid_https_and_git_scp(self):
self.assertEqual(
validate_repo_url("https://github.com/openqj/codex-app-server.git"),
"https://github.com/openqj/codex-app-server.git")
self.assertEqual(
validate_repo_url("git@github.com:openqj/codex-app-server.git"),
"git@github.com:openqj/codex-app-server.git")
def test_rejects_userinfo_file_ssh_local_empty(self):
bad = [
"https://user:pass@github.com/o/r.git",
"https://user@github.com/o/r.git",
"file:///tmp/repo",
"ssh://git@github.com/o/r.git",
"ssh://git:pass@github.com/o/r.git",
"/tmp/local-repo",
"./relative",
"",
" ",
"https://",
"git@github.com",
"https://github.com/o/r.git\n",
]
for url in bad:
with self.subTest(url=url):
with self.assertRaises(ValueError):
validate_repo_url(url)
with self.assertRaises(ValueError):
validate_repo_url(None)
with self.assertRaises(ValueError):
validate_repo_url(123)
class CheckRepoAccessTestCase(unittest.TestCase):
def test_ok_returns_head(self):
def runner(cmd, timeout=None):
self.assertEqual(cmd[:3], ["git", "ls-remote",
"git@github.com:o/r.git"])
return SimpleNamespace(returncode=0,
stdout="abc123\tHEAD\n", stderr="")
ok, detail = check_repo_access("git@github.com:o/r.git",
runner=runner)
self.assertTrue(ok)
self.assertEqual(detail, "abc123")
def test_failure_returns_reason_not_exception(self):
def runner(cmd, timeout=None):
return SimpleNamespace(returncode=128, stdout="",
stderr="fatal: Authentication failed")
ok, detail = check_repo_access("git@github.com:o/private.git",
runner=runner)
self.assertFalse(ok)
self.assertIn("Authentication failed", detail)
def test_https_failure_hints_ssh_form(self):
def runner(cmd, timeout=None):
return SimpleNamespace(returncode=128, stdout="",
stderr="fatal: unable to access")
ok, detail = check_repo_access("https://github.com/o/r.git",
runner=runner)
self.assertFalse(ok)
self.assertIn("git@", detail)
def test_invalid_url_returns_false(self):
ok, detail = check_repo_access("file:///tmp/r")
self.assertFalse(ok)
self.assertTrue(detail)
def test_timeout_returns_false(self):
def runner(cmd, timeout=None):
raise TimeoutError("timed out")
ok, detail = check_repo_access("git@github.com:o/r.git",
runner=runner)
self.assertFalse(ok)
self.assertIn("timed out", detail)
def test_many_continues_after_failure(self):
seen = []
def runner(cmd, timeout=None):
seen.append(cmd[2])
if "good" in cmd[2]:
return SimpleNamespace(returncode=0,
stdout="abc123\tHEAD\n", stderr="")
return SimpleNamespace(returncode=128, stdout="",
stderr="fatal: denied")
from cloud.repos import check_repo_access_many
out = check_repo_access_many(
["git@github.com:o/good.git", "git@github.com:o/bad.git"],
runner=runner)
self.assertEqual(seen, ["git@github.com:o/good.git",
"git@github.com:o/bad.git"])
self.assertTrue(out["git@github.com:o/good.git"][0])
self.assertFalse(out["git@github.com:o/bad.git"][0])
self.assertEqual(check_repo_access_many([]), {})
class CheckoutDirTestCase(unittest.TestCase):
def test_sanitizes_and_stays_inside(self):
tmp = tempfile.TemporaryDirectory()
base = os.path.join(tmp.name, "repos")
path = checkout_dir(base, "abc123")
self.assertEqual(path, os.path.join(os.path.abspath(base), "abc123"))
# 穿越尝试被过滤,仍在 repos_dir 之下。
path2 = checkout_dir(base, "../../etc")
self.assertTrue(os.path.commonpath(
[os.path.abspath(base), path2]) == os.path.abspath(base))
tmp.cleanup()
class ResolveRepoTestCase(unittest.TestCase):
def test_clone_when_missing(self):
tmp = tempfile.TemporaryDirectory()
calls = []
runner = _git_runner_factory({}, calls)
url = "https://github.com/openqj/codex-app-server.git"
path = resolve_repo(url, os.path.join(tmp.name, "repos"),
"proj1", runner=runner)
self.assertTrue(os.path.isdir(os.path.join(path, ".git")))
self.assertEqual(calls[0][:3], ["git", "clone", url])
tmp.cleanup()
def test_clone_failure_mentions_no_stored_credentials(self):
tmp = tempfile.TemporaryDirectory()
calls = []
runner = _git_runner_factory(
{"clone": (128, "", "fatal: Authentication failed")}, calls)
with self.assertRaises(RepoError) as ctx:
resolve_repo("https://github.com/o/private.git",
os.path.join(tmp.name, "repos"),
"proj1", runner=runner)
self.assertIn("控制面不存仓库凭证", str(ctx.exception))
tmp.cleanup()
def test_existing_matching_origin_fetches(self):
tmp = tempfile.TemporaryDirectory()
repos = os.path.join(tmp.name, "repos")
target = checkout_dir(repos, "proj1")
os.makedirs(os.path.join(target, ".git"))
url = "https://github.com/openqj/codex-app-server.git"
calls = []
runner = _git_runner_factory({"origin": url}, calls)
path = resolve_repo(url, repos, "proj1", runner=runner)
self.assertEqual(path, target)
self.assertTrue(any("fetch" in c for c in calls))
tmp.cleanup()
def test_existing_mismatched_origin_refuses(self):
tmp = tempfile.TemporaryDirectory()
repos = os.path.join(tmp.name, "repos")
target = checkout_dir(repos, "proj1")
os.makedirs(os.path.join(target, ".git"))
calls = []
runner = _git_runner_factory(
{"origin": "https://github.com/other/repo.git"}, calls)
with self.assertRaises(RepoError) as ctx:
resolve_repo("https://github.com/openqj/codex-app-server.git",
repos, "proj1", runner=runner)
self.assertIn("不一致", str(ctx.exception))
tmp.cleanup()
def test_existing_not_git_refuses(self):
tmp = tempfile.TemporaryDirectory()
repos = os.path.join(tmp.name, "repos")
os.makedirs(checkout_dir(repos, "proj1"))
calls = []
runner = _git_runner_factory({}, calls)
with self.assertRaises(RepoError):
resolve_repo("https://github.com/o/r.git",
repos, "proj1", runner=runner)
self.assertEqual(calls, [])
tmp.cleanup()
def test_fetch_failure_refuses(self):
tmp = tempfile.TemporaryDirectory()
repos = os.path.join(tmp.name, "repos")
target = checkout_dir(repos, "proj1")
os.makedirs(os.path.join(target, ".git"))
url = "https://github.com/o/r.git"
calls = []
runner = _git_runner_factory(
{"origin": url, "fetch": (128, "", "fatal: unable to access")},
calls)
with self.assertRaises(RepoError) as ctx:
resolve_repo(url, repos, "proj1", runner=runner)
self.assertIn("fetch", str(ctx.exception))
tmp.cleanup()
class _FakeExecutor:
def __init__(self):
self.seen = []
def _host_name(self, task):
return task["id"]
def dispatch_task(self, task):
self.seen.append(dict(task))
return {"executor": "local", "task_id": task["id"],
"status": "completed", "stdout": "ok", "returncode": 0,
"host_status": None}
def _make_worker_db(tmp, project_repo_url="__unset__"):
"""建库 + 项目行 + queued 任务行,返回 (db_path, project_id)。"""
db_path = os.path.join(tmp, "control.db")
init_db(db_path)
project_id = "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6"
conn = sqlite3.connect(db_path)
try:
if project_repo_url == "__unset__":
conn.execute(
"INSERT INTO projects(id, name, owner_id, created_at)"
" VALUES(?, 'p', 'owner', 0)", (project_id,))
else:
conn.execute(
"INSERT INTO projects(id, name, owner_id, created_at,"
" repo_url) VALUES(?, 'p', 'owner', 0, ?)",
(project_id, project_repo_url))
conn.execute(
"INSERT INTO tasks(id, project_id, prompt, status,"
" depends_on, priority, created_by, created_at, updated_at,"
" last_heartbeat) VALUES('t1', ?, 'do it', 'queued',"
" '[]', 0, 'system', 0, 0, NULL)", (project_id,))
conn.commit()
finally:
conn.close()
return db_path, project_id
class WorkerProjectRepoTestCase(unittest.TestCase):
def test_dispatch_uses_project_repo(self):
tmp = tempfile.TemporaryDirectory()
url = "https://github.com/openqj/codex-app-server.git"
db_path, _ = _make_worker_db(tmp.name, url)
repos = os.path.join(tmp.name, "repos")
calls = []
executor = _FakeExecutor()
worker = Worker(db_path, "host.py", repo=None,
env={"XCM_API_KEY": "test-placeholder"},
env_key=None, executor=executor,
repos_dir=repos,
repos_runner=_git_runner_factory({}, calls),
retry_backoff_seconds=0)
result = worker.run_one()
self.assertEqual(result["status"], "completed")
self.assertEqual(len(executor.seen), 1)
self.assertEqual(executor.seen[0].get("repo"),
checkout_dir(repos,
"a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6"))
conn = sqlite3.connect(db_path)
try:
status = conn.execute(
"SELECT status FROM tasks WHERE id='t1'").fetchone()[0]
finally:
conn.close()
self.assertEqual(status, "completed")
tmp.cleanup()
def test_no_repo_url_uses_default(self):
tmp = tempfile.TemporaryDirectory()
db_path, _ = _make_worker_db(tmp.name, "__unset__")
executor = _FakeExecutor()
worker = Worker(db_path, "host.py", repo="/default/repo",
env={"XCM_API_KEY": "test-placeholder"},
env_key=None, executor=executor,
repos_dir=os.path.join(tmp.name, "repos"),
retry_backoff_seconds=0)
result = worker.run_one()
self.assertEqual(result["status"], "completed")
# task 本身无 repo 字段:执行器走自身默认 repo,不被改写。
self.assertNotIn("repo", executor.seen[0])
tmp.cleanup()
def test_repo_resolve_failure_is_failed_not_completed(self):
tmp = tempfile.TemporaryDirectory()
url = "https://github.com/o/private.git"
db_path, _ = _make_worker_db(tmp.name, url)
executor = _FakeExecutor()
worker = Worker(db_path, "host.py", repo=None,
env={"XCM_API_KEY": "test-placeholder"},
env_key=None, executor=executor,
repos_dir=os.path.join(tmp.name, "repos"),
repos_runner=_git_runner_factory(
{"clone": (128, "",
"fatal: Authentication failed")},
[]),
retry_backoff_seconds=0)
result = worker.run_one()
self.assertEqual(result["status"], "failed")
self.assertIn("仓库解析失败", result["error"])
self.assertEqual(executor.seen, [])
conn = sqlite3.connect(db_path)
try:
status, error = conn.execute(
"SELECT status, error_text FROM tasks WHERE id='t1'"
).fetchone()
finally:
conn.close()
self.assertEqual(status, "failed")
self.assertIn("仓库解析失败", error)
tmp.cleanup()
class ProjectRepoApiTestCase(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.db_path = os.path.join(self.tmp.name, "control.db")
# 老库:projects 表不含 repo_url 列,init_db 必须幂等补列。
conn = sqlite3.connect(self.db_path)
conn.execute(
"CREATE TABLE projects(id TEXT PRIMARY KEY, name TEXT,"
" owner_id TEXT, created_at REAL)")
conn.execute(
"CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT,"
" prompt TEXT, status TEXT, idempotency_key TEXT,"
" depends_on TEXT, priority INTEGER, created_by TEXT,"
" created_at REAL, updated_at REAL, last_heartbeat REAL,"
" UNIQUE(project_id, idempotency_key))")
conn.commit()
conn.close()
auth = AuthStore(self.db_path)
self.owner_id, self.owner_key = auth.create_user("owner")
auth.conn.close()
self.server = create_server(self.db_path, "127.0.0.1", 0)
self.port = self.server.server_address[1]
self.thread = threading.Thread(
target=self.server.serve_forever, daemon=True)
self.thread.start()
def tearDown(self):
self.server.shutdown()
self.server.server_close()
self.thread.join(timeout=5)
self.tmp.cleanup()
def _req(self, method, path, body=None, key=None):
conn = http.client.HTTPConnection("127.0.0.1", self.port, timeout=10)
payload = json.dumps(body).encode("utf-8") if body is not None else None
headers = {"Content-Type": "application/json"}
if key is not None:
headers["X-API-Key"] = key
try:
conn.request(method, path, body=payload, headers=headers)
resp = conn.getresponse()
return resp.status, json.loads(resp.read() or b"{}")
finally:
conn.close()
def test_old_db_migrated_and_create_with_repo_url(self):
conn = sqlite3.connect(self.db_path)
cols = {r[1] for r in conn.execute(
"PRAGMA table_info(projects)").fetchall()}
conn.close()
self.assertIn("repo_url", cols)
status, project = self._req(
"POST", "/projects",
{"name": "p1",
"repo_url": "https://github.com/openqj/codex-app-server.git"},
self.owner_key)
self.assertEqual(status, 201, project)
self.assertEqual(
project["repo_url"],
"https://github.com/openqj/codex-app-server.git")
status, listed = self._req("GET", "/projects", key=self.owner_key)
self.assertEqual(status, 200)
self.assertEqual(len(listed["projects"]), 1)
self.assertEqual(
listed["projects"][0]["repo_url"],
"https://github.com/openqj/codex-app-server.git")
def test_create_without_repo_url_is_none(self):
status, project = self._req(
"POST", "/projects", {"name": "p2"}, self.owner_key)
self.assertEqual(status, 201, project)
self.assertIsNone(project["repo_url"])
def test_create_with_bad_repo_url_is_400_and_not_created(self):
for bad in ("https://user:pass@github.com/o/r.git",
"file:///tmp/r", "ssh://git@github.com/o/r.git",
"/tmp/local"):
status, body = self._req(
"POST", "/projects",
{"name": "bad", "repo_url": bad}, self.owner_key)
self.assertEqual(status, 400, (bad, body))
status, listed = self._req("GET", "/projects", key=self.owner_key)
self.assertEqual(listed["projects"], [])
if __name__ == "__main__":
unittest.main()