diff --git a/scripts/container_release.py b/scripts/container_release.py index 244c441..c13f47b 100644 --- a/scripts/container_release.py +++ b/scripts/container_release.py @@ -24,6 +24,7 @@ ELSA_VERSION = re.compile(rf"^3\.{_NUM}\.{_NUM}(?:-{_PRERELEASE_ID}(?:\.{_PRERELEASE_ID})*)?$") REQUIRED_PLATFORMS = ("linux/amd64", "linux/arm64") APPS_REPOSITORY = "elsa-workflows/elsa-apps" +BEARER_API_ENDPOINT = "/elsa/api/workflow-definitions?page=0&pageSize=1" EXTENSION_PACKAGES = ( "Elsa.Agents.", "Elsa.Logging.", @@ -479,27 +480,46 @@ def login_and_probe_api(url: str, username: str, password: str) -> dict[str, Any with urllib.request.urlopen(login_request, timeout=15) as response: login_status = response.status login = json.loads(response.read()) - except (urllib.error.URLError, urllib.error.HTTPError, json.JSONDecodeError) as error: - raise ReleaseError(f"Synthetic admin login failed: {error}") from error + except (urllib.error.URLError, urllib.error.HTTPError, TimeoutError, json.JSONDecodeError) as error: + raise ReleaseError( + f"Synthetic admin login request to /elsa/api/identity/login failed: {error}" + ) from error access_token = login.get("accessToken") or login.get("AccessToken") if login_status != 200 or not access_token: raise ReleaseError("Synthetic admin login did not return an access token") + endpoint = BEARER_API_ENDPOINT probe = urllib.request.Request( - f"{api_root}/identity/me/permissions", + f"{url.rstrip('/')}{endpoint}", headers={"Authorization": f"Bearer {access_token}"}, ) try: with urllib.request.urlopen(probe, timeout=15) as response: api_status = response.status - response.read() - except (urllib.error.URLError, urllib.error.HTTPError) as error: - raise ReleaseError(f"Bearer-authenticated identity API probe failed: {error}") from error - if api_status != 200: - raise ReleaseError(f"Bearer-authenticated identity API returned HTTP {api_status}") + content_type = response.headers.get("Content-Type", "").lower() + result = json.loads(response.read()) + except (urllib.error.URLError, urllib.error.HTTPError, TimeoutError) as error: + raise ReleaseError( + f"Bearer-authenticated workflow definitions API request to {endpoint} failed: {error}" + ) from error + except json.JSONDecodeError as error: + raise ReleaseError( + f"Bearer-authenticated workflow definitions API request to {endpoint} returned invalid JSON" + ) from error + items = result.get("items", result.get("Items")) if isinstance(result, dict) else None + total_count = result.get("totalCount", result.get("TotalCount")) if isinstance(result, dict) else None + if ( + api_status != 200 + or not content_type.startswith("application/json") + or not isinstance(items, list) + or not isinstance(total_count, int) + ): + raise ReleaseError( + f"Bearer-authenticated workflow definitions API request to {endpoint} did not return a paged JSON response" + ) return { "identityLogin": {"status": login_status, "endpoint": "/elsa/api/identity/login"}, - "bearerApi": {"status": api_status, "endpoint": "/elsa/api/identity/me/permissions"}, + "bearerApi": {"status": api_status, "endpoint": endpoint}, } @@ -511,8 +531,8 @@ def verify_smoke_assets(url: str, asset_paths: Iterable[str]) -> list[dict[str, body = response.read() status = response.status content_type = response.headers.get("Content-Type", "").lower() - except (urllib.error.URLError, urllib.error.HTTPError) as error: - raise ReleaseError(f"Browser framework asset {path} is unavailable: {error}") from error + except (urllib.error.URLError, urllib.error.HTTPError, TimeoutError) as error: + raise ReleaseError(f"Browser framework asset request for {path} failed: {error}") from error if status != 200 or len(body) < 100: raise ReleaseError(f"Browser framework asset {path} returned HTTP {status} or an empty body") if "javascript" not in content_type and "ecmascript" not in content_type: @@ -550,23 +570,24 @@ def smoke_image( if auth_enabled else [] ) - run = run_command( - [ - "docker", - "run", - "--detach", - *platform_args, - "--publish", - f"127.0.0.1::{port}", - "--env", - "ASPNETCORE_ENVIRONMENT=Production", - *auth_args, - reference, - ], - env=run_env, - ) - container_id = run.stdout.strip() + container_id = "" try: + run = run_command( + [ + "docker", + "run", + "--detach", + *platform_args, + "--publish", + f"127.0.0.1::{port}", + "--env", + "ASPNETCORE_ENVIRONMENT=Production", + *auth_args, + reference, + ], + env=run_env, + ) + container_id = run.stdout.strip() url, status = wait_for_http(container_id, port) image_digest = image_digest or (reference.rsplit("@", 1)[-1] if "@" in reference else reference) result = { @@ -580,8 +601,11 @@ def smoke_image( if auth_enabled: result.update(login_and_probe_api(url, username, password)) return result + except ReleaseError as error: + raise ReleaseError(f"Smoke test failed for {reference} on {platform}: {error}") from error finally: - run_command(["docker", "rm", "--force", container_id], check=False) + if container_id: + run_command(["docker", "rm", "--force", container_id], check=False) def verify_registry_image( @@ -777,7 +801,7 @@ def validate_fragment_evidence( row.get("identityLogin", {}).get("status") != 200 or row.get("bearerApi", {}).get("status") != 200 or row.get("identityLogin", {}).get("endpoint") != "/elsa/api/identity/login" - or row.get("bearerApi", {}).get("endpoint") != "/elsa/api/identity/me/permissions" + or row.get("bearerApi", {}).get("endpoint") != BEARER_API_ENDPOINT ): raise ReleaseError(f"Image evidence for {image['name']} lacks successful authenticated API smoke") diff --git a/scripts/tests/test_container_release.py b/scripts/tests/test_container_release.py index 2cc8331..936fea3 100644 --- a/scripts/tests/test_container_release.py +++ b/scripts/tests/test_container_release.py @@ -224,7 +224,7 @@ def server_fragment(): "httpStatus": 200, "browserAssets": [], "identityLogin": {"status": 200, "endpoint": "/elsa/api/identity/login"}, - "bearerApi": {"status": 200, "endpoint": "/elsa/api/identity/me/permissions"}, + "bearerApi": {"status": 200, "endpoint": "/elsa/api/workflow-definitions?page=0&pageSize=1"}, } for platform, digest in PLATFORMS.items() ] @@ -390,6 +390,79 @@ def test_rejects_html_fallback_for_missing_javascript_asset(self): release.verify_smoke_assets("http://127.0.0.1:1234", ["/_framework/dotnet.js"]) +class AuthenticatedApiSmokeTests(unittest.TestCase): + class Response: + status = 200 + + def __init__(self, body, content_type="application/json"): + self.body = body + self.headers = {"Content-Type": content_type} + + def __enter__(self): + return self + + def __exit__(self, *_): + return False + + def read(self): + return self.body + + def test_uses_bearer_protected_workflow_definitions_api_and_validates_paged_json(self): + login = self.Response(b'{"accessToken":"synthetic-token"}') + definitions = self.Response(b'{"items":[],"totalCount":0}') + requests = [] + + def open_url(request, timeout): + requests.append(request) + return (login, definitions)[len(requests) - 1] + + with patch.object(release.urllib.request, "urlopen", side_effect=open_url): + result = release.login_and_probe_api("http://127.0.0.1:1234/", "smoke", "secret") + + self.assertEqual(requests[0].full_url, "http://127.0.0.1:1234/elsa/api/identity/login") + self.assertEqual(requests[0].get_method(), "POST") + self.assertEqual( + requests[1].full_url, + "http://127.0.0.1:1234/elsa/api/workflow-definitions?page=0&pageSize=1", + ) + self.assertEqual(requests[1].get_method(), "GET") + self.assertEqual(requests[1].get_header("Authorization"), "Bearer synthetic-token") + self.assertEqual(result["bearerApi"]["status"], 200) + + def test_rejects_html_fallback_even_when_it_returns_http_200(self): + login = self.Response(b'{"accessToken":"synthetic-token"}') + fallback = self.Response(b'Studio', "text/html; charset=utf-8") + with patch.object(release.urllib.request, "urlopen", side_effect=[login, fallback]): + with self.assertRaisesRegex(release.ReleaseError, "invalid JSON"): + release.login_and_probe_api("http://127.0.0.1:1234", "smoke", "secret") + + def test_rejects_non_paged_json_response(self): + login = self.Response(b'{"accessToken":"synthetic-token"}') + invalid = self.Response(b'{"message":"not the workflow list"}') + with patch.object(release.urllib.request, "urlopen", side_effect=[login, invalid]): + with self.assertRaisesRegex(release.ReleaseError, "paged JSON response"): + release.login_and_probe_api("http://127.0.0.1:1234", "smoke", "secret") + + def test_login_and_api_timeouts_name_the_failed_endpoint(self): + with patch.object(release.urllib.request, "urlopen", side_effect=TimeoutError("timed out")): + with self.assertRaisesRegex(release.ReleaseError, "identity/login failed"): + release.login_and_probe_api("http://127.0.0.1:1234", "smoke", "secret") + + login = self.Response(b'{"accessToken":"synthetic-token"}') + with patch.object(release.urllib.request, "urlopen", side_effect=[login, TimeoutError("timed out")]): + with self.assertRaisesRegex(release.ReleaseError, "workflow definitions API request.*failed"): + release.login_and_probe_api("http://127.0.0.1:1234", "smoke", "secret") + + def test_asset_timeout_names_the_failed_asset(self): + with patch.object( + release.urllib.request, + "urlopen", + side_effect=TimeoutError("timed out"), + ): + with self.assertRaisesRegex(release.ReleaseError, "asset request for /_framework/dotnet.js failed"): + release.verify_smoke_assets("http://127.0.0.1:1234", ["/_framework/dotnet.js"]) + + class SmokeCredentialTests(unittest.TestCase): def test_ephemeral_credentials_are_shared_with_login_without_command_line_exposure(self): with ( @@ -411,6 +484,26 @@ def test_ephemeral_credentials_are_shared_with_login_without_command_line_exposu self.assertEqual(len(passwords), 2) self.assertNotEqual(*passwords) + def test_smoke_errors_include_image_and_platform_without_credentials(self): + with ( + patch.object(release.secrets, "token_urlsafe", return_value="known-smoke-password"), + patch.object(release, "run_command", return_value=Mock(stdout="test-container")), + patch.object(release, "wait_for_http", return_value=("http://127.0.0.1:1234/", 200)), + patch.object(release, "login_and_probe_api", side_effect=release.ReleaseError("API request timed out")), + ): + with self.assertRaisesRegex( + release.ReleaseError, + r"registry/image:3\.8\.4 on linux/arm64: API request timed out", + ) as error: + release.smoke_image( + "registry/image:3.8.4", + "linux/arm64", + 8080, + auth_enabled=True, + ) + self.assertNotIn("known-smoke-password", str(error.exception)) + self.assertNotIn("container-smoke", str(error.exception)) + if __name__ == "__main__": unittest.main()