Files
codex-app-server/cloud/tests/test_server_oauth_push.py
host 7091a2d0d2 P0 security batch: true global admin, quota gates on all spending paths, default quota for quota-less users, fix-review author trust + docker/owner attribution, GitHub App setup issuer binding, real cancel kill, wider shell env exclusion
- CODEX_GLOBAL_ADMINS replaces maintainer-derived global admin;
  admin endpoints (quotas, sweep, backup, push dispatch) fail-closed
  403 when unset, with audit on allow and deny.
- continue/followups/messages/retry check both caller and original
  creator quotas (USD + unpriced token cap), 402 on either overrun.
- Quota-less users now get the #86 default quota instead of unlimited;
  init_db backfills and OAuth create/bind/existing-login branches
  INSERT OR IGNORE a default row; explicit NULL row stays unlimited.
  user_quotas added to migration tables.
- /fix-review: untrusted authors rejected before dedup claim, no task;
  trusted tasks force executor=docker, created_by=project owner, owner
  quota gate, prompt is a fixed template plus a 2000-char excerpt.
- GitHub App setup state only issued by global admins, state rows bind
  issued_by (ALTER-compatible for old DBs), callback rejects issuers
  no longer in the admin list without consuming the state.
- Cancel: popen poll checks DB status every second, Docker path runs a
  cancel watchdog that kills by container name, write-back is CAS-guarded
  so late results never overwrite a new status.
- SHELL_ENV_EXCLUDE adds *_TOKEN, *_AUTH*, *_CREDENTIAL*, *API_KEY*;
  comment states the residual risk: same-UID /proc environ remains
  readable, true isolation needs UID separation or hidepid.
- Tests: new cloud/tests/test_p0_security.py (17 cases); old tests
  updated to the new semantics (admin env, default quota, trusted
  fix-review author) without weakening any gate.
2026-10-27 15:35:23 +00:00

186 lines
7.2 KiB
Python

"""OAuth 与推送的 HTTP 端点测试。
不联网、不向真实 GitHub/Google/推送服务发请求;OAuth 回调成功
路径由模块级单测覆盖,这里只测 HTTP 层的失败路径与订阅分发。
"""
import http.client
import json
import os
import sys
import tempfile
import threading
import unittest
from pathlib import Path
try:
from cloud.auth import AuthStore
from cloud.oauth import OAuthStateStore
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.oauth import OAuthStateStore
from cloud.server import create_server
class OAuthPushEndpointsTestCase(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._old_admins = os.environ.get("CODEX_GLOBAL_ADMINS")
os.environ["CODEX_GLOBAL_ADMINS"] = self.owner_id
self.outsider_id, self.outsider_key = self.auth.create_user("outsider")
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()
status, self.project = self._req(
"POST", "/projects", {"name": "demo"}, self.owner_key)
self.assertEqual(status, 201)
def tearDown(self):
self.server.shutdown()
self.server.server_close()
self.thread.join(timeout=5)
try:
self.auth.conn.close()
except Exception:
pass
self.tmp.cleanup()
if getattr(self, "_old_admins", None) is None:
os.environ.pop("CODEX_GLOBAL_ADMINS", None)
else:
os.environ["CODEX_GLOBAL_ADMINS"] = self._old_admins
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
# -- OAuth -----------------------------------------------------------
def test_oauth_login_returns_url_without_secret(self):
status, body = self._req(
"GET",
"/oauth/github/login?client_id=test-client"
"&redirect_uri=http%3A%2F%2F127.0.0.1%2Fcb")
self.assertEqual(status, 200)
self.assertIn("github.com/login/oauth/authorize",
body["authorization_url"])
self.assertIn("test-client", body["authorization_url"])
self.assertNotIn("secret", body["authorization_url"])
self.assertTrue(body["state"])
def test_oauth_login_unknown_provider_400(self):
status, _ = self._req(
"GET",
"/oauth/nope/login?client_id=x"
"&redirect_uri=http%3A%2F%2F127.0.0.1%2Fcb")
self.assertEqual(status, 400)
def test_oauth_callback_bad_state_400_no_session(self):
status, body = self._req(
"POST", "/oauth/github/callback",
{"code": "fake-code", "state": "no-such-state",
"client_id": "test-client",
"redirect_uri": "http://127.0.0.1/cb"})
self.assertEqual(status, 400)
self.assertIn("state", body["error"])
def test_oauth_callback_good_state_no_secret_400(self):
store = OAuthStateStore(self.db_path)
try:
state = store.create_state(
"github", "http://127.0.0.1/cb")
finally:
store.conn.close()
# 确保环境里没有真实 secret(本测试不依赖它)。
old = os.environ.pop("GITHUB_OAUTH_CLIENT_SECRET", None)
try:
status, body = self._req(
"POST", "/oauth/github/callback",
{"code": "fake-code", "state": state,
"client_id": "test-client",
"redirect_uri": "http://127.0.0.1/cb"})
finally:
if old is not None:
os.environ["GITHUB_OAUTH_CLIENT_SECRET"] = old
self.assertEqual(status, 400)
self.assertIn("client_secret", body["error"])
# -- 推送 -------------------------------------------------------------
def test_push_subscribe_list_dispatch_and_delete(self):
pid = self.project["id"]
status, sub = self._req(
"POST", "/projects/%s/push/subscriptions" % pid,
{"channel": "webhook", "target": "http://127.0.0.1:1/hook",
"preference": "instant"},
self.owner_key)
self.assertEqual(status, 201, sub)
status, body = self._req(
"GET", "/projects/%s/push/subscriptions" % pid,
key=self.owner_key)
self.assertEqual(status, 200)
self.assertEqual(len(body["subscriptions"]), 1)
# 内置真实发送端会真 POST:目标不可达时分发应报投递失败
# (不是“无 sender”,也不是静默成功),sent 为空。
status, body = self._req(
"POST", "/admin/push/dispatch",
{"event_type": "failed", "project_id": pid,
"payload": {"task_id": "t1"}},
self.owner_key)
self.assertEqual(status, 200)
self.assertEqual(body["sent"], [])
self.assertEqual(len(body["errors"]), 1)
self.assertIn("delivery failed", body["errors"][0]["reason"])
# outsider 不能往别人项目里订阅/分发。
status, _ = self._req(
"POST", "/projects/%s/push/subscriptions" % pid,
{"channel": "webhook", "target": "https://example.invalid/x"},
self.outsider_key)
self.assertEqual(status, 403)
# 删除:DELETE 方法须可用,且删后列表为空。
conn = http.client.HTTPConnection("127.0.0.1", self.port, timeout=10)
try:
conn.request("DELETE",
"/projects/%s/push/subscriptions/%s" % (pid, sub["id"]),
headers={"X-API-Key": self.owner_key})
resp = conn.getresponse()
resp.read()
self.assertEqual(resp.status, 200)
finally:
conn.close()
status, body = self._req(
"GET", "/projects/%s/push/subscriptions" % pid,
key=self.owner_key)
self.assertEqual(body["subscriptions"], [])
def test_push_dispatch_requires_auth(self):
status, _ = self._req(
"POST", "/admin/push/dispatch",
{"event_type": "completed", "project_id": self.project["id"]})
self.assertEqual(status, 401)
if __name__ == "__main__":
unittest.main()