runv-server

server tooling for runv.club
Log | Files | Refs | README

smoke_test_email_aliases.py (11834B)


      1 #!/usr/bin/env python3
      2 """
      3 Smoke test dos aliases de email runv.club (Linux).
      4 
      5 Por defeito usa diretório temporário. Modo --direct chama a biblioteca in-process
      6 (útil em dev/WSL sem sudo). Na VPS use sudo sem --direct para testar os bins.
      7 
      8   sudo python3 scripts/admin/smoke_test_email_aliases.py --user MEMBRO
      9 """
     10 
     11 from __future__ import annotations
     12 
     13 import argparse
     14 import json
     15 import os
     16 import subprocess
     17 import sys
     18 import tempfile
     19 from collections.abc import Callable
     20 from contextlib import contextmanager
     21 from pathlib import Path
     22 
     23 REPO_ROOT = Path(__file__).resolve().parents[2]
     24 BIN_EMAIL = REPO_ROOT / "tools" / "bin" / "runv-email-alias"
     25 BIN_ADMIN = REPO_ROOT / "tools" / "bin" / "runv-admin-email-alias"
     26 
     27 
     28 def fail(msg: str) -> None:
     29     print(f"FAIL: {msg}", file=sys.stderr)
     30     raise SystemExit(1)
     31 
     32 
     33 def ok(msg: str) -> None:
     34     print(f"OK: {msg}")
     35 
     36 
     37 @contextmanager
     38 def push_env(updates: dict[str, str]):
     39     old: dict[str, str | None] = {}
     40     for key, val in updates.items():
     41         old[key] = os.environ.get(key)
     42         os.environ[key] = val
     43     try:
     44         yield
     45     finally:
     46         for key, prev in old.items():
     47             if prev is None:
     48                 os.environ.pop(key, None)
     49             else:
     50                 os.environ[key] = prev
     51 
     52 
     53 def run_cmd(
     54     cmd: list[str],
     55     *,
     56     env: dict[str, str],
     57     as_root: bool = False,
     58     as_user: str | None = None,
     59     check: bool = True,
     60 ) -> subprocess.CompletedProcess[str]:
     61     full = list(cmd)
     62     if as_user:
     63         full = ["sudo", "-n", "-u", as_user] + full
     64     elif as_root and os.geteuid() != 0:
     65         full = ["sudo", "-n"] + full
     66     # Fundir com os.environ: sudo -u não propaga um env mínimo de 3 chaves.
     67     child_env = {**os.environ, **env}
     68     proc = subprocess.run(
     69         full,
     70         env=child_env,
     71         capture_output=True,
     72         text=True,
     73         cwd=str(REPO_ROOT),
     74         timeout=60,
     75     )
     76     if check and proc.returncode != 0:
     77         fail(
     78             f"{' '.join(full)}\nstdout={proc.stdout!r}\nstderr={proc.stderr!r}"
     79         )
     80     return proc
     81 
     82 
     83 def expect_exit(fn: Callable[[], object]) -> bool:
     84     try:
     85         fn()
     86     except SystemExit:
     87         return True
     88     return False
     89 
     90 
     91 def _prepare_smoke_temp_layout(base: Path) -> None:
     92     """Espelha permissões de produção para subprocess como membro runv-members."""
     93     import grp
     94 
     95     try:
     96         member_gid = grp.getgrnam("runv-members").gr_gid
     97     except KeyError:
     98         fail("grupo runv-members ausente; execute setup_email_aliases.py primeiro")
     99 
    100     queue = base / "queue"
    101     aliases = base / "email-aliases.json"
    102     lock = base / "email-aliases.lock"
    103 
    104     queue.mkdir(parents=True, exist_ok=True)
    105     for sub in ("approved", "rejected", "cancelled"):
    106         (queue / sub).mkdir(parents=True, exist_ok=True)
    107 
    108     aliases.write_text("{}\n", encoding="utf-8")
    109     lock.touch()
    110 
    111     os.chmod(base, 0o755)
    112     for path in (queue, queue / "approved", queue / "rejected", queue / "cancelled"):
    113         os.chown(path, 0, member_gid)
    114         os.chmod(path, 0o2770)
    115     os.chown(aliases, 0, member_gid)
    116     os.chmod(aliases, 0o640)
    117     os.chown(lock, 0, member_gid)
    118     os.chmod(lock, 0o660)
    119 
    120 
    121 def main() -> int:
    122     if sys.platform == "win32":
    123         print(
    124             "Este smoke test requer Linux (pwd/grp/fcntl). "
    125             "Execute na VPS runv ou em WSL.",
    126             file=sys.stderr,
    127         )
    128         return 2
    129 
    130     p = argparse.ArgumentParser(description="Smoke test aliases de email")
    131     p.add_argument(
    132         "--production",
    133         action="store_true",
    134         help="usar paths em /var/lib/runv (cuidado: altera estado real)",
    135     )
    136     p.add_argument(
    137         "--direct",
    138         action="store_true",
    139         help="chamar runv_email_aliases in-process (temp dir; sem testar bins admin root)",
    140     )
    141     p.add_argument(
    142         "--user",
    143         default="",
    144         help="username Unix para pedidos",
    145     )
    146     args = p.parse_args()
    147     if args.production and args.direct:
    148         fail("--production e --direct são incompatíveis")
    149 
    150     username = args.user.strip() or os.environ.get("SUDO_USER", "").strip()
    151     if not username:
    152         username = os.environ.get("USER", "").strip()
    153     if not username:
    154         fail("defina --user ou execute com USER/SUDO_USER definido")
    155     if os.geteuid() == 0 and not args.user and not os.environ.get("SUDO_USER"):
    156         fail("como root directo, use --user MEMBRO (não reservado)")
    157 
    158     sys.path.insert(0, str(REPO_ROOT / "tools" / "lib"))
    159     import runv_email_aliases as ea  # noqa: E402
    160 
    161     try:
    162         ea.validate_alias_username(username)
    163     except SystemExit:
    164         fail(f"username {username!r} inválido ou reservado para alias")
    165 
    166     if args.production:
    167         queue = Path("/var/lib/runv/email-alias-queue")
    168         aliases = Path("/var/lib/runv/email-aliases.json")
    169         lock = Path("/var/lib/runv/email-aliases.lock")
    170         tmp_ctx = None
    171     else:
    172         tmp_ctx = tempfile.TemporaryDirectory(prefix="runv-email-smoke-")
    173         base = Path(tmp_ctx.name)
    174         queue = base / "queue"
    175         aliases = base / "email-aliases.json"
    176         lock = base / "email-aliases.lock"
    177         _prepare_smoke_temp_layout(base)
    178 
    179     env = {
    180         "RUNV_EMAIL_ALIAS_QUEUE_DIR": str(queue),
    181         "RUNV_EMAIL_ALIASES_PATH": str(aliases),
    182         "RUNV_EMAIL_ALIASES_LOCK_PATH": str(lock),
    183     }
    184 
    185     use_direct = bool(args.direct) or (tmp_ctx is not None and os.geteuid() != 0)
    186     if use_direct and not args.direct:
    187         print(
    188             "aviso: sem root; a usar modo direct (biblioteca). "
    189             "Na VPS: sudo python3 scripts/admin/smoke_test_email_aliases.py --user MEMBRO",
    190             file=sys.stderr,
    191         )
    192 
    193     with push_env(env):
    194         if use_direct:
    195             _run_direct(username, queue, aliases)
    196         else:
    197             _run_subprocess(env, username, queue, aliases)
    198 
    199     if tmp_ctx is not None:
    200         tmp_ctx.cleanup()
    201 
    202     print("\nSmoke test aliases de email: PASS")
    203     return 0
    204 
    205 
    206 def _run_direct(username: str, queue: Path, aliases: Path) -> None:
    207     import runv_email_aliases as ea
    208 
    209     for dest in ("foo", "x@runv.club"):
    210         if not expect_exit(lambda d=dest: ea.validate_destination_email(d)):
    211             fail(f"validate {dest!r} deveria falhar")
    212     ok("validações de destino inválido rejeitadas")
    213 
    214     dest_ok = "smoke-alias-test@example.org"
    215     ea.create_pending_request(username, dest_ok)
    216     ok("request criado")
    217 
    218     if ea.find_pending_for_user(username) is None:
    219         fail("pending não encontrado")
    220     if not expect_exit(lambda: ea.create_pending_request(username, dest_ok)):
    221         fail("segundo request deveria falhar")
    222     ok("duplo pending bloqueado")
    223 
    224     entry = ea.approve_pending(username, "smoke-test")
    225     ok("approve")
    226 
    227     rows = ea.list_active_aliases()
    228     if not any(r[2] == dest_ok for r in rows):
    229         fail(f"list_active sem {dest_ok!r}")
    230     ok("list")
    231 
    232     if ea.get_active_alias(username) is None:
    233         fail("alias activo ausente após approve")
    234     ok("status active")
    235 
    236     data = json.loads(aliases.read_text(encoding="utf-8"))
    237     created_at = data[username].get("created_at")
    238     if not any((queue / "approved").glob("*.json")):
    239         fail("nenhum pedido em approved/")
    240     ok("pedido arquivado em approved/")
    241 
    242     dest2 = "smoke-alias-test2@example.org"
    243     ea.create_pending_request(username, dest2)
    244     ea.approve_pending(username, "smoke-test")
    245     data2 = json.loads(aliases.read_text(encoding="utf-8"))
    246     if data2[username].get("destination") != dest2:
    247         fail("destino não actualizado")
    248     if data2[username].get("created_at") != created_at:
    249         fail("created_at não preservado")
    250     ok("alteração de destino com created_at preservado")
    251 
    252     dest3 = "smoke-cancel@example.org"
    253     ea.create_pending_request(username, dest3)
    254     if ea.cancel_latest_pending(username) is None:
    255         fail("cancel falhou")
    256     ok("cancel")
    257 
    258     dest4 = "smoke-reject@example.org"
    259     ea.create_pending_request(username, dest4)
    260     ea.reject_pending(username, "smoke-test", "smoke test")
    261     if not any((queue / "rejected").glob("*.json")):
    262         fail("reject não arquivou")
    263     ok("reject")
    264 
    265     if not expect_exit(lambda: ea.approve_pending("entre", "smoke-test")):
    266         fail("approve entre deveria falhar")
    267     ok("username reservado rejeitado no approve")
    268 
    269 
    270 def _run_subprocess(
    271     env: dict[str, str],
    272     username: str,
    273     queue: Path,
    274     aliases: Path,
    275 ) -> None:
    276     py = sys.executable
    277     email_bin = str(BIN_EMAIL) if BIN_EMAIL.is_file() else "runv-email-alias"
    278     admin_bin = str(BIN_ADMIN) if BIN_ADMIN.is_file() else "runv-admin-email-alias"
    279     member_user = username if os.geteuid() == 0 else None
    280 
    281     for dest in ("foo", "x@runv.club"):
    282         proc = run_cmd(
    283             [py, email_bin, "request", dest],
    284             env=env,
    285             as_user=member_user,
    286             check=False,
    287         )
    288         if proc.returncode == 0:
    289             fail(f"request {dest!r} deveria falhar")
    290     ok("validações de destino inválido rejeitadas")
    291 
    292     dest_ok = "smoke-alias-test@example.org"
    293     run_cmd([py, email_bin, "request", dest_ok], env=env, as_user=member_user)
    294     ok("request criado")
    295 
    296     proc = run_cmd([py, email_bin, "status"], env=env, as_user=member_user)
    297     if "pending" not in proc.stdout.lower():
    298         fail(f"status sem pending: {proc.stdout!r}")
    299 
    300     proc2 = run_cmd(
    301         [py, email_bin, "request", dest_ok],
    302         env=env,
    303         as_user=member_user,
    304         check=False,
    305     )
    306     if proc2.returncode == 0:
    307         fail("segundo request deveria falhar")
    308     ok("duplo pending bloqueado")
    309 
    310     run_cmd([py, admin_bin, "approve", username], env=env, as_root=True)
    311     ok("approve")
    312 
    313     proc = run_cmd([py, admin_bin, "list"], env=env, as_root=True)
    314     if dest_ok not in proc.stdout:
    315         fail(f"list não mostra destino: {proc.stdout!r}")
    316     ok("list")
    317 
    318     proc = run_cmd([py, email_bin, "status"], env=env, as_user=member_user)
    319     if "active" not in proc.stdout.lower():
    320         fail(f"status sem active: {proc.stdout!r}")
    321     ok("status active")
    322 
    323     data = json.loads(aliases.read_text(encoding="utf-8"))
    324     entry = data.get(username)
    325     if not entry or entry.get("status") != "active":
    326         fail(f"email-aliases.json inválido: {data!r}")
    327     if not any((queue / "approved").glob("*.json")):
    328         fail("nenhum pedido em approved/")
    329     ok("pedido arquivado em approved/")
    330 
    331     dest2 = "smoke-alias-test2@example.org"
    332     run_cmd([py, email_bin, "request", dest2], env=env, as_user=member_user)
    333     run_cmd([py, admin_bin, "approve", username], env=env, as_root=True)
    334     data2 = json.loads(aliases.read_text(encoding="utf-8"))
    335     if data2[username].get("destination") != dest2:
    336         fail("destino não actualizado")
    337     if data2[username].get("created_at") != entry.get("created_at"):
    338         fail("created_at não preservado")
    339     ok("alteração de destino")
    340 
    341     dest3 = "smoke-cancel@example.org"
    342     run_cmd([py, email_bin, "request", dest3], env=env, as_user=member_user)
    343     run_cmd([py, email_bin, "cancel"], env=env, as_user=member_user)
    344     if not any((queue / "cancelled").glob("*.json")):
    345         fail("cancel não arquivou")
    346     ok("cancel")
    347 
    348     dest4 = "smoke-reject@example.org"
    349     run_cmd([py, email_bin, "request", dest4], env=env, as_user=member_user)
    350     run_cmd(
    351         [py, admin_bin, "reject", username, "--reason", "smoke test"],
    352         env=env,
    353         as_root=True,
    354     )
    355     if not any((queue / "rejected").glob("*.json")):
    356         fail("reject não arquivou")
    357     ok("reject")
    358 
    359     proc = run_cmd(
    360         [py, admin_bin, "approve", "entre"],
    361         env=env,
    362         as_root=True,
    363         check=False,
    364     )
    365     if proc.returncode == 0:
    366         fail("approve entre deveria falhar")
    367     ok("username reservado rejeitado")
    368 
    369 
    370 if __name__ == "__main__":
    371     raise SystemExit(main())