"""项目级 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, 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, 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 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()