Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
414c758
expand code coverage
aaronburtle Aug 28, 2026
b2f5825
expand MsSql pipeline coverage
aaronburtle Aug 29, 2026
e33548f
expand PostgreSQL pipeline coverage
aaronburtle Aug 29, 2026
7c4a34e
expand MySQL pipeline coverage
aaronburtle Aug 29, 2026
3dc6aec
expand DWSQL pipeline coverage
aaronburtle Aug 29, 2026
ec822f1
expand Cosmos DB pipeline coverage
aaronburtle Aug 29, 2026
450b21b
fix unit pipeline formatting
aaronburtle Aug 29, 2026
5efb26d
fix MsSql pipeline formatting
aaronburtle Aug 29, 2026
fa9c957
fix MySQL pipeline formatting
aaronburtle Aug 29, 2026
080b9b8
fix PostgreSQL pipeline formatting
aaronburtle Aug 29, 2026
b194e08
fix Cosmos DB pipeline formatting
aaronburtle Aug 29, 2026
9e752f5
remove unused Cosmos DB test import
aaronburtle Aug 29, 2026
67560e8
remove unused MsSql test import
aaronburtle Aug 29, 2026
ba6deb8
fix entity health threshold error message
aaronburtle Aug 29, 2026
f6a718f
fix aggregate records test indentation
aaronburtle Aug 29, 2026
4dbc444
fix remaining unit test formatting
aaronburtle Aug 29, 2026
2138ee6
stabilize Cosmos time-partitioned sampler tests
aaronburtle Aug 30, 2026
acf38d0
extend branch coverage
aaronburtle Aug 30, 2026
b7c8b14
format
aaronburtle Aug 30, 2026
e3f4999
reviewed and corrected
aaronburtle Aug 31, 2026
ecdaf7f
address code coverage review feedback
aaronburtle Sep 3, 2026
4e05408
document non-obvious coverage tests
aaronburtle Sep 3, 2026
fead930
Merge branch 'main' into dev/aaronburtle/code-coverage-completion
aaronburtle Sep 3, 2026
f0af827
dispose Cosmos test clients
aaronburtle Sep 3, 2026
a408a67
stabilize child config failure test
aaronburtle Sep 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,10 @@ public async Task<CallToolResult> ExecuteAsync(
}
}
}
catch (OperationCanceledException)
{
return McpResponseBuilder.BuildErrorResult(toolName, "OperationCanceled", "The create operation was canceled.", logger);
}
catch (Exception ex)
{
return McpResponseBuilder.BuildErrorResult(toolName, "Error", $"Error: {ex.Message}", logger);
Expand Down
5 changes: 2 additions & 3 deletions src/Azure.DataApiBuilder.Mcp/Core/McpStdioServer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -696,14 +696,13 @@ private void WriteError(JsonElement? id, int code, string message)
/// Extracts the value of a JSON-RPC request identifier.
/// </summary>
/// <param name="id">The JSON element representing the request identifier.</param>
/// <returns>The extracted identifier value as an object, or null if the identifier is not a primitive type.</returns>
/// <returns>The string value or a cloned numeric element, or null if the identifier is not a supported primitive type.</returns>
private static object? GetIdValue(JsonElement id)
{
return id.ValueKind switch
{
JsonValueKind.String => id.GetString(),
JsonValueKind.Number => id.TryGetInt64(out long l) ? l :
id.TryGetDouble(out double d) ? d : null,
JsonValueKind.Number => id.Clone(),
Comment thread
aaronburtle marked this conversation as resolved.
_ => null
};
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ public HealthCheckOptionsConverter(DeserializationVariableReplacementSettings? r
int parseThresholdMs = reader.GetInt32();
if (parseThresholdMs <= 0)
{
throw new JsonException($"Invalid value for ttl-seconds: {parseThresholdMs}. Value must be greater than 0.");
throw new JsonException($"Invalid value for threshold-ms: {parseThresholdMs}. Value must be greater than 0.");
}

threshold_ms = parseThresholdMs;
Expand Down
5 changes: 2 additions & 3 deletions src/Config/Converters/DmlToolsConfigConverter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ internal class DmlToolsConfigConverter : JsonConverter<DmlToolsConfig>
/// Reads DmlToolsConfig from JSON which can be either:
/// - A boolean: all tools are enabled/disabled
/// - An object: individual tool settings (unspecified tools default to true)
/// - Null/undefined: defaults to all tools enabled (true)
/// - Null: defaults to all tools enabled (true)
/// </summary>
public override DmlToolsConfig? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
Expand Down Expand Up @@ -163,8 +163,7 @@ internal class DmlToolsConfigConverter : JsonConverter<DmlToolsConfig>
aggregateRecordsQueryTimeout: aggregateRecordsQueryTimeout);
}

// For any other unexpected token type, return default (all enabled)
return DmlToolsConfig.Default;
throw new JsonException("The MCP dml-tools configuration must be a boolean or object value.");
}

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ private class HealthCheckOptionsConverter : JsonConverter<EntityHealthCheckConfi
int parseThresholdMs = reader.GetInt32();
if (parseThresholdMs <= 0)
{
throw new JsonException($"Invalid value for ttl-seconds: {parseThresholdMs}. Value must be greater than 0.");
throw new JsonException($"Invalid value for threshold-ms: {parseThresholdMs}. Value must be greater than 0.");
Comment thread
aaronburtle marked this conversation as resolved.
}

threshold_ms = parseThresholdMs;
Expand Down
17 changes: 15 additions & 2 deletions src/Config/DatabasePrimitives/DatabaseObject.cs
Original file line number Diff line number Diff line change
Expand Up @@ -407,8 +407,21 @@ public bool Equals(ForeignKeyDefinition? other)

public override int GetHashCode()
{
return HashCode.Combine(
Pair, ReferencedColumns, ReferencingColumns);
HashCode hashCode = new();
hashCode.Add(Pair);
hashCode.Add(ReferencedColumns.Count);
foreach (string column in ReferencedColumns)
{
hashCode.Add(column, StringComparer.Ordinal);
}

hashCode.Add(ReferencingColumns.Count);
foreach (string column in ReferencingColumns)
{
hashCode.Add(column, StringComparer.Ordinal);
}

return hashCode.ToHashCode();
}
}

Expand Down
2 changes: 1 addition & 1 deletion src/Core/Models/SqlQueryStructures.cs
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@ public override bool Equals(object? obj)
/// <inheritdoc/>
public override int GetHashCode()
{
return base.GetHashCode() ^ Label.GetHashCode(StringComparison.Ordinal);
return HashCode.Combine(TableSchema, TableName, ColumnName, Label);
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -478,7 +478,7 @@ protected static object ParseParamAsSystemType(string param, Type systemType)
"Date" => DateOnly.Parse(param),
"Guid" => Guid.Parse(param),
"TimeOnly" => TimeOnly.Parse(param),
"TimeSpan" => TimeOnly.Parse(param),
"TimeSpan" => TimeSpan.Parse(param, CultureInfo.InvariantCulture),
"Single[]" => ParseArrayIntoSystemType(param, systemType),
_ => throw new NotSupportedException($"{systemType.Name} is not supported")
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,13 @@ public class AuthorizationResolverUnitTests
private const string TEST_AUTHENTICATION_TYPE = "TestAuth";
private const string TEST_CLAIMTYPE_NAME = "TestName";

[TestMethod]
public void GetRolesForOperation_NullEntityNameThrows()
{
Assert.ThrowsException<ArgumentNullException>(() =>
IAuthorizationResolver.GetRolesForOperation(null!, EntityActionOperation.Read, null));
}

#region Role Context Tests
/// <summary>
/// When the client role header is present, validates result when
Expand Down Expand Up @@ -1737,6 +1744,53 @@ public async Task TestClaimsParsingToJson()
Assert.AreEqual(expected: "", actual: claimsInRequestContext["nullValuedClaim"]);
}

[TestMethod]
public void GetProcessedUserClaims_MultipleClaimsPreserveArrayValueTypes()
{
List<Claim> claims = new()
{
new("booleans", "true", ClaimValueTypes.Boolean),
new("booleans", "false", ClaimValueTypes.Boolean),
new("integers", "-1", ClaimValueTypes.Integer),
new("integers", "2", ClaimValueTypes.Integer),
new("integer32s", "-3", ClaimValueTypes.Integer32),
new("integer32s", "4", ClaimValueTypes.Integer32),
new("uinteger32s", "5", ClaimValueTypes.UInteger32),
new("uinteger32s", "6", ClaimValueTypes.UInteger32),
new("integer64s", "-7", ClaimValueTypes.Integer64),
new("integer64s", "8", ClaimValueTypes.Integer64),
new("uinteger64s", "9", ClaimValueTypes.UInteger64),
new("uinteger64s", "10", ClaimValueTypes.UInteger64),
new("doubles", "11", ClaimValueTypes.Double),
new("doubles", "12", ClaimValueTypes.Double),
new("strings", "first", ClaimValueTypes.String),
new("strings", "second", ClaimValueTypes.String),
new("jsonNulls", "null", JsonClaimValueTypes.JsonNull),
new("jsonNulls", "null", JsonClaimValueTypes.JsonNull),
new("jsonObjects", "{\"id\":1}", JsonClaimValueTypes.Json),
new("jsonObjects", "{\"id\":2}", JsonClaimValueTypes.Json),
new("customs", "alpha", ClaimValueTypes.DateTime),
new("customs", "beta", ClaimValueTypes.DateTime)
};
ClaimsIdentity identity = new(claims, TEST_AUTHENTICATION_TYPE, TEST_CLAIMTYPE_NAME, AuthenticationOptions.ROLE_CLAIM_TYPE);
DefaultHttpContext context = new() { User = new ClaimsPrincipal(identity) };

Dictionary<string, string> processedClaims = AuthorizationResolver.GetProcessedUserClaims(context);

Assert.AreEqual("[true,false]", processedClaims["booleans"]);
Assert.AreEqual("[-1,2]", processedClaims["integers"]);
Assert.AreEqual("[-3,4]", processedClaims["integer32s"]);
Assert.AreEqual("[5,6]", processedClaims["uinteger32s"]);
Assert.AreEqual("[-7,8]", processedClaims["integer64s"]);
Assert.AreEqual("[9,10]", processedClaims["uinteger64s"]);
Assert.AreEqual("[11,12]", processedClaims["doubles"]);
Assert.AreEqual("[\"first\",\"second\"]", processedClaims["strings"]);
Assert.AreEqual("[\"null\",\"null\"]", processedClaims["jsonNulls"]);
Assert.AreEqual("[\"{\\u0022id\\u0022:1}\",\"{\\u0022id\\u0022:2}\"]", processedClaims["jsonObjects"]);
Assert.AreEqual("[\"alpha\",\"beta\"]", processedClaims["customs"]);
Assert.AreEqual(0, AuthorizationResolver.GetProcessedUserClaims(null).Count);
}

/// <summary>
/// JWT token JSON payloads may not be flat and may contain nested JSON objects or arrays.
/// This test validates that when dotnet's JWT processing code flattens the JWT token payload
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

using System;
using System.Collections;
using System.Collections.Generic;
using System.Security.Claims;
using System.Text.Json;
using System.Threading.Tasks;
using Azure.DataApiBuilder.Auth;
using Azure.DataApiBuilder.Config.DatabasePrimitives;
Expand Down Expand Up @@ -288,6 +290,159 @@ public async Task FindColumnPermissionsTests(string[] columnsRequestedInput,
CollectionAssert.AreEquivalent(expected: (ICollection)allowedColumns, actual: stubRestRequestContext.FieldsToBeReturned, message: "FieldsToBeReturned not subset of allowed columns.");
}

/// <summary>
/// Verifies that a context with more than one pending requirement is rejected because the handler evaluates requirements sequentially.
/// </summary>
[TestMethod]
Comment thread
aaronburtle marked this conversation as resolved.
public async Task MultipleRequirementsAreRejected()
{
AuthorizationHandlerContext context = new(
new IAuthorizationRequirement[] { new RoleContextPermissionsRequirement(), new ColumnsPermissionsRequirement() },
new ClaimsPrincipal(),
AuthorizationHelpers.TEST_ENTITY);
RestAuthorizationHandler handler = CreateHandler(new Mock<IAuthorizationResolver>().Object, CreateHttpContext());

await Assert.ThrowsExceptionAsync<DataApiBuilderException>(() => handler.HandleAsync(context));
}

/// <summary>
/// Verifies that authorization is rejected when the HTTP context accessor has no current context.
/// </summary>
[TestMethod]
public async Task MissingHttpContextIsRejected()
{
AuthorizationHandlerContext context = new(
new IAuthorizationRequirement[] { new RoleContextPermissionsRequirement() },
new ClaimsPrincipal(),
AuthorizationHelpers.TEST_ENTITY);
RestAuthorizationHandler handler = CreateHandler(new Mock<IAuthorizationResolver>().Object, null);

await Assert.ThrowsExceptionAsync<DataApiBuilderException>(() => handler.HandleAsync(context));
}

/// <summary>
/// Verifies that an entity-operation requirement rejects an HTTP verb that cannot be mapped to a supported operation.
/// </summary>
[TestMethod]
public async Task UnsupportedHttpVerbIsRejected()
{
await Assert.ThrowsExceptionAsync<DataApiBuilderException>(() => IsAuthorizationSuccessfulAsync(
new EntityRoleOperationPermissionsRequirement(),
AuthorizationHelpers.TEST_ENTITY,
new Mock<IAuthorizationResolver>().Object,
CreateHttpContext("OPTIONS")));
}

/// <summary>
/// Verifies that DELETE requests satisfy the column requirement without evaluating projected or writable columns.
/// </summary>
[TestMethod]
public async Task DeleteColumnRequirementSucceedsWithoutColumnChecks()
{
bool result = await IsAuthorizationSuccessfulAsync(
new ColumnsPermissionsRequirement(),
CreateRestRequestContext(Array.Empty<string>()),
new Mock<IAuthorizationResolver>().Object,
CreateHttpContext(HttpConstants.DELETE));

Assert.IsTrue(result);
}

/// <summary>
/// Verifies that an insert with no supplied columns is authorized only when the role exposes at least one field.
/// </summary>
[DataTestMethod]
[DataRow(true, true)]
[DataRow(false, false)]
public async Task EmptyInsertColumnsDependOnAccessibleFields(bool hasAccessibleFields, bool expected)
{
Mock<IAuthorizationResolver> resolver = new();
resolver.Setup(x => x.GetAllowedExposedColumns(
AuthorizationHelpers.TEST_ENTITY,
AuthorizationHelpers.TEST_ROLE,
EntityActionOperation.Create))
.Returns(hasAccessibleFields ? new[] { "id" } : Array.Empty<string>());
using JsonDocument payload = JsonDocument.Parse("{}");
RestRequestContext context = new InsertRequestContext(
AuthorizationHelpers.TEST_ENTITY,
new DatabaseTable { TableDefinition = new SourceDefinition() },
payload.RootElement,
EntityActionOperation.Insert);

bool result = await IsAuthorizationSuccessfulAsync(
new ColumnsPermissionsRequirement(),
context,
resolver.Object,
CreateHttpContext(HttpConstants.POST));

Assert.AreEqual(expected, result);
}

/// <summary>
/// Verifies that a column requirement rejects a resource that is not a REST request context.
/// </summary>
[TestMethod]
public async Task InvalidColumnsRequirementResourceIsRejected()
{
await Assert.ThrowsExceptionAsync<DataApiBuilderException>(() => IsAuthorizationSuccessfulAsync(
new ColumnsPermissionsRequirement(),
new object(),
new Mock<IAuthorizationResolver>().Object,
CreateHttpContext()));
}

/// <summary>
/// Verifies that stored-procedure authorization uses the resolver's execution-permission decision.
/// </summary>
[DataTestMethod]
[DataRow(true, true)]
[DataRow(false, false)]
public async Task StoredProcedureRequirementUsesResolverDecision(bool permitted, bool expected)
{
Mock<IAuthorizationResolver> resolver = new();
resolver.Setup(x => x.IsStoredProcedureExecutionPermitted(
AuthorizationHelpers.TEST_ENTITY,
AuthorizationHelpers.TEST_ROLE,
SupportedHttpVerb.Post))
.Returns(permitted);

bool result = await IsAuthorizationSuccessfulAsync(
new StoredProcedurePermissionsRequirement(),
AuthorizationHelpers.TEST_ENTITY,
resolver.Object,
CreateHttpContext(HttpConstants.POST));

Assert.AreEqual(expected, result);
}

/// <summary>
/// Verifies that a stored-procedure requirement fails when no entity resource is supplied.
/// </summary>
[TestMethod]
public async Task StoredProcedureRequirementFailsForNullResource()
{
bool result = await IsAuthorizationSuccessfulAsync(
new StoredProcedurePermissionsRequirement(),
null,
new Mock<IAuthorizationResolver>().Object,
CreateHttpContext(HttpConstants.POST));

Assert.IsFalse(result);
}

/// <summary>
/// Verifies that a stored-procedure requirement rejects a resource that is not an entity name.
/// </summary>
[TestMethod]
public async Task InvalidStoredProcedureResourceIsRejected()
{
await Assert.ThrowsExceptionAsync<DataApiBuilderException>(() => IsAuthorizationSuccessfulAsync(
new StoredProcedurePermissionsRequirement(),
new object(),
new Mock<IAuthorizationResolver>().Object,
CreateHttpContext(HttpConstants.POST)));
}

#region Helper Methods
/// <summary>
/// Setup request and authorization context and get Authorization result
Expand Down Expand Up @@ -315,6 +470,13 @@ private static async Task<bool> IsAuthorizationSuccessfulAsync(
return context.HasSucceeded;
}

private static RestAuthorizationHandler CreateHandler(IAuthorizationResolver resolver, HttpContext? httpContext)
{
Mock<IHttpContextAccessor> accessor = new();
accessor.Setup(x => x.HttpContext).Returns(httpContext);
return new RestAuthorizationHandler(resolver, accessor.Object, new Mock<ILogger<RestAuthorizationHandler>>().Object);
}

/// <summary>
/// Create Mock HttpContext object for use in test fixture.
/// </summary>
Expand Down
Loading