#!/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"" 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())