From 9bb5b0627943119123ca8790b9b1cbc4120a752c Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Thu, 23 Apr 2026 13:12:37 -0700 Subject: [PATCH 1/6] Run DownloadURL conformance in Python, Ruby, and TypeScript Wire DownloadURL dispatch into the three non-Go runners and route the mock catch-all so the relative second hop matches. Three of the five downloads.json cases now run live (302 to signed URL, direct 2xx body, no-Location protocol error). Two retry cases stay skipped, but with capability-based reasons: each SDK's download path (Python's get_no_retry, Ruby's http.get_no_retry, TS's raw fetch bypassing the retry middleware) doesn't yet implement 5xx / Retry-After retry, so unskipping would guarantee red. Go runner is unchanged. --- conformance/runner/python/runner.py | 23 ++++++++--------- conformance/runner/ruby/runner.rb | 26 +++++++++++--------- conformance/runner/typescript/runner.test.ts | 22 ++++++++++------- 3 files changed, 39 insertions(+), 32 deletions(-) diff --git a/conformance/runner/python/runner.py b/conformance/runner/python/runner.py index 7179a4ab..96076a0f 100644 --- a/conformance/runner/python/runner.py +++ b/conformance/runner/python/runner.py @@ -64,8 +64,11 @@ class OperationMapper: def __init__(self, account_client): self._account = account_client - def __call__(self, operation: str, *, path_params: dict, query_params: dict, body: dict | None) -> Any: + def __call__(self, operation: str, *, path_params: dict, query_params: dict, body: dict | None, path: str = "") -> Any: match operation: + case "DownloadURL": + raw_url = "https://storage.3.basecamp.com" + path + return self._account.download_url(raw_url) case "ListProjects": return self._account.projects.list() case "GetProject": @@ -144,6 +147,7 @@ def run(self) -> TestResult: path_params=self._test.get("pathParams", {}), query_params=self._test.get("queryParams", {}), body=self._test.get("requestBody"), + path=self._test.get("path", ""), ) return self._verify_assertions(result=result, error=None) except Exception as e: @@ -184,7 +188,10 @@ def side_effect(request: httpx.Request) -> httpx.Response: else: return httpx.Response(500, content=b'{"error":"No more mock responses"}', headers={"Content-Type": "application/json"}) - respx.route(method=method, url__regex=f".*{re.escape(path)}.*").mock(side_effect=side_effect) + if self._test["operation"] == "DownloadURL": + respx.route(method=method).mock(side_effect=side_effect) + else: + respx.route(method=method, url__regex=f".*{re.escape(path)}.*").mock(side_effect=side_effect) def _auto_paginates(self) -> bool: return any( @@ -372,24 +379,18 @@ def _get_error_field(error: Exception, field_path: str) -> Any: class ConformanceRunner: - _DOWNLOAD_SKIP = "Python runner does not yet dispatch DownloadURL (tracked as follow-up)" + _DOWNLOAD_RETRY_SKIP = "Python SDK download path uses get_no_retry; retry on 5xx / Retry-After is not implemented" _MULTIHOP_SKIP = "Python runner's respx stub matches a single path; multi-hop download fixtures need per-hop stub wiring (tracked as follow-up with DownloadURL)" SKIPS: set[str] = { "maxItems caps results across pages", - "DownloadURL auth'd first hop 302s to signed URL", - "DownloadURL direct 2xx body", "DownloadURL retries on 503 at the auth'd first hop", "DownloadURL honors Retry-After on 429 at the auth'd first hop", - "DownloadURL surfaces redirect with no Location", "UploadsDownload delegates through DownloadURL primitive", } SKIP_REASONS: dict[str, str] = { "maxItems caps results across pages": "Python SDK list methods don't expose a public max_items parameter", - "DownloadURL auth'd first hop 302s to signed URL": _DOWNLOAD_SKIP, - "DownloadURL direct 2xx body": _DOWNLOAD_SKIP, - "DownloadURL retries on 503 at the auth'd first hop": _DOWNLOAD_SKIP, - "DownloadURL honors Retry-After on 429 at the auth'd first hop": _DOWNLOAD_SKIP, - "DownloadURL surfaces redirect with no Location": _DOWNLOAD_SKIP, + "DownloadURL retries on 503 at the auth'd first hop": _DOWNLOAD_RETRY_SKIP, + "DownloadURL honors Retry-After on 429 at the auth'd first hop": _DOWNLOAD_RETRY_SKIP, "UploadsDownload delegates through DownloadURL primitive": _MULTIHOP_SKIP, } diff --git a/conformance/runner/ruby/runner.rb b/conformance/runner/ruby/runner.rb index 23e5a549..4b8230d2 100644 --- a/conformance/runner/ruby/runner.rb +++ b/conformance/runner/ruby/runner.rb @@ -66,8 +66,11 @@ def initialize(account_client) @account = account_client end - def call(operation, path_params: {}, query_params: {}, body: nil) + def call(operation, path_params: {}, query_params: {}, body: nil, path: "") case operation + when "DownloadURL" + raw_url = "https://storage.3.basecamp.com" + path + @account.download_url(raw_url) when "ListProjects" @account.projects.list.to_a when "GetProject" @@ -170,15 +173,12 @@ def call(operation, path_params: {}, query_params: {}, body: nil) "Missing X-Total-Count returns zero", "Pagination stops at maxPages safety cap", "maxItems caps results across pages", - "DownloadURL auth'd first hop 302s to signed URL", - "DownloadURL direct 2xx body", "DownloadURL retries on 503 at the auth'd first hop", "DownloadURL honors Retry-After on 429 at the auth'd first hop", - "DownloadURL surfaces redirect with no Location", "UploadsDownload delegates through DownloadURL primitive", ].freeze) -DOWNLOAD_SKIP = "Ruby runner does not yet dispatch DownloadURL (tracked as follow-up)".freeze +DOWNLOAD_RETRY_SKIP = "Ruby SDK download path uses http.get_no_retry; retry on 5xx / Retry-After is not implemented".freeze MULTIHOP_SKIP = "Ruby runner's WebMock stub matches a single path; multi-hop download fixtures need per-hop stub wiring (tracked as follow-up with DownloadURL)".freeze RUBY_SKIP_REASONS = { "PUT operation is naturally idempotent" => "Ruby SDK only retries GET", @@ -187,11 +187,8 @@ def call(operation, path_params: {}, query_params: {}, body: nil) "Missing X-Total-Count returns zero" => "Ruby SDK paginate doesn't expose X-Total-Count metadata", "Pagination stops at maxPages safety cap" => "Ruby SDK paginate doesn't expose truncation metadata", "maxItems caps results across pages" => "Ruby SDK paginate doesn't support maxItems", - "DownloadURL auth'd first hop 302s to signed URL" => DOWNLOAD_SKIP, - "DownloadURL direct 2xx body" => DOWNLOAD_SKIP, - "DownloadURL retries on 503 at the auth'd first hop" => DOWNLOAD_SKIP, - "DownloadURL honors Retry-After on 429 at the auth'd first hop" => DOWNLOAD_SKIP, - "DownloadURL surfaces redirect with no Location" => DOWNLOAD_SKIP, + "DownloadURL retries on 503 at the auth'd first hop" => DOWNLOAD_RETRY_SKIP, + "DownloadURL honors Retry-After on 429 at the auth'd first hop" => DOWNLOAD_RETRY_SKIP, "UploadsDownload delegates through DownloadURL primitive" => MULTIHOP_SKIP, }.freeze @@ -212,7 +209,8 @@ def run @test["operation"], path_params: @test["pathParams"] || {}, query_params: @test["queryParams"] || {}, - body: @test["requestBody"] + body: @test["requestBody"], + path: @test["path"] || "" ) verify_assertions(result: result, error: nil) rescue StandardError => e @@ -243,7 +241,11 @@ def setup_mock_responses # Register the stub with a block to track requests and return queued responses method = @test["method"]&.downcase&.to_sym || :get - url_pattern = %r{#{Regexp.escape(path)}} + url_pattern = if @test["operation"] == "DownloadURL" + %r{.*} + else + %r{#{Regexp.escape(path)}} + end stub = WebMock.stub_request(method, url_pattern) diff --git a/conformance/runner/typescript/runner.test.ts b/conformance/runner/typescript/runner.test.ts index 6efd83f7..4cbef225 100644 --- a/conformance/runner/typescript/runner.test.ts +++ b/conformance/runner/typescript/runner.test.ts @@ -71,16 +71,10 @@ const TS_SDK_SKIPS: Record = { "TS SDK retry middleware yields at most 1 retry per middleware pass", "Large integer IDs preserved without precision loss": "JavaScript loses precision on integers > Number.MAX_SAFE_INTEGER (2^53)", - "DownloadURL auth'd first hop 302s to signed URL": - "TS runner does not yet dispatch DownloadURL (tracked as follow-up)", - "DownloadURL direct 2xx body": - "TS runner does not yet dispatch DownloadURL (tracked as follow-up)", "DownloadURL retries on 503 at the auth'd first hop": - "TS runner does not yet dispatch DownloadURL (tracked as follow-up)", + "TS SDK downloadURL uses raw fetch bypassing the retry middleware; 5xx / Retry-After retry is not implemented", "DownloadURL honors Retry-After on 429 at the auth'd first hop": - "TS runner does not yet dispatch DownloadURL (tracked as follow-up)", - "DownloadURL surfaces redirect with no Location": - "TS runner does not yet dispatch DownloadURL (tracked as follow-up)", + "TS SDK downloadURL uses raw fetch bypassing the retry middleware; 5xx / Retry-After retry is not implemented", "UploadsDownload delegates through DownloadURL primitive": "TS runner's MSW stub matches a single path; multi-hop download fixtures need per-hop stub wiring (tracked as follow-up with DownloadURL)", }; @@ -232,8 +226,18 @@ async function executeOperation( break; } + case "DownloadURL": { + const rawURL = "https://storage.3.basecamp.com" + tc.path; + const result = await client.downloadURL(rawURL); + // Fire-and-forget cancel — matches typescript/tests/download.test.ts. + // Awaiting MSW's mocked ReadableStream.cancel() can hang past vitest's + // default 5s test timeout, so don't await it here. + result.body.cancel(); + return {}; + } + default: - throw new Error(`Unknown operation: ${tc.operation}`); + throw new Error(`Unknown operation: ${tc.operation}`); } // Success path: no error From 7785a62566581142a1f4566f2131314030c6b036 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Thu, 23 Apr 2026 13:30:21 -0700 Subject: [PATCH 2/6] Constrain DownloadURL mock catch-all to the API host Tighten Python and Ruby DownloadURL mock routes from "any URL on this method" to "any URL on https://3.basecampapi.com". The catch-all has to remain path-flexible because hop 2 lands on a relative Location resolved against the rewritten first-hop URL, but it doesn't have to be host-flexible. A misroute to a different origin now fails instead of silently consuming a queued response. --- conformance/runner/python/runner.py | 6 +++++- conformance/runner/ruby/runner.rb | 6 +++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/conformance/runner/python/runner.py b/conformance/runner/python/runner.py index 96076a0f..d1bd8642 100644 --- a/conformance/runner/python/runner.py +++ b/conformance/runner/python/runner.py @@ -189,7 +189,11 @@ def side_effect(request: httpx.Request) -> httpx.Response: return httpx.Response(500, content=b'{"error":"No more mock responses"}', headers={"Content-Type": "application/json"}) if self._test["operation"] == "DownloadURL": - respx.route(method=method).mock(side_effect=side_effect) + # Catch-all on the API host: the SDK rewrites the synthetic download + # URL onto base_url, then resolves a relative Location to a second + # path on the same host. Constraining to the host ensures a misroute + # to a different origin fails instead of consuming a queued response. + respx.route(method=method, url__regex=r"https://3\.basecampapi\.com/.*").mock(side_effect=side_effect) else: respx.route(method=method, url__regex=f".*{re.escape(path)}.*").mock(side_effect=side_effect) diff --git a/conformance/runner/ruby/runner.rb b/conformance/runner/ruby/runner.rb index 4b8230d2..7e0ebd26 100644 --- a/conformance/runner/ruby/runner.rb +++ b/conformance/runner/ruby/runner.rb @@ -242,7 +242,11 @@ def setup_mock_responses # Register the stub with a block to track requests and return queued responses method = @test["method"]&.downcase&.to_sym || :get url_pattern = if @test["operation"] == "DownloadURL" - %r{.*} + # Catch-all on the API host: the SDK rewrites the synthetic download + # URL onto base_url, then resolves a relative Location to a second + # path on the same host. Constraining to the host ensures a misroute + # to a different origin fails instead of consuming a queued response. + %r{\Ahttps://3\.basecampapi\.com/} else %r{#{Regexp.escape(path)}} end From be24d7e3ab0c64fa02f0a03921a6fd184a503b5a Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Thu, 23 Apr 2026 13:52:05 -0700 Subject: [PATCH 3/6] Derive DownloadURL mock host from configOverrides; guard TS path Address review feedback on the catch-all routing and the TS dispatcher: - Python and Ruby DownloadURL routes now derive their host from the active test client's base URL (configOverrides.baseUrl when set, else the default https://3.basecampapi.com). A future DownloadURL case with configOverrides would otherwise route through a different origin and the mock would silently not match. - TypeScript DownloadURL dispatch now throws if tc.path is empty rather than silently concatenating "undefined" into the synthetic URL. The TestCase interface marks path as optional, so the type system doesn't catch this. --- conformance/runner/python/runner.py | 15 ++++++++++----- conformance/runner/ruby/runner.rb | 18 +++++++++++++----- conformance/runner/typescript/runner.test.ts | 3 +++ 3 files changed, 26 insertions(+), 10 deletions(-) diff --git a/conformance/runner/python/runner.py b/conformance/runner/python/runner.py index d1bd8642..e8160e0e 100644 --- a/conformance/runner/python/runner.py +++ b/conformance/runner/python/runner.py @@ -189,11 +189,16 @@ def side_effect(request: httpx.Request) -> httpx.Response: return httpx.Response(500, content=b'{"error":"No more mock responses"}', headers={"Content-Type": "application/json"}) if self._test["operation"] == "DownloadURL": - # Catch-all on the API host: the SDK rewrites the synthetic download - # URL onto base_url, then resolves a relative Location to a second - # path on the same host. Constraining to the host ensures a misroute - # to a different origin fails instead of consuming a queued response. - respx.route(method=method, url__regex=r"https://3\.basecampapi\.com/.*").mock(side_effect=side_effect) + # Catch-all on the active client's host: the SDK rewrites the synthetic + # download URL onto base_url, then resolves a relative Location to a + # second path on the same host. Constraining to the origin (derived + # from configOverrides.baseUrl when present) ensures a misroute to a + # different host fails instead of consuming a queued response. + overrides = self._test.get("configOverrides") or {} + download_base = overrides.get("baseUrl", "https://3.basecampapi.com") + parsed = urlparse(download_base) + origin = f"{parsed.scheme}://{parsed.netloc}" + respx.route(method=method, url__regex=rf"{re.escape(origin)}/.*").mock(side_effect=side_effect) else: respx.route(method=method, url__regex=f".*{re.escape(path)}.*").mock(side_effect=side_effect) diff --git a/conformance/runner/ruby/runner.rb b/conformance/runner/ruby/runner.rb index 7e0ebd26..c4e76689 100644 --- a/conformance/runner/ruby/runner.rb +++ b/conformance/runner/ruby/runner.rb @@ -242,11 +242,19 @@ def setup_mock_responses # Register the stub with a block to track requests and return queued responses method = @test["method"]&.downcase&.to_sym || :get url_pattern = if @test["operation"] == "DownloadURL" - # Catch-all on the API host: the SDK rewrites the synthetic download - # URL onto base_url, then resolves a relative Location to a second - # path on the same host. Constraining to the host ensures a misroute - # to a different origin fails instead of consuming a queued response. - %r{\Ahttps://3\.basecampapi\.com/} + # Catch-all on the active client's host: the SDK rewrites the synthetic + # download URL onto base_url, then resolves a relative Location to a + # second path on the same host. Constraining to the origin (derived + # from configOverrides.baseUrl when present) ensures a misroute to a + # different host fails instead of consuming a queued response. + overrides = @test["configOverrides"] || {} + download_base = overrides["baseUrl"] || "https://3.basecampapi.com" + download_uri = URI.parse(download_base) + port_part = download_uri.port && download_uri.port != download_uri.default_port \ + ? ":#{download_uri.port}" \ + : "" + download_origin = "#{download_uri.scheme}://#{download_uri.host}#{port_part}" + %r{\A#{Regexp.escape(download_origin)}/} else %r{#{Regexp.escape(path)}} end diff --git a/conformance/runner/typescript/runner.test.ts b/conformance/runner/typescript/runner.test.ts index 4cbef225..e293fb5e 100644 --- a/conformance/runner/typescript/runner.test.ts +++ b/conformance/runner/typescript/runner.test.ts @@ -227,6 +227,9 @@ async function executeOperation( } case "DownloadURL": { + if (!tc.path) { + throw new Error("DownloadURL test case requires a non-empty path"); + } const rawURL = "https://storage.3.basecamp.com" + tc.path; const result = await client.downloadURL(rawURL); // Fire-and-forget cancel — matches typescript/tests/download.test.ts. From add32453d18b0d85f8da73bf5cca0d904ac64cf1 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Thu, 23 Apr 2026 14:48:41 -0700 Subject: [PATCH 4/6] Support headerAbsent and indexed header assertions The base branch added a headerAbsent assertion type with an optional index field (0-based; negative counts from end), so the happy-path DownloadURL case can now assert Authorization is present on the auth'd hop and absent on the unauthenticated signed-URL hop. Mirror the Go runner: thread index through headerPresent/headerInjected (default 0 preserves prior behavior) and add headerAbsent in Python, Ruby, and TypeScript. --- conformance/runner/python/runner.py | 43 ++++++++++--- conformance/runner/ruby/runner.rb | 43 +++++++++---- conformance/runner/typescript/runner.test.ts | 63 +++++++++++++++----- 3 files changed, 117 insertions(+), 32 deletions(-) diff --git a/conformance/runner/python/runner.py b/conformance/runner/python/runner.py index e8160e0e..bc1e07a2 100644 --- a/conformance/runner/python/runner.py +++ b/conformance/runner/python/runner.py @@ -208,6 +208,18 @@ def _auto_paginates(self) -> bool: for r in self._test.get("mockResponses", []) ) + def _request_headers_at(self, index: int) -> dict | None: + """Return captured headers at index (0-based; negative counts from end), or None if out of range.""" + requests = self._tracker.requests + n = len(requests) + if n == 0: + return None + if index < 0: + index += n + if index < 0 or index >= n: + return None + return requests[index]["headers"] + def _verify_assertions(self, *, result: Any, error: Exception | None) -> TestResult: failures: list[str] = [] @@ -308,21 +320,36 @@ def _verify_assertions(self, *, result: Any, error: Exception | None) -> TestRes case "headerInjected": header_name = assertion["path"] expected = assertion["expected"] - if not self._tracker.requests: - failures.append(f"Expected header {header_name}={expected!r}, but no requests recorded") + idx = assertion.get("index", 0) + headers = self._request_headers_at(idx) + if headers is None: + failures.append(f"Expected header {header_name}={expected!r} on request index {idx}, but only {self._tracker.request_count} requests were recorded") else: - actual = self._tracker.requests[0]["headers"].get(header_name.lower()) + actual = headers.get(header_name.lower()) if actual != expected: - failures.append(f"Expected header {header_name}={expected!r}, got {actual!r}") + failures.append(f"Expected header {header_name}={expected!r} on request index {idx}, got {actual!r}") case "headerPresent": header_name = assertion["path"] - if not self._tracker.requests: - failures.append(f"Expected header {header_name} to be present, but no requests recorded") + idx = assertion.get("index", 0) + headers = self._request_headers_at(idx) + if headers is None: + failures.append(f"Expected header {header_name} on request index {idx}, but only {self._tracker.request_count} requests were recorded") else: - actual = self._tracker.requests[0]["headers"].get(header_name.lower()) + actual = headers.get(header_name.lower()) if not actual: - failures.append(f"Expected header {header_name} to be present, but it was missing") + failures.append(f"Expected header {header_name} on request index {idx}, but it was empty or missing") + + case "headerAbsent": + header_name = assertion["path"] + idx = assertion.get("index", 0) + headers = self._request_headers_at(idx) + if headers is None: + failures.append(f"Expected header {header_name} absent on request index {idx}, but only {self._tracker.request_count} requests were recorded") + else: + actual = headers.get(header_name.lower()) + if actual: + failures.append(f"Expected header {header_name} absent on request index {idx}, got {actual!r}") case "requestScheme": expected = assertion["expected"] diff --git a/conformance/runner/ruby/runner.rb b/conformance/runner/ruby/runner.rb index c4e76689..05616bea 100644 --- a/conformance/runner/ruby/runner.rb +++ b/conformance/runner/ruby/runner.rb @@ -287,6 +287,14 @@ def auto_paginates? end end + # Return captured headers at index (0-based; negative counts from end), or nil if out of range. + def request_headers_at(index) + requests = @tracker.requests + n = requests.size + resolved = index < 0 ? index + n : index + resolved >= 0 && resolved < n ? requests[resolved][:headers] : nil + end + def verify_assertions(result:, error:) failures = [] @@ -425,25 +433,40 @@ def verify_assertions(result:, error:) when "headerInjected" header_name = assertion["path"] expected = assertion["expected"] - requests = @tracker.requests - if requests.empty? - failures << "Expected header #{header_name}=#{expected.inspect}, but no requests recorded" + idx = assertion["index"] || 0 + headers = request_headers_at(idx) + if headers.nil? + failures << "Expected header #{header_name}=#{expected.inspect} on request index #{idx}, but only #{@tracker.request_count} requests were recorded" else - actual = requests.first[:headers]&.[](header_name) + actual = headers[header_name] unless actual == expected - failures << "Expected header #{header_name}=#{expected.inspect}, got #{actual.inspect}" + failures << "Expected header #{header_name}=#{expected.inspect} on request index #{idx}, got #{actual.inspect}" end end when "headerPresent" header_name = assertion["path"] - requests = @tracker.requests - if requests.empty? - failures << "Expected header #{header_name} to be present, but no requests recorded" + idx = assertion["index"] || 0 + headers = request_headers_at(idx) + if headers.nil? + failures << "Expected header #{header_name} on request index #{idx}, but only #{@tracker.request_count} requests were recorded" else - actual = requests.first[:headers]&.[](header_name) + actual = headers[header_name] if actual.nil? || actual.empty? - failures << "Expected header #{header_name} to be present, but it was missing or empty" + failures << "Expected header #{header_name} on request index #{idx}, but it was empty or missing" + end + end + + when "headerAbsent" + header_name = assertion["path"] + idx = assertion["index"] || 0 + headers = request_headers_at(idx) + if headers.nil? + failures << "Expected header #{header_name} absent on request index #{idx}, but only #{@tracker.request_count} requests were recorded" + else + actual = headers[header_name] + unless actual.nil? || actual.empty? + failures << "Expected header #{header_name} absent on request index #{idx}, got #{actual.inspect}" end end diff --git a/conformance/runner/typescript/runner.test.ts b/conformance/runner/typescript/runner.test.ts index e293fb5e..781aa371 100644 --- a/conformance/runner/typescript/runner.test.ts +++ b/conformance/runner/typescript/runner.test.ts @@ -33,6 +33,7 @@ interface Assertion { min?: number; max?: number; path?: string; + index?: number; } interface TestCase { @@ -358,6 +359,19 @@ function installMockHandlers(tc: TestCase): { // Assertion checker // ============================================================================= +/** Resolve captured headers at index (0-based; negative counts from end), or undefined if out of range. */ +function requestHeadersAt( + all: Record[], + index: number, +): Record | undefined { + const n = all.length; + if (n === 0) return undefined; + let i = index; + if (i < 0) i += n; + if (i < 0 || i >= n) return undefined; + return all[i]; +} + function checkAssertions( tc: TestCase, tracker: ReturnType, @@ -452,19 +466,38 @@ function checkAssertions( case "headerPresent": { const headerName = assertion.path!; - const headers = tracker.requestHeaders(); - expect( - headers.length, - `[${tc.name}] expected at least one request for header presence check`, - ).toBeGreaterThan(0); - const actual = headers[0]![headerName.toLowerCase()]; + const idx = assertion.index ?? 0; + const headers = requestHeadersAt(tracker.requestHeaders(), idx); + if (headers === undefined) { + throw new Error( + `[${tc.name}] expected header ${headerName} on request index ${idx}, but only ${tracker.requestCount()} requests were recorded`, + ); + } + const actual = headers[headerName.toLowerCase()]; expect( actual, - `[${tc.name}] expected header ${headerName} to be present, but it was empty or missing`, + `[${tc.name}] expected header ${headerName} on request index ${idx}, but it was empty or missing`, ).toBeTruthy(); break; } + case "headerAbsent": { + const headerName = assertion.path!; + const idx = assertion.index ?? 0; + const headers = requestHeadersAt(tracker.requestHeaders(), idx); + if (headers === undefined) { + throw new Error( + `[${tc.name}] expected header ${headerName} absent on request index ${idx}, but only ${tracker.requestCount()} requests were recorded`, + ); + } + const actual = headers[headerName.toLowerCase()]; + expect( + actual, + `[${tc.name}] expected header ${headerName} absent on request index ${idx}, got "${actual}"`, + ).toBeFalsy(); + break; + } + case "headerValue": { const headerName = assertion.path!; const expected = String(assertion.expected); @@ -553,15 +586,17 @@ function checkAssertions( case "headerInjected": { const headerName = assertion.path!; const expected = String(assertion.expected); - const headers = tracker.requestHeaders(); - expect( - headers.length, - `[${tc.name}] expected at least one request for header check`, - ).toBeGreaterThan(0); - const actual = headers[0]![headerName.toLowerCase()]; + const idx = assertion.index ?? 0; + const headers = requestHeadersAt(tracker.requestHeaders(), idx); + if (headers === undefined) { + throw new Error( + `[${tc.name}] expected header ${headerName}="${expected}" on request index ${idx}, but only ${tracker.requestCount()} requests were recorded`, + ); + } + const actual = headers[headerName.toLowerCase()]; expect( actual, - `[${tc.name}] expected header ${headerName}="${expected}", got "${actual}"`, + `[${tc.name}] expected header ${headerName}="${expected}" on request index ${idx}, got "${actual}"`, ).toBe(expected); break; } From 230e6a78db2ed9fea68354c361021d36fb39bc68 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Thu, 23 Apr 2026 14:52:44 -0700 Subject: [PATCH 5/6] Assert DownloadURL hop 1 hits the test case path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The DownloadURL mock route is origin-wide so hop 2's relative-resolved URL is served, but a regression that misroutes hop 1 to a different path on the same origin would otherwise pass silently — downloads.json doesn't have a requestPath assertion. Add a runner-level invariant in Python, Ruby, and TypeScript: for the DownloadURL operation, the first captured request's path must equal tc.path. Path correctness on hop 1 is implicit to the operation, so it belongs in the runner rather than the shared spec. --- conformance/runner/python/runner.py | 10 ++++++++++ conformance/runner/ruby/runner.rb | 12 ++++++++++++ conformance/runner/typescript/runner.test.ts | 13 +++++++++++++ 3 files changed, 35 insertions(+) diff --git a/conformance/runner/python/runner.py b/conformance/runner/python/runner.py index bc1e07a2..744a5fd2 100644 --- a/conformance/runner/python/runner.py +++ b/conformance/runner/python/runner.py @@ -223,6 +223,16 @@ def _request_headers_at(self, index: int) -> dict | None: def _verify_assertions(self, *, result: Any, error: Exception | None) -> TestResult: failures: list[str] = [] + # DownloadURL implicit invariant: hop 1 must hit the test case path. + # The mock route is origin-wide so hop 2's relative-resolved URL is + # served, but a regression that misroutes hop 1 to a different path + # on the same origin would otherwise pass silently. + if self._test["operation"] == "DownloadURL" and self._tracker.requests: + expected_path = self._test["path"] + actual_path = urlparse(self._tracker.requests[0]["url"]).path + if actual_path != expected_path: + failures.append(f"DownloadURL hop 1 expected path {expected_path!r}, got {actual_path!r}") + for assertion in self._test.get("assertions", []): match assertion["type"]: case "requestCount": diff --git a/conformance/runner/ruby/runner.rb b/conformance/runner/ruby/runner.rb index 05616bea..4d0e18f1 100644 --- a/conformance/runner/ruby/runner.rb +++ b/conformance/runner/ruby/runner.rb @@ -298,6 +298,18 @@ def request_headers_at(index) def verify_assertions(result:, error:) failures = [] + # DownloadURL implicit invariant: hop 1 must hit the test case path. + # The mock route is origin-wide so hop 2's relative-resolved URL is + # served, but a regression that misroutes hop 1 to a different path + # on the same origin would otherwise pass silently. + if @test["operation"] == "DownloadURL" && @tracker.requests.any? + expected_path = @test["path"] + actual_path = URI.parse(@tracker.requests.first[:uri]).path + unless actual_path == expected_path + failures << "DownloadURL hop 1 expected path #{expected_path.inspect}, got #{actual_path.inspect}" + end + end + (@test["assertions"] || []).each do |assertion| case assertion["type"] when "requestCount" diff --git a/conformance/runner/typescript/runner.test.ts b/conformance/runner/typescript/runner.test.ts index 781aa371..24ffff32 100644 --- a/conformance/runner/typescript/runner.test.ts +++ b/conformance/runner/typescript/runner.test.ts @@ -385,6 +385,19 @@ function checkAssertions( (r) => r.headers?.["Link"]?.includes('rel="next"'), ); + // DownloadURL implicit invariant: hop 1 must hit the test case path. + // The MSW handler is origin-wide so hop 2's relative-resolved URL is + // served, but a regression that misroutes hop 1 to a different path on + // the same origin would otherwise pass silently. + if (tc.operation === "DownloadURL") { + const recordedPaths = tracker.requestPaths(); + if (recordedPaths.length > 0 && recordedPaths[0] !== tc.path) { + throw new Error( + `[${tc.name}] DownloadURL hop 1 expected path ${tc.path}, got ${recordedPaths[0]}`, + ); + } + } + for (const assertion of tc.assertions) { switch (assertion.type) { case "requestCount": { From 4e7ef304f3e1bbbc2a5d4797ebbdbc27494137f7 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Thu, 23 Apr 2026 16:06:56 -0700 Subject: [PATCH 6/6] Guard empty DownloadURL path in Python and Ruby; void+catch TS cancel - Python and Ruby DownloadURL dispatch now raises if path is empty, matching the TypeScript runner. An empty path would silently produce the bare host URL "https://storage.3.basecamp.com" and fail later in a less obvious way. - TypeScript: explicitly void result.body.cancel() and attach a no-op .catch() to suppress any unhandled-promise-rejection from the discarded Promise. Fire-and-forget semantics are unchanged. --- conformance/runner/python/runner.py | 2 ++ conformance/runner/ruby/runner.rb | 1 + conformance/runner/typescript/runner.test.ts | 5 +++-- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/conformance/runner/python/runner.py b/conformance/runner/python/runner.py index 744a5fd2..fdf94cd9 100644 --- a/conformance/runner/python/runner.py +++ b/conformance/runner/python/runner.py @@ -67,6 +67,8 @@ def __init__(self, account_client): def __call__(self, operation: str, *, path_params: dict, query_params: dict, body: dict | None, path: str = "") -> Any: match operation: case "DownloadURL": + if not path: + raise ValueError("DownloadURL test case requires a non-empty path") raw_url = "https://storage.3.basecamp.com" + path return self._account.download_url(raw_url) case "ListProjects": diff --git a/conformance/runner/ruby/runner.rb b/conformance/runner/ruby/runner.rb index 4d0e18f1..8983f8a6 100644 --- a/conformance/runner/ruby/runner.rb +++ b/conformance/runner/ruby/runner.rb @@ -69,6 +69,7 @@ def initialize(account_client) def call(operation, path_params: {}, query_params: {}, body: nil, path: "") case operation when "DownloadURL" + raise "DownloadURL test case requires a non-empty path" if path.nil? || path.empty? raw_url = "https://storage.3.basecamp.com" + path @account.download_url(raw_url) when "ListProjects" diff --git a/conformance/runner/typescript/runner.test.ts b/conformance/runner/typescript/runner.test.ts index 24ffff32..55a40d63 100644 --- a/conformance/runner/typescript/runner.test.ts +++ b/conformance/runner/typescript/runner.test.ts @@ -235,8 +235,9 @@ async function executeOperation( const result = await client.downloadURL(rawURL); // Fire-and-forget cancel — matches typescript/tests/download.test.ts. // Awaiting MSW's mocked ReadableStream.cancel() can hang past vitest's - // default 5s test timeout, so don't await it here. - result.body.cancel(); + // default 5s test timeout, so don't await it here. void marks intent; + // .catch() suppresses any unhandled-rejection from the discarded Promise. + void result.body.cancel().catch(() => {}); return {}; }