mirror of
https://github.com/magnus919/agent-skills.git
synced 2026-09-14 13:06:30 +03:00
3256a87bcb
Adds five top-level operational tool skills, one per named tool: - slack: messages, channels, threads, search, files, and webhook signature verification (HMAC-SHA256) via a bounded, stdlib-only slack-cli. - notion: pages, database queries, search, and guarded page updates via notion-cli. - email: transactional email via Twilio SendGrid (send, deliverability bounces/spam reports, Signed Event Webhook verification with a self-contained ECDSA P-256 verifier) via email-cli. - crm: HubSpot CRM records, contact search, and deal pipeline views with guarded stage updates via crm-cli. - stripe: read-only-first balance, payment, and subscription queries with a guarded period-end subscription cancellation via stripe-cli. Each skill ships an executable script (--json output, --limit bounded reads, --dry-run/--yes mutation gate), a human README with the five required sections, a schema-v1 evals/evals.json with six output-quality cases, a dated source index + operations reference, and a deterministic unittest suite run by check-artifacts. All five are indexed in the top-level README and the generated catalogs were regenerated. Eval coverage rises from 78/139 to 83/144. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
291 lines
12 KiB
Python
291 lines
12 KiB
Python
#!/usr/bin/env python3
|
|
"""Deterministic tests for email/scripts/email-cli.
|
|
|
|
Runs the script as a subprocess so the tests exercise the real CLI surface
|
|
(--help, --json, --limit, mutation gate, exit codes, JSON payloads). A local
|
|
stdlib HTTP server stubs the SendGrid v3 API (mail/send, suppression
|
|
bounces/spam_reports). Webhook signature verification uses fixed ECDSA P-256
|
|
test vectors generated offline with OpenSSL (independent of this
|
|
implementation), so the valid-signature path is cross-checked against a
|
|
reference signing tool. No external network is needed.
|
|
"""
|
|
import base64
|
|
import json
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
import threading
|
|
import unittest
|
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
from pathlib import Path
|
|
|
|
ROOT = Path(__file__).resolve().parent.parent
|
|
SCRIPT = ROOT / "scripts" / "email-cli"
|
|
|
|
# Fixed ECDSA P-256 vectors: signature over SHA256("1712345678" + body)
|
|
# generated with OpenSSL (openssl dgst -sha256 -sign) as an independent
|
|
# cross-check of the stdlib verifier embedded in email-cli.
|
|
WEBHOOK_PUBLIC_KEY = """-----BEGIN PUBLIC KEY-----
|
|
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEwOVzJT7nlfguzr/GSiBh0EAp7fQX
|
|
YqMhYoJ0lChYC6LnXnC1y5BQiF5SxM7tjj69z9NeMnM9fQvZ4h65V0AYaQ==
|
|
-----END PUBLIC KEY-----
|
|
"""
|
|
WEBHOOK_BODY = b'{"RecordType":"Delivery","MessageID":"m1"}'
|
|
WEBHOOK_TIMESTAMP = "1712345678"
|
|
WEBHOOK_SIGNATURE = "MEUCID/YPP4118Tr+6IPyV+OlrV0IktxgRWcQk+a2BOKkeP/AiEA7XPfz1DejTxPuCXkFCnXEPhSPy4L2nDwuvC20TZBiZ8="
|
|
|
|
BOUNCE = {"email": "bad@example.com", "created": "2026-08-01T00:00:00Z",
|
|
"reason": "550 invalid mailbox", "status": "5.1.1"}
|
|
SPAM = {"email": "spam@example.com", "created": "2026-08-01T00:00:00Z",
|
|
"ip": "1.2.3.4", "reason": "user complaint"}
|
|
|
|
|
|
class StubSendGridServer:
|
|
"""Minimal stub of the SendGrid v3 API surface used by email-cli."""
|
|
|
|
def __init__(self):
|
|
self.requests = [] # (method, path, body)
|
|
handler = self._make_handler()
|
|
self.server = ThreadingHTTPServer(("127.0.0.1", 0), handler)
|
|
self.port = self.server.server_address[1]
|
|
self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
|
|
|
|
def _make_handler(self):
|
|
stub = self
|
|
|
|
class Handler(BaseHTTPRequestHandler):
|
|
def _read_body(self):
|
|
length = int(self.headers.get("Content-Length", "0"))
|
|
raw = self.rfile.read(length)
|
|
return json.loads(raw.decode("utf-8")) if raw else {}
|
|
|
|
def _json(self, payload, status=200):
|
|
self.send_response(status)
|
|
self.send_header("Content-Type", "application/json")
|
|
self.end_headers()
|
|
self.wfile.write(json.dumps(payload).encode("utf-8"))
|
|
|
|
def do_GET(self): # noqa: N802
|
|
stub.requests.append(("GET", self.path, None))
|
|
if "/suppression/bounces" in self.path:
|
|
self._json([BOUNCE])
|
|
elif "/suppression/spam_reports" in self.path:
|
|
self._json([SPAM])
|
|
else:
|
|
self._json({"errors": [{"message": "not_found"}]}, 404)
|
|
|
|
def do_POST(self): # noqa: N802
|
|
body = self._read_body()
|
|
stub.requests.append(("POST", self.path, body))
|
|
if self.path == "/v3/mail/send":
|
|
self.send_response(202)
|
|
self.send_header("X-Message-Id", "msg-test-0001")
|
|
self.end_headers()
|
|
else:
|
|
self._json({"errors": [{"message": "not_found"}]}, 404)
|
|
|
|
def log_message(self, *args): # silence stderr
|
|
pass
|
|
|
|
return Handler
|
|
|
|
def __enter__(self):
|
|
self.thread.start()
|
|
return self
|
|
|
|
def __exit__(self, *exc):
|
|
self.server.shutdown()
|
|
self.server.server_close()
|
|
|
|
|
|
def run_script(env, *args):
|
|
return subprocess.run(
|
|
[sys.executable, str(SCRIPT), *args],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=30,
|
|
env=env,
|
|
)
|
|
|
|
|
|
def base_env(stub):
|
|
env = dict(os.environ)
|
|
env["SENDGRID_API_KEY"] = "SG.test-key"
|
|
env["SENDGRID_API_BASE"] = f"http://127.0.0.1:{stub.port}/v3"
|
|
return env
|
|
|
|
|
|
def load_json(proc):
|
|
return json.loads(proc.stdout)
|
|
|
|
|
|
def write_webhook_files(tmpdir):
|
|
body_file = tmpdir / "webhook-body.json"
|
|
key_file = tmpdir / "public-key.pem"
|
|
body_file.write_bytes(WEBHOOK_BODY)
|
|
key_file.write_text(WEBHOOK_PUBLIC_KEY)
|
|
return body_file, key_file
|
|
|
|
|
|
class EmailCliTests(unittest.TestCase):
|
|
def test_help_lists_json_and_bounded_reads(self):
|
|
proc = run_script(dict(os.environ), "--help")
|
|
self.assertEqual(proc.returncode, 0)
|
|
self.assertIn("--json", proc.stdout)
|
|
self.assertIn("--limit", proc.stdout)
|
|
|
|
def test_help_works_without_api_key(self):
|
|
env = dict(os.environ)
|
|
env.pop("SENDGRID_API_KEY", None)
|
|
proc = run_script(env, "deliverability", "bounces", "--help")
|
|
self.assertEqual(proc.returncode, 0)
|
|
|
|
def test_send_requires_confirmation(self):
|
|
with StubSendGridServer() as stub:
|
|
proc = run_script(base_env(stub), "send", "--to", "a@example.com",
|
|
"--from", "b@example.com", "--subject", "Hi", "--body", "Hello")
|
|
self.assertEqual(proc.returncode, 1)
|
|
self.assertIn("refusing to send", proc.stderr)
|
|
self.assertEqual(stub.requests, [], "no API call may be made without confirmation")
|
|
|
|
def test_send_dry_run_does_not_post(self):
|
|
with StubSendGridServer() as stub:
|
|
proc = run_script(base_env(stub), "--json", "send", "--to", "a@example.com",
|
|
"--from", "b@example.com", "--subject", "Hi", "--body", "Hello",
|
|
"--dry-run")
|
|
self.assertEqual(proc.returncode, 0, proc.stderr)
|
|
data = load_json(proc)
|
|
self.assertTrue(data["dry_run"])
|
|
self.assertEqual(stub.requests, [], "dry-run must not reach the API")
|
|
|
|
def test_send_with_yes_posts(self):
|
|
with StubSendGridServer() as stub:
|
|
proc = run_script(base_env(stub), "--json", "send", "--to", "a@example.com",
|
|
"--from", "b@example.com", "--subject", "Hi", "--body", "Hello",
|
|
"--yes")
|
|
self.assertEqual(proc.returncode, 0, proc.stderr)
|
|
data = load_json(proc)
|
|
self.assertEqual(data["message_id"], "msg-test-0001")
|
|
posts = [body for method, path, body in stub.requests
|
|
if method == "POST" and path == "/v3/mail/send"]
|
|
self.assertEqual(len(posts), 1)
|
|
self.assertEqual(posts[0]["personalizations"][0]["to"][0]["email"], "a@example.com")
|
|
|
|
def test_deliverability_bounces(self):
|
|
with StubSendGridServer() as stub:
|
|
proc = run_script(base_env(stub), "--json", "--limit", "5", "deliverability", "bounces")
|
|
self.assertEqual(proc.returncode, 0, proc.stderr)
|
|
data = load_json(proc)
|
|
self.assertEqual(data["kind"], "bounces")
|
|
self.assertEqual(data["items"][0]["email"], "bad@example.com")
|
|
|
|
def test_deliverability_spam_reports(self):
|
|
with StubSendGridServer() as stub:
|
|
proc = run_script(base_env(stub), "--json", "--limit", "5", "deliverability", "spam-reports")
|
|
self.assertEqual(proc.returncode, 0, proc.stderr)
|
|
data = load_json(proc)
|
|
self.assertEqual(data["kind"], "spam_reports")
|
|
self.assertEqual(data["items"][0]["email"], "spam@example.com")
|
|
|
|
def test_deliverability_limit_is_bounded_in_request(self):
|
|
with StubSendGridServer() as stub:
|
|
run_script(base_env(stub), "--json", "--limit", "7", "deliverability", "bounces")
|
|
gets = [path for method, path, _ in stub.requests if method == "GET"]
|
|
self.assertTrue(any("/suppression/bounces?limit=7" in path for path in gets))
|
|
|
|
def test_webhook_verify_valid_signature(self):
|
|
tmp = ROOT / "tests"
|
|
body_file, key_file = write_webhook_files(tmp)
|
|
try:
|
|
proc = run_script(dict(os.environ), "--json", "webhook", "verify",
|
|
"--body-file", str(body_file), "--signature", WEBHOOK_SIGNATURE,
|
|
"--timestamp", WEBHOOK_TIMESTAMP,
|
|
"--public-key-file", str(key_file), "--max-age", "0")
|
|
finally:
|
|
body_file.unlink(missing_ok=True)
|
|
key_file.unlink(missing_ok=True)
|
|
self.assertEqual(proc.returncode, 0, proc.stderr)
|
|
self.assertTrue(load_json(proc)["verified"])
|
|
|
|
def test_webhook_verify_rejects_tampered_body(self):
|
|
tmp = ROOT / "tests"
|
|
body_file = tmp / "webhook-body.json"
|
|
key_file = tmp / "public-key.pem"
|
|
body_file.write_bytes(b'{"RecordType":"Delivery","MessageID":"FORGED"}')
|
|
key_file.write_text(WEBHOOK_PUBLIC_KEY)
|
|
try:
|
|
proc = run_script(dict(os.environ), "--json", "webhook", "verify",
|
|
"--body-file", str(body_file), "--signature", WEBHOOK_SIGNATURE,
|
|
"--timestamp", WEBHOOK_TIMESTAMP,
|
|
"--public-key-file", str(key_file), "--max-age", "0")
|
|
finally:
|
|
body_file.unlink(missing_ok=True)
|
|
key_file.unlink(missing_ok=True)
|
|
self.assertEqual(proc.returncode, 1)
|
|
self.assertIn("does not match", proc.stdout)
|
|
|
|
def test_webhook_verify_rejects_wrong_key(self):
|
|
tmp = ROOT / "tests"
|
|
body_file, key_file = write_webhook_files(tmp)
|
|
other_key = """-----BEGIN PUBLIC KEY-----
|
|
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEefPWWSDlPHw0mRIqmQHcvmNx6ArM
|
|
SCPBuBGQ9ObEdv7Wyfnu/T99Aa1eD3Nfd3YG+WKUvNUEw5qXX2EXJIP/Kw==
|
|
-----END PUBLIC KEY-----
|
|
"""
|
|
key_file.write_text(other_key)
|
|
try:
|
|
proc = run_script(dict(os.environ), "--json", "webhook", "verify",
|
|
"--body-file", str(body_file), "--signature", WEBHOOK_SIGNATURE,
|
|
"--timestamp", WEBHOOK_TIMESTAMP,
|
|
"--public-key-file", str(key_file), "--max-age", "0")
|
|
finally:
|
|
body_file.unlink(missing_ok=True)
|
|
key_file.unlink(missing_ok=True)
|
|
self.assertEqual(proc.returncode, 1)
|
|
self.assertIn("does not match", proc.stdout)
|
|
|
|
def test_webhook_verify_rejects_stale_timestamp(self):
|
|
tmp = ROOT / "tests"
|
|
body_file, key_file = write_webhook_files(tmp)
|
|
stale = str(int(__import__("time").time()) - 3600)
|
|
try:
|
|
proc = run_script(dict(os.environ), "--json", "webhook", "verify",
|
|
"--body-file", str(body_file), "--signature", WEBHOOK_SIGNATURE,
|
|
"--timestamp", stale, "--public-key-file", str(key_file))
|
|
finally:
|
|
body_file.unlink(missing_ok=True)
|
|
key_file.unlink(missing_ok=True)
|
|
self.assertEqual(proc.returncode, 1)
|
|
self.assertIn("replay window", proc.stdout)
|
|
|
|
def test_webhook_verify_rejects_malformed_signature(self):
|
|
tmp = ROOT / "tests"
|
|
body_file, key_file = write_webhook_files(tmp)
|
|
try:
|
|
proc = run_script(dict(os.environ), "--json", "webhook", "verify",
|
|
"--body-file", str(body_file), "--signature", "not-base64!!",
|
|
"--timestamp", WEBHOOK_TIMESTAMP,
|
|
"--public-key-file", str(key_file), "--max-age", "0")
|
|
finally:
|
|
body_file.unlink(missing_ok=True)
|
|
key_file.unlink(missing_ok=True)
|
|
self.assertEqual(proc.returncode, 1)
|
|
self.assertIn("base64", proc.stdout)
|
|
|
|
def test_missing_api_key_errors_cleanly(self):
|
|
env = dict(os.environ)
|
|
env.pop("SENDGRID_API_KEY", None)
|
|
proc = run_script(env, "--json", "deliverability", "bounces")
|
|
self.assertEqual(proc.returncode, 1)
|
|
self.assertIn("SENDGRID_API_KEY", proc.stdout)
|
|
|
|
def test_read_only_contract_no_write_opens(self):
|
|
source = SCRIPT.read_text()
|
|
writes = [line for line in source.splitlines()
|
|
if line.strip().startswith("open(") and ("'w'" in line or '"w"' in line)]
|
|
self.assertEqual(writes, [], "script must never open files in write mode")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|