#!/usr/bin/env python3 """Read-only self-test harness for the bitdefender skill. Runs each CLI command as an isolated subprocess and checks exit code + output markers. NO state-changing API calls are made (create/scan/move/ isolate/blocklist-add/delete are only tested in their --confirm-absent refusal path). Prints a PASS/FAIL report. """ from __future__ import annotations import json import os import subprocess import sys HERE = os.path.dirname(os.path.abspath(__file__)) GZ = os.path.join(HERE, "gz.py") ACG = "5c428b246c031893678b4569" # ACG internal company (real) results = [] def run(args): env = dict(os.environ) env.setdefault("CLAUDETOOLS_ROOT", "C:/claudetools") env["PYTHONIOENCODING"] = "utf-8" p = subprocess.run([sys.executable, GZ] + args, capture_output=True, text=True, env=env, timeout=120) return p.returncode, p.stdout, p.stderr def check(name, args, *, want_rc=None, out_has=None, err_has=None, out_json_ok=False, not_out=None): rc, out, err = run(args) problems = [] if want_rc is not None and rc != want_rc: problems.append(f"rc={rc} want {want_rc}") if out_has and out_has not in out: problems.append(f"stdout missing {out_has!r}") if err_has and err_has not in err: problems.append(f"stderr missing {err_has!r}") if not_out and not_out in out: problems.append(f"stdout should NOT contain {not_out!r}") if out_json_ok: try: json.loads(out) except Exception as e: problems.append(f"stdout not valid JSON: {e}") status = "PASS" if not problems else "FAIL" results.append((status, name, "; ".join(problems), (out[:120] + (out[120:] and "...")).replace("\n", " "))) return rc, out, err # --- read commands: should succeed (rc 0) --- check("status table", ["status"], want_rc=0, out_has="apiKey") check("status json", ["status", "--json"], want_rc=0, out_json_ok=True) check("companies table", ["companies"], want_rc=0, out_has="Companies:") check("companies json", ["companies", "--json"], want_rc=0, out_json_ok=True) check("endpoints (real co)", ["endpoints", "--company", ACG], want_rc=0, out_has="Endpoints:") check("endpoints json", ["endpoints", "--company", ACG, "--json"], want_rc=0, out_json_ok=True) check("endpoints bogus parent -> err", ["endpoints", "--company", "bogus"], want_rc=1, err_has="[ERROR]") check("policies", ["policies"], want_rc=0, out_has="Policies:") check("packages", ["packages"], want_rc=0, out_has="Packages:") check("quarantine (real co)", ["quarantine", "--company", ACG], want_rc=0, out_has="Quarantine items:") check("quarantine json", ["quarantine", "--company", ACG, "--json"], want_rc=0, out_json_ok=True) check("blocklist", ["blocklist"], want_rc=0, out_has="Blocklist items:") check("blocklist json", ["blocklist", "--json"], want_rc=0, out_json_ok=True) check("blocklist page2", ["blocklist", "--page", "2", "--per-page", "3"], want_rc=0, out_has="Blocklist items:") check("inventory cached", ["inventory"], want_rc=0, out_has="Inventory cached_at:") # --- error handling: a MALFORMED id (not valid hex/ObjectId) makes the API # error, which must exit non-zero (1). Note: a well-formed but non-existent # hex id is ACCEPTED by GravityZone and returns a stub (rc 0) -- that is the # API's behavior, not a skill bug, so we test with a malformed value here. --- check("endpoint bad id -> rc1", ["endpoint", "bogus"], want_rc=1, err_has="[ERROR]") check("policy bad id -> rc1", ["policy", "bogus"], want_rc=1, err_has="[ERROR]") # --- argparse: missing required arg -> rc2 --- check("quarantine missing --company -> rc2", ["quarantine"], want_rc=2) check("endpoint missing positional -> rc2", ["endpoint"], want_rc=2) # --- gating: destructive without --confirm -> rc3, no API call --- check("isolate no confirm -> rc3", ["isolate", "--endpoints", "x"], want_rc=3, out_has="Would") check("unisolate no confirm -> rc3", ["unisolate", "--endpoints", "x"], want_rc=3) check("blocklist-add no confirm -> rc3", ["blocklist-add", "--company", ACG, "--hashes", "abc"], want_rc=3) check("blocklist-remove no confirm -> rc3", ["blocklist-remove", "--id", "x"], want_rc=3) check("delete-endpoint no confirm -> rc3", ["delete-endpoint", "x"], want_rc=3) check("delete-package no confirm -> rc3", ["delete-package", "--package", "x"], want_rc=3) check("delete-group no confirm -> rc3", ["delete-group", "--group", "x"], want_rc=3) # --- raw gating --- check("raw destructive no confirm -> rc3", ["raw", "--module", "network", "--method", "deleteEndpoint", "--params", "{}"], want_rc=3) check("raw bad json params -> rc2", ["raw", "--module", "general", "--method", "getApiKeyDetails", "--params", "{bad"], want_rc=2) check("raw read ok", ["raw", "--module", "general", "--method", "getApiKeyDetails", "--params", "{}"], want_rc=0) # --- report --- print("\n==== bitdefender skill self-test ====") npass = sum(1 for r in results if r[0] == "PASS") for status, name, prob, sample in results: line = f"[{status}] {name}" if prob: line += f" -> {prob}" print(line) print(f"\n{npass}/{len(results)} passed, {len(results)-npass} failed") sys.exit(0 if npass == len(results) else 1)