From 35093b48dbe4989dbd51e7bea26cb20f32f1165c Mon Sep 17 00:00:00 2001 From: Ben Copeland Date: Wed, 26 Aug 2026 17:45:56 +0100 Subject: [PATCH 1/5] mcp: reject unknown status filter values The MCP query tools take status as a free-form string and hand it to StatusFilter, which only ever compares against lowercase 'pass', 'fail' and 'inconclusive'. Anything else matched nothing and returned an empty page with no error, so a caller could not tell a rejected value from a genuine absence of results. That includes the uppercase 'FAIL' the dashboard itself reports, which made echoing an observed status back as a filter look like a clean "no failures" answer. Normalise the value and reject anything outside the set the filter understands. The CLI is unaffected, since click.Choice already constrains it there. Also list 'all' in the tool docstrings, which the filter accepts but none of them mentioned. Signed-off-by: Ben Copeland --- kcidev/mcp/tools_dashboard.py | 13 +++++++------ kcidev/mcp/validation.py | 15 +++++++++++++++ tests/test_mcp_tools_dashboard.py | 27 +++++++++++++++++++++++++++ 3 files changed, 49 insertions(+), 6 deletions(-) create mode 100644 kcidev/mcp/validation.py diff --git a/kcidev/mcp/tools_dashboard.py b/kcidev/mcp/tools_dashboard.py index 07a3259..38fc155 100644 --- a/kcidev/mcp/tools_dashboard.py +++ b/kcidev/mcp/tools_dashboard.py @@ -7,6 +7,7 @@ from kcidev.api import KciDevError, KernelCIClient from kcidev.libs.filters import StatusFilter from kcidev.mcp.errors import tool_errors +from kcidev.mcp.validation import checked_status _active_client = ContextVar("dashboard_tool_client", default=None) @@ -19,7 +20,7 @@ def _page(data, key, status, limit, offset, fields=None): items = data[key] if isinstance(data, dict) else data total = len(items) if status: - status_filter = StatusFilter(status) + status_filter = StatusFilter(checked_status(status)) items = [item for item in items if status_filter.matches(item)] page = items[offset : offset + limit] if fields: @@ -142,7 +143,7 @@ def list_builds( """List kernel builds for one commit of a tree. Optional filters: arch (e.g. 'arm64'), tree name, ISO date range, and - status ('pass', 'fail' or 'inconclusive'). Results are paginated with + status ('pass', 'fail', 'inconclusive' or 'all'). Results are paginated with limit/offset; the response carries 'total' (before status filtering) and 'matched' counts so you know whether to fetch further pages; fields projects each entry to only those keys. @@ -173,7 +174,7 @@ def list_boots( """List boot test results for one commit of a tree. Optional filters: arch, tree name, ISO date range, boot origin, and - status ('pass', 'fail' or 'inconclusive'). Results are paginated with + status ('pass', 'fail', 'inconclusive' or 'all'). Results are paginated with limit/offset; the response carries 'total' (before status filtering) and 'matched' counts so you know whether to fetch further pages; fields projects each entry to only those keys. @@ -203,7 +204,7 @@ def list_tests( """List test results for one commit of a tree. Optional filters: arch, tree name, ISO date range, and status ('pass', - 'fail' or 'inconclusive'). A full commit can carry tens of thousands + 'fail', 'inconclusive' or 'all'). A full commit can carry tens of thousands of tests, so filter by status and paginate with limit/offset; the response carries 'total' (before status filtering) and 'matched' counts so you know whether to fetch further pages; fields projects @@ -327,7 +328,7 @@ def get_issue_builds( An empty list means the issue has no builds recorded against it, and also what an unknown issue id returns, since the dashboard reports both the same way; confirm the id with get_issue if it matters. - Optional status filter ('pass', 'fail' or 'inconclusive') and + Optional status filter ('pass', 'fail', 'inconclusive' or 'all') and limit/offset pagination; the response carries 'total' and 'matched' counts; fields projects each entry to only those keys. """ @@ -349,7 +350,7 @@ def get_issue_tests( An empty list means the issue has no tests recorded against it, and also what an unknown issue id returns, since the dashboard reports both the same way; confirm the id with get_issue if it matters. - Optional status filter ('pass', 'fail' or 'inconclusive') and + Optional status filter ('pass', 'fail', 'inconclusive' or 'all') and limit/offset pagination; the response carries 'total' and 'matched' counts; fields projects each entry to only those keys. """ diff --git a/kcidev/mcp/validation.py b/kcidev/mcp/validation.py new file mode 100644 index 0000000..0c8e298 --- /dev/null +++ b/kcidev/mcp/validation.py @@ -0,0 +1,15 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +from kcidev.api import KciDevError + +STATUS_CHOICES = ("all", "pass", "fail", "inconclusive") + + +def checked_status(status): + normalised = status.strip().lower() + if normalised not in STATUS_CHOICES: + raise KciDevError( + f"Unknown status {status!r}: expected one of {', '.join(STATUS_CHOICES)}" + ) + return normalised diff --git a/tests/test_mcp_tools_dashboard.py b/tests/test_mcp_tools_dashboard.py index 2b2e131..d219b78 100644 --- a/tests/test_mcp_tools_dashboard.py +++ b/tests/test_mcp_tools_dashboard.py @@ -282,3 +282,30 @@ def test_get_issue_tests_still_reports_other_errors(monkeypatch): _mock_get(monkeypatch, {"error": "Issue not found"}) with pytest.raises(ToolExecutionError): tools_dashboard.get_issue_tests("maestro:nope") + + +def _tree_args(**extra): + args = { + "giturl": "https://git.example.org/linux.git", + "branch": "master", + "commit": "deadbeef", + } + args.update(extra) + return args + + +def test_list_tests_accepts_uppercase_status(monkeypatch): + _mock_get( + monkeypatch, + {"tests": [{"id": "p1", "status": "PASS"}, {"id": "f1", "status": "FAIL"}]}, + ) + result = tools_dashboard.list_tests(**_tree_args(status="FAIL")) + assert result["matched"] == 1 + assert result["tests"] == [{"id": "f1", "status": "FAIL"}] + + +def test_list_tests_rejects_unknown_status(monkeypatch): + _mock_get(monkeypatch, {"tests": [{"id": "f1", "status": "FAIL"}]}) + with pytest.raises(ToolExecutionError) as excinfo: + tools_dashboard.list_tests(**_tree_args(status="borked")) + assert "borked" in str(excinfo.value) From 294f4951d61dab0fc771f0199ac8ab66f29e5b37 Mon Sep 17 00:00:00 2001 From: Ben Copeland Date: Wed, 26 Aug 2026 17:46:21 +0100 Subject: [PATCH 2/5] mcp: reject negative limit and offset limit and offset reach a plain list slice, so Python's negative index handling quietly reinterpreted them: a negative limit returned nearly the whole result set, defeating the pagination that exists to keep responses small, and a negative offset returned an empty page while still reporting the full total. list_nodes passed both straight to the Maestro API instead. Reject negative values in both paths. Signed-off-by: Ben Copeland --- kcidev/mcp/tools_dashboard.py | 3 ++- kcidev/mcp/tools_maestro.py | 2 ++ kcidev/mcp/validation.py | 7 +++++++ tests/test_mcp_server.py | 22 ++++++++++++++++++++++ tests/test_mcp_tools_dashboard.py | 12 ++++++++++++ 5 files changed, 45 insertions(+), 1 deletion(-) diff --git a/kcidev/mcp/tools_dashboard.py b/kcidev/mcp/tools_dashboard.py index 38fc155..25e6c39 100644 --- a/kcidev/mcp/tools_dashboard.py +++ b/kcidev/mcp/tools_dashboard.py @@ -7,7 +7,7 @@ from kcidev.api import KciDevError, KernelCIClient from kcidev.libs.filters import StatusFilter from kcidev.mcp.errors import tool_errors -from kcidev.mcp.validation import checked_status +from kcidev.mcp.validation import check_page_bounds, checked_status _active_client = ContextVar("dashboard_tool_client", default=None) @@ -17,6 +17,7 @@ def _current_client(): def _page(data, key, status, limit, offset, fields=None): + check_page_bounds(limit, offset) items = data[key] if isinstance(data, dict) else data total = len(items) if status: diff --git a/kcidev/mcp/tools_maestro.py b/kcidev/mcp/tools_maestro.py index 132586b..6d6af5a 100644 --- a/kcidev/mcp/tools_maestro.py +++ b/kcidev/mcp/tools_maestro.py @@ -2,6 +2,7 @@ # -*- coding: utf-8 -*- from kcidev.mcp.errors import tool_errors +from kcidev.mcp.validation import check_page_bounds def register_tools(server, client, api_url, pipeline_url, token): @@ -44,6 +45,7 @@ def list_nodes( paginate within the window; full nodes are large, so use fields to project each node to only those keys. """ + check_page_bounds(limit, offset) nodes = client.get_nodes(limit=limit, offset=offset, filters=filters or []) if fields: return [{k: n[k] for k in fields if k in n} for n in nodes] diff --git a/kcidev/mcp/validation.py b/kcidev/mcp/validation.py index 0c8e298..fb1bdd4 100644 --- a/kcidev/mcp/validation.py +++ b/kcidev/mcp/validation.py @@ -13,3 +13,10 @@ def checked_status(status): f"Unknown status {status!r}: expected one of {', '.join(STATUS_CHOICES)}" ) return normalised + + +def check_page_bounds(limit, offset): + if limit < 0: + raise KciDevError(f"Invalid limit {limit}: must be zero or greater") + if offset < 0: + raise KciDevError(f"Invalid offset {offset}: must be zero or greater") diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index 0ed785b..2ebef8d 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -235,3 +235,25 @@ def test_list_nodes_http_error_keeps_api_detail(monkeypatch): result = _call_tool(create_server(CFG, "test"), "list_nodes", {}) assert result.isError is True assert "422" in result.content[0].text + + +def _no_http(monkeypatch): + from kcidev.libs import maestro_common + + get = Mock() + monkeypatch.setattr(maestro_common.kcidev_session, "get", get) + return get + + +def test_list_nodes_rejects_negative_limit(monkeypatch): + get = _no_http(monkeypatch) + result = _call_tool(create_server(CFG, "test"), "list_nodes", {"limit": -1}) + assert result.isError is True + get.assert_not_called() + + +def test_list_nodes_rejects_negative_offset(monkeypatch): + get = _no_http(monkeypatch) + result = _call_tool(create_server(CFG, "test"), "list_nodes", {"offset": -1}) + assert result.isError is True + get.assert_not_called() diff --git a/tests/test_mcp_tools_dashboard.py b/tests/test_mcp_tools_dashboard.py index d219b78..803aa1e 100644 --- a/tests/test_mcp_tools_dashboard.py +++ b/tests/test_mcp_tools_dashboard.py @@ -309,3 +309,15 @@ def test_list_tests_rejects_unknown_status(monkeypatch): with pytest.raises(ToolExecutionError) as excinfo: tools_dashboard.list_tests(**_tree_args(status="borked")) assert "borked" in str(excinfo.value) + + +def test_list_tests_rejects_negative_limit(monkeypatch): + _mock_get(monkeypatch, {"tests": [{"id": str(i)} for i in range(5)]}) + with pytest.raises(ToolExecutionError): + tools_dashboard.list_tests(**_tree_args(limit=-1)) + + +def test_list_tests_rejects_negative_offset(monkeypatch): + _mock_get(monkeypatch, {"tests": [{"id": str(i)} for i in range(5)]}) + with pytest.raises(ToolExecutionError): + tools_dashboard.list_tests(**_tree_args(offset=-1)) From b14b36a900ad8dc879a534d03b47c7ecf6b7bb34 Mon Sep 17 00:00:00 2001 From: Ben Copeland Date: Wed, 26 Aug 2026 17:46:38 +0100 Subject: [PATCH 3/5] mcp: validate list_nodes filter syntax Filters are split on '=' and passed to requests as query parameter pairs. A filter string without '=' produced a one-element tuple that failed deep in the request layer, where the generic handler turned it into "Maestro nodes request failed" with no mention of the filter that caused it. Check the syntax before the request and name the offending filter. Signed-off-by: Ben Copeland --- kcidev/mcp/tools_maestro.py | 6 ++++-- kcidev/mcp/validation.py | 10 ++++++++++ tests/test_mcp_server.py | 10 ++++++++++ 3 files changed, 24 insertions(+), 2 deletions(-) diff --git a/kcidev/mcp/tools_maestro.py b/kcidev/mcp/tools_maestro.py index 6d6af5a..d6f9185 100644 --- a/kcidev/mcp/tools_maestro.py +++ b/kcidev/mcp/tools_maestro.py @@ -2,7 +2,7 @@ # -*- coding: utf-8 -*- from kcidev.mcp.errors import tool_errors -from kcidev.mcp.validation import check_page_bounds +from kcidev.mcp.validation import check_page_bounds, checked_filters def register_tools(server, client, api_url, pipeline_url, token): @@ -46,7 +46,9 @@ def list_nodes( fields to project each node to only those keys. """ check_page_bounds(limit, offset) - nodes = client.get_nodes(limit=limit, offset=offset, filters=filters or []) + nodes = client.get_nodes( + limit=limit, offset=offset, filters=checked_filters(filters) + ) if fields: return [{k: n[k] for k in fields if k in n} for n in nodes] return nodes diff --git a/kcidev/mcp/validation.py b/kcidev/mcp/validation.py index fb1bdd4..21aae23 100644 --- a/kcidev/mcp/validation.py +++ b/kcidev/mcp/validation.py @@ -20,3 +20,13 @@ def check_page_bounds(limit, offset): raise KciDevError(f"Invalid limit {limit}: must be zero or greater") if offset < 0: raise KciDevError(f"Invalid offset {offset}: must be zero or greater") + + +def checked_filters(filters): + for entry in filters or []: + if "=" not in entry: + raise KciDevError( + f"Invalid filter {entry!r}: expected 'field=value', " + "for example 'state=done'" + ) + return list(filters or []) diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index 2ebef8d..e7b4cce 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -257,3 +257,13 @@ def test_list_nodes_rejects_negative_offset(monkeypatch): result = _call_tool(create_server(CFG, "test"), "list_nodes", {"offset": -1}) assert result.isError is True get.assert_not_called() + + +def test_list_nodes_rejects_filter_without_equals(monkeypatch): + get = _no_http(monkeypatch) + result = _call_tool( + create_server(CFG, "test"), "list_nodes", {"filters": ["state done"]} + ) + assert result.isError is True + assert "state done" in result.content[0].text + get.assert_not_called() From 1ce91c0e0b13c68cec77f98fd41c4b73d2891a75 Mon Sep 17 00:00:00 2001 From: Ben Copeland Date: Thu, 27 Aug 2026 08:13:36 +0100 Subject: [PATCH 4/5] dashboard: reject an empty issue id The issue fetchers interpolate the id into their endpoint, so an empty id turns "issue/" into "issue/", which is the collection endpoint dashboard_fetch_issue_list already uses. The request then succeeded and returned every known issue in place of the one that was asked for. Over MCP that surfaced as a 150KB result reported as success. On the command line, "kci-dev results issue --id ''" printed an issue with every field None and a dashboard link to /issue/None, then exited 0. Guard the three issue fetchers, which is the point the CLI and the library client share. The builds and tests siblings requested "issue//builds", which 404s, so those failed already but without saying why. Signed-off-by: Ben Copeland --- kcidev/libs/dashboard.py | 8 ++++++ tests/test_dashboard.py | 47 +++++++++++++++++++++++++++++++ tests/test_mcp_tools_dashboard.py | 7 +++++ 3 files changed, 62 insertions(+) diff --git a/kcidev/libs/dashboard.py b/kcidev/libs/dashboard.py index 4859590..054d92e 100644 --- a/kcidev/libs/dashboard.py +++ b/kcidev/libs/dashboard.py @@ -400,12 +400,19 @@ def dashboard_fetch_issue_list(origin, days, use_json): return dashboard_api_fetch("issue/", params, use_json) +def _require_issue_id(issue_id): + if not issue_id or not issue_id.strip(): + raise click.ClickException("Issue id is required") + + def dashboard_fetch_issue(issue_id, use_json): + _require_issue_id(issue_id) logging.info(f"Fetching issue details for issue ID: {issue_id}") return dashboard_api_fetch(f"issue/{issue_id}", {}, use_json) def dashboard_fetch_issue_builds(origin, issue_id, use_json, error_verbose=True): + _require_issue_id(issue_id) logging.info(f"Fetching builds for issue ID: {issue_id}") params = {"filter_origin": origin} if origin else {} return dashboard_api_fetch( @@ -414,6 +421,7 @@ def dashboard_fetch_issue_builds(origin, issue_id, use_json, error_verbose=True) def dashboard_fetch_issue_tests(origin, issue_id, use_json, error_verbose=True): + _require_issue_id(issue_id) logging.info(f"Fetching tests for issue ID: {issue_id}") params = {"filter_origin": origin} if origin else {} return dashboard_api_fetch( diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py index e842813..f70d1ec 100644 --- a/tests/test_dashboard.py +++ b/tests/test_dashboard.py @@ -198,3 +198,50 @@ def test_other_dashboard_errors_are_passed_through_unchanged(monkeypatch): dashboard.dashboard_api_fetch("build/x", {}, False) assert excinfo.value.message == "Build not found" + + +def _issue_collection_get(monkeypatch): + response = Mock(status_code=200) + response.json.return_value = {"issues": [{"id": "maestro:one"}]} + get = Mock(return_value=response) + monkeypatch.setattr(dashboard.kcidev_session, "get", get) + return get + + +def test_dashboard_fetch_issue_rejects_empty_id(monkeypatch): + get = _issue_collection_get(monkeypatch) + + with pytest.raises(click.ClickException): + dashboard.dashboard_fetch_issue("", False) + + get.assert_not_called() + + +def test_dashboard_fetch_issue_builds_rejects_empty_id(monkeypatch): + get = _issue_collection_get(monkeypatch) + + with pytest.raises(click.ClickException): + dashboard.dashboard_fetch_issue_builds(None, "", False) + + get.assert_not_called() + + +def test_dashboard_fetch_issue_tests_rejects_empty_id(monkeypatch): + get = _issue_collection_get(monkeypatch) + + with pytest.raises(click.ClickException): + dashboard.dashboard_fetch_issue_tests(None, "", False) + + get.assert_not_called() + + +def test_cli_issue_command_rejects_empty_id(monkeypatch): + from kcidev.main import get_cli + + get = _issue_collection_get(monkeypatch) + + runner = CliRunner() + result = runner.invoke(get_cli(), ["results", "issue", "--id", ""]) + + assert result.exit_code != 0 + get.assert_not_called() diff --git a/tests/test_mcp_tools_dashboard.py b/tests/test_mcp_tools_dashboard.py index 803aa1e..6b49c02 100644 --- a/tests/test_mcp_tools_dashboard.py +++ b/tests/test_mcp_tools_dashboard.py @@ -321,3 +321,10 @@ def test_list_tests_rejects_negative_offset(monkeypatch): _mock_get(monkeypatch, {"tests": [{"id": str(i)} for i in range(5)]}) with pytest.raises(ToolExecutionError): tools_dashboard.list_tests(**_tree_args(offset=-1)) + + +def test_get_issue_rejects_empty_id(monkeypatch): + get = _mock_get(monkeypatch, {"issues": [{"id": "maestro:one"}]}) + with pytest.raises(ToolExecutionError): + tools_dashboard.get_issue("") + get.assert_not_called() From f1a8c690c16b46b85eb643a3ba1a84182c5d0240 Mon Sep 17 00:00:00 2001 From: Ben Copeland Date: Fri, 4 Sep 2026 11:09:45 +0100 Subject: [PATCH 5/5] mcp: validate paging arguments before fetching The dashboard tools fetch a whole result set and page it in memory, so validating inside the pager meant an invalid status, limit or offset still cost a full request, and a request failure could surface instead of the validation error that was the real complaint. Check the arguments before the client call. The pager keeps its own check, so any future caller of it stays covered. Signed-off-by: Ben Copeland --- kcidev/mcp/tools_dashboard.py | 7 ++++++- kcidev/mcp/validation.py | 13 +++++++++++++ tests/test_mcp_tools_dashboard.py | 28 ++++++++++++++++++++++++++++ 3 files changed, 47 insertions(+), 1 deletion(-) diff --git a/kcidev/mcp/tools_dashboard.py b/kcidev/mcp/tools_dashboard.py index 25e6c39..37887e3 100644 --- a/kcidev/mcp/tools_dashboard.py +++ b/kcidev/mcp/tools_dashboard.py @@ -7,7 +7,7 @@ from kcidev.api import KciDevError, KernelCIClient from kcidev.libs.filters import StatusFilter from kcidev.mcp.errors import tool_errors -from kcidev.mcp.validation import check_page_bounds, checked_status +from kcidev.mcp.validation import check_page_args, check_page_bounds, checked_status _active_client = ContextVar("dashboard_tool_client", default=None) @@ -150,6 +150,7 @@ def list_builds( fields projects each entry to only those keys. Returns build entries with ids usable with get_build. """ + check_page_args(status, limit, offset) data = _current_client().get_builds( origin, giturl, branch, commit, arch, tree, start_date, end_date ) @@ -181,6 +182,7 @@ def list_boots( fields projects each entry to only those keys. Returns boot entries with ids usable with get_test. """ + check_page_args(status, limit, offset) data = _current_client().get_boots( origin, giturl, branch, commit, arch, tree, start_date, end_date, boot_origin ) @@ -212,6 +214,7 @@ def list_tests( each entry to only those keys. Returns test entries with ids usable with get_test. """ + check_page_args(status, limit, offset) data = _current_client().get_tests( origin, giturl, branch, commit, arch, tree, start_date, end_date ) @@ -333,6 +336,7 @@ def get_issue_builds( limit/offset pagination; the response carries 'total' and 'matched' counts; fields projects each entry to only those keys. """ + check_page_args(status, limit, offset) data = _current_client().get_issue_builds(issue_id, origin) return _page(data, "builds", status, limit, offset, fields) @@ -355,6 +359,7 @@ def get_issue_tests( limit/offset pagination; the response carries 'total' and 'matched' counts; fields projects each entry to only those keys. """ + check_page_args(status, limit, offset) data = _current_client().get_issue_tests(issue_id, origin) return _page(data, "tests", status, limit, offset, fields) diff --git a/kcidev/mcp/validation.py b/kcidev/mcp/validation.py index 21aae23..6c4d26a 100644 --- a/kcidev/mcp/validation.py +++ b/kcidev/mcp/validation.py @@ -30,3 +30,16 @@ def checked_filters(filters): "for example 'state=done'" ) return list(filters or []) + + +def check_page_args(status, limit, offset): + """Validate paging arguments before any request is made. + + The tools fetch a whole result set and page it in memory, so + validating inside the pager would mean an expensive request for + input that was never usable, and a request failure would mask the + real complaint. + """ + check_page_bounds(limit, offset) + if status: + checked_status(status) diff --git a/tests/test_mcp_tools_dashboard.py b/tests/test_mcp_tools_dashboard.py index 6b49c02..87c9cd6 100644 --- a/tests/test_mcp_tools_dashboard.py +++ b/tests/test_mcp_tools_dashboard.py @@ -328,3 +328,31 @@ def test_get_issue_rejects_empty_id(monkeypatch): with pytest.raises(ToolExecutionError): tools_dashboard.get_issue("") get.assert_not_called() + + +def test_invalid_status_is_rejected_before_any_request(monkeypatch): + get = _mock_get(monkeypatch, {"tests": []}) + with pytest.raises(ToolExecutionError): + tools_dashboard.list_tests(**_tree_args(status="borked")) + get.assert_not_called() + + +def test_invalid_limit_is_rejected_before_any_request(monkeypatch): + get = _mock_get(monkeypatch, {"tests": []}) + with pytest.raises(ToolExecutionError): + tools_dashboard.list_tests(**_tree_args(limit=-1)) + get.assert_not_called() + + +def test_invalid_offset_is_rejected_before_any_request(monkeypatch): + get = _mock_get(monkeypatch, {"builds": []}) + with pytest.raises(ToolExecutionError): + tools_dashboard.list_builds(**_tree_args(offset=-1)) + get.assert_not_called() + + +def test_issue_tools_validate_before_any_request(monkeypatch): + get = _mock_get(monkeypatch, {"tests": []}) + with pytest.raises(ToolExecutionError): + tools_dashboard.get_issue_tests("maestro:i1", status="borked") + get.assert_not_called()