Skip to content

Commit e2d69fd

Browse files
committed
tighten plugin validation error paths
1 parent 0fd923e commit e2d69fd

3 files changed

Lines changed: 152 additions & 4 deletions

File tree

.github/workflows/validate-plugin-smoke.yml

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,8 +86,22 @@ jobs:
8686
if not should_validate:
8787
validation_note = "No plugin entries changed in plugins.json; skipping smoke validation."
8888
89+
astrbot_ref = "master"
90+
try:
91+
default_head = subprocess.check_output(
92+
["git", "ls-remote", "--symref", "https://github.com/AstrBotDevs/AstrBot", "HEAD"],
93+
text=True,
94+
stderr=subprocess.DEVNULL,
95+
)
96+
for line in default_head.splitlines():
97+
if line.startswith("ref: refs/heads/") and line.endswith("\tHEAD"):
98+
astrbot_ref = line.split("refs/heads/", 1)[1].split("\t", 1)[0]
99+
break
100+
except subprocess.CalledProcessError:
101+
pass
102+
89103
with open(os.environ["GITHUB_ENV"], "a", encoding="utf-8") as handle:
90-
handle.write("ASTRBOT_REF=master\n")
104+
handle.write(f"ASTRBOT_REF={astrbot_ref}\n")
91105
handle.write(f"PLUGIN_NAME_LIST={','.join(changed)}\n")
92106
handle.write("PLUGIN_LIMIT=\n")
93107
handle.write(f"SHOULD_VALIDATE={'true' if should_validate else 'false'}\n")

scripts/validate_plugins/run.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -134,9 +134,11 @@ def load_metadata(path: Path) -> dict:
134134
loaded = yaml.safe_load(text)
135135
except yaml.YAMLError as exc:
136136
raise MetadataLoadError(str(exc)) from exc
137-
if isinstance(loaded, dict):
138-
return loaded
139-
return {}
137+
if loaded is None:
138+
return {}
139+
if not isinstance(loaded, dict):
140+
raise MetadataLoadError("metadata.yaml must contain a mapping at the top level")
141+
return loaded
140142
return _parse_simple_yaml(path)
141143

142144

tests/test_validate_plugins.py

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,27 @@ def test_load_metadata_uses_yaml_safe_load_when_available(self):
243243
self.assertEqual(metadata, {"name": "from-yaml", "version": "1.0.0"})
244244
fake_yaml.safe_load.assert_called_once()
245245

246+
def test_load_metadata_rejects_non_mapping_yaml_root(self):
247+
module = load_validator_module()
248+
249+
with tempfile.NamedTemporaryFile("w", suffix=".yml", delete=False) as handle:
250+
handle.write("- item\n")
251+
metadata_path = Path(handle.name)
252+
253+
fake_yaml = mock.Mock()
254+
fake_yaml.safe_load.return_value = ["item"]
255+
fake_yaml.YAMLError = ValueError
256+
257+
try:
258+
with mock.patch.object(module, "yaml", fake_yaml):
259+
with self.assertRaisesRegex(
260+
module.MetadataLoadError,
261+
"metadata.yaml must contain a mapping at the top level",
262+
):
263+
module.load_metadata(metadata_path)
264+
finally:
265+
os.remove(metadata_path)
266+
246267
def test_load_metadata_uses_simple_parser_when_yaml_unavailable(self):
247268
module = load_validator_module()
248269

@@ -347,6 +368,117 @@ def test_validate_selected_plugins_emits_progress_and_result_lines(self):
347368
print_mock.assert_any_call("[2/2] FAIL plugin-b [metadata] invalid metadata.yaml", flush=True)
348369

349370

371+
class ValidatePluginTests(unittest.TestCase):
372+
def setUp(self):
373+
self.module = load_validator_module()
374+
self.plugin_key = "demo-plugin"
375+
self.plugin_data = {"repo": "https://github.com/example/demo-plugin"}
376+
self.astrbot_path = Path("/tmp/AstrBot")
377+
self.script_path = Path("/tmp/run.py")
378+
self.work_dir = Path("/tmp/work")
379+
380+
def call_validate_plugin(self, plugin_data=None):
381+
return self.module.validate_plugin(
382+
plugin=self.plugin_key,
383+
plugin_data=self.plugin_data if plugin_data is None else plugin_data,
384+
astrbot_path=self.astrbot_path,
385+
script_path=self.script_path,
386+
work_dir=self.work_dir,
387+
clone_timeout=30,
388+
load_timeout=60,
389+
)
390+
391+
def test_missing_repo_field_sets_repo_url_stage(self):
392+
result = self.call_validate_plugin(plugin_data={})
393+
394+
self.assertFalse(result["ok"])
395+
self.assertEqual(result["stage"], "repo_url")
396+
self.assertEqual(result["message"], "missing repo field")
397+
398+
def test_invalid_repo_url_sets_repo_url_stage(self):
399+
with mock.patch.object(self.module, "normalize_repo_url", side_effect=ValueError("invalid repo URL")):
400+
result = self.call_validate_plugin()
401+
402+
self.assertFalse(result["ok"])
403+
self.assertEqual(result["stage"], "repo_url")
404+
self.assertEqual(result["message"], "invalid repo URL")
405+
406+
def test_clone_called_process_error_uses_stderr_or_stdout(self):
407+
error = subprocess.CalledProcessError(
408+
returncode=1,
409+
cmd=["git", "clone"],
410+
output="clone stdout",
411+
stderr="clone stderr",
412+
)
413+
414+
with mock.patch.object(self.module, "clone_plugin_repo", side_effect=error):
415+
result = self.call_validate_plugin()
416+
417+
self.assertFalse(result["ok"])
418+
self.assertEqual(result["stage"], "clone")
419+
self.assertIn("clone stderr", result["message"])
420+
421+
def test_clone_timeout_uses_process_output_details(self):
422+
timeout = subprocess.TimeoutExpired(cmd=["git", "clone"], timeout=30, output="slow", stderr="warning")
423+
424+
with mock.patch.object(self.module, "clone_plugin_repo", side_effect=timeout):
425+
with mock.patch.object(
426+
self.module,
427+
"build_process_output_details",
428+
return_value={"stdout": "slow", "stderr": "warning"},
429+
) as details_mock:
430+
result = self.call_validate_plugin()
431+
432+
self.assertFalse(result["ok"])
433+
self.assertEqual(result["stage"], "clone_timeout")
434+
self.assertEqual(result["details"], {"stdout": "slow", "stderr": "warning"})
435+
details_mock.assert_called_once_with(stdout="slow", stderr="warning")
436+
437+
def test_precheck_failure_is_mapped_into_result(self):
438+
with mock.patch.object(self.module, "clone_plugin_repo"):
439+
with mock.patch.object(
440+
self.module,
441+
"precheck_plugin_directory",
442+
return_value={"ok": False, "stage": "metadata", "message": "invalid metadata", "details": "line 3"},
443+
):
444+
result = self.call_validate_plugin()
445+
446+
self.assertFalse(result["ok"])
447+
self.assertEqual(result["stage"], "metadata")
448+
self.assertEqual(result["message"], "invalid metadata")
449+
self.assertEqual(result["details"], "line 3")
450+
451+
def test_successful_clone_and_precheck_invokes_worker_and_parses_output(self):
452+
completed = subprocess.CompletedProcess(
453+
args=["python3", "run.py"],
454+
returncode=0,
455+
stdout='{"ok": true}',
456+
stderr="",
457+
)
458+
parsed_output = {"ok": True, "stage": "load", "message": "plugin loaded successfully"}
459+
460+
with mock.patch.object(
461+
self.module,
462+
"precheck_plugin_directory",
463+
return_value={"ok": True, "plugin_dir_name": "demo_plugin", "message": "ok", "stage": "precheck"},
464+
) as precheck_mock:
465+
with mock.patch.object(self.module, "clone_plugin_repo"):
466+
with mock.patch.object(subprocess, "run", return_value=completed) as run_mock:
467+
with mock.patch.object(self.module, "parse_worker_output", return_value=parsed_output) as parse_mock:
468+
result = self.call_validate_plugin()
469+
470+
self.assertEqual(result, parsed_output)
471+
precheck_mock.assert_called_once()
472+
run_mock.assert_called_once()
473+
parse_mock.assert_called_once_with(
474+
plugin=self.plugin_key,
475+
repo=self.plugin_data["repo"],
476+
normalized_repo_url=self.plugin_data["repo"],
477+
completed=completed,
478+
plugin_dir_name="demo_plugin",
479+
)
480+
481+
350482
class MetadataValidationTests(unittest.TestCase):
351483
def test_reports_missing_required_metadata_fields(self):
352484
module = load_validator_module()

0 commit comments

Comments
 (0)