forked from luopingyi/xc_agent_platform_demo
Phase1 skeleton: replace demo main.py with src.server contract
- Overlay ModelHub XC Phase1 (health/status, STRATEGY_ID gate, dry-run) - Dockerfile CMD -> python -m src.server; FROM stays platform TCR base - Remove demo main.py and requirements.txt (stdlib only) - Add tests/, scripts/, docs/, Makefile, config/examples (no secrets)
This commit is contained in:
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
138
tests/test_gate.py
Normal file
138
tests/test_gate.py
Normal file
@@ -0,0 +1,138 @@
|
||||
"""Gate defaults to dry-run / blocked; live only with dual confirm + STRATEGY_ID."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
from src.gate import LIVE_CONFIRM_VALUE, attempt_live_submit, evaluate_gate
|
||||
|
||||
|
||||
class FakeClient:
|
||||
def __init__(self) -> None:
|
||||
self.calls = []
|
||||
|
||||
def submit(self, payload):
|
||||
self.calls.append(payload)
|
||||
return {"task_id": "fake-1"}
|
||||
|
||||
|
||||
class TestGate(unittest.TestCase):
|
||||
def _clear_gate_env(self):
|
||||
return mock.patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"STRATEGY_ID": "sid-1",
|
||||
"ALLOW_LIVE_SUBMIT": "0",
|
||||
"LIVE_CONFIRM": "",
|
||||
},
|
||||
clear=False,
|
||||
)
|
||||
|
||||
def test_default_dry_run(self) -> None:
|
||||
with self._clear_gate_env():
|
||||
d = evaluate_gate()
|
||||
self.assertFalse(d.allowed)
|
||||
self.assertEqual(d.mode, "dry-run")
|
||||
|
||||
def test_missing_strategy_blocked(self) -> None:
|
||||
env = {
|
||||
"ALLOW_LIVE_SUBMIT": "1",
|
||||
"LIVE_CONFIRM": LIVE_CONFIRM_VALUE,
|
||||
}
|
||||
# Remove STRATEGY_ID
|
||||
base = {k: v for k, v in os.environ.items() if k != "STRATEGY_ID"}
|
||||
base.update(env)
|
||||
with mock.patch.dict(os.environ, base, clear=True):
|
||||
d = evaluate_gate()
|
||||
self.assertFalse(d.allowed)
|
||||
self.assertEqual(d.mode, "blocked")
|
||||
|
||||
def test_allow_without_confirm_blocked(self) -> None:
|
||||
with mock.patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"STRATEGY_ID": "sid-1",
|
||||
"ALLOW_LIVE_SUBMIT": "1",
|
||||
"LIVE_CONFIRM": "YES",
|
||||
},
|
||||
clear=False,
|
||||
):
|
||||
d = evaluate_gate()
|
||||
self.assertFalse(d.allowed)
|
||||
self.assertEqual(d.mode, "blocked")
|
||||
|
||||
def test_dual_gate_opens(self) -> None:
|
||||
with mock.patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"STRATEGY_ID": "sid-1",
|
||||
"ALLOW_LIVE_SUBMIT": "1",
|
||||
"LIVE_CONFIRM": LIVE_CONFIRM_VALUE,
|
||||
},
|
||||
clear=False,
|
||||
):
|
||||
d = evaluate_gate()
|
||||
self.assertTrue(d.allowed)
|
||||
self.assertEqual(d.mode, "live")
|
||||
|
||||
def test_open_gate_without_client_still_blocked(self) -> None:
|
||||
"""Phase1: no real HTTP client even when gate is open."""
|
||||
with mock.patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"STRATEGY_ID": "sid-1",
|
||||
"ALLOW_LIVE_SUBMIT": "1",
|
||||
"LIVE_CONFIRM": LIVE_CONFIRM_VALUE,
|
||||
},
|
||||
clear=False,
|
||||
):
|
||||
result = attempt_live_submit({"modelAddress": "x/y"})
|
||||
self.assertFalse(result["submitted"])
|
||||
self.assertIn("no real", result["reason"].lower())
|
||||
self.assertEqual(result["mode"], "blocked")
|
||||
self.assertFalse(result["ok"])
|
||||
|
||||
def test_prd_example_and_whitespace_id_never_submit(self) -> None:
|
||||
cases = [
|
||||
{"ALLOW_LIVE_SUBMIT": "true", "LIVE_CONFIRM": LIVE_CONFIRM_VALUE},
|
||||
{"ALLOW_LIVE_SUBMIT": "1", "LIVE_CONFIRM": "", "CONFIRM_LIVE": "YES"},
|
||||
{"ALLOW_LIVE_SUBMIT": "1", "LIVE_CONFIRM": LIVE_CONFIRM_VALUE, "STRATEGY_ID": " "},
|
||||
]
|
||||
for overrides in cases:
|
||||
with self.subTest(overrides=overrides):
|
||||
client = FakeClient()
|
||||
env = {"STRATEGY_ID": "sid-test", **overrides}
|
||||
with mock.patch.dict(os.environ, env, clear=True):
|
||||
result = attempt_live_submit({}, client=client)
|
||||
self.assertFalse(result["submitted"])
|
||||
self.assertFalse(result["ok"])
|
||||
self.assertEqual(client.calls, [])
|
||||
self.assertTrue(result["reason"])
|
||||
|
||||
def test_injected_fake_client(self) -> None:
|
||||
client = FakeClient()
|
||||
with mock.patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"STRATEGY_ID": "sid-1",
|
||||
"ALLOW_LIVE_SUBMIT": "1",
|
||||
"LIVE_CONFIRM": LIVE_CONFIRM_VALUE,
|
||||
},
|
||||
clear=False,
|
||||
):
|
||||
result = attempt_live_submit({"modelAddress": "x/y"}, client=client)
|
||||
self.assertTrue(result["submitted"])
|
||||
self.assertEqual(len(client.calls), 1)
|
||||
|
||||
def test_default_attempt_rejects(self) -> None:
|
||||
client = FakeClient()
|
||||
with self._clear_gate_env():
|
||||
result = attempt_live_submit({"modelAddress": "x/y"}, client=client)
|
||||
self.assertFalse(result["submitted"])
|
||||
self.assertEqual(len(client.calls), 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
60
tests/test_health.py
Normal file
60
tests/test_health.py
Normal file
@@ -0,0 +1,60 @@
|
||||
"""Tests for /health and /status handlers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
from src.health import handle_get, health_payload, status_payload
|
||||
|
||||
|
||||
class TestHealth(unittest.TestCase):
|
||||
def test_health_payload(self) -> None:
|
||||
self.assertEqual(health_payload(), {"status": "ok"})
|
||||
|
||||
def test_handle_get_health(self) -> None:
|
||||
code, headers, body = handle_get("/health")
|
||||
self.assertEqual(code, 200)
|
||||
self.assertIn("application/json", headers["Content-Type"])
|
||||
data = json.loads(body.decode("utf-8"))
|
||||
self.assertEqual(data, {"status": "ok"})
|
||||
|
||||
def test_handle_get_unknown(self) -> None:
|
||||
code, _, body = handle_get("/nope")
|
||||
self.assertEqual(code, 404)
|
||||
self.assertIn("not_found", body.decode("utf-8"))
|
||||
|
||||
def test_status_booleans_only_no_secret_values(self) -> None:
|
||||
with mock.patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"STRATEGY_ID": "secret-strategy-id-xyz",
|
||||
"EXTERNAL_SERVICE_TOKEN": "secret-token-abc",
|
||||
},
|
||||
clear=False,
|
||||
):
|
||||
payload = status_payload()
|
||||
self.assertTrue(payload["strategy_id_present"])
|
||||
self.assertTrue(payload["external_service_token_present"])
|
||||
blob = json.dumps(payload)
|
||||
self.assertNotIn("secret-strategy-id-xyz", blob)
|
||||
self.assertNotIn("secret-token-abc", blob)
|
||||
|
||||
def test_status_absent(self) -> None:
|
||||
env = {k: v for k, v in os.environ.items() if k not in (
|
||||
"STRATEGY_ID",
|
||||
"EXTERNAL_SERVICE_TOKEN",
|
||||
"XC_TOKEN",
|
||||
"XC_TOKEN_FILE",
|
||||
"MODELHUB_XC_TOKEN",
|
||||
)}
|
||||
with mock.patch.dict(os.environ, env, clear=True):
|
||||
payload = status_payload()
|
||||
self.assertFalse(payload["strategy_id_present"])
|
||||
self.assertFalse(payload["external_service_token_present"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
32
tests/test_report_safety.py
Normal file
32
tests/test_report_safety.py
Normal file
@@ -0,0 +1,32 @@
|
||||
"""Local report and HTTP log regression checks; no network requests."""
|
||||
import contextlib
|
||||
import io
|
||||
import os
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
from scripts.dry_run import _render
|
||||
from src.server import AgentHandler
|
||||
|
||||
|
||||
class TestReportSafety(unittest.TestCase):
|
||||
def test_report_marks_pending_and_does_not_echo_gate_values(self):
|
||||
sentinel = "synthetic-sensitive-value"
|
||||
with mock.patch.dict(os.environ, {
|
||||
"ALLOW_LIVE_SUBMIT": sentinel,
|
||||
"LIVE_CONFIRM": sentinel,
|
||||
"STRATEGY_ID": sentinel,
|
||||
"EXTERNAL_SERVICE_TOKEN": sentinel,
|
||||
}, clear=True):
|
||||
report = _render([], "test-time", Path("example.json"))
|
||||
self.assertNotIn(sentinel, report)
|
||||
for required in ("待对接 pending-submit", "A→B", "需 Victor 人工提交", "积分:待平台核实", "≥95"):
|
||||
self.assertIn(required, report)
|
||||
|
||||
def test_http_log_omits_request_details(self):
|
||||
output = io.StringIO()
|
||||
with contextlib.redirect_stderr(output):
|
||||
AgentHandler.log_message(None, '"%s" %s', '/health?token=synthetic-sensitive-value', 200)
|
||||
self.assertNotIn("synthetic-sensitive-value", output.getvalue())
|
||||
self.assertIn("request handled", output.getvalue())
|
||||
69
tests/test_sigterm.py
Normal file
69
tests/test_sigterm.py
Normal file
@@ -0,0 +1,69 @@
|
||||
"""SIGTERM should stop the server subprocess promptly (<< 30s platform window)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import unittest
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
class TestSigterm(unittest.TestCase):
|
||||
def test_sigterm_exits_quickly(self) -> None:
|
||||
port = "19090"
|
||||
env = os.environ.copy()
|
||||
env["STRATEGY_ID"] = "sigterm-test"
|
||||
env["PORT"] = port
|
||||
env["HOST"] = "127.0.0.1"
|
||||
|
||||
proc = subprocess.Popen(
|
||||
[sys.executable, "-m", "src.server"],
|
||||
cwd=str(ROOT),
|
||||
env=env,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
try:
|
||||
# wait until health is up
|
||||
url = f"http://127.0.0.1:{port}/health"
|
||||
deadline = time.time() + 5
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout=0.5) as resp:
|
||||
data = json.loads(resp.read().decode("utf-8"))
|
||||
if resp.status == 200 and data.get("status") == "ok":
|
||||
break
|
||||
except Exception:
|
||||
time.sleep(0.1)
|
||||
else:
|
||||
if proc.poll() is not None:
|
||||
self.fail(f"server exited before health check: {proc.stderr.read().decode(errors='replace')}")
|
||||
self.fail("server did not become healthy in time")
|
||||
|
||||
t0 = time.time()
|
||||
proc.send_signal(signal.SIGTERM)
|
||||
try:
|
||||
proc.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
self.fail("server did not exit within 5s after SIGTERM (platform allows ≤30s)")
|
||||
elapsed = time.time() - t0
|
||||
self.assertLess(elapsed, 5.0, f"exit took {elapsed:.2f}s")
|
||||
self.assertEqual(proc.returncode, 0, "SIGTERM must exit cleanly, not kill the process")
|
||||
finally:
|
||||
if proc.poll() is None:
|
||||
proc.kill()
|
||||
proc.wait(timeout=2)
|
||||
if proc.stderr:
|
||||
proc.stderr.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
57
tests/test_strategy_startup.py
Normal file
57
tests/test_strategy_startup.py
Normal file
@@ -0,0 +1,57 @@
|
||||
"""Missing STRATEGY_ID must cause startup failure (exit non-zero)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
class TestStrategyStartup(unittest.TestCase):
|
||||
def test_missing_strategy_id_exits_nonzero(self) -> None:
|
||||
env = os.environ.copy()
|
||||
env["STRATEGY_ID"] = ""
|
||||
env["PORT"] = "19081"
|
||||
env["HOST"] = "127.0.0.1"
|
||||
proc = subprocess.run(
|
||||
[sys.executable, "-m", "src.server"],
|
||||
cwd=str(ROOT),
|
||||
env=env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
self.assertNotEqual(proc.returncode, 0)
|
||||
self.assertIn("STRATEGY_ID", proc.stderr)
|
||||
|
||||
def test_whitespace_strategy_id_exits_nonzero(self) -> None:
|
||||
env = os.environ.copy()
|
||||
env["STRATEGY_ID"] = " "
|
||||
env["PORT"] = "19082"
|
||||
env["HOST"] = "127.0.0.1"
|
||||
proc = subprocess.run(
|
||||
[sys.executable, "-m", "src.server"],
|
||||
cwd=str(ROOT),
|
||||
env=env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
self.assertNotEqual(proc.returncode, 0)
|
||||
|
||||
def test_require_strategy_id_helper(self) -> None:
|
||||
from src.strategy import require_strategy_id
|
||||
|
||||
with self.assertRaises(SystemExit) as ctx:
|
||||
with mock.patch.dict(os.environ, {"STRATEGY_ID": ""}, clear=False):
|
||||
require_strategy_id()
|
||||
self.assertNotEqual(ctx.exception.code, 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user