Skip to content
Merged
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
2 changes: 1 addition & 1 deletion schemas/dab.draft.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,7 @@
"description": "Allow enabling/disabling GraphQL requests for all entities."
},
"depth-limit": {
"type": "integer",
"type": [ "integer", "null" ],
"description": "Maximum allowed depth of a GraphQL query.",
"default": null
},
Expand Down
17 changes: 16 additions & 1 deletion src/Config/Converters/GraphQLRuntimeOptionsConverterFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,22 @@ internal GraphQLRuntimeOptionsConverter(bool replaceEnvVar)
}
else if (reader.TokenType is JsonTokenType.Number)
{
graphQLRuntimeOptions = graphQLRuntimeOptions with { DepthLimit = reader.GetInt32(), UserProvidedDepthLimit = true };
int depthLimit;
try
{
depthLimit = reader.GetInt32();
}
catch (FormatException)
{
throw new JsonException($"The JSON token value is of the incorrect numeric format.");
}

if (depthLimit < -1 || depthLimit == 0)
{
throw new JsonException($"Invalid depth-limit: {depthLimit}. Specify a depth limit > 0 or remove the existing depth limit by specifying -1.");
}

graphQLRuntimeOptions = graphQLRuntimeOptions with { DepthLimit = depthLimit, UserProvidedDepthLimit = true };
}
else
{
Expand Down
334 changes: 334 additions & 0 deletions src/Service.Tests/Configuration/ConfigurationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1298,6 +1298,73 @@ public async Task TestConfigIsValid()
}
}

/// <summary>
/// Test to verify that provided invalid value of depth-limit in the config file should
/// result in validation failure during `dab validate` and `dab start`.
/// </summary>
[DataTestMethod]
[DataRow(0, DisplayName = "[FAIL]: Invalid Value: 0 for depth-limit.")]
[DataRow(-2, DisplayName = "[FAIL]: Invalid Value: -2 for depth-limit.")]
[TestCategory(TestCategory.MSSQL)]
public async Task TestValidateConfigForInvalidDepthLimit(int? depthLimit)
{
await ValidateConfigWithDepthLimit(depthLimit, expectedSuccess: false);
}

/// <summary>
/// Test to verify that provided valid value of depth-limit in the config file should not
/// result in any validation failure during `dab validate` and `dab start`.
/// -1 and null are special values.
/// -1 can be set to remove the depth limit, while `null` is the default value which means no depth limit check.
/// </summary>
[DataTestMethod]
[DataRow(-1, DisplayName = "[PASS]: Valid Value: -1 to disable depth limit")]
[DataRow(2, DisplayName = "[PASS]: Valid Value: 2 for depth-limit.")]
[DataRow(2147483647, DisplayName = "[PASS]: Valid Value: Using Int32.MaxValue(2147483647) for depth-limit.")]
[DataRow(null, DisplayName = "[PASS]: Default Value: null for depth-limit.")]
[TestCategory(TestCategory.MSSQL)]
public async Task TestValidateConfigForValidDepthLimit(int? depthLimit)
{
await ValidateConfigWithDepthLimit(depthLimit, expectedSuccess: true);
}

/// <summary>
/// This method validates that depth-limit outside the valid range should fail validation
/// during `dab validate` and `dab start`.
/// </summary>
/// <param name="depthLimit"></param>
/// <param name="expectedSuccess"></param>
private static async Task ValidateConfigWithDepthLimit(int? depthLimit, bool expectedSuccess)
{
// Arrange: Common setup logic
TestHelper.SetupDatabaseEnvironment(MSSQL_ENVIRONMENT);
const string CUSTOM_CONFIG = "custom-config.json";
FileSystemRuntimeConfigLoader testConfigPath = TestHelper.GetRuntimeConfigLoader();
RuntimeConfig configuration = TestHelper.GetRuntimeConfigProvider(testConfigPath).GetConfig();
configuration = configuration with
{
Runtime = configuration.Runtime with
{
GraphQL = configuration.Runtime.GraphQL with { DepthLimit = depthLimit, UserProvidedDepthLimit = true }
}
};

MockFileSystem fileSystem = new();
fileSystem.AddFile(CUSTOM_CONFIG, new MockFileData(configuration.ToJson()));
FileSystemRuntimeConfigLoader configLoader = new(fileSystem);
configLoader.UpdateConfigFilePath(CUSTOM_CONFIG);
RuntimeConfigProvider configProvider = TestHelper.GetRuntimeConfigProvider(configLoader);

Mock<ILogger<RuntimeConfigValidator>> configValidatorLogger = new();
RuntimeConfigValidator configValidator = new(configProvider, fileSystem, configValidatorLogger.Object, true);

// Act
bool isSuccess = await configValidator.TryValidateConfig(CUSTOM_CONFIG, TestHelper.ProvisionLoggerFactory());

// Assert based on expected success
Assert.AreEqual(expectedSuccess, isSuccess);
}

/// <summary>
/// This test method checks a valid config's entities against
/// the database and ensures they are valid.
Expand Down Expand Up @@ -3698,6 +3765,273 @@ public async Task ValidateNextLinkUsage()
}
}

/// <summary>
/// Tests the enforcement of depth limit restrictions on GraphQL queries and mutations in non-hosted mode.
/// Verifies that requests exceeding the specified depth limit result in a BadRequest,
/// while requests within the limit succeed with the expected status code.
/// Also verifies that the error message contains the current and allowed max depth limit value.
/// Example:
/// Query:
/// query book_by_pk{
/// book_by_pk(id: 1) { // depth: 1
/// id, // depth: 2
/// title, // depth: 2
/// publisher_id // depth: 2
/// }
/// }
/// Mutation:
/// mutation createbook {
/// createbook(item: { title: ""Book #1"", publisher_id: 1234 }) { // depth: 1
/// title, // depth: 2
/// publisher_id // depth: 2
/// }
/// </summary>
/// <param name="depthLimit">The maximum allowed depth for GraphQL queries and mutations.</param>
/// <param name="operationType">Indicates whether the operation is a mutation or a query.</param>
/// <param name="expectedStatusCodeForGraphQL">The expected HTTP status code for the operation.</param>
[DataTestMethod]
[DataRow(1, GraphQLOperation.Query, HttpStatusCode.BadRequest, DisplayName = "Failed Query execution when max depth limit is set to 1")]
[DataRow(2, GraphQLOperation.Query, HttpStatusCode.OK, DisplayName = "Query execution successful when max depth limit is set to 2")]
[DataRow(1, GraphQLOperation.Mutation, HttpStatusCode.BadRequest, DisplayName = "Failed Mutation execution when max depth limit is set to 1")]
[DataRow(2, GraphQLOperation.Mutation, HttpStatusCode.OK, DisplayName = "Mutation execution successful when max depth limit is set to 2")]
[TestCategory(TestCategory.MSSQL)]
public async Task TestDepthLimitRestrictionOnGraphQLInNonHostedMode(
int depthLimit,
GraphQLOperation operationType,
HttpStatusCode expectedStatusCodeForGraphQL)
{
// Arrange
GraphQLRuntimeOptions graphqlOptions = new(DepthLimit: depthLimit);
graphqlOptions = graphqlOptions with { UserProvidedDepthLimit = true };

DataSource dataSource = new(DatabaseType.MSSQL,
GetConnectionStringFromEnvironmentConfig(environment: TestCategory.MSSQL), Options: null);

RuntimeConfig configuration = InitMinimalRuntimeConfig(dataSource, graphqlOptions, restOptions: new());
const string CUSTOM_CONFIG = "custom-config.json";
File.WriteAllText(CUSTOM_CONFIG, configuration.ToJson());

string[] args = new[]
{
$"--ConfigFileName={CUSTOM_CONFIG}"
};

using (TestServer server = new(Program.CreateWebHostBuilder(args)))
using (HttpClient client = server.CreateClient())
{
string query;
if (operationType is GraphQLOperation.Mutation)
{
// requested mutation operation has depth of 2
query = @"mutation createbook{
createbook(item: { title: ""Book #1"", publisher_id: 1234 }) {
title
publisher_id
}
}";
}
else
{
// requested query operation has depth of 2
query = @"query book_by_pk{
book_by_pk(id: 1) {
id,
title,
publisher_id
}
}";
}

object payload = new { query };

HttpRequestMessage graphQLRequest = new(HttpMethod.Post, "/graphql")
{
Content = JsonContent.Create(payload)
};

// Act
HttpResponseMessage graphQLResponse = await client.SendAsync(graphQLRequest);

// Assert
Assert.AreEqual(expectedStatusCodeForGraphQL, graphQLResponse.StatusCode);
string body = await graphQLResponse.Content.ReadAsStringAsync();
JsonElement responseJson = JsonSerializer.Deserialize<JsonElement>(body);
if (graphQLResponse.StatusCode == HttpStatusCode.OK)
{
Assert.IsTrue(responseJson.TryGetProperty("data", out JsonElement data), "The response should contain data.");
Assert.IsFalse(data.TryGetProperty("errors", out _), "The response should not contain any errors.");
}
else
{
Assert.IsTrue(responseJson.TryGetProperty("errors", out JsonElement data), "The response should contain errors.");
Assert.IsTrue(data.EnumerateArray().Any(), "The response should contain at least one error.");
Assert.IsTrue(data.EnumerateArray().FirstOrDefault().TryGetProperty("message", out JsonElement message), "The error should contain a message.");
string errorMessage = message.GetString();
string expectedErrorMessage = $"The GraphQL document has an execution depth of 2 which exceeds the max allowed execution depth of {depthLimit}.";
Assert.AreEqual(expectedErrorMessage, errorMessage, "The error message should contain the current and allowed max depth limit value.");
}
}
}

/// <summary>
/// This test verifies that the depth-limit specified for GraphQL does not affect introspection queries.
/// In this test, we have specified the depth limit as 2 and we are sending introspection query with depth 6.
/// The expected result is that the query should be successful and should not return any errors.
/// Example:
/// {
/// __schema { // depth: 1
/// types { // depth: 2
/// name // depth: 3
/// fields { // depth: 3
/// name // depth: 4
/// type { // depth: 4
/// name // depth: 5
/// kind // depth: 5
/// ofType { // depth: 5
/// name // depth: 6
/// kind // depth: 6
/// }
/// }
/// }
/// }
/// </summary>
[TestCategory(TestCategory.MSSQL)]
[TestMethod]
public async Task TestGraphQLIntrospectionQueriesAreNotImpactedByDepthLimit()
{
// Arrange
GraphQLRuntimeOptions graphqlOptions = new(DepthLimit: 2);
graphqlOptions = graphqlOptions with { UserProvidedDepthLimit = true };

DataSource dataSource = new(DatabaseType.MSSQL,
GetConnectionStringFromEnvironmentConfig(environment: TestCategory.MSSQL), Options: null);

RuntimeConfig configuration = InitMinimalRuntimeConfig(dataSource, graphqlOptions, restOptions: new());
const string CUSTOM_CONFIG = "custom-config.json";
File.WriteAllText(CUSTOM_CONFIG, configuration.ToJson());

string[] args = new[]
{
$"--ConfigFileName={CUSTOM_CONFIG}"
};

using (TestServer server = new(Program.CreateWebHostBuilder(args)))
using (HttpClient client = server.CreateClient())
{
// nested depth:6
string query = @"{
__schema {
types {
name
fields {
name
type {
name
kind
ofType {
name
kind
}
}
}
}
}
}";

object payload = new { query };

HttpRequestMessage graphQLRequest = new(HttpMethod.Post, "/graphql")
{
Content = JsonContent.Create(payload)
};

// Act
HttpResponseMessage graphQLResponse = await client.SendAsync(graphQLRequest);

// Assert
Assert.AreEqual(HttpStatusCode.OK, graphQLResponse.StatusCode);
string body = await graphQLResponse.Content.ReadAsStringAsync();

JsonElement responseJson = JsonSerializer.Deserialize<JsonElement>(body);
Assert.IsNotNull(responseJson, "The response should be a valid JSON.");
Assert.IsTrue(responseJson.TryGetProperty("data", out JsonElement data), "The response should contain data.");
Assert.IsFalse(data.TryGetProperty("errors", out _), "The response should not contain any errors.");
Assert.IsTrue(responseJson.GetProperty("data").TryGetProperty("__schema", out JsonElement schema));
Assert.IsNotNull(schema, "The response should contain schema information.");
}
}

/// <summary>
/// Tests the behavior of GraphQL queries in non-hosted mode when the depth limit is explicitly set to -1 or null.
/// Setting the depth limit to -1 is intended to disable the depth limit check, allowing queries of any depth.
/// Using null as default value of dab which also disables the depth limit check.
/// This test verifies that queries are processed successfully without any errors under these configurations.
/// Example Query:
/// {
/// book_by_pk(id: 1) { // depth: 1
/// id, // depth: 2
/// title, // depth: 2
/// publisher_id // depth: 2
/// }
/// }
/// </summary>
/// <param name="depthLimit"> </param>
[DataTestMethod]
[DataRow(-1, DisplayName = "Setting -1 for depth-limit will disable the depth limit")]
[DataRow(null, DisplayName = "Using default value: null for depth-limit which also disables the depth limit check")]
[TestCategory(TestCategory.MSSQL)]
public async Task TestNoDepthLimitOnGrahQLInNonHostedMode(int? depthLimit)
{
// Arrange
GraphQLRuntimeOptions graphqlOptions = new(DepthLimit: depthLimit);
graphqlOptions = graphqlOptions with { UserProvidedDepthLimit = true };

DataSource dataSource = new(DatabaseType.MSSQL,
GetConnectionStringFromEnvironmentConfig(environment: TestCategory.MSSQL), Options: null);

RuntimeConfig configuration = InitMinimalRuntimeConfig(dataSource, graphqlOptions, restOptions: new());
const string CUSTOM_CONFIG = "custom-config.json";
File.WriteAllText(CUSTOM_CONFIG, configuration.ToJson());

string[] args = new[]
{
$"--ConfigFileName={CUSTOM_CONFIG}"
};

using (TestServer server = new(Program.CreateWebHostBuilder(args)))
using (HttpClient client = server.CreateClient())
{
// requested query operation has depth of 2
string query = @"{
book_by_pk(id: 1) {
id,
title,
publisher_id
}
}";

object payload = new { query };

HttpRequestMessage graphQLRequest = new(HttpMethod.Post, "/graphql")
{
Content = JsonContent.Create(payload)
};

// Act
HttpResponseMessage graphQLResponse = await client.SendAsync(graphQLRequest);

// Assert
Assert.AreEqual(HttpStatusCode.OK, graphQLResponse.StatusCode);
string body = await graphQLResponse.Content.ReadAsStringAsync();

JsonElement responseJson = JsonSerializer.Deserialize<JsonElement>(body);
Assert.IsNotNull(responseJson, "The response should be a valid JSON.");
Assert.IsTrue(responseJson.TryGetProperty("data", out JsonElement data), "The response should contain data.");
Assert.IsFalse(data.TryGetProperty("errors", out _), "The response should not contain any errors.");
Assert.IsTrue(data.TryGetProperty("book_by_pk", out _), "The response data should contain book_by_pk data.");
}
}

/// <summary>
/// Helper function to write custom configuration file. with minimal REST/GraphQL global settings
/// using the supplied entities.
Expand Down
Loading