diff --git a/flake.nix b/flake.nix index 3c1f472..b024d57 100644 --- a/flake.nix +++ b/flake.nix @@ -463,7 +463,8 @@ inherit self; inherit (inputs) nixpkgs; }); - "dns/python-env" = dns.passthru.python; + "dns/python-env" = builtins.dirOf (builtins.dirOf dns.interpreter); + "mailtest/python-env" = builtins.dirOf (builtins.dirOf self.checks.x86_64-linux.liam.nodes.checker.vacu.mailtest.smtp.interpreter); }; haproxy-auth-request = pkgs.callPackage ./packages/haproxy-auth-request.nix { inherit haproxy-lua-http; diff --git a/pyproject.toml b/pyproject.toml index 4d4337b..550e235 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,3 +15,7 @@ extraPaths = [ [[tool.pyright.executionEnvironments]] root = "scripts/dns" extraPaths = [ ".generated/dns/python-env/lib/python3.12/site-packages" ] + +[[tool.pyright.executionEnvironments]] +root = "tests/liam/mailtest" +extraPaths = [ ".generated/mailtest/python-env/lib/python3.12/site-packages" ] diff --git a/tests/liam/default.nix b/tests/liam/default.nix index 60eba08..eac3114 100644 --- a/tests/liam/default.nix +++ b/tests/liam/default.nix @@ -69,6 +69,31 @@ let mkdir -p $out/liam SOPS_AGE_KEY="${testAgeSecret}" ${pkgs.sops}/bin/sops --verbose -e --age "$(echo "${testAgeSecret}" | ${pkgs.age}/bin/age-keygen -y)" ${sopsTestSecretsYaml} --output-type yaml > $out/liam/main.yaml ''; + mailtestModule = { + config, + pkgs, + lib, + ... + }: + let + libraries = with pkgs.python3Packages; [ + imap-tools + requests + ]; + mkPkg = name: pkgs.writers.writePython3Bin "mailtest-${name}" { inherit libraries; } '' + # flake8: noqa + ${builtins.readFile ./mailtest/${name}.py} + ''; + in + { + options.vacu.mailtest = lib.mkOption { readOnly = true; }; + config.vacu.mailtest = { + smtp = mkPkg "smtp"; + imap = mkPkg "imap"; + mailpit = mkPkg "mailpit"; + }; + config.environment.systemPackages = [ config.vacu.mailtest.smtp config.vacu.mailtest.imap ]; + }; in { name = "liam-receives-mail"; @@ -185,10 +210,9 @@ in nodes.checker = { pkgs, lib, ... }: { + imports = [ mailtestModule ]; environment.systemPackages = [ pkgs.wget - pkgs.python311Packages.imap-tools - pkgs.python311 (pkgs.writers.writePython3Bin "mailtest" { libraries = with pkgs.python3Packages; [ diff --git a/tests/liam/mailtest/imap.py b/tests/liam/mailtest/imap.py new file mode 100644 index 0000000..64a07de --- /dev/null +++ b/tests/liam/mailtest/imap.py @@ -0,0 +1,120 @@ +import imaplib +import imap_tools +import argparse +import json +from .util import * +from typing import NamedTuple + +parser = argparse.ArgumentParser() +parser.add_argument("host", type=str) +parser.add_argument("--imap-insecure", default=False, action="store_true") +parser.add_argument("--imap-move-to") +parser.add_argument("--imap-dir", default=None) +parser.add_argument("--username") +parser.add_argument("--password") +parser.add_argument("--message-magic", type=str) + +args = parser.parse_args() +msg_magic = args.message_magic + +info(f"got args {args!r}") + +username = args.username +password = args.password +if password is None: + password = username + +MessageInFolder = NamedTuple( + "MessageInFolder", [("message", imap_tools.message.MailMessage), ("folder", str)] +) + +info(f"looking for {msg_magic}") +result = "" +matching_messages = [] +try: + def connection() -> imap_tools.BaseMailBox: + return imap_tools.MailBox(args.host, ssl_context=mk_ctx()).login( + username, password + ) + + def find_messages(mailbox: imap_tools.BaseMailBox) -> list[MessageInFolder]: + matching_messages = [] + directories = [] + for d in mailbox.folder.list(): + if "\\Noselect" not in d.flags: + directories.append(d.name) + # info(f"directories is {directories!r}") + for imap_dir in directories: + info(f"checking in {imap_dir!r}") + mailbox.folder.set(imap_dir) + # except imap_tools.errors.MailboxFolderSelectError as e: + # # info(f"failed to select folder {e!r}") + # continue + for msg in mailbox.fetch(mark_seen=False): + if "\\Deleted" in msg.flags: + continue + # info(f"found message {msg.uid!r} with text {msg.text!r} in folder {imap_dir!r}") + msg_str = msg.obj.as_string() + info(f"flags: {msg.flags!r}") + info(f"{msg_str}") + if msg_magic == msg.text.strip(): + in_folder = MessageInFolder(message=msg, folder=imap_dir) + matching_messages.append(in_folder) + return matching_messages + + if args.imap_move_to is not None: + with connection() as mailbox: + info("prefind") + prefind = find_messages(mailbox) + assert len(prefind) > 0, "Could not find message to move anywhere" + assert len(prefind) == 1, "Found duplicate messages" + mailbox.folder.set(prefind[0].folder) + msg = prefind[0].message + uid = msg.uid + assert uid is not None + info(f"about to move {uid} to {args.imap_move_to}") + res = mailbox.move(uid, args.imap_move_to) + assert res[1][1][1] is not None, "failed to move" # type: ignore + info(f"done moving, res {res!r}") + + with connection() as mailbox: + matching_messages = find_messages(mailbox) + if args.expect == "received": + # print(f"{matching_messages!r}") + assert ( + len(matching_messages) > 0 + ), "Could not find the message in the mailbox" + assert ( + len(matching_messages) == 1 + ), f"Multiple messages matching message magic {msg_magic}" + matching_mif = matching_messages[0] + if args.imap_dir is not None: + expected_dir = args.imap_dir + actual_dir = matching_mif.folder + assert ( + expected_dir == actual_dir + ), f"Expected to find message in {expected_dir}, found it in {actual_dir} instead" + matching_message = matching_mif.message + for expected_flag in args.expect_flag: + assert ( + expected_flag in matching_message.flags + ), f"Flag {expected_flag} not found, message flags: {matching_message.flags!r}" + +except imaplib.IMAP4.error as e: + info(f"IMAP error {e!r}") + result = "error" + assert args.expect == "imap_error", f"IMAP error: {e}" +else: + result = "success" + +def mail_to_jsonish(m: MessageInFolder) -> dict: + return { + "folder": m.folder, + "flags": m.message.flags, + "body": m.message.text.strip(), + } + +print_json( + result = result, + messages = [mail_to_jsonish(m) for m in matching_messages], +) diff --git a/tests/liam/mailtest/smtp.py b/tests/liam/mailtest/smtp.py new file mode 100644 index 0000000..d4dcb64 --- /dev/null +++ b/tests/liam/mailtest/smtp.py @@ -0,0 +1,54 @@ +import smtplib +import argparse +import json +from .util import * + +parser = argparse.ArgumentParser() +parser.add_argument("host", type=str) +parser.add_argument("--mailfrom", default="foo@example.com") +parser.add_argument("--rcptto", default="awesome@vacu.store") +parser.add_argument("--subject", default="Some test message") +parser.add_argument("--header", action="append", default=[]) +parser.add_argument("--submission", default=False, action="store_true") +parser.add_argument("--smtp-starttls", default=None, action="store_true") +parser.add_argument("--username") +parser.add_argument("--password") +parser.add_argument("--message-magic", type=str) + +args = parser.parse_args() + +info(f"got args {args!r}") + +args.header.append(f"Subject: {args.subject}") + + +username = args.username +password = args.password +if password is None: + password = username + +result = "" + +try: + smtp = None + if args.submission: + smtp = smtplib.SMTP_SSL(args.host, port=465, context=mk_ctx()) + else: + smtp = smtplib.SMTP(args.host, port=25) + smtp.ehlo() + if args.smtp_starttls: + smtp.starttls(context=mk_ctx()) + smtp.ehlo() + if args.submission: + smtp.login(username, password) + headers = "\n".join(args.header) + smtp.sendmail(args.mailfrom, args.rcptto, f"{headers}\n\n{args.message_magic}") + smtp.close() +except smtplib.SMTPRecipientsRefused: + result = False +except smtplib.SMTPSenderRefused: + result = False +else: + result = True + +print_json(result = result) diff --git a/tests/liam/mailtest/util.py b/tests/liam/mailtest/util.py new file mode 100644 index 0000000..a6f7b42 --- /dev/null +++ b/tests/liam/mailtest/util.py @@ -0,0 +1,15 @@ +import sys +import ssl +import json + +def info(msg: str): + print(msg, file=sys.stderr) + +def mk_ctx(): + ctx = ssl.create_default_context() + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + return ctx + +def print_json(**kwargs): + print(json.dumps(kwargs)) diff --git a/tests/liam/testScript/main.py b/tests/liam/testScript/main.py index 47c7763..a85ffb7 100644 --- a/tests/liam/testScript/main.py +++ b/tests/liam/testScript/main.py @@ -1,9 +1,12 @@ -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any if TYPE_CHECKING: from hints import * import json +import shlex +import uuid + DATA_JSON = """ @data@ """ @@ -25,6 +28,55 @@ liam.wait_for_unit("postfix.service") liam.wait_for_unit("dovecot2.service") relay.wait_for_unit("mailpit.service") +def make_command(args: list) -> str: + return " ".join(map(shlex.quote, (map(str, args)))) + +class TesterThing(): + uuid: str = "" + default_smtp: dict[str, str] = {} + default_imap: dict[str, str] = {} + default_mailpit: dict[str, str] = {} + + def __init__(self, username: str, smtp: dict[str, str] = {}, imap: dict[str, str] = {}, mailpit: dict[str, str] = {}): + self.uuid = str(uuid.uuid4()) + self.default_smtp = { + "rcptto": "someone@example.com", + "username": username, + **smtp + } + self.default_imap = { + "username": username, + **imap + } + self.default_mailpit = { + "mailpit-url": f"http://{relay_ip}:8025", + **mailpit + } + + def run_expecting_json(self, name: str, **kwargs: dict[str, str]) -> dict[str, Any]: + args:list[str] = [name] + for k, v in kwargs: + dashed = k.replace("_","-") + args.append(f"--{dashed}") + args.append(v) + res = checker.succeed(make_command(args)) + res = res.strip() + assert res != "" + return json.loads(res) + + def run_smtp(self, **kwargs: dict[str, str]) -> bool: + args = {"message_magic": self.uuid, **self.default_smtp, **kwargs} + res = self.run_expecting_json("mailpit-smtp", **args) + return res["result"] + + def smtp_accepted(self, **kwargs): + res = self.run_smtp(**kwargs) + assert res, "Message was not accepted when it should have been" + + def smtp_rejected(self, **kwargs): + res = self.run_smtp(**kwargs) + assert not res, "Message was accepted when it was supposed to be rejected" + # The order of these shouldn't matter, other than what fails first. Whatever is at the top is probably whatever I was working on most recently. checks = f""" --submission --mailfrom robot@vacu.store --rcptto someone@example.com --username vacustore --expect-mailpit-received --mailpit-url http://{relay_ip}:8025