From cadb0daba040b186636279c56cd0a859bd7d8e67 Mon Sep 17 00:00:00 2001 From: Brad Stein Date: Sun, 4 Jan 2026 00:41:45 -0300 Subject: [PATCH] tests: add Keycloak email probe --- .../test_keycloak_execute_actions_email.py | 127 ++++++++++++++++++ scripts/tests/test_portal_onboarding_flow.py | 2 +- .../portal-onboarding-e2e-test-job.yaml | 2 +- services/keycloak/kustomization.yaml | 2 + ...al-e2e-execute-actions-email-test-job.yaml | 52 +++++++ 5 files changed, 183 insertions(+), 2 deletions(-) create mode 100644 scripts/tests/test_keycloak_execute_actions_email.py create mode 100644 services/keycloak/portal-e2e-execute-actions-email-test-job.yaml diff --git a/scripts/tests/test_keycloak_execute_actions_email.py b/scripts/tests/test_keycloak_execute_actions_email.py new file mode 100644 index 0000000..040d7d3 --- /dev/null +++ b/scripts/tests/test_keycloak_execute_actions_email.py @@ -0,0 +1,127 @@ +#!/usr/bin/env python3 +import json +import os +import sys +import urllib.error +import urllib.parse +import urllib.request + + +def _require_env(name: str) -> str: + value = os.environ.get(name) + if not value: + raise SystemExit(f"missing required env var: {name}") + return value + + +def _post_form(url: str, data: dict[str, str], timeout_s: int = 30) -> dict: + body = urllib.parse.urlencode(data).encode() + req = urllib.request.Request( + url, + data=body, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + method="POST", + ) + try: + with urllib.request.urlopen(req, timeout=timeout_s) as resp: + payload = resp.read().decode() + return json.loads(payload) if payload else {} + except urllib.error.HTTPError as exc: + raw = exc.read().decode(errors="replace") + raise SystemExit(f"HTTP {exc.code} from {url}: {raw}") + + +def _request_json(method: str, url: str, token: str, payload: object | None = None, timeout_s: int = 30) -> tuple[int, object | None]: + data = None + headers = {"Authorization": f"Bearer {token}"} + if payload is not None: + data = json.dumps(payload).encode() + headers["Content-Type"] = "application/json" + req = urllib.request.Request(url, data=data, headers=headers, method=method) + try: + with urllib.request.urlopen(req, timeout=timeout_s) as resp: + body = resp.read() + if not body: + return resp.status, None + return resp.status, json.loads(body.decode()) + except urllib.error.HTTPError as exc: + raw = exc.read().decode(errors="replace") + raise SystemExit(f"HTTP {exc.code} from {url}: {raw}") + + +def main() -> int: + keycloak_base = _require_env("KEYCLOAK_SERVER").rstrip("/") + realm = os.environ.get("KEYCLOAK_REALM", "atlas") + client_id = _require_env("PORTAL_E2E_CLIENT_ID") + client_secret = _require_env("PORTAL_E2E_CLIENT_SECRET") + + probe_username = os.environ.get("E2E_PROBE_USERNAME", "e2e-smtp-probe") + probe_email = os.environ.get("E2E_PROBE_EMAIL", "e2e-smtp-probe@bstein.dev") + + execute_client_id = os.environ.get("EXECUTE_ACTIONS_CLIENT_ID", "bstein-dev-home") + execute_redirect_uri = os.environ.get("EXECUTE_ACTIONS_REDIRECT_URI", "https://bstein.dev/") + + token_url = f"{keycloak_base}/realms/{realm}/protocol/openid-connect/token" + admin_users_url = f"{keycloak_base}/admin/realms/{realm}/users" + + token_payload = _post_form( + token_url, + {"grant_type": "client_credentials", "client_id": client_id, "client_secret": client_secret}, + timeout_s=30, + ) + access_token = token_payload.get("access_token") + if not isinstance(access_token, str) or not access_token: + raise SystemExit("client credentials token missing access_token") + + status, users = _request_json( + "GET", + f"{admin_users_url}?{urllib.parse.urlencode({'username': probe_username, 'exact': 'true'})}", + access_token, + timeout_s=30, + ) + if status != 200 or not isinstance(users, list): + raise SystemExit("unexpected admin API response when searching for probe user") + + if not users: + create_payload = { + "username": probe_username, + "enabled": False, + "email": probe_email, + "emailVerified": True, + } + status, _ = _request_json("POST", admin_users_url, access_token, create_payload, timeout_s=30) + if status not in (201, 204): + raise SystemExit(f"unexpected status creating probe user: {status}") + status, users = _request_json( + "GET", + f"{admin_users_url}?{urllib.parse.urlencode({'username': probe_username, 'exact': 'true'})}", + access_token, + timeout_s=30, + ) + if status != 200 or not isinstance(users, list) or not users: + raise SystemExit("failed to refetch probe user after creation") + + user_id = users[0].get("id") + if not isinstance(user_id, str) or not user_id: + raise SystemExit("probe user missing id") + + # Trigger an email to validate Keycloak SMTP integration. + query = urllib.parse.urlencode( + { + "client_id": execute_client_id, + "redirect_uri": execute_redirect_uri, + "lifespan": "600", + } + ) + url = f"{admin_users_url}/{urllib.parse.quote(user_id)}/execute-actions-email?{query}" + status, _ = _request_json("PUT", url, access_token, ["UPDATE_PASSWORD"], timeout_s=30) + if status != 204: + raise SystemExit(f"unexpected status from execute-actions-email: {status}") + + print("PASS: Keycloak execute-actions-email succeeded") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) + diff --git a/scripts/tests/test_portal_onboarding_flow.py b/scripts/tests/test_portal_onboarding_flow.py index 6f94b6e..a69c0c0 100644 --- a/scripts/tests/test_portal_onboarding_flow.py +++ b/scripts/tests/test_portal_onboarding_flow.py @@ -197,7 +197,7 @@ def main() -> int: if isinstance(required_actions, list): required = {a for a in required_actions if isinstance(a, str)} - missing = [name for name in ("UPDATE_PASSWORD", "CONFIGURE_TOTP") if name not in required] + missing = [name for name in ("UPDATE_PASSWORD", "VERIFY_EMAIL", "CONFIGURE_TOTP") if name not in required] if missing: raise SystemExit(f"Keycloak user missing required actions {missing}: requiredActions={sorted(required)}") diff --git a/services/bstein-dev-home/portal-onboarding-e2e-test-job.yaml b/services/bstein-dev-home/portal-onboarding-e2e-test-job.yaml index 3b1fcc7..0b05da4 100644 --- a/services/bstein-dev-home/portal-onboarding-e2e-test-job.yaml +++ b/services/bstein-dev-home/portal-onboarding-e2e-test-job.yaml @@ -2,7 +2,7 @@ apiVersion: batch/v1 kind: Job metadata: - name: portal-onboarding-e2e-test-3 + name: portal-onboarding-e2e-test-4 namespace: bstein-dev-home spec: backoffLimit: 0 diff --git a/services/keycloak/kustomization.yaml b/services/keycloak/kustomization.yaml index 8f5b0a5..b9266b8 100644 --- a/services/keycloak/kustomization.yaml +++ b/services/keycloak/kustomization.yaml @@ -11,6 +11,7 @@ resources: - portal-e2e-target-client-job.yaml - portal-e2e-token-exchange-permissions-job.yaml - portal-e2e-token-exchange-test-job.yaml + - portal-e2e-execute-actions-email-test-job.yaml - ldap-federation-job.yaml - user-overrides-job.yaml - service.yaml @@ -21,3 +22,4 @@ configMapGenerator: - name: portal-e2e-tests files: - test_portal_token_exchange.py=../../scripts/tests/test_portal_token_exchange.py + - test_keycloak_execute_actions_email.py=../../scripts/tests/test_keycloak_execute_actions_email.py diff --git a/services/keycloak/portal-e2e-execute-actions-email-test-job.yaml b/services/keycloak/portal-e2e-execute-actions-email-test-job.yaml new file mode 100644 index 0000000..3775984 --- /dev/null +++ b/services/keycloak/portal-e2e-execute-actions-email-test-job.yaml @@ -0,0 +1,52 @@ +# services/keycloak/portal-e2e-execute-actions-email-test-job.yaml +apiVersion: batch/v1 +kind: Job +metadata: + name: keycloak-portal-e2e-execute-actions-email-1 + namespace: sso +spec: + backoffLimit: 3 + template: + spec: + restartPolicy: Never + containers: + - name: test + image: python:3.11-alpine + env: + - name: KEYCLOAK_SERVER + value: http://keycloak.sso.svc.cluster.local + - name: KEYCLOAK_REALM + value: atlas + - name: PORTAL_E2E_CLIENT_ID + valueFrom: + secretKeyRef: + name: portal-e2e-client + key: client_id + - name: PORTAL_E2E_CLIENT_SECRET + valueFrom: + secretKeyRef: + name: portal-e2e-client + key: client_secret + - name: E2E_PROBE_USERNAME + value: e2e-smtp-probe + - name: E2E_PROBE_EMAIL + value: e2e-smtp-probe@bstein.dev + - name: EXECUTE_ACTIONS_CLIENT_ID + value: bstein-dev-home + - name: EXECUTE_ACTIONS_REDIRECT_URI + value: https://bstein.dev/ + command: ["/bin/sh", "-c"] + args: + - | + set -euo pipefail + python /scripts/test_keycloak_execute_actions_email.py + volumeMounts: + - name: tests + mountPath: /scripts + readOnly: true + volumes: + - name: tests + configMap: + name: portal-e2e-tests + defaultMode: 0555 +