"""Skills/apps/MCP server tests: /projects//skills, /apps, /mcp-servers, /mcp-servers/reload, /mcp-servers//oauth-login. No real host.py CLI, no real codex binary: a stub script written into tmp pretends to be host.py and prints canned JSON per the frozen CLI contract - one JSON object on stdout, nonzero exit plus {"error": ...} on failure. The stub reads a scenario JSON file and appends every invocation to a log file so tests can assert the argv the server passed, including the tenant work dir used as skills/list cwd. """ import http.client import json import os import sqlite3 import sys import tempfile import threading import unittest from pathlib import Path try: from cloud.auth import AuthStore from cloud.server import create_server except ImportError: sys.path.insert(0, str(Path(__file__).resolve().parents[2])) from cloud.auth import AuthStore from cloud.server import create_server STUB = r'''import json import os import sys def main(): argv = sys.argv[1:] cmd = None rest = [] i = 0 while i < len(argv): a = argv[i] if a == "--codex-home": i += 2 continue if a.startswith("--codex-home="): i += 1 continue if cmd is None: cmd = a i += 1 continue rest.append(a) i += 1 log = os.environ.get("THREADOPS_STUB_LOG") if log: with open(log, "a", encoding="utf-8") as f: f.write(json.dumps({"cmd": cmd, "argv": rest}) + "\n") scenario = {} path = os.environ.get("THREADOPS_STUB_SCENARIO") if path and os.path.exists(path): with open(path, "r", encoding="utf-8") as f: scenario = json.load(f) if scenario.get("fail_cmd") == cmd: print(json.dumps({"error": scenario.get("fail_error", "boom")})) sys.exit(1) if cmd == "skills-list": print(json.dumps({"data": scenario.get("skills", [ {"cwd": "/work", "skills": [{"name": "demo-skill"}]}, ])})) elif cmd == "apps-list": print(json.dumps({"data": [{"id": "app-1", "name": "Demo App"}], "nextCursor": "cur-1"})) elif cmd == "mcp-servers-list": print(json.dumps({"data": [{"name": "srv-1", "runtimeStatus": "running"}]})) elif cmd == "mcp-servers-reload": print(json.dumps({})) elif cmd == "mcp-server-oauth-login": if scenario.get("oauth_no_url"): print(json.dumps({})) else: print(json.dumps({ "authorizationUrl": "https://example.invalid/oauth/authorize?x=1", "loginId": "login-123"})) else: print(json.dumps({"error": "unknown subcommand"})) sys.exit(1) main() ''' class SkillsMcpTestCase(unittest.TestCase): def setUp(self): self.tmp = tempfile.TemporaryDirectory() self.db_path = os.path.join(self.tmp.name, "control.db") self.auth = AuthStore(self.db_path) self.owner_id, self.owner_key = self.auth.create_user("owner") self.reader_id, self.reader_key = self.auth.create_user("reader") self.outsider_id, self.outsider_key = self.auth.create_user("outsider") self.stub_path = os.path.join(self.tmp.name, "stub_host.py") with open(self.stub_path, "w", encoding="utf-8") as f: f.write(STUB) self.scenario_path = os.path.join(self.tmp.name, "scenario.json") self.log_path = os.path.join(self.tmp.name, "stub.log") self._write_scenario({}) self._old_host_py = os.environ.get("CODEX_HOST_PY") os.environ["CODEX_HOST_PY"] = self.stub_path os.environ["THREADOPS_STUB_SCENARIO"] = self.scenario_path os.environ["THREADOPS_STUB_LOG"] = self.log_path self.server = create_server(self.db_path, "127.0.0.1", 0) self.server.tenants_root = os.path.join(self.tmp.name, "tenants") self.port = self.server.server_address[1] self.thread = threading.Thread( target=self.server.serve_forever, daemon=True) self.thread.start() status, self.project = self._req( "POST", "/projects", {"name": "demo"}, self.owner_key) self.assertEqual(status, 201, self.project) self.auth.set_member(self.project["id"], self.owner_id, self.reader_id, "viewer") def tearDown(self): self.server.shutdown() self.server.server_close() self.thread.join(timeout=5) try: self.auth.conn.close() except Exception: pass if self._old_host_py is None: os.environ.pop("CODEX_HOST_PY", None) else: os.environ["CODEX_HOST_PY"] = self._old_host_py os.environ.pop("THREADOPS_STUB_SCENARIO", None) os.environ.pop("THREADOPS_STUB_LOG", None) self.tmp.cleanup() def _write_scenario(self, scenario): with open(self.scenario_path, "w", encoding="utf-8") as f: json.dump(scenario, f) def _stub_calls(self, cmd=None): if not os.path.exists(self.log_path): return [] calls = [] with open(self.log_path, "r", encoding="utf-8") as f: for line in f: line = line.strip() if line: calls.append(json.loads(line)) if cmd is not None: calls = [c for c in calls if c["cmd"] == cmd] return calls def _req(self, method, path, body=None, key=None): conn = http.client.HTTPConnection( "127.0.0.1", self.port, timeout=10) hdrs = {} payload = None if body is not None: payload = json.dumps(body).encode("utf-8") hdrs["Content-Type"] = "application/json" if key is not None: hdrs["X-API-Key"] = key try: conn.request(method, path, body=payload, headers=hdrs) resp = conn.getresponse() data = resp.read() finally: conn.close() try: parsed = json.loads(data.decode("utf-8")) if data else None except ValueError: parsed = None return resp.status, parsed def _audit_actions(self, project_id): conn = sqlite3.connect(self.db_path) try: rows = conn.execute( "SELECT action, detail FROM audit_log WHERE project_id = ?", (project_id,)).fetchall() finally: conn.close() return [(r[0], r[1]) for r in rows] def _pid(self): return self.project["id"] def test_unauthorized_without_key(self): status, body = self._req( "GET", "/projects/%s/skills" % self._pid(), None, None) self.assertEqual(status, 401, body) def test_forbidden_outsider_reads(self): for suffix in ("skills", "apps", "mcp-servers"): status, body = self._req( "GET", "/projects/%s/%s" % (self._pid(), suffix), None, self.outsider_key) self.assertEqual(status, 403, body) def test_project_not_found(self): status, body = self._req( "GET", "/projects/nope/skills", None, self.owner_key) self.assertEqual(status, 404, body) def test_skills_list_passthrough_and_cwd(self): status, body = self._req( "GET", "/projects/%s/skills" % self._pid(), None, self.owner_key) self.assertEqual(status, 200, body) self.assertEqual(body["project_id"], self._pid()) self.assertEqual(body["skills"], [ {"cwd": "/work", "skills": [{"name": "demo-skill"}]}]) calls = self._stub_calls("skills-list") self.assertEqual(len(calls), 1) self.assertIn("--cwd", calls[0]["argv"]) idx = calls[0]["argv"].index("--cwd") expected = os.path.join( self.tmp.name, "tenants", self._pid(), "work") self.assertEqual(calls[0]["argv"][idx + 1], expected) def test_reader_can_read_skills(self): status, body = self._req( "GET", "/projects/%s/skills" % self._pid(), None, self.reader_key) self.assertEqual(status, 200, body) def test_apps_list_passthrough(self): status, body = self._req( "GET", "/projects/%s/apps" % self._pid(), None, self.owner_key) self.assertEqual(status, 200, body) self.assertEqual(body["apps"], [{"id": "app-1", "name": "Demo App"}]) self.assertEqual(body["nextCursor"], "cur-1") def test_mcp_servers_list_passthrough(self): status, body = self._req( "GET", "/projects/%s/mcp-servers" % self._pid(), None, self.owner_key) self.assertEqual(status, 200, body) self.assertEqual(body["servers"], [ {"name": "srv-1", "runtimeStatus": "running"}]) def test_reload_ok_and_audit(self): status, body = self._req( "POST", "/projects/%s/mcp-servers/reload" % self._pid(), {}, self.owner_key) self.assertEqual(status, 200, body) self.assertTrue(body["reloaded"]) self.assertEqual(body["result"], {}) actions = [a for a, _d in self._audit_actions(self._pid())] self.assertIn("mcp_servers_reloaded", actions) def test_reload_forbidden_for_reader(self): status, body = self._req( "POST", "/projects/%s/mcp-servers/reload" % self._pid(), {}, self.reader_key) self.assertEqual(status, 403, body) def test_reload_cli_failure_502_not_fake_success(self): self._write_scenario( {"fail_cmd": "mcp-servers-reload", "fail_error": "reload rejected"}) status, body = self._req( "POST", "/projects/%s/mcp-servers/reload" % self._pid(), {}, self.owner_key) self.assertEqual(status, 502, body) self.assertEqual(body["error"], "reload rejected") self.assertNotIn("reloaded", body) def test_oauth_login_passthrough(self): status, body = self._req( "POST", "/projects/%s/mcp-servers/srv-1/oauth-login" % self._pid(), {"scopes": ["read", "write"]}, self.owner_key) self.assertEqual(status, 200, body) self.assertEqual( body["authorizationUrl"], "https://example.invalid/oauth/authorize?x=1") self.assertEqual(body["loginId"], "login-123") self.assertEqual(body["project_id"], self._pid()) self.assertEqual(body["server_name"], "srv-1") calls = self._stub_calls("mcp-server-oauth-login") self.assertEqual(len(calls), 1) self.assertIn("--server-name", calls[0]["argv"]) idx = calls[0]["argv"].index("--server-name") self.assertEqual(calls[0]["argv"][idx + 1], "srv-1") self.assertIn("--scopes", calls[0]["argv"]) idx = calls[0]["argv"].index("--scopes") self.assertEqual(calls[0]["argv"][idx + 1], "read,write") actions = self._audit_actions(self._pid()) self.assertTrue(any( a == "mcp_server_oauth_login_started" and "srv-1" in d for a, d in actions)) def test_oauth_login_forbidden_for_reader(self): status, body = self._req( "POST", "/projects/%s/mcp-servers/srv-1/oauth-login" % self._pid(), {}, self.reader_key) self.assertEqual(status, 403, body) def test_oauth_login_validation(self): status, body = self._req( "POST", "/projects/%s/mcp-servers/srv-1/oauth-login" % self._pid(), {"scopes": "not-a-list"}, self.owner_key) self.assertEqual(status, 400, body) status, body = self._req( "POST", "/projects/%s/mcp-servers/srv-1/oauth-login" % self._pid(), {"timeoutSecs": 0}, self.owner_key) self.assertEqual(status, 400, body) def test_oauth_login_cli_failure_502(self): self._write_scenario( {"fail_cmd": "mcp-server-oauth-login", "fail_error": "oauth rejected"}) status, body = self._req( "POST", "/projects/%s/mcp-servers/srv-1/oauth-login" % self._pid(), {}, self.owner_key) self.assertEqual(status, 502, body) self.assertEqual(body["error"], "oauth rejected") def test_oauth_login_missing_url_502(self): self._write_scenario({"oauth_no_url": True}) status, body = self._req( "POST", "/projects/%s/mcp-servers/srv-1/oauth-login" % self._pid(), {}, self.owner_key) self.assertEqual(status, 502, body) self.assertNotIn("authorizationUrl", body) if __name__ == "__main__": unittest.main()