Files
mcp-microsoft/tools/Azure.Mcp.Tools.Postgres/tests/Azure.Mcp.Tools.Postgres.Tests/PostgresCommandTests.cs
T
1fa503200a Mark MySQL/PostgreSQL database query as destructive and drop SQL verb blocklists (#3202)
* Fixed input validation issue in MySql and Postgres

* Added MySql validation test cases

* Fix Postgres SQL validator: tokenize string-stripped content and add regex timeout

- Tokenize withoutStrings instead of decodedForValidation to avoid false
  positives when dangerous keywords appear inside string literals
  (e.g., SELECT 'drop table' AS msg)
- Only strip U&'...' (string literals) not U&"..." (identifiers) per
  PostgreSQL spec, so obfuscated function names in identifiers are still
  caught by tokenization
- Add RegexHelper.DefaultRegexTimeout to DecodeUnicodeEscapes for
  consistency with other regex usage in the validator
- Fix whitespace formatting in SqlQueryValidatorTests.cs closing brace
- Move U&'...' test case to safe queries (string literals cannot invoke
  functions per PostgreSQL spec)

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* Marked MySql and Postgres query commands as destructive, not idempotent, and not read-only

* Block the uescape command in validator

* Remove SQL verb/keyword blocklists from MySQL and Postgres query validation

The MySQL and Postgres database query tools are now marked destructive, so
rejecting statements by SQL verb or dangerous-identifier blocklist is no longer
meaningful - the signed-in user's database permissions are the authority.

Validation is reduced to structural checks: empty input, max length, SQL
comments, and multiple/stacked statements. The Unicode/National-string
de-obfuscation logic (which only existed to feed the blocklists) is removed,
along with the unfinished UESCAPE check that did not compile.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* Reword changelog entries to fix cspell failure

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* Move MySQL/Postgres query tools to a destructive consolidated tool

The consolidated tool discovery strategy requires every mapped command's
ToolMetadata to match its consolidated tool exactly. Marking the query
commands destructive left them mismatched against the read-only
get_azure_databases_details tool, which made the server fail to start in
consolidated mode.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* Test consolidated tool metadata mappings

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Vinay Gera <vigera@microsoft.com>
2026-09-11 15:27:05 -07:00

527 lines
20 KiB
C#

// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
using System.Text.Json;
using Azure.Core;
using Azure.Identity;
using Azure.Mcp.Tools.Postgres.Options;
using Microsoft.Mcp.Tests;
using Microsoft.Mcp.Tests.Attributes;
using Microsoft.Mcp.Tests.Client;
using ModelContextProtocol.Protocol;
using Npgsql;
using Xunit;
namespace Azure.Mcp.Tools.Postgres.Tests;
public class PostgresCommandTests(ITestOutputHelper output, LiveServerFixture liveServerFixture) : CommandTestsBase(output, liveServerFixture)
{
private string TestDatabaseName => Settings.DeploymentOutputs["TESTDATABASENAME"];
private string ServerName => Settings.DeploymentOutputs["POSTGRESSERVERNAME"];
private string ServerFqdn => Settings.DeploymentOutputs["POSTGRESSERVERFQDN"];
private string AdminUsername => Settings.PrincipalName ?? string.Empty;
private static bool _testDataInitialized = false;
private static readonly SemaphoreSlim _initLock = new(1, 1);
public override async ValueTask InitializeAsync()
{
await base.InitializeAsync();
// Only initialize test data once for all tests
if (_testDataInitialized)
{
return;
}
await _initLock.WaitAsync();
try
{
if (_testDataInitialized)
{
return;
}
Output.WriteLine("Initializing test data...");
await CreateTestDataAsync();
_testDataInitialized = true;
Output.WriteLine("Test data initialized successfully");
}
finally
{
_initLock.Release();
}
}
private async Task CreateTestDataAsync()
{
Output.WriteLine($"ServerFqdn: {ServerFqdn}");
Output.WriteLine($"AdminUsername: {AdminUsername}");
Output.WriteLine($"TestDatabaseName: {TestDatabaseName}");
// Get Entra ID access token for PostgreSQL
var options = new DefaultAzureCredentialOptions
{
TenantId = Settings.TenantId,
ExcludeManagedIdentityCredential = true, // We don't want to use ADO build server identity
};
var tokenCredential = new DefaultAzureCredential(options);
var tokenRequestContext = new TokenRequestContext(["https://ossrdbms-aad.database.windows.net/.default"], tenantId: Settings.TenantId);
AccessToken accessToken = await tokenCredential.GetTokenAsync(tokenRequestContext, TestContext.Current.CancellationToken);
string connectionString = $"Host={ServerFqdn};Database={TestDatabaseName};Username={AdminUsername};Password={accessToken.Token};SSL Mode=Require;Trust Server Certificate=true;";
Output.WriteLine($"Connecting to PostgreSQL...");
await using var connection = new NpgsqlConnection(connectionString);
await connection.OpenAsync();
Output.WriteLine($"Connected successfully!");
// Create employees table
string createEmployeesTable = @"
CREATE TABLE IF NOT EXISTS employees (
id SERIAL PRIMARY KEY,
first_name VARCHAR(50) NOT NULL,
last_name VARCHAR(50) NOT NULL,
email VARCHAR(100) UNIQUE NOT NULL,
department VARCHAR(50),
salary DECIMAL(10, 2),
hire_date DATE DEFAULT CURRENT_DATE,
is_active BOOLEAN DEFAULT true
);";
await using (var cmd = new NpgsqlCommand(createEmployeesTable, connection))
{
await cmd.ExecuteNonQueryAsync();
}
// Insert employee data
string insertEmployees = @"
INSERT INTO employees (first_name, last_name, email, department, salary, hire_date, is_active)
VALUES
('John', 'Doe', 'john.doe@example.com', 'Engineering', 75000.00, '2023-01-15', true),
('Jane', 'Smith', 'jane.smith@example.com', 'Marketing', 65000.00, '2023-02-20', true),
('Bob', 'Johnson', 'bob.johnson@example.com', 'Sales', 70000.00, '2023-03-10', true),
('Alice', 'Williams', 'alice.williams@example.com', 'Engineering', 80000.00, '2023-04-05', true),
('Charlie', 'Brown', 'charlie.brown@example.com', 'HR', 60000.00, '2023-05-12', false)
ON CONFLICT (email) DO NOTHING;";
await using (var cmd = new NpgsqlCommand(insertEmployees, connection))
{
await cmd.ExecuteNonQueryAsync();
}
// Create departments table
string createDepartmentsTable = @"
CREATE TABLE IF NOT EXISTS departments (
dept_id SERIAL PRIMARY KEY,
dept_name VARCHAR(50) NOT NULL UNIQUE,
location VARCHAR(100),
budget DECIMAL(12, 2)
);";
await using (var cmd = new NpgsqlCommand(createDepartmentsTable, connection))
{
await cmd.ExecuteNonQueryAsync();
}
// Insert department data
string insertDepartments = @"
INSERT INTO departments (dept_name, location, budget)
VALUES
('Engineering', 'Seattle', 1000000.00),
('Marketing', 'New York', 500000.00),
('Sales', 'San Francisco', 750000.00),
('HR', 'Austin', 300000.00)
ON CONFLICT (dept_name) DO NOTHING;";
await using (var cmd = new NpgsqlCommand(insertDepartments, connection))
{
await cmd.ExecuteNonQueryAsync();
}
Output.WriteLine("Test tables and data created successfully");
}
[LiveTestOnly]
[Fact]
public async Task Should_ListDatabases_Successfully()
{
JsonElement? result = await CallToolAsync(
"postgres_list",
new()
{
{ "subscription", Settings.SubscriptionId },
{ "resource-group", Settings.ResourceGroupName },
{ "server", ServerName },
{ PostgresOptionDefinitions.AuthTypeText, AuthTypes.MicrosoftEntra },
{ "user", AdminUsername }
});
// Should successfully retrieve the list of databases
JsonElement databases = result.AssertProperty("Databases");
Assert.Equal(JsonValueKind.Array, databases.ValueKind);
// Should contain at least our test databases
List<JsonElement> databaseList = databases.EnumerateArray().ToList();
Assert.True(databaseList.Count >= 2, $"Should contain at least {TestDatabaseName} and postgres databases");
// Verify that our test databases exist
List<string?> testDbNames = databaseList.Select(db => db.GetString()).ToList();
Assert.Contains(TestDatabaseName, testDbNames);
Assert.Contains("postgres", testDbNames);
}
[LiveTestOnly]
[Fact]
public async Task Should_ListTables_Successfully()
{
JsonElement? result = await CallToolAsync(
"postgres_list",
new()
{
{ "subscription", Settings.SubscriptionId },
{ "resource-group", Settings.ResourceGroupName },
{ "server", ServerName },
{ "database", TestDatabaseName },
{ PostgresOptionDefinitions.AuthTypeText, AuthTypes.MicrosoftEntra },
{ "user", AdminUsername }
});
// Should successfully retrieve the list of tables
JsonElement tables = result.AssertProperty("Tables");
Assert.Equal(JsonValueKind.Array, tables.ValueKind);
// Should contain our test tables
List<JsonElement> tableList = tables.EnumerateArray().ToList();
Assert.True(tableList.Count >= 2, "Should contain at least employees and departments tables");
List<string?> tableNames = tableList.Select(t => t.GetString()).ToList();
Assert.Contains("employees", tableNames);
Assert.Contains("departments", tableNames);
}
[LiveTestOnly]
[Fact]
public async Task Should_GetTableSchema_Successfully()
{
JsonElement? result = await CallToolAsync(
"postgres_table_schema_get",
new()
{
{ "server", ServerName },
{ "database", TestDatabaseName },
{ PostgresOptionDefinitions.AuthTypeText, AuthTypes.MicrosoftEntra },
{ "user", AdminUsername },
{ "table", "employees" }
});
// Should successfully retrieve the table schema
JsonElement schema = result.AssertProperty("Schema");
Assert.Equal(JsonValueKind.Array, schema.ValueKind);
// Should contain all columns from the employees table
List<JsonElement> schemaArray = schema.EnumerateArray().ToList();
Assert.True(schemaArray.Count >= 8, "Should contain at least 8 columns");
// Verify that key columns exist in the schema
string schemaJson = schema.ToString();
Assert.Contains("id", schemaJson);
Assert.Contains("first_name", schemaJson);
Assert.Contains("last_name", schemaJson);
Assert.Contains("email", schemaJson);
Assert.Contains("department", schemaJson);
Assert.Contains("salary", schemaJson);
Assert.Contains("hire_date", schemaJson);
Assert.Contains("is_active", schemaJson);
}
[LiveTestOnly]
[Fact]
public async Task Should_ExecuteQuery_Successfully()
{
// Test a simple SELECT query
JsonElement? result = await CallToolAsync(
"postgres_database_query",
new()
{
{ "server", ServerName },
{ "database", TestDatabaseName },
{ PostgresOptionDefinitions.AuthTypeText, AuthTypes.MicrosoftEntra },
{ "user", AdminUsername },
{ "query", "SELECT first_name, last_name FROM employees WHERE department = 'Engineering';" }
});
// Should successfully execute the query
JsonElement queryResult = result.AssertProperty("QueryResult");
Assert.Equal(JsonValueKind.Array, queryResult.ValueKind);
// Should return at least the employees we inserted
List<JsonElement> resultList = queryResult.EnumerateArray().ToList();
Assert.True(resultList.Count >= 2, "Should return at least 2 Engineering employees");
// Verify the result contains expected data
string resultJson = queryResult.ToString();
Assert.Contains("John", resultJson);
Assert.Contains("Alice", resultJson);
}
[LiveTestOnly]
[Fact]
public async Task Should_ExecuteQuery_WithAggregation_Successfully()
{
// Test a query with COUNT aggregation
JsonElement? result = await CallToolAsync(
"postgres_database_query",
new()
{
{ "server", ServerName },
{ "database", TestDatabaseName },
{ PostgresOptionDefinitions.AuthTypeText, AuthTypes.MicrosoftEntra },
{ "user", AdminUsername },
{ "query", "SELECT department, COUNT(*) as emp_count FROM employees GROUP BY department ORDER BY department;" }
});
// Should successfully execute the query
JsonElement queryResult = result.AssertProperty("QueryResult");
Assert.Equal(JsonValueKind.Array, queryResult.ValueKind);
// Should return aggregated results
List<JsonElement> resultArray = queryResult.EnumerateArray().ToList();
Assert.True(resultArray.Count >= 3, "Should return at least 3 departments");
}
[LiveTestOnly]
[Fact]
public async Task Should_ExecuteQuery_WithJoin_Successfully()
{
// Test a query with JOIN
JsonElement? result = await CallToolAsync(
"postgres_database_query",
new()
{
{ "server", ServerName },
{ "database", TestDatabaseName },
{ PostgresOptionDefinitions.AuthTypeText, AuthTypes.MicrosoftEntra },
{ "user", AdminUsername },
{ "query", @"SELECT e.first_name, e.last_name, d.location
FROM employees e
JOIN departments d ON e.department = d.dept_name
WHERE d.location = 'Seattle';" }
});
// Should successfully execute the join query
JsonElement queryResult = result.AssertProperty("QueryResult");
Assert.Equal(JsonValueKind.Array, queryResult.ValueKind);
// Should return employees in Seattle
List<JsonElement> resultArray = queryResult.EnumerateArray().ToList();
Assert.True(resultArray.Count >= 1, "Should return at least 1 employee in Seattle");
}
[LiveTestOnly]
[Fact]
public async Task Should_ListServerConfigs_Successfully()
{
JsonElement? result = await CallToolAsync(
"postgres_server_config_get",
new()
{
{ "subscription", Settings.SubscriptionId },
{ "resource-group", Settings.ResourceGroupName },
{ "server", ServerName },
{ "user", AdminUsername },
});
// Should successfully retrieve server configurations
JsonElement config = result.AssertProperty("Configuration");
Assert.Equal(JsonValueKind.String, config.ValueKind);
string[] configLines = config
.GetString()?
.Trim()
.Split("\n") ?? Array.Empty<string>();
// Should contain multiple configuration parameters
Assert.True(configLines.Length > 0, "Should return at least one configuration parameter");
List<KeyValuePair<string, string>> configList = configLines
.Select(line =>
{
string[] parts = line.Split(":", 2);
return new KeyValuePair<string, string>(
parts[0].Trim(),
parts.Length > 1 ? parts[1].Trim() : string.Empty);
})
.ToList();
// Should contain at least the "Server Name" parameter
KeyValuePair<string, string> serverConfig = configList.FirstOrDefault(c => c.Key == "Server Name");
Assert.Equal("Server Name", serverConfig.Key);
Assert.Equal(ServerName, serverConfig.Value);
}
[LiveTestOnly]
[Fact]
public async Task Should_GetServerParameter_Successfully()
{
// Get a specific server parameter (max_connections is a common one)
JsonElement? result = await CallToolAsync(
"postgres_server_param_get",
new()
{
{ "subscription", Settings.SubscriptionId },
{ "resource-group", Settings.ResourceGroupName },
{ "server", ServerName },
{ "user", AdminUsername },
{ "param", "max_connections" }
});
// Should successfully retrieve the parameter
JsonElement parameter = result.AssertProperty("ParameterValue");
Assert.Equal(JsonValueKind.String, parameter.ValueKind);
int maxConnections = int.Parse(parameter.GetString() ?? "0");
Assert.True(maxConnections > 0, "max_connections should be greater than 0");
}
[LiveTestOnly]
[Fact]
public async Task Should_ListServers_Successfully()
{
JsonElement? result = await CallToolAsync(
"postgres_list",
new()
{
{ "subscription", Settings.SubscriptionId },
{ "resource-group", Settings.ResourceGroupName },
{ "user", AdminUsername },
});
// Should successfully retrieve the list of servers
List<string?> servers = result.AssertProperty("Servers")
.EnumerateArray()
.Select(s => s.GetString())
.ToList();
Assert.True(servers.Count >= 1, $"Should contain at least the {ServerName} server");
// Verify that our test server exists
Assert.Contains(ServerName, servers);
}
[LiveTestOnly]
[Fact]
public async Task Should_RejectStackedQuery_WithValidationError()
{
JsonElement error = await this.CallToolAsyncWithErrorExpected("postgres_database_query",
new()
{
{ "server", ServerName },
{ "database", TestDatabaseName },
{ PostgresOptionDefinitions.AuthTypeText, AuthTypes.MicrosoftEntra },
{ "user", AdminUsername },
{ "query", "SELECT * FROM employees; DELETE FROM employees WHERE id = 1;" }
});
int errorStatus = error.GetProperty("status").GetInt32();
Assert.Equal(400, errorStatus);
string errorMessage = error.GetProperty("message").GetString()!;
Assert.Equal("Multiple or stacked SQL statements are not allowed.", errorMessage);
}
[LiveTestOnly]
[Fact]
public async Task Should_HandleInvalidServerName_Gracefully()
{
string serverName = Guid.NewGuid().ToString(); // <-- nonexistent_server
JsonElement error = await this.CallToolAsyncWithErrorExpected(
"postgres_list",
new()
{
{ "subscription", Settings.SubscriptionId },
{ "resource-group", Settings.ResourceGroupName },
{ "server", serverName },
{ "database", TestDatabaseName },
{ PostgresOptionDefinitions.AuthTypeText, AuthTypes.MicrosoftEntra },
{ "user", AdminUsername }
});
int errorStatus = error.GetProperty("status").GetInt32();
Assert.Equal(500, errorStatus);
string errorMessage = error.GetProperty("message").GetString()!;
bool isExpectedError =
errorMessage.Contains("No such host is known") ||
errorMessage.Contains("Name or service not known");
Assert.True(isExpectedError, $"Error message should indicate unknown host, but was: {errorMessage}");
}
[LiveTestOnly]
[Fact]
public async Task Should_HandleInvalidDatabaseName_Gracefully()
{
string databaseName = Guid.NewGuid().ToString(); // <-- nonexistent_database
JsonElement error = await this.CallToolAsyncWithErrorExpected(
"postgres_list",
new()
{
{ "subscription", Settings.SubscriptionId },
{ "resource-group", Settings.ResourceGroupName },
{ "server", ServerName },
{ "database", databaseName },
{ PostgresOptionDefinitions.AuthTypeText, AuthTypes.MicrosoftEntra },
{ "user", AdminUsername }
});
int errorStatus = error.GetProperty("status").GetInt32();
Assert.Equal(500, errorStatus);
string errorMessage = error.GetProperty("message").GetString()!;
Assert.Contains($"\"{databaseName}\" does not exist", errorMessage);
}
[LiveTestOnly]
[Fact]
public async Task Should_HandleInvalidTableName_Gracefully()
{
string tableName = Guid.NewGuid().ToString(); // <-- nonexistent_table
JsonElement? result = await CallToolAsync(
"postgres_table_schema_get",
new()
{
{ "server", ServerName },
{ "database", TestDatabaseName },
{ PostgresOptionDefinitions.AuthTypeText, AuthTypes.MicrosoftEntra },
{ "user", AdminUsername },
{ "table", tableName }
});
JsonElement schema = result.AssertProperty("Schema");
Assert.Equal(JsonValueKind.Array, schema.ValueKind);
// Schema should be empty for nonexistent table
List<JsonElement> schemaList = schema.EnumerateArray().ToList();
Assert.Empty(schemaList);
}
private async Task<JsonElement> CallToolAsyncWithErrorExpected(string command, Dictionary<string, object?> parameters)
{
CallToolResult result = await Client.CallToolAsync(command, parameters);
Assert.NotNull(result);
Assert.True(result.IsError, $"Command {command} should have failed but succeeded.");
Assert.NotNull(result.Content);
Assert.Single(result.Content);
var textContent = (TextContentBlock)result.Content.Single();
Assert.NotNull(textContent.Text);
Assert.NotEmpty(textContent.Text);
JsonElement errorContent = JsonSerializer.Deserialize<JsonElement>(textContent.Text);
return errorContent;
}
}