feat: initialize XAS test console with native execution
This commit is contained in:
@@ -0,0 +1,157 @@
|
||||
"""Console contracts: immutable uploads, isolation and durable queue semantics."""
|
||||
import json
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
from fastapi.testclient import TestClient
|
||||
from console.app import create_app
|
||||
from console.settings import Settings
|
||||
from console.store import Store
|
||||
|
||||
|
||||
class ConsoleTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.root = Path(self.temp.name)
|
||||
self.source = self.root / "original" / "scripts"
|
||||
self.source.mkdir(parents=True)
|
||||
self.settings = Settings(self.root / "runtime", [
|
||||
{"id": "trial", "path": str(self.source)}], allowed_hosts=["testserver"],
|
||||
allowed_networks=["127.0.0.0/8"], test_mode=True)
|
||||
self.settings.test_definitions = [{"key": name, "source_group": "trial", "entry": name,
|
||||
"name": name, "kind": "test"} for name in ("sample.py", "test.py", "stop.sh")]
|
||||
self.app = create_app(self.settings)
|
||||
self.client = TestClient(self.app, client=("127.0.0.1", 5555))
|
||||
self.headers = {"X-XAS-Console": "1"}
|
||||
|
||||
def tearDown(self):
|
||||
self.client.close()
|
||||
self.temp.cleanup()
|
||||
|
||||
def upload(self, content=b"print('hello')\n", name="test.py"):
|
||||
r = self.client.post("/api/scripts/upload", files={"file": (name, content)},
|
||||
data={"name": "Sample"}, headers=self.headers)
|
||||
self.assertEqual(r.status_code, 200, r.text)
|
||||
return r.json()
|
||||
|
||||
def queue(self, script, key="9ad0745c-0d5e-4d1e-88e8-000000000001"):
|
||||
return self.client.post(f"/api/scripts/{script['id']}/runs",
|
||||
json={"request_id": key}, headers=self.headers)
|
||||
|
||||
def test_upload_versions_are_immutable_and_runs_pin_version(self):
|
||||
script = self.upload()
|
||||
run = self.queue(script).json()
|
||||
new = self.client.post(f"/api/scripts/{script['id']}/versions",
|
||||
files={"file": ("test.py", b"print('v2')\n")}, headers=self.headers)
|
||||
self.assertEqual(new.status_code, 200, new.text)
|
||||
old = self.client.get(f"/api/scripts/{script['id']}/code?version=1").json()
|
||||
self.assertEqual(old["code"], "print('hello')\n")
|
||||
self.assertEqual(self.client.get(f"/api/runs/{run['id']}").json()["version"], 1)
|
||||
self.assertEqual(new.json()["version"], 2)
|
||||
|
||||
def test_code_cannot_be_patched_and_path_cannot_be_replaced(self):
|
||||
s = self.upload()
|
||||
for patch in ({"code": "bad"}, {"source_path": "/etc/passwd"},
|
||||
{"config": {"profile": "legacy_smoke"}}):
|
||||
r = self.client.patch(f"/api/scripts/{s['id']}", json=patch, headers=self.headers)
|
||||
self.assertEqual(r.status_code, 422, r.text)
|
||||
|
||||
def test_upload_rejects_traversal_secrets_and_oversize(self):
|
||||
for name, content in [("../x.py", b"pass"), ("x.env", b"pass"),
|
||||
("x.py", b"API_KEY = 'abc-secret-key-value-01234'"),
|
||||
("x.py", b"x" * (1024 * 1024 + 1))]:
|
||||
r = self.client.post("/api/scripts/upload", files={"file": (name, content)},
|
||||
headers=self.headers)
|
||||
self.assertIn(r.status_code, (400, 413, 422), r.text)
|
||||
|
||||
def test_cross_site_mutations_and_host_spoofing_rejected(self):
|
||||
r = self.client.post("/api/discover", headers={**self.headers, "Origin": "https://evil.test"})
|
||||
self.assertEqual(r.status_code, 403)
|
||||
self.assertEqual(self.client.post("/api/discover").status_code, 403)
|
||||
self.assertEqual(self.client.get("/api/summary", headers={"Host": "evil.test"}).status_code, 403)
|
||||
|
||||
def test_duplicate_submission_and_single_claim(self):
|
||||
s = self.upload()
|
||||
a, b = self.queue(s), self.queue(s)
|
||||
self.assertEqual(a.json()["id"], b.json()["id"])
|
||||
store = Store(self.settings)
|
||||
self.assertEqual(store.claim()["id"], a.json()["id"])
|
||||
self.assertIsNone(store.claim())
|
||||
self.queue(s, "9ad0745c-0d5e-4d1e-88e8-000000000002")
|
||||
self.assertIsNone(store.claim())
|
||||
|
||||
def test_pending_cancellation_does_not_execute(self):
|
||||
run = self.queue(self.upload()).json()
|
||||
r = self.client.post(f"/api/runs/{run['id']}/cancel", headers=self.headers)
|
||||
self.assertEqual(r.json()["state"], "cancelled")
|
||||
self.assertIsNone(Store(self.settings).claim())
|
||||
|
||||
def test_external_inventory_readonly_and_missing_detection(self):
|
||||
p = self.source / "sample.py"
|
||||
p.write_text("print('original')", encoding="utf-8")
|
||||
self.client.post("/api/discover", headers=self.headers)
|
||||
s = self.client.get("/api/scripts").json()["items"][0]
|
||||
self.assertEqual(s["kind"], "external")
|
||||
self.assertFalse(s["enabled"])
|
||||
self.assertEqual(p.read_text(), "print('original')")
|
||||
p.unlink()
|
||||
self.client.post("/api/discover", headers=self.headers)
|
||||
self.assertEqual(self.client.get(f"/api/scripts/{s['id']}").json()["status"], "missing")
|
||||
|
||||
def test_service_management_script_cannot_be_enabled(self):
|
||||
(self.source / "stop.sh").write_text("docker stop other-service\n")
|
||||
self.client.post("/api/discover", headers=self.headers)
|
||||
s = self.client.get("/api/scripts").json()["items"][0]
|
||||
r = self.client.patch(f"/api/scripts/{s['id']}", json={"enabled": True}, headers=self.headers)
|
||||
self.assertEqual(r.status_code, 400, r.text)
|
||||
|
||||
def test_external_output_path_cannot_escape_source_family(self):
|
||||
(self.source / "test.py").write_text("print(1)\n")
|
||||
self.client.post("/api/discover", headers=self.headers)
|
||||
s = self.client.get("/api/scripts").json()["items"][0]
|
||||
for path in ["/etc", str(self.source), str(self.source.parent / "configs")]:
|
||||
r = self.client.patch(f"/api/scripts/{s['id']}",
|
||||
json={"config": {"output_paths": [path]}}, headers=self.headers)
|
||||
self.assertEqual(r.status_code, 400, r.text)
|
||||
|
||||
def test_log_offset_unicode_and_artifact_path_boundary(self):
|
||||
run = self.queue(self.upload()).json()
|
||||
d = self.settings.runs / run["id"]
|
||||
d.mkdir(exist_ok=True)
|
||||
(d / "console.log").write_bytes("你好\n".encode("utf-8"))
|
||||
a = self.client.get(f"/api/runs/{run['id']}/log?offset=0").json()
|
||||
self.assertEqual(a["text"], "你好\n")
|
||||
b = self.client.get(f"/api/runs/{run['id']}/log?offset={a['next_offset']}").json()
|
||||
self.assertEqual(b["text"], "")
|
||||
self.assertEqual(self.client.get(f"/api/runs/{run['id']}/artifacts/nope").status_code, 404)
|
||||
|
||||
def test_finished_log_utf8_character_across_chunk_boundary(self):
|
||||
run = self.queue(self.upload()).json()
|
||||
directory = self.settings.runs / run["id"]
|
||||
directory.mkdir()
|
||||
content = "x" * 65535 + "你" + "tail"
|
||||
(directory / "console.log").write_bytes(content.encode("utf-8"))
|
||||
Store(self.settings).finish(run["id"], "succeeded")
|
||||
first = self.client.get(f"/api/runs/{run['id']}/log").json()
|
||||
second = self.client.get(f"/api/runs/{run['id']}/log?offset={first['next_offset']}").json()
|
||||
self.assertEqual(first["text"] + second["text"], content)
|
||||
|
||||
def test_runtime_parent_link_rejected_before_writes(self):
|
||||
from deploy.dedicated import validate_runtime
|
||||
outside = self.root / "outside"
|
||||
outside.mkdir()
|
||||
project = self.root / "project"
|
||||
project.mkdir()
|
||||
try:
|
||||
(project / "runtime").symlink_to(outside, target_is_directory=True)
|
||||
except OSError:
|
||||
self.skipTest("OS account cannot create directory symlinks")
|
||||
with self.assertRaises(ValueError):
|
||||
validate_runtime(project)
|
||||
with self.assertRaises(ValueError):
|
||||
Settings(project / "runtime", []).prepare()
|
||||
self.assertEqual(list(outside.iterdir()), [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,126 @@
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
import json
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from console.artifacts import Collector
|
||||
from console.job_main import BudgetRelay
|
||||
from console.settings import Settings
|
||||
|
||||
|
||||
class OutputTests(unittest.TestCase):
|
||||
def test_final_file_log_drain_includes_fast_script_tail_larger_than_one_chunk(self):
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
root = Path(temp)
|
||||
logs, run = root / "logs", root / "run"
|
||||
logs.mkdir()
|
||||
run.mkdir()
|
||||
collector = Collector(Settings(root, []), run, [str(logs)])
|
||||
payload = b"file log\n" * 10000
|
||||
(logs / "new.log").write_bytes(payload)
|
||||
parts = []
|
||||
collector.finish_follow(parts.append)
|
||||
self.assertEqual(b"".join(parts), "\n[文件日志 new.log]\n".encode() + payload)
|
||||
self.assertEqual(collector.follow(parts.append), 0)
|
||||
|
||||
def test_proxy_does_not_redirect_credentials_or_add_requests(self):
|
||||
seen = []
|
||||
class Redirect(BaseHTTPRequestHandler):
|
||||
def log_message(self, *args):
|
||||
pass
|
||||
def do_GET(self):
|
||||
seen.append(self.path)
|
||||
self.send_response(302)
|
||||
self.send_header("Location", "/unexpected")
|
||||
self.end_headers()
|
||||
upstream = HTTPServer(("127.0.0.1", 0), Redirect)
|
||||
relay = BudgetRelay(("127.0.0.1", 0), f"http://127.0.0.1:{upstream.server_port}", "test-only-credential")
|
||||
threads = [threading.Thread(target=s.serve_forever, daemon=True) for s in (upstream, relay)]
|
||||
for thread in threads:
|
||||
thread.start()
|
||||
try:
|
||||
with self.assertRaises(urllib.error.HTTPError) as rejected:
|
||||
urllib.request.urlopen(f"http://127.0.0.1:{relay.server_port}/v1/models", timeout=5)
|
||||
self.assertEqual(rejected.exception.code, 302)
|
||||
self.assertEqual(seen, ["/v1/models"])
|
||||
self.assertEqual(relay.gets, 1)
|
||||
finally:
|
||||
for server in (relay, upstream):
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
for thread in threads:
|
||||
thread.join()
|
||||
|
||||
def test_only_changed_external_outputs_are_archived_and_originals_preserved(self):
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
root = Path(temp)
|
||||
output, run = root / "original_logs", root / "run"
|
||||
output.mkdir()
|
||||
run.mkdir()
|
||||
(run / "work").mkdir()
|
||||
old = output / "old.json"
|
||||
old.write_text('{"old":true}')
|
||||
log = output / "current.log"
|
||||
log.write_bytes(b"previous\n")
|
||||
collector = Collector(Settings(root, []), run, [str(output)])
|
||||
with log.open("ab") as handle:
|
||||
handle.write("新日志\n".encode())
|
||||
messages = []
|
||||
collector.follow(messages.append)
|
||||
collector.follow(messages.append)
|
||||
self.assertEqual(len(messages), 1)
|
||||
self.assertIn("新日志", messages[0].decode())
|
||||
self.assertNotIn("previous", messages[0].decode())
|
||||
(run / "work/result.json").write_text('{"ok":true}')
|
||||
items = collector.collect()
|
||||
self.assertEqual({i["name"] for i in items}, {"current.log", "result.json"})
|
||||
self.assertEqual(old.read_text(), '{"old":true}')
|
||||
self.assertTrue(log.exists())
|
||||
log.write_text("overwritten later")
|
||||
archived = next(i for i in items if i["name"] == "current.log")
|
||||
self.assertIn("新日志", (run / "artifacts" / archived["id"]).read_text(encoding="utf-8"))
|
||||
|
||||
def test_model_proxy_budget_and_auth_are_per_run_without_retry(self):
|
||||
requests = []
|
||||
class Upstream(BaseHTTPRequestHandler):
|
||||
def log_message(self, *args):
|
||||
pass
|
||||
def do_GET(self):
|
||||
requests.append((self.path, self.headers.get("Authorization")))
|
||||
self.send_response(200)
|
||||
self.end_headers()
|
||||
self.wfile.write(b'{"data":[]}')
|
||||
def do_POST(self):
|
||||
self.rfile.read(int(self.headers["Content-Length"]))
|
||||
requests.append((self.path, self.headers.get("Authorization")))
|
||||
self.send_response(503)
|
||||
self.end_headers()
|
||||
upstream = HTTPServer(("127.0.0.1", 0), Upstream)
|
||||
relay = BudgetRelay(("127.0.0.1", 0), f"http://127.0.0.1:{upstream.server_port}", "test-only-credential")
|
||||
threads = [threading.Thread(target=s.serve_forever, daemon=True) for s in (upstream, relay)]
|
||||
for t in threads:
|
||||
t.start()
|
||||
url = f"http://127.0.0.1:{relay.server_port}"
|
||||
try:
|
||||
with urllib.request.urlopen(url + "/v1/models", timeout=5) as response:
|
||||
self.assertEqual(response.status, 200)
|
||||
with self.assertRaises(urllib.error.HTTPError) as rejected:
|
||||
urllib.request.urlopen(url + "/v1/models", timeout=5)
|
||||
self.assertEqual(rejected.exception.code, 403)
|
||||
data = json.dumps({"model": "deepseek-v4.1-flash", "max_tokens": 512}).encode()
|
||||
for i in range(4):
|
||||
with self.assertRaises(urllib.error.HTTPError) as rejected:
|
||||
urllib.request.urlopen(url + "/v1/chat/completions", data, timeout=5)
|
||||
self.assertEqual(rejected.exception.code, 503 if i < 3 else 403)
|
||||
self.assertEqual(len(requests), 4)
|
||||
self.assertTrue(all(auth == "Bearer test-only-credential" for _, auth in requests))
|
||||
self.assertNotIn("test-only-credential", json.dumps(relay.records))
|
||||
finally:
|
||||
for server in (relay, upstream):
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
for thread in threads:
|
||||
thread.join()
|
||||
@@ -0,0 +1,48 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
from console.catalog import upload
|
||||
from console.settings import Settings
|
||||
from console.store import Store
|
||||
|
||||
|
||||
@unittest.skipUnless(sys.platform == "linux", "worker dispatcher runs on Linux")
|
||||
class WorkerFinalizationTests(unittest.TestCase):
|
||||
def test_unmount_failure_changes_both_record_and_archived_summary(self):
|
||||
from console.worker import Worker
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
settings = Settings(Path(temp), [])
|
||||
store = Store(settings)
|
||||
script = upload(store, b"print('test')\n", "test.py")
|
||||
run = store.queue(script, "test-request", "127.0.0.1")
|
||||
store.claim()
|
||||
worker = Worker(settings, {})
|
||||
directory = settings.runs / run["id"]
|
||||
real_popen = subprocess.Popen
|
||||
def command(value, target):
|
||||
(target / "work").mkdir()
|
||||
(target / "work/result.txt").write_text("test output")
|
||||
return ["create"], {}
|
||||
def fake_docker(*args, **kwargs):
|
||||
if args[0] == "inspect":
|
||||
return json.dumps([{"Config": {"Labels": {"xas.console": "dedicated"}},
|
||||
"State": {"ExitCode": 0, "Running": False}}])
|
||||
return "owned-test-container"
|
||||
def process(*args, **kwargs):
|
||||
return real_popen([sys.executable, "-c", "import sys;sys.stdin.readline();print('output')"], **kwargs)
|
||||
with patch.object(worker, "command", command), patch("console.worker.docker", fake_docker), \
|
||||
patch("console.worker.subprocess.Popen", process), \
|
||||
patch("console.worker.workspace.mounts", return_value=[directory / "work"]), \
|
||||
patch("console.worker.workspace.remove", side_effect=OSError("simulated unmount failure")):
|
||||
worker.execute(run)
|
||||
result = store.run(run["id"])
|
||||
self.assertEqual(result["state"], "interrupted")
|
||||
summary = next(a for a in result["artifacts"] if a["name"] == "summary.json")
|
||||
archived = json.loads((directory / "artifacts" / summary["id"]).read_text())
|
||||
self.assertEqual(archived["state"], result["state"])
|
||||
self.assertEqual(archived["message"], result["message"])
|
||||
self.assertTrue(worker.stopping)
|
||||
@@ -0,0 +1,82 @@
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest import mock
|
||||
import contextlib
|
||||
import io
|
||||
import json
|
||||
import sys
|
||||
from types import SimpleNamespace
|
||||
|
||||
MODULE = Path(__file__).resolve().parents[1] / "deploy/start.py"
|
||||
spec = importlib.util.spec_from_file_location("start_console", MODULE)
|
||||
start = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(start)
|
||||
|
||||
|
||||
class DeploymentPathsTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.base = Path(self.temp.name)
|
||||
self.project = self.base / "project"
|
||||
self.project.mkdir()
|
||||
self.outside = self.base / "other-owner"
|
||||
self.outside.mkdir()
|
||||
|
||||
def validator(self):
|
||||
self.assertTrue(hasattr(start, "validate_runtime"), "runtime boundary validation is missing")
|
||||
return start.validate_runtime
|
||||
|
||||
def link(self, target, destination):
|
||||
try:
|
||||
destination.symlink_to(target, target_is_directory=True)
|
||||
except OSError:
|
||||
self.skipTest("OS account cannot create a directory symlink")
|
||||
|
||||
def test_runtime_parent_link_cannot_escape_project(self):
|
||||
validate = self.validator()
|
||||
self.link(self.outside, self.project / "runtime")
|
||||
before = self.outside.stat().st_mode
|
||||
with self.assertRaises(ValueError):
|
||||
validate(self.project)
|
||||
self.assertEqual(self.outside.stat().st_mode, before)
|
||||
self.assertEqual(list(self.outside.iterdir()), [])
|
||||
|
||||
def test_runtime_child_link_cannot_escape_project(self):
|
||||
validate = self.validator()
|
||||
(self.project / "runtime").mkdir()
|
||||
self.link(self.outside, self.project / "runtime/data")
|
||||
with self.assertRaises(ValueError):
|
||||
validate(self.project)
|
||||
self.assertEqual(list(self.outside.iterdir()), [])
|
||||
|
||||
def test_normal_runtime_is_resolved_without_writing(self):
|
||||
validate = self.validator()
|
||||
self.assertEqual(validate(self.project), self.project.resolve() / "runtime")
|
||||
self.assertEqual(list(self.project.iterdir()), [])
|
||||
|
||||
def test_backend_is_only_on_proxy_loopback_namespace(self):
|
||||
# Regress the reviewed direct-container-IP role-header bypass at the Docker boundary.
|
||||
deploy = self.project / 'deploy'
|
||||
deploy.mkdir()
|
||||
(deploy/'lan-settings.json').write_text(json.dumps({
|
||||
'address':'10.88.118.188','allowed_cidrs':['10.88.0.0/17','10.20.0.0/21'],'port':14440}))
|
||||
calls = []
|
||||
def docker(args, **kwargs):
|
||||
calls.append(args)
|
||||
absent = args[:3] in (['docker','container','inspect'], ['docker','network','inspect'])
|
||||
return SimpleNamespace(returncode=1 if absent else 0, stdout='')
|
||||
with mock.patch.object(sys, 'argv', ['start.py','--project-root',str(self.project)]), \
|
||||
mock.patch.object(sys, 'path', [str(MODULE.parent)] + sys.path), \
|
||||
mock.patch.object(start.subprocess, 'run', side_effect=docker), \
|
||||
mock.patch.object(start.os, 'chown', create=True), \
|
||||
mock.patch.object(start.socket, 'socket'), contextlib.redirect_stdout(io.StringIO()):
|
||||
start.main()
|
||||
backend = next(c for c in calls if '--name' in c and c[c.index('--name')+1]=='adamwen-test-console')
|
||||
proxy = next(c for c in calls if '--name' in c and c[c.index('--name')+1]=='adamwen-test-console-proxy')
|
||||
self.assertIn('RUNDECK_SERVER_ADDRESS=127.0.0.1', backend)
|
||||
self.assertEqual(backend[backend.index('--network')+1], 'container:adamwen-test-console-proxy')
|
||||
self.assertNotIn('--publish', backend)
|
||||
self.assertLess(calls.index(proxy), calls.index(backend))
|
||||
@@ -0,0 +1,36 @@
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
|
||||
MODULE = Path(__file__).resolve().parents[1] / 'deploy/lan_config.py'
|
||||
|
||||
|
||||
class LanConfigurationTests(unittest.TestCase):
|
||||
def validator(self):
|
||||
self.assertTrue(MODULE.is_file(), 'LAN boundary configuration is not implemented')
|
||||
spec = importlib.util.spec_from_file_location('lan_config', MODULE)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module.validate_settings
|
||||
|
||||
def test_explicit_private_interface_and_matching_subnet_are_accepted(self):
|
||||
result = self.validator()({'address':'10.88.118.188','allowed_cidrs':['10.88.0.0/17','10.20.0.0/21'],'port':14440})
|
||||
self.assertEqual(result['address'], '10.88.118.188')
|
||||
self.assertEqual(result['allowed_cidrs'], ['10.88.0.0/17','10.20.0.0/21'])
|
||||
|
||||
def test_wildcard_public_loopback_and_nonmatching_network_are_rejected(self):
|
||||
validate = self.validator()
|
||||
for address, cidr in [('0.0.0.0','0.0.0.0/0'), ('8.8.8.8','8.8.8.0/24'),
|
||||
('127.0.0.1','127.0.0.0/8'), ('10.88.118.188','10.89.0.0/17'),
|
||||
('10.88.118.188','0.0.0.0/0'), ('10.88.118.188; admin','10.88.0.0/17')]:
|
||||
with self.subTest(address=address,cidr=cidr), self.assertRaises(ValueError):
|
||||
validate({'address':address,'allowed_cidrs':[cidr],'port':14440})
|
||||
|
||||
def test_invalid_port_and_extra_fields_are_rejected(self):
|
||||
validate = self.validator()
|
||||
base = {'address':'10.88.118.188','allowed_cidrs':['10.88.0.0/17','10.20.0.0/21'],'port':14440}
|
||||
for port in (0, 80, 65536, True, '14440'):
|
||||
with self.subTest(port=port), self.assertRaises(ValueError):
|
||||
validate(dict(base, port=port))
|
||||
with self.assertRaises(ValueError):
|
||||
validate(dict(base, public=True))
|
||||
@@ -0,0 +1,89 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
import tempfile
|
||||
import os
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
from console.native_executor import inspect, NativeExecutor
|
||||
from console.native_io import SecretFilter
|
||||
from console.artifacts import Collector
|
||||
from console.settings import Settings
|
||||
from console.store import Store
|
||||
|
||||
|
||||
class LifecycleTests(unittest.TestCase):
|
||||
def test_many_old_outputs_do_not_hide_new_native_log(self):
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
root=Path(temp);logs=root/'logs';run=root/'run'
|
||||
logs.mkdir();run.mkdir();(run/'work').mkdir()
|
||||
for number in range(300):(logs/f'{number:04}.log').write_text('old history')
|
||||
collector=Collector(Settings(root,[]),run,lambda:sorted(logs.iterdir()),file_limit=4096)
|
||||
(logs/'new.log').write_text('current progress')
|
||||
chunks=[];collector.finish_follow(chunks.append)
|
||||
self.assertIn(b'current progress',b''.join(chunks))
|
||||
self.assertNotIn(b'old history',b''.join(chunks))
|
||||
self.assertEqual([a['name'] for a in collector.collect()],['new.log'])
|
||||
|
||||
@unittest.skipIf(os.name == "nt", "Linux controlling terminal")
|
||||
def test_menu_has_controlling_terminal(self):
|
||||
import pty
|
||||
import subprocess
|
||||
import sys
|
||||
from console.native_job import acquire_terminal
|
||||
master, slave = pty.openpty()
|
||||
try:
|
||||
result = subprocess.run([sys.executable, "-c", "import os; t=os.open('/dev/tty',os.O_RDWR); os.close(t)"],
|
||||
stdin=slave, stdout=slave, stderr=slave, start_new_session=True, preexec_fn=acquire_terminal, timeout=5)
|
||||
self.assertEqual(result.returncode, 0)
|
||||
finally:
|
||||
os.close(master);os.close(slave)
|
||||
|
||||
def test_systemd_probe_error_is_not_missing_unit(self):
|
||||
result = SimpleNamespace(returncode=1, stdout="", stderr="D-Bus unavailable")
|
||||
with patch("console.native_executor.subprocess.run", return_value=result):
|
||||
with self.assertRaises(OSError):inspect("a" * 32)
|
||||
result.stdout = "LoadState=not-found\n"
|
||||
with patch("console.native_executor.subprocess.run", return_value=result):
|
||||
self.assertIsNone(inspect("a" * 32))
|
||||
|
||||
def test_file_log_secret_split_at_chunk_boundary_is_filtered(self):
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
root = Path(temp)
|
||||
logs, run = root / "logs", root / "run"
|
||||
logs.mkdir();run.mkdir();(run / "work").mkdir()
|
||||
secret = "dummy-fixture-credential-123456"
|
||||
collector = Collector(Settings(root, []), run, [logs], stream_filter_factory=lambda: SecretFilter([secret]))
|
||||
(logs / "x.log").write_bytes(b"x" * 32760 + secret.encode() + b"\n")
|
||||
chunks=[];collector.finish_follow(chunks.append)
|
||||
result=b"".join(chunks)
|
||||
self.assertNotIn(secret.encode(),result)
|
||||
self.assertIn(b"[REDACTED]",result)
|
||||
|
||||
def test_terminal_state_waits_for_confirmed_cleanup(self):
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
settings=Settings(Path(temp),[]);store=Store(settings)
|
||||
script={"id":"b"*32,"name":"fixture","version":1,"sha256":"x","config":{},"source_path":"fixture.py",
|
||||
"kind":"external","source_group":"fixture","language":"python"}
|
||||
run=store.queue(script,"fixture-request","127.0.0.1",execution={"mode":"native"})
|
||||
store.claim()
|
||||
directory=settings.runs/run['id'];directory.mkdir()
|
||||
(directory/'native-result.json').write_text(json.dumps({'exit_code':0,'artifacts':[]}))
|
||||
executor=NativeExecutor(SimpleNamespace(settings=settings,store=store,stopping=False))
|
||||
with patch.object(executor,'wait_stopped',return_value=False):
|
||||
executor.finish(run,{'Result':'success'})
|
||||
self.assertEqual(store.run(run['id'])['state'],'running')
|
||||
self.assertIsNone(store.claim())
|
||||
|
||||
def test_cancelled_before_dispatch_does_not_create_unit(self):
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
settings=Settings(Path(temp),[]);store=Store(settings)
|
||||
script={"id":"b"*32,"name":"fixture","version":1,"sha256":"x","config":{},"source_path":"fixture.py",
|
||||
"kind":"external","source_group":"fixture","language":"python"}
|
||||
run=store.queue(script,"fixture-request","127.0.0.1",execution={"mode":"native"})
|
||||
store.claim();store.cancel(run['id'])
|
||||
executor=NativeExecutor(SimpleNamespace(settings=settings,store=store,stopping=False))
|
||||
with patch('console.native_executor.subprocess.run') as launch:
|
||||
executor.execute(run)
|
||||
launch.assert_not_called()
|
||||
self.assertEqual(store.run(run['id'])['state'],'cancelled')
|
||||
@@ -0,0 +1,89 @@
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from console.settings import Settings
|
||||
from console.native_plans import build_plan, profile_for, validate_options, public_execution
|
||||
from console.native_io import SecretFilter
|
||||
|
||||
|
||||
class NativePlanTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.root = Path(self.temp.name)
|
||||
source = self.root / "original"
|
||||
source.mkdir()
|
||||
(source / "logs").mkdir()
|
||||
self.entry = source / "test.py"
|
||||
self.entry.write_text("print('original')\n")
|
||||
self.settings = Settings(self.root / "runtime", [{"id": "original", "path": str(source)}])
|
||||
self.profile = {"id": "test", "source_group": "original", "entry": "test.py", "cwd": str(source),
|
||||
"interpreter": sys.executable, "user": "root", "arguments": ["{concurrency}", "{output_file}"],
|
||||
"parameters": [{"key": "concurrency", "label": "并发", "type": "integer", "default": 2, "min": 1, "max": 8},
|
||||
{"key": "configuration", "type": "select", "default": "known", "options": [{"value": "known", "label": "已登记配置"}]}],
|
||||
"impact": {"level": "service", "title": "切换服务", "details": ["可能停止旧服务"], "requires_confirmation": True},
|
||||
"environment": {}, "credential_files": [], "interactive": False, "output_paths": [str(source / "logs" / "console-{run_id}*")]}
|
||||
self.settings.native_catalog = {"profiles": [self.profile], "targets": []}
|
||||
self.script = {"id": "a" * 32, "kind": "external", "source_group": "original", "source_path": str(self.entry),
|
||||
"sha256": hashlib.sha256(self.entry.read_bytes()).hexdigest(), "config": {"native_options": {}, "timeout_seconds": 300}, "language": "python"}
|
||||
|
||||
def tearDown(self):
|
||||
self.temp.cleanup()
|
||||
|
||||
def test_plan_uses_original_file_and_typed_argv_without_shell_join(self):
|
||||
before = self.entry.read_bytes()
|
||||
plan = build_plan(self.settings, self.script, run_id="b" * 32, check_host=False)
|
||||
self.assertEqual(plan["argv"][:2], [sys.executable, str(self.entry)])
|
||||
self.assertEqual(plan["argv"][2], "2")
|
||||
self.assertIn("console-" + "b" * 32, plan["argv"][3])
|
||||
self.assertEqual(plan["cwd"], str(self.entry.parent))
|
||||
self.assertEqual(self.entry.read_bytes(), before)
|
||||
|
||||
def test_unregistered_uploaded_and_replaced_entries_cannot_run_natively(self):
|
||||
for change in ({"kind": "managed"}, {"source_path": str(self.root / "unknown.py")}, {"source_group": "other"}):
|
||||
self.assertIsNone(profile_for(self.settings, {**self.script, **change}))
|
||||
|
||||
def test_parameters_reject_injection_unknown_keys_bool_and_out_of_range(self):
|
||||
for value in ({"configuration": "../../etc/profile"}, {"configuration": "known;id"},
|
||||
{"concurrency": True}, {"concurrency": 9}, {"command": "id"}):
|
||||
with self.assertRaises(ValueError):
|
||||
validate_options(self.profile, value)
|
||||
|
||||
def test_confirmation_changes_with_options_source_or_recipe(self):
|
||||
first = build_plan(self.settings, self.script, check_host=False)["plan_token"]
|
||||
self.script["config"]["native_options"] = {"concurrency": 3}
|
||||
second = build_plan(self.settings, self.script, check_host=False)["plan_token"]
|
||||
self.assertNotEqual(first, second)
|
||||
self.profile["impact"]["details"].append("使用全部 GPU")
|
||||
self.assertNotEqual(second, build_plan(self.settings, self.script, check_host=False)["plan_token"])
|
||||
self.entry.write_text("print('changed')\n")
|
||||
with self.assertRaises(ValueError):
|
||||
build_plan(self.settings, self.script, check_host=False)
|
||||
|
||||
def test_credentials_not_in_public_execution(self):
|
||||
self.profile["credential_files"] = [str(self.root / "private.env")]
|
||||
visible = public_execution(self.settings, self.script)
|
||||
self.assertEqual(visible["mode"], "native")
|
||||
self.assertNotIn("credential_files", visible)
|
||||
self.assertNotIn("private.env", json.dumps(visible))
|
||||
|
||||
def test_stream_filter_redacts_secret_split_across_reads(self):
|
||||
secret = "dummy-credential-abcdefghijk"
|
||||
filt = SecretFilter([secret])
|
||||
output = filt.feed(b"hello\nkey=dummy-cred")
|
||||
output += filt.feed(b"ential-abcdefghijk\nworld\n")
|
||||
output += filt.finish()
|
||||
self.assertNotIn(secret, output.decode())
|
||||
self.assertIn("[REDACTED]", output.decode())
|
||||
self.assertIn("world", output.decode())
|
||||
|
||||
def test_ansi_split_inside_secret_cannot_reconstruct_plaintext(self):
|
||||
secret = "dummy-credential-abcdefghijk"
|
||||
filt = SecretFilter([secret])
|
||||
result = filt.feed(b"dummy-cred\x1b[")
|
||||
result += filt.feed(b"31mential-abcdefghijk\x1b[0m\n")
|
||||
result += filt.finish()
|
||||
self.assertNotIn(secret.encode(), result)
|
||||
self.assertIn(b"[REDACTED]", result)
|
||||
@@ -0,0 +1,68 @@
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
from console.settings import Settings
|
||||
from console.store import Store
|
||||
from console.native_plans import build_plan
|
||||
from console.native_rpc import handle
|
||||
|
||||
|
||||
class NativeRPCTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
root = Path(self.temp.name)
|
||||
source = root / "source"
|
||||
source.mkdir()
|
||||
entry = source / "test.py"
|
||||
entry.write_text("print(1)\n")
|
||||
self.settings = Settings(root / "runtime", [{"id": "original", "path": str(source)}])
|
||||
self.settings.test_definitions = [{"key": "native", "source_group": "original", "entry": "test.py", "name": "原环境样例"}]
|
||||
self.settings.native_catalog = {"targets": [], "profiles": [{"id": "native", "source_group": "original", "entry": "test.py",
|
||||
"cwd": str(source), "user": "root", "interpreter": "/usr/bin/python3", "parameters": [], "arguments": [],
|
||||
"impact": {"level": "service", "title": "服务切换", "details": ["会停止指定服务"], "requires_confirmation": True}}]}
|
||||
self.script = {"id": "a" * 32, "name": "test.py", "description": "", "kind": "external", "source_group": "original",
|
||||
"source_path": str(entry), "sha256": hashlib.sha256(entry.read_bytes()).hexdigest(), "language": "python", "version": 1,
|
||||
"enabled": True, "status": "ready", "config": {"native_options": {}, "timeout_seconds": 300}}
|
||||
self.store = Store(self.settings)
|
||||
self.store.save_script(self.script)
|
||||
self.plan = build_plan(self.settings, self.script, check_host=False)
|
||||
self.request = {"op": "queue", "script_id": self.script["id"], "request_id": "5b0202b7-851d-48ad-a7b9-dce22f000001",
|
||||
"plan_token": self.plan["plan_token"], "source_ip": "127.0.0.1"}
|
||||
|
||||
def tearDown(self):
|
||||
self.temp.cleanup()
|
||||
|
||||
def test_server_requires_explicit_current_confirmation(self):
|
||||
with patch("console.native_rpc.native_plans.build_plan", return_value=self.plan):
|
||||
with self.assertRaises(ValueError):handle(self.settings, self.store, self.request)
|
||||
with self.assertRaises(ValueError):handle(self.settings, self.store, {**self.request, "impact_acknowledged": True, "plan_token": "old"})
|
||||
self.assertEqual(self.store.runs(), [])
|
||||
run = handle(self.settings, self.store, {**self.request, "impact_acknowledged": True})
|
||||
self.assertTrue(run["execution"]["impact_acknowledged"])
|
||||
self.assertEqual(run["execution"]["mode"], "native")
|
||||
|
||||
def test_retry_returns_original_even_if_plan_later_changed(self):
|
||||
with patch("console.native_rpc.native_plans.build_plan", return_value=self.plan):
|
||||
first = handle(self.settings, self.store, {**self.request, "impact_acknowledged": True})
|
||||
with patch("console.native_rpc.native_plans.build_plan", side_effect=ValueError("changed")):
|
||||
again = handle(self.settings, self.store, self.request)
|
||||
self.assertEqual(first["id"], again["id"])
|
||||
self.assertEqual(len(self.store.runs()), 1)
|
||||
|
||||
def test_failed_preflight_and_unknown_operation_never_queue(self):
|
||||
with patch("console.native_rpc.native_plans.build_plan", return_value={**self.plan, "ready": False}):
|
||||
with self.assertRaises(ValueError):handle(self.settings, self.store, {**self.request, "impact_acknowledged": True})
|
||||
with self.assertRaises(ValueError):handle(self.settings, self.store, {"op": "exec", "command": "id"})
|
||||
self.assertEqual(self.store.runs(), [])
|
||||
|
||||
def test_input_rejected_for_queued_or_noninteractive_run(self):
|
||||
run = self.store.queue(self.script, "sample", "127.0.0.1", execution={"mode": "native", "interactive": True})
|
||||
with self.assertRaises(ValueError):handle(self.settings, self.store, {"op": "input", "run_id": run["id"], "text": "1"})
|
||||
with self.assertRaises(ValueError):handle(self.settings, self.store, {"op": "input", "run_id": "../../bad", "text": "1"})
|
||||
|
||||
def test_input_channel_rejects_tmux_keys_and_shell_commands(self):
|
||||
for text in ("\x02c", "\x1b", "bash", "1\nid", "echo test", ":new-window"):
|
||||
with self.assertRaisesRegex(ValueError, "控制键"):
|
||||
handle(self.settings, self.store, {"op": "input", "run_id": "a"*32, "text": text})
|
||||
@@ -0,0 +1,102 @@
|
||||
"""Behavioral acceptance; Linux process tests also run on the target host."""
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
RUNNER = ROOT / "scripts" / "run_smoke.py"
|
||||
|
||||
|
||||
class RunnerTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.results = Path(self.temp.name)
|
||||
|
||||
def command(self, mode="success", run_id="test-1", timeout="3"):
|
||||
self.assertTrue(RUNNER.is_file(), "run_smoke.py is not implemented")
|
||||
return [sys.executable, "-u", str(RUNNER), "--mode", mode,
|
||||
"--run-id", run_id, "--timeout", timeout,
|
||||
"--results-root", str(self.results)]
|
||||
|
||||
def run_task(self, **kwargs):
|
||||
return subprocess.run(self.command(**kwargs), capture_output=True,
|
||||
text=True, timeout=12)
|
||||
|
||||
def summary(self, run_id="test-1"):
|
||||
return json.loads((self.results / run_id / "summary.json").read_text())
|
||||
|
||||
def test_success_streams_progress_and_records_synthetic_result(self):
|
||||
result = self.run_task()
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
summary = self.summary()
|
||||
self.assertEqual(summary["status"], "succeeded")
|
||||
self.assertEqual(summary["run_id"], "test-1")
|
||||
self.assertTrue(summary["synthetic"])
|
||||
self.assertEqual(summary["result"]["checks_passed"], 3)
|
||||
self.assertIn("progress=1/3", result.stdout)
|
||||
self.assertIn("progress=3/3", result.stdout)
|
||||
self.assertEqual(len(summary["source_sha256"]["smoke_task.py"]), 64)
|
||||
|
||||
def test_nonzero_exit_is_recorded_as_failure(self):
|
||||
result = self.run_task(mode="failure")
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertEqual(self.summary()["status"], "failed")
|
||||
self.assertEqual(self.summary()["exit_code"], 23)
|
||||
|
||||
def test_zero_exit_with_invalid_report_is_failure(self):
|
||||
result = self.run_task(mode="invalid-report")
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertEqual(self.summary()["status"], "invalid_result")
|
||||
|
||||
def test_reused_run_id_preserves_previous_result(self):
|
||||
self.assertEqual(self.run_task().returncode, 0)
|
||||
before = (self.results / "test-1" / "summary.json").read_bytes()
|
||||
self.assertNotEqual(self.run_task(mode="failure").returncode, 0)
|
||||
self.assertEqual((self.results / "test-1" / "summary.json").read_bytes(), before)
|
||||
|
||||
def test_invalid_inputs_do_not_create_run_directory(self):
|
||||
for kwargs in ({"run_id": "../escape"}, {"mode": "arbitrary"},
|
||||
{"timeout": "0"}, {"timeout": "31"}, {"timeout": "nan"}):
|
||||
with self.subTest(kwargs=kwargs):
|
||||
self.assertNotEqual(self.run_task(**kwargs).returncode, 0)
|
||||
self.assertEqual(list(self.results.iterdir()), [])
|
||||
|
||||
@unittest.skipUnless(sys.platform == "linux", "requires Linux process groups")
|
||||
def test_timeout_terminates_child_and_records_timeout(self):
|
||||
result = self.run_task(mode="wait", timeout="2")
|
||||
self.assertEqual(result.returncode, 124, result.stdout + result.stderr)
|
||||
summary = self.summary()
|
||||
self.assertEqual(summary["status"], "timed_out")
|
||||
self.assertFalse(Path(f'/proc/{summary["child_pid"]}').exists())
|
||||
|
||||
@unittest.skipUnless(sys.platform == "linux", "requires Linux process groups")
|
||||
def test_cancel_terminates_parent_and_child(self):
|
||||
proc = subprocess.Popen(self.command(mode="wait", timeout="15"),
|
||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
||||
try:
|
||||
deadline = time.monotonic() + 4
|
||||
child_file = self.results / "test-1" / "child.pid"
|
||||
while not child_file.exists() and time.monotonic() < deadline:
|
||||
time.sleep(.05)
|
||||
self.assertTrue(child_file.exists(), "worker did not start")
|
||||
child_pid = int(child_file.read_text())
|
||||
os.kill(proc.pid, signal.SIGTERM)
|
||||
out, err = proc.communicate(timeout=4)
|
||||
self.assertEqual(proc.returncode, 130, out + err)
|
||||
self.assertEqual(self.summary()["status"], "cancelled")
|
||||
self.assertFalse(Path(f"/proc/{child_pid}").exists())
|
||||
finally:
|
||||
if proc.poll() is None:
|
||||
proc.kill()
|
||||
proc.communicate()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,103 @@
|
||||
"""A test entry represents behavior, not every source file under a directory."""
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
from fastapi.testclient import TestClient
|
||||
from console.app import create_app
|
||||
from console.settings import Settings
|
||||
from console.store import Store
|
||||
|
||||
|
||||
class TestCatalogTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
root = Path(self.temp.name)
|
||||
self.source = root / "scripts"
|
||||
self.source.mkdir()
|
||||
(self.source / "run-suite.sh").write_text("python3 helper.py\n")
|
||||
(self.source / "helper.py").write_text("print('helper')\n")
|
||||
(self.source / "tests").mkdir()
|
||||
(self.source / "tests/test_internal.py").write_text("assert True\n")
|
||||
self.settings = Settings(root / "runtime", [{"id": "suite", "path": str(self.source)}],
|
||||
allowed_hosts=["testserver"], allowed_networks=["127.0.0.0/8"])
|
||||
self.settings.test_definitions = [{"key": "suite", "source_group": "suite", "entry": "run-suite.sh",
|
||||
"name": "模型综合测试", "description": "一轮完整验证", "kind": "suite",
|
||||
"members": [{"source_group": "suite", "pattern": "**/*"}]}]
|
||||
self.client = TestClient(create_app(self.settings), client=("127.0.0.1", 5555))
|
||||
self.headers = {"X-XAS-Console": "1"}
|
||||
self.client.post("/api/discover", headers=self.headers)
|
||||
self.store = Store(self.settings)
|
||||
|
||||
def tearDown(self):
|
||||
self.client.close()
|
||||
self.temp.cleanup()
|
||||
|
||||
def test_suite_is_one_item_with_readonly_members_not_three_cards(self):
|
||||
items = self.client.get("/api/scripts").json()["items"]
|
||||
self.assertEqual(len(items), 1)
|
||||
self.assertEqual(items[0]["name"], "模型综合测试")
|
||||
self.assertEqual(items[0]["test_kind"], "suite")
|
||||
self.assertEqual(items[0]["file_count"], 3)
|
||||
files = self.client.get(f"/api/scripts/{items[0]['id']}/files").json()["items"]
|
||||
self.assertEqual(len(files), 3)
|
||||
self.assertEqual(sum(f["role"] == "entry" for f in files), 1)
|
||||
summary = self.client.get("/api/summary").json()
|
||||
self.assertEqual(summary["scripts"], 1)
|
||||
self.assertEqual(summary["source_files"], 3)
|
||||
|
||||
def test_refresh_does_not_promote_new_support_file_or_clear_configuration(self):
|
||||
entry = next(s for s in self.store.scripts() if s["source_path"].endswith("run-suite.sh"))
|
||||
self.client.patch(f"/api/scripts/{entry['id']}", json={"name": "我的综合测试"}, headers=self.headers)
|
||||
(self.source / "new_module.py").write_text("pass\n")
|
||||
self.client.post("/api/discover", headers=self.headers)
|
||||
items = self.client.get("/api/scripts").json()["items"]
|
||||
self.assertEqual(len(items), 1)
|
||||
self.assertEqual(items[0]["name"], "我的综合测试")
|
||||
self.assertEqual(items[0]["file_count"], 4)
|
||||
|
||||
def test_member_cannot_be_enabled_or_run_by_direct_api(self):
|
||||
helper = next(s for s in self.store.scripts() if s["source_path"].endswith("helper.py"))
|
||||
self.assertFalse(self.client.get(f"/api/scripts/{helper['id']}").json()["is_test"])
|
||||
enabled = self.client.patch(f"/api/scripts/{helper['id']}", json={"enabled": True}, headers=self.headers)
|
||||
self.assertEqual(enabled.status_code, 400)
|
||||
# Even a pre-existing enabled inventory record cannot bypass the entry boundary.
|
||||
helper.update(enabled=True, status="ready")
|
||||
self.store.save_script(helper)
|
||||
run = self.client.post(f"/api/scripts/{helper['id']}/runs",
|
||||
json={"request_id": "caa3943d-6872-472c-86c2-500000000001"}, headers=self.headers)
|
||||
self.assertEqual(run.status_code, 400)
|
||||
self.assertEqual(self.store.runs(), [])
|
||||
|
||||
def test_upload_remains_one_test_and_examples_are_opt_in(self):
|
||||
uploaded = self.client.post("/api/scripts/upload", headers=self.headers,
|
||||
files={"file": ("test.py", b"print('custom')\n")}, data={"name": "用户测试"}).json()
|
||||
example = self.client.post("/api/scripts/upload", headers=self.headers,
|
||||
files={"file": ("demo.py", b"print('demo')\n")}, data={"name": "演示"}).json()
|
||||
item = self.store.script(example["id"])
|
||||
item["synthetic"] = True
|
||||
self.store.save_script(item)
|
||||
regular = self.client.get("/api/scripts").json()["items"]
|
||||
self.assertEqual({s["name"] for s in regular}, {"模型综合测试", "用户测试"})
|
||||
all_items = self.client.get("/api/scripts?include_examples=true").json()["items"]
|
||||
self.assertEqual(len(all_items), 3)
|
||||
self.assertTrue(next(s for s in all_items if s["id"] == uploaded["id"])["is_test"])
|
||||
|
||||
def test_historical_runs_keep_ids_and_source_contents_unchanged(self):
|
||||
entry = next(s for s in self.store.scripts() if s["source_path"].endswith("run-suite.sh"))
|
||||
historical = self.store.queue(entry, "past-request", "127.0.0.1")
|
||||
before = (self.source / "run-suite.sh").read_bytes()
|
||||
self.client.get("/api/scripts")
|
||||
self.client.post("/api/discover", headers=self.headers)
|
||||
self.assertEqual(self.store.run(historical["id"])["script_name"], "run-suite.sh")
|
||||
self.assertEqual(self.store.run(historical["id"])["script_id"], entry["id"])
|
||||
self.assertEqual(before, (self.source / "run-suite.sh").read_bytes())
|
||||
|
||||
def test_new_run_uses_one_entry_and_the_test_name(self):
|
||||
entry = self.client.get("/api/scripts").json()["items"][0]
|
||||
response = self.client.patch(f"/api/scripts/{entry['id']}", json={"enabled": True}, headers=self.headers)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
run = self.client.post(f"/api/scripts/{entry['id']}/runs", headers=self.headers,
|
||||
json={"request_id": "caa3943d-6872-472c-86c2-500000000002"}).json()
|
||||
self.assertEqual(run["script_name"], "模型综合测试")
|
||||
self.assertEqual(run["source_path"], str(self.source / "run-suite.sh"))
|
||||
self.assertEqual(len(self.store.runs()), 1)
|
||||
Reference in New Issue
Block a user