Add Helpdesk smoke test

This commit is contained in:
Jason Thistlethwaite
2026-04-24 23:15:07 +00:00
parent cdfa298420
commit c0904659e4
5 changed files with 507 additions and 3 deletions
+433
View File
@@ -0,0 +1,433 @@
#!/usr/bin/env python3
"""End-to-end Helpdesk smoke test for the LAN Redmine copy.
The test intentionally creates one Helpdesk issue in the controlled
``fud-helpdesk`` project and leaves it closed as audit evidence.
"""
from __future__ import annotations
import argparse
import json
import os
import smtplib
import subprocess
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
from dataclasses import dataclass
from email.message import EmailMessage
from pathlib import Path
from typing import Any
DEFAULT_REDMINE_URL = "http://192.168.50.170"
DEFAULT_MAILPIT_HOST = "192.168.1.105"
DEFAULT_MAILPIT_HTTP_PORT = 8025
DEFAULT_MAILPIT_SMTP_PORT = 1025
DEFAULT_PROJECT = "fud-helpdesk"
DEFAULT_CONTROL_PROJECT = "fud-nohelpdesk"
DEFAULT_SSH_HOST = "reddev@192.168.50.170"
DEFAULT_SSH_KEY = Path("/tmp/reddev")
DEFAULT_REMOTE_REDMINE = "/usr/share/redmine"
class SmokeError(RuntimeError):
pass
@dataclass(frozen=True)
class SmokeConfig:
redmine_url: str
api_key: str
mailpit_host: str
mailpit_http_port: int
mailpit_smtp_port: int
project: str
control_project: str
ssh_host: str
ssh_key: Path
remote_redmine: str
skip_preflight: bool
keep_open: bool
def main() -> int:
parser = argparse.ArgumentParser(description="Run a live Helpdesk/redMCP smoke test against the LAN Redmine copy.")
parser.add_argument("--redmine-url", default=os.getenv("REDMINE_URL", DEFAULT_REDMINE_URL))
parser.add_argument("--api-key", default=os.getenv("REDMINE_API_KEY") or os.getenv("REDMNINE_API_KEY"))
parser.add_argument("--env-file", type=Path, default=Path("redMCP/.env"))
parser.add_argument("--mailpit-host", default=DEFAULT_MAILPIT_HOST)
parser.add_argument("--mailpit-http-port", type=int, default=DEFAULT_MAILPIT_HTTP_PORT)
parser.add_argument("--mailpit-smtp-port", type=int, default=DEFAULT_MAILPIT_SMTP_PORT)
parser.add_argument("--project", default=DEFAULT_PROJECT)
parser.add_argument("--control-project", default=DEFAULT_CONTROL_PROJECT)
parser.add_argument("--ssh-host", default=os.getenv("REDMINE_SSH_HOST", DEFAULT_SSH_HOST))
parser.add_argument("--ssh-key", type=Path, default=Path(os.getenv("REDMINE_SSH_KEY", str(DEFAULT_SSH_KEY))))
parser.add_argument("--remote-redmine", default=os.getenv("REDMINE_REMOTE_PATH", DEFAULT_REMOTE_REDMINE))
parser.add_argument("--skip-preflight", action="store_true", help="Skip validate_test_instance.py.")
parser.add_argument("--keep-open", action="store_true", help="Do not close the created Helpdesk issue.")
args = parser.parse_args()
env = load_env_file(args.env_file)
redmine_url = args.redmine_url or env.get("REDMINE_URL") or DEFAULT_REDMINE_URL
api_key = args.api_key or env.get("REDMINE_API_KEY") or env.get("REDMNINE_API_KEY")
if not api_key:
print("error: missing Redmine API key. Set REDMINE_API_KEY or redMCP/.env.", file=sys.stderr)
return 1
config = SmokeConfig(
redmine_url=redmine_url.rstrip("/"),
api_key=api_key,
mailpit_host=args.mailpit_host,
mailpit_http_port=args.mailpit_http_port,
mailpit_smtp_port=args.mailpit_smtp_port,
project=args.project,
control_project=args.control_project,
ssh_host=args.ssh_host,
ssh_key=args.ssh_key,
remote_redmine=args.remote_redmine,
skip_preflight=args.skip_preflight,
keep_open=args.keep_open,
)
try:
run_smoke(config)
return 0
except SmokeError as exc:
print(f"error: {exc}", file=sys.stderr)
return 1
def run_smoke(config: SmokeConfig) -> None:
token = time.strftime("%Y%m%d%H%M%S")
subject = f"[redMCP-smoke {token}] Helpdesk inbound validation"
from_address = f"redmcp-smoke-{token}@example.test"
reply_token = f"redMCP outbound smoke reply {token}"
if not config.skip_preflight:
run_checked(["./validate_test_instance.py"], "preflight validator")
before_ids = set(mailpit_message_ids(config))
send_inbound_email(config, from_address, subject, token)
print(f"[OK] Sent inbound Mailpit message: {subject}")
trigger_helpdesk_import(config)
issue_id = wait_for_issue(config, subject)
print(f"[OK] Helpdesk imported issue #{issue_id}")
context = redmcp_call(config, "issueWithHelpdesk", {"issue_id": issue_id, "limit": 20})
assert_helpdesk_context(context, issue_id, from_address, subject, require_messages=False)
print("[OK] redMCP issueWithHelpdesk returned Helpdesk ticket context")
control = redmcp_call(config, "createControlIssue", {"project": config.control_project, "token": token})
control_id = int(control["id"])
redmcp_call(config, "updateIssue", {"issue_id": control_id, "fields": {"notes": f"redMCP control update {token}"}})
redmcp_call(config, "deleteIssue", {"issue_id": control_id})
print(f"[OK] redMCP non-Helpdesk CRUD control passed with issue #{control_id}")
send_helpdesk_reply(config, issue_id, reply_token)
wait_for_mailpit_text(config, before_ids, reply_token)
print("[OK] Mailpit received outbound Helpdesk/Redmine mail containing the reply token")
context = redmcp_call(config, "issueWithHelpdesk", {"issue_id": issue_id, "limit": 20})
assert_helpdesk_context(context, issue_id, from_address, subject, require_messages=True)
print("[OK] redMCP issueWithHelpdesk returned post-reply journal message context")
if not config.keep_open:
redmcp_call(
config,
"updateIssue",
{"issue_id": issue_id, "fields": {"status_id": 5, "notes": f"Closed by redMCP smoke test {token}"}},
)
print(f"[OK] Closed smoke-test Helpdesk issue #{issue_id}")
else:
print(f"[WARN] Left smoke-test Helpdesk issue #{issue_id} open because --keep-open was passed")
print("\nSmoke test passed.")
print(f" issue_id: {issue_id}")
print(f" subject: {subject}")
def load_env_file(path: Path) -> dict[str, str]:
values: dict[str, str] = {}
if not path.exists():
return values
for raw_line in path.read_text(encoding="utf-8").splitlines():
line = raw_line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
values[key.strip()] = value.strip().strip('"').strip("'")
return values
def send_inbound_email(config: SmokeConfig, from_address: str, subject: str, token: str) -> None:
message = EmailMessage()
message["From"] = f"redMCP Smoke <{from_address}>"
message["To"] = f"helpdesk-{config.project}@example.test"
message["Subject"] = subject
message["Message-ID"] = f"<redmcp-smoke-{token}@example.test>"
message.set_content(
"\n".join(
[
"This is an automated redMCP Helpdesk smoke-test message.",
f"Smoke token: {token}",
"The test validates Helpdesk import, redMCP enrichment, and outbound delivery.",
]
)
)
with smtplib.SMTP(config.mailpit_host, config.mailpit_smtp_port, timeout=10) as smtp:
smtp.send_message(message)
def trigger_helpdesk_import(config: SmokeConfig) -> None:
ruby = (
f"project = Project.find_by_identifier({ruby_string(config.project)}); "
"raise 'project not found' unless project; "
"HelpdeskMailer.check_project(project.id); "
"puts 'ok'"
)
result = ssh_redmine(config, f"bin/rails runner -e production {shell_quote(ruby)}")
if result.returncode != 0:
raise SmokeError("Helpdesk import failed: " + (result.stderr or result.stdout).strip())
def send_helpdesk_reply(config: SmokeConfig, issue_id: int, content: str) -> None:
ruby = (
"issue = Issue.find(%d); "
"user = User.find_by_login('rebot') || User.find_by_login('admin') || User.find(1); "
"User.current = user; "
"raise 'issue has no Helpdesk customer' if issue.customer.nil?; "
"journal = issue.init_journal(user); "
"journal.notes = %s; "
"issue.save!; "
"contact = issue.customer; "
"HelpdeskMailer.with_activated_perform_deliveries do; "
"msg = HelpdeskMailer.issue_response(contact, journal, {}).deliver; "
"JournalMessage.create!(:from_address => '', "
":to_address => contact.primary_email.downcase, "
":is_incoming => false, "
":message_date => Time.now, "
":message_id => msg.message_id.to_s.slice(0, 255), "
":source => HelpdeskTicket::HELPDESK_EMAIL_SOURCE, "
":contact => contact, "
":journal => journal); "
"end; "
"puts 'ok'"
) % (issue_id, ruby_string(content))
result = ssh_redmine(config, f"bin/rails runner -e production {shell_quote(ruby)}")
if result.returncode != 0:
raise SmokeError("Helpdesk reply failed: " + (result.stderr or result.stdout).strip())
def wait_for_issue(config: SmokeConfig, subject: str, timeout: int = 60) -> int:
deadline = time.time() + timeout
while time.time() < deadline:
issue_id = find_issue_id(config, subject)
if issue_id is not None:
return issue_id
time.sleep(2)
raise SmokeError(f"Timed out waiting for Helpdesk issue with subject {subject!r}")
def find_issue_id(config: SmokeConfig, subject: str) -> int | None:
sql = (
"SELECT i.id "
"FROM issues i JOIN projects p ON p.id = i.project_id "
f"WHERE p.identifier = {sql_string(config.project)} "
f"AND i.subject = {sql_string(subject)} "
"ORDER BY i.id DESC LIMIT 1;"
)
output = remote_mysql(config, sql).strip()
if not output:
return None
return int(output.splitlines()[0])
def remote_mysql(config: SmokeConfig, sql: str) -> str:
ruby = (
"require 'yaml'; "
"c = YAML.load_file('config/database.yml')['production']; "
"ENV['MYSQL_PWD'] = c['password'].to_s; "
"args = ['--batch', '--raw', '--quick', '--skip-column-names', "
"'--default-character-set=utf8', '-h', c['host'].to_s, "
"'-P', (c['port'] || 3306).to_s, '-u', c['username'].to_s, c['database'].to_s]; "
"exec('mysql', *args)"
)
command = [
"ssh",
"-i",
str(config.ssh_key),
"-o",
"IdentitiesOnly=yes",
config.ssh_host,
f"cd {shell_quote(config.remote_redmine)} && ruby -e {shell_quote(ruby)}",
]
result = subprocess.run(command, input=sql, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False)
if result.returncode != 0:
raise SmokeError("Remote MySQL query failed: " + (result.stderr or result.stdout).strip())
return result.stdout
def redmcp_call(config: SmokeConfig, action: str, payload: dict[str, Any]) -> dict[str, Any]:
php = r'''
require getcwd() . '/redMCP/vendor/autoload.php';
use RedMCP\RedmineClient;
$input = json_decode(stream_get_contents(STDIN), true);
$client = RedmineClient::fromCredentials($input['redmine_url'], $input['api_key']);
$payload = $input['payload'];
switch ($input['action']) {
case 'issueWithHelpdesk':
$result = $client->issueWithHelpdesk((int) $payload['issue_id'], (int) $payload['limit']);
break;
case 'createControlIssue':
$result = $client->createIssue([
'project_id' => $payload['project'],
'subject' => '[redMCP-smoke ' . $payload['token'] . '] Non-Helpdesk CRUD control',
'description' => 'Created by the redMCP Helpdesk smoke test.',
'tracker_id' => 3,
]);
break;
case 'updateIssue':
$client->updateIssue((int) $payload['issue_id'], $payload['fields']);
$result = ['ok' => true];
break;
case 'deleteIssue':
$client->deleteIssue((int) $payload['issue_id']);
$result = ['ok' => true];
break;
default:
throw new RuntimeException('Unknown redMCP smoke action: ' . $input['action']);
}
echo json_encode($result, JSON_UNESCAPED_SLASHES);
'''
request = {
"redmine_url": config.redmine_url,
"api_key": config.api_key,
"action": action,
"payload": payload,
}
result = subprocess.run(
["php", "-r", php],
input=json.dumps(request),
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
check=False,
)
if result.returncode != 0:
raise SmokeError(f"redMCP {action} failed: " + (result.stderr or result.stdout).strip())
try:
decoded = json.loads(result.stdout)
except json.JSONDecodeError as exc:
raise SmokeError(f"redMCP {action} returned invalid JSON: {result.stdout[:500]}") from exc
if not isinstance(decoded, dict):
raise SmokeError(f"redMCP {action} returned unexpected JSON")
return decoded
def assert_helpdesk_context(
context: dict[str, Any],
issue_id: int,
from_address: str,
subject: str,
require_messages: bool,
) -> None:
issue = context.get("issue")
helpdesk = context.get("helpdesk")
if not isinstance(issue, dict) or int(issue.get("id", 0)) != issue_id:
raise SmokeError("redMCP context did not include the expected issue")
if issue.get("subject") != subject:
raise SmokeError("redMCP context issue subject did not match the smoke-test subject")
if not isinstance(helpdesk, dict) or helpdesk.get("available") is not True:
raise SmokeError("redMCP context did not mark Helpdesk data as available")
ticket = helpdesk.get("ticket")
if not isinstance(ticket, dict):
raise SmokeError("redMCP context did not include Helpdesk ticket metadata")
messages = helpdesk.get("journal_messages")
if not isinstance(messages, list):
raise SmokeError("redMCP context did not include a Helpdesk journal message list")
if require_messages and not messages:
raise SmokeError("redMCP context did not include post-reply Helpdesk journal messages")
text = json.dumps(helpdesk, sort_keys=True)
if from_address not in text:
raise SmokeError(f"redMCP Helpdesk context did not include sender {from_address}")
def mailpit_message_ids(config: SmokeConfig) -> list[str]:
data = mailpit_json(config, "/api/v1/messages", {"limit": "200"})
messages = data.get("messages", []) if isinstance(data, dict) else []
return [message["ID"] for message in messages if isinstance(message, dict) and "ID" in message]
def wait_for_mailpit_text(config: SmokeConfig, before_ids: set[str], needle: str, timeout: int = 60) -> None:
deadline = time.time() + timeout
while time.time() < deadline:
for message_id in mailpit_message_ids(config):
if message_id in before_ids:
continue
message = mailpit_json(config, f"/api/v1/message/{message_id}", {})
if needle in json.dumps(message):
return
time.sleep(2)
raise SmokeError(f"Timed out waiting for Mailpit message containing {needle!r}")
def mailpit_json(config: SmokeConfig, path: str, params: dict[str, str]) -> dict[str, Any]:
query = urllib.parse.urlencode(params)
url = f"http://{config.mailpit_host}:{config.mailpit_http_port}{path}"
if query:
url += "?" + query
try:
with urllib.request.urlopen(url, timeout=10) as response:
return json.loads(response.read().decode("utf-8"))
except (urllib.error.URLError, json.JSONDecodeError) as exc:
raise SmokeError(f"Mailpit API failed for {path}: {exc}") from exc
def ssh_redmine(config: SmokeConfig, command: str) -> subprocess.CompletedProcess[str]:
return subprocess.run(
[
"ssh",
"-i",
str(config.ssh_key),
"-o",
"IdentitiesOnly=yes",
config.ssh_host,
f"cd {shell_quote(config.remote_redmine)} && {command}",
],
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
check=False,
)
def run_checked(command: list[str], label: str) -> None:
result = subprocess.run(command, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False)
if result.returncode != 0:
raise SmokeError(f"{label} failed:\n{result.stdout}{result.stderr}")
print(f"[OK] {label}")
def shell_quote(value: str) -> str:
return "'" + value.replace("'", "'\"'\"'") + "'"
def ruby_string(value: str) -> str:
return json.dumps(value)
def sql_string(value: str) -> str:
return "'" + value.replace("\\", "\\\\").replace("'", "''") + "'"
if __name__ == "__main__":
raise SystemExit(main())