tests(portal): verify access requests via email

This commit is contained in:
Brad Stein 2026-01-04 01:48:46 -03:00
parent a7f68ddddb
commit 04b730dbab
3 changed files with 137 additions and 15 deletions

View File

@ -1,7 +1,11 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import email
import http.client import http.client
import imaplib
import json import json
import os import os
import re
import ssl
import sys import sys
import time import time
import urllib.error import urllib.error
@ -97,6 +101,89 @@ def _keycloak_get_user(keycloak_base: str, realm: str, token: str, user_id: str)
return data return data
def _extract_attr(attributes: object, key: str) -> str:
if not isinstance(attributes, dict):
return ""
value = attributes.get(key)
if isinstance(value, list) and value and isinstance(value[0], str):
return value[0]
if isinstance(value, str):
return value
return ""
def _imap_wait_for_verify_token(
*,
host: str,
port: int,
username: str,
password: str,
request_code: str,
deadline_sec: int,
) -> str:
ssl_context = ssl._create_unverified_context()
deadline_at = time.monotonic() + deadline_sec
with imaplib.IMAP4_SSL(host, port, ssl_context=ssl_context) as client:
client.login(username, password)
client.select("INBOX")
while time.monotonic() < deadline_at:
status, data = client.search(None, "TEXT", request_code)
if status == "OK" and data and data[0]:
ids = data[0].split()
msg_id = ids[-1]
fetch_status, msg_data = client.fetch(msg_id, "(RFC822)")
if fetch_status != "OK" or not msg_data:
time.sleep(2)
continue
raw = msg_data[0][1] if isinstance(msg_data[0], tuple) and len(msg_data[0]) > 1 else None
if not isinstance(raw, (bytes, bytearray)):
time.sleep(2)
continue
message = email.message_from_bytes(raw)
body = None
if message.is_multipart():
for part in message.walk():
if part.get_content_type() == "text/plain":
payload = part.get_payload(decode=True)
if isinstance(payload, (bytes, bytearray)):
body = payload.decode(errors="replace")
break
else:
payload = message.get_payload(decode=True)
if isinstance(payload, (bytes, bytearray)):
body = payload.decode(errors="replace")
if not body:
time.sleep(2)
continue
url = None
for line in body.splitlines():
candidate = line.strip()
if "verify=" in candidate and candidate.startswith("http"):
url = candidate
break
if not url:
match = re.search(r"https?://\\S+verify=\\S+", body)
url = match.group(0) if match else None
if not url:
time.sleep(2)
continue
parsed = urllib.parse.urlparse(url)
query = urllib.parse.parse_qs(parsed.query)
token = query.get("verify", [""])[0]
if isinstance(token, str) and token:
return token
time.sleep(2)
raise SystemExit("verification email not found before deadline")
def main() -> int: def main() -> int:
portal_base = _env("PORTAL_BASE_URL").rstrip("/") portal_base = _env("PORTAL_BASE_URL").rstrip("/")
db_url = _env("PORTAL_DATABASE_URL") db_url = _env("PORTAL_DATABASE_URL")
@ -106,13 +193,38 @@ def main() -> int:
kc_admin_client_id = _env("KEYCLOAK_ADMIN_CLIENT_ID") kc_admin_client_id = _env("KEYCLOAK_ADMIN_CLIENT_ID")
kc_admin_client_secret = _env("KEYCLOAK_ADMIN_CLIENT_SECRET") kc_admin_client_secret = _env("KEYCLOAK_ADMIN_CLIENT_SECRET")
contact_email = os.environ.get("E2E_CONTACT_EMAIL", "robotuser@bstein.dev").strip()
if not contact_email:
raise SystemExit("E2E_CONTACT_EMAIL must not be empty")
imap_host = os.environ.get("E2E_IMAP_HOST", "mailu-front.mailu-mailserver.svc.cluster.local").strip()
imap_port = int(os.environ.get("E2E_IMAP_PORT", "993"))
imap_keycloak_username = os.environ.get("E2E_IMAP_KEYCLOAK_USERNAME", "robotuser").strip()
imap_wait_sec = int(os.environ.get("E2E_IMAP_WAIT_SECONDS", "90"))
token = _keycloak_admin_token(keycloak_base, realm, kc_admin_client_id, kc_admin_client_secret)
mailbox_user = _keycloak_find_user(keycloak_base, realm, token, imap_keycloak_username)
if not mailbox_user:
raise SystemExit(f"unable to locate Keycloak mailbox user {imap_keycloak_username!r}")
mailbox_user_id = mailbox_user.get("id")
if not isinstance(mailbox_user_id, str) or not mailbox_user_id:
raise SystemExit("mailbox user missing id")
mailbox_full = _keycloak_get_user(keycloak_base, realm, token, mailbox_user_id)
mailbox_attrs = mailbox_full.get("attributes")
mailu_email = _extract_attr(mailbox_attrs, "mailu_email")
if not mailu_email:
mailu_email = contact_email
mailu_password = _extract_attr(mailbox_attrs, "mailu_app_password")
if not mailu_password:
raise SystemExit(f"Keycloak user {imap_keycloak_username!r} missing mailu_app_password attribute")
username_prefix = os.environ.get("E2E_USERNAME_PREFIX", "e2e-user") username_prefix = os.environ.get("E2E_USERNAME_PREFIX", "e2e-user")
now = int(time.time()) now = int(time.time())
username = f"{username_prefix}-{now}" username = f"{username_prefix}-{now}"
email = f"{username}@example.invalid"
submit_url = f"{portal_base}/api/access/request" submit_url = f"{portal_base}/api/access/request"
submit_payload = {"username": username, "email": email, "note": "portal onboarding e2e"} submit_payload = {"username": username, "email": contact_email, "note": "portal onboarding e2e"}
submit = None submit = None
for attempt in range(1, 6): for attempt in range(1, 6):
try: try:
@ -129,18 +241,23 @@ def main() -> int:
if not isinstance(request_code, str) or not request_code: if not isinstance(request_code, str) or not request_code:
raise SystemExit(f"request submit did not return request_code: {submit}") raise SystemExit(f"request submit did not return request_code: {submit}")
with psycopg.connect(db_url, autocommit=True) as conn: verify_token = _imap_wait_for_verify_token(
# Bypass the emailed token by marking the request as verified and pending (same as /api/access/request/verify). host=imap_host,
conn.execute( port=imap_port,
""" username=mailu_email,
UPDATE access_requests password=mailu_password,
SET status = 'pending', request_code=request_code,
email_verified_at = NOW(), deadline_sec=imap_wait_sec,
email_verification_token_hash = NULL
WHERE request_code = %s AND status = 'pending_email_verification'
""",
(request_code,),
) )
verify_resp = _post_json(
f"{portal_base}/api/access/request/verify",
{"request_code": request_code, "token": verify_token},
timeout_s=30,
)
if not isinstance(verify_resp, dict) or verify_resp.get("ok") is not True:
raise SystemExit(f"unexpected verify response: {verify_resp}")
with psycopg.connect(db_url, autocommit=True) as conn:
# Simulate admin approval. # Simulate admin approval.
conn.execute( conn.execute(
""" """
@ -183,7 +300,6 @@ def main() -> int:
raise SystemExit(f"timed out waiting for provisioning to finish (last status={last_status}){suffix}") raise SystemExit(f"timed out waiting for provisioning to finish (last status={last_status}){suffix}")
time.sleep(interval_s) time.sleep(interval_s)
token = _keycloak_admin_token(keycloak_base, realm, kc_admin_client_id, kc_admin_client_secret)
user = _keycloak_find_user(keycloak_base, realm, token, username) user = _keycloak_find_user(keycloak_base, realm, token, username)
if not user: if not user:
raise SystemExit("expected Keycloak user was not created") raise SystemExit("expected Keycloak user was not created")

View File

@ -80,6 +80,8 @@ spec:
value: "120" value: "120"
- name: ACCESS_REQUEST_STATUS_RATE_WINDOW_SEC - name: ACCESS_REQUEST_STATUS_RATE_WINDOW_SEC
value: "60" value: "60"
- name: ACCESS_REQUEST_INTERNAL_EMAIL_ALLOWLIST
value: robotuser@bstein.dev
ports: ports:
- name: http - name: http
containerPort: 8080 containerPort: 8080

View File

@ -2,7 +2,7 @@
apiVersion: batch/v1 apiVersion: batch/v1
kind: Job kind: Job
metadata: metadata:
name: portal-onboarding-e2e-test-4 name: portal-onboarding-e2e-test-5
namespace: bstein-dev-home namespace: bstein-dev-home
spec: spec:
backoffLimit: 0 backoffLimit: 0
@ -33,6 +33,10 @@ spec:
key: client_secret key: client_secret
- name: E2E_USERNAME_PREFIX - name: E2E_USERNAME_PREFIX
value: e2e-portal value: e2e-portal
- name: E2E_CONTACT_EMAIL
value: robotuser@bstein.dev
- name: E2E_IMAP_KEYCLOAK_USERNAME
value: robotuser
- name: E2E_DEADLINE_SECONDS - name: E2E_DEADLINE_SECONDS
value: "600" value: "600"
- name: E2E_POLL_SECONDS - name: E2E_POLL_SECONDS