* ci: centralize All builds smoke checks and releases Use orb 0.7.9 and its immutable shared release worker. Build once, preserve core/full verification and conditional public GHCR delivery, and consume public Circle provenance without exporting a token. Co-Authored-By: Codex <noreply@openai.com> * fix: use shared SDK v2 S3 support for MinIO Replace obsolete SDK v1 client overrides with upstream endpoint and path-style metadata. Preserve endpoint coverage for every filesystem action and local HTTP listing/write assertions. Co-Authored-By: Codex <noreply@openai.com> * test: verify the HttpOnly OIDC session contract Current QQQ omits the session UUID from JSON for HttpOnly cookies. Require the nonempty HttpOnly cookie and reject exposed UUIDs, retaining authenticated backend and permission checks. Co-Authored-By: Codex <noreply@openai.com> * fix: accept leading-dash Mongo credentials in Compose Use explicit username/password option assignments in health and replica-set initialization. Reproduced the original failure and verified authenticated ping with the corrected arguments against mongo:7 and a leading-dash fixture password. Co-Authored-By: Codex <noreply@openai.com> --------- Co-authored-by: Codex <noreply@openai.com>
385 lines
16 KiB
Python
385 lines
16 KiB
Python
#!/usr/bin/env python3
|
|
"""Black-box HTTP smoke checks for the qqq-all reference application."""
|
|
|
|
import argparse
|
|
import base64
|
|
import hashlib
|
|
import html.parser
|
|
import http.cookiejar
|
|
import json
|
|
import os
|
|
import re
|
|
import secrets
|
|
import sys
|
|
import time
|
|
import urllib.error
|
|
import urllib.parse
|
|
import urllib.request
|
|
import uuid
|
|
|
|
|
|
CORE_TABLES = ("customer", "order", "orderLine", "product")
|
|
CORE_SEEDS = (
|
|
("customer", "name", "Ada Lovelace"),
|
|
("order", "orderNo", "ORD-1001"),
|
|
("orderLine", "quantity", 2),
|
|
("product", "name", "QRun Starter Kit"),
|
|
)
|
|
FULL_TABLES = CORE_TABLES + (
|
|
"warehouseCustomer", "supplierOrder", "shipment", "document", "externalImportFile",
|
|
"SFTPImportSourceFileTable",
|
|
)
|
|
CORE_ADMIN_SESSION = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
|
|
|
|
|
|
class LoginForm(html.parser.HTMLParser):
|
|
def __init__(self):
|
|
super().__init__()
|
|
self.action = None
|
|
self.values = {}
|
|
self.inside = False
|
|
|
|
def handle_starttag(self, tag, attrs):
|
|
values = dict(attrs)
|
|
if tag == "form" and values.get("id") == "kc-form-login":
|
|
self.action = values.get("action")
|
|
self.inside = True
|
|
elif tag == "input" and self.inside and values.get("name"):
|
|
self.values[values["name"]] = values.get("value", "")
|
|
|
|
def handle_endtag(self, tag):
|
|
if tag == "form":
|
|
self.inside = False
|
|
|
|
|
|
class StopAtCallback(urllib.request.HTTPRedirectHandler):
|
|
def __init__(self, callback):
|
|
self.callback = callback
|
|
|
|
def redirect_request(self, request, fp, code, msg, headers, url):
|
|
if url.startswith(self.callback + "?"):
|
|
return None
|
|
return super().redirect_request(request, fp, code, msg, headers, url)
|
|
|
|
|
|
def wait_for(label, predicate, timeout):
|
|
deadline = time.monotonic() + timeout
|
|
while time.monotonic() < deadline:
|
|
try:
|
|
if predicate():
|
|
return
|
|
except (OSError, TimeoutError, AssertionError, ValueError):
|
|
pass
|
|
time.sleep(0.25)
|
|
raise AssertionError(f"timed out waiting for {label}")
|
|
|
|
|
|
class SmokeClient:
|
|
def __init__(self, base_url, session_id=None):
|
|
self.base_url = base_url.rstrip("/")
|
|
self.session_id = session_id
|
|
self.cookies = http.cookiejar.CookieJar()
|
|
self.opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(self.cookies))
|
|
|
|
def request(self, path, body=None, method=None, expected_status=200):
|
|
data = None if body is None else json.dumps(body).encode()
|
|
headers = {"Content-Type": "application/json"} if data is not None else {}
|
|
if self.session_id:
|
|
headers["Cookie"] = "sessionId=" + self.session_id
|
|
request = urllib.request.Request(
|
|
self.base_url + path, data=data, method=method,
|
|
headers=headers,
|
|
)
|
|
try:
|
|
with self.opener.open(request, timeout=5) as response:
|
|
if response.status != expected_status:
|
|
raise AssertionError(f"{path}: HTTP {response.status}")
|
|
return response.read()
|
|
except urllib.error.HTTPError as error:
|
|
status = error.code
|
|
try:
|
|
if status == expected_status:
|
|
return error.read()
|
|
finally:
|
|
error.close()
|
|
raise AssertionError(f"{path}: HTTP {status}") from None
|
|
|
|
def json(self, path, body=None):
|
|
return json.loads(self.request(path, body))
|
|
|
|
def records(self, table):
|
|
result = self.json(f"/qqq/v1/table/{table}/query", {})
|
|
records = result.get("records")
|
|
if not isinstance(records, list):
|
|
raise AssertionError(f"{table}: query did not return records")
|
|
return records
|
|
|
|
def require_record(self, table, field, value):
|
|
if not any(record.get("values", {}).get(field) == value for record in self.records(table)):
|
|
raise AssertionError(f"missing expected {table} {field}")
|
|
|
|
|
|
def require_oidc_session(client, result):
|
|
# Next uses an HttpOnly cookie; current QQQ intentionally omits its UUID from JSON.
|
|
cookies = [cookie for cookie in client.cookies if cookie.name == "sessionUUID"]
|
|
if (len(cookies) != 1 or not cookies[0].value
|
|
or not cookies[0].has_nonstandard_attr("HttpOnly")):
|
|
raise AssertionError("QQQ did not establish an HttpOnly OIDC session")
|
|
if "uuid" in result:
|
|
raise AssertionError("QQQ exposed the HttpOnly session UUID in JSON")
|
|
|
|
|
|
def allow_loopback_http_session(client):
|
|
parsed = urllib.parse.urlsplit(client.base_url)
|
|
if parsed.scheme != "http" or parsed.hostname not in ("127.0.0.1", "localhost"):
|
|
raise AssertionError("OIDC HTTP cookie adjustment requires a loopback app")
|
|
cookies = [cookie for cookie in client.cookies if cookie.name == "sessionUUID"]
|
|
if len(cookies) != 1:
|
|
raise AssertionError("OIDC did not set exactly one session cookie")
|
|
if cookies[0].secure:
|
|
cookies[0].secure = False
|
|
|
|
|
|
def allow_loopback_http_cookies(cookies, url):
|
|
parsed = urllib.parse.urlsplit(url)
|
|
host = parsed.hostname or ""
|
|
if parsed.scheme != "http" or not (host in ("127.0.0.1", "localhost")
|
|
or host.endswith(".localhost")):
|
|
raise AssertionError("OIDC HTTP cookie adjustment requires a loopback host")
|
|
for cookie in cookies:
|
|
if cookie.secure and cookie.domain.lstrip(".") == host:
|
|
cookie.secure = False
|
|
|
|
|
|
def check_health_and_dashboard(client, timeout):
|
|
wait_for("health", lambda: client.json("/health").get("status") == "UP", timeout)
|
|
html = client.request("/").decode()
|
|
match = re.search(r'["\'](/_next/static/[^"\']+\.(?:js|css))["\']', html)
|
|
if not match:
|
|
raise AssertionError("dashboard did not reference a Next asset")
|
|
if not client.request(match.group(1)):
|
|
raise AssertionError("Next asset was empty")
|
|
print("health and Next asset: OK")
|
|
|
|
|
|
def check_tables(client, tables, require_seed=True):
|
|
for table in tables:
|
|
records = client.records(table)
|
|
if require_seed and not records:
|
|
raise AssertionError(f"{table}: no seeded records")
|
|
if require_seed:
|
|
for table, field, value in CORE_SEEDS:
|
|
client.require_record(table, field, value)
|
|
else:
|
|
client.require_record("customer", "name", "Ada Lovelace")
|
|
print("backend table queries: OK")
|
|
|
|
|
|
def trigger_order(client):
|
|
before = {record.get("values", {}).get("id") for record in client.records("processTrace")}
|
|
marker = "SMOKE-" + uuid.uuid4().hex[:12]
|
|
response = client.json("/data/order/", {
|
|
"orderNo": marker, "customerId": 1, "status": "NEW"
|
|
})
|
|
inserted = response.get("records", [])
|
|
order_id = inserted[0].get("values", {}).get("id") if inserted else None
|
|
if not order_id:
|
|
raise AssertionError("order insert did not return an ID")
|
|
return before, order_id, marker
|
|
|
|
|
|
def check_artemis_round_trip(client, timeout):
|
|
before, order_id, marker = trigger_order(client)
|
|
process_uuid = None
|
|
|
|
def new_sync_trace():
|
|
nonlocal process_uuid
|
|
for record in client.records("processTrace"):
|
|
values = record.get("values", {})
|
|
if (values.get("id") not in before
|
|
and record.get("recordLabel", "").startswith("Sync Order -")
|
|
and values.get("keyRecordId") == order_id
|
|
and values.get("processUUID")):
|
|
process_uuid = values["processUUID"]
|
|
return True
|
|
return False
|
|
|
|
wait_for("Artemis syncOrder trace",
|
|
new_sync_trace, timeout)
|
|
client.require_record("order", "orderNo", marker)
|
|
print("Artemis order event round trip: OK")
|
|
return process_uuid
|
|
|
|
|
|
def check_runtime_features(client, can_calculate=True):
|
|
records = client.records("apiCatalog")
|
|
if [record.get("values", {}).get("id") for record in records] != [101, 102, 103]:
|
|
raise AssertionError("API catalog did not traverse all pages")
|
|
record = client.json("/data/apiCatalog/103")
|
|
if record.get("values", {}).get("name") != "Shipping Box":
|
|
raise AssertionError("API catalog get returned incorrect record")
|
|
client.require_record("demoNote", "text", "Transient demo note")
|
|
path = "/processes/calculateOrderTotal/run?quantity=3&unitPriceCents=1250"
|
|
if can_calculate:
|
|
result = client.json(path, {})
|
|
if result.get("values", {}).get("totalCents") != 3750:
|
|
raise AssertionError("JavaScript calculation returned incorrect total")
|
|
else:
|
|
client.request(path, {}, expected_status=403)
|
|
client.request("/data/demoNote/1", {"text": "forbidden"}, method="PATCH", expected_status=403)
|
|
client.request("/data/apiCatalog/", {"id": 999, "name": "forbidden"}, expected_status=403)
|
|
print("API catalog, transient memory, JavaScript permissions: OK")
|
|
|
|
|
|
def check_core(client, timeout):
|
|
check_health_and_dashboard(client, timeout)
|
|
check_tables(client, CORE_TABLES)
|
|
check_runtime_features(client)
|
|
check_runtime_features(SmokeClient(client.base_url, "dddddddd-dddd-4ddd-8ddd-dddddddddddd"), False)
|
|
check_artemis_round_trip(client, timeout)
|
|
|
|
|
|
def oidc_login(client, keycloak_url, username, password):
|
|
"""Complete the browser authorization-code flow and establish a QQQ cookie."""
|
|
try:
|
|
discovery_url = keycloak_url.rstrip("/") + "/.well-known/openid-configuration"
|
|
with urllib.request.urlopen(discovery_url, timeout=5) as response:
|
|
authorization_endpoint = json.load(response)["authorization_endpoint"]
|
|
verifier = secrets.token_urlsafe(48)
|
|
challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode()
|
|
state = secrets.token_urlsafe(24)
|
|
callback = client.base_url + "/"
|
|
query = urllib.parse.urlencode({
|
|
"client_id": "qqq-all", "response_type": "code", "scope": "openid email profile",
|
|
"redirect_uri": callback, "state": state, "code_challenge": challenge,
|
|
"code_challenge_method": "S256",
|
|
})
|
|
browser_cookies = http.cookiejar.CookieJar()
|
|
browser = urllib.request.build_opener(
|
|
urllib.request.HTTPCookieProcessor(browser_cookies), StopAtCallback(callback)
|
|
)
|
|
with browser.open(authorization_endpoint + "?" + query, timeout=10) as response:
|
|
form = LoginForm()
|
|
form.feed(response.read().decode())
|
|
if not form.action:
|
|
raise AssertionError("Keycloak login form missing")
|
|
if urllib.parse.urlsplit(authorization_endpoint).scheme == "http":
|
|
allow_loopback_http_cookies(browser_cookies, authorization_endpoint)
|
|
form.values.update({"username": username, "password": password})
|
|
request = urllib.request.Request(form.action, data=urllib.parse.urlencode(form.values).encode())
|
|
try:
|
|
with browser.open(request, timeout=10) as response:
|
|
raise AssertionError(f"Keycloak did not redirect after login (HTTP {response.status})")
|
|
except urllib.error.HTTPError as response:
|
|
if response.code not in (302, 303):
|
|
status = response.code
|
|
response.close()
|
|
raise AssertionError(f"Keycloak rejected the login (HTTP {status})") from None
|
|
location = response.headers.get("Location", "")
|
|
response.close()
|
|
parameters = urllib.parse.parse_qs(urllib.parse.urlsplit(location).query)
|
|
if parameters.get("state") != [state] or not parameters.get("code"):
|
|
raise AssertionError("OIDC callback did not contain a valid code and state")
|
|
result = client.json("/manageSession", {
|
|
"code": parameters["code"][0], "redirectUri": callback, "codeVerifier": verifier
|
|
})
|
|
require_oidc_session(client, result)
|
|
if urllib.parse.urlsplit(client.base_url).scheme == "http":
|
|
allow_loopback_http_session(client)
|
|
except (OSError, KeyError, ValueError, urllib.error.HTTPError) as error:
|
|
raise AssertionError("OIDC login did not complete") from None
|
|
print(f"OIDC {username} login: OK")
|
|
|
|
|
|
def http_query_index(search_url, index_name, query):
|
|
request = urllib.request.Request(
|
|
search_url.rstrip("/") + "/" + urllib.parse.quote(index_name, safe="") + "/_search",
|
|
data=json.dumps(query).encode(), headers={"Content-Type": "application/json"},
|
|
)
|
|
with urllib.request.urlopen(request, timeout=5) as response:
|
|
return json.load(response)
|
|
|
|
|
|
def check_quick_search(client, query_index, index_name, timeout):
|
|
result = client.json("/processes/quickSearchFullReindex/run", {})
|
|
if result.get("exception"):
|
|
raise AssertionError("quick-search reindex failed")
|
|
|
|
def finds_customer():
|
|
query = {"query": {"bool": {"must": {"match": {"searchableText": "Ada"}},
|
|
"filter": {"term": {"sourceTable": "customer"}}}}}
|
|
hits = query_index(index_name, query)["hits"]["hits"]
|
|
return any("Ada Lovelace" in json.dumps(hit.get("_source", {})) for hit in hits)
|
|
|
|
wait_for("quick search seeded customer", finds_customer, timeout)
|
|
print("OpenSearch customer quick search: OK")
|
|
|
|
|
|
def check_rabbitmq(client, timeout, process_uuid):
|
|
def message_arrived():
|
|
offset = 0
|
|
while True:
|
|
suffix = "" if offset == 0 else f"?offset={offset}"
|
|
page = client.json("/qqq/v1/esb/messages/orderSyncEvents" + suffix)
|
|
messages = page.get("messages", [])
|
|
for message in messages:
|
|
event = message.get("event")
|
|
if not isinstance(event, dict):
|
|
continue
|
|
data = event.get("data")
|
|
if (event.get("type") == "qqq.process.syncOrder.completed"
|
|
and isinstance(data, dict)
|
|
and data.get("processName") == "syncOrder"
|
|
and data.get("processUUID") == process_uuid):
|
|
return True
|
|
if not page.get("hasMore"):
|
|
return False
|
|
if not messages:
|
|
raise ValueError("ESB browse returned an empty page with more messages")
|
|
offset += len(messages)
|
|
|
|
wait_for("RabbitMQ syncOrder completion", message_arrived, timeout)
|
|
print("RabbitMQ completion publication: OK")
|
|
|
|
|
|
def check_full(base_url, keycloak_url, query_index, timeout):
|
|
viewer = SmokeClient(base_url)
|
|
check_health_and_dashboard(viewer, timeout)
|
|
oidc_login(viewer, keycloak_url, "demo-user", os.environ["DEMO_USER_PASSWORD"])
|
|
viewer.require_record("customer", "name", "Ada Lovelace")
|
|
check_runtime_features(viewer, False)
|
|
admin = SmokeClient(base_url)
|
|
oidc_login(admin, keycloak_url, "demo-admin", os.environ["DEMO_ADMIN_PASSWORD"])
|
|
check_tables(admin, FULL_TABLES, require_seed=False)
|
|
check_runtime_features(admin)
|
|
check_quick_search(admin, query_index, os.environ.get("QQQ_ALL_OPENSEARCH_INDEX", "qqq-all-customers"), timeout)
|
|
process_uuid = check_artemis_round_trip(admin, timeout)
|
|
check_rabbitmq(admin, timeout, process_uuid)
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("profile", choices=("core", "full"))
|
|
parser.add_argument("--base-url", default="http://127.0.0.1:8080")
|
|
parser.add_argument("--timeout", type=int, default=90)
|
|
parser.add_argument("--keycloak-url", default="http://keycloak.localhost:8081/realms/qqq-all")
|
|
parser.add_argument("--opensearch-url")
|
|
args = parser.parse_args()
|
|
client = SmokeClient(args.base_url, CORE_ADMIN_SESSION if args.profile == "core" else None)
|
|
if args.profile == "core":
|
|
check_core(client, args.timeout)
|
|
else:
|
|
if not args.opensearch_url:
|
|
raise AssertionError("full profile needs --opensearch-url; use run_full_smoke.py for private Compose")
|
|
check_full(args.base_url, args.keycloak_url,
|
|
lambda index, query: http_query_index(args.opensearch_url, index, query),
|
|
args.timeout)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
try:
|
|
main()
|
|
except (AssertionError, OSError, ValueError, KeyError) as error:
|
|
print(f"smoke failed: {error}", file=sys.stderr)
|
|
raise SystemExit(1) from None
|