| # |
| # Licensed to the Apache Software Foundation (ASF) under one |
| # or more contributor license agreements. See the NOTICE file |
| # distributed with this work for additional information |
| # regarding copyright ownership. The ASF licenses this file |
| # to you under the Apache License, Version 2.0 (the |
| # "License"); you may not use this file except in compliance |
| # with the License. You may obtain a copy of the License at |
| # |
| # http://www.apache.org/licenses/LICENSE-2.0 |
| # |
| # Unless required by applicable law or agreed to in writing, |
| # software distributed under the License is distributed on an |
| # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, |
| # either express or implied. See the License for the specific |
| # language governing permissions and limitations under the |
| # License. |
| # |
| |
| from unittest.mock import MagicMock |
| |
| import pytest |
| |
| from drill_mcp.client_jdbc import JdbcClient |
| from drill_mcp.client_rest import DrillError, QueryResult, RestClient |
| from drill_mcp.config import load_config |
| from drill_mcp.server import DrillTools, ToolError, build_client, build_server |
| |
| |
| def make_tools(client=None, **overrides): |
| client = client or MagicMock() |
| return DrillTools(load_config(overrides=overrides, env={}), client) |
| |
| |
| class TestRunQuery: |
| def test_returns_columns_rows_and_query_id(self): |
| client = MagicMock() |
| client.query.return_value = QueryResult(["a"], [{"a": 1}], "q1", False) |
| result = make_tools(client).run_query("SELECT 1") |
| assert result["columns"] == ["a"] |
| assert result["rows"] == [{"a": 1}] |
| assert result["query_id"] == "q1" |
| assert result["truncated"] is False |
| |
| def test_applies_the_configured_row_cap(self): |
| client = MagicMock() |
| client.query.return_value = QueryResult() |
| make_tools(client, max_rows=100).run_query("SELECT 1") |
| assert client.query.call_args.kwargs["max_rows"] == 100 |
| |
| def test_enforces_the_row_cap_even_if_the_client_returns_more(self): |
| # RestClient and JdbcClient both cap rows themselves, but run_query |
| # is the last chokepoint before the model, so it must not simply |
| # trust whatever the client hands back. |
| client = MagicMock() |
| client.query.return_value = QueryResult( |
| ["a"], [{"a": i} for i in range(10)], "q1", False |
| ) |
| result = make_tools(client, max_rows=3).run_query("SELECT 1") |
| assert len(result["rows"]) == 3 |
| assert result["rows"] == [{"a": 0}, {"a": 1}, {"a": 2}] |
| |
| def test_caller_may_lower_the_cap(self): |
| client = MagicMock() |
| client.query.return_value = QueryResult() |
| make_tools(client, max_rows=100).run_query("SELECT 1", max_rows=10) |
| assert client.query.call_args.kwargs["max_rows"] == 10 |
| |
| def test_caller_may_not_raise_the_cap(self): |
| client = MagicMock() |
| client.query.return_value = QueryResult() |
| make_tools(client, max_rows=100).run_query("SELECT 1", max_rows=10_000) |
| assert client.query.call_args.kwargs["max_rows"] == 100 |
| |
| def test_truncation_is_reported(self): |
| client = MagicMock() |
| client.query.return_value = QueryResult(["a"], [{"a": 1}], None, True) |
| result = make_tools(client, max_rows=1).run_query("SELECT 1") |
| assert result["truncated"] is True |
| assert "truncated" in result["note"].lower() |
| |
| def test_write_is_rejected_before_reaching_the_client(self): |
| client = MagicMock() |
| with pytest.raises(ToolError, match="writable_plugins"): |
| make_tools(client).run_query("CREATE TABLE dfs.tmp.x AS SELECT 1") |
| client.query.assert_not_called() |
| |
| def test_write_is_allowed_when_the_plugin_is_writable(self): |
| client = MagicMock() |
| client.query.return_value = QueryResult() |
| make_tools(client, writable_plugins=["dfs.tmp"]).run_query( |
| "CREATE TABLE dfs.tmp.x AS SELECT 1" |
| ) |
| client.query.assert_called_once() |
| |
| def test_hidden_schema_is_rejected_before_reaching_the_client(self): |
| client = MagicMock() |
| with pytest.raises(ToolError, match="hidden"): |
| make_tools(client, hidden_schemas=["sys"]).run_query( |
| "SELECT * FROM sys.options" |
| ) |
| client.query.assert_not_called() |
| |
| def test_drill_errors_are_surfaced_as_tool_errors(self): |
| client = MagicMock() |
| client.query.side_effect = DrillError("VALIDATION ERROR: no such table") |
| with pytest.raises(ToolError, match="no such table"): |
| make_tools(client).run_query("SELECT * FROM nope") |
| |
| def test_pathological_sql_that_crashes_the_parser_is_rejected_without_a_traceback( |
| self, |
| ): |
| client = MagicMock() |
| deeply_nested = "SELECT " + "(" * 400 + "1" + ")" * 400 |
| with pytest.raises(ToolError) as excinfo: |
| make_tools(client).run_query(deeply_nested) |
| client.query.assert_not_called() |
| message = str(excinfo.value) |
| assert "/" not in message |
| assert "\\" not in message |
| assert ".py" not in message |
| |
| def test_non_string_sql_is_rejected_as_a_tool_error(self): |
| client = MagicMock() |
| with pytest.raises(ToolError, match="sql must be a string"): |
| make_tools(client).run_query(5) |
| client.query.assert_not_called() |
| |
| def test_non_integer_max_rows_is_rejected_as_a_tool_error(self): |
| client = MagicMock() |
| with pytest.raises(ToolError, match="max_rows must be an integer"): |
| make_tools(client).run_query("SELECT 1", max_rows="10") |
| client.query.assert_not_called() |
| |
| |
| class TestListSchemas: |
| def test_returns_all_schemas_by_default(self): |
| client = MagicMock() |
| client.schemas.return_value = [{"name": "dfs.tmp"}, {"name": "sys"}] |
| assert len(make_tools(client).list_schemas()) == 2 |
| |
| def test_filters_hidden_schemas(self): |
| client = MagicMock() |
| client.schemas.return_value = [ |
| {"name": "dfs.tmp"}, |
| {"name": "sys"}, |
| {"name": "INFORMATION_SCHEMA"}, |
| ] |
| result = make_tools( |
| client, hidden_schemas=["sys", "INFORMATION_SCHEMA"] |
| ).list_schemas() |
| assert [s["name"] for s in result] == ["dfs.tmp"] |
| |
| def test_filtering_is_case_insensitive(self): |
| client = MagicMock() |
| client.schemas.return_value = [{"name": "SYS"}, {"name": "dfs.tmp"}] |
| result = make_tools(client, hidden_schemas=["sys"]).list_schemas() |
| assert [s["name"] for s in result] == ["dfs.tmp"] |
| |
| def test_filters_child_schemas_of_a_hidden_parent(self): |
| client = MagicMock() |
| client.schemas.return_value = [{"name": "sys.mem"}, {"name": "dfs.tmp"}] |
| result = make_tools(client, hidden_schemas=["sys"]).list_schemas() |
| assert [s["name"] for s in result] == ["dfs.tmp"] |
| |
| |
| class TestListTables: |
| def test_lists_tables(self): |
| client = MagicMock() |
| client.tables.return_value = [{"name": "t", "type": "TABLE"}] |
| assert make_tools(client).list_tables("dfs.tmp") == [ |
| {"name": "t", "type": "TABLE"} |
| ] |
| client.tables.assert_called_once_with("dfs.tmp") |
| |
| def test_hidden_schema_is_refused(self): |
| client = MagicMock() |
| with pytest.raises(ToolError, match="hidden"): |
| make_tools(client, hidden_schemas=["sys"]).list_tables("sys") |
| client.tables.assert_not_called() |
| |
| def test_information_schema_still_works_internally_when_hidden(self): |
| """Hiding INFORMATION_SCHEMA must not break metadata tools.""" |
| client = MagicMock() |
| client.tables.return_value = [{"name": "t", "type": "TABLE"}] |
| tools = make_tools(client, hidden_schemas=["INFORMATION_SCHEMA"]) |
| assert tools.list_tables("dfs.tmp") == [{"name": "t", "type": "TABLE"}] |
| |
| |
| class TestDescribeTable: |
| def test_describes_columns(self): |
| client = MagicMock() |
| client.columns.return_value = [ |
| {"name": "id", "data_type": "INTEGER", "nullable": True} |
| ] |
| assert make_tools(client).describe_table("dfs.tmp", "t")[0]["name"] == "id" |
| client.columns.assert_called_once_with("dfs.tmp", "t") |
| |
| def test_hidden_schema_is_refused(self): |
| client = MagicMock() |
| with pytest.raises(ToolError, match="hidden"): |
| make_tools(client, hidden_schemas=["sys"]).describe_table("sys", "options") |
| client.columns.assert_not_called() |
| |
| def test_unknown_table_error_is_surfaced(self): |
| client = MagicMock() |
| client.columns.side_effect = DrillError("invalid identifier") |
| with pytest.raises(ToolError, match="invalid identifier"): |
| make_tools(client).describe_table("dfs.tmp", "nope") |
| |
| |
| class TestManagementTools: |
| def test_list_storage_plugins_passes_through_redacted_output(self): |
| client = MagicMock() |
| client.storage_plugins.return_value = [ |
| {"name": "s3", "config": {"secret": "***REDACTED***"}} |
| ] |
| assert make_tools(client).list_storage_plugins()[0]["name"] == "s3" |
| |
| def test_list_storage_plugins_hides_plugins_backing_hidden_schemas(self): |
| client = MagicMock() |
| client.storage_plugins.return_value = [{"name": "sys"}, {"name": "dfs"}] |
| result = make_tools(client, hidden_schemas=["sys"]).list_storage_plugins() |
| assert [p["name"] for p in result] == ["dfs"] |
| |
| def test_cluster_status(self): |
| client = MagicMock() |
| client.cluster_status.return_value = {"status": "Running!"} |
| assert make_tools(client).cluster_status()["status"] == "Running!" |
| |
| def test_list_profiles_uses_the_default_limit(self): |
| client = MagicMock() |
| client.profiles.return_value = [] |
| make_tools(client).list_profiles() |
| assert client.profiles.call_args.kwargs["limit"] == 20 |
| |
| def test_list_profiles_honours_an_explicit_limit(self): |
| client = MagicMock() |
| client.profiles.return_value = [] |
| make_tools(client).list_profiles(limit=5) |
| assert client.profiles.call_args.kwargs["limit"] == 5 |
| |
| def test_get_profile(self): |
| client = MagicMock() |
| client.profile.return_value = {"queryId": "abc"} |
| assert make_tools(client).get_profile("abc")["queryId"] == "abc" |
| |
| def test_get_profile_redacts_secret_looking_keys(self): |
| # Profiles are cluster-wide: a full profile embeds Drill's |
| # serialized physical plan, which for JDBC/HTTP plugins can carry |
| # plugin configuration (passwords, tokens). This must go through the |
| # same redaction as list_storage_plugins, not be returned unmodified. |
| client = MagicMock() |
| client.profile.return_value = {"queryId": "abc", "password": "hunter2"} |
| result = make_tools(client).get_profile("abc") |
| assert result["password"] == "***REDACTED***" |
| assert result["queryId"] == "abc" |
| |
| def test_get_profile_is_refused_when_its_query_text_names_a_hidden_schema(self): |
| # A profile carries the query TEXT of whatever user ran it -- other |
| # users' queries, not just the caller's own. A hidden schema's name |
| # can leak out here as data even though it is unreachable directly, |
| # which is exactly the enumeration path the guard and hidden-schema |
| # filtering elsewhere were built to close. |
| client = MagicMock() |
| client.profile.return_value = { |
| "queryId": "abc", |
| "query": "SELECT * FROM sys.options", |
| } |
| with pytest.raises(ToolError, match="hidden"): |
| make_tools(client, hidden_schemas=["sys"]).get_profile("abc") |
| |
| def test_list_profiles_redacts_secret_looking_keys(self): |
| client = MagicMock() |
| client.profiles.return_value = [{"queryId": "abc", "password": "hunter2"}] |
| result = make_tools(client).list_profiles() |
| assert result[0]["password"] == "***REDACTED***" |
| |
| def test_list_profiles_drops_entries_whose_query_text_names_a_hidden_schema(self): |
| client = MagicMock() |
| client.profiles.return_value = [ |
| {"queryId": "abc", "query": "SELECT * FROM sys.options"}, |
| {"queryId": "def", "query": "SELECT * FROM dfs.tmp.x"}, |
| ] |
| result = make_tools(client, hidden_schemas=["sys"]).list_profiles() |
| assert [p["queryId"] for p in result] == ["def"] |
| |
| def test_cancel_query(self): |
| client = MagicMock() |
| client.cancel_query.return_value = "Cancelled" |
| assert make_tools(client).cancel_query("abc") == "Cancelled" |
| |
| def test_management_tools_are_unavailable_on_a_client_without_them(self): |
| client = MagicMock(spec=["query", "schemas", "tables", "columns"]) |
| with pytest.raises(ToolError, match="REST"): |
| make_tools(client).cluster_status() |
| |
| @pytest.mark.parametrize( |
| "call", |
| [ |
| lambda tools: tools.list_storage_plugins(), |
| lambda tools: tools.cluster_status(), |
| lambda tools: tools.list_profiles(), |
| lambda tools: tools.get_profile("abc"), |
| lambda tools: tools.cancel_query("abc"), |
| ], |
| ids=[ |
| "list_storage_plugins", |
| "cluster_status", |
| "list_profiles", |
| "get_profile", |
| "cancel_query", |
| ], |
| ) |
| def test_every_management_tool_is_unavailable_on_a_client_without_it(self, call): |
| # get_profile/cancel_query run their own isinstance validation on |
| # query_id before touching the client, so a valid string argument is |
| # used here to make sure that validation doesn't mask the missing |
| # REST endpoint being detected first. |
| client = MagicMock(spec=["query", "schemas", "tables", "columns"]) |
| with pytest.raises(ToolError, match="REST"): |
| call(make_tools(client)) |
| |
| def test_list_storage_plugins_skips_non_dict_entries_rather_than_crashing(self): |
| client = MagicMock() |
| client.storage_plugins.return_value = ["not-a-dict", {"name": "dfs"}] |
| result = make_tools(client).list_storage_plugins() |
| assert [p["name"] for p in result] == ["dfs"] |
| |
| def test_drill_errors_become_tool_errors(self): |
| client = MagicMock() |
| client.profile.side_effect = DrillError("no such query") |
| with pytest.raises(ToolError, match="no such query"): |
| make_tools(client).get_profile("abc") |
| |
| def test_get_profile_rejects_non_string_query_id(self): |
| client = MagicMock() |
| with pytest.raises(ToolError): |
| make_tools(client).get_profile(123) |
| |
| def test_cancel_query_rejects_non_string_query_id(self): |
| client = MagicMock() |
| with pytest.raises(ToolError): |
| make_tools(client).cancel_query(123) |
| |
| def test_list_profiles_rejects_non_integer_limit(self): |
| client = MagicMock() |
| with pytest.raises(ToolError): |
| make_tools(client).list_profiles(limit="20") |
| |
| |
| class TestShowFiltering: |
| """SHOW is evaluated server-side by Drill, so rows are filtered on return. |
| |
| Filtering applies to *every* SHOW command's first column, not just SHOW |
| SCHEMAS/SHOW DATABASES. Three narrower attempts at recognising only the |
| schema-listing spellings (a raw regex over the SQL text, exact equality |
| against the parsed Command's literal, a comment-stripping regex over that |
| literal) each leaked hidden schemas through some spelling the classifier |
| failed to recognise. Filtering all SHOW output instead means SHOW |
| TABLES/SHOW FILES rows are incidentally filtered too, but that direction |
| of failure — over-filtering a table that happens to share a name with a |
| hidden schema — is the safe one; leaking the schema list is not. See |
| guard.is_show_command's docstring for the full history. |
| """ |
| |
| def test_show_schemas_rows_are_filtered(self): |
| client = MagicMock() |
| client.query.return_value = QueryResult( |
| ["SCHEMA_NAME"], |
| [{"SCHEMA_NAME": "sys"}, {"SCHEMA_NAME": "dfs.tmp"}], |
| ) |
| result = make_tools(client, hidden_schemas=["sys"]).run_query("SHOW SCHEMAS") |
| assert result["rows"] == [{"SCHEMA_NAME": "dfs.tmp"}] |
| |
| def test_show_databases_rows_are_filtered(self): |
| client = MagicMock() |
| client.query.return_value = QueryResult( |
| ["SCHEMA_NAME"], |
| [{"SCHEMA_NAME": "INFORMATION_SCHEMA"}, {"SCHEMA_NAME": "dfs"}], |
| ) |
| result = make_tools(client, hidden_schemas=["INFORMATION_SCHEMA"]).run_query( |
| "SHOW DATABASES" |
| ) |
| assert result["rows"] == [{"SCHEMA_NAME": "dfs"}] |
| |
| def test_ordinary_select_rows_are_not_filtered(self): |
| client = MagicMock() |
| client.query.return_value = QueryResult( |
| ["SCHEMA_NAME"], [{"SCHEMA_NAME": "sys"}] |
| ) |
| result = make_tools(client, hidden_schemas=["sys"]).run_query( |
| "SELECT SCHEMA_NAME FROM dfs.tmp.notes" |
| ) |
| assert result["rows"] == [{"SCHEMA_NAME": "sys"}] |
| |
| def test_show_filtering_is_a_no_op_without_hidden_schemas(self): |
| client = MagicMock() |
| client.query.return_value = QueryResult( |
| ["SCHEMA_NAME"], [{"SCHEMA_NAME": "sys"}] |
| ) |
| result = make_tools(client).run_query("SHOW SCHEMAS") |
| assert result["rows"] == [{"SCHEMA_NAME": "sys"}] |
| |
| def test_show_schemas_with_leading_block_comment_is_still_filtered(self): |
| """Regression test for the raw-regex bypass: a leading comment defeats |
| a `^\\s*SHOW` anchor but not the parser, since the tokenizer strips |
| comments before the guard or this filter ever sees the text.""" |
| client = MagicMock() |
| client.query.return_value = QueryResult( |
| ["SCHEMA_NAME"], |
| [{"SCHEMA_NAME": "sys"}, {"SCHEMA_NAME": "dfs.tmp"}], |
| ) |
| result = make_tools(client, hidden_schemas=["sys"]).run_query( |
| "/* x */ SHOW SCHEMAS" |
| ) |
| assert result["rows"] == [{"SCHEMA_NAME": "dfs.tmp"}] |
| |
| def test_show_databases_with_leading_line_comment_is_still_filtered(self): |
| client = MagicMock() |
| client.query.return_value = QueryResult( |
| ["SCHEMA_NAME"], |
| [{"SCHEMA_NAME": "INFORMATION_SCHEMA"}, {"SCHEMA_NAME": "dfs"}], |
| ) |
| result = make_tools(client, hidden_schemas=["INFORMATION_SCHEMA"]).run_query( |
| "-- comment\nSHOW DATABASES" |
| ) |
| assert result["rows"] == [{"SCHEMA_NAME": "dfs"}] |
| |
| def test_show_filtering_is_case_insensitive_lowercase(self): |
| client = MagicMock() |
| client.query.return_value = QueryResult( |
| ["SCHEMA_NAME"], |
| [{"SCHEMA_NAME": "sys"}, {"SCHEMA_NAME": "dfs.tmp"}], |
| ) |
| result = make_tools(client, hidden_schemas=["sys"]).run_query("show schemas") |
| assert result["rows"] == [{"SCHEMA_NAME": "dfs.tmp"}] |
| |
| def test_show_filtering_is_case_insensitive_mixed_case(self): |
| client = MagicMock() |
| client.query.return_value = QueryResult( |
| ["SCHEMA_NAME"], |
| [{"SCHEMA_NAME": "sys"}, {"SCHEMA_NAME": "dfs.tmp"}], |
| ) |
| result = make_tools(client, hidden_schemas=["sys"]).run_query("ShOw ScHeMaS") |
| assert result["rows"] == [{"SCHEMA_NAME": "dfs.tmp"}] |
| |
| def test_show_tables_rows_are_now_filtered_too(self): |
| """Inverted deliberately: SHOW TABLES rows are table names, not |
| schema names, so filtering them against hidden_schemas can drop a |
| table that happens to share a name with a hidden schema. That is the |
| accepted, fail-closed trade-off — SHOW output is filtered as a whole |
| because no reliable way exists to single out just the |
| schema-listing spellings of SHOW without risking a leak (see the |
| class docstring). A table literally named "sys" is rare; a leaked |
| hidden schema list is not an acceptable alternative.""" |
| client = MagicMock() |
| client.query.return_value = QueryResult( |
| ["TABLE_NAME"], [{"TABLE_NAME": "sys"}, {"TABLE_NAME": "orders"}] |
| ) |
| result = make_tools(client, hidden_schemas=["sys"]).run_query("SHOW TABLES") |
| assert result["rows"] == [{"TABLE_NAME": "orders"}] |
| |
| def test_show_tables_like_rows_are_filtered_too(self): |
| client = MagicMock() |
| client.query.return_value = QueryResult( |
| ["TABLE_NAME"], [{"TABLE_NAME": "sys"}, {"TABLE_NAME": "orders"}] |
| ) |
| result = make_tools(client, hidden_schemas=["sys"]).run_query( |
| "SHOW TABLES LIKE '%s%'" |
| ) |
| assert result["rows"] == [{"TABLE_NAME": "orders"}] |
| |
| def test_show_schemas_like_rows_are_filtered(self): |
| """Regression test: `SHOW SCHEMAS LIKE '...'` is documented Drill |
| syntax. sqlglot's Command fallback swallows the whole remainder |
| (`SCHEMAS LIKE '%dfs%'`) into a single literal, so any classifier |
| that inspects that text specifically has some spelling it misses; |
| filtering every SHOW command sidesteps the classification problem |
| entirely.""" |
| client = MagicMock() |
| client.query.return_value = QueryResult( |
| ["SCHEMA_NAME"], |
| [{"SCHEMA_NAME": "sys"}, {"SCHEMA_NAME": "dfs.tmp"}], |
| ) |
| result = make_tools(client, hidden_schemas=["sys"]).run_query( |
| "SHOW SCHEMAS LIKE '%s%'" |
| ) |
| assert result["rows"] == [{"SCHEMA_NAME": "dfs.tmp"}] |
| |
| def test_show_databases_like_rows_are_filtered(self): |
| client = MagicMock() |
| client.query.return_value = QueryResult( |
| ["SCHEMA_NAME"], |
| [{"SCHEMA_NAME": "INFORMATION_SCHEMA"}, {"SCHEMA_NAME": "dfs"}], |
| ) |
| result = make_tools(client, hidden_schemas=["INFORMATION_SCHEMA"]).run_query( |
| "SHOW DATABASES LIKE '%y%'" |
| ) |
| assert result["rows"] == [{"SCHEMA_NAME": "dfs"}] |
| |
| def test_show_schemas_with_trailing_block_comment_is_still_filtered(self): |
| client = MagicMock() |
| client.query.return_value = QueryResult( |
| ["SCHEMA_NAME"], |
| [{"SCHEMA_NAME": "sys"}, {"SCHEMA_NAME": "dfs.tmp"}], |
| ) |
| result = make_tools(client, hidden_schemas=["sys"]).run_query( |
| "SHOW SCHEMAS /* trailing */" |
| ) |
| assert result["rows"] == [{"SCHEMA_NAME": "dfs.tmp"}] |
| |
| def test_show_schemas_with_no_whitespace_before_comment_is_still_filtered(self): |
| client = MagicMock() |
| client.query.return_value = QueryResult( |
| ["SCHEMA_NAME"], |
| [{"SCHEMA_NAME": "sys"}, {"SCHEMA_NAME": "dfs.tmp"}], |
| ) |
| result = make_tools(client, hidden_schemas=["sys"]).run_query("SHOW/**/SCHEMAS") |
| assert result["rows"] == [{"SCHEMA_NAME": "dfs.tmp"}] |
| |
| def test_show_schemas_with_trailing_semicolon_is_still_filtered(self): |
| client = MagicMock() |
| client.query.return_value = QueryResult( |
| ["SCHEMA_NAME"], |
| [{"SCHEMA_NAME": "sys"}, {"SCHEMA_NAME": "dfs.tmp"}], |
| ) |
| result = make_tools(client, hidden_schemas=["sys"]).run_query("SHOW SCHEMAS;") |
| assert result["rows"] == [{"SCHEMA_NAME": "dfs.tmp"}] |
| |
| def test_show_schemas_with_nested_block_comment_is_still_filtered(self): |
| """Regression test for the specific input that defeated the |
| comment-stripping regex fix: non-greedy `/\\*.*?\\*/` matching stops |
| at the first `*/`, leaving `*/ SCHEMAS` behind, so the "first token" |
| became `*/` instead of `SCHEMAS` and the row leaked.""" |
| client = MagicMock() |
| client.query.return_value = QueryResult( |
| ["SCHEMA_NAME"], |
| [{"SCHEMA_NAME": "sys"}, {"SCHEMA_NAME": "dfs.tmp"}], |
| ) |
| result = make_tools(client, hidden_schemas=["sys"]).run_query( |
| "SHOW /* /* nested */ */ SCHEMAS" |
| ) |
| assert result["rows"] == [{"SCHEMA_NAME": "dfs.tmp"}] |
| |
| def test_show_schemas_with_unbalanced_comment_delimiter_is_still_filtered(self): |
| """Regression test for the other input that defeated the |
| comment-stripping regex: a stray `*/` with no opener strips nothing |
| at all, so the row leaked.""" |
| client = MagicMock() |
| client.query.return_value = QueryResult( |
| ["SCHEMA_NAME"], |
| [{"SCHEMA_NAME": "sys"}, {"SCHEMA_NAME": "dfs.tmp"}], |
| ) |
| result = make_tools(client, hidden_schemas=["sys"]).run_query("SHOW */ SCHEMAS") |
| assert result["rows"] == [{"SCHEMA_NAME": "dfs.tmp"}] |
| |
| def test_row_that_is_not_a_dict_does_not_crash_filtering(self): |
| """A malformed row must not raise a raw AttributeError out of the |
| filter; it should simply be treated as not identifiable and dropped |
| rather than crashing the whole call.""" |
| client = MagicMock() |
| client.query.return_value = QueryResult( |
| ["SCHEMA_NAME"], ["not-a-dict", {"SCHEMA_NAME": "dfs.tmp"}] |
| ) |
| result = make_tools(client, hidden_schemas=["sys"]).run_query("SHOW SCHEMAS") |
| assert result["rows"] == [{"SCHEMA_NAME": "dfs.tmp"}] |
| |
| @pytest.mark.parametrize( |
| "sql", |
| [ |
| "SHOW SCHEMAS", |
| "SHOW DATABASES", |
| "/* x */ SHOW SCHEMAS", |
| "-- c\nSHOW DATABASES", |
| "SHOW SCHEMAS LIKE '%dfs%'", |
| "SHOW SCHEMAS /* t */", |
| "SHOW/**/SCHEMAS", |
| "SHOW /* /* nested */ */ SCHEMAS", |
| "SHOW */ SCHEMAS", |
| "show schemas", |
| "SHOW SCHEMAS;", |
| ], |
| ) |
| def test_hidden_row_never_appears_in_the_tool_payload(self, sql): |
| """End-to-end assertion on the tool's actual output, independent of |
| how detection is implemented: for every spelling that previously |
| leaked through one of the three narrower classifiers, the hidden |
| schema must not appear anywhere in the returned payload, not just in |
| a specific `rows` shape.""" |
| client = MagicMock() |
| client.query.return_value = QueryResult( |
| ["SCHEMA_NAME"], |
| [{"SCHEMA_NAME": "sys"}, {"SCHEMA_NAME": "dfs.tmp"}], |
| ) |
| result = make_tools(client, hidden_schemas=["sys"]).run_query(sql) |
| assert {"SCHEMA_NAME": "sys"} not in result["rows"] |
| assert result["rows"] == [{"SCHEMA_NAME": "dfs.tmp"}] |
| |
| |
| class TestWiring: |
| def test_rest_backend_builds_a_rest_client(self): |
| assert isinstance(build_client(load_config(env={})), RestClient) |
| |
| def test_jdbc_backend_builds_a_jdbc_client(self): |
| cfg = load_config( |
| overrides={"backend": "jdbc", "jdbc_driver_path": "/x.jar"}, env={} |
| ) |
| assert isinstance(build_client(cfg), JdbcClient) |
| |
| def test_all_tools_are_registered(self): |
| server = build_server(load_config(env={})) |
| names = {tool.name for tool in server._tool_manager.list_tools()} |
| assert names == { |
| "run_query", |
| "list_schemas", |
| "list_tables", |
| "describe_table", |
| "list_storage_plugins", |
| "cluster_status", |
| "list_profiles", |
| "get_profile", |
| "cancel_query", |
| } |
| |
| def test_every_tool_has_a_description(self): |
| server = build_server(load_config(env={})) |
| assert all(tool.description for tool in server._tool_manager.list_tools()) |
| |
| def test_no_write_or_mutation_tools_are_registered(self): |
| server = build_server(load_config(env={})) |
| names = {tool.name for tool in server._tool_manager.list_tools()} |
| forbidden = { |
| "create_storage_plugin", |
| "update_storage_plugin", |
| "delete_storage_plugin", |
| "set_option", |
| "alter_system", |
| } |
| assert not (names & forbidden) |
| |
| def test_no_registered_tool_accepts_a_credential_argument(self): |
| """Credentials come from config or environment only, never a tool argument.""" |
| server = build_server(load_config(env={})) |
| credential_words = { |
| "user", |
| "password", |
| "username", |
| "passwd", |
| "secret", |
| "token", |
| "credential", |
| } |
| for tool in server._tool_manager.list_tools(): |
| params = set(tool.parameters.get("properties", {})) |
| assert not (params & credential_words), ( |
| f"{tool.name} accepts {params & credential_words}" |
| ) |
| |
| |
| class TestMain: |
| def test_config_error_exits_nonzero_with_a_message(self, capsys): |
| from drill_mcp.server import main |
| |
| assert main(["--config", "/nonexistent.yaml"]) == 1 |
| assert "not found" in capsys.readouterr().err |
| |
| def test_cli_flags_reach_the_config(self, monkeypatch): |
| from drill_mcp import server as server_module |
| |
| # Note: `captured.setdefault("cfg", cfg) or MagicMock()` (as drafted |
| # in the task brief) returns the truthy `cfg` itself rather than the |
| # MagicMock, so `build_server(cfg).run()` would blow up calling |
| # `.run()` on a `Config`. Using a real fake avoids that trap. |
| captured = {} |
| |
| def fake_build_server(cfg): |
| captured["cfg"] = cfg |
| return MagicMock() |
| |
| monkeypatch.setattr(server_module, "build_server", fake_build_server) |
| server_module.main(["--url", "http://cli:8047", "--max-rows", "7"]) |
| assert captured["cfg"].url == "http://cli:8047" |
| assert captured["cfg"].max_rows == 7 |