Files
magnus919_agent-skills/email/tests/test_email_cli.py
T
Magnus HedemarkGitHubfactory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
3256a87bcb feat(skill): add collaboration & business-app tool layer (Slack, Notion, email, CRM, payments) (#269)
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>
2026-08-03 20:19:26 -04:00

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()