|
| 1 | +"""Integration test: WebServer route layer URL-decodes DurableExecutionArn. |
| 2 | +
|
| 3 | +Drives a real ``boto3`` Lambda client against a live ``WebServer`` and asserts |
| 4 | +that ``DurableExecutionArn`` values containing characters that boto |
| 5 | +percent-encodes in URI labels (e.g. ``/`` -> ``%2F``) round-trip correctly so |
| 6 | +the store lookup hits. |
| 7 | +""" |
| 8 | + |
| 9 | +from __future__ import annotations |
| 10 | + |
| 11 | +import threading |
| 12 | +import time |
| 13 | +from typing import Any |
| 14 | + |
| 15 | +import boto3 # type: ignore |
| 16 | +import pytest |
| 17 | +from botocore.config import Config # type: ignore |
| 18 | +from botocore.exceptions import ClientError # type: ignore |
| 19 | + |
| 20 | +from aws_durable_execution_sdk_python_testing.checkpoint.processor import ( |
| 21 | + CheckpointProcessor, |
| 22 | +) |
| 23 | +from aws_durable_execution_sdk_python_testing.execution import Execution |
| 24 | +from aws_durable_execution_sdk_python_testing.executor import Executor |
| 25 | +from aws_durable_execution_sdk_python_testing.model import ( |
| 26 | + StartDurableExecutionInput, |
| 27 | +) |
| 28 | +from aws_durable_execution_sdk_python_testing.scheduler import Scheduler |
| 29 | +from aws_durable_execution_sdk_python_testing.stores.memory import ( |
| 30 | + InMemoryExecutionStore, |
| 31 | +) |
| 32 | +from aws_durable_execution_sdk_python_testing.web.server import ( |
| 33 | + WebServer, |
| 34 | + WebServiceConfig, |
| 35 | +) |
| 36 | + |
| 37 | + |
| 38 | +class _NoOpInvoker: |
| 39 | + """Satisfies the Invoker protocol without invoking anything. |
| 40 | +
|
| 41 | + The route-layer regression doesn't depend on actually executing the |
| 42 | + function; the executor just needs *some* invoker to construct it. |
| 43 | + """ |
| 44 | + |
| 45 | + def create_invocation_input(self, execution: Any) -> Any: # noqa: ARG002 |
| 46 | + return None |
| 47 | + |
| 48 | + def invoke(self, *args: Any, **kwargs: Any) -> Any: # noqa: ARG002 |
| 49 | + return None |
| 50 | + |
| 51 | + def update_endpoint(self, *args: Any, **kwargs: Any) -> None: # noqa: ARG002 |
| 52 | + return None |
| 53 | + |
| 54 | + |
| 55 | +def _assert_no_percent_encoding_in_error(exc: ClientError, arn: str) -> None: |
| 56 | + """Fail the test if a ResourceNotFoundException carries a %2F-form ARN. |
| 57 | +
|
| 58 | + Other errors (e.g. invalid checkpoint token, wrong state) are fine; this |
| 59 | + test is narrowly about whether the route layer decoded the path segment. |
| 60 | + """ |
| 61 | + msg = str(exc) |
| 62 | + assert "%2F" not in msg, ( |
| 63 | + f"WebServer route layer did not URL-decode DurableExecutionArn. " |
| 64 | + f"Original ARN: {arn!r}. Error: {msg}" |
| 65 | + ) |
| 66 | + |
| 67 | + |
| 68 | +@pytest.fixture |
| 69 | +def server_with_slash_arn(): |
| 70 | + """Yield ``(boto_client, arn, executor, store)`` for a live WebServer. |
| 71 | +
|
| 72 | + The yielded ARN contains a literal ``/`` matching the v1.2.0+ format |
| 73 | + produced by ``Execution.new()``. The Execution is pre-started and saved |
| 74 | + so read paths have something to find. |
| 75 | + """ |
| 76 | + store = InMemoryExecutionStore() |
| 77 | + scheduler = Scheduler() |
| 78 | + checkpoint_processor = CheckpointProcessor(store=store, scheduler=scheduler) |
| 79 | + executor = Executor( |
| 80 | + store=store, |
| 81 | + scheduler=scheduler, |
| 82 | + invoker=_NoOpInvoker(), |
| 83 | + checkpoint_processor=checkpoint_processor, |
| 84 | + ) |
| 85 | + checkpoint_processor.add_execution_observer(executor) |
| 86 | + scheduler.start() |
| 87 | + |
| 88 | + # Hand-build a started Execution whose ARN contains '/' so we control |
| 89 | + # the format under test without going through executor.start_execution |
| 90 | + # (which schedules a real invoke + timeout). |
| 91 | + start_input = StartDurableExecutionInput( |
| 92 | + account_id="123456789012", |
| 93 | + function_name="test-fn", |
| 94 | + function_qualifier="$LATEST", |
| 95 | + execution_name="test-exec", |
| 96 | + execution_timeout_seconds=300, |
| 97 | + execution_retention_period_days=7, |
| 98 | + invocation_id="inv-12345", |
| 99 | + input='"hi"', |
| 100 | + ) |
| 101 | + execution = Execution.new(start_input) |
| 102 | + execution.start() |
| 103 | + store.save(execution) |
| 104 | + arn = execution.durable_execution_arn |
| 105 | + assert "/" in arn, "regression precondition: ARN must contain literal '/'" |
| 106 | + |
| 107 | + config = WebServiceConfig(host="127.0.0.1", port=0) |
| 108 | + server = WebServer(config, executor) |
| 109 | + port = server.server_address[1] |
| 110 | + server_thread = threading.Thread(target=server.serve_forever, daemon=True) |
| 111 | + server_thread.start() |
| 112 | + # Give the listener a beat to come up before the boto client connects. |
| 113 | + time.sleep(0.05) |
| 114 | + |
| 115 | + client = boto3.client( |
| 116 | + "lambda", |
| 117 | + endpoint_url=f"http://127.0.0.1:{port}", |
| 118 | + region_name="us-east-1", |
| 119 | + aws_access_key_id="x", # noqa: S106 - test stub |
| 120 | + aws_secret_access_key="y", # noqa: S106 - test stub |
| 121 | + config=Config(parameter_validation=False, retries={"max_attempts": 0}), |
| 122 | + ) |
| 123 | + |
| 124 | + try: |
| 125 | + yield client, arn, executor, store |
| 126 | + finally: |
| 127 | + server.shutdown() |
| 128 | + server.server_close() |
| 129 | + scheduler.stop() |
| 130 | + |
| 131 | + |
| 132 | +def test_get_durable_execution_decodes_slash_in_arn(server_with_slash_arn): |
| 133 | + """GetDurableExecution: %2F must be decoded so the store lookup hits.""" |
| 134 | + client, arn, _executor, _store = server_with_slash_arn |
| 135 | + |
| 136 | + response = client.get_durable_execution(DurableExecutionArn=arn) |
| 137 | + |
| 138 | + assert response["DurableExecutionArn"] == arn |
| 139 | + |
| 140 | + |
| 141 | +def test_get_durable_execution_state_decodes_slash_in_arn(server_with_slash_arn): |
| 142 | + """GetDurableExecutionState: %2F must be decoded so the store lookup hits.""" |
| 143 | + client, arn, _executor, _store = server_with_slash_arn |
| 144 | + |
| 145 | + response = client.get_durable_execution_state( |
| 146 | + DurableExecutionArn=arn, |
| 147 | + CheckpointToken="ignored-by-route-layer", # noqa: S106 - test stub |
| 148 | + ) |
| 149 | + |
| 150 | + # Response shape varies; the only assertion this test cares about is |
| 151 | + # that we got past route resolution. |
| 152 | + assert response is not None |
| 153 | + |
| 154 | + |
| 155 | +def test_get_durable_execution_history_decodes_slash_in_arn(server_with_slash_arn): |
| 156 | + """GetDurableExecutionHistory: %2F must be decoded so the store lookup hits.""" |
| 157 | + client, arn, _executor, _store = server_with_slash_arn |
| 158 | + |
| 159 | + response = client.get_durable_execution_history(DurableExecutionArn=arn) |
| 160 | + |
| 161 | + assert response is not None |
| 162 | + |
| 163 | + |
| 164 | +def test_checkpoint_durable_execution_decodes_slash_in_arn(server_with_slash_arn): |
| 165 | + """CheckpointDurableExecution: %2F must be decoded so the store lookup hits. |
| 166 | +
|
| 167 | + A checkpoint with no operation updates may still trip secondary |
| 168 | + validation; we only assert the failure (if any) is not the |
| 169 | + %2F-in-message 404 that indicates the route layer dropped the ball. |
| 170 | + """ |
| 171 | + client, arn, _executor, store = server_with_slash_arn |
| 172 | + execution = store.load(arn) |
| 173 | + token = execution.get_new_checkpoint_token() |
| 174 | + |
| 175 | + try: |
| 176 | + client.checkpoint_durable_execution( |
| 177 | + DurableExecutionArn=arn, |
| 178 | + CheckpointToken=token, |
| 179 | + Updates=[], |
| 180 | + ) |
| 181 | + except ClientError as exc: |
| 182 | + _assert_no_percent_encoding_in_error(exc, arn) |
| 183 | + |
| 184 | + |
| 185 | +def test_stop_durable_execution_decodes_slash_in_arn(server_with_slash_arn): |
| 186 | + """StopDurableExecution: %2F must be decoded so the store lookup hits.""" |
| 187 | + client, arn, _executor, _store = server_with_slash_arn |
| 188 | + |
| 189 | + try: |
| 190 | + client.stop_durable_execution(DurableExecutionArn=arn) |
| 191 | + except ClientError as exc: |
| 192 | + _assert_no_percent_encoding_in_error(exc, arn) |
| 193 | + |
| 194 | + |
| 195 | +def test_list_durable_executions_by_function_decodes_colon_in_name( |
| 196 | + server_with_slash_arn, |
| 197 | +): |
| 198 | + """ListDurableExecutionsByFunction: %3A/%24 in FunctionName must be decoded. |
| 199 | +
|
| 200 | + boto percent-encodes ``:`` and ``$`` in the non-greedy ``{FunctionName}`` |
| 201 | + URI label, so a realistic value like ``MyFunction:$LATEST`` arrives as |
| 202 | + ``MyFunction%3A%24LATEST``. The route layer must decode the segment so |
| 203 | + the store's exact-match filter on ``function_name`` returns the expected |
| 204 | + execution. |
| 205 | +
|
| 206 | + Pre-fix behavior: handler filters on the encoded string, response has |
| 207 | + no executions. Post-fix: handler filters on the decoded string, response |
| 208 | + returns the seeded execution. |
| 209 | + """ |
| 210 | + client, _arn, _executor, store = server_with_slash_arn |
| 211 | + |
| 212 | + # Seed an execution whose function_name contains characters boto encodes. |
| 213 | + realistic_function_name = "MyFunction:$LATEST" |
| 214 | + seed = StartDurableExecutionInput( |
| 215 | + account_id="123456789012", |
| 216 | + function_name=realistic_function_name, |
| 217 | + function_qualifier="$LATEST", |
| 218 | + execution_name="encoded-fn-exec", |
| 219 | + execution_timeout_seconds=300, |
| 220 | + execution_retention_period_days=7, |
| 221 | + invocation_id="inv-encoded-fn", |
| 222 | + input='"hi"', |
| 223 | + ) |
| 224 | + seeded = Execution.new(seed) |
| 225 | + seeded.start() |
| 226 | + store.save(seeded) |
| 227 | + |
| 228 | + response = client.list_durable_executions_by_function( |
| 229 | + FunctionName=realistic_function_name, |
| 230 | + ) |
| 231 | + |
| 232 | + arns = [e["DurableExecutionArn"] for e in response.get("DurableExecutions", [])] |
| 233 | + assert seeded.durable_execution_arn in arns, ( |
| 234 | + f"WebServer route layer did not URL-decode FunctionName. " |
| 235 | + f"Seeded function_name {realistic_function_name!r} produced arn " |
| 236 | + f"{seeded.durable_execution_arn!r}, but list response contained " |
| 237 | + f"{arns!r}." |
| 238 | + ) |
0 commit comments