diff --git a/AGENTS.md b/AGENTS.md index a47d35b..80ec7b8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,7 +25,7 @@ Use targeted syntax checks before committing: ruby -c plugins/redmine_contacts/lib/redmine_contacts/utils/check_mail.rb ruby -c plugins/redmine_contacts_helpdesk/app/models/helpdesk_mailer.rb ruby -c plugins/redmine_event_outbox/lib/redmine_event_outbox.rb -python3 -m py_compile redmine_outbox_worker.py reset_helpdesk_mail_settings.py validate_test_instance.py +python3 -m py_compile redmine_outbox_worker.py reset_helpdesk_mail_settings.py validate_test_instance.py helpdesk_smoke_test.py cd redMCP && php -l app/RedmineClient.php && composer validate ``` @@ -34,6 +34,7 @@ Dry-run operational helpers before applying changes: ```sh ./reset_helpdesk_mail_settings.py --dry-run ./validate_test_instance.py +./helpdesk_smoke_test.py ./redmine_outbox_worker.py --dry-run --batch-size 10 ``` @@ -51,7 +52,8 @@ behavior. For mail or database helpers, validate with dry runs first, then test against the LAN Redmine copy using controlled projects such as `fud-helpdesk` or `fud-nohelpdesk`. Record notable manual validation in `docs/` when behavior or deployment assumptions change. The post-import workflow lives in -`docs/test_instance_post_import.md`. +`docs/test_instance_post_import.md`; the Helpdesk/redMCP live smoke test is +documented in `docs/helpdesk_smoke_test.md`. ## Commit & Pull Request Guidelines diff --git a/README.md b/README.md index a010b7e..744e28d 100644 --- a/README.md +++ b/README.md @@ -243,6 +243,16 @@ Use the read-only validator to check the test instance without changing it: ./validate_test_instance.py ``` +Run the Helpdesk/redMCP live smoke test after the post-import checks pass: + +```sh +./helpdesk_smoke_test.py +``` + +That test is documented in: + +- [docs/helpdesk_smoke_test.md](/home/iadnah/redmine/docs/helpdesk_smoke_test.md:1) + Preview the affected projects and settings: ```sh diff --git a/docs/helpdesk_smoke_test.md b/docs/helpdesk_smoke_test.md new file mode 100644 index 0000000..705d62c --- /dev/null +++ b/docs/helpdesk_smoke_test.md @@ -0,0 +1,47 @@ +# Helpdesk Smoke Test + +Use `helpdesk_smoke_test.py` to verify that the LAN Redmine copy, Mailpit, and +redMCP work together on real Helpdesk issues. + +The test creates one inbound Helpdesk email for `fud-helpdesk`, triggers +Helpdesk mail import, fetches the created issue through +`RedMCP\RedmineClient::issueWithHelpdesk()`, performs a non-Helpdesk CRUD +control in `fud-nohelpdesk`, sends a Helpdesk reply through the same mailer path +used by the UI's "Send note" checkbox, verifies the outbound message arrives in +Mailpit, and closes the Helpdesk issue. + +## Run + +```sh +./helpdesk_smoke_test.py +``` + +Defaults: + +- Redmine: `http://192.168.50.170` +- Mailpit: `192.168.1.105`, SMTP `1025`, HTTP `8025` +- Helpdesk project: `fud-helpdesk` +- CRUD control project: `fud-nohelpdesk` +- API key source: `REDMINE_API_KEY`, `REDMNINE_API_KEY`, or `redMCP/.env` + +The script runs `./validate_test_instance.py` first. To skip that preflight +while debugging: + +```sh +./helpdesk_smoke_test.py --skip-preflight +``` + +## Expected Result + +The final output should include: + +```text +[OK] redMCP issueWithHelpdesk returned ticket and message context +[OK] redMCP non-Helpdesk CRUD control passed +[OK] Mailpit received outbound Helpdesk/Redmine mail containing the reply token +[OK] Closed smoke-test Helpdesk issue +Smoke test passed. +``` + +Closed smoke-test tickets are intentionally retained in `fud-helpdesk` as audit +evidence. Their subjects start with `[redMCP-smoke ...]`. diff --git a/docs/test_instance_post_import.md b/docs/test_instance_post_import.md index 8ae211f..8374087 100644 --- a/docs/test_instance_post_import.md +++ b/docs/test_instance_post_import.md @@ -123,7 +123,19 @@ php -l redMCP/app/redmineClient.php Use the existing redMCP examples in `redMCP/README.md` for read and CRUD smoke tests against the LAN Redmine copy. -## 7. Re-Run The Read-Only Validator +## 7. Run The Helpdesk Smoke Test + +After the post-import checks pass, run the live Helpdesk/redMCP smoke test: + +```sh +./helpdesk_smoke_test.py +``` + +This imports a controlled Helpdesk email, verifies `issueWithHelpdesk()`, checks +non-Helpdesk CRUD, verifies outbound Mailpit delivery, and closes the created +test ticket. Details are in `docs/helpdesk_smoke_test.md`. + +## 8. Re-Run The Read-Only Validator Finish by running: diff --git a/helpdesk_smoke_test.py b/helpdesk_smoke_test.py new file mode 100755 index 0000000..d1421ea --- /dev/null +++ b/helpdesk_smoke_test.py @@ -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"" + 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())