Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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 @@ -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.");
}

threshold_ms = parseThresholdMs;
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,132 @@ public async Task FindColumnPermissionsTests(string[] columnsRequestedInput,
CollectionAssert.AreEquivalent(expected: (ICollection)allowedColumns, actual: stubRestRequestContext.FieldsToBeReturned, message: "FieldsToBeReturned not subset of allowed columns.");
}

[TestMethod]
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));
}

[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));
}

[TestMethod]
public async Task UnsupportedHttpVerbIsRejected()
{
await Assert.ThrowsExceptionAsync<DataApiBuilderException>(() => IsAuthorizationSuccessfulAsync(
new EntityRoleOperationPermissionsRequirement(),
AuthorizationHelpers.TEST_ENTITY,
new Mock<IAuthorizationResolver>().Object,
CreateHttpContext("OPTIONS")));
}

[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);
}

[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);
}

[TestMethod]
public async Task InvalidColumnsRequirementResourceIsRejected()
{
await Assert.ThrowsExceptionAsync<DataApiBuilderException>(() => IsAuthorizationSuccessfulAsync(
new ColumnsPermissionsRequirement(),
new object(),
new Mock<IAuthorizationResolver>().Object,
CreateHttpContext()));
}

[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);
}

[TestMethod]
public async Task StoredProcedureRequirementFailsForNullResource()
{
bool result = await IsAuthorizationSuccessfulAsync(
new StoredProcedurePermissionsRequirement(),
null,
new Mock<IAuthorizationResolver>().Object,
CreateHttpContext(HttpConstants.POST));

Assert.IsFalse(result);
}

[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 +443,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
84 changes: 61 additions & 23 deletions src/Service.Tests/CosmosTests/SamplerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ public class SamplerTests : TestBase

private const string CONTAINER_NAME_ID_PK = "containerWithIdPk";
private const string CONTAINER_NAME_NAME_PK = "containerWithNamePk";
private const int DEFAULT_TIME_GROUP_COUNT = 10;
private const int DEFAULT_RECORDS_PER_TIME_GROUP = 10;

/// <summary>
/// Initializes the test environment by creating Cosmos DB containers and populating them with sample data.
Expand All @@ -61,19 +63,19 @@ public async Task Initialize()

// Retrieve timestamps from the container to use in validation.
CosmosExecutor executor = new(_containerWithIdPk, new Mock<ILogger>().Object);
await executor
.ExecuteQueryAsync<JsonDocument>("SELECT DISTINCT c._ts FROM c ORDER BY c._ts desc",
callback: (item) => _sortedTimespansIdPk.Add(item.RootElement.GetProperty("_ts").GetInt32()));
await executor.ExecuteQueryAsync<JsonDocument>(
"SELECT c._ts FROM c ORDER BY c._ts desc",
callback: (item) => _sortedTimespansIdPk.Add(item.RootElement.GetProperty("_ts").GetInt32()));

// Insert additional items into the second container with a delay for unique timestamps and partitioned over name i.e planets name.
// Number of partitions would be 9 as we have 9 unique names.
CreateItems(DATABASE_NAME, CONTAINER_NAME_NAME_PK, 15, "/name", waitInMs: 1000);

// Retrieve timestamps for the second container to use in validation.
executor = new(_containerWithNamePk, new Mock<ILogger>().Object);
await executor
.ExecuteQueryAsync<JsonDocument>("SELECT DISTINCT c._ts FROM c ORDER BY c._ts desc",
callback: (item) => _sortedTimespansNamePk.Add(item.RootElement.GetProperty("_ts").GetInt32()));
await executor.ExecuteQueryAsync<JsonDocument>(
"SELECT c._ts FROM c ORDER BY c._ts desc",
callback: (item) => _sortedTimespansNamePk.Add(item.RootElement.GetProperty("_ts").GetInt32()));
}

/// <summary>
Expand Down Expand Up @@ -118,7 +120,6 @@ public async Task TestTopNExtractor(int count, int? maxDays, int expectedCount)
/// <param name="partitionKeyPath">The path of the partition key to use for sampling. If null, partition key path is not considered.</param>
/// <param name="numberOfRecordsPerPartition">The number of records to retrieve per partition. Defaults to 5 if not specified.</param>
/// <param name="maxDaysPerPartition">The maximum number of days to filter records within each partition. If null, no date-based filtering is applied.</param>
/// <param name="expectedResultCount">The expected number of records returned by the sampler.</param>
/// <remarks>
/// This test case ensures that the <c>EligibleDataSampler</c> handles partition-based sampling correctly with various configurations.
/// It verifies that the sampler correctly applies partition key paths, record limits per partition, and date-based filters as specified.
Expand Down Expand Up @@ -198,31 +199,68 @@ public async Task TestGetPartitionInfoInEligibleDataSampler(string partitionKeyP
/// The test cases also include scenarios where records are not evenly distributed across time-based groups.
/// </remarks>
[TestMethod(displayName: "TimePartitionedSampler Scenarios")]
[DataRow(5, 1, 0, 5, DisplayName = "Retrieve 1 record, if it is allowed to fetch 1 item from a group and there are 5 groups (or time range)")]
[DataRow(1, 10, 0, 10, DisplayName = "Retrieve 10 records, if it is allowed to fetch 10 item from a group and there is only 1 group.")]
[DataRow(null, 1, 0, 10, DisplayName = "Retrieve 10 records, if 1 item is allowed to fetch from each group and number of groups is 10 (i.e default)")]
[DataRow(null, null, null, 10, DisplayName = "Retrieve 10 records i.e last 10 days data, based on default values when no specific limits are set.")]
[DataRow(5, 1, 4, 1, DisplayName = "Retrieve 1 record from a single group when records cannot be evenly divided into time-based groups.")]
public async Task TestTimePartitionedSampler(int? groupCount, int? numberOfRecordsPerGroup, int? maxDays, int expectedResultCount)
[DataRow(5, 1, 0, DisplayName = "Retrieve at most 1 record from each of 5 groups.")]
[DataRow(1, 10, 0, DisplayName = "Retrieve at most 10 records from a single group.")]
[DataRow(null, 1, 0, DisplayName = "Use the default group count and retrieve at most 1 record from each group.")]
[DataRow(null, null, null, DisplayName = "Use the default group, record, and day limits.")]
[DataRow(5, 1, 4, DisplayName = "Sample a short time range that cannot be evenly divided into 5 groups.")]
public async Task TestTimePartitionedSampler(int? groupCount, int? numberOfRecordsPerGroup, int? maxDays)
{
Mock<TimePartitionedSampler> timePartitionedSampler
= new(_containerWithNamePk, groupCount, numberOfRecordsPerGroup, maxDays, _mockLogger.Object);

if (maxDays is null || maxDays == 0)
// Compress day-sized windows to seconds so this integration test does not take days to arrange its data.
// Cosmos writes can take longer than one second on hosted agents, so the observed timestamps may contain gaps.
int timeWindowInSeconds = maxDays ?? TimePartitionedSampler.MAX_DAYS;
if (timeWindowInSeconds > 0)
{
maxDays = TimePartitionedSampler.MAX_DAYS;
timePartitionedSampler
.Setup<long>(x => x.GetTimeStampThreshold())
.Returns(_sortedTimespansNamePk[0] - timeWindowInSeconds);
}

timePartitionedSampler
.Setup<long>(x => x.GetTimeStampThreshold())
.Returns((long)(_sortedTimespansNamePk[0] - maxDays));

List<JsonDocument> result = await timePartitionedSampler.Object.GetSampleAsync();
int expectedResultCount = CalculateExpectedTimePartitionedResultCount(
_sortedTimespansNamePk,
groupCount ?? DEFAULT_TIME_GROUP_COUNT,
numberOfRecordsPerGroup ?? DEFAULT_RECORDS_PER_TIME_GROUP,
timeWindowInSeconds);

// We're relying on a delay to create records with different timestamps.
// However, this can cause the actual result to intermittently vary by one record in some cases, particularly in pipelines.
// To prevent these tests from becoming flaky, the assertion has been adjusted.
Assert.IsTrue(expectedResultCount == result.Count || (expectedResultCount + 1) == result.Count || (expectedResultCount - 1) == result.Count, $"Expected result count is {expectedResultCount} and Actual result count is {result.Count}");
Assert.AreEqual(
expectedResultCount,
result.Count,
$"The sampled result count should match the populated time groups. Timestamps: {string.Join(", ", _sortedTimespansNamePk)}");
}

private static int CalculateExpectedTimePartitionedResultCount(
IReadOnlyList<int> timestamps,
int groupCount,
int numberOfRecordsPerGroup,
int timeWindowInSeconds)
{
long maxTimestamp = timestamps[0];
long minTimestamp = timeWindowInSeconds > 0 ? maxTimestamp - timeWindowInSeconds : timestamps[^1];
long rangeSize = (maxTimestamp - minTimestamp) / groupCount;
int expectedResultCount = 0;

for (int group = 0; group < groupCount; group++)
{
long rangeStart = minTimestamp + (group * rangeSize);
long rangeEnd = group == groupCount - 1 ? maxTimestamp : rangeStart + rangeSize - 1;
int recordsInRange = 0;

foreach (int timestamp in timestamps)
{
if (timestamp >= rangeStart && timestamp <= rangeEnd)
{
recordsInRange++;
}
}

expectedResultCount += System.Math.Min(numberOfRecordsPerGroup, recordsInRange);
}

return expectedResultCount;
}

/// <summary>
Expand Down
Loading
Loading