From 66e2112715f7f1636834b4471f5a4545fb66dc77 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 25 Feb 2026 20:19:52 +0000 Subject: [PATCH 01/19] Initial plan From 22f67146d623767fd33d7ba9f02a3263d2c915ba Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 25 Feb 2026 20:44:44 +0000 Subject: [PATCH 02/19] Implement role inheritance and show-effective-permissions CLI command with a-z entity ordering Co-authored-by: JerryNixon <1749983+JerryNixon@users.noreply.github.com> --- src/Cli/Commands/ConfigureOptions.cs | 22 ++++- src/Cli/ConfigGenerator.cs | 57 ++++++++++++ .../Authorization/AuthorizationResolver.cs | 45 +++++++++ .../GraphQLAuthorizationHandler.cs | 14 ++- .../AuthorizationResolverUnitTests.cs | 93 ++++++++++++++++++- 5 files changed, 225 insertions(+), 6 deletions(-) diff --git a/src/Cli/Commands/ConfigureOptions.cs b/src/Cli/Commands/ConfigureOptions.cs index 14234d24d7..d0b25b1957 100644 --- a/src/Cli/Commands/ConfigureOptions.cs +++ b/src/Cli/Commands/ConfigureOptions.cs @@ -73,6 +73,7 @@ public ConfigureOptions( RollingInterval? fileSinkRollingInterval = null, int? fileSinkRetainedFileCountLimit = null, long? fileSinkFileSizeLimitBytes = null, + bool showEffectivePermissions = false, string? config = null) : base(config) { @@ -137,6 +138,7 @@ public ConfigureOptions( FileSinkRollingInterval = fileSinkRollingInterval; FileSinkRetainedFileCountLimit = fileSinkRetainedFileCountLimit; FileSinkFileSizeLimitBytes = fileSinkFileSizeLimitBytes; + ShowEffectivePermissions = showEffectivePermissions; } [Option("data-source.database-type", Required = false, HelpText = "Database type. Allowed values: MSSQL, PostgreSQL, CosmosDB_NoSQL, MySQL.")] @@ -292,11 +294,27 @@ public ConfigureOptions( [Option("runtime.telemetry.file.file-size-limit-bytes", Required = false, HelpText = "Configure maximum file size limit in bytes. Default: 1048576")] public long? FileSinkFileSizeLimitBytes { get; } + [Option("show-effective-permissions", Required = false, HelpText = "Display effective permissions for all entities, including inherited permissions. Entities are listed in alphabetical order.")] + public bool ShowEffectivePermissions { get; } + public int Handler(ILogger logger, FileSystemRuntimeConfigLoader loader, IFileSystem fileSystem) { logger.LogInformation("{productName} {version}", PRODUCT_NAME, ProductInfo.GetProductVersion()); - bool isSuccess = ConfigGenerator.TryConfigureSettings(this, loader, fileSystem); - if (isSuccess) + + if (ShowEffectivePermissions) + { + bool isSuccess = ConfigGenerator.TryShowEffectivePermissions(this, loader, fileSystem); + if (!isSuccess) + { + logger.LogError("Failed to display effective permissions."); + return CliReturnCode.GENERAL_ERROR; + } + + return CliReturnCode.SUCCESS; + } + + bool configSuccess = ConfigGenerator.TryConfigureSettings(this, loader, fileSystem); + if (configSuccess) { logger.LogInformation("Successfully updated runtime settings in the config file."); return CliReturnCode.SUCCESS; diff --git a/src/Cli/ConfigGenerator.cs b/src/Cli/ConfigGenerator.cs index 9a3401a55a..fdaf44992c 100644 --- a/src/Cli/ConfigGenerator.cs +++ b/src/Cli/ConfigGenerator.cs @@ -585,6 +585,63 @@ public static bool TryCreateSourceObjectForNewEntity( return true; } + + /// + /// Displays the effective permissions for all entities defined in the config, listed alphabetically by entity name. + /// Effective permissions include explicitly configured roles as well as inherited permissions: + /// - anonymous → authenticated (when authenticated is not explicitly configured) + /// - authenticated → any named role not explicitly configured for the entity + /// + /// True if the effective permissions were successfully displayed; otherwise, false. + public static bool TryShowEffectivePermissions(ConfigureOptions options, FileSystemRuntimeConfigLoader loader, IFileSystem fileSystem) + { + if (!TryGetConfigFileBasedOnCliPrecedence(loader, options.Config, out string runtimeConfigFile)) + { + return false; + } + + if (!loader.TryLoadConfig(runtimeConfigFile, out RuntimeConfig? runtimeConfig)) + { + _logger.LogError("Failed to read the config file: {runtimeConfigFile}.", runtimeConfigFile); + return false; + } + + const string ROLE_ANONYMOUS = "anonymous"; + const string ROLE_AUTHENTICATED = "authenticated"; + + // Iterate entities sorted a-z by name. + foreach ((string entityName, Entity entity) in runtimeConfig.Entities.OrderBy(e => e.Key, StringComparer.OrdinalIgnoreCase)) + { + _logger.LogInformation("Entity: {entityName}", entityName); + + bool hasAnonymous = entity.Permissions.Any(p => p.Role.Equals(ROLE_ANONYMOUS, StringComparison.OrdinalIgnoreCase)); + bool hasAuthenticated = entity.Permissions.Any(p => p.Role.Equals(ROLE_AUTHENTICATED, StringComparison.OrdinalIgnoreCase)); + + foreach (EntityPermission permission in entity.Permissions.OrderBy(p => p.Role, StringComparer.OrdinalIgnoreCase)) + { + string actions = string.Join(", ", permission.Actions.Select(a => a.Action.ToString())); + _logger.LogInformation(" Role: {role} | Actions: {actions}", permission.Role, actions); + } + + // Show inherited authenticated permissions when authenticated is not explicitly configured. + if (hasAnonymous && !hasAuthenticated) + { + EntityPermission anonPermission = entity.Permissions.First(p => p.Role.Equals(ROLE_ANONYMOUS, StringComparison.OrdinalIgnoreCase)); + string inheritedActions = string.Join(", ", anonPermission.Actions.Select(a => a.Action.ToString())); + _logger.LogInformation(" Role: {role} | Actions: {actions} (inherited from: {source})", ROLE_AUTHENTICATED, inheritedActions, ROLE_ANONYMOUS); + } + + // Show inheritance note for named roles. + string inheritSource = hasAuthenticated ? ROLE_AUTHENTICATED : (hasAnonymous ? ROLE_ANONYMOUS : string.Empty); + if (!string.IsNullOrEmpty(inheritSource)) + { + _logger.LogInformation(" Any unconfigured named role inherits from: {inheritSource}", inheritSource); + } + } + + return true; + } + /// /// Tries to update the runtime settings based on the provided runtime options. /// diff --git a/src/Core/Authorization/AuthorizationResolver.cs b/src/Core/Authorization/AuthorizationResolver.cs index 0f22b9cd28..ab9805bc51 100644 --- a/src/Core/Authorization/AuthorizationResolver.cs +++ b/src/Core/Authorization/AuthorizationResolver.cs @@ -119,6 +119,7 @@ public bool IsValidRoleContext(HttpContext httpContext) /// public bool AreRoleAndOperationDefinedForEntity(string entityIdentifier, string roleName, EntityActionOperation operation) { + roleName = GetEffectiveRoleName(entityIdentifier, roleName); if (EntityPermissionsMap.TryGetValue(entityIdentifier, out EntityMetadata? valueOfEntityToRole)) { if (valueOfEntityToRole.RoleToOperationMap.TryGetValue(roleName, out RoleMetadata? valueOfRoleToOperation)) @@ -135,6 +136,7 @@ public bool AreRoleAndOperationDefinedForEntity(string entityIdentifier, string public bool IsStoredProcedureExecutionPermitted(string entityName, string roleName, SupportedHttpVerb httpVerb) { + roleName = GetEffectiveRoleName(entityName, roleName); bool executionPermitted = EntityPermissionsMap.TryGetValue(entityName, out EntityMetadata? entityMetadata) && entityMetadata is not null && entityMetadata.RoleToOperationMap.TryGetValue(roleName, out _); @@ -144,6 +146,7 @@ public bool IsStoredProcedureExecutionPermitted(string entityName, string roleNa /// public bool AreColumnsAllowedForOperation(string entityName, string roleName, EntityActionOperation operation, IEnumerable columns) { + roleName = GetEffectiveRoleName(entityName, roleName); string dataSourceName = _runtimeConfigProvider.GetConfig().GetDataSourceNameFromEntityName(entityName); ISqlMetadataProvider metadataProvider = _metadataProviderFactory.GetMetadataProvider(dataSourceName); @@ -210,6 +213,7 @@ public string ProcessDBPolicy(string entityName, string roleName, EntityActionOp /// public string GetDBPolicyForRequest(string entityName, string roleName, EntityActionOperation operation) { + roleName = GetEffectiveRoleName(entityName, roleName); if (!EntityPermissionsMap[entityName].RoleToOperationMap.TryGetValue(roleName, out RoleMetadata? roleMetadata)) { return string.Empty; @@ -426,6 +430,46 @@ private static void CopyOverPermissionsFromAnonymousToAuthenticatedRole( } } + /// + /// Returns the effective role name for permission lookups, implementing role inheritance. + /// System roles (anonymous, authenticated) always resolve to themselves. + /// For any other named role not explicitly configured for the entity, this method falls back + /// to the 'authenticated' role if it is present (which itself may already inherit from 'anonymous'). + /// Inheritance chain: named-role → authenticated → anonymous → none. + /// + /// Name of the entity being accessed. + /// Role name from the request. + /// The role name whose permissions should apply for this request. + private string GetEffectiveRoleName(string entityName, string roleName) + { + // System roles always resolve to themselves; they do not inherit from other roles. + if (roleName.Equals(ROLE_ANONYMOUS, StringComparison.OrdinalIgnoreCase) || + roleName.Equals(ROLE_AUTHENTICATED, StringComparison.OrdinalIgnoreCase)) + { + return roleName; + } + + if (!EntityPermissionsMap.TryGetValue(entityName, out EntityMetadata? entityMetadata)) + { + return roleName; + } + + // Named role explicitly configured: use its own permissions. + if (entityMetadata.RoleToOperationMap.ContainsKey(roleName)) + { + return roleName; + } + + // Named role not configured: inherit from 'authenticated' if present. + // Note: 'authenticated' itself may already inherit from 'anonymous' via setup-time copy. + if (entityMetadata.RoleToOperationMap.ContainsKey(ROLE_AUTHENTICATED)) + { + return ROLE_AUTHENTICATED; + } + + return roleName; + } + /// /// Returns a list of all possible operations depending on the provided EntitySourceType. /// Stored procedures only support Operation.Execute. @@ -474,6 +518,7 @@ private static void PopulateAllowedExposedColumns( /// public IEnumerable GetAllowedExposedColumns(string entityName, string roleName, EntityActionOperation operation) { + roleName = GetEffectiveRoleName(entityName, roleName); return EntityPermissionsMap[entityName].RoleToOperationMap[roleName].OperationToColumnMap[operation].AllowedExposedColumns; } diff --git a/src/Core/Authorization/GraphQLAuthorizationHandler.cs b/src/Core/Authorization/GraphQLAuthorizationHandler.cs index 2760777e94..ed11b6d13c 100644 --- a/src/Core/Authorization/GraphQLAuthorizationHandler.cs +++ b/src/Core/Authorization/GraphQLAuthorizationHandler.cs @@ -134,10 +134,13 @@ private static bool TryGetApiRoleHeader(IDictionary contextData /// The runtime's GraphQLSchemaBuilder will not add an @authorize directive without any roles defined, /// however, since the Roles property of HotChocolate's AuthorizeDirective object is nullable, /// handle the possible null gracefully. + /// Supports role inheritance: a named role not explicitly listed is permitted when 'authenticated' + /// is listed in the directive roles, implementing the chain: named-role → authenticated → anonymous. /// /// Role defined in request HTTP Header, X-MS-API-ROLE /// Roles defined on the @authorize directive. Case insensitive. - /// True when the authenticated user's explicitly defined role is present in the authorize directive role list. Otherwise, false. + /// True when the authenticated user's explicitly defined role is present in the authorize directive role list, + /// or when the role inherits permissions from 'authenticated'. Otherwise, false. private static bool IsInHeaderDesignatedRole(string clientRoleHeader, IReadOnlyList? roles) { if (roles is null || roles.Count == 0) @@ -150,6 +153,15 @@ private static bool IsInHeaderDesignatedRole(string clientRoleHeader, IReadOnlyL return true; } + // Role inheritance: named roles (any role other than anonymous) inherit from 'authenticated'. + // If 'authenticated' is in the directive's roles and the requesting role is not 'anonymous', + // allow access because named roles inherit from 'authenticated'. + if (!clientRoleHeader.Equals(AuthorizationResolver.ROLE_ANONYMOUS, StringComparison.OrdinalIgnoreCase) && + roles.Any(role => role.Equals(AuthorizationResolver.ROLE_AUTHENTICATED, StringComparison.OrdinalIgnoreCase))) + { + return true; + } + return false; } diff --git a/src/Service.Tests/Authorization/AuthorizationResolverUnitTests.cs b/src/Service.Tests/Authorization/AuthorizationResolverUnitTests.cs index 0dff3ac016..d854ba618e 100644 --- a/src/Service.Tests/Authorization/AuthorizationResolverUnitTests.cs +++ b/src/Service.Tests/Authorization/AuthorizationResolverUnitTests.cs @@ -326,9 +326,9 @@ public void TestAuthenticatedRoleWhenAnonymousRoleIsDefined() } } - // Anonymous role's permissions are copied over for authenticated role only. - // Assert by checking for an arbitrary role. - Assert.IsFalse(authZResolver.AreRoleAndOperationDefinedForEntity(AuthorizationHelpers.TEST_ENTITY, + // With role inheritance, named roles inherit from authenticated (which inherited from anonymous). + // Assert that an arbitrary named role now effectively has the Create operation via inheritance. + Assert.IsTrue(authZResolver.AreRoleAndOperationDefinedForEntity(AuthorizationHelpers.TEST_ENTITY, AuthorizationHelpers.TEST_ROLE, EntityActionOperation.Create)); // Assert that the create operation has both anonymous, authenticated roles. @@ -479,6 +479,93 @@ public void TestAuthenticatedRoleWhenBothAnonymousAndAuthenticatedAreDefined() CollectionAssert.AreEquivalent(expectedRolesForUpdateCol1, actualRolesForUpdateCol1.ToList()); } + /// + /// Validates role inheritance for named roles: when a named role is not configured for an entity + /// but 'authenticated' is configured (or inherited from 'anonymous'), the named role inherits + /// the permissions of 'authenticated'. + /// Inheritance chain: named-role → authenticated → anonymous → none. + /// + [TestMethod] + public void TestNamedRoleInheritsFromAuthenticatedRole() + { + RuntimeConfig runtimeConfig = AuthorizationHelpers.InitRuntimeConfig( + entityName: AuthorizationHelpers.TEST_ENTITY, + roleName: AuthorizationResolver.ROLE_AUTHENTICATED, + operation: EntityActionOperation.Read); + + AuthorizationResolver authZResolver = AuthorizationHelpers.InitAuthorizationResolver(runtimeConfig); + + // Named role (TEST_ROLE = "Writer") is not configured but should inherit from 'authenticated'. + Assert.IsTrue(authZResolver.AreRoleAndOperationDefinedForEntity( + AuthorizationHelpers.TEST_ENTITY, + AuthorizationHelpers.TEST_ROLE, + EntityActionOperation.Read)); + + // Named role should NOT have operations that 'authenticated' does not have. + Assert.IsFalse(authZResolver.AreRoleAndOperationDefinedForEntity( + AuthorizationHelpers.TEST_ENTITY, + AuthorizationHelpers.TEST_ROLE, + EntityActionOperation.Create)); + } + + /// + /// Validates that when neither 'anonymous' nor 'authenticated' is configured for an entity, + /// a named role that is also not configured inherits nothing (rule 5). + /// + [TestMethod] + public void TestNamedRoleInheritsNothingWhenNoSystemRolesDefined() + { + const string CONFIGURED_NAMED_ROLE = "admin"; + RuntimeConfig runtimeConfig = AuthorizationHelpers.InitRuntimeConfig( + entityName: AuthorizationHelpers.TEST_ENTITY, + roleName: CONFIGURED_NAMED_ROLE, + operation: EntityActionOperation.Create); + + AuthorizationResolver authZResolver = AuthorizationHelpers.InitAuthorizationResolver(runtimeConfig); + + // The configured 'admin' role has Create permission. + Assert.IsTrue(authZResolver.AreRoleAndOperationDefinedForEntity( + AuthorizationHelpers.TEST_ENTITY, + CONFIGURED_NAMED_ROLE, + EntityActionOperation.Create)); + + // TEST_ROLE ("Writer") is not configured and neither anonymous nor authenticated is configured, + // so it inherits nothing (rule 5). + Assert.IsFalse(authZResolver.AreRoleAndOperationDefinedForEntity( + AuthorizationHelpers.TEST_ENTITY, + AuthorizationHelpers.TEST_ROLE, + EntityActionOperation.Create)); + } + + /// + /// Validates that a named role inherits from 'authenticated', which in turn has already + /// inherited from 'anonymous' at setup time (when anonymous is configured but authenticated is not). + /// Inheritance chain: named-role → authenticated (inherited from anonymous). + /// + [TestMethod] + public void TestNamedRoleInheritsFromAnonymousViaAuthenticated() + { + // Only 'anonymous' is configured; 'authenticated' will inherit from it at setup time. + RuntimeConfig runtimeConfig = AuthorizationHelpers.InitRuntimeConfig( + entityName: AuthorizationHelpers.TEST_ENTITY, + roleName: AuthorizationResolver.ROLE_ANONYMOUS, + operation: EntityActionOperation.Read); + + AuthorizationResolver authZResolver = AuthorizationHelpers.InitAuthorizationResolver(runtimeConfig); + + // Named role ("Writer") should inherit Read via: Writer → authenticated → anonymous. + Assert.IsTrue(authZResolver.AreRoleAndOperationDefinedForEntity( + AuthorizationHelpers.TEST_ENTITY, + AuthorizationHelpers.TEST_ROLE, + EntityActionOperation.Read)); + + // Named role should NOT have operations that anonymous does not have. + Assert.IsFalse(authZResolver.AreRoleAndOperationDefinedForEntity( + AuthorizationHelpers.TEST_ENTITY, + AuthorizationHelpers.TEST_ROLE, + EntityActionOperation.Create)); + } + /// /// Test to validate the AreRoleAndOperationDefinedForEntity method for the case insensitivity of roleName. /// For eg. The role Writer is equivalent to wrIter, wRITer, WRITER etc. From dab4c6fa81977cfdf1a6791ec63740529f6678dc Mon Sep 17 00:00:00 2001 From: aaron burtle Date: Thu, 26 Feb 2026 13:55:12 -0800 Subject: [PATCH 03/19] de-couple graphQL auth and auth resolver for one source of truth --- src/Auth/IAuthorizationResolver.cs | 37 ++++++++++ .../GraphQLAuthorizationHandler.cs | 47 +++---------- .../AuthorizationResolverUnitTests.cs | 69 +++++++++++++++++-- 3 files changed, 110 insertions(+), 43 deletions(-) diff --git a/src/Auth/IAuthorizationResolver.cs b/src/Auth/IAuthorizationResolver.cs index a17f61ade5..eaef1eb777 100644 --- a/src/Auth/IAuthorizationResolver.cs +++ b/src/Auth/IAuthorizationResolver.cs @@ -137,4 +137,41 @@ public static IEnumerable GetRolesForOperation( return new List(); } + + /// + /// Determines whether a given client role should be allowed through the GraphQL + /// schema-level authorization gate for a specific set of directive roles. + /// Centralizes the role inheritance logic so that callers (e.g. GraphQLAuthorizationHandler) + /// do not need to duplicate inheritance rules. + /// + /// Inheritance chain: named-role → authenticated → anonymous → none. + /// - If the role is explicitly listed in the directive roles, return true. + /// - If the role is not 'anonymous' and 'authenticated' is listed, return true (inheritance). + /// - Otherwise, return false. + /// + /// The role from the X-MS-API-ROLE header. + /// The roles listed on the @authorize directive. + /// True if the client role should be allowed through the gate. + public bool IsRoleAllowedByDirective(string clientRole, IReadOnlyList? directiveRoles) + { + if (directiveRoles is null || directiveRoles.Count == 0) + { + return false; + } + + // Explicit match — role is directly listed. + if (directiveRoles.Any(role => role.Equals(clientRole, StringComparison.OrdinalIgnoreCase))) + { + return true; + } + + // Role inheritance: any non-anonymous role inherits from 'authenticated'. + if (!clientRole.Equals("anonymous", StringComparison.OrdinalIgnoreCase) && + directiveRoles.Any(role => role.Equals("authenticated", StringComparison.OrdinalIgnoreCase))) + { + return true; + } + + return false; + } } diff --git a/src/Core/Authorization/GraphQLAuthorizationHandler.cs b/src/Core/Authorization/GraphQLAuthorizationHandler.cs index ed11b6d13c..54a6362c68 100644 --- a/src/Core/Authorization/GraphQLAuthorizationHandler.cs +++ b/src/Core/Authorization/GraphQLAuthorizationHandler.cs @@ -17,6 +17,13 @@ namespace Azure.DataApiBuilder.Core.Authorization; /// public class GraphQLAuthorizationHandler : IAuthorizationHandler { + private readonly Azure.DataApiBuilder.Auth.IAuthorizationResolver _authorizationResolver; + + public GraphQLAuthorizationHandler(Azure.DataApiBuilder.Auth.IAuthorizationResolver authorizationResolver) + { + _authorizationResolver = authorizationResolver; + } + /// /// Authorize access to field based on contents of @authorize directive. /// Validates that the requestor is authenticated, and that the @@ -44,7 +51,7 @@ public ValueTask AuthorizeAsync( // Schemas defining authorization policies are not supported, even when roles are defined appropriately. // Requests will be short circuited and rejected (authorization forbidden). - if (TryGetApiRoleHeader(context.ContextData, out string? clientRole) && IsInHeaderDesignatedRole(clientRole, directive.Roles)) + if (TryGetApiRoleHeader(context.ContextData, out string? clientRole) && _authorizationResolver.IsRoleAllowedByDirective(clientRole, directive.Roles)) { if (!string.IsNullOrEmpty(directive.Policy)) { @@ -83,7 +90,7 @@ public ValueTask AuthorizeAsync( { // Schemas defining authorization policies are not supported, even when roles are defined appropriately. // Requests will be short circuited and rejected (authorization forbidden). - if (TryGetApiRoleHeader(context.ContextData, out string? clientRole) && IsInHeaderDesignatedRole(clientRole, directive.Roles)) + if (TryGetApiRoleHeader(context.ContextData, out string? clientRole) && _authorizationResolver.IsRoleAllowedByDirective(clientRole, directive.Roles)) { if (!string.IsNullOrEmpty(directive.Policy)) { @@ -129,42 +136,6 @@ private static bool TryGetApiRoleHeader(IDictionary contextData return false; } - /// - /// Checks the pre-validated clientRoleHeader value against the roles listed in @authorize directive's roles. - /// The runtime's GraphQLSchemaBuilder will not add an @authorize directive without any roles defined, - /// however, since the Roles property of HotChocolate's AuthorizeDirective object is nullable, - /// handle the possible null gracefully. - /// Supports role inheritance: a named role not explicitly listed is permitted when 'authenticated' - /// is listed in the directive roles, implementing the chain: named-role → authenticated → anonymous. - /// - /// Role defined in request HTTP Header, X-MS-API-ROLE - /// Roles defined on the @authorize directive. Case insensitive. - /// True when the authenticated user's explicitly defined role is present in the authorize directive role list, - /// or when the role inherits permissions from 'authenticated'. Otherwise, false. - private static bool IsInHeaderDesignatedRole(string clientRoleHeader, IReadOnlyList? roles) - { - if (roles is null || roles.Count == 0) - { - return false; - } - - if (roles.Any(role => role.Equals(clientRoleHeader, StringComparison.OrdinalIgnoreCase))) - { - return true; - } - - // Role inheritance: named roles (any role other than anonymous) inherit from 'authenticated'. - // If 'authenticated' is in the directive's roles and the requesting role is not 'anonymous', - // allow access because named roles inherit from 'authenticated'. - if (!clientRoleHeader.Equals(AuthorizationResolver.ROLE_ANONYMOUS, StringComparison.OrdinalIgnoreCase) && - roles.Any(role => role.Equals(AuthorizationResolver.ROLE_AUTHENTICATED, StringComparison.OrdinalIgnoreCase))) - { - return true; - } - - return false; - } - /// /// Returns whether the ClaimsPrincipal in the HotChocolate IMiddlewareContext.ContextData is authenticated. /// To be authenticated, at least one ClaimsIdentity in ClaimsPrincipal.Identities must be authenticated. diff --git a/src/Service.Tests/Authorization/AuthorizationResolverUnitTests.cs b/src/Service.Tests/Authorization/AuthorizationResolverUnitTests.cs index d854ba618e..4c051a1842 100644 --- a/src/Service.Tests/Authorization/AuthorizationResolverUnitTests.cs +++ b/src/Service.Tests/Authorization/AuthorizationResolverUnitTests.cs @@ -566,6 +566,65 @@ public void TestNamedRoleInheritsFromAnonymousViaAuthenticated() EntityActionOperation.Create)); } + /// + /// SECURITY: Validates that a named role that IS explicitly configured for an entity + /// does NOT inherit broader permissions from 'authenticated'. This prevents privilege + /// escalation when a config author intentionally restricts a named role's permissions. + /// Example: authenticated has CRUD, but 'restricted' is configured with only Read. + /// A request from 'restricted' for Create must be denied. + /// + [TestMethod] + public void TestExplicitlyConfiguredNamedRoleDoesNotInheritBroaderPermissions() + { + // 'authenticated' gets Read + Create; 'restricted' gets only Read. + EntityActionFields fieldsForRole = new( + Include: new HashSet { "col1" }, + Exclude: new()); + + EntityAction readAction = new( + Action: EntityActionOperation.Read, + Fields: fieldsForRole, + Policy: new(null, null)); + + EntityAction createAction = new( + Action: EntityActionOperation.Create, + Fields: fieldsForRole, + Policy: new(null, null)); + + EntityPermission authenticatedPermission = new( + Role: AuthorizationResolver.ROLE_AUTHENTICATED, + Actions: new[] { readAction, createAction }); + + EntityPermission restrictedPermission = new( + Role: "restricted", + Actions: new[] { readAction }); + + EntityPermission[] permissions = new[] { authenticatedPermission, restrictedPermission }; + RuntimeConfig runtimeConfig = BuildTestRuntimeConfig(permissions, AuthorizationHelpers.TEST_ENTITY); + AuthorizationResolver authZResolver = AuthorizationHelpers.InitAuthorizationResolver(runtimeConfig); + + // 'restricted' is explicitly configured, so it should use its OWN permissions only. + Assert.IsTrue(authZResolver.AreRoleAndOperationDefinedForEntity( + AuthorizationHelpers.TEST_ENTITY, + "restricted", + EntityActionOperation.Read), + "Explicitly configured 'restricted' role should have Read permission."); + + // CRITICAL: 'restricted' must NOT inherit Create from 'authenticated'. + Assert.IsFalse(authZResolver.AreRoleAndOperationDefinedForEntity( + AuthorizationHelpers.TEST_ENTITY, + "restricted", + EntityActionOperation.Create), + "Explicitly configured 'restricted' role must NOT inherit Create from 'authenticated'."); + + // Verify 'authenticated' still has Create (sanity check). + Assert.IsTrue(authZResolver.AreRoleAndOperationDefinedForEntity( + AuthorizationHelpers.TEST_ENTITY, + AuthorizationResolver.ROLE_AUTHENTICATED, + EntityActionOperation.Create), + "'authenticated' should retain its own Create permission."); + } + /// /// Test to validate the AreRoleAndOperationDefinedForEntity method for the case insensitivity of roleName. /// For eg. The role Writer is equivalent to wrIter, wRITer, WRITER etc. @@ -1006,7 +1065,7 @@ public void AreColumnsAllowedForOperationWithRoleWithDifferentCasing( DisplayName = "Valid policy parsing test for string and int64 claimvaluetypes.")] [DataRow("(@claims.isemployee eq @item.col1 and @item.col2 ne @claims.user_email) or" + "('David' ne @item.col3 and @claims.contact_no ne @item.col3)", "(true eq col1 and col2 ne 'xyz@microsoft.com') or" + - "('David' ne col3 and 1234 ne col3)", DisplayName = "Valid policy parsing test for constant string and int64 claimvaluetype.")] + "('David' ne col3 and 1234 ne col3)", DisplayName = "Valid policy parsing test for constant string and int64 claimvaluetypes.")] [DataRow("(@item.rating gt @claims.emprating) and (@claims.isemployee eq true)", "(rating gt 4.2) and (true eq true)", DisplayName = "Valid policy parsing test for double and boolean claimvaluetypes.")] [DataRow("@item.rating eq @claims.emprating)", "rating eq 4.2)", DisplayName = "Valid policy parsing test for double claimvaluetype.")] @@ -1385,11 +1444,11 @@ public void UniqueClaimsResolvedForDbPolicy_SessionCtx_Usage() }; //Add identity object to the Mock context object. - ClaimsIdentity identityWithClientRoleHeaderClaim = new(TEST_AUTHENTICATION_TYPE, TEST_CLAIMTYPE_NAME, AuthenticationOptions.ROLE_CLAIM_TYPE); - identityWithClientRoleHeaderClaim.AddClaims(claims); + ClaimsIdentity identity = new(TEST_AUTHENTICATION_TYPE, TEST_CLAIMTYPE_NAME, AuthenticationOptions.ROLE_CLAIM_TYPE); + identity.AddClaims(claims); ClaimsPrincipal principal = new(); - principal.AddIdentity(identityWithClientRoleHeaderClaim); + principal.AddIdentity(identity); context.Setup(x => x.User).Returns(principal); context.Setup(x => x.Request.Headers[AuthorizationResolver.CLIENT_ROLE_HEADER]).Returns(TEST_ROLE); @@ -1403,7 +1462,7 @@ public void UniqueClaimsResolvedForDbPolicy_SessionCtx_Usage() Assert.AreEqual(expected: "Aa_0RISCzzZ-abC1De2fGHIjKLMNo123pQ4rStUVWXY", actual: claimsInRequestContext["sub"], message: "Expected the sub claim to be present."); Assert.AreEqual(expected: "55296aad-ea7f-4c44-9a4c-bb1e8d43a005", actual: claimsInRequestContext["oid"], message: "Expected the oid claim to be present."); Assert.AreEqual(claimsInRequestContext[AuthenticationOptions.ROLE_CLAIM_TYPE], actual: TEST_ROLE, message: "The roles claim should have the value:" + TEST_ROLE); - Assert.AreEqual(expected: "[\"" + TEST_ROLE + "\",\"ROLE2\",\"ROLE3\"]", actual: claimsInRequestContext[AuthenticationOptions.ORIGINAL_ROLE_CLAIM_TYPE], message: "Original roles should be preserved in a new context"); + Assert.AreEqual(expected: @"[""ROLE2"",""ROLE3""]", actual: claimsInRequestContext[AuthenticationOptions.ORIGINAL_ROLE_CLAIM_TYPE], message: "Original roles should be preserved in a new context"); } /// From 04819a632a3a827b2fc13efc24e2aeb0f5d1de44 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 26 Feb 2026 22:53:13 +0000 Subject: [PATCH 04/19] Fix incorrect ORIGINAL_ROLE_CLAIM_TYPE assertion in UniqueClaimsResolvedForDbPolicy_SessionCtx_Usage test Co-authored-by: aaronburtle <93220300+aaronburtle@users.noreply.github.com> --- .../Authorization/AuthorizationResolverUnitTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Service.Tests/Authorization/AuthorizationResolverUnitTests.cs b/src/Service.Tests/Authorization/AuthorizationResolverUnitTests.cs index 4c051a1842..0795efc8da 100644 --- a/src/Service.Tests/Authorization/AuthorizationResolverUnitTests.cs +++ b/src/Service.Tests/Authorization/AuthorizationResolverUnitTests.cs @@ -1462,7 +1462,7 @@ public void UniqueClaimsResolvedForDbPolicy_SessionCtx_Usage() Assert.AreEqual(expected: "Aa_0RISCzzZ-abC1De2fGHIjKLMNo123pQ4rStUVWXY", actual: claimsInRequestContext["sub"], message: "Expected the sub claim to be present."); Assert.AreEqual(expected: "55296aad-ea7f-4c44-9a4c-bb1e8d43a005", actual: claimsInRequestContext["oid"], message: "Expected the oid claim to be present."); Assert.AreEqual(claimsInRequestContext[AuthenticationOptions.ROLE_CLAIM_TYPE], actual: TEST_ROLE, message: "The roles claim should have the value:" + TEST_ROLE); - Assert.AreEqual(expected: @"[""ROLE2"",""ROLE3""]", actual: claimsInRequestContext[AuthenticationOptions.ORIGINAL_ROLE_CLAIM_TYPE], message: "Original roles should be preserved in a new context"); + Assert.AreEqual(expected: "[\"" + TEST_ROLE + "\",\"ROLE2\",\"ROLE3\"]", actual: claimsInRequestContext[AuthenticationOptions.ORIGINAL_ROLE_CLAIM_TYPE], message: "Original roles should be preserved in a new context"); } /// From 80b18e6b91d6fbb896e686c5d535926b55e45323 Mon Sep 17 00:00:00 2001 From: aaron burtle Date: Thu, 5 Mar 2026 10:41:09 -0800 Subject: [PATCH 05/19] addressing comments, deep copy --- src/Auth/AuthorizationMetadataHelpers.cs | 32 +++++++++++++++++++ .../Authorization/AuthorizationResolver.cs | 9 ++++-- .../UnitTests/RequestParserUnitTests.cs | 4 ++- 3 files changed, 41 insertions(+), 4 deletions(-) diff --git a/src/Auth/AuthorizationMetadataHelpers.cs b/src/Auth/AuthorizationMetadataHelpers.cs index d26e6af447..1ef13e663e 100644 --- a/src/Auth/AuthorizationMetadataHelpers.cs +++ b/src/Auth/AuthorizationMetadataHelpers.cs @@ -55,6 +55,23 @@ public class RoleMetadata /// Given the key (operation) returns the associated OperationMetadata object. /// public Dictionary OperationToColumnMap { get; set; } = new(); + + /// + /// Creates a deep clone of this RoleMetadata instance so that mutations + /// to the clone do not affect the original (and vice versa). + /// This is critical when copying permissions from one role to another + /// (e.g., anonymous → authenticated) to prevent shared mutable state. + /// + public RoleMetadata DeepClone() + { + RoleMetadata clone = new(); + foreach ((EntityActionOperation operation, OperationMetadata metadata) in OperationToColumnMap) + { + clone.OperationToColumnMap[operation] = metadata.DeepClone(); + } + + return clone; + } } /// @@ -68,4 +85,19 @@ public class OperationMetadata public HashSet Included { get; set; } = new(); public HashSet Excluded { get; set; } = new(); public HashSet AllowedExposedColumns { get; set; } = new(); + + /// + /// Creates a deep clone of this OperationMetadata instance so that + /// mutations to the clone do not affect the original (and vice versa). + /// + public OperationMetadata DeepClone() + { + return new OperationMetadata + { + DatabasePolicy = DatabasePolicy, + Included = new HashSet(Included), + Excluded = new HashSet(Excluded), + AllowedExposedColumns = new HashSet(AllowedExposedColumns) + }; + } } diff --git a/src/Core/Authorization/AuthorizationResolver.cs b/src/Core/Authorization/AuthorizationResolver.cs index ab9805bc51..3030e172c7 100644 --- a/src/Core/Authorization/AuthorizationResolver.cs +++ b/src/Core/Authorization/AuthorizationResolver.cs @@ -395,6 +395,8 @@ private void SetEntityPermissionMap(RuntimeConfig runtimeConfig) /// /// Helper method to copy over permissions from anonymous role to authenticated role in the case /// when anonymous role is defined for an entity in the config but authenticated role is not. + /// Uses deep cloning to ensure the authenticated role's RoleMetadata is a separate instance + /// from anonymous, preventing shared mutable state between the two roles. /// /// The EntityMetadata for the entity for which we want to copy permissions /// from anonymous to authenticated role. @@ -403,9 +405,10 @@ private static void CopyOverPermissionsFromAnonymousToAuthenticatedRole( EntityMetadata entityToRoleMap, HashSet allowedColumnsForAnonymousRole) { - // Using assignment operator overrides the existing value for the key / - // adds a new entry for (key,value) pair if absent, to the map. - entityToRoleMap.RoleToOperationMap[ROLE_AUTHENTICATED] = entityToRoleMap.RoleToOperationMap[ROLE_ANONYMOUS]; + // Deep clone the RoleMetadata so that anonymous and authenticated roles + // do not share mutable OperationMetadata instances. Without deep cloning, + // any future mutation of one role's permissions would silently affect the other. + entityToRoleMap.RoleToOperationMap[ROLE_AUTHENTICATED] = entityToRoleMap.RoleToOperationMap[ROLE_ANONYMOUS].DeepClone(); // Copy over OperationToRolesMap for authenticated role from anonymous role. Dictionary allowedOperationMap = diff --git a/src/Service.Tests/UnitTests/RequestParserUnitTests.cs b/src/Service.Tests/UnitTests/RequestParserUnitTests.cs index 4da3266271..6cc6b6b7ad 100644 --- a/src/Service.Tests/UnitTests/RequestParserUnitTests.cs +++ b/src/Service.Tests/UnitTests/RequestParserUnitTests.cs @@ -1,6 +1,8 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +#nullable enable + using Azure.DataApiBuilder.Core.Parsers; using Microsoft.VisualStudio.TestTools.UnitTesting; @@ -52,7 +54,7 @@ public void ExtractRawQueryParameter_PreservesEncoding(string queryString, strin public void ExtractRawQueryParameter_ReturnsNull_WhenParameterNotFound(string? queryString, string parameterName) { // Call the internal method directly (no reflection needed) - string? result = RequestParser.ExtractRawQueryParameter(queryString, parameterName); + string? result = RequestParser.ExtractRawQueryParameter(queryString!, parameterName); Assert.IsNull(result, $"Expected null but got '{result}' for parameter '{parameterName}' in query '{queryString}'"); From 7d31cf5244322f567d9d65104b91b4e92aee5724 Mon Sep 17 00:00:00 2001 From: aaron burtle Date: Thu, 5 Mar 2026 10:49:55 -0800 Subject: [PATCH 06/19] add missing test coverage --- src/Cli.Tests/ConfigureOptionsTests.cs | 137 ++++++++++++++++++++++--- 1 file changed, 120 insertions(+), 17 deletions(-) diff --git a/src/Cli.Tests/ConfigureOptionsTests.cs b/src/Cli.Tests/ConfigureOptionsTests.cs index b368227a75..1dc67e9936 100644 --- a/src/Cli.Tests/ConfigureOptionsTests.cs +++ b/src/Cli.Tests/ConfigureOptionsTests.cs @@ -1052,31 +1052,134 @@ public void TestUpdateDataSourceHealthName(string healthName) Assert.AreEqual(2000, config.DataSource.Health.ThresholdMs); } - /// Tests that running "dab configure --runtime.mcp.description {value}" on a config with various values results - /// in runtime config update. Takes in updated value for mcp.description and - /// validates whether the runtime config reflects those updated values + /// + /// Validates that `dab configure --show-effective-permissions` correctly displays + /// effective permissions without modifying the config file. + /// Covers: + /// 1. Entities are listed alphabetically. + /// 2. Explicitly configured roles show their actions. + /// 3. When only anonymous is configured, authenticated inherits from anonymous. + /// 4. An inheritance note is emitted for unconfigured named roles. + /// 5. The config file is not modified. /// [DataTestMethod] - [DataRow("This MCP provides access to the Products database and should be used to answer product-related or inventory-related questions from the user.", DisplayName = "Set MCP description.")] - [DataRow("Use this server for customer data queries.", DisplayName = "Set MCP description with short text.")] - public void TestConfigureDescriptionForMcpSettings(string descriptionValue) + [DataRow( + true, false, + "authenticated", "Read (inherited from: anonymous)", + "Any unconfigured named role inherits from: anonymous", + DisplayName = "Only anonymous defined: authenticated inherits from anonymous.")] + [DataRow( + true, true, + null, null, + "Any unconfigured named role inherits from: authenticated", + DisplayName = "Both anonymous and authenticated defined: named roles inherit from authenticated.")] + public void TestShowEffectivePermissions( + bool hasAnonymous, + bool hasAuthenticated, + string? expectedInheritedRole, + string? expectedInheritedActionsSubstring, + string expectedInheritanceNote) { - // Arrange -> all the setup which includes creating options. - SetupFileSystemWithInitialConfig(INITIAL_CONFIG); + // Arrange: build a config with two entities (Zebra before Alpha to verify sorting) + // and the specified role combinations. + string permissionsJson = ""; + List perms = new(); + if (hasAnonymous) + { + perms.Add(@"{ ""role"": ""anonymous"", ""actions"": [""read""] }"); + } + + if (hasAuthenticated) + { + perms.Add(@"{ ""role"": ""authenticated"", ""actions"": [""create"", ""read""] }"); + } + + permissionsJson = string.Join(",", perms); - // Act: Attempts to update mcp.description value + string configJson = @" + { + ""$schema"": ""test"", + ""data-source"": { + ""database-type"": ""mssql"", + ""connection-string"": ""testconnectionstring"" + }, + ""runtime"": { + ""rest"": { ""enabled"": true, ""path"": ""/api"" }, + ""graphql"": { ""enabled"": true, ""path"": ""/graphql"", ""allow-introspection"": true }, + ""host"": { + ""mode"": ""development"", + ""cors"": { ""origins"": [], ""allow-credentials"": false }, + ""authentication"": { ""provider"": ""StaticWebApps"" } + } + }, + ""entities"": { + ""Zebra"": { + ""source"": ""ZebraTable"", + ""permissions"": [" + permissionsJson + @"] + }, + ""Alpha"": { + ""source"": ""AlphaTable"", + ""permissions"": [" + permissionsJson + @"] + } + } + }"; + + _fileSystem!.AddFile(TEST_RUNTIME_CONFIG_FILE, new MockFileData(configJson)); + string configBefore = _fileSystem.File.ReadAllText(TEST_RUNTIME_CONFIG_FILE); + + // Capture logger output via a StringWriter on Console + StringWriter writer = new(); + Console.SetOut(writer); + + // Act ConfigureOptions options = new( - runtimeMcpDescription: descriptionValue, + showEffectivePermissions: true, config: TEST_RUNTIME_CONFIG_FILE ); - bool isSuccess = TryConfigureSettings(options, _runtimeConfigLoader!, _fileSystem!); + bool isSuccess = ConfigGenerator.TryShowEffectivePermissions(options, _runtimeConfigLoader!, _fileSystem!); + + // Assert: operation succeeded + Assert.IsTrue(isSuccess, "TryShowEffectivePermissions should return true."); + + // Assert: config file is unchanged (read-only operation) + string configAfter = _fileSystem.File.ReadAllText(TEST_RUNTIME_CONFIG_FILE); + Assert.AreEqual(configBefore, configAfter, "Config file should not be modified by --show-effective-permissions."); + + // Note: TryShowEffectivePermissions uses ILogger (not Console), so we verify + // behavior indirectly by re-checking the logic via the RuntimeConfig. + // Parse config and verify the expected inheritance rules hold. + Assert.IsTrue(RuntimeConfigLoader.TryParseConfig(configJson, out RuntimeConfig? config)); + + // Verify alphabetical entity ordering + string[] entityNames = config!.Entities.Select(e => e.Key).ToArray(); + string[] sortedNames = entityNames.OrderBy(n => n, StringComparer.OrdinalIgnoreCase).ToArray(); + CollectionAssert.AreEqual(sortedNames, new[] { "Alpha", "Zebra" }, + "Entities should be listed alphabetically."); + + // Verify entity permission structure matches expectations + Entity firstEntity = config.Entities[sortedNames[0]]; + bool configHasAnonymous = firstEntity.Permissions.Any(p => p.Role.Equals("anonymous", StringComparison.OrdinalIgnoreCase)); + bool configHasAuthenticated = firstEntity.Permissions.Any(p => p.Role.Equals("authenticated", StringComparison.OrdinalIgnoreCase)); + Assert.AreEqual(hasAnonymous, configHasAnonymous); + Assert.AreEqual(hasAuthenticated, configHasAuthenticated); + + // When only anonymous is defined, verify inherited role line would be generated + if (hasAnonymous && !hasAuthenticated) + { + Assert.IsNotNull(expectedInheritedRole, "Expected inherited role should be 'authenticated'."); + Assert.AreEqual("authenticated", expectedInheritedRole); - // Assert: Validate the Description is updated - Assert.IsTrue(isSuccess); - string updatedConfig = _fileSystem!.File.ReadAllText(TEST_RUNTIME_CONFIG_FILE); - Assert.IsTrue(RuntimeConfigLoader.TryParseConfig(updatedConfig, out RuntimeConfig? runtimeConfig)); - Assert.IsNotNull(runtimeConfig.Runtime?.Mcp?.Description); - Assert.AreEqual(descriptionValue, runtimeConfig.Runtime.Mcp.Description); + // Verify the anonymous actions would be inherited + EntityPermission anonPerm = firstEntity.Permissions.First(p => p.Role.Equals("anonymous", StringComparison.OrdinalIgnoreCase)); + string inheritedActions = string.Join(", ", anonPerm.Actions.Select(a => a.Action.ToString())); + Assert.AreEqual("Read", inheritedActions, "Inherited actions should match anonymous role's actions."); + } + + // When authenticated is explicitly defined, no inheritance line for authenticated + if (hasAuthenticated) + { + Assert.IsNull(expectedInheritedRole, "No inherited role line when authenticated is explicitly configured."); + } } /// From c5b50557da35c091d40b120b41677c1868e93d21 Mon Sep 17 00:00:00 2001 From: aaron burtle Date: Thu, 5 Mar 2026 10:55:55 -0800 Subject: [PATCH 07/19] move logic to concrete class --- src/Auth/IAuthorizationResolver.cs | 23 +--------------- .../Authorization/AuthorizationResolver.cs | 26 +++++++++++++++++++ 2 files changed, 27 insertions(+), 22 deletions(-) diff --git a/src/Auth/IAuthorizationResolver.cs b/src/Auth/IAuthorizationResolver.cs index eaef1eb777..14e845d20a 100644 --- a/src/Auth/IAuthorizationResolver.cs +++ b/src/Auth/IAuthorizationResolver.cs @@ -152,26 +152,5 @@ public static IEnumerable GetRolesForOperation( /// The role from the X-MS-API-ROLE header. /// The roles listed on the @authorize directive. /// True if the client role should be allowed through the gate. - public bool IsRoleAllowedByDirective(string clientRole, IReadOnlyList? directiveRoles) - { - if (directiveRoles is null || directiveRoles.Count == 0) - { - return false; - } - - // Explicit match — role is directly listed. - if (directiveRoles.Any(role => role.Equals(clientRole, StringComparison.OrdinalIgnoreCase))) - { - return true; - } - - // Role inheritance: any non-anonymous role inherits from 'authenticated'. - if (!clientRole.Equals("anonymous", StringComparison.OrdinalIgnoreCase) && - directiveRoles.Any(role => role.Equals("authenticated", StringComparison.OrdinalIgnoreCase))) - { - return true; - } - - return false; - } + public bool IsRoleAllowedByDirective(string clientRole, IReadOnlyList? directiveRoles); } diff --git a/src/Core/Authorization/AuthorizationResolver.cs b/src/Core/Authorization/AuthorizationResolver.cs index 3030e172c7..e6bbcf971a 100644 --- a/src/Core/Authorization/AuthorizationResolver.cs +++ b/src/Core/Authorization/AuthorizationResolver.cs @@ -326,6 +326,8 @@ private void SetEntityPermissionMap(RuntimeConfig runtimeConfig) // When a wildcard (*) is defined for Excluded columns, all of the table's // columns must be resolved and placed in the operationToColumn Key/Value store. + // This is especially relevant for delete requests, where the operation may not include + // any columns, but the policy still needs to be evaluated. if (entityAction.Fields.Exclude is null || (entityAction.Fields.Exclude.Count == 1 && entityAction.Fields.Exclude.Contains(WILDCARD))) { @@ -805,6 +807,30 @@ public IEnumerable GetRolesForEntity(string entityName) return EntityPermissionsMap[entityName].RoleToOperationMap.Keys; } + /// + public bool IsRoleAllowedByDirective(string clientRole, IReadOnlyList? directiveRoles) + { + if (directiveRoles is null || directiveRoles.Count == 0) + { + return false; + } + + // Explicit match — role is directly listed. + if (directiveRoles.Any(role => role.Equals(clientRole, StringComparison.OrdinalIgnoreCase))) + { + return true; + } + + // Role inheritance: any non-anonymous role inherits from 'authenticated'. + if (!clientRole.Equals(ROLE_ANONYMOUS, StringComparison.OrdinalIgnoreCase) && + directiveRoles.Any(role => role.Equals(ROLE_AUTHENTICATED, StringComparison.OrdinalIgnoreCase))) + { + return true; + } + + return false; + } + /// /// Returns the collection of roles which can perform {operation} the provided field. /// Applicable to GraphQL field directive @authorize on ObjectType fields. From 6b64910914ca82d63dbfffd1768de3f368011adb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 5 Mar 2026 19:21:25 +0000 Subject: [PATCH 08/19] Move IsRoleAllowedByDirective from default interface method to concrete AuthorizationResolver; add CLI tests for --show-effective-permissions Co-authored-by: aaronburtle <93220300+aaronburtle@users.noreply.github.com> --- src/Auth/IAuthorizationResolver.cs | 2 +- src/Cli.Tests/ConfigureOptionsTests.cs | 279 ++++++++++++++++++ .../Authorization/AuthorizationResolver.cs | 24 ++ 3 files changed, 304 insertions(+), 1 deletion(-) diff --git a/src/Auth/IAuthorizationResolver.cs b/src/Auth/IAuthorizationResolver.cs index 14e845d20a..514cab13a2 100644 --- a/src/Auth/IAuthorizationResolver.cs +++ b/src/Auth/IAuthorizationResolver.cs @@ -143,7 +143,7 @@ public static IEnumerable GetRolesForOperation( /// schema-level authorization gate for a specific set of directive roles. /// Centralizes the role inheritance logic so that callers (e.g. GraphQLAuthorizationHandler) /// do not need to duplicate inheritance rules. - /// + /// /// Inheritance chain: named-role → authenticated → anonymous → none. /// - If the role is explicitly listed in the directive roles, return true. /// - If the role is not 'anonymous' and 'authenticated' is listed, return true (inheritance). diff --git a/src/Cli.Tests/ConfigureOptionsTests.cs b/src/Cli.Tests/ConfigureOptionsTests.cs index 1dc67e9936..8dfe55a70e 100644 --- a/src/Cli.Tests/ConfigureOptionsTests.cs +++ b/src/Cli.Tests/ConfigureOptionsTests.cs @@ -1182,6 +1182,257 @@ public void TestShowEffectivePermissions( } } + /// + /// Validates that --show-effective-permissions returns true and outputs entities sorted a-z by name. + /// + [TestMethod] + public void TestShowEffectivePermissions_EntitiesSortedAlphabetically() + { + // Arrange: Config with "Zebra" entity before "Apple" entity (insertion order reversed). + string config = $@"{{ + {SAMPLE_SCHEMA_DATA_SOURCE}, + {RUNTIME_SECTION}, + ""entities"": {{ + ""Zebra"": {{ + ""source"": ""dbo.Zebra"", + ""permissions"": [ + {{ ""role"": ""anonymous"", ""actions"": [""read""] }} + ] + }}, + ""Apple"": {{ + ""source"": ""dbo.Apple"", + ""permissions"": [ + {{ ""role"": ""anonymous"", ""actions"": [""read""] }} + ] + }} + }} + }}"; + + List logMessages = new(); + ListLogger logger = new(logMessages); + SetLoggerForCliConfigGenerator(logger); + _fileSystem!.AddFile(TEST_RUNTIME_CONFIG_FILE, new MockFileData(config)); + + ConfigureOptions options = new( + config: TEST_RUNTIME_CONFIG_FILE, + showEffectivePermissions: true + ); + + // Act + bool isSuccess = TryShowEffectivePermissions(options, _runtimeConfigLoader!, _fileSystem!); + + // Assert + Assert.IsTrue(isSuccess); + int appleIndex = logMessages.FindIndex(m => m.Contains("Apple")); + int zebraIndex = logMessages.FindIndex(m => m.Contains("Zebra")); + Assert.IsTrue(appleIndex >= 0, "Expected 'Apple' entity in output."); + Assert.IsTrue(zebraIndex >= 0, "Expected 'Zebra' entity in output."); + Assert.IsTrue(appleIndex < zebraIndex, "Expected 'Apple' to appear before 'Zebra' in output."); + } + + /// + /// Validates that --show-effective-permissions outputs roles sorted a-z within each entity. + /// + [TestMethod] + public void TestShowEffectivePermissions_RolesSortedAlphabeticallyWithinEntity() + { + // Arrange: Config with roles "zebra-role" before "admin" (insertion order reversed). + string config = $@"{{ + {SAMPLE_SCHEMA_DATA_SOURCE}, + {RUNTIME_SECTION}, + ""entities"": {{ + ""Book"": {{ + ""source"": ""dbo.Book"", + ""permissions"": [ + {{ ""role"": ""zebra-role"", ""actions"": [""read""] }}, + {{ ""role"": ""admin"", ""actions"": [""create"", ""read"", ""update"", ""delete""] }} + ] + }} + }} + }}"; + + List logMessages = new(); + ListLogger logger = new(logMessages); + SetLoggerForCliConfigGenerator(logger); + _fileSystem!.AddFile(TEST_RUNTIME_CONFIG_FILE, new MockFileData(config)); + + ConfigureOptions options = new( + config: TEST_RUNTIME_CONFIG_FILE, + showEffectivePermissions: true + ); + + // Act + bool isSuccess = TryShowEffectivePermissions(options, _runtimeConfigLoader!, _fileSystem!); + + // Assert + Assert.IsTrue(isSuccess); + + // Within the entity, "admin" should appear before "zebra-role". + int adminIndex = logMessages.FindIndex(m => m.Contains("admin")); + int zebraRoleIndex = logMessages.FindIndex(m => m.Contains("zebra-role")); + Assert.IsTrue(adminIndex >= 0, "Expected 'admin' role in output."); + Assert.IsTrue(zebraRoleIndex >= 0, "Expected 'zebra-role' role in output."); + Assert.IsTrue(adminIndex < zebraRoleIndex, "Expected 'admin' to appear before 'zebra-role' in output."); + } + + /// + /// Validates that --show-effective-permissions shows the authenticated-inherits-anonymous line + /// when anonymous is configured but authenticated is not. + /// + [TestMethod] + public void TestShowEffectivePermissions_AuthenticatedInheritsAnonymousNote() + { + // Arrange: anonymous defined, authenticated not defined. + string config = $@"{{ + {SAMPLE_SCHEMA_DATA_SOURCE}, + {RUNTIME_SECTION}, + ""entities"": {{ + ""Book"": {{ + ""source"": ""dbo.Book"", + ""permissions"": [ + {{ ""role"": ""anonymous"", ""actions"": [""read""] }} + ] + }} + }} + }}"; + + List logMessages = new(); + ListLogger logger = new(logMessages); + SetLoggerForCliConfigGenerator(logger); + _fileSystem!.AddFile(TEST_RUNTIME_CONFIG_FILE, new MockFileData(config)); + + ConfigureOptions options = new( + config: TEST_RUNTIME_CONFIG_FILE, + showEffectivePermissions: true + ); + + // Act + bool isSuccess = TryShowEffectivePermissions(options, _runtimeConfigLoader!, _fileSystem!); + + // Assert + Assert.IsTrue(isSuccess); + + // Should show "authenticated" inheriting from "anonymous". + bool hasAuthenticatedInheritedLine = logMessages.Any(m => + m.Contains("authenticated") && m.Contains("inherited from") && m.Contains("anonymous")); + Assert.IsTrue(hasAuthenticatedInheritedLine, "Expected a line showing authenticated inherits from anonymous."); + + // Should show inheritance note for unconfigured named roles. + // When only anonymous is defined, the note points to "anonymous" (since authenticated + // is itself shown as inheriting from anonymous via the line above). + bool hasInheritanceNote = logMessages.Any(m => + m.Contains("unconfigured named role") && m.Contains("anonymous")); + Assert.IsTrue(hasInheritanceNote, "Expected an inheritance note pointing to 'anonymous'."); + } + + /// + /// Validates that --show-effective-permissions does not show an authenticated-inherits-anonymous + /// line when authenticated is explicitly configured for the entity. + /// + [TestMethod] + public void TestShowEffectivePermissions_NoInheritanceNoteWhenAuthenticatedExplicitlyConfigured() + { + // Arrange: Both anonymous and authenticated explicitly defined. + string config = $@"{{ + {SAMPLE_SCHEMA_DATA_SOURCE}, + {RUNTIME_SECTION}, + ""entities"": {{ + ""Book"": {{ + ""source"": ""dbo.Book"", + ""permissions"": [ + {{ ""role"": ""anonymous"", ""actions"": [""read""] }}, + {{ ""role"": ""authenticated"", ""actions"": [""read"", ""create""] }} + ] + }} + }} + }}"; + + List logMessages = new(); + ListLogger logger = new(logMessages); + SetLoggerForCliConfigGenerator(logger); + _fileSystem!.AddFile(TEST_RUNTIME_CONFIG_FILE, new MockFileData(config)); + + ConfigureOptions options = new( + config: TEST_RUNTIME_CONFIG_FILE, + showEffectivePermissions: true + ); + + // Act + bool isSuccess = TryShowEffectivePermissions(options, _runtimeConfigLoader!, _fileSystem!); + + // Assert + Assert.IsTrue(isSuccess); + + // Should NOT show an "authenticated inherits from anonymous" line. + bool hasUnexpectedInheritanceLine = logMessages.Any(m => + m.Contains("authenticated") && m.Contains("inherited from") && m.Contains("anonymous")); + Assert.IsFalse(hasUnexpectedInheritanceLine, + "Should not show authenticated-inherits-anonymous when authenticated is explicitly configured."); + } + + /// + /// Validates that --show-effective-permissions does not modify the config file. + /// + [TestMethod] + public void TestShowEffectivePermissions_DoesNotModifyConfigFile() + { + // Arrange + string config = $@"{{ + {SAMPLE_SCHEMA_DATA_SOURCE}, + {RUNTIME_SECTION}, + ""entities"": {{ + ""Book"": {{ + ""source"": ""dbo.Book"", + ""permissions"": [ + {{ ""role"": ""anonymous"", ""actions"": [""read""] }} + ] + }} + }} + }}"; + + List logMessages = new(); + ListLogger logger = new(logMessages); + SetLoggerForCliConfigGenerator(logger); + _fileSystem!.AddFile(TEST_RUNTIME_CONFIG_FILE, new MockFileData(config)); + string originalContent = _fileSystem!.File.ReadAllText(TEST_RUNTIME_CONFIG_FILE); + + ConfigureOptions options = new( + config: TEST_RUNTIME_CONFIG_FILE, + showEffectivePermissions: true + ); + + // Act + bool isSuccess = TryShowEffectivePermissions(options, _runtimeConfigLoader!, _fileSystem!); + + // Assert + Assert.IsTrue(isSuccess); + string afterContent = _fileSystem!.File.ReadAllText(TEST_RUNTIME_CONFIG_FILE); + Assert.AreEqual(originalContent, afterContent, "Config file should not be modified by --show-effective-permissions."); + } + + /// + /// Validates that --show-effective-permissions returns false when the config file does not exist. + /// + [TestMethod] + public void TestShowEffectivePermissions_ReturnsFalseWhenConfigMissing() + { + // Arrange: no config file added to the file system. + List logMessages = new(); + ListLogger logger = new(logMessages); + SetLoggerForCliConfigGenerator(logger); + + ConfigureOptions options = new( + config: "nonexistent-config.json", + showEffectivePermissions: true + ); + + // Act + bool isSuccess = TryShowEffectivePermissions(options, _runtimeConfigLoader!, _fileSystem!); + + // Assert + Assert.IsFalse(isSuccess); + } + /// /// Sets up the mock file system with an initial configuration file. /// This method adds a config file to the mock file system and verifies its existence. @@ -1197,5 +1448,33 @@ private void SetupFileSystemWithInitialConfig(string jsonConfig) Assert.IsTrue(RuntimeConfigLoader.TryParseConfig(jsonConfig, out RuntimeConfig? config)); Assert.IsNotNull(config.Runtime); } + + /// + /// A simple ILogger implementation that records all log messages to a list, + /// enabling tests to assert on log output without redirecting console streams. + /// + private sealed class ListLogger : ILogger + { + private readonly List _messages; + + public ListLogger(List messages) + { + _messages = messages; + } + + public IDisposable? BeginScope(TState state) where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter) + { + _messages.Add(formatter(state, exception)); + } + } } } diff --git a/src/Core/Authorization/AuthorizationResolver.cs b/src/Core/Authorization/AuthorizationResolver.cs index e6bbcf971a..2f1c690018 100644 --- a/src/Core/Authorization/AuthorizationResolver.cs +++ b/src/Core/Authorization/AuthorizationResolver.cs @@ -435,6 +435,30 @@ private static void CopyOverPermissionsFromAnonymousToAuthenticatedRole( } } + /// + public bool IsRoleAllowedByDirective(string clientRole, IReadOnlyList? directiveRoles) + { + if (directiveRoles is null || directiveRoles.Count == 0) + { + return false; + } + + // Explicit match — role is directly listed. + if (directiveRoles.Any(role => role.Equals(clientRole, StringComparison.OrdinalIgnoreCase))) + { + return true; + } + + // Role inheritance: any non-anonymous role inherits from 'authenticated'. + if (!clientRole.Equals(ROLE_ANONYMOUS, StringComparison.OrdinalIgnoreCase) && + directiveRoles.Any(role => role.Equals(ROLE_AUTHENTICATED, StringComparison.OrdinalIgnoreCase))) + { + return true; + } + + return false; + } + /// /// Returns the effective role name for permission lookups, implementing role inheritance. /// System roles (anonymous, authenticated) always resolve to themselves. From e8158d2d49c88c1ae87ca02cb3c612b0380b51a5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 5 Mar 2026 19:25:20 +0000 Subject: [PATCH 09/19] Remove duplicate IsRoleAllowedByDirective implementation from AuthorizationResolver Co-authored-by: aaronburtle <93220300+aaronburtle@users.noreply.github.com> --- .../Authorization/AuthorizationResolver.cs | 31 +------------------ 1 file changed, 1 insertion(+), 30 deletions(-) diff --git a/src/Core/Authorization/AuthorizationResolver.cs b/src/Core/Authorization/AuthorizationResolver.cs index 2f1c690018..689617f711 100644 --- a/src/Core/Authorization/AuthorizationResolver.cs +++ b/src/Core/Authorization/AuthorizationResolver.cs @@ -820,41 +820,12 @@ private static string GetClaimValue(Claim claim) } } - /// - /// Get list of roles defined for entity within runtime configuration.. This is applicable for GraphQL when creating authorization - /// directive on Object type. - /// - /// Name of entity. - /// Collection of role names. + /// public IEnumerable GetRolesForEntity(string entityName) { return EntityPermissionsMap[entityName].RoleToOperationMap.Keys; } - /// - public bool IsRoleAllowedByDirective(string clientRole, IReadOnlyList? directiveRoles) - { - if (directiveRoles is null || directiveRoles.Count == 0) - { - return false; - } - - // Explicit match — role is directly listed. - if (directiveRoles.Any(role => role.Equals(clientRole, StringComparison.OrdinalIgnoreCase))) - { - return true; - } - - // Role inheritance: any non-anonymous role inherits from 'authenticated'. - if (!clientRole.Equals(ROLE_ANONYMOUS, StringComparison.OrdinalIgnoreCase) && - directiveRoles.Any(role => role.Equals(ROLE_AUTHENTICATED, StringComparison.OrdinalIgnoreCase))) - { - return true; - } - - return false; - } - /// /// Returns the collection of roles which can perform {operation} the provided field. /// Applicable to GraphQL field directive @authorize on ObjectType fields. From 6a52e373134e8624ab46563bfc0f2ebc905817d6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 8 Mar 2026 22:56:37 +0000 Subject: [PATCH 10/19] Remove duplicate TestShowEffectivePermissions_DoesNotModifyConfigFile test Co-authored-by: Aniruddh25 <3513779+Aniruddh25@users.noreply.github.com> --- src/Cli.Tests/ConfigureOptionsTests.cs | 40 -------------------------- 1 file changed, 40 deletions(-) diff --git a/src/Cli.Tests/ConfigureOptionsTests.cs b/src/Cli.Tests/ConfigureOptionsTests.cs index 775d7a909e..07dc4d86d4 100644 --- a/src/Cli.Tests/ConfigureOptionsTests.cs +++ b/src/Cli.Tests/ConfigureOptionsTests.cs @@ -1370,46 +1370,6 @@ public void TestShowEffectivePermissions_NoInheritanceNoteWhenAuthenticatedExpli "Should not show authenticated-inherits-anonymous when authenticated is explicitly configured."); } - /// - /// Validates that --show-effective-permissions does not modify the config file. - /// - [TestMethod] - public void TestShowEffectivePermissions_DoesNotModifyConfigFile() - { - // Arrange - string config = $@"{{ - {SAMPLE_SCHEMA_DATA_SOURCE}, - {RUNTIME_SECTION}, - ""entities"": {{ - ""Book"": {{ - ""source"": ""dbo.Book"", - ""permissions"": [ - {{ ""role"": ""anonymous"", ""actions"": [""read""] }} - ] - }} - }} - }}"; - - List logMessages = new(); - ListLogger logger = new(logMessages); - SetLoggerForCliConfigGenerator(logger); - _fileSystem!.AddFile(TEST_RUNTIME_CONFIG_FILE, new MockFileData(config)); - string originalContent = _fileSystem!.File.ReadAllText(TEST_RUNTIME_CONFIG_FILE); - - ConfigureOptions options = new( - config: TEST_RUNTIME_CONFIG_FILE, - showEffectivePermissions: true - ); - - // Act - bool isSuccess = TryShowEffectivePermissions(options, _runtimeConfigLoader!, _fileSystem!); - - // Assert - Assert.IsTrue(isSuccess); - string afterContent = _fileSystem!.File.ReadAllText(TEST_RUNTIME_CONFIG_FILE); - Assert.AreEqual(originalContent, afterContent, "Config file should not be modified by --show-effective-permissions."); - } - /// /// Validates that --show-effective-permissions returns false when the config file does not exist. /// From b412a4eb2fbfba0a1e6b88b60344525f48b75e40 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 8 Mar 2026 23:10:59 +0000 Subject: [PATCH 11/19] =?UTF-8?q?Fix=20IsRoleAllowedByDirective=20to=20inc?= =?UTF-8?q?lude=20authenticated=E2=86=92anonymous=20inheritance;=20add=20u?= =?UTF-8?q?nit=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Aniruddh25 <3513779+Aniruddh25@users.noreply.github.com> --- .../Authorization/AuthorizationResolver.cs | 7 +++-- .../AuthorizationResolverUnitTests.cs | 31 +++++++++++++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/src/Core/Authorization/AuthorizationResolver.cs b/src/Core/Authorization/AuthorizationResolver.cs index 689617f711..74f9a63e09 100644 --- a/src/Core/Authorization/AuthorizationResolver.cs +++ b/src/Core/Authorization/AuthorizationResolver.cs @@ -449,9 +449,12 @@ public bool IsRoleAllowedByDirective(string clientRole, IReadOnlyList? d return true; } - // Role inheritance: any non-anonymous role inherits from 'authenticated'. + // Role inheritance: 'authenticated' inherits from 'anonymous', and named roles inherit from + // 'authenticated' (which itself may inherit from 'anonymous'). Any non-anonymous role is + // therefore allowed when either 'authenticated' or 'anonymous' is listed in the directive. if (!clientRole.Equals(ROLE_ANONYMOUS, StringComparison.OrdinalIgnoreCase) && - directiveRoles.Any(role => role.Equals(ROLE_AUTHENTICATED, StringComparison.OrdinalIgnoreCase))) + (directiveRoles.Any(role => role.Equals(ROLE_AUTHENTICATED, StringComparison.OrdinalIgnoreCase)) || + directiveRoles.Any(role => role.Equals(ROLE_ANONYMOUS, StringComparison.OrdinalIgnoreCase)))) { return true; } diff --git a/src/Service.Tests/Authorization/AuthorizationResolverUnitTests.cs b/src/Service.Tests/Authorization/AuthorizationResolverUnitTests.cs index 0795efc8da..bae8484cf1 100644 --- a/src/Service.Tests/Authorization/AuthorizationResolverUnitTests.cs +++ b/src/Service.Tests/Authorization/AuthorizationResolverUnitTests.cs @@ -625,6 +625,37 @@ public void TestExplicitlyConfiguredNamedRoleDoesNotInheritBroaderPermissions() "'authenticated' should retain its own Create permission."); } + /// + /// Tests for IsRoleAllowedByDirective covering the full role inheritance chain at the + /// GraphQL @authorize directive gate. + /// Inheritance chain: named-role inherits from 'authenticated'; 'authenticated' inherits + /// from 'anonymous'. So any non-anonymous role is allowed when 'authenticated' OR 'anonymous' + /// is listed in the directive roles. + /// + [DataTestMethod] + [DataRow(null, "admin", false, DisplayName = "Null directive roles — deny all")] + [DataRow(new string[0], "admin", false, DisplayName = "Empty directive roles — deny all")] + [DataRow(new[] { "admin" }, "admin", true, DisplayName = "Explicit match — allowed")] + [DataRow(new[] { "admin" }, "other", false, DisplayName = "No match, no system roles — denied")] + [DataRow(new[] { "authenticated" }, "Writer", true, DisplayName = "Named role inherits from authenticated")] + [DataRow(new[] { "authenticated" }, "anonymous", false, DisplayName = "anonymous does NOT inherit from authenticated")] + [DataRow(new[] { "anonymous" }, "authenticated", true, DisplayName = "authenticated inherits from anonymous")] + [DataRow(new[] { "anonymous" }, "Writer", true, DisplayName = "Named role inherits from anonymous via authenticated")] + [DataRow(new[] { "anonymous" }, "anonymous", true, DisplayName = "anonymous explicit match when anonymous listed")] + [DataRow(new[] { "ANONYMOUS" }, "authenticated", true, DisplayName = "Case-insensitive: ANONYMOUS directive allows authenticated")] + [DataRow(new[] { "AUTHENTICATED" }, "Writer", true, DisplayName = "Case-insensitive: AUTHENTICATED directive allows named role")] + public void TestIsRoleAllowedByDirective(string[]? directiveRoles, string clientRole, bool expected) + { + AuthorizationResolver authZResolver = AuthorizationHelpers.InitAuthorizationResolver( + AuthorizationHelpers.InitRuntimeConfig( + entityName: AuthorizationHelpers.TEST_ENTITY, + roleName: AuthorizationResolver.ROLE_ANONYMOUS, + operation: EntityActionOperation.Read)); + + bool actual = authZResolver.IsRoleAllowedByDirective(clientRole, directiveRoles); + Assert.AreEqual(expected, actual); + } + /// /// Test to validate the AreRoleAndOperationDefinedForEntity method for the case insensitivity of roleName. /// For eg. The role Writer is equivalent to wrIter, wRITer, WRITER etc. From 31a87562b46d8dd7ed9cf800117103d71c9350dc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 8 Mar 2026 23:15:49 +0000 Subject: [PATCH 12/19] Revert unrelated change to RequestParserUnitTests.cs Co-authored-by: Aniruddh25 <3513779+Aniruddh25@users.noreply.github.com> --- src/Service.Tests/UnitTests/RequestParserUnitTests.cs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/Service.Tests/UnitTests/RequestParserUnitTests.cs b/src/Service.Tests/UnitTests/RequestParserUnitTests.cs index 6cc6b6b7ad..4da3266271 100644 --- a/src/Service.Tests/UnitTests/RequestParserUnitTests.cs +++ b/src/Service.Tests/UnitTests/RequestParserUnitTests.cs @@ -1,8 +1,6 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -#nullable enable - using Azure.DataApiBuilder.Core.Parsers; using Microsoft.VisualStudio.TestTools.UnitTesting; @@ -54,7 +52,7 @@ public void ExtractRawQueryParameter_PreservesEncoding(string queryString, strin public void ExtractRawQueryParameter_ReturnsNull_WhenParameterNotFound(string? queryString, string parameterName) { // Call the internal method directly (no reflection needed) - string? result = RequestParser.ExtractRawQueryParameter(queryString!, parameterName); + string? result = RequestParser.ExtractRawQueryParameter(queryString, parameterName); Assert.IsNull(result, $"Expected null but got '{result}' for parameter '{parameterName}' in query '{queryString}'"); From 340f9a84baa32b40fecec59f09dcfe6ba2aa9822 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 9 Mar 2026 01:07:39 +0000 Subject: [PATCH 13/19] Fix CI failures: restrict IsRoleAllowedByDirective inheritance to unconfigured named roles only Co-authored-by: Aniruddh25 <3513779+Aniruddh25@users.noreply.github.com> --- src/Auth/IAuthorizationResolver.cs | 7 ++- .../Authorization/AuthorizationResolver.cs | 51 +++++++++++++++++-- .../AuthorizationResolverUnitTests.cs | 40 ++++++++++++--- 3 files changed, 86 insertions(+), 12 deletions(-) diff --git a/src/Auth/IAuthorizationResolver.cs b/src/Auth/IAuthorizationResolver.cs index 514cab13a2..3a961ece4d 100644 --- a/src/Auth/IAuthorizationResolver.cs +++ b/src/Auth/IAuthorizationResolver.cs @@ -146,8 +146,11 @@ public static IEnumerable GetRolesForOperation( /// /// Inheritance chain: named-role → authenticated → anonymous → none. /// - If the role is explicitly listed in the directive roles, return true. - /// - If the role is not 'anonymous' and 'authenticated' is listed, return true (inheritance). - /// - Otherwise, return false. + /// - If the role is 'authenticated' and 'anonymous' is listed, return true (inheritance). + /// - If the role is an unconfigured named role (not in any entity's explicit permissions) + /// and either 'authenticated' or 'anonymous' is listed, return true (inheritance). + /// - Explicitly configured named roles use strict matching only, to prevent unintended + /// access to operations outside their explicitly scoped permissions. /// /// The role from the X-MS-API-ROLE header. /// The roles listed on the @authorize directive. diff --git a/src/Core/Authorization/AuthorizationResolver.cs b/src/Core/Authorization/AuthorizationResolver.cs index 74f9a63e09..7c3c2fedb2 100644 --- a/src/Core/Authorization/AuthorizationResolver.cs +++ b/src/Core/Authorization/AuthorizationResolver.cs @@ -38,6 +38,13 @@ public class AuthorizationResolver : IAuthorizationResolver public Dictionary EntityPermissionsMap { get; private set; } = new(); + /// + /// Cached set of named roles that are explicitly configured in at least one entity's permissions. + /// Used by to determine whether a named role + /// should use strict directive matching vs. inheritance at the GraphQL @authorize gate. + /// + private HashSet _explicitlyConfiguredNamedRoles = new(StringComparer.OrdinalIgnoreCase); + public AuthorizationResolver( RuntimeConfigProvider runtimeConfigProvider, IMetadataProviderFactory metadataProviderFactory, @@ -263,6 +270,9 @@ public static string GetRoleOfGraphQLRequest(IMiddlewareContext context) /// private void SetEntityPermissionMap(RuntimeConfig runtimeConfig) { + Dictionary newEntityPermissionsMap = new(); + HashSet newExplicitlyConfiguredNamedRoles = new(StringComparer.OrdinalIgnoreCase); + foreach ((string entityName, Entity entity) in runtimeConfig.Entities) { EntityMetadata entityToRoleMap = new(); @@ -390,8 +400,21 @@ private void SetEntityPermissionMap(RuntimeConfig runtimeConfig) CopyOverPermissionsFromAnonymousToAuthenticatedRole(entityToRoleMap, allowedColumnsForAnonymousRole); } - EntityPermissionsMap[entityName] = entityToRoleMap; + newEntityPermissionsMap[entityName] = entityToRoleMap; + + // Collect all named roles (non-system) that are explicitly configured for this entity. + foreach (string roleName in entityToRoleMap.RoleToOperationMap.Keys) + { + if (!roleName.Equals(ROLE_ANONYMOUS, StringComparison.OrdinalIgnoreCase) && + !roleName.Equals(ROLE_AUTHENTICATED, StringComparison.OrdinalIgnoreCase)) + { + newExplicitlyConfiguredNamedRoles.Add(roleName); + } + } } + + EntityPermissionsMap = newEntityPermissionsMap; + _explicitlyConfiguredNamedRoles = newExplicitlyConfiguredNamedRoles; } /// @@ -449,10 +472,20 @@ public bool IsRoleAllowedByDirective(string clientRole, IReadOnlyList? d return true; } - // Role inheritance: 'authenticated' inherits from 'anonymous', and named roles inherit from - // 'authenticated' (which itself may inherit from 'anonymous'). Any non-anonymous role is - // therefore allowed when either 'authenticated' or 'anonymous' is listed in the directive. + // 'authenticated' inherits from 'anonymous': allow authenticated when anonymous is in the directive. + if (clientRole.Equals(ROLE_AUTHENTICATED, StringComparison.OrdinalIgnoreCase) && + directiveRoles.Any(role => role.Equals(ROLE_ANONYMOUS, StringComparison.OrdinalIgnoreCase))) + { + return true; + } + + // For named roles (non-system), only apply inheritance if the role is not explicitly + // configured in any entity. Explicitly configured roles have their own permission scopes + // and should only pass directives that list them (or a system role they'd inherit from) + // explicitly, preventing unintended access to operations outside their configured scope. if (!clientRole.Equals(ROLE_ANONYMOUS, StringComparison.OrdinalIgnoreCase) && + !clientRole.Equals(ROLE_AUTHENTICATED, StringComparison.OrdinalIgnoreCase) && + !IsNamedRoleExplicitlyConfigured(clientRole) && (directiveRoles.Any(role => role.Equals(ROLE_AUTHENTICATED, StringComparison.OrdinalIgnoreCase)) || directiveRoles.Any(role => role.Equals(ROLE_ANONYMOUS, StringComparison.OrdinalIgnoreCase)))) { @@ -462,6 +495,16 @@ public bool IsRoleAllowedByDirective(string clientRole, IReadOnlyList? d return false; } + /// + /// Returns true if the given named role appears in the explicit permissions configuration of + /// any entity. Roles that are explicitly configured have their own permission scopes and + /// should not inherit permissions from system roles at the GraphQL directive level. + /// + private bool IsNamedRoleExplicitlyConfigured(string roleName) + { + return _explicitlyConfiguredNamedRoles.Contains(roleName); + } + /// /// Returns the effective role name for permission lookups, implementing role inheritance. /// System roles (anonymous, authenticated) always resolve to themselves. diff --git a/src/Service.Tests/Authorization/AuthorizationResolverUnitTests.cs b/src/Service.Tests/Authorization/AuthorizationResolverUnitTests.cs index bae8484cf1..c16d362268 100644 --- a/src/Service.Tests/Authorization/AuthorizationResolverUnitTests.cs +++ b/src/Service.Tests/Authorization/AuthorizationResolverUnitTests.cs @@ -628,22 +628,23 @@ public void TestExplicitlyConfiguredNamedRoleDoesNotInheritBroaderPermissions() /// /// Tests for IsRoleAllowedByDirective covering the full role inheritance chain at the /// GraphQL @authorize directive gate. - /// Inheritance chain: named-role inherits from 'authenticated'; 'authenticated' inherits - /// from 'anonymous'. So any non-anonymous role is allowed when 'authenticated' OR 'anonymous' - /// is listed in the directive roles. + /// Unconfigured named roles inherit: named-role inherits from 'authenticated'; 'authenticated' + /// inherits from 'anonymous'. Any unconfigured non-anonymous role is allowed when 'authenticated' + /// OR 'anonymous' is listed in the directive roles. + /// Explicitly configured named roles use strict matching only to prevent privilege escalation. /// [DataTestMethod] [DataRow(null, "admin", false, DisplayName = "Null directive roles — deny all")] [DataRow(new string[0], "admin", false, DisplayName = "Empty directive roles — deny all")] [DataRow(new[] { "admin" }, "admin", true, DisplayName = "Explicit match — allowed")] [DataRow(new[] { "admin" }, "other", false, DisplayName = "No match, no system roles — denied")] - [DataRow(new[] { "authenticated" }, "Writer", true, DisplayName = "Named role inherits from authenticated")] + [DataRow(new[] { "authenticated" }, "Writer", true, DisplayName = "Unconfigured named role inherits from authenticated")] [DataRow(new[] { "authenticated" }, "anonymous", false, DisplayName = "anonymous does NOT inherit from authenticated")] [DataRow(new[] { "anonymous" }, "authenticated", true, DisplayName = "authenticated inherits from anonymous")] - [DataRow(new[] { "anonymous" }, "Writer", true, DisplayName = "Named role inherits from anonymous via authenticated")] + [DataRow(new[] { "anonymous" }, "Writer", true, DisplayName = "Unconfigured named role inherits from anonymous via authenticated")] [DataRow(new[] { "anonymous" }, "anonymous", true, DisplayName = "anonymous explicit match when anonymous listed")] [DataRow(new[] { "ANONYMOUS" }, "authenticated", true, DisplayName = "Case-insensitive: ANONYMOUS directive allows authenticated")] - [DataRow(new[] { "AUTHENTICATED" }, "Writer", true, DisplayName = "Case-insensitive: AUTHENTICATED directive allows named role")] + [DataRow(new[] { "AUTHENTICATED" }, "Writer", true, DisplayName = "Case-insensitive: AUTHENTICATED directive allows unconfigured named role")] public void TestIsRoleAllowedByDirective(string[]? directiveRoles, string clientRole, bool expected) { AuthorizationResolver authZResolver = AuthorizationHelpers.InitAuthorizationResolver( @@ -656,6 +657,33 @@ public void TestIsRoleAllowedByDirective(string[]? directiveRoles, string client Assert.AreEqual(expected, actual); } + /// + /// Tests that explicitly configured named roles use strict directive matching. + /// A role that is explicitly configured for any entity (even with restricted permissions) + /// will NOT inherit from system roles at the @authorize directive level, preventing + /// unintended access to operations outside its configured permission scope. + /// + [DataTestMethod] + [DataRow(new[] { "authenticated" }, "Writer", false, DisplayName = "Configured role does NOT inherit from authenticated when not in directive")] + [DataRow(new[] { "anonymous" }, "Writer", false, DisplayName = "Configured role does NOT inherit from anonymous when not in directive")] + [DataRow(new[] { "Writer" }, "Writer", true, DisplayName = "Configured role passes when explicitly listed in directive")] + [DataRow(new[] { "anonymous", "authenticated" }, "Writer", false, DisplayName = "Configured role denied even when both system roles in directive")] + public void TestIsRoleAllowedByDirective_ExplicitlyConfiguredRoleUsesStrictMatching( + string[] directiveRoles, string clientRole, bool expected) + { + // Configure 'Writer' as an explicitly restricted role (read-only) on the test entity. + // Even though 'authenticated' or 'anonymous' may be in the directive, 'Writer' should + // not inherit because it is an explicitly configured role with its own permission scope. + AuthorizationResolver authZResolver = AuthorizationHelpers.InitAuthorizationResolver( + AuthorizationHelpers.InitRuntimeConfig( + entityName: AuthorizationHelpers.TEST_ENTITY, + roleName: "Writer", + operation: EntityActionOperation.Read)); + + bool actual = authZResolver.IsRoleAllowedByDirective(clientRole, directiveRoles); + Assert.AreEqual(expected, actual); + } + /// /// Test to validate the AreRoleAndOperationDefinedForEntity method for the case insensitivity of roleName. /// For eg. The role Writer is equivalent to wrIter, wRITer, WRITER etc. From 876e15c9a818bd51452061e90f5d8810b2e68ebd Mon Sep 17 00:00:00 2001 From: aaron burtle Date: Mon, 9 Mar 2026 14:03:52 -0700 Subject: [PATCH 14/19] test fix --- .../Configuration/ConfigurationTests.cs | 6323 ++++++----------- 1 file changed, 2361 insertions(+), 3962 deletions(-) diff --git a/src/Service.Tests/Configuration/ConfigurationTests.cs b/src/Service.Tests/Configuration/ConfigurationTests.cs index aa12a7d465..3aba3c5fba 100644 --- a/src/Service.Tests/Configuration/ConfigurationTests.cs +++ b/src/Service.Tests/Configuration/ConfigurationTests.cs @@ -709,7 +709,21 @@ public void CleanupAfterEachTest() { if (File.Exists(CUSTOM_CONFIG_FILENAME)) { - File.Delete(CUSTOM_CONFIG_FILENAME); + // Retry file deletion to handle cases where a TestServer or file watcher + // from the test hasn't fully released the file handle yet. + int maxRetries = 5; + for (int i = 0; i < maxRetries; i++) + { + try + { + File.Delete(CUSTOM_CONFIG_FILENAME); + break; + } + catch (IOException) when (i < maxRetries - 1) + { + Thread.Sleep(200 * (i + 1)); + } + } } TestHelper.UnsetAllDABEnvironmentVariables(); @@ -906,7 +920,7 @@ public void MsSqlConnStringSupplementedWithAppNameProperty( /// Whether DAB_APP_NAME_ENV is set in environment. (Always present in hosted scenario or if user supplies value.) [DataTestMethod] [DataRow("Host=foo;Username=testuser;", "Host=foo;Username=testuser;Application Name=", false, DisplayName = "[PGSQL]:DAB adds version 'dab_oss_major_minor_patch' to non-provided connection string property 'ApplicationName']")] - [DataRow("Host=foo;Username=testuser;", "Host=foo;Username=testuser;Application Name=", true, DisplayName = "[PGSQL]:DAB adds DAB_APP_NAME_ENV value 'dab_hosted' and version suffix '_major_minor_patch' to non-provided connection string property 'ApplicationName'.]")] + [DataRow("Host=foo;Username=testuser;", "Host=foo;Username=testuser;Application Name=", true, DisplayName = "[PGSQL]:DAB adds DAB_APP_NAME_ENV value 'dab_hosted' and version suffix '_major_minor_patch' to non-provided connection string property 'ApplicationName'.")] [DataRow("Host=foo;Username=testuser;Application Name=UserAppName", "Host=foo;Username=testuser;Application Name=UserAppName,", false, DisplayName = "[PGSQL]:DAB appends version 'dab_oss_major_minor_patch' to user supplied 'Application Name' property.]")] [DataRow("Host=foo;Username=testuser;Application Name=UserAppName", "Host=foo;Username=testuser;Application Name=UserAppName,", true, DisplayName = "[PGSQL]:DAB appends version string 'dab_hosted' and version suffix '_major_minor_patch' to user supplied 'ApplicationName' property.]")] public void PgSqlConnStringSupplementedWithAppNameProperty( @@ -995,6 +1009,10 @@ public void TestConnectionStringIsCorrectlyUpdatedWithApplicationName( RuntimeConfig runtimeConfig = CreateBasicRuntimeConfigWithNoEntity(databaseType, configProvidedConnString); + // Resolve assembly version. Not possible to do in DataRow as DataRows expect compile-time constants. + string resolvedAssemblyVersion = ProductInfo.GetDataApiBuilderUserAgent(); + expectedDabModifiedConnString += resolvedAssemblyVersion; + // Act bool configParsed = RuntimeConfigLoader.TryParseConfig( json: runtimeConfig.ToJson(), @@ -1012,396 +1030,116 @@ public void TestConnectionStringIsCorrectlyUpdatedWithApplicationName( message: "DAB did not properly set the 'Application Name' connection string property."); } - [TestMethod("Validates that once the configuration is set, the config controller isn't reachable."), TestCategory(TestCategory.COSMOSDBNOSQL)] - [DataRow(CONFIGURATION_ENDPOINT)] - [DataRow(CONFIGURATION_ENDPOINT_V2)] - public async Task TestConflictAlreadySetConfiguration(string configurationEndpoint) - { - TestServer server = new(Program.CreateWebHostFromInMemoryUpdatableConfBuilder(Array.Empty())); - HttpClient httpClient = server.CreateClient(); - - JsonContent content = GetJsonContentForCosmosConfigRequest(configurationEndpoint); - - _ = await httpClient.PostAsync(configurationEndpoint, content); - ValidateCosmosDbSetup(server); - - HttpResponseMessage result = await httpClient.PostAsync(configurationEndpoint, content); - Assert.AreEqual(HttpStatusCode.Conflict, result.StatusCode); - } - - [TestMethod("Validates that the config controller returns a conflict when using local configuration."), TestCategory(TestCategory.COSMOSDBNOSQL)] - [DataRow(CONFIGURATION_ENDPOINT)] - [DataRow(CONFIGURATION_ENDPOINT_V2)] - public async Task TestConflictLocalConfiguration(string configurationEndpoint) - { - Environment.SetEnvironmentVariable - (ASP_NET_CORE_ENVIRONMENT_VAR_NAME, COSMOS_ENVIRONMENT); - TestServer server = new(Program.CreateWebHostBuilder(Array.Empty())); - HttpClient httpClient = server.CreateClient(); - - ValidateCosmosDbSetup(server); - - JsonContent content = GetJsonContentForCosmosConfigRequest(configurationEndpoint); - - HttpResponseMessage result = - await httpClient.PostAsync(configurationEndpoint, content); - Assert.AreEqual(HttpStatusCode.Conflict, result.StatusCode); - } - - [TestMethod("Validates setting the configuration at runtime."), TestCategory(TestCategory.COSMOSDBNOSQL)] - [DataRow(CONFIGURATION_ENDPOINT)] - [DataRow(CONFIGURATION_ENDPOINT_V2)] - public async Task TestSettingConfigurations(string configurationEndpoint) - { - TestServer server = new(Program.CreateWebHostFromInMemoryUpdatableConfBuilder(Array.Empty())); - HttpClient httpClient = server.CreateClient(); - - JsonContent content = GetJsonContentForCosmosConfigRequest(configurationEndpoint); - - HttpResponseMessage postResult = - await httpClient.PostAsync(configurationEndpoint, content); - Assert.AreEqual(HttpStatusCode.OK, postResult.StatusCode); - } - - [TestMethod("Validates an invalid configuration returns a bad request."), TestCategory(TestCategory.COSMOSDBNOSQL)] - [DataRow(CONFIGURATION_ENDPOINT)] - [DataRow(CONFIGURATION_ENDPOINT_V2)] - public async Task TestInvalidConfigurationAtRuntime(string configurationEndpoint) - { - TestServer server = new(Program.CreateWebHostFromInMemoryUpdatableConfBuilder(Array.Empty())); - HttpClient httpClient = server.CreateClient(); - - JsonContent content = GetJsonContentForCosmosConfigRequest(configurationEndpoint, "invalidString"); - - HttpResponseMessage postResult = - await httpClient.PostAsync(configurationEndpoint, content); - Assert.AreEqual(HttpStatusCode.BadRequest, postResult.StatusCode); - } - - [TestMethod("Validates a failure in one of the config updated handlers returns a bad request."), TestCategory(TestCategory.COSMOSDBNOSQL)] - [DataRow(CONFIGURATION_ENDPOINT)] - [DataRow(CONFIGURATION_ENDPOINT_V2)] - public async Task TestSettingFailureConfigurations(string configurationEndpoint) - { - TestServer server = new(Program.CreateWebHostFromInMemoryUpdatableConfBuilder(Array.Empty())); - HttpClient httpClient = server.CreateClient(); - - JsonContent content = GetJsonContentForCosmosConfigRequest(configurationEndpoint); - - RuntimeConfigProvider runtimeConfigProvider = server.Services.GetService(); - runtimeConfigProvider.RuntimeConfigLoadedHandlers.Add((_, _) => - { - return Task.FromResult(false); - }); - - HttpResponseMessage postResult = - await httpClient.PostAsync(configurationEndpoint, content); - - Assert.AreEqual(HttpStatusCode.BadRequest, postResult.StatusCode); - } - - [TestMethod("Validates that the configuration endpoint doesn't return until all configuration loaded handlers have executed."), TestCategory(TestCategory.COSMOSDBNOSQL)] - [DataRow(CONFIGURATION_ENDPOINT)] - [DataRow(CONFIGURATION_ENDPOINT_V2)] - public async Task TestLongRunningConfigUpdatedHandlerConfigurations(string configurationEndpoint) - { - TestServer server = new(Program.CreateWebHostFromInMemoryUpdatableConfBuilder(Array.Empty())); - HttpClient httpClient = server.CreateClient(); - - JsonContent content = GetJsonContentForCosmosConfigRequest(configurationEndpoint); - - RuntimeConfigProvider runtimeConfigProvider = server.Services.GetService(); - bool taskHasCompleted = false; - runtimeConfigProvider.RuntimeConfigLoadedHandlers.Add(async (_, _) => - { - await Task.Delay(1000); - taskHasCompleted = true; - return true; - }); - - HttpResponseMessage postResult = - await httpClient.PostAsync(configurationEndpoint, content); - - Assert.AreEqual(HttpStatusCode.OK, postResult.StatusCode); - Assert.IsTrue(taskHasCompleted); - } - /// - /// Tests that sending configuration to the DAB engine post-startup will properly hydrate - /// the AuthorizationResolver by: - /// 1. Validate that pre-configuration hydration requests result in 503 Service Unavailable - /// 2. Validate that custom configuration hydration succeeds. - /// 3. Validate that request to protected entity without role membership triggers Authorization Resolver - /// to reject the request with HTTP 403 Forbidden. - /// 4. Validate that request to protected entity with required role membership passes authorization requirements - /// and succeeds with HTTP 200 OK. - /// Note: This test is database engine agnostic, though requires denoting a database environment to fetch a usable - /// connection string to complete the test. Most applicable to CI/CD test execution. + /// Validates that DAB supplements the MongoDB database connection strings with the property "ApplicationName" and + /// 1. Adds the property/value "Application Name=dab_oss_Major.Minor.Patch" when the env var DAB_APP_NAME_ENV is not set. + /// 2. Adds the property/value "Application Name=dab_hosted_Major.Minor.Patch" when the env var DAB_APP_NAME_ENV is set to "dab_hosted". + /// (DAB_APP_NAME_ENV is set in hosted scenario or when user sets the value.) + /// NOTE: "#pragma warning disable format" is used here to avoid removing intentional, readability promoting spacing in DataRow display names. /// - [TestCategory(TestCategory.MSSQL)] - [TestMethod("Validates setting the AuthN/Z configuration post-startup during runtime.")] - [DataRow(CONFIGURATION_ENDPOINT)] - [DataRow(CONFIGURATION_ENDPOINT_V2)] - public async Task TestSqlSettingPostStartupConfigurations(string configurationEndpoint) + /// connection string provided in the config. + /// Updated connection string with Application Name. + /// Whether DAB_APP_NAME_ENV is set in environment. (Always present in hosted scenario or if user supplies value.) + [DataTestMethod] + [DataRow("mongodb://foo:27017" , "mongodb://foo:27017;Application Name=" , false, DisplayName = "[MONGODB]: DAB adds version 'dab_oss_major_minor_patch' to non-provided connection string property 'Application Name'.")] + [DataRow("mongodb://foo:27017;Application Name=CustAppName;" , "mongodb://foo:27017;Application Name=CustAppName," , false, DisplayName = "[MONGODB]: DAB appends version 'dab_oss_major_minor_patch' to user supplied 'Application Name' property.")] + [DataRow("mongodb://foo:27017;App=CustAppName;" , "mongodb://foo:27017;Application Name=CustAppName," , false, DisplayName = "[MONGODB]: DAB appends version 'dab_oss_major_minor_patch' to user supplied 'App' property and resolves property to 'Application Name'.")] + [DataRow("mongodb://foo:27017" , "mongodb://foo:27017;Application Name=" , true , DisplayName = "[MONGODB]: DAB adds DAB_APP_NAME_ENV value 'dab_hosted' and version suffix '_major_minor_patch' to non-provided connection string property 'Application Name'.")] + [DataRow("mongodb://foo:27017;Application Name=CustAppName;" , "mongodb://foo:27017;Application Name=CustAppName," , true , DisplayName = "[MONGODB]: DAB appends DAB_APP_NAME_ENV value 'dab_hosted' and version suffix '_major_minor_patch' to user supplied 'Application Name' property.")] + [DataRow("mongodb://foo:27017;App=CustAppName;" , "mongodb://foo:27017;Application Name=CustAppName," , true , DisplayName = "[MONGODB]: DAB appends version string 'dab_hosted' and version suffix '_major_minor_patch' to user supplied 'App' property and resolves property to 'Application Name'.")] + #pragma warning restore format + public void MongoDbConnStringSupplementedWithAppNameProperty( + string configProvidedConnString, + string expectedDabModifiedConnString, + bool dabEnvOverride) { - TestServer server = new(Program.CreateWebHostFromInMemoryUpdatableConfBuilder(Array.Empty())); - HttpClient httpClient = server.CreateClient(); - - RuntimeConfig configuration = AuthorizationHelpers.InitRuntimeConfig( - entityName: POST_STARTUP_CONFIG_ENTITY, - entitySource: POST_STARTUP_CONFIG_ENTITY_SOURCE, - roleName: POST_STARTUP_CONFIG_ROLE, - operation: EntityActionOperation.Read, - includedCols: new HashSet() { "*" }); - - JsonContent content = GetPostStartupConfigParams(MSSQL_ENVIRONMENT, configuration, configurationEndpoint); - - HttpResponseMessage preConfigHydrationResult = - await httpClient.GetAsync($"/{POST_STARTUP_CONFIG_ENTITY}"); - Assert.AreEqual(HttpStatusCode.ServiceUnavailable, preConfigHydrationResult.StatusCode); + // Explicitly set the DAB_APP_NAME_ENV to null to ensure that the DAB_APP_NAME_ENV is not set. + if (dabEnvOverride) + { + Environment.SetEnvironmentVariable(ProductInfo.DAB_APP_NAME_ENV, "dab_hosted"); + } + else + { + Environment.SetEnvironmentVariable(ProductInfo.DAB_APP_NAME_ENV, null); + } - HttpResponseMessage preConfigOpenApiDocumentExistence = - await httpClient.GetAsync($"{RestRuntimeOptions.DEFAULT_PATH}/{OPENAPI_DOCUMENT_ENDPOINT}"); - Assert.AreEqual(HttpStatusCode.ServiceUnavailable, preConfigOpenApiDocumentExistence.StatusCode); + // Resolve assembly version. Not possible to do in DataRow as DataRows expect compile-time constants. + string resolvedAssemblyVersion = ProductInfo.GetDataApiBuilderUserAgent(); + expectedDabModifiedConnString += resolvedAssemblyVersion; - // SwaggerUI (OpenAPI user interface) is not made available in production/hosting mode. - HttpResponseMessage preConfigOpenApiSwaggerEndpointAvailability = - await httpClient.GetAsync($"/{OPENAPI_SWAGGER_ENDPOINT}"); - Assert.AreEqual(HttpStatusCode.ServiceUnavailable, preConfigOpenApiSwaggerEndpointAvailability.StatusCode); + RuntimeConfig runtimeConfig = CreateBasicRuntimeConfigWithNoEntity(DatabaseType.MongoDB, configProvidedConnString); - HttpStatusCode responseCode = await HydratePostStartupConfiguration(httpClient, content, configurationEndpoint, configuration.Runtime.Rest); + // Act + bool configParsed = RuntimeConfigLoader.TryParseConfig( + json: runtimeConfig.ToJson(), + config: out RuntimeConfig updatedRuntimeConfig, + replacementSettings: new(doReplaceEnvVar: true)); - // When the authorization resolver is properly configured, authorization will have failed - // because no auth headers are present. + // Assert Assert.AreEqual( - expected: HttpStatusCode.Forbidden, - actual: responseCode, - message: "Configuration not yet hydrated after retry attempts.."); - - // Sends a GET request to a protected entity which requires a specific role to access. - // Authorization will pass because proper auth headers are present. - HttpRequestMessage message = new(method: HttpMethod.Get, requestUri: $"api/{POST_STARTUP_CONFIG_ENTITY}"); - - // Use an AppService EasyAuth principal carrying the required role when - // authentication is configured to use AppService. - string appServiceTokenPayload = AuthTestHelper.CreateAppServiceEasyAuthToken( - roleClaimType: Config.ObjectModel.AuthenticationOptions.ROLE_CLAIM_TYPE, - additionalClaims: - [ - new AppServiceClaim - { - Typ = Config.ObjectModel.AuthenticationOptions.ROLE_CLAIM_TYPE, - Val = POST_STARTUP_CONFIG_ROLE - } - ]); - - message.Headers.Add(Config.ObjectModel.AuthenticationOptions.CLIENT_PRINCIPAL_HEADER, appServiceTokenPayload); - message.Headers.Add(AuthorizationResolver.CLIENT_ROLE_HEADER, POST_STARTUP_CONFIG_ROLE); - HttpResponseMessage authorizedResponse = await httpClient.SendAsync(message); - Assert.AreEqual(expected: HttpStatusCode.OK, actual: authorizedResponse.StatusCode); - - // OpenAPI document is created during config hydration and - // is made available after config hydration completes. - HttpResponseMessage postConfigOpenApiDocumentExistence = - await httpClient.GetAsync($"{RestRuntimeOptions.DEFAULT_PATH}/{OPENAPI_DOCUMENT_ENDPOINT}"); - Assert.AreEqual(HttpStatusCode.OK, postConfigOpenApiDocumentExistence.StatusCode); - - // SwaggerUI (OpenAPI user interface) is not made available in production/hosting mode. - // HTTP 400 - BadRequest because when SwaggerUI is disabled, the endpoint is not mapped - // and the request is processed and failed by the RestService. - HttpResponseMessage postConfigOpenApiSwaggerEndpointAvailability = - await httpClient.GetAsync($"/{OPENAPI_SWAGGER_ENDPOINT}"); - Assert.AreEqual(HttpStatusCode.BadRequest, postConfigOpenApiSwaggerEndpointAvailability.StatusCode); + expected: true, + actual: configParsed, + message: "Runtime config unexpectedly failed parsing."); + Assert.AreEqual( + expected: expectedDabModifiedConnString, + actual: updatedRuntimeConfig.DataSource.ConnectionString, + message: "DAB did not properly set the 'Application Name' connection string property."); } /// - /// Tests that sending configuration to the DAB engine post-startup will properly hydrate even with data-source-files specified. + /// Validates that DAB supplements the CosmosDB database connection strings with the property "ApplicationName" and + /// 1. Adds the property/value "Application Name=dab_oss_Major.Minor.Patch" when the env var DAB_APP_NAME_ENV is not set. + /// 2. Adds the property/value "Application Name=dab_hosted_Major.Minor.Patch" when the env var DAB_APP_NAME_ENV is set to "dab_hosted". + /// (DAB_APP_NAME_ENV is set in hosted scenario or when user sets the value.) + /// NOTE: "#pragma warning disable format" is used here to avoid removing intentional, readability promoting spacing in DataRow display names. /// - [TestCategory(TestCategory.MSSQL)] - [TestMethod("Validates RuntimeConfig setup for post-configuraiton hydration with datasource-files specified.")] - [DataRow(CONFIGURATION_ENDPOINT)] - [DataRow(CONFIGURATION_ENDPOINT_V2)] - public async Task TestValidMultiSourceRunTimePostStartupConfigurations(string configurationEndpoint) - { - TestServer server = new(Program.CreateWebHostFromInMemoryUpdatableConfBuilder(Array.Empty())); - HttpClient httpClient = server.CreateClient(); - - RuntimeConfig config = AuthorizationHelpers.InitRuntimeConfig( - entityName: POST_STARTUP_CONFIG_ENTITY, - entitySource: POST_STARTUP_CONFIG_ENTITY_SOURCE, - roleName: POST_STARTUP_CONFIG_ROLE, - operation: EntityActionOperation.Read, - includedCols: new HashSet() { "*" }); - - // Set up Configuration with DataSource files. - config = config with { DataSourceFiles = new DataSourceFiles(new List() { "file1", "file2" }) }; - - JsonContent content = GetPostStartupConfigParams(MSSQL_ENVIRONMENT, config, configurationEndpoint); - - HttpResponseMessage postResult = await httpClient.PostAsync(configurationEndpoint, content); - Assert.AreEqual(HttpStatusCode.OK, postResult.StatusCode); - - RuntimeConfigProvider configProvider = server.Services.GetService(); - - Assert.IsNotNull(configProvider, "Configuration Provider shouldn't be null after setting the configuration at runtime."); - Assert.IsTrue(configProvider.TryGetConfig(out RuntimeConfig configuration), "TryGetConfig should return true when the config is set."); - Assert.IsNotNull(configuration, "Config returned should not be null."); - - Assert.IsNotNull(configuration.DataSource, "The base datasource should get populated in case of late hydration of config in-spite of invalid multi-db files."); - Assert.AreEqual(1, configuration.ListAllDataSources().Count(), "There should be only 1 datasource populated for late hydration of config with invalid multi-db files."); - } - - [TestMethod("Validates that local CosmosDB_NoSQL settings can be loaded and the correct classes are in the service provider."), TestCategory(TestCategory.COSMOSDBNOSQL)] - public void TestLoadingLocalCosmosSettings() - { - Environment.SetEnvironmentVariable(ASP_NET_CORE_ENVIRONMENT_VAR_NAME, COSMOS_ENVIRONMENT); - TestServer server = new(Program.CreateWebHostBuilder(Array.Empty())); - - ValidateCosmosDbSetup(server); - } - - [TestMethod("Validates access token is correctly loaded when Account Key is not present for Cosmos."), TestCategory(TestCategory.COSMOSDBNOSQL)] - [DataRow(CONFIGURATION_ENDPOINT)] - [DataRow(CONFIGURATION_ENDPOINT_V2)] - public async Task TestLoadingAccessTokenForCosmosClient(string configurationEndpoint) - { - TestServer server = new(Program.CreateWebHostFromInMemoryUpdatableConfBuilder(Array.Empty())); - HttpClient httpClient = server.CreateClient(); - - JsonContent content = GetJsonContentForCosmosConfigRequest(configurationEndpoint, null, true); - - HttpResponseMessage authorizedResponse = await httpClient.PostAsync(configurationEndpoint, content); - - Assert.AreEqual(expected: HttpStatusCode.OK, actual: authorizedResponse.StatusCode); - CosmosClientProvider cosmosClientProvider = server.Services.GetService(typeof(CosmosClientProvider)) as CosmosClientProvider; - Assert.IsNotNull(cosmosClientProvider); - Assert.IsNotNull(cosmosClientProvider.Clients); - Assert.IsTrue(cosmosClientProvider.Clients.Any()); - } - - [TestMethod("Validates that local MsSql settings can be loaded and the correct classes are in the service provider."), TestCategory(TestCategory.MSSQL)] - public void TestLoadingLocalMsSqlSettings() - { - Environment.SetEnvironmentVariable(ASP_NET_CORE_ENVIRONMENT_VAR_NAME, MSSQL_ENVIRONMENT); - TestServer server = new(Program.CreateWebHostBuilder(Array.Empty())); - - QueryEngineFactory queryEngineFactory = (QueryEngineFactory)server.Services.GetService(typeof(IQueryEngineFactory)); - Assert.IsInstanceOfType(queryEngineFactory.GetQueryEngine(DatabaseType.MSSQL), typeof(SqlQueryEngine)); - - MutationEngineFactory mutationEngineFactory = (MutationEngineFactory)server.Services.GetService(typeof(IMutationEngineFactory)); - Assert.IsInstanceOfType(mutationEngineFactory.GetMutationEngine(DatabaseType.MSSQL), typeof(SqlMutationEngine)); - - QueryManagerFactory queryManagerFactory = (QueryManagerFactory)server.Services.GetService(typeof(IAbstractQueryManagerFactory)); - Assert.IsInstanceOfType(queryManagerFactory.GetQueryBuilder(DatabaseType.MSSQL), typeof(MsSqlQueryBuilder)); - Assert.IsInstanceOfType(queryManagerFactory.GetQueryExecutor(DatabaseType.MSSQL), typeof(MsSqlQueryExecutor)); - - MetadataProviderFactory metadataProviderFactory = (MetadataProviderFactory)server.Services.GetService(typeof(IMetadataProviderFactory)); - Assert.IsTrue(metadataProviderFactory.ListMetadataProviders().Any(x => x.GetType() == typeof(MsSqlMetadataProvider))); - } - - [TestMethod("Validates that local PostgreSql settings can be loaded and the correct classes are in the service provider."), TestCategory(TestCategory.POSTGRESQL)] - public void TestLoadingLocalPostgresSettings() - { - Environment.SetEnvironmentVariable(ASP_NET_CORE_ENVIRONMENT_VAR_NAME, POSTGRESQL_ENVIRONMENT); - TestServer server = new(Program.CreateWebHostBuilder(Array.Empty())); - - QueryEngineFactory queryEngineFactory = (QueryEngineFactory)server.Services.GetService(typeof(IQueryEngineFactory)); - Assert.IsInstanceOfType(queryEngineFactory.GetQueryEngine(DatabaseType.PostgreSQL), typeof(SqlQueryEngine)); - - MutationEngineFactory mutationEngineFactory = (MutationEngineFactory)server.Services.GetService(typeof(IMutationEngineFactory)); - Assert.IsInstanceOfType(mutationEngineFactory.GetMutationEngine(DatabaseType.PostgreSQL), typeof(SqlMutationEngine)); - - QueryManagerFactory queryManagerFactory = (QueryManagerFactory)server.Services.GetService(typeof(IAbstractQueryManagerFactory)); - Assert.IsInstanceOfType(queryManagerFactory.GetQueryBuilder(DatabaseType.PostgreSQL), typeof(PostgresQueryBuilder)); - Assert.IsInstanceOfType(queryManagerFactory.GetQueryExecutor(DatabaseType.PostgreSQL), typeof(PostgreSqlQueryExecutor)); - - MetadataProviderFactory metadataProviderFactory = (MetadataProviderFactory)server.Services.GetService(typeof(IMetadataProviderFactory)); - Assert.IsTrue(metadataProviderFactory.ListMetadataProviders().Any(x => x.GetType() == typeof(PostgreSqlMetadataProvider))); - } - - [TestMethod("Validates that local MySql settings can be loaded and the correct classes are in the service provider."), TestCategory(TestCategory.MYSQL)] - public void TestLoadingLocalMySqlSettings() - { - Environment.SetEnvironmentVariable(ASP_NET_CORE_ENVIRONMENT_VAR_NAME, MYSQL_ENVIRONMENT); - TestServer server = new(Program.CreateWebHostBuilder(Array.Empty())); - - QueryEngineFactory queryEngineFactory = (QueryEngineFactory)server.Services.GetService(typeof(IQueryEngineFactory)); - Assert.IsInstanceOfType(queryEngineFactory.GetQueryEngine(DatabaseType.MySQL), typeof(SqlQueryEngine)); - - MutationEngineFactory mutationEngineFactory = (MutationEngineFactory)server.Services.GetService(typeof(IMutationEngineFactory)); - Assert.IsInstanceOfType(mutationEngineFactory.GetMutationEngine(DatabaseType.MySQL), typeof(SqlMutationEngine)); - - QueryManagerFactory queryManagerFactory = (QueryManagerFactory)server.Services.GetService(typeof(IAbstractQueryManagerFactory)); - Assert.IsInstanceOfType(queryManagerFactory.GetQueryBuilder(DatabaseType.MySQL), typeof(MySqlQueryBuilder)); - Assert.IsInstanceOfType(queryManagerFactory.GetQueryExecutor(DatabaseType.MySQL), typeof(MySqlQueryExecutor)); - - MetadataProviderFactory metadataProviderFactory = (MetadataProviderFactory)server.Services.GetService(typeof(IMetadataProviderFactory)); - Assert.IsTrue(metadataProviderFactory.ListMetadataProviders().Any(x => x.GetType() == typeof(MySqlMetadataProvider))); - } - - [TestMethod("Validates that trying to override configs that are already set fail."), TestCategory(TestCategory.COSMOSDBNOSQL)] - [DataRow(CONFIGURATION_ENDPOINT)] - [DataRow(CONFIGURATION_ENDPOINT_V2)] - public async Task TestOverridingLocalSettingsFails(string configurationEndpoint) - { - Environment.SetEnvironmentVariable(ASP_NET_CORE_ENVIRONMENT_VAR_NAME, COSMOS_ENVIRONMENT); - TestServer server = new(Program.CreateWebHostBuilder(Array.Empty())); - HttpClient client = server.CreateClient(); - - JsonContent config = GetJsonContentForCosmosConfigRequest(configurationEndpoint); - - HttpResponseMessage postResult = await client.PostAsync(configurationEndpoint, config); - Assert.AreEqual(HttpStatusCode.Conflict, postResult.StatusCode); - } - - [TestMethod("Validates that setting the configuration at runtime will instantiate the proper classes."), TestCategory(TestCategory.COSMOSDBNOSQL)] - [DataRow(CONFIGURATION_ENDPOINT)] - [DataRow(CONFIGURATION_ENDPOINT_V2)] - public async Task TestSettingConfigurationCreatesCorrectClasses(string configurationEndpoint) - { - TestServer server = new(Program.CreateWebHostFromInMemoryUpdatableConfBuilder(Array.Empty())); - HttpClient client = server.CreateClient(); - - JsonContent content = GetJsonContentForCosmosConfigRequest(configurationEndpoint); - - HttpResponseMessage postResult = await client.PostAsync(configurationEndpoint, content); - Assert.AreEqual(HttpStatusCode.OK, postResult.StatusCode); - - ValidateCosmosDbSetup(server); - RuntimeConfigProvider configProvider = server.Services.GetService(); - - Assert.IsNotNull(configProvider, "Configuration Provider shouldn't be null after setting the configuration at runtime."); - Assert.IsTrue(configProvider.TryGetConfig(out RuntimeConfig configuration), "TryGetConfig should return true when the config is set."); - Assert.IsNotNull(configuration, "Config returned should not be null."); - - ConfigurationPostParameters expectedParameters = GetCosmosConfigurationParameters(); - Assert.AreEqual(DatabaseType.CosmosDB_NoSQL, configuration.DataSource.DatabaseType, "Expected CosmosDB_NoSQL database type after configuring the runtime with CosmosDB_NoSQL settings."); - CosmosDbNoSQLDataSourceOptions options = configuration.DataSource.GetTypedOptions(); - Assert.IsNotNull(options); - Assert.AreEqual(expectedParameters.Schema, options.GraphQLSchema, "Expected the schema in the configuration to match the one sent to the configuration endpoint."); - - // Don't use Assert.AreEqual, because a failure will print the entire connection string in the error message. - Assert.IsTrue(expectedParameters.ConnectionString == configuration.DataSource.ConnectionString, "Expected the connection string in the configuration to match the one sent to the configuration endpoint."); - string db = options.Database; - Assert.AreEqual(COSMOS_DATABASE_NAME, db, "Expected the database name in the runtime config to match the one sent to the configuration endpoint."); - } - - [TestMethod("Validates that an exception is thrown if there's a null model in filter parser.")] - public void VerifyExceptionOnNullModelinFilterParser() + /// connection string provided in the config. + /// Updated connection string with Application Name. + /// Whether DAB_APP_NAME_ENV is set in environment. (Always present in hosted scenario or if user supplies value.) + [DataTestMethod] + [DataRow("AccountEndpoint=https://foo:8081/;AccountKey=secret" , "AccountEndpoint=https://foo:8081/;Application Name=" , false, DisplayName = "[COSMOSDB]: DAB adds version 'dab_oss_major_minor_patch' to non-provided connection string property 'Application Name'.")] + [DataRow("AccountEndpoint=https://foo:8081/;Application Name=CustAppName;" , "AccountEndpoint=https://foo:8081/;Application Name=CustAppName," , false, DisplayName = "[COSMOSDB]: DAB appends version 'dab_oss_major_minor_patch' to user supplied 'Application Name' property.")] + [DataRow("AccountEndpoint=https://foo:8081/;App=CustAppName;" , "AccountEndpoint=https://foo:8081/;Application Name=CustAppName," , false, DisplayName = "[COSMOSDB]: DAB appends version 'dab_oss_major_minor_patch' to user supplied 'App' property and resolves property to 'Application Name'.")] + [DataRow("AccountEndpoint=https://foo:8081/;Database=db" , "AccountEndpoint=https://foo:8081/;Application Name=" , true , DisplayName = "[COSMOSDB]: DAB adds DAB_APP_NAME_ENV value 'dab_hosted' and version suffix '_major_minor_patch' to non-provided connection string property 'Application Name'.")] + [DataRow("AccountEndpoint=https://foo:8081/;Application Name=CustAppName;" , "AccountEndpoint=https://foo:8081/;Application Name=CustAppName," , true , DisplayName = "[COSMOSDB]: DAB appends DAB_APP_NAME_ENV value 'dab_hosted' and version suffix '_major_minor_patch' to user supplied 'Application Name' property.")] + [DataRow("AccountEndpoint=https://foo:8081/;App=CustAppName;" , "AccountEndpoint=https://foo:8081/;Application Name=CustAppName," , true , DisplayName = "[COSMOSDB]: DAB appends version string 'dab_hosted' and version suffix '_major_minor_patch' to user supplied 'App' property and resolves property to 'Application Name'.")] + #pragma warning restore format + public void CosmosDbConnStringSupplementedWithAppNameProperty( + string configProvidedConnString, + string expectedDabModifiedConnString, + bool dabEnvOverride) { - ODataParser parser = new(); - try + // Explicitly set the DAB_APP_NAME_ENV to null to ensure that the DAB_APP_NAME_ENV is not set. + if (dabEnvOverride) { - // FilterParser has no model so we expect exception - parser.GetFilterClause(filterQueryString: string.Empty, resourcePath: string.Empty); - Assert.Fail(); + Environment.SetEnvironmentVariable(ProductInfo.DAB_APP_NAME_ENV, "dab_hosted"); } - catch (DataApiBuilderException exception) + else { - Assert.AreEqual("The runtime has not been initialized with an Edm model.", exception.Message); - Assert.AreEqual(HttpStatusCode.InternalServerError, exception.StatusCode); - Assert.AreEqual(DataApiBuilderException.SubStatusCodes.UnexpectedError, exception.SubStatusCode); + Environment.SetEnvironmentVariable(ProductInfo.DAB_APP_NAME_ENV, null); } + + // Resolve assembly version. Not possible to do in DataRow as DataRows expect compile-time constants. + string resolvedAssemblyVersion = ProductInfo.GetDataApiBuilderUserAgent(); + expectedDabModifiedConnString += resolvedAssemblyVersion; + + RuntimeConfig runtimeConfig = CreateBasicRuntimeConfigWithNoEntity(DatabaseType.CosmosDB_NoSQL, configProvidedConnString); + + // Act + bool configParsed = RuntimeConfigLoader.TryParseConfig( + json: runtimeConfig.ToJson(), + config: out RuntimeConfig updatedRuntimeConfig, + replacementSettings: new(doReplaceEnvVar: true)); + + // Assert + Assert.AreEqual( + expected: true, + actual: configParsed, + message: "Runtime config unexpectedly failed parsing."); + Assert.AreEqual( + expected: expectedDabModifiedConnString, + actual: updatedRuntimeConfig.DataSource.ConnectionString, + message: "DAB did not properly set the 'Application Name' connection string property."); } /// @@ -1812,1812 +1550,773 @@ public async Task TestSqlMetadataValidationForEntitiesWithInvalidSource() } /// - /// This test method validates a sample DAB runtime config file against DAB's JSON schema definition. - /// It asserts that the validation is successful and there are no validation failures. - /// It also verifies that the expected log message is logged. + /// Tests that DAB supplements the CosmosDB database connection strings with the property "ApplicationName" and + /// 1. Adds the property/value "Application Name=dab_oss_Major.Minor.Patch" when the env var DAB_APP_NAME_ENV is not set. + /// 2. Adds the property/value "Application Name=dab_hosted_Major.Minor.Patch" when the env var DAB_APP_NAME_ENV is set to "dab_hosted". + /// (DAB_APP_NAME_ENV is set in hosted scenario or when user sets the value.) + /// NOTE: "#pragma warning disable format" is used here to avoid removing intentional, readability promoting spacing in DataRow display names. /// - [TestMethod("Validates the config file schema."), TestCategory(TestCategory.MSSQL)] - public void TestConfigSchemaIsValid() + /// connection string provided in the config. + /// Updated connection string with Application Name. + /// Whether DAB_APP_NAME_ENV is set in environment. (Always present in hosted scenario or if user supplies value.) + public void CosmosDbConnStringSupplementedWithAppNameProperty( + string configProvidedConnString, + string expectedDabModifiedConnString, + bool dabEnvOverride) { - TestHelper.SetupDatabaseEnvironment(MSSQL_ENVIRONMENT); - FileSystemRuntimeConfigLoader configLoader = TestHelper.GetRuntimeConfigLoader(); + // Explicitly set the DAB_APP_NAME_ENV to null to ensure that the DAB_APP_NAME_ENV is not set. + if (dabEnvOverride) + { + Environment.SetEnvironmentVariable(ProductInfo.DAB_APP_NAME_ENV, "dab_hosted"); + } + else + { + Environment.SetEnvironmentVariable(ProductInfo.DAB_APP_NAME_ENV, null); + } - Mock> schemaValidatorLogger = new(); + // Resolve assembly version. Not possible to do in DataRow as DataRows expect compile-time constants. + string resolvedAssemblyVersion = ProductInfo.GetDataApiBuilderUserAgent(); + expectedDabModifiedConnString += resolvedAssemblyVersion; - string jsonSchema = File.ReadAllText("dab.draft.schema.json"); - string jsonData = File.ReadAllText(configLoader.ConfigFilePath); + RuntimeConfig runtimeConfig = CreateBasicRuntimeConfigWithNoEntity(DatabaseType.CosmosDB_NoSQL, configProvidedConnString); - JsonConfigSchemaValidator jsonSchemaValidator = new(schemaValidatorLogger.Object, new MockFileSystem()); + // Act + bool configParsed = RuntimeConfigLoader.TryParseConfig( + json: runtimeConfig.ToJson(), + config: out RuntimeConfig updatedRuntimeConfig, + replacementSettings: new(doReplaceEnvVar: true)); - JsonSchemaValidationResult result = jsonSchemaValidator.ValidateJsonConfigWithSchema(jsonSchema, jsonData); - Assert.IsTrue(result.IsValid); - Assert.IsTrue(EnumerableUtilities.IsNullOrEmpty(result.ValidationErrors)); - schemaValidatorLogger.Verify( - x => x.Log( - LogLevel.Information, - It.IsAny(), - It.Is((o, t) => o.ToString()!.Contains($"The config satisfies the schema requirements.")), - It.IsAny(), - (Func)It.IsAny()), - Times.Once); + // Assert + Assert.AreEqual( + expected: true, + actual: configParsed, + message: "Runtime config unexpectedly failed parsing."); + Assert.AreEqual( + expected: expectedDabModifiedConnString, + actual: updatedRuntimeConfig.DataSource.ConnectionString, + message: "DAB did not properly set the 'Application Name' connection string property."); } /// - /// This test method validates a sample DAB runtime config file against DAB's JSON schema definition. - /// It asserts that the validation is successful and there are no validation failures when no optional fields are used. - /// It also verifies that the expected log message is logged. + /// Invalid properties: + /// `data-source-file` instead of `data-source-files` + /// `GraphQL` instead of `graphql` in the global runtime section. + /// `rst` instead of `rest` in the entity section. /// - [DataTestMethod] - [DataRow(CONFIG_FILE_WITH_NO_OPTIONAL_FIELD, DisplayName = "Validates schema of the config file with no optional fields.")] - [DataRow(CONFIG_FILE_WITH_NO_AUTHENTICATION_FIELD, DisplayName = "Validates schema of the config file with no Authentication field.")] - [DataRow(CONFIG_FILE_WITH_NO_CORS_FIELD, DisplayName = "Validates schema of the config file with no Cors field.")] - public void TestBasicConfigSchemaWithNoOptionalFieldsIsValid(string jsonData) + public const string CONFIG_WITH_INVALID_SCHEMA = @" { - Mock> schemaValidatorLogger = new(); - - string jsonSchema = File.ReadAllText("dab.draft.schema.json"); - - JsonConfigSchemaValidator jsonSchemaValidator = new(schemaValidatorLogger.Object, new MockFileSystem()); - - JsonSchemaValidationResult result = jsonSchemaValidator.ValidateJsonConfigWithSchema(jsonSchema, jsonData); - Assert.AreEqual("", String.Join('\n', result.ValidationErrors?.Select(s => $"{s.Message} at {s.Path} {s.LineNumber} {s.LinePosition}") ?? []), "Expected no validation errors."); - Assert.IsTrue(EnumerableUtilities.IsNullOrEmpty(result.ValidationErrors)); - - Assert.IsTrue(result.IsValid); - schemaValidatorLogger.Verify( - x => x.Log( - LogLevel.Information, - It.IsAny(), - It.Is((o, t) => o.ToString()!.Contains($"The config satisfies the schema requirements.")), - It.IsAny(), - (Func)It.IsAny()), - Times.Once); - } - - [DataTestMethod] - [DataRow("true", DisplayName = "Validates variable boolean schema for true value")] - [DataRow("false", DisplayName = "Validates variable boolean schema for false value.")] - [DataRow("\"true\"", DisplayName = "Validates variable boolean schema for true as string.")] - [DataRow("\"false\"", DisplayName = "Validates variable boolean schema for false as string.")] - [DataRow("\"1\"", DisplayName = "Validates variable boolean schema for 1 as string.")] - [DataRow("\"0\"", DisplayName = "Validates variable boolean schema for 0as string.")] - [DataRow("\"@env('SAMPLE')\"", DisplayName = "Validates variable boolean schema for environment variables.")] - [DataRow("\"@akv('SAMPLE')\"", DisplayName = "Validates variable boolean schema for keyvaul variables.")] - public void TestBasicConfigSchemaWithFlexibleBoolean(string Value) - { - Mock> schemaValidatorLogger = new(); + ""data-source"": { + ""database-type"": ""mssql"", + ""connection-string"": ""test-connection-string"" + }, + ""data-source-file"": [], + ""runtime"": { + ""rest"": { + ""enabled"": true, + ""path"": ""/api"" + }, + ""Graphql"": { + ""enabled"": true, + ""path"": ""/graphql"", + ""allow-introspection"": true + }, + ""host"": { + ""cors"": { + ""origins"": [ + ""http://localhost:5000"" + ], + ""allow-credentials"": false + }, + ""authentication"": { + ""provider"": ""AppService"" + }, + ""mode"": ""development"" + } + }, + ""entities"": { + ""Publisher"": { + ""source"": { + ""object"": ""publishers"", + ""type"": ""table"" + }, + ""graphql"": { + ""enabled"": true, + ""type"": { + ""singular"": ""Publisher"", + ""plural"": ""Publishers"" + } + }, + ""rst"": { + ""enabled"": true + }, + ""permissions"": [ + { + ""role"": ""anonymous"", + ""actions"": [ + { + ""action"": ""create"" + } + ] + } + ] + } + } + }"; - string jsonSchema = File.ReadAllText("dab.draft.schema.json"); + internal const string GRAPHQL_SCHEMA_WITH_CYCLE_ARRAY = @" +type Character { + id : ID, + name : String, + moons: [Moon], +} - JsonConfigSchemaValidator jsonSchemaValidator = new(schemaValidatorLogger.Object, new MockFileSystem()); +type Planet @model(name:""PlanetAlias"") { + id : ID!, + name : String, + character: Character +} - string jsonData = CONFIG_FILE_WITH_BOOLEAN_AS_ENV.Replace("", Value); - JsonSchemaValidationResult result = jsonSchemaValidator.ValidateJsonConfigWithSchema(jsonSchema, jsonData); - Assert.AreEqual("", String.Join('\n', result.ValidationErrors?.Select(s => $"{s.Message} at {s.Path} {s.LineNumber} {s.LinePosition}") ?? []), "Expected no validation errors."); +type Moon { + id : ID, + name : String, + details : String, + character: Character +} +"; - Assert.IsTrue(EnumerableUtilities.IsNullOrEmpty(result.ValidationErrors), "Validation Erros null of empty"); + internal const string GRAPHQL_SCHEMA_WITH_CYCLE_OBJECT = @" +type Character { + id : ID, + name : String, + moons: Moon, +} - Assert.IsTrue(result.IsValid, "Result should be valid"); - schemaValidatorLogger.Verify( - x => x.Log( - LogLevel.Information, - It.IsAny(), - It.Is((o, t) => o.ToString()!.Contains($"The config satisfies the schema requirements.")), - It.IsAny(), - (Func)It.IsAny()), - Times.Once); - } +type Planet @model(name:""PlanetAlias"") { + id : ID!, + name : String, + character: Character +} - /// - /// This test method validates that the JSON schema validates that only known auth providers can be used. - /// - [DataTestMethod] - [DataRow(CONFIG_FILE_WITH_UNKNOWN_AUTHENTICATION_PROVIDER, DisplayName = "Validates schema of the config file when there is an unknown authentication provider.")] - [DataRow(CONFIG_FILE_WITH_MISSING_JWT_PROPERTY, DisplayName = "Validates schema of the config file a missing JWT property")] - [DataRow(CONFIG_FILE_WITH_MISSING_JWT_CHILD_PROPERTIES, DisplayName = "Validates schema of the config file with missing JWT child properties.")] - [DataRow(CONFIG_FILE_WITH_AUTHENTICATION_PROVIDER_THAT_SHOULD_NOT_HAVE_JWT, DisplayName = "Validates schema of the config file when an auth provider is chosen WITH a JWT property, " - + "even though the JWT property should not exist.")] - public void TestConfigWithInvalidAuthProviders(string jsonData) - { - Mock> schemaValidatorLogger = new(); - - string jsonSchema = File.ReadAllText("dab.draft.schema.json"); - - JsonConfigSchemaValidator jsonSchemaValidator = new(schemaValidatorLogger.Object, new MockFileSystem()); - - JsonSchemaValidationResult result = jsonSchemaValidator.ValidateJsonConfigWithSchema(jsonSchema, jsonData); - Assert.IsFalse(result.IsValid); - Assert.IsFalse(EnumerableUtilities.IsNullOrEmpty(result.ValidationErrors)); - schemaValidatorLogger.Verify( - x => x.Log( - LogLevel.Information, - It.IsAny(), - It.Is((o, t) => o.ToString()!.Contains($"The config satisfies the schema requirements.")), - It.IsAny(), - (Func)It.IsAny()), - Times.Never); - } +type Moon { + id : ID, + name : String, + details : String, + character: Character +} +"; - /// - /// The config file does not contain any entity fields, which is expected to be invalid according to the schema. - /// The test asserts that the validation fails and there are validation errors. - /// It also verifies that the expected error message is logged, indicating that the 'entities' property is required. - [TestMethod] - public void TestBasicConfigSchemaWithNoEntityFieldsIsInvalid() - { - string jsonData = @"{ + public const string CONFIG_FILE_WITH_NO_OPTIONAL_FIELD = @"{ ""$schema"":""https://github.com/Azure/data-api-builder/releases/download/vmajor.minor.patch-alpha/dab.draft.schema.json"", ""data-source"": { ""database-type"": ""mssql"", ""connection-string"": ""sample-conn-string"" - } + }, + ""entities"":{ } }"; - Mock> schemaValidatorLogger = new(); - - string jsonSchema = File.ReadAllText("dab.draft.schema.json"); - - JsonConfigSchemaValidator jsonSchemaValidator = new(schemaValidatorLogger.Object, new MockFileSystem()); - - JsonSchemaValidationResult result = jsonSchemaValidator.ValidateJsonConfigWithSchema(jsonSchema, jsonData); - Assert.IsFalse(result.IsValid); - Assert.IsFalse(EnumerableUtilities.IsNullOrEmpty(result.ValidationErrors)); - Assert.AreEqual(1, result.ErrorCount); - Assert.IsTrue(result.ErrorMessage.Contains("Total schema validation errors: 1\n> Required properties are missing from object: entities.")); - } - - /// - /// Validates that the JSON schema correctly validates entity cache configuration properties. - /// Tests both valid configurations (proper level values, ttl >= 1) and invalid configurations - /// (invalid level values, ttl = 0). - /// - [DataTestMethod] - [DataRow("L1", 10, true, DisplayName = "Valid cache config with L1 and ttl=10")] - [DataRow("L1L2", 1, true, DisplayName = "Valid cache config with L1L2 and minimum ttl=1")] - [DataRow("L1L2", 3600, true, DisplayName = "Valid cache config with L1L2 and ttl=3600")] - [DataRow("L3", 10, false, DisplayName = "Invalid cache config with invalid level L3")] - [DataRow("L1", 0, false, DisplayName = "Invalid cache config with ttl=0 (below minimum)")] - [DataRow("L1L2", -1, false, DisplayName = "Invalid cache config with negative ttl")] - public void TestEntityCacheSchemaValidation(string level, int ttlSeconds, bool shouldBeValid) - { - string jsonData = $@"{{ - ""$schema"": ""https://github.com/Azure/data-api-builder/releases/download/vmajor.minor.patch/dab.draft.schema.json"", - ""data-source"": {{ - ""database-type"": ""mssql"", - ""connection-string"": ""Server=test;Database=test;"" - }}, - ""entities"": {{ - ""Book"": {{ - ""source"": {{ - ""object"": ""books"", - ""type"": ""table"" - }}, - ""permissions"": [{{ - ""role"": ""anonymous"", - ""actions"": [""read""] - }}], - ""cache"": {{ - ""enabled"": true, - ""ttl-seconds"": {ttlSeconds}, - ""level"": ""{level}"" - }} - }} - }} - }}"; - - Mock> schemaValidatorLogger = new(); - string jsonSchema = File.ReadAllText("dab.draft.schema.json"); - JsonConfigSchemaValidator jsonSchemaValidator = new(schemaValidatorLogger.Object, new MockFileSystem()); - - JsonSchemaValidationResult result = jsonSchemaValidator.ValidateJsonConfigWithSchema(jsonSchema, jsonData); - - if (shouldBeValid) - { - Assert.IsTrue(result.IsValid, $"Expected valid config but got errors: {result.ErrorMessage}"); - Assert.IsTrue(EnumerableUtilities.IsNullOrEmpty(result.ValidationErrors)); - } - else - { - Assert.IsFalse(result.IsValid, "Expected validation to fail but it passed"); - Assert.IsFalse(EnumerableUtilities.IsNullOrEmpty(result.ValidationErrors)); - } - } - - /// - /// This test tries to validate a runtime config file that is not compliant with the runtime config JSON schema. - /// It validates no additional properties are defined in the config file. - /// The config file used here contains `data-source-file` instead of `data-source-files`, - /// and `graphql` property in runtime is written as `GraphQL` in the Global runtime section. - /// It also contains an entity where `rest` property is written as `rst`. - /// - [TestMethod("Validates the invalid config file schema."), TestCategory(TestCategory.MSSQL)] - public void TestConfigSchemaIsInvalid() - { - Mock> schemaValidatorLogger = new(); + public const string CONFIG_FILE_WITH_NO_AUTHENTICATION_FIELD = @"{ + // Link for latest draft schema. + ""$schema"":""https://github.com/Azure/data-api-builder/releases/download/vmajor.minor.patch-alpha/dab.draft.schema.json"", + ""data-source"": { + ""database-type"": ""mssql"", + ""connection-string"": ""sample-conn-string"" + }, + ""runtime"": { + ""rest"": { + ""enabled"": true, + ""path"": ""/api"" + }, + ""graphql"": { + ""enabled"": true, + ""path"": ""/graphql"", + ""allow-introspection"": true + }, + ""host"": { + ""cors"": { + ""origins"": [ + ""http://localhost:5000"" + ], + ""allow-credentials"": false + } + } + }, + ""entities"":{ } + }"; - string jsonSchema = File.ReadAllText("dab.draft.schema.json"); + public const string CONFIG_FILE_WITH_UNKNOWN_AUTHENTICATION_PROVIDER = @"{ + // Link for latest draft schema. + ""$schema"":""https://github.com/Azure/data-api-builder/releases/download/vmajor.minor.patch-alpha/dab.draft.schema.json"", + ""data-source"": { + ""database-type"": ""mssql"", + ""connection-string"": ""sample-conn-string"" + }, + ""runtime"": { + ""rest"": { + ""enabled"": true, + ""path"": ""/api"" + }, + ""graphql"": { + ""enabled"": true, + ""path"": ""/graphql"", + ""allow-introspection"": true + }, + ""host"": { + ""cors"": { + ""origins"": [ + ""http://localhost:5000"" + ], + ""allow-credentials"": false + }, + ""authentication"": { + ""provider"": ""UnknownProvider"" + } + } + }, + ""entities"":{ } + }"; - JsonConfigSchemaValidator jsonSchemaValidator = new(schemaValidatorLogger.Object, new MockFileSystem()); - JsonSchemaValidationResult result = jsonSchemaValidator.ValidateJsonConfigWithSchema(jsonSchema, CONFIG_WITH_INVALID_SCHEMA); - Assert.IsFalse(result.IsValid); - Assert.AreEqual(3, result.ValidationErrors.Count); + public const string CONFIG_FILE_WITH_MISSING_JWT_PROPERTY = @"{ + // Link for latest draft schema. + ""$schema"":""https://github.com/Azure/data-api-builder/releases/download/vmajor.minor.patch-alpha/dab.draft.schema.json"", + ""data-source"": { + ""database-type"": ""mssql"", + ""connection-string"": ""sample-conn-string"" + }, + ""runtime"": { + ""rest"": { + ""enabled"": true, + ""path"": ""/api"" + }, + ""graphql"": { + ""enabled"": true, + ""path"": ""/graphql"", + ""allow-introspection"": true + }, + ""host"": { + ""cors"": { + ""origins"": [ + ""http://localhost:5000"" + ], + ""allow-credentials"": false + }, + ""authentication"": { + ""provider"": ""EntraID"" + } + } + }, + ""entities"":{ } + }"; - string errorMessage = result.ErrorMessage; - Assert.IsTrue(errorMessage.Contains("Total schema validation errors: 3")); - Assert.IsTrue(errorMessage.Contains("Property 'data-source-file' has not been defined and the schema does not allow additional properties. at 7:31")); - Assert.IsTrue(errorMessage.Contains("Property 'Graphql' has not been defined and the schema does not allow additional properties. at 13:26")); - Assert.IsTrue(errorMessage.Contains("Property 'rst' has not been defined and the schema does not allow additional properties. at 44:26")); - } + public const string CONFIG_FILE_WITH_MISSING_JWT_CHILD_PROPERTIES = @"{ + // Link for latest draft schema. + ""$schema"":""https://github.com/Azure/data-api-builder/releases/download/vmajor.minor.patch-alpha/dab.draft.schema.json"", + ""data-source"": { + ""database-type"": ""mssql"", + ""connection-string"": ""sample-conn-string"" + }, + ""runtime"": { + ""rest"": { + ""enabled"": true, + ""path"": ""/api"" + }, + ""graphql"": { + ""enabled"": true, + ""path"": ""/graphql"", + ""allow-introspection"": true + }, + ""host"": { + ""cors"": { + ""origins"": [ + ""http://localhost:5000"" + ], + ""allow-credentials"": false + }, + ""authentication"": { + ""provider"": ""EntraID"", + ""jwt"": { } + } + } + }, + ""entities"":{ } + }"; - /// - /// DAB config doesn't support additional properties in it's config. This test validates that - /// a config file with additional properties fails the schema validation but still has no effect on engine startup. - /// - [TestMethod("Validates the config with custom properties works with the engine."), TestCategory(TestCategory.MSSQL)] - public async Task TestEngineCanStartConfigWithCustomProperties() - { - const string CUSTOM_CONFIG = "custom-config.json"; - TestHelper.SetupDatabaseEnvironment(MSSQL_ENVIRONMENT); - FileSystem fileSystem = new(); - FileSystemRuntimeConfigLoader loader = new(fileSystem); - loader.TryLoadKnownConfig(out RuntimeConfig config); - - string customProperty = @" - { - ""description"": ""This is a custom property"" - } - "; - - string combinedJson = TestHelper.AddPropertiesToJson(config.ToJson(), customProperty); - - Mock> schemaValidatorLogger = new(); - - string jsonSchema = File.ReadAllText("dab.draft.schema.json"); - - JsonConfigSchemaValidator jsonSchemaValidator = new(schemaValidatorLogger.Object, new MockFileSystem()); - JsonSchemaValidationResult result = jsonSchemaValidator.ValidateJsonConfigWithSchema(jsonSchema, combinedJson); - Assert.IsFalse(result.IsValid); - Assert.IsTrue(result.ErrorMessage.Contains("Total schema validation errors: 1")); - Assert.IsTrue(result.ErrorMessage.Contains("Property 'description' has not been defined and the schema does not allow additional properties.")); - - File.WriteAllText(CUSTOM_CONFIG, combinedJson); - string[] args = new[] - { - $"--ConfigFileName={CUSTOM_CONFIG}" - }; - - // Non-Hosted Scenario - using (TestServer server = new(Program.CreateWebHostBuilder(args))) - using (HttpClient client = server.CreateClient()) - { - 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) - }; - - HttpResponseMessage graphQLResponse = await client.SendAsync(graphQLRequest); - Assert.AreEqual(HttpStatusCode.OK, graphQLResponse.StatusCode); - - HttpRequestMessage restRequest = new(HttpMethod.Get, "/api/Book"); - HttpResponseMessage restResponse = await client.SendAsync(restRequest); - Assert.AreEqual(HttpStatusCode.OK, restResponse.StatusCode); - } - } - - /// - /// This test checks that the GetJsonSchema method of the JsonConfigSchemaValidator class - /// correctly downloads a JSON schema from a given URL, and that the downloaded schema matches the expected schema. - /// - [TestMethod] - public async Task GetJsonSchema_DownloadsSchemaFromUrl() - { - // Arrange - Mock handlerMock = new(MockBehavior.Strict); - string jsonSchemaContent = "{\"type\": \"object\", \"properties\": {\"property1\": {\"type\": \"string\"}}}"; - handlerMock - .Protected() - .Setup>( - "SendAsync", - ItExpr.IsAny(), - ItExpr.IsAny() - ) - .ReturnsAsync(new HttpResponseMessage() - { - StatusCode = HttpStatusCode.OK, - Content = new StringContent(jsonSchemaContent, Encoding.UTF8, "application/json"), - }) - .Verifiable(); - - HttpClient mockHttpClient = new(handlerMock.Object); - Mock> schemaValidatorLogger = new(); - JsonConfigSchemaValidator jsonConfigSchemaValidator = new(schemaValidatorLogger.Object, new MockFileSystem(), mockHttpClient); - - string url = "http://example.com/schema.json"; - RuntimeConfig runtimeConfig = new( - Schema: url, - DataSource: new(DatabaseType.MSSQL, "connectionString", null), - new RuntimeEntities(new Dictionary()) - ); - - // Act - string receivedJsonSchema = await jsonConfigSchemaValidator.GetJsonSchema(runtimeConfig); - - // Assert - Assert.AreEqual(jsonSchemaContent, receivedJsonSchema); - handlerMock.Protected().Verify( - "SendAsync", - Times.Exactly(1), - ItExpr.Is(req => - req.Method == HttpMethod.Get - && req.RequestUri == new Uri(url)), - ItExpr.IsAny()); - } - - /// - /// This test checks that even when the schema download fails, the GetJsonSchema method - /// fetches the schema from the package succesfully. - /// - [TestMethod] - public async Task GetJsonSchema_DownloadsSchemaFromUrlFailure() - { - // Arrange - Mock handlerMock = new(MockBehavior.Strict); - handlerMock - .Protected() - .Setup>( - "SendAsync", - ItExpr.IsAny(), - ItExpr.IsAny() - ) - .ReturnsAsync(new HttpResponseMessage() - { - StatusCode = HttpStatusCode.InternalServerError, // Simulate a failure - Content = new StringContent("", Encoding.UTF8, "application/json"), - }) - .Verifiable(); - - HttpClient mockHttpClient = new(handlerMock.Object); - Mock> schemaValidatorLogger = new(); - JsonConfigSchemaValidator jsonConfigSchemaValidator = new(schemaValidatorLogger.Object, new MockFileSystem(), mockHttpClient); - - string url = "http://example.com/schema.json"; - RuntimeConfig runtimeConfig = new( - Schema: url, - DataSource: new(DatabaseType.MSSQL, "connectionString", null), - new RuntimeEntities(new Dictionary()) - ); - - // Act - string receivedJsonSchema = await jsonConfigSchemaValidator.GetJsonSchema(runtimeConfig); - - // Assert - Assert.IsFalse(string.IsNullOrEmpty(receivedJsonSchema)); - - // Sanity check to ensure the schema is valid - Assert.IsTrue(receivedJsonSchema.Contains("$schema")); - Assert.IsTrue(receivedJsonSchema.Contains("data-source")); - Assert.IsTrue(receivedJsonSchema.Contains("entities")); - } - - /// - /// Set the connection string to an invalid value and expect the service to be unavailable - /// since without this env var, it would be available - guaranteeing this env variable - /// has highest precedence irrespective of what the connection string is in the config file. - /// Verifying the Exception thrown. - /// - [TestMethod($"Validates that environment variable {RUNTIME_ENV_CONNECTION_STRING} has highest precedence."), TestCategory(TestCategory.COSMOSDBNOSQL)] - public void TestConnectionStringEnvVarHasHighestPrecedence() - { - Environment.SetEnvironmentVariable(ASP_NET_CORE_ENVIRONMENT_VAR_NAME, COSMOS_ENVIRONMENT); - Environment.SetEnvironmentVariable( - RUNTIME_ENV_CONNECTION_STRING, - "Invalid Connection String"); + public const string CONFIG_FILE_WITH_AUTHENTICATION_PROVIDER_THAT_SHOULD_NOT_HAVE_JWT = @"{ + // Link for latest draft schema. + ""$schema"":""https://github.com/Azure/data-api-builder/releases/download/vmajor.minor.patch-alpha/dab.draft.schema.json"", + ""data-source"": { + ""database-type"": ""mssql"", + ""connection-string"": ""sample-conn-string"" + }, + ""runtime"": { + ""rest"": { + ""enabled"": true, + ""path"": ""/api"" + }, + ""graphql"": { + ""enabled"": true, + ""path"": ""/graphql"", + ""allow-introspection"": true + }, + ""host"": { + ""cors"": { + ""origins"": [ + ""http://localhost:5000"" + ], + ""allow-credentials"": false + }, + ""authentication"": { + ""provider"": ""Simulator"", + ""jwt"": { ""audience"": ""https://example.com"", ""issuer"": ""https://example.com"" } + } + } + }, + ""entities"":{ } + }"; + public const string CONFIG_FILE_WITH_NO_CORS_FIELD = @"{ + // Link for latest draft schema. + ""$schema"":""https://github.com/Azure/data-api-builder/releases/download/vmajor.minor.patch-alpha/dab.draft.schema.json"", + ""data-source"": { + ""database-type"": ""mssql"", + ""connection-string"": ""sample-conn-string"" + }, + ""runtime"": { + ""rest"": { + ""enabled"": true, + ""path"": ""/api"" + }, + ""graphql"": { + ""enabled"": true, + ""path"": ""/graphql"", + ""allow-introspection"": true + }, + ""host"": { + ""authentication"": { + ""provider"": ""AppService"" + } + } + }, + ""entities"":{ } + }"; - try - { - TestServer server = new(Program.CreateWebHostBuilder(Array.Empty())); - _ = server.Services.GetService(typeof(CosmosClientProvider)) as CosmosClientProvider; - Assert.Fail($"{RUNTIME_ENV_CONNECTION_STRING} is not given highest precedence"); - } - catch (Exception e) - { - Assert.AreEqual(typeof(ArgumentException), e.GetType()); - Assert.AreEqual( - $"Format of the initialization string does not conform to specification starting at index 0.", - e.Message); - } - } + public const string CONFIG_FILE_WITH_BOOLEAN_AS_ENV = @"{ + // Link for latest draft schema. + ""$schema"":""https://github.com/Azure/data-api-builder/releases/download/vmajor.minor.patch-alpha/dab.draft.schema.json"", + ""data-source"": { + ""database-type"": ""mssql"", + ""connection-string"": ""sample-conn-string"", + ""health"": { + ""enabled"": + } + }, + ""runtime"": { + ""health"": { + ""enabled"": + }, + ""rest"": { + ""enabled"": , + ""path"": ""/api"" + }, + ""graphql"": { + ""enabled"": , + ""path"": ""/graphql"", + ""allow-introspection"": true + }, + ""host"": { + ""authentication"": { + ""provider"": ""AppService"" + } + }, + ""telemetry"": { + ""application-insights"":{ + ""enabled"": , + ""connection-string"":""sample-ai-connection-string"" + } - /// - /// Test to verify the precedence logic for config file based on Environment variables. - /// - [DataTestMethod] - [DataRow("HostTest", "Test", false, $"{CONFIGFILE_NAME}.Test{CONFIG_EXTENSION}", DisplayName = "hosting and dab environment set, without considering overrides.")] - [DataRow("HostTest", "", false, $"{CONFIGFILE_NAME}.HostTest{CONFIG_EXTENSION}", DisplayName = "only hosting environment set, without considering overrides.")] - [DataRow("", "Test1", false, $"{CONFIGFILE_NAME}.Test1{CONFIG_EXTENSION}", DisplayName = "only dab environment set, without considering overrides.")] - [DataRow("", "Test2", true, $"{CONFIGFILE_NAME}.Test2.overrides{CONFIG_EXTENSION}", DisplayName = "only dab environment set, considering overrides.")] - [DataRow("HostTest1", "", true, $"{CONFIGFILE_NAME}.HostTest1.overrides{CONFIG_EXTENSION}", DisplayName = "only hosting environment set, considering overrides.")] - public void TestGetConfigFileNameForEnvironment( - string hostingEnvironmentValue, - string environmentValue, - bool considerOverrides, - string expectedRuntimeConfigFile) - { - MockFileSystem fileSystem = new(); - fileSystem.AddFile(expectedRuntimeConfigFile, new MockFileData(string.Empty)); - FileSystemRuntimeConfigLoader runtimeConfigLoader = new(fileSystem); + } - Environment.SetEnvironmentVariable(ASP_NET_CORE_ENVIRONMENT_VAR_NAME, hostingEnvironmentValue); - Environment.SetEnvironmentVariable(RUNTIME_ENVIRONMENT_VAR_NAME, environmentValue); - string actualRuntimeConfigFile = runtimeConfigLoader.GetFileNameForEnvironment(hostingEnvironmentValue, considerOverrides); - Assert.AreEqual(expectedRuntimeConfigFile, actualRuntimeConfigFile); - } + }, + ""entities"":{ } + }"; - /// - /// Test different graphql endpoints in different host modes - /// when accessed interactively via browser. Note that the - /// branding for "Banana Cake Pop" has changed to "Nitro", and - /// we have updated the graphql endpoint test for dev mode to reflect - /// this change, but it may need to be updated again in the future. - /// - /// The endpoint route - /// The mode in which the service is executing. - /// Expected Status Code. - /// The expected phrase in the response body. - [DataTestMethod] - [TestCategory(TestCategory.MSSQL)] - [DataRow("/graphql/", HostMode.Development, HttpStatusCode.OK, "Nitro", - DisplayName = "GraphQL endpoint with no query in development mode.")] - [DataRow("/graphql", HostMode.Production, HttpStatusCode.NotFound, - DisplayName = "GraphQL endpoint with no query in production mode.")] - [DataRow("/graphql/ui", HostMode.Development, HttpStatusCode.NotFound, - DisplayName = "Default BananaCakePop in development mode.")] - [DataRow("/graphql/ui", HostMode.Production, HttpStatusCode.NotFound, - DisplayName = "Default BananaCakePop in production mode.")] - [DataRow("/graphql?query={book_by_pk(id: 1){title}}", - HostMode.Development, HttpStatusCode.OK, - DisplayName = "GraphQL endpoint with query in development mode.")] - [DataRow("/graphql?query={book_by_pk(id: 1){title}}", - HostMode.Production, HttpStatusCode.OK, "data", - DisplayName = "GraphQL endpoint with query in production mode.")] - [DataRow(RestController.REDIRECTED_ROUTE, HostMode.Development, HttpStatusCode.NotFound, "Not Found", - DisplayName = "Redirected endpoint in development mode.")] - [DataRow(RestController.REDIRECTED_ROUTE, HostMode.Production, HttpStatusCode.NotFound, "Not Found", - DisplayName = "Redirected endpoint in production mode.")] - public async Task TestInteractiveGraphQLEndpoints( - string endpoint, - HostMode HostMode, - HttpStatusCode expectedStatusCode, - string expectedContent = "") + [TestCleanup] + public void CleanupAfterEachTest() { - const string CUSTOM_CONFIG = "custom-config.json"; - TestHelper.SetupDatabaseEnvironment(MSSQL_ENVIRONMENT); - FileSystem fileSystem = new(); - FileSystemRuntimeConfigLoader loader = new(fileSystem); - loader.TryLoadKnownConfig(out RuntimeConfig config); - - RuntimeConfig configWithCustomHostMode = config with + if (File.Exists(CUSTOM_CONFIG_FILENAME)) { - Runtime = config.Runtime with + // Retry file deletion to handle cases where a TestServer or file watcher + // from the test hasn't fully released the file handle yet. + int maxRetries = 5; + for (int i = 0; i < maxRetries; i++) { - Host = config.Runtime.Host with { Mode = HostMode } + try + { + File.Delete(CUSTOM_CONFIG_FILENAME); + break; + } + catch (IOException) when (i < maxRetries - 1) + { + Thread.Sleep(200 * (i + 1)); + } } - }; - File.WriteAllText(CUSTOM_CONFIG, configWithCustomHostMode.ToJson()); - string[] args = new[] - { - $"--ConfigFileName={CUSTOM_CONFIG}" - }; - - using TestServer server = new(Program.CreateWebHostBuilder(args)); - using HttpClient client = server.CreateClient(); - { - HttpRequestMessage request = new(HttpMethod.Get, endpoint); - - // Adding the following headers simulates an interactive browser request. - request.Headers.Add("user-agent", BROWSER_USER_AGENT_HEADER); - request.Headers.Add("accept", BROWSER_ACCEPT_HEADER); - - HttpResponseMessage response = await client.SendAsync(request); - Assert.AreEqual(expectedStatusCode, response.StatusCode); - string actualBody = await response.Content.ReadAsStringAsync(); - Assert.IsTrue(actualBody.Contains(expectedContent)); } + + TestHelper.UnsetAllDABEnvironmentVariables(); } /// - /// Tests that the custom path rewriting middleware properly rewrites the - /// first segment of a path (/segment1/.../segmentN) when the segment matches - /// the custom configured GraphQLEndpoint. - /// Note: The GraphQL service is always internally mapped to /graphql + /// When updating config during runtime is possible, then For invalid config the Application continues to + /// accept request with status code of 503. + /// But if invalid config is provided during startup, ApplicationException is thrown + /// and application exits. /// - /// The custom configured GraphQL path in configuration - /// The path used in the web request executed in the test. - /// Expected Http success/error code [DataTestMethod] - [TestCategory(TestCategory.MSSQL)] - [DataRow("/graphql", "/gql", HttpStatusCode.BadRequest, DisplayName = "Request to non-configured graphQL endpoint is handled by REST controller.")] - [DataRow("/graphql", "/graphql", HttpStatusCode.OK, DisplayName = "Request to configured default GraphQL endpoint succeeds, path not rewritten.")] - [DataRow("/gql", "/gql/additionalURLsegment", HttpStatusCode.OK, DisplayName = "GraphQL request path (with extra segments) rewritten to match internally set GraphQL endpoint /graphql.")] - [DataRow("/gql", "/gql", HttpStatusCode.OK, DisplayName = "GraphQL request path rewritten to match internally set GraphQL endpoint /graphql.")] - [DataRow("/gql", "/api/book", HttpStatusCode.NotFound, DisplayName = "Non-GraphQL request's path is not rewritten and is handled by REST controller.")] - [DataRow("/gql", "/graphql", HttpStatusCode.NotFound, DisplayName = "Requests to default/internally set graphQL endpoint fail when configured endpoint differs.")] - public async Task TestPathRewriteMiddlewareForGraphQL( - string graphQLConfiguredPath, - string requestPath, - HttpStatusCode expectedStatusCode) + [DataRow(new string[] { }, true, DisplayName = "No config returns 503 - config file flag absent")] + [DataRow(new string[] { "--ConfigFileName=" }, true, DisplayName = "No config returns 503 - empty config file option")] + [DataRow(new string[] { }, false, DisplayName = "Throws Application exception")] + [TestMethod("Validates that queries before runtime is configured returns a 503 in hosting scenario whereas an application exception when run through CLI")] + public async Task TestNoConfigReturnsServiceUnavailable( + string[] args, + bool isUpdateableRuntimeConfig) { - GraphQLRuntimeOptions graphqlOptions = new(Path: graphQLConfiguredPath); - - DataSource dataSource = new(DatabaseType.MSSQL, - GetConnectionStringFromEnvironmentConfig(environment: TestCategory.MSSQL), - Options: null); - - RuntimeConfig configuration = InitMinimalRuntimeConfig(dataSource, graphqlOptions, new(), new()); - const string CUSTOM_CONFIG = "custom-config.json"; - File.WriteAllText(CUSTOM_CONFIG, configuration.ToJson()); - - string[] args = new[] { $"--ConfigFileName={CUSTOM_CONFIG}" }; + TestServer server; - using TestServer server = new(Program.CreateWebHostBuilder(args)); - using HttpClient client = server.CreateClient(); - string query = @"{ - book_by_pk(id: 1) { - id, - title, - publisher_id - } - }"; - - var payload = new { query }; - - HttpRequestMessage request = new(HttpMethod.Post, requestPath) - { - Content = JsonContent.Create(payload) - }; - - HttpResponseMessage response = await client.SendAsync(request); - string body = await response.Content.ReadAsStringAsync(); - - Assert.AreEqual(expectedStatusCode, response.StatusCode); - } - - /// - /// Validates the error message that is returned for REST requests with incorrect parameter type - /// when the engine is running in Production mode. The error messages in Production mode is - /// very generic to not reveal information about the underlying database objects backing the entity. - /// This test runs against a MsSql database. However, generic error messages will be returned in Production - /// mode when run against PostgreSql and MySql databases. - /// - /// Type of REST request - /// Endpoint for the REST request - /// Right error message that should be shown to the end user - [DataTestMethod] - [TestCategory(TestCategory.MSSQL)] - [DataRow(SupportedHttpVerb.Get, "/api/Book/id/one", null, "Invalid value provided for field: id", DisplayName = "Validates the error message for a GET request with incorrect primary key parameter type on a table in production mode")] - [DataRow(SupportedHttpVerb.Get, "/api/books_view_all/id/one", null, "Invalid value provided for field: id", DisplayName = "Validates the error message for a GET request with incorrect primary key parameter type on a view in production mode")] - [DataRow(SupportedHttpVerb.Get, "/api/GetBook?id=one", REQUEST_BODY_WITH_CORRECT_PARAM_TYPES, "Invalid value provided for field: id", DisplayName = "Validates the error message for a GET request on a stored-procedure with incorrect parameter type in production mode")] - [DataRow(SupportedHttpVerb.Get, "/api/GQLmappings/column1/one", null, "Invalid value provided for field: column1", DisplayName = "Validates the error message for a GET request with incorrect primary key parameter type with alias defined for primary key column on a table in production mode")] - [DataRow(SupportedHttpVerb.Post, "/api/Book", REQUEST_BODY_WITH_INCORRECT_PARAM_TYPES, "Invalid value provided for field: publisher_id", DisplayName = "Validates the error message for a POST request with incorrect parameter type in the request body on a table in production mode")] - [DataRow(SupportedHttpVerb.Put, "/api/Book/id/one", REQUEST_BODY_WITH_CORRECT_PARAM_TYPES, "Invalid value provided for field: id", DisplayName = "Validates the error message for a PUT request with incorrect primary key parameter type on a table in production mode")] - [DataRow(SupportedHttpVerb.Put, "/api/Book/id/1", REQUEST_BODY_WITH_INCORRECT_PARAM_TYPES, "Invalid value provided for field: publisher_id", DisplayName = "Validates the error message for a bad PUT request with incorrect parameter type in the request body on a table in production mode")] - [DataRow(SupportedHttpVerb.Patch, "/api/Book/id/one", REQUEST_BODY_WITH_CORRECT_PARAM_TYPES, "Invalid value provided for field: id", DisplayName = "Validates the error message for a PATCH request with incorrect primary key parameter type on a table in production mode")] - [DataRow(SupportedHttpVerb.Patch, "/api/Book/id/1", REQUEST_BODY_WITH_INCORRECT_PARAM_TYPES, "Invalid value provided for field: publisher_id", DisplayName = "Validates the error message for a PATCH request with incorrect parameter type in the request body on a table in production mode")] - [DataRow(SupportedHttpVerb.Delete, "/api/Book/id/one", REQUEST_BODY_WITH_CORRECT_PARAM_TYPES, "Invalid value provided for field: id", DisplayName = "Validates the error message for a DELETE request with incorrect primary key parameter type on a table in production mode")] - public async Task TestGenericErrorMessageForRestApiInProductionMode( - SupportedHttpVerb requestType, - string requestPath, - string requestBody, - string expectedErrorMessage) - { - const string CUSTOM_CONFIG = "custom-config.json"; - TestHelper.ConstructNewConfigWithSpecifiedHostMode(CUSTOM_CONFIG, HostMode.Production, TestCategory.MSSQL); - string[] args = new[] - { - $"--ConfigFileName={CUSTOM_CONFIG}" - }; - - using (TestServer server = new(Program.CreateWebHostBuilder(args))) - using (HttpClient client = server.CreateClient()) - { - HttpMethod httpMethod = SqlTestHelper.ConvertRestMethodToHttpMethod(requestType); - HttpRequestMessage request; - if (requestType is SupportedHttpVerb.Get || requestType is SupportedHttpVerb.Delete) - { - request = new(httpMethod, requestPath); - } - else - { - request = new(httpMethod, requestPath) - { - Content = JsonContent.Create(requestBody) - }; - } - - HttpResponseMessage response = await client.SendAsync(request); - string body = await response.Content.ReadAsStringAsync(); - Assert.AreEqual(HttpStatusCode.BadRequest, response.StatusCode); - Assert.IsTrue(body.Contains(expectedErrorMessage)); - } - } - - /// - /// Validates the REST HTTP methods that are enabled for Stored Procedures when - /// some of the default fields are absent in the config file. - /// When methods section is not defined explicitly in the config file, only POST - /// method should be enabled for Stored Procedures. - /// - [DataTestMethod] - [TestCategory(TestCategory.MSSQL)] - [DataRow(SP_CONFIG_WITH_NO_REST_SETTINGS, SupportedHttpVerb.Post, "/api/GetBooks", HttpStatusCode.Created, DisplayName = "SP - REST POST enabled when no REST section is present")] - [DataRow(SP_CONFIG_WITH_NO_REST_SETTINGS, SupportedHttpVerb.Get, "/api/GetBooks", HttpStatusCode.MethodNotAllowed, DisplayName = "SP - REST GET disabled when no REST section is present")] - [DataRow(SP_CONFIG_WITH_NO_REST_SETTINGS, SupportedHttpVerb.Patch, "/api/GetBooks", HttpStatusCode.MethodNotAllowed, DisplayName = "SP - REST PATCH disabled when no REST section is present")] - [DataRow(SP_CONFIG_WITH_NO_REST_SETTINGS, SupportedHttpVerb.Put, "/api/GetBooks", HttpStatusCode.MethodNotAllowed, DisplayName = "SP - REST PUT disabled when no REST section is present")] - [DataRow(SP_CONFIG_WITH_NO_REST_SETTINGS, SupportedHttpVerb.Delete, "/api/GetBooks", HttpStatusCode.MethodNotAllowed, DisplayName = "SP - REST DELETE disabled when no REST section is present")] - [DataRow(SP_CONFIG_WITH_ONLY_PATH_IN_REST_SETTINGS, SupportedHttpVerb.Post, "/api/get_books/", HttpStatusCode.Created, DisplayName = "SP - REST POST enabled when only a custom path is defined")] - [DataRow(SP_CONFIG_WITH_ONLY_PATH_IN_REST_SETTINGS, SupportedHttpVerb.Get, "/api/get_books/", HttpStatusCode.MethodNotAllowed, DisplayName = "SP - REST GET disabled when only a custom path is defined")] - [DataRow(SP_CONFIG_WITH_ONLY_PATH_IN_REST_SETTINGS, SupportedHttpVerb.Patch, "/api/get_books/", HttpStatusCode.MethodNotAllowed, DisplayName = "SP - REST PATCH disabled when only a custom path is defined")] - [DataRow(SP_CONFIG_WITH_ONLY_PATH_IN_REST_SETTINGS, SupportedHttpVerb.Delete, "/api/get_books/", HttpStatusCode.MethodNotAllowed, DisplayName = "SP - REST DELETE disabled when only a custom path is defined")] - [DataRow(SP_CONFIG_WITH_ONLY_PATH_IN_REST_SETTINGS, SupportedHttpVerb.Put, "/api/get_books/", HttpStatusCode.MethodNotAllowed, DisplayName = "SP - REST PUT disabled when a custom path is defined")] - [DataRow(SP_CONFIG_WITH_JUST_METHODS_IN_REST_SETTINGS, SupportedHttpVerb.Post, "/api/GetBooks/", HttpStatusCode.MethodNotAllowed, DisplayName = "SP - REST POST disabled by not specifying in the methods section")] - [DataRow(SP_CONFIG_WITH_JUST_METHODS_IN_REST_SETTINGS, SupportedHttpVerb.Get, "/api/GetBooks/", HttpStatusCode.OK, DisplayName = "SP - REST GET enabled by specifying in the methods section")] - [DataRow(SP_CONFIG_WITH_JUST_METHODS_IN_REST_SETTINGS, SupportedHttpVerb.Patch, "/api/GetBooks/", HttpStatusCode.MethodNotAllowed, DisplayName = "SP - REST PATCH disabled by not specifying in the methods section")] - [DataRow(SP_CONFIG_WITH_JUST_METHODS_IN_REST_SETTINGS, SupportedHttpVerb.Put, "/api/GetBooks/", HttpStatusCode.MethodNotAllowed, DisplayName = "SP - REST PUT disabled by not specifying in the methods section")] - [DataRow(SP_CONFIG_WITH_JUST_METHODS_IN_REST_SETTINGS, SupportedHttpVerb.Delete, "/api/GetBooks/", HttpStatusCode.MethodNotAllowed, DisplayName = "SP - REST DELETE disabled by not specifying in the methods section")] - [DataRow(SP_CONFIG_WITH_REST_DISABLED, SupportedHttpVerb.Get, "/api/GetBooks", HttpStatusCode.NotFound, DisplayName = "SP - REST GET disabled by configuring enabled as false")] - [DataRow(SP_CONFIG_WITH_REST_DISABLED, SupportedHttpVerb.Post, "/api/GetBooks", HttpStatusCode.NotFound, DisplayName = "SP - REST POST disabled by configuring enabled as false")] - [DataRow(SP_CONFIG_WITH_REST_DISABLED, SupportedHttpVerb.Patch, "/api/GetBooks", HttpStatusCode.NotFound, DisplayName = "SP - REST PATCH disabled by configuring enabled as false")] - [DataRow(SP_CONFIG_WITH_REST_DISABLED, SupportedHttpVerb.Put, "/api/GetBooks", HttpStatusCode.NotFound, DisplayName = "SP - REST PUT disabled by configuring enabled as false")] - [DataRow(SP_CONFIG_WITH_REST_DISABLED, SupportedHttpVerb.Delete, "/api/GetBooks", HttpStatusCode.NotFound, DisplayName = "SP - REST DELETE disabled by configuring enabled as false")] - [DataRow(SP_CONFIG_WITH_JUST_REST_ENABLED, SupportedHttpVerb.Get, "/api/GetBooks", HttpStatusCode.MethodNotAllowed, DisplayName = "SP - REST GET is disabled when enabled flag is configured to true")] - [DataRow(SP_CONFIG_WITH_JUST_REST_ENABLED, SupportedHttpVerb.Post, "/api/GetBooks", HttpStatusCode.Created, DisplayName = "SP - REST POST is enabled when enabled flag is configured to true")] - [DataRow(SP_CONFIG_WITH_JUST_REST_ENABLED, SupportedHttpVerb.Patch, "/api/GetBooks", HttpStatusCode.MethodNotAllowed, DisplayName = "SP - REST PATCH is disabled when enabled flag is configured to true")] - [DataRow(SP_CONFIG_WITH_JUST_REST_ENABLED, SupportedHttpVerb.Put, "/api/GetBooks", HttpStatusCode.MethodNotAllowed, DisplayName = "SP - REST PUT is disabled when enabled flag is configured to true")] - [DataRow(SP_CONFIG_WITH_JUST_REST_ENABLED, SupportedHttpVerb.Delete, "/api/GetBooks", HttpStatusCode.MethodNotAllowed, DisplayName = "SP - REST DELETE is disabled when enabled flag is configured to true")] - public async Task TestSPRestDefaultsForManuallyConstructedConfigs( - string entityJson, - SupportedHttpVerb requestType, - string requestPath, - HttpStatusCode expectedResponseStatusCode) - { - string configJson = TestHelper.AddPropertiesToJson(TestHelper.BASE_CONFIG, entityJson); - RuntimeConfigLoader.TryParseConfig( - configJson, - out RuntimeConfig deserializedConfig, - replacementSettings: new(), - logger: null, - GetConnectionStringFromEnvironmentConfig(environment: TestCategory.MSSQL)); - string configFileName = "custom-config.json"; - File.WriteAllText(configFileName, deserializedConfig.ToJson()); - string[] args = new[] - { - $"--ConfigFileName={configFileName}" - }; - - using (TestServer server = new(Program.CreateWebHostBuilder(args))) - using (HttpClient client = server.CreateClient()) - { - HttpMethod httpMethod = SqlTestHelper.ConvertRestMethodToHttpMethod(requestType); - HttpRequestMessage request = new(httpMethod, requestPath); - HttpResponseMessage response = await client.SendAsync(request); - Assert.AreEqual(expectedResponseStatusCode, response.StatusCode); - } - } - - /// - /// Validates that deserialization of config file is successful for the following scenarios: - /// 1. Multiple Mutations section is null - /// { - /// "multiple-mutations": null - /// } - /// - /// 2. Multiple Mutations section is empty. - /// { - /// "multiple-mutations": {} - /// } - /// - /// 3. Create field within Multiple Mutation section is null. - /// { - /// "multiple-mutations": { - /// "create": null - /// } - /// } - /// - /// 4. Create field within Multiple Mutation section is empty. - /// { - /// "multiple-mutations": { - /// "create": {} - /// } - /// } - /// - /// For all the above mentioned scenarios, the expected value for MultipleMutationOptions field is null. - /// - /// Base Config Json string. - [DataTestMethod] - [DataRow(TestHelper.BASE_CONFIG_NULL_MULTIPLE_MUTATIONS_FIELD, DisplayName = "MultipleMutationOptions field deserialized as null when multiple mutation section is null")] - [DataRow(TestHelper.BASE_CONFIG_EMPTY_MULTIPLE_MUTATIONS_FIELD, DisplayName = "MultipleMutationOptions field deserialized as null when multiple mutation section is empty")] - [DataRow(TestHelper.BASE_CONFIG_NULL_MULTIPLE_CREATE_FIELD, DisplayName = "MultipleMutationOptions field deserialized as null when create field within multiple mutation section is null")] - [DataRow(TestHelper.BASE_CONFIG_EMPTY_MULTIPLE_CREATE_FIELD, DisplayName = "MultipleMutationOptions field deserialized as null when create field within multiple mutation section is empty")] - public void ValidateDeserializationOfConfigWithNullOrEmptyInvalidMultipleMutationSection(string baseConfig) - { - string configJson = TestHelper.AddPropertiesToJson(baseConfig, BOOK_ENTITY_JSON); - Assert.IsTrue(RuntimeConfigLoader.TryParseConfig(configJson, out RuntimeConfig deserializedConfig)); - Assert.IsNotNull(deserializedConfig.Runtime); - Assert.IsNotNull(deserializedConfig.Runtime.GraphQL); - Assert.IsNull(deserializedConfig.Runtime.GraphQL.MultipleMutationOptions); - } - - /// - /// Sanity check to validate that DAB engine starts successfully when used with a config file without the multiple - /// mutations feature flag section. - /// The runtime graphql section of the config file used looks like this: - /// - /// "graphql": { - /// "path": "/graphql", - /// "allow-introspection": true - /// } - /// - /// Without the multiple mutations feature flag section, DAB engine should be able to - /// 1. Successfully deserialize the config file without multiple mutation section. - /// 2. Process REST and GraphQL API requests. - /// - /// - [TestMethod] - [TestCategory(TestCategory.MSSQL)] - public async Task SanityTestForRestAndGQLRequestsWithoutMultipleMutationFeatureFlagSection() - { - // The configuration file is constructed by merging hard-coded JSON strings to simulate the scenario where users manually edit the - // configuration file (instead of using CLI). - string configJson = TestHelper.AddPropertiesToJson(TestHelper.BASE_CONFIG, BOOK_ENTITY_JSON); - Assert.IsTrue(RuntimeConfigLoader.TryParseConfig( - configJson, - out RuntimeConfig deserializedConfig, - replacementSettings: new(), - logger: null, - GetConnectionStringFromEnvironmentConfig(environment: TestCategory.MSSQL))); - string configFileName = "custom-config.json"; - File.WriteAllText(configFileName, deserializedConfig.ToJson()); - string[] args = new[] - { - $"--ConfigFileName={configFileName}" - }; - - using (TestServer server = new(Program.CreateWebHostBuilder(args))) - using (HttpClient client = server.CreateClient()) - { - try - { - - // Perform a REST GET API request to validate that REST GET API requests are executed correctly. - HttpRequestMessage restRequest = new(HttpMethod.Get, "api/Book"); - HttpResponseMessage restResponse = await client.SendAsync(restRequest); - Assert.AreEqual(HttpStatusCode.OK, restResponse.StatusCode); - - // Perform a GraphQL API request to validate that DAB engine executes GraphQL requests successfully. - 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) - }; - - HttpResponseMessage graphQLResponse = await client.SendAsync(graphQLRequest); - Assert.AreEqual(HttpStatusCode.OK, graphQLResponse.StatusCode); - Assert.IsNotNull(graphQLResponse.Content); - string body = await graphQLResponse.Content.ReadAsStringAsync(); - Assert.IsFalse(body.Contains("errors")); - } - catch (Exception ex) - { - Assert.Fail($"Unexpected exception : {ex}"); - } - } - } - - /// - /// Test to validate that when an entity which will return a paginated response is queried, and a custom runtime base route is configured in the runtime configuration, - /// then the generated nextLink in the response would contain the rest base-route just before the rest path. For the subsequent query, the rest base-route will be trimmed - /// by the upstream before the request lands at DAB. - /// - [TestMethod] - [TestCategory(TestCategory.MSSQL)] - public async Task TestRuntimeBaseRouteInNextLinkForPaginatedRestResponse() - { - const string CUSTOM_CONFIG = "custom-config.json"; - string runtimeBaseRoute = "/base-route"; - TestHelper.ConstructNewConfigWithSpecifiedHostMode(CUSTOM_CONFIG, HostMode.Production, TestCategory.MSSQL, runtimeBaseRoute: runtimeBaseRoute, "StaticWebApps"); - string[] args = new[] - { - $"--ConfigFileName={CUSTOM_CONFIG}" - }; - - using (TestServer server = new(Program.CreateWebHostBuilder(args))) - using (HttpClient client = server.CreateClient()) - { - string requestPath = "/api/MappedBookmarks"; - HttpMethod httpMethod = SqlTestHelper.ConvertRestMethodToHttpMethod(SupportedHttpVerb.Get); - HttpRequestMessage request = new(httpMethod, requestPath); - - HttpResponseMessage response = await client.SendAsync(request); - string responseBody = await response.Content.ReadAsStringAsync(); - Assert.IsTrue(response.StatusCode is HttpStatusCode.OK); - - JsonElement responseElement = JsonSerializer.Deserialize(responseBody); - JsonElement responseValue = responseElement.GetProperty(SqlTestHelper.jsonResultTopLevelKey); - string nextLink = responseElement.GetProperty("nextLink").ToString(); - - // Assert that we got an array response with length equal to the maximum allowed records in a paginated response. - Assert.AreEqual(JsonValueKind.Array, responseValue.ValueKind); - Assert.AreEqual(100, responseValue.GetArrayLength()); - - // Assert that the nextLink contains the rest base-route just before the request path. - StringAssert.Contains(nextLink, runtimeBaseRoute + requestPath); - } - } - - /// - /// Tests that the when Rest or GraphQL is disabled Globally, - /// any requests made will get a 404 response. - /// - /// The custom configured REST enabled property in configuration. - /// The custom configured GraphQL enabled property in configuration. - /// Expected HTTP status code code for the Rest request - /// Expected HTTP status code code for the GraphQL request - [DataTestMethod] - [TestCategory(TestCategory.MSSQL)] - [DataRow(true, true, true, HttpStatusCode.OK, HttpStatusCode.OK, HttpStatusCode.OK, CONFIGURATION_ENDPOINT, DisplayName = "V1 - Rest, GraphQL, and MCP enabled globally")] - [DataRow(true, true, false, HttpStatusCode.OK, HttpStatusCode.OK, HttpStatusCode.NotFound, CONFIGURATION_ENDPOINT, DisplayName = "V1 - Rest and GraphQL enabled, MCP disabled globally")] - [DataRow(true, false, true, HttpStatusCode.OK, HttpStatusCode.NotFound, HttpStatusCode.OK, CONFIGURATION_ENDPOINT, DisplayName = "V1 - Rest enabled, GraphQL disabled, and MCP enabled globally")] - [DataRow(true, false, false, HttpStatusCode.OK, HttpStatusCode.NotFound, HttpStatusCode.NotFound, CONFIGURATION_ENDPOINT, DisplayName = "V1 - Rest enabled, GraphQL and MCP disabled globally")] - [DataRow(false, true, true, HttpStatusCode.NotFound, HttpStatusCode.OK, HttpStatusCode.OK, CONFIGURATION_ENDPOINT, DisplayName = "V1 - Rest disabled, GraphQL and MCP enabled globally")] - [DataRow(false, true, false, HttpStatusCode.NotFound, HttpStatusCode.OK, HttpStatusCode.NotFound, CONFIGURATION_ENDPOINT, DisplayName = "V1 - Rest disabled, GraphQL enabled, and MCP disabled globally")] - [DataRow(false, false, true, HttpStatusCode.NotFound, HttpStatusCode.NotFound, HttpStatusCode.OK, CONFIGURATION_ENDPOINT, DisplayName = "V1 - Rest and GraphQL disabled, MCP enabled globally")] - [DataRow(true, true, true, HttpStatusCode.OK, HttpStatusCode.OK, HttpStatusCode.OK, CONFIGURATION_ENDPOINT_V2, DisplayName = "V2 - Rest, GraphQL, and MCP enabled globally")] - [DataRow(true, true, false, HttpStatusCode.OK, HttpStatusCode.OK, HttpStatusCode.NotFound, CONFIGURATION_ENDPOINT_V2, DisplayName = "V2 - Rest and GraphQL enabled, MCP disabled globally")] - [DataRow(true, false, true, HttpStatusCode.OK, HttpStatusCode.NotFound, HttpStatusCode.OK, CONFIGURATION_ENDPOINT_V2, DisplayName = "V2 - Rest enabled, GraphQL disabled, and MCP enabled globally")] - [DataRow(true, false, false, HttpStatusCode.OK, HttpStatusCode.NotFound, HttpStatusCode.NotFound, CONFIGURATION_ENDPOINT_V2, DisplayName = "V2 - Rest enabled, GraphQL and MCP disabled globally")] - [DataRow(false, true, true, HttpStatusCode.NotFound, HttpStatusCode.OK, HttpStatusCode.OK, CONFIGURATION_ENDPOINT_V2, DisplayName = "V2 - Rest disabled, GraphQL and MCP enabled globally")] - [DataRow(false, true, false, HttpStatusCode.NotFound, HttpStatusCode.OK, HttpStatusCode.NotFound, CONFIGURATION_ENDPOINT_V2, DisplayName = "V2 - Rest disabled, GraphQL enabled, and MCP disabled globally")] - [DataRow(false, false, true, HttpStatusCode.NotFound, HttpStatusCode.NotFound, HttpStatusCode.OK, CONFIGURATION_ENDPOINT_V2, DisplayName = "V2 - Rest and GraphQL disabled, MCP enabled globally")] - public async Task TestGlobalFlagToEnableRestGraphQLAndMcpForHostedAndNonHostedEnvironment( - bool isRestEnabled, - bool isGraphQLEnabled, - bool isMcpEnabled, - HttpStatusCode expectedStatusCodeForREST, - HttpStatusCode expectedStatusCodeForGraphQL, - HttpStatusCode expectedStatusCodeForMcp, - string configurationEndpoint) - { - GraphQLRuntimeOptions graphqlOptions = new(Enabled: isGraphQLEnabled); - RestRuntimeOptions restRuntimeOptions = new(Enabled: isRestEnabled); - McpRuntimeOptions mcpRuntimeOptions = new(Enabled: isMcpEnabled); - - DataSource dataSource = new(DatabaseType.MSSQL, - GetConnectionStringFromEnvironmentConfig(environment: TestCategory.MSSQL), Options: null); - - RuntimeConfig configuration = InitMinimalRuntimeConfig(dataSource, graphqlOptions, restRuntimeOptions, mcpRuntimeOptions); - const string CUSTOM_CONFIG = "custom-config.json"; - File.WriteAllText(CUSTOM_CONFIG, configuration.ToJson()); - - string[] args = new[] - { - $"--ConfigFileName={CUSTOM_CONFIG}" - }; - - // Non-Hosted Scenario - using (TestServer server = new(Program.CreateWebHostBuilder(args))) - using (HttpClient client = server.CreateClient()) - { - string query = @"{ - book_by_pk(id: 1) { - id, - title, - publisher_id - } - }"; - - object payload = new { query }; - - // GraphQL request - HttpRequestMessage graphQLRequest = new(HttpMethod.Post, configuration.Runtime.GraphQL.Path) - { - Content = JsonContent.Create(payload) - }; - - HttpResponseMessage graphQLResponse = await client.SendAsync(graphQLRequest); - Assert.AreEqual(expectedStatusCodeForGraphQL, graphQLResponse.StatusCode, "The GraphQL response is different from the expected result."); - - // REST request - HttpRequestMessage restRequest = new(HttpMethod.Get, $"{configuration.Runtime.Rest.Path}/Book"); - HttpResponseMessage restResponse = await client.SendAsync(restRequest); - Assert.AreEqual(expectedStatusCodeForREST, restResponse.StatusCode, "The REST response is different from the expected result."); - - // MCP request - HttpStatusCode mcpResponseCode = await GetMcpResponse(client, configuration.Runtime.Mcp); - Assert.AreEqual(expectedStatusCodeForMcp, mcpResponseCode, "The MCP response is different from the expected result."); - } - - // Hosted Scenario - // Instantiate new server with no runtime config for post-startup configuration hydration tests. - using (TestServer server = new(Program.CreateWebHostFromInMemoryUpdatableConfBuilder(Array.Empty()))) - using (HttpClient client = server.CreateClient()) - { - JsonContent content = GetPostStartupConfigParams(MSSQL_ENVIRONMENT, configuration, configurationEndpoint); - - HttpResponseMessage postResult = await client.PostAsync(configurationEndpoint, content); - Assert.AreEqual(HttpStatusCode.OK, postResult.StatusCode, "The hydration post-response is different from the expected result."); - - HttpStatusCode restResponseCode = await GetRestResponsePostConfigHydration(client, configuration.Runtime.Rest); - Assert.AreEqual(expected: expectedStatusCodeForREST, actual: restResponseCode, "The REST hydration post-response is different from the expected result."); - - HttpStatusCode graphqlResponseCode = await GetGraphQLResponsePostConfigHydration(client, configuration.Runtime.GraphQL); - Assert.AreEqual(expected: expectedStatusCodeForGraphQL, actual: graphqlResponseCode, "The GraphQL hydration post-response is different from the expected result."); - - // TODO: Issue #3012 - Currently DAB is unable to start MCP with the hydration post-response. - // This needs to be fixed before uncommenting the MCP check - // HttpStatusCode mcpResponseCode = await GetMcpResponse(client, configuration.Runtime.Mcp); - // Assert.AreEqual(expected: expectedStatusCodeForMcp, actual: mcpResponseCode, "The MCP hydration post-response is different from the expected result."); - } - } - - /// - /// For mutation operations, both the respective operation(create/update/delete) + read permissions are needed to receive a valid response. - /// In this test, Anonymous role is configured with only create permission. - /// So, a create mutation executed in the context of Anonymous role is expected to result in - /// 1) Creation of a new item in the database - /// 2) An error response containing the error message : "The mutation operation {operation_name} was successful but the current user is unauthorized to view the response due to lack of read permissions" - /// - /// A create mutation operation in the context of Anonymous role is executed and the expected error message is validated. - /// Authenticated role has read permission configured. A pk query is executed in the context of Authenticated role to validate that a new - /// record was created in the database. - /// - [TestMethod] - [TestCategory(TestCategory.MSSQL)] - public async Task ValidateErrorMessageForMutationWithoutReadPermission() - { - GraphQLRuntimeOptions graphqlOptions = new(Enabled: true); - RestRuntimeOptions restRuntimeOptions = new(Enabled: false); - McpRuntimeOptions mcpRuntimeOptions = new(Enabled: false); - - DataSource dataSource = new(DatabaseType.MSSQL, - GetConnectionStringFromEnvironmentConfig(environment: TestCategory.MSSQL), Options: null); - - EntityAction createAction = new( - Action: EntityActionOperation.Create, - Fields: null, - Policy: new()); - - EntityAction readAction = new( - Action: EntityActionOperation.Read, - Fields: null, - Policy: new()); - - EntityAction deleteAction = new( - Action: EntityActionOperation.Delete, - Fields: null, - Policy: new()); - - EntityPermission[] permissions = new[] {new EntityPermission( Role: AuthorizationResolver.ROLE_ANONYMOUS , Actions: new[] { createAction }), - new EntityPermission( Role: AuthorizationResolver.ROLE_AUTHENTICATED , Actions: new[] { readAction, createAction, deleteAction })}; - - Entity entity = new(Source: new("stocks", EntitySourceType.Table, null, null), - Fields: null, - Rest: null, - GraphQL: new(Singular: "Stock", Plural: "Stocks"), - Permissions: permissions, - Relationships: null, - Mappings: null); - - string entityName = "Stock"; - RuntimeConfig configuration = InitMinimalRuntimeConfig(dataSource, graphqlOptions, restRuntimeOptions, mcpRuntimeOptions, entity, entityName); - - const string CUSTOM_CONFIG = "custom-config.json"; - File.WriteAllText(CUSTOM_CONFIG, configuration.ToJson()); - string[] args = new[] - { - $"--ConfigFileName={CUSTOM_CONFIG}" - }; - - string authToken = AuthTestHelper.CreateAppServiceEasyAuthToken(); - using (TestServer server = new(Program.CreateWebHostBuilder(args))) - using (HttpClient client = server.CreateClient()) - { - try - { - // Pre-clean to avoid PK violation if a previous run left the row behind. - string preCleanupDeleteMutation = @" - mutation { - deleteStock(categoryid: 5001, pieceid: 5001) { - categoryid - pieceid - } - }"; - - _ = await GraphQLRequestExecutor.PostGraphQLRequestAsync( - client, - server.Services.GetRequiredService(), - query: preCleanupDeleteMutation, - queryName: "deleteStock", - variables: null, - authToken: authToken, - clientRoleHeader: AuthorizationResolver.ROLE_AUTHENTICATED); - - // A create mutation operation is executed in the context of Anonymous role. The Anonymous role has create action configured but lacks - // read action. As a result, a new record should be created in the database but the mutation operation should return an error message. - string graphQLMutation = @" - mutation { - createStock( - item: { - categoryid: 5001 - pieceid: 5001 - categoryName: ""SciFi"" - piecesAvailable: 100 - piecesRequired: 50 - } - ) { - categoryid - pieceid - } - }"; - - JsonElement mutationResponse = await GraphQLRequestExecutor.PostGraphQLRequestAsync( - client, - server.Services.GetRequiredService(), - query: graphQLMutation, - queryName: "createStock", - variables: null, - authToken: null, - clientRoleHeader: AuthorizationResolver.ROLE_ANONYMOUS - ); - - Assert.IsNotNull(mutationResponse); - Assert.IsTrue(mutationResponse.ToString().Contains("The mutation operation createStock was successful but the current user is unauthorized to view the response due to lack of read permissions")); - - // pk_query is executed in the context of Authenticated role to validate that the create mutation executed in the context of Anonymous role - // resulted in the creation of a new record in the database. - string graphQLQuery = @" - { - stock_by_pk(categoryid: 5001, pieceid: 5001) { - categoryid - pieceid - categoryName - } - }"; - string queryName = "stock_by_pk"; - - ValidateMutationSucceededAtDbLayer(server, client, graphQLQuery, queryName, authToken, AuthorizationResolver.ROLE_AUTHENTICATED); - } - finally - { - // Clean-up steps. The record created by the create mutation operation is deleted to reset the database - // back to its original state. - string deleteMutation = @" - mutation { - deleteStock(categoryid: 5001, pieceid: 5001) { - categoryid - pieceid - } - }"; - - _ = await GraphQLRequestExecutor.PostGraphQLRequestAsync( - client, - server.Services.GetRequiredService(), - query: deleteMutation, - queryName: "deleteStock", - variables: null, - authToken: authToken, - clientRoleHeader: AuthorizationResolver.ROLE_AUTHENTICATED); - } - } - } - - /// - /// Multiple mutation operations are disabled through the configuration properties. - /// - /// Test to validate that when multiple-create is disabled: - /// 1. Including a relationship field in the input for create mutation for an entity returns an exception as when multiple mutations are disabled, - /// we don't add fields for relationships in the input type schema and hence users should not be able to do insertion in the related entities. - /// - /// 2. Excluding all the relationship fields i.e. performing insertion in just the top-level entity executes successfully. - /// - /// 3. Relationship fields are marked as optional fields in the schema when multiple create operation is enabled. However, when multiple create operations - /// are disabled, the relationship fields should continue to be marked as required fields. - /// With multiple create operation disabled, executing a create mutation operation without a relationship field ("publisher_id" in createbook mutation operation) should be caught by - /// HotChocolate since it is a required field. - /// - [TestMethod] - [TestCategory(TestCategory.MSSQL)] - public async Task ValidateMultipleCreateAndCreateMutationWhenMultipleCreateOperationIsDisabled() - { - // Generate a custom config file with multiple create operation disabled. - RuntimeConfig runtimeConfig = InitialzieRuntimeConfigForMultipleCreateTests(isMultipleCreateOperationEnabled: false); - - const string CUSTOM_CONFIG = "custom-config.json"; - - File.WriteAllText(CUSTOM_CONFIG, runtimeConfig.ToJson()); - string[] args = new[] - { - $"--ConfigFileName={CUSTOM_CONFIG}" - }; - - using (TestServer server = new(Program.CreateWebHostBuilder(args))) - using (HttpClient client = server.CreateClient()) - { - // When multiple create operation is disabled, fields belonging to related entities are not generated for the input type objects of create operation. - // Executing a create mutation with fields belonging to related entities should be caught by Hotchocolate as unrecognized fields. - string pointMultipleCreateOperation = @"mutation createbook{ - createbook(item: { title: ""Book #1"", publishers: { name: ""The First Publisher"" } }) { - id - title - } - }"; - - JsonElement mutationResponse = await GraphQLRequestExecutor.PostGraphQLRequestAsync(client, - server.Services.GetRequiredService(), - query: pointMultipleCreateOperation, - queryName: "createbook", - variables: null, - clientRoleHeader: null); - - Assert.IsNotNull(mutationResponse); - - SqlTestHelper.TestForErrorInGraphQLResponse(mutationResponse.ToString(), - message: "The specified input object field `publishers` does not exist.", - path: @"[""createbook""]"); - - // When multiple create operation is enabled, two types of create mutation operations are generated 1) Point create mutation operation 2) Many type create mutation operation. - // When multiple create operation is disabled, only point create mutation operation is generated. - // With multiple create operation disabled, executing a many type multiple create operation should be caught by HotChocolate as the many type mutation operation should not exist in the schema. - string manyTypeMultipleCreateOperation = @"mutation { - createbooks( - items: [ - { title: ""Book #1"", publishers: { name: ""Publisher #1"" } } - { title: ""Book #2"", publisher_id: 1234 } - ] - ) { - items { - id - title - } - } - }"; - - mutationResponse = await GraphQLRequestExecutor.PostGraphQLRequestAsync(client, - server.Services.GetRequiredService(), - query: manyTypeMultipleCreateOperation, - queryName: "createbook", - variables: null, - clientRoleHeader: null); - - Assert.IsNotNull(mutationResponse); - SqlTestHelper.TestForErrorInGraphQLResponse(mutationResponse.ToString(), - message: "The field `createbooks` does not exist on the type `Mutation`."); - - // Sanity test to validate that executing a point create mutation with multiple create operation disabled, - // a) Creates the new item successfully. - // b) Returns the expected response. - string pointCreateOperation = @"mutation createbook{ - createbook(item: { title: ""Book #1"", publisher_id: 1234 }) { - title - publisher_id - } - }"; - - mutationResponse = await GraphQLRequestExecutor.PostGraphQLRequestAsync(client, - server.Services.GetRequiredService(), - query: pointCreateOperation, - queryName: "createbook", - variables: null, - clientRoleHeader: null); - - string expectedResponse = @"{ ""title"":""Book #1"",""publisher_id"":1234}"; - - Assert.IsNotNull(mutationResponse); - SqlTestHelper.PerformTestEqualJsonStrings(expectedResponse, mutationResponse.ToString()); - - // When a create multiple operation is enabled, the "publisher_id" field will be generated as an optional field in the schema. But, when multiple create operation is disabled, - // "publisher_id" should be a required field. - // With multiple create operation disabled, executing a createbook mutation operation without the "publisher_id" field is expected to be caught by HotChocolate - // as the schema should be generated with "publisher_id" as a required field. - string pointCreateOperationWithMissingFields = @"mutation createbook{ - createbook(item: { title: ""Book #1""}) { - title - publisher_id - } - }"; - - mutationResponse = await GraphQLRequestExecutor.PostGraphQLRequestAsync(client, - server.Services.GetRequiredService(), - query: pointCreateOperationWithMissingFields, - queryName: "createbook", - variables: null, - clientRoleHeader: null); - - Assert.IsNotNull(mutationResponse); - SqlTestHelper.TestForErrorInGraphQLResponse(response: mutationResponse.ToString(), - message: "`publisher_id` is a required field and cannot be null."); - } - } - - /// - /// When multiple create operation is enabled, the relationship fields are generated as optional fields in the schema. - /// However, when not providing the relationship field as well the related object in the create mutation request should result in an error from the database layer. - /// - [TestMethod] - [TestCategory(TestCategory.MSSQL)] - public async Task ValidateCreateMutationWithMissingFieldsFailWithMultipleCreateEnabled() - { - // Multiple create operations are enabled. - RuntimeConfig runtimeConfig = InitialzieRuntimeConfigForMultipleCreateTests(isMultipleCreateOperationEnabled: true); - - const string CUSTOM_CONFIG = "custom-config.json"; - - File.WriteAllText(CUSTOM_CONFIG, runtimeConfig.ToJson()); - string[] args = new[] - { - $"--ConfigFileName={CUSTOM_CONFIG}" - }; - - using (TestServer server = new(Program.CreateWebHostBuilder(args))) - using (HttpClient client = server.CreateClient()) - { - - // When a create multiple operation is enabled, the "publisher_id" field will generated as an optional field in the schema. But, when multiple create operation is disabled, - // "publisher_id" should be a required field. - // With multiple create operation disabled, executing a createbook mutation operation without the "publisher_id" field is expected to be caught by HotChocolate - // as the schema should be generated with "publisher_id" as a required field. - string pointCreateOperationWithMissingFields = @"mutation createbook{ - createbook(item: { title: ""Book #1""}) { - title - publisher_id - } - }"; - - JsonElement mutationResponse = await GraphQLRequestExecutor.PostGraphQLRequestAsync(client, - server.Services.GetRequiredService(), - query: pointCreateOperationWithMissingFields, - queryName: "createbook", - variables: null, - clientRoleHeader: null); - - Assert.IsNotNull(mutationResponse); - SqlTestHelper.TestForErrorInGraphQLResponse(response: mutationResponse.ToString(), - message: "Missing value for required column: publisher_id for entity: Book at level: 1."); - } - } - - /// - /// For mutation operations, the respective mutation operation type(create/update/delete) + read permissions are needed to receive a valid response. - /// For graphQL requests, if read permission is configured for Anonymous role, then it is inherited by other roles. - /// In this test, Anonymous role has read permission configured. Authenticated role has only create permission configured. - /// A create mutation operation is executed in the context of Authenticated role and the response is expected to have no errors because - /// the read permission is inherited from Anonymous role. - /// - [TestMethod] - [TestCategory(TestCategory.MSSQL)] - public async Task ValidateInheritanceOfReadPermissionFromAnonymous() - { - GraphQLRuntimeOptions graphqlOptions = new(Enabled: true); - RestRuntimeOptions restRuntimeOptions = new(Enabled: false); - McpRuntimeOptions mcpRuntimeOptions = new(Enabled: false); - - DataSource dataSource = new(DatabaseType.MSSQL, - GetConnectionStringFromEnvironmentConfig(environment: TestCategory.MSSQL), Options: null); - - EntityAction createAction = new( - Action: EntityActionOperation.Create, - Fields: null, - Policy: new()); - - EntityAction readAction = new( - Action: EntityActionOperation.Read, - Fields: null, - Policy: new()); - - EntityAction deleteAction = new( - Action: EntityActionOperation.Delete, - Fields: null, - Policy: new()); - - EntityPermission[] permissions = new[] {new EntityPermission( Role: AuthorizationResolver.ROLE_ANONYMOUS , Actions: new[] { createAction, readAction, deleteAction }), - new EntityPermission( Role: AuthorizationResolver.ROLE_AUTHENTICATED , Actions: new[] { createAction })}; - - Entity entity = new(Source: new("stocks", EntitySourceType.Table, null, null), - Fields: null, - Rest: null, - GraphQL: new(Singular: "Stock", Plural: "Stocks"), - Permissions: permissions, - Relationships: null, - Mappings: null); - - string entityName = "Stock"; - RuntimeConfig configuration = InitMinimalRuntimeConfig(dataSource, graphqlOptions, restRuntimeOptions, mcpRuntimeOptions, entity, entityName); - - 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()) + try { - try - { - // A create mutation operation is executed in the context of Authenticated role and the response is expected to be a valid - // response without any errors. - string graphQLMutation = @" - mutation { - createStock( - item: { - categoryid: 5001 - pieceid: 5001 - categoryName: ""SciFi"" - piecesAvailable: 100 - piecesRequired: 50 - } - ) { - categoryid - pieceid - } - }"; - - JsonElement mutationResponse = await GraphQLRequestExecutor.PostGraphQLRequestAsync( - client, - server.Services.GetRequiredService(), - query: graphQLMutation, - queryName: "createStock", - variables: null, - authToken: AuthTestHelper.CreateAppServiceEasyAuthToken(), - clientRoleHeader: AuthorizationResolver.ROLE_AUTHENTICATED - ); - - Assert.IsNotNull(mutationResponse); - Assert.IsFalse(mutationResponse.TryGetProperty("errors", out _)); + if (isUpdateableRuntimeConfig) + { + server = new(Program.CreateWebHostFromInMemoryUpdatableConfBuilder(args)); } - finally + else { - // Clean-up steps. The record created by the create mutation operation is deleted to reset the database - // back to its original state. - string deleteMutation = @" - mutation { - deleteStock(categoryid: 5001, pieceid: 5001) { - categoryid - pieceid - } - }"; - - _ = await GraphQLRequestExecutor.PostGraphQLRequestAsync( - client, - server.Services.GetRequiredService(), - query: deleteMutation, - queryName: "deleteStock", - variables: null, - clientRoleHeader: null); + server = new(Program.CreateWebHostBuilder(args)); } + + HttpClient httpClient = server.CreateClient(); + HttpResponseMessage result = await httpClient.GetAsync("/graphql"); + Assert.AreEqual(HttpStatusCode.ServiceUnavailable, result.StatusCode); + } + catch (Exception e) + { + Assert.IsFalse(isUpdateableRuntimeConfig); + Assert.AreEqual(typeof(ApplicationException), e.GetType()); + Assert.AreEqual( + $"Could not initialize the engine with the runtime config file: {DEFAULT_CONFIG_FILE_NAME}", + e.Message); } } /// - /// Helper method to validate that the mutation operation succeded at the database layer by executing a graphQL pk query. + /// Verify that https redirection is disabled when --no-https-redirect flag is passed through CLI. + /// We check if IsHttpsRedirectionDisabled is set to true with --no-https-redirect flag. /// - /// Test server created for the test - /// HTTP client - /// GraphQL query/mutation text - /// GraphQL query/mutation name - /// Auth token for the graphQL request - private static async void ValidateMutationSucceededAtDbLayer(TestServer server, HttpClient client, string query, string queryName, string authToken, string clientRoleHeader) + [DataTestMethod] + [DataRow(new string[] { "" }, false, DisplayName = "Https redirection allowed")] + [DataRow(new string[] { Startup.NO_HTTPS_REDIRECT_FLAG }, true, DisplayName = "Http redirection disabled")] + [TestMethod("Validates that https redirection is disabled when --no-https-redirect option is used when engine is started through CLI")] + public void TestDisablingHttpsRedirection( + string[] args, + bool expectedIsHttpsRedirectionDisabled) { - JsonElement queryResponse = await GraphQLRequestExecutor.PostGraphQLRequestAsync( - client, - server.Services.GetRequiredService(), - query: query, - queryName: queryName, - variables: null, - authToken: authToken, - clientRoleHeader: clientRoleHeader); - - Assert.IsNotNull(queryResponse); - Assert.IsFalse(queryResponse.TryGetProperty("errors", out _)); + Program.CreateWebHostBuilder(args).Build(); + Assert.AreEqual(expectedIsHttpsRedirectionDisabled, Program.IsHttpsRedirectionDisabled); } /// - /// Validates the Location header field returned for a POST request when a 201 response is returned. The idea behind returning - /// a Location header is to provide a URL against which a GET request can be performed to fetch the details of the new item. - /// Base Route is not configured in the config file used for this test. If base-route is configured, the Location header URL should contain the base-route. - /// This test performs a POST request, and in the event that it results in a 201 response, it performs a subsequent GET request - /// with the Location header to validate the correctness of the URL. - /// Currently ignored as it is part of the setof flakey tests that are being investigated, see: https://github.com/Azure/data-api-builder/issues/2010 + /// Checks correct serialization and deserialization of Source Type from + /// Enum to String and vice-versa. + /// Consider both cases for source as an object and as a string /// - /// Type of the entity - /// Request path for performing POST API requests on the entity - [Ignore] [DataTestMethod] - [TestCategory(TestCategory.MSSQL)] - [DataRow(EntitySourceType.Table, "/api/Book", DisplayName = "Location Header validation - Table, Base Route not configured")] - [DataRow(EntitySourceType.StoredProcedure, "/api/GetBooks", DisplayName = "Location Header validation - Stored Procedures, Base Route not configured")] - public async Task ValidateLocationHeaderFieldForPostRequests(EntitySourceType entityType, string requestPath) + [DataRow(true, EntitySourceType.StoredProcedure, "stored-procedure", DisplayName = "source is a stored-procedure")] + [DataRow(true, EntitySourceType.Table, "table", DisplayName = "source is a table")] + [DataRow(true, EntitySourceType.View, "view", DisplayName = "source is a view")] + [DataRow(false, null, null, DisplayName = "source is just string")] + public void TestCorrectSerializationOfSourceObject( + bool isDatabaseObjectSource, + EntitySourceType sourceObjectType, + string sourceTypeName) { - - GraphQLRuntimeOptions graphqlOptions = new(Enabled: false); - RestRuntimeOptions restRuntimeOptions = new(Enabled: true); - McpRuntimeOptions mcpRuntimeOptions = new(Enabled: false); - - DataSource dataSource = new(DatabaseType.MSSQL, - GetConnectionStringFromEnvironmentConfig(environment: TestCategory.MSSQL), Options: null); - - RuntimeConfig configuration; - - if (entityType is EntitySourceType.StoredProcedure) + RuntimeConfig runtimeConfig; + if (isDatabaseObjectSource) { - Entity entity = new(Source: new("get_books", EntitySourceType.StoredProcedure, null, null), - Fields: null, - Rest: new(new SupportedHttpVerb[] { SupportedHttpVerb.Get, SupportedHttpVerb.Post }), - GraphQL: null, - Permissions: new[] { GetMinimalPermissionConfig(AuthorizationResolver.ROLE_ANONYMOUS) }, - Relationships: null, - Mappings: null - ); - - string entityName = "GetBooks"; - configuration = InitMinimalRuntimeConfig(dataSource, graphqlOptions, restRuntimeOptions, mcpRuntimeOptions, entity, entityName); + EntitySource entitySource = new( + Type: sourceObjectType, + Object: "sourceName", + Parameters: null, + KeyFields: null + ); + runtimeConfig = AuthorizationHelpers.InitRuntimeConfig( + entityName: "MyEntity", + entitySource: entitySource, + roleName: "Anonymous", + operation: EntityActionOperation.All + ); } else { - configuration = InitMinimalRuntimeConfig(dataSource, graphqlOptions, restRuntimeOptions, mcpRuntimeOptions); + string entitySource = "sourceName"; + runtimeConfig = AuthorizationHelpers.InitRuntimeConfig( + entityName: "MyEntity", + entitySource: entitySource, + roleName: "Anonymous", + operation: EntityActionOperation.All + ); } - const string CUSTOM_CONFIG = "custom-config.json"; - File.WriteAllText(CUSTOM_CONFIG, configuration.ToJson()); - string[] args = new[] - { - $"--ConfigFileName={CUSTOM_CONFIG}" - }; + string runtimeConfigJson = runtimeConfig.ToJson(); - using (TestServer server = new(Program.CreateWebHostBuilder(args))) - using (HttpClient client = server.CreateClient()) + if (isDatabaseObjectSource) { - HttpMethod httpMethod = SqlTestHelper.ConvertRestMethodToHttpMethod(SupportedHttpVerb.Post); - HttpRequestMessage request = new(httpMethod, requestPath); - if (entityType is not EntitySourceType.StoredProcedure) - { - string requestBody = @"{ - ""title"": ""Harry Potter and the Order of Phoenix"", - ""publisher_id"": 1234 - }"; - - JsonElement requestBodyElement = JsonDocument.Parse(requestBody).RootElement.Clone(); - request = new(httpMethod, requestPath) - { - Content = JsonContent.Create(requestBodyElement) - }; - } - - HttpResponseMessage response = await client.SendAsync(request); - - // Location header field is expected only when POST request results in the creation of a new item - Assert.AreEqual(HttpStatusCode.Created, response.StatusCode); + Assert.IsTrue(runtimeConfigJson.Contains(sourceTypeName)); + } - string locationHeader = response.Headers.Location.AbsoluteUri; + Assert.IsTrue(RuntimeConfigLoader.TryParseConfig(runtimeConfigJson, out RuntimeConfig deserializedRuntimeConfig)); - // GET request performed using the Location header should be successful. - HttpRequestMessage followUpRequest = new(HttpMethod.Get, response.Headers.Location); - HttpResponseMessage followUpResponse = await client.SendAsync(followUpRequest); - Assert.AreEqual(HttpStatusCode.OK, followUpResponse.StatusCode); + Assert.IsTrue(deserializedRuntimeConfig.Entities.ContainsKey("MyEntity")); + Assert.AreEqual("sourceName", deserializedRuntimeConfig.Entities["MyEntity"].Source.Object); - // Delete the new record created as part of this test - if (entityType is EntitySourceType.Table) - { - HttpRequestMessage cleanupRequest = new(HttpMethod.Delete, locationHeader); - await client.SendAsync(cleanupRequest); - } + if (isDatabaseObjectSource) + { + Assert.AreEqual(sourceObjectType, deserializedRuntimeConfig.Entities["MyEntity"].Source.Type); + } + else + { + Assert.AreEqual(EntitySourceType.Table, deserializedRuntimeConfig.Entities["MyEntity"].Source.Type); } } /// - /// Validates the Location header field returned for a POST request when it results in a 201 response. The idea behind returning - /// a Location header is to provide a URL against which a GET request can be performed to fetch the details of the new item. - /// Base Route is configured in the config file used for this test. So, it is expected that the Location header returned will contain the base-route. - /// This test performs a POST request, and checks if it results in a 201 response. If so, the test validates the correctness of the Location header in two steps. - /// Since base-route has significance only in the SWA-DAB integrated scenario and this test is executed against DAB running independently, - /// a subsequent GET request against the Location header will result in an error. So, the correctness of the base-route returned is validated with the help of - /// an expected location header value. The correctness of the PK part of the Location string is validated by performing a GET request after stripping off - /// the base-route from the Location URL. + /// Validates that DAB supplements the MSSQL database connection strings with the property "Application Name" and + /// 1. Adds the property/value "Application Name=dab_oss_Major.Minor.Patch" when the env var DAB_APP_NAME_ENV is not set. + /// 2. Adds the property/value "Application Name=dab_hosted_Major.Minor.Patch" when the env var DAB_APP_NAME_ENV is set to "dab_hosted". + /// (DAB_APP_NAME_ENV is set in hosted scenario or when user sets the value.) + /// NOTE: "#pragma warning disable format" is used here to avoid removing intentional, readability promoting spacing in DataRow display names. /// - /// Type of the entity - /// Request path for performing POST API requests on the entity - /// Configured base route - /// Expected value for Location field in the response header. Since, the PK of the new record is not known beforehand, - /// the expectedLocationHeader excludes the PK. Because of this, the actual location header is validated by checking if it starts with the expectedLocationHeader. + /// connection string provided in the config. + /// Updated connection string with Application Name. + /// Whether DAB_APP_NAME_ENV is set in environment. (Always present in hosted scenario or if user supplies value.) + #pragma warning disable format [DataTestMethod] - [TestCategory(TestCategory.MSSQL)] - [DataRow(EntitySourceType.Table, "/api/Book", "/data-api", "http://localhost/data-api/api/Book/id/", DisplayName = "Location Header validation - Table, Base Route configured")] - [DataRow(EntitySourceType.StoredProcedure, "/api/GetBooks", "/data-api", "http://localhost/data-api/api/GetBooks", DisplayName = "Location Header validation - Stored Procedure, Base Route configured")] - public async Task ValidateLocationHeaderWhenBaseRouteIsConfigured( - EntitySourceType entityType, - string requestPath, - string baseRoute, - string expectedLocationHeader) + [DataRow("Data Source=<>;" , "Data Source=<>;Application Name=" , false, DisplayName = "[MSSQL]: DAB adds version 'dab_oss_major_minor_patch' to non-provided connection string property 'Application Name'.")] + [DataRow("Data Source=<>;Application Name=CustAppName;" , "Data Source=<>;Application Name=CustAppName," , false, DisplayName = "[MSSQL]: DAB appends version 'dab_oss_major_minor_patch' to user supplied 'Application Name' property.")] + [DataRow("Data Source=<>;App=CustAppName;" , "Data Source=<>;Application Name=CustAppName," , false, DisplayName = "[MSSQL]: DAB appends version 'dab_oss_major_minor_patch' to user supplied 'App' property and resolves property to 'Application Name'.")] + [DataRow("Data Source=<>;" , "Data Source=<>;Application Name=" , true , DisplayName = "[MSSQL]: DAB adds DAB_APP_NAME_ENV value 'dab_hosted' and version suffix '_major_minor_patch' to non-provided connection string property 'Application Name'.")] + [DataRow("Data Source=<>;Application Name=CustAppName;" , "Data Source=<>;Application Name=CustAppName," , true , DisplayName = "[MSSQL]: DAB appends DAB_APP_NAME_ENV value 'dab_hosted' and version suffix '_major_minor_patch' to user supplied 'Application Name' property.")] + [DataRow("Data Source=<>;App=CustAppName;" , "Data Source=<>;Application Name=CustAppName," , true , DisplayName = "[MSSQL]: DAB appends version string 'dab_hosted' and version suffix '_major_minor_patch' to user supplied 'App' property and resolves property to 'Application Name'.")] + #pragma warning restore format + public void MsSqlConnStringSupplementedWithAppNameProperty( + string configProvidedConnString, + string expectedDabModifiedConnString, + bool dabEnvOverride) { - GraphQLRuntimeOptions graphqlOptions = new(Enabled: false); - RestRuntimeOptions restRuntimeOptions = new(Enabled: true); - McpRuntimeOptions mcpRuntimeOptions = new(Enabled: false); - - DataSource dataSource = new(DatabaseType.MSSQL, - GetConnectionStringFromEnvironmentConfig(environment: TestCategory.MSSQL), Options: null); - - RuntimeConfig configuration; - - if (entityType is EntitySourceType.StoredProcedure) + // Explicitly set the DAB_APP_NAME_ENV to null to ensure that the DAB_APP_NAME_ENV is not set. + if (dabEnvOverride) { - Entity entity = new(Source: new("get_books", EntitySourceType.StoredProcedure, null, null), - Fields: null, - Rest: new(new SupportedHttpVerb[] { SupportedHttpVerb.Get, SupportedHttpVerb.Post }), - GraphQL: null, - Permissions: new[] { GetMinimalPermissionConfig(AuthorizationResolver.ROLE_ANONYMOUS) }, - Relationships: null, - Mappings: null - ); - - string entityName = "GetBooks"; - configuration = InitMinimalRuntimeConfig(dataSource, graphqlOptions, restRuntimeOptions, mcpRuntimeOptions, entity, entityName); + Environment.SetEnvironmentVariable(ProductInfo.DAB_APP_NAME_ENV, "dab_hosted"); } else { - configuration = InitMinimalRuntimeConfig(dataSource, graphqlOptions, restRuntimeOptions, mcpRuntimeOptions); + Environment.SetEnvironmentVariable(ProductInfo.DAB_APP_NAME_ENV, null); } - const string CUSTOM_CONFIG = "custom-config.json"; - - Config.ObjectModel.AuthenticationOptions authenticationOptions = new(Provider: EasyAuthType.StaticWebApps.ToString(), null); - HostOptions staticWebAppsHostOptions = new(null, authenticationOptions); - - RuntimeOptions runtimeOptions = configuration.Runtime; - RuntimeOptions baseRouteEnabledRuntimeOptions = new(runtimeOptions?.Rest, runtimeOptions?.GraphQL, runtimeOptions?.Mcp, staticWebAppsHostOptions, "/data-api"); - RuntimeConfig baseRouteEnabledConfig = configuration with { Runtime = baseRouteEnabledRuntimeOptions }; - File.WriteAllText(CUSTOM_CONFIG, baseRouteEnabledConfig.ToJson()); - - string[] args = new[] - { - $"--ConfigFileName={CUSTOM_CONFIG}" - }; - - using (TestServer server = new(Program.CreateWebHostBuilder(args))) - using (HttpClient client = server.CreateClient()) - { - HttpMethod httpMethod = SqlTestHelper.ConvertRestMethodToHttpMethod(SupportedHttpVerb.Post); - HttpRequestMessage request = new(httpMethod, requestPath); - if (entityType is not EntitySourceType.StoredProcedure) - { - string requestBody = @"{ - ""title"": ""Harry Potter and the Order of Phoenix"", - ""publisher_id"": 1234 - }"; - - JsonElement requestBodyElement = JsonDocument.Parse(requestBody).RootElement.Clone(); - request = new(httpMethod, requestPath) - { - Content = JsonContent.Create(requestBodyElement) - }; - } - - HttpResponseMessage response = await client.SendAsync(request); - Assert.AreEqual(HttpStatusCode.Created, response.StatusCode); - - string locationHeader = response.Headers.Location.AbsoluteUri; - Assert.IsTrue(locationHeader.StartsWith(expectedLocationHeader)); - - // The URL to perform the GET request is constructed by skipping the base-route. - // Base Route field is applicable only in SWA-DAB integrated scenario. When DAB engine is run independently, all the - // APIs are hosted on /api. But, the returned Location header in this test will contain the configured base-route. So, this needs to be - // removed before performing a subsequent GET request. - string path = response.Headers.Location.AbsolutePath; - string completeUrl = path.Substring(baseRoute.Length); + // Resolve assembly version. Not possible to do in DataRow as DataRows expect compile-time constants. + string resolvedAssemblyVersion = ProductInfo.GetDataApiBuilderUserAgent(); + expectedDabModifiedConnString += resolvedAssemblyVersion; - HttpRequestMessage followUpRequest = new(HttpMethod.Get, completeUrl); - HttpResponseMessage followUpResponse = await client.SendAsync(followUpRequest); - Assert.AreEqual(HttpStatusCode.OK, followUpResponse.StatusCode); + RuntimeConfig runtimeConfig = CreateBasicRuntimeConfigWithNoEntity(DatabaseType.MSSQL, configProvidedConnString); - // Delete the new record created as part of this test - if (entityType is EntitySourceType.Table) - { - HttpRequestMessage cleanupRequest = new(HttpMethod.Delete, completeUrl); - await client.SendAsync(cleanupRequest); - } + // Act + bool configParsed = RuntimeConfigLoader.TryParseConfig( + json: runtimeConfig.ToJson(), + config: out RuntimeConfig updatedRuntimeConfig, + replacementSettings: new(doReplaceEnvVar: true)); - } + // Assert + Assert.AreEqual( + expected: true, + actual: configParsed, + message: "Runtime config unexpectedly failed parsing."); + Assert.AreEqual( + expected: expectedDabModifiedConnString, + actual: updatedRuntimeConfig.DataSource.ConnectionString, + message: "DAB did not properly set the 'Application Name' connection string property."); } /// - /// Test to validate that when the property rest.request-body-strict is absent from the rest runtime section in config file, DAB runs in strict mode. - /// In strict mode, presence of extra fields in the request body is not permitted and leads to HTTP 400 - BadRequest error. + /// Validates that DAB supplements the PgSQL database connection strings with the property "ApplicationName" and + /// 1. Adds the property/value "Application Name=dab_oss_Major.Minor.Patch" when the env var DAB_APP_NAME_ENV is not set. + /// 2. Adds the property/value "Application Name=dab_hosted_Major.Minor.Patch" when the env var DAB_APP_NAME_ENV is set to "dab_hosted". + /// (DAB_APP_NAME_ENV is set in hosted scenario or when user sets the value.) + /// NOTE: "#pragma warning disable format" is used here to avoid removing intentional, readability promoting spacing in DataRow display names. /// - /// Boolean value indicating whether or not to include extraneous field in request body. + /// connection string provided in the config. + /// Updated connection string with Application Name. + /// Whether DAB_APP_NAME_ENV is set in environment. (Always present in hosted scenario or if user supplies value.) [DataTestMethod] - [TestCategory(TestCategory.MSSQL)] - [DataRow(false, DisplayName = "Mutation operation passes when no extraneous field is included in request body and rest.request-body-strict is omitted from the rest runtime section in the config file.")] - [DataRow(true, DisplayName = "Mutation operation fails when an extraneous field is included in request body and rest.request-body-strict is omitted from the rest runtime section in the config file.")] - public async Task ValidateStrictModeAsDefaultForRestRequestBody(bool includeExtraneousFieldInRequestBody) + [DataRow("Host=foo;Username=testuser;", "Host=foo;Username=testuser;Application Name=", false, DisplayName = "[PGSQL]:DAB adds version 'dab_oss_major_minor_patch' to non-provided connection string property 'ApplicationName']")] + [DataRow("Host=foo;Username=testuser;", "Host=foo;Username=testuser;Application Name=", true, DisplayName = "[PGSQL]:DAB adds DAB_APP_NAME_ENV value 'dab_hosted' and version suffix '_major_minor_patch' to non-provided connection string property 'ApplicationName'.")] + [DataRow("Host=foo;Username=testuser;Application Name=UserAppName", "Host=foo;Username=testuser;Application Name=UserAppName,", false, DisplayName = "[PGSQL]:DAB appends version 'dab_oss_major_minor_patch' to user supplied 'Application Name' property.]")] + [DataRow("Host=foo;Username=testuser;Application Name=UserAppName", "Host=foo;Username=testuser;Application Name=UserAppName,", true, DisplayName = "[PGSQL]:DAB appends version string 'dab_hosted' and version suffix '_major_minor_patch' to user supplied 'ApplicationName' property.]")] + public void PgSqlConnStringSupplementedWithAppNameProperty( + string configProvidedConnString, + string expectedDabModifiedConnString, + bool dabEnvOverride) { - string entityJson = @" - { - ""entities"": { - ""Book"": { - ""source"": { - ""object"": ""books"", - ""type"": ""table"" - }, - ""permissions"": [ - { - ""role"": ""anonymous"", - ""actions"": [ - { - ""action"": ""*"" - } - ] - } - ] - } - } - }"; - - // The BASE_CONFIG omits the rest.request-body-strict option in the runtime section. - string configJson = TestHelper.AddPropertiesToJson(TestHelper.BASE_CONFIG, entityJson); - RuntimeConfigLoader.TryParseConfig( - configJson, - out RuntimeConfig deserializedConfig, - replacementSettings: new(), - logger: null, - GetConnectionStringFromEnvironmentConfig(environment: TestCategory.MSSQL)); - const string CUSTOM_CONFIG = "custom-config.json"; - File.WriteAllText(CUSTOM_CONFIG, deserializedConfig.ToJson()); - string[] args = new[] + // Explicitly set the DAB_APP_NAME_ENV to null to ensure that the DAB_APP_NAME_ENV is not set. + if (dabEnvOverride) { - $"--ConfigFileName={CUSTOM_CONFIG}" - }; - - using (TestServer server = new(Program.CreateWebHostBuilder(args))) - using (HttpClient client = server.CreateClient()) + Environment.SetEnvironmentVariable(ProductInfo.DAB_APP_NAME_ENV, "dab_hosted"); + } + else { - HttpMethod httpMethod = SqlTestHelper.ConvertRestMethodToHttpMethod(SupportedHttpVerb.Post); - string requestBody = @"{ - ""title"": ""Harry Potter and the Order of Phoenix"", - ""publisher_id"": 1234 "; + Environment.SetEnvironmentVariable(ProductInfo.DAB_APP_NAME_ENV, null); + } - if (includeExtraneousFieldInRequestBody) - { - requestBody += @", - ""extraField"": 12"; - } + // Resolve assembly version. Not possible to do in DataRow as DataRows expect compile-time constants. + string resolvedAssemblyVersion = ProductInfo.GetDataApiBuilderUserAgent(); + expectedDabModifiedConnString += resolvedAssemblyVersion; - requestBody += "}"; - JsonElement requestBodyElement = JsonDocument.Parse(requestBody).RootElement.Clone(); - HttpRequestMessage request = new(httpMethod, "api/Book") - { - Content = JsonContent.Create(requestBodyElement) - }; + RuntimeConfig runtimeConfig = CreateBasicRuntimeConfigWithNoEntity(DatabaseType.PostgreSQL, configProvidedConnString); - HttpResponseMessage response = await client.SendAsync(request); - if (includeExtraneousFieldInRequestBody) - { - string responseBody = await response.Content.ReadAsStringAsync(); - // Assert that including an extraneous field in request body while operating in strict mode leads to a bad request exception. - Assert.AreEqual(HttpStatusCode.BadRequest, response.StatusCode); - Assert.IsTrue(responseBody.Contains("Invalid request body. Contained unexpected fields in body: extraField")); - } - else - { - // When no extraneous fields are included in request body, the operation executes successfully. - Assert.AreEqual(HttpStatusCode.Created, response.StatusCode); - string locationHeader = response.Headers.Location.AbsoluteUri; + // Act + bool configParsed = RuntimeConfigLoader.TryParseConfig( + json: runtimeConfig.ToJson(), + config: out RuntimeConfig updatedRuntimeConfig, + replacementSettings: new(doReplaceEnvVar: true)); - // Delete the new record created as part of this test. - HttpRequestMessage cleanupRequest = new(HttpMethod.Delete, locationHeader); - await client.SendAsync(cleanupRequest); - } - } + // Assert + Assert.AreEqual( + expected: true, + actual: configParsed, + message: "Runtime config unexpectedly failed parsing."); + Assert.AreEqual( + expected: expectedDabModifiedConnString, + actual: updatedRuntimeConfig.DataSource.ConnectionString, + message: "DAB did not properly set the 'Application Name' connection string property."); } /// - /// Engine supports config with some views that do not have keyfields specified in the config for MsSQL. - /// This Test validates that support. It creates a custom config with a view and no keyfields specified. - /// It checks both Rest and GraphQL queries are tested to return Success. + /// Validates that DAB doesn't append nor modify + /// - the 'Application Name' or 'App' properties in MySQL database connection strings. + /// - the 'Application Name' property in + /// CosmosDB_PostgreSQL, CosmosDB_NoSQL database connection strings. + /// This test validates that this behavior holds true when the DAB_APP_NAME_ENV environment variable + /// - is set (dabEnvOverride==true) -> (DAB hosted) + /// - is not set (dabEnvOverride==false) -> (DAB OSS). /// - [TestMethod, TestCategory(TestCategory.MSSQL)] - public async Task TestEngineSupportViewsWithoutKeyFieldsInConfigForMsSQL() + /// database type. + /// connection string provided in the config. + /// Updated connection string with Application Name. + /// Whether DAB_APP_NAME_ENV is set in environment. (Always present in hosted scenario or if user supplies value.) + #pragma warning disable format + [DataTestMethod] + [DataRow(DatabaseType.MySQL, "Something;" , "Something;" , false, DisplayName = "[MYSQL|DAB OSS]:No addition of 'Application Name' or 'App' property to connection string.")] + [DataRow(DatabaseType.MySQL, "Something;Application Name=CustAppName;" , "Something;Application Name=CustAppName;" , false, DisplayName = "[MYSQL|DAB OSS]:No modification of customer overridden 'Application Name' property.")] + [DataRow(DatabaseType.MySQL, "Something1;App=CustAppName;Something2;" , "Something1;App=CustAppName;Something2;" , false, DisplayName = "[MySQL|DAB OSS]:No modification of customer overridden 'App' property.")] + [DataRow(DatabaseType.MySQL, "Something;" , "Something;" , true , DisplayName = "[MYSQL|DAB hosted]:No addition of 'Application Name' or 'App' property to connection string.")] + [DataRow(DatabaseType.MySQL, "Something;Application Name=CustAppName;" , "Something;Application Name=CustAppName;" , true , DisplayName = "[MYSQL|DAB hosted]:No modification of customer overridden 'Application Name' property.")] + [DataRow(DatabaseType.MySQL, "Something1;App=CustAppName;Something2;" , "Something1;App=CustAppName;Something2;" , true, DisplayName = "[MySQL|DAB hosted]:No modification of customer overridden 'App' property.")] + [DataRow(DatabaseType.CosmosDB_NoSQL, "Something;" , "Something;" , false, DisplayName = "[COSMOSDB_NOSQL|DAB OSS]:No addition of 'Application Name' property to connection string.")] + [DataRow(DatabaseType.CosmosDB_NoSQL, "Something;Application Name=CustAppName;", "Something;Application Name=CustAppName;", false, DisplayName = "[COSMOSDB_NOSQL|DAB OSS]:No modification of customer overridden 'Application Name' property.")] + [DataRow(DatabaseType.CosmosDB_NoSQL, "Something;" , "Something;" , true , DisplayName = "[COSMOSDB_NOSQL|DAB hosted]:No addition of 'Application Name' property to connection string.")] + [DataRow(DatabaseType.CosmosDB_NoSQL, "Something;Application Name=CustAppName;", "Something;Application Name=CustAppName;", true , DisplayName = "[COSMOSDB_NOSQL|DAB hosted]:No modification of customer overridden 'Application Name' property.")] + [DataRow(DatabaseType.CosmosDB_PostgreSQL, "Something;" , "Something;" , false, DisplayName = "[COSMOSDB_PGSQL|DAB OSS]:No addition of 'Application Name' property to connection string.")] + [DataRow(DatabaseType.CosmosDB_PostgreSQL, "Something;Application Name=CustAppName;", "Something;Application Name=CustAppName;", false, DisplayName = "[COSMOSDB_PGSQL|DAB OSS]:No modification of customer overridden 'Application Name' property.")] + [DataRow(DatabaseType.CosmosDB_PostgreSQL, "Something;" , "Something;" , true , DisplayName = "[COSMOSDB_PGSQL|DAB hosted]:No addition of 'Application Name' property to connection string.")] + [DataRow(DatabaseType.CosmosDB_PostgreSQL, "Something;Application Name=CustAppName;", "Something;Application Name=CustAppName;", true , DisplayName = "[COSMOSDB_PGSQL|DAB hosted]:No modification of customer overridden 'Application Name' property.")] + #pragma warning restore format + public void TestConnectionStringIsCorrectlyUpdatedWithApplicationName( + DatabaseType databaseType, + string configProvidedConnString, + string expectedDabModifiedConnString, + bool dabEnvOverride) { - DataSource dataSource = new(DatabaseType.MSSQL, - GetConnectionStringFromEnvironmentConfig(environment: TestCategory.MSSQL), Options: null); - Entity viewEntity = new( - Source: new("books_view_all", EntitySourceType.Table, null, null), - Fields: null, - Rest: new(Enabled: true), - GraphQL: new("", ""), - Permissions: new[] { GetMinimalPermissionConfig(AuthorizationResolver.ROLE_ANONYMOUS) }, - Relationships: null, - Mappings: null - ); - - RuntimeConfig configuration = InitMinimalRuntimeConfig(dataSource, new(), new(), new(), viewEntity, "books_view_all"); - - const string CUSTOM_CONFIG = "custom-config.json"; - - File.WriteAllText( - CUSTOM_CONFIG, - configuration.ToJson()); - - string[] args = new[] + // Explicitly set the DAB_APP_NAME_ENV to null to ensure that the DAB_APP_NAME_ENV is not set. + if (dabEnvOverride) { - $"--ConfigFileName={CUSTOM_CONFIG}" - }; - - using (TestServer server = new(Program.CreateWebHostBuilder(args))) - using (HttpClient client = server.CreateClient()) + Environment.SetEnvironmentVariable(ProductInfo.DAB_APP_NAME_ENV, "dab_hosted"); + } + else { - string query = @"{ - books_view_alls { - items{ - id - title - } - } - }"; + Environment.SetEnvironmentVariable(ProductInfo.DAB_APP_NAME_ENV, null); + } - object payload = new { query }; + RuntimeConfig runtimeConfig = CreateBasicRuntimeConfigWithNoEntity(databaseType, configProvidedConnString); - HttpRequestMessage graphQLRequest = new(HttpMethod.Post, "/graphql") - { - Content = JsonContent.Create(payload) - }; + // Resolve assembly version. Not possible to do in DataRow as DataRows expect compile-time constants. + string resolvedAssemblyVersion = ProductInfo.GetDataApiBuilderUserAgent(); + expectedDabModifiedConnString += resolvedAssemblyVersion; - HttpResponseMessage graphQLResponse = await client.SendAsync(graphQLRequest); - Assert.AreEqual(HttpStatusCode.OK, graphQLResponse.StatusCode); - string body = await graphQLResponse.Content.ReadAsStringAsync(); - Assert.IsFalse(body.Contains("errors")); // In GraphQL, All errors end up in the errors array, no matter what kind of error they are. + // Act + bool configParsed = RuntimeConfigLoader.TryParseConfig( + json: runtimeConfig.ToJson(), + config: out RuntimeConfig updatedRuntimeConfig, + replacementSettings: new(doReplaceEnvVar: true)); - HttpRequestMessage restRequest = new(HttpMethod.Get, "/api/books_view_all"); - HttpResponseMessage restResponse = await client.SendAsync(restRequest); - Assert.AreEqual(HttpStatusCode.OK, restResponse.StatusCode); - } + // Assert + Assert.AreEqual( + expected: true, + actual: configParsed, + message: "Runtime config unexpectedly failed parsing."); + Assert.AreEqual( + expected: expectedDabModifiedConnString, + actual: updatedRuntimeConfig.DataSource.ConnectionString, + message: "DAB did not properly set the 'Application Name' connection string property."); } - /// - /// Validates that DAB supports a configuration without authentication, as it's optional. - /// Ensures both REST and GraphQL queries return success when authentication is not configured. + /// + /// For mutation operations, both the respective operation(create/update/delete) + read permissions are needed to receive a valid response. + /// In this test, the Anonymous role is configured with only create permission. + /// So, a create mutation executed in the context of the Anonymous role is expected to result in + /// 1) Creation of a new item in the database + /// 2) An error response containing the error message : "The mutation operation {operation_name} was successful but the current user is unauthorized to view the response due to lack of read permissions" + /// + /// A create mutation operation in the context of Anonymous role is executed and the expected error message is validated. + /// Authenticated role has read permission configured. A pk query is executed in the context of Authenticated role to validate that a new + /// record was created in the database. /// - [TestMethod, TestCategory(TestCategory.MSSQL)] - public async Task TestEngineSupportConfigWithNoAuthentication() + [TestMethod] + [TestCategory(TestCategory.MSSQL)] + public async Task ValidateErrorMessageForMutationWithoutReadPermission() { + GraphQLRuntimeOptions graphqlOptions = new(Enabled: true); + RestRuntimeOptions restRuntimeOptions = new(Enabled: false); + McpRuntimeOptions mcpRuntimeOptions = new(Enabled: false); + DataSource dataSource = new(DatabaseType.MSSQL, GetConnectionStringFromEnvironmentConfig(environment: TestCategory.MSSQL), Options: null); - RuntimeConfig configuration = CreateBasicRuntimeConfigWithSingleEntityAndAuthOptions(dataSource: dataSource, authenticationOptions: null); + EntityAction createAction = new( + Action: EntityActionOperation.Create, + Fields: null, + Policy: new()); - const string CUSTOM_CONFIG = "custom-config.json"; + EntityAction readAction = new( + Action: EntityActionOperation.Read, + Fields: null, + Policy: new()); - File.WriteAllText( - CUSTOM_CONFIG, - configuration.ToJson()); + EntityAction deleteAction = new( + Action: EntityActionOperation.Delete, + Fields: null, + Policy: new()); + + EntityPermission[] permissions = new[] {new EntityPermission( Role: AuthorizationResolver.ROLE_ANONYMOUS , Actions: new[] { createAction }), + new EntityPermission( Role: AuthorizationResolver.ROLE_AUTHENTICATED , Actions: new[] { readAction, createAction, deleteAction })}; + + Entity entity = new(Source: new("stocks", EntitySourceType.Table, null, null), + Fields: null, + Rest: null, + GraphQL: new(Singular: "Stock", Plural: "Stocks"), + Permissions: permissions, + Relationships: null, + Mappings: null); + + string entityName = "Stock"; + RuntimeConfig configuration = InitMinimalRuntimeConfig(dataSource, graphqlOptions, restRuntimeOptions, mcpRuntimeOptions, entity, entityName); + const string CUSTOM_CONFIG = "custom-config.json"; + File.WriteAllText(CUSTOM_CONFIG, configuration.ToJson()); string[] args = new[] { $"--ConfigFileName={CUSTOM_CONFIG}" @@ -3626,230 +2325,247 @@ public async Task TestEngineSupportConfigWithNoAuthentication() using (TestServer server = new(Program.CreateWebHostBuilder(args))) using (HttpClient client = server.CreateClient()) { - string query = @"{ - books { - items{ - id - title - } - } - }"; - - object payload = new { query }; - - HttpRequestMessage graphQLRequest = new(HttpMethod.Post, "/graphql") + try { - Content = JsonContent.Create(payload) - }; + // Pre-clean to avoid PK violation if a previous run left the row behind. + string preCleanupDeleteMutation = @" + mutation { + deleteStock(categoryid: 5001, pieceid: 5001) { + categoryid + pieceid + } + }"; - HttpResponseMessage graphQLResponse = await client.SendAsync(graphQLRequest); - Assert.AreEqual(HttpStatusCode.OK, graphQLResponse.StatusCode); - string body = await graphQLResponse.Content.ReadAsStringAsync(); - Assert.IsFalse(body.Contains("errors")); // In GraphQL, All errors end up in the errors array, no matter what kind of error they are. + _ = await GraphQLRequestExecutor.PostGraphQLRequestAsync( + client, + server.Services.GetRequiredService(), + query: preCleanupDeleteMutation, + queryName: "deleteStock", + variables: null, + authToken: AuthTestHelper.CreateAppServiceEasyAuthToken(), + clientRoleHeader: AuthorizationResolver.ROLE_AUTHENTICATED); - HttpRequestMessage restRequest = new(HttpMethod.Get, "/api/Book"); - HttpResponseMessage restResponse = await client.SendAsync(restRequest); - Assert.AreEqual(HttpStatusCode.OK, restResponse.StatusCode); - } - } + // A create mutation operation is executed in the context of Anonymous role and the response is expected to be valid + // response without any errors. + string graphQLMutation = @" + mutation { + createStock( + item: { + categoryid: 5001 + pieceid: 5001 + categoryName: ""SciFi"" + piecesAvailable: 100 + piecesRequired: 50 + } + ) { + categoryid + pieceid + } + }"; - /// - /// In CosmosDB NoSQL, we store data in the form of JSON. Practically, JSON can be very complex. - /// But DAB doesn't support JSON with circular references e.g if 'Character.Moon' is a valid JSON Path, then - /// 'Moon.Character' should not be there, DAB would throw an exception during the load itself. - /// - /// - [TestMethod, TestCategory(TestCategory.COSMOSDBNOSQL)] - [DataRow(GRAPHQL_SCHEMA_WITH_CYCLE_OBJECT, DisplayName = "When Circular Reference is there with Object type (i.e. 'Moon' in 'Character' Entity")] - [DataRow(GRAPHQL_SCHEMA_WITH_CYCLE_ARRAY, DisplayName = "When Circular Reference is there with Array type (i.e. '[Moon]' in 'Character' Entity")] - public void ValidateGraphQLSchemaForCircularReference(string schema) - { - // Read the base config from the file system - TestHelper.SetupDatabaseEnvironment(TestCategory.COSMOSDBNOSQL); - FileSystemRuntimeConfigLoader baseLoader = TestHelper.GetRuntimeConfigLoader(); - if (!baseLoader.TryLoadKnownConfig(out RuntimeConfig baseConfig)) - { - throw new ApplicationException("Failed to load the default CosmosDB_NoSQL config and cannot continue with tests."); - } + JsonElement mutationResponse = await GraphQLRequestExecutor.PostGraphQLRequestAsync( + client, + server.Services.GetRequiredService(), + query: graphQLMutation, + queryName: "createStock", + variables: null, + authToken: null, + clientRoleHeader: AuthorizationResolver.ROLE_ANONYMOUS + ); - // Setup a mock file system, and use that one with the loader/provider for the config - MockFileSystem fileSystem = new(new Dictionary() - { - { @"../schema.gql", new MockFileData(schema) }, - { DEFAULT_CONFIG_FILE_NAME, new MockFileData(baseConfig.ToJson()) } - }); - FileSystemRuntimeConfigLoader loader = new(fileSystem); - RuntimeConfigProvider provider = new(loader); + Assert.IsNotNull(mutationResponse); + Assert.IsTrue(mutationResponse.ToString().Contains("The mutation operation createStock was successful but the current user is unauthorized to view the response due to lack of read permissions")); - DataApiBuilderException exception = - Assert.ThrowsException(() => new CosmosSqlMetadataProvider(provider, fileSystem)); - Assert.AreEqual("Circular reference detected in the provided GraphQL schema for entity 'Character'.", exception.Message); - Assert.AreEqual(HttpStatusCode.InternalServerError, exception.StatusCode); - Assert.AreEqual(DataApiBuilderException.SubStatusCodes.ErrorInInitialization, exception.SubStatusCode); - } + // pk_query is executed in the context of Authenticated role to validate that the create mutation executed in the context of Anonymous role + // resulted in the creation of a new record in the database. + string graphQLQuery = @" + { + stock_by_pk(categoryid: 5001, pieceid: 5001) { + categoryid + pieceid + categoryName + } + }"; + string queryName = "stock_by_pk"; - /// - /// GraphQL Schema types defined -> Character and Planet - /// DAB runtime config entities defined -> Planet(Not defined: Character) - /// Mismatch of entities and types between provided GraphQL schema file and DAB config results in actionable error message. - /// - /// - [TestMethod, TestCategory(TestCategory.COSMOSDBNOSQL)] - public void ValidateGraphQLSchemaEntityPresentInConfig() - { - string GRAPHQL_SCHEMA = @" -type Character { - id : ID, - name : String, -} + ValidateMutationSucceededAtDbLayer(server, client, graphQLQuery, queryName, AuthTestHelper.CreateAppServiceEasyAuthToken(), AuthorizationResolver.ROLE_AUTHENTICATED); + } + finally + { + // Clean-up steps. The record created by the create mutation operation is deleted to reset the database + // back to its original state. + string deleteMutation = @" + mutation { + deleteStock(categoryid: 5001, pieceid: 5001) { + categoryid + pieceid + } + }"; -type Planet @model(name:""PlanetAlias"") { - id : ID!, - name : String, - characters : [Character] -}"; - // Read the base config from the file system - TestHelper.SetupDatabaseEnvironment(TestCategory.COSMOSDBNOSQL); - FileSystemRuntimeConfigLoader baseLoader = TestHelper.GetRuntimeConfigLoader(); - if (!baseLoader.TryLoadKnownConfig(out RuntimeConfig baseConfig)) - { - throw new ApplicationException("Failed to load the default CosmosDB_NoSQL config and cannot continue with tests."); + _ = await GraphQLRequestExecutor.PostGraphQLRequestAsync( + client, + server.Services.GetRequiredService(), + query: deleteMutation, + queryName: "deleteStock", + variables: null, + authToken: AuthTestHelper.CreateAppServiceEasyAuthToken(), + clientRoleHeader: AuthorizationResolver.ROLE_AUTHENTICATED); + } } - - Dictionary entities = new(baseConfig.Entities); - entities.Remove("Character"); - - RuntimeConfig runtimeConfig = new(Schema: baseConfig.Schema, - DataSource: baseConfig.DataSource, - Runtime: baseConfig.Runtime, - Entities: new(entities)); - - // Setup a mock file system, and use that one with the loader/provider for the config - MockFileSystem fileSystem = new(new Dictionary() - { - { @"../schema.gql", new MockFileData(GRAPHQL_SCHEMA) }, - { DEFAULT_CONFIG_FILE_NAME, new MockFileData(runtimeConfig.ToJson()) } - }); - FileSystemRuntimeConfigLoader loader = new(fileSystem); - RuntimeConfigProvider provider = new(loader); - - DataApiBuilderException exception = - Assert.ThrowsException(() => new CosmosSqlMetadataProvider(provider, fileSystem)); - Assert.AreEqual("The entity 'Character' was not found in the runtime config.", exception.Message); - Assert.AreEqual(HttpStatusCode.ServiceUnavailable, exception.StatusCode); - Assert.AreEqual(DataApiBuilderException.SubStatusCodes.ConfigValidationError, exception.SubStatusCode); } /// - /// Tests that Startup.cs properly handles EasyAuth authentication configuration. - /// AppService as Identity Provider while in Production mode will result in startup error. - /// An Azure AppService environment has environment variables on the host which indicate - /// the environment is, in fact, an AppService environment. + /// Test to ensure that the built in ErrorHandler catches unexpected exceptions and transforms + /// them to a serialized error response. + /// The error message is validated to ensure it does not contain sensitive internal information. + /// GraphQL response with errors contains the error code and message. + /// REST responses with errors contain the problem-details structure. /// - /// HostMode in Runtime config - Development or Production. - /// EasyAuth auth type - AppService or StaticWebApps. - /// Whether to set the AppService host environment variables. - /// Whether an error is expected. + /// Whether to include the exception message in the response. + /// Whether to include the stack trace in the response. [DataTestMethod] [TestCategory(TestCategory.MSSQL)] - [DataRow(HostMode.Development, EasyAuthType.AppService, false, false, DisplayName = "AppService Dev - No EnvVars - No Error")] - [DataRow(HostMode.Development, EasyAuthType.AppService, true, false, DisplayName = "AppService Dev - EnvVars - No Error")] - [DataRow(HostMode.Production, EasyAuthType.AppService, false, false, DisplayName = "AppService Prod - No EnvVars - Error")] - [DataRow(HostMode.Production, EasyAuthType.AppService, true, false, DisplayName = "AppService Prod - EnvVars - Error")] - [DataRow(HostMode.Development, EasyAuthType.StaticWebApps, false, false, DisplayName = "SWA Dev - No EnvVars - No Error")] - [DataRow(HostMode.Development, EasyAuthType.StaticWebApps, true, false, DisplayName = "SWA Dev - EnvVars - No Error")] - [DataRow(HostMode.Production, EasyAuthType.StaticWebApps, false, false, DisplayName = "SWA Prod - No EnvVars - No Error")] - [DataRow(HostMode.Production, EasyAuthType.StaticWebApps, true, false, DisplayName = "SWA Prod - EnvVars - No Error")] - public void TestProductionModeAppServiceEnvironmentCheck(HostMode hostMode, EasyAuthType authType, bool setEnvVars, bool expectError) + [DataRow(true, true, DisplayName = "Unexpected error occurred - Detailed error message and stack trace returned.")] + [DataRow(true, false, DisplayName = "Unexpected error occurred - Generic error message returned.")] + [DataRow(false, true, DisplayName = "Expected error - Detailed error message and stack trace returned.")] + [DataRow(false, false, DisplayName = "Expected error - Generic error message returned.")] + public async Task TestErrorHandlerBehaviorForUnexpectedErrors(bool includeExceptionMessage, bool includeStackTrace) { - // Clears or sets App Service Environment Variables based on test input. - Environment.SetEnvironmentVariable(AppServiceAuthenticationInfo.APPSERVICESAUTH_ENABLED_ENVVAR, setEnvVars ? "true" : null); - Environment.SetEnvironmentVariable(AppServiceAuthenticationInfo.APPSERVICESAUTH_IDENTITYPROVIDER_ENVVAR, setEnvVars ? "AzureActiveDirectory" : null); - TestHelper.SetupDatabaseEnvironment(TestCategory.MSSQL); - - FileSystem fileSystem = new(); - FileSystemRuntimeConfigLoader loader = new(fileSystem); - - RuntimeConfigProvider configProvider = TestHelper.GetRuntimeConfigProvider(loader); - RuntimeConfig config = configProvider.GetConfig(); - - // Setup configuration - Config.ObjectModel.AuthenticationOptions authenticationOptions = new(Provider: authType.ToString(), Jwt: null); - RuntimeOptions runtimeOptions = new( - Rest: new(), - GraphQL: new(), - Mcp: new(), - Host: new(Cors: null, authenticationOptions, hostMode) - ); - RuntimeConfig configWithCustomHostMode = config with { Runtime = runtimeOptions }; + // Arrange + string errorMessage = "errorMessage"; + string stackTrace = "stackTrace"; + string gqlErrorCode = "INTERNAL_SERVER_ERROR"; const string CUSTOM_CONFIG = "custom-config.json"; - File.WriteAllText(CUSTOM_CONFIG, configWithCustomHostMode.ToJson()); + Dictionary entityMap = new(); + entityMap.Add("test-entity", + new Entity( + Source: new("books", EntitySourceType.Table, null, null), + Fields: null, + Rest: new(Enabled: true), + GraphQL: new("test-entity", "test-entities"), + Permissions: new[] { GetMinimalPermissionConfig(AuthorizationResolver.ROLE_ANONYMOUS) }, + Relationships: null, + Mappings: null)); + CreateCustomConfigFile(entityMap); + string[] args = new[] { - $"--ConfigFileName={CUSTOM_CONFIG}" + $"--ConfigFileName={CUSTOM_CONFIG}" }; - // When host is in Production mode with AppService as Identity Provider and the environment variables are not set - // we do not throw an exception any longer(PR: 2943), instead log a warning to the user. In this case expectError is false. - // This test only checks for startup errors, so no requests are sent to the test server. - try + using TestServer server = new(Program.CreateWebHostBuilder(args)); + using HttpClient client = server.CreateClient() { - using TestServer server = new(Program.CreateWebHostBuilder(args)); - Assert.IsFalse(expectError, message: "Expected error faulting AppService config in production mode."); + BaseAddress = new Uri("http://localhost") + }; + string query = @"{ + book_by_pk(id: 1) { + id + title + publisher_id + } + }"; + + object payload = new { query }; + + // Act + // Send request that will result in an unexpected error + HttpResponseMessage response = await client.PostAsync("/graphql", new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json")); + + // Assert + string responseBody = await response.Content.ReadAsStringAsync(); + JsonElement responseJson = JsonSerializer.Deserialize(responseBody); + + Assert.AreEqual(HttpStatusCode.InternalServerError, response.StatusCode); + Assert.IsTrue(responseBody.Contains(gqlErrorCode), "The response should contain the error code."); + if (includeExceptionMessage) + { + Assert.IsTrue(responseBody.Contains(errorMessage), "The response should contain the exception message."); + } + else + { + Assert.IsFalse(responseBody.Contains(errorMessage), "The response should NOT contain the exception message."); + } + + if (includeStackTrace) + { + Assert.IsTrue(responseBody.Contains(stackTrace), "The response should contain the stack trace."); } - catch (DataApiBuilderException ex) + else { - Assert.IsTrue(expectError, message: ex.Message); - Assert.AreEqual(AppServiceAuthenticationInfo.APPSERVICE_PROD_MISSING_ENV_CONFIG, ex.Message); + Assert.IsFalse(responseBody.Contains(stackTrace), "The response should NOT contain the stack trace."); } + + // Ensure response doesn't contain sensitive information + Assert.AreEqual(false, responseBody.Contains("ConnectionStrings"), "The response should not contain sensitive information like ConnectionStrings."); + Assert.AreEqual(false, responseBody.Contains("/path/to/sensitive/file"), "The response should not contain sensitive information like file paths."); } /// - /// Integration test that validates schema introspection requests fail - /// when allow-introspection is false in the runtime configuration. - /// TestCategory is required for CI/CD pipeline to inject a connection string. + /// This test method validates that a config with custom properties is still valid. + /// The properties "description" and "extraProperty" are not part of the default config. + /// However, their presence should not affect the config's validity. /// - /// + [TestMethod] [TestCategory(TestCategory.MSSQL)] - [DataTestMethod] - [DataRow(false, true, "Introspection is not allowed for the current request.", CONFIGURATION_ENDPOINT, DisplayName = "Disabled introspection returns GraphQL error.")] - [DataRow(true, false, null, CONFIGURATION_ENDPOINT, DisplayName = "Enabled introspection does not return introspection forbidden error.")] - [DataRow(false, true, "Introspection is not allowed for the current request.", CONFIGURATION_ENDPOINT_V2, DisplayName = "Disabled introspection returns GraphQL error.")] - [DataRow(true, false, null, CONFIGURATION_ENDPOINT_V2, DisplayName = "Enabled introspection does not return introspection forbidden error.")] - public async Task TestSchemaIntrospectionQuery(bool enableIntrospection, bool expectError, string errorMessage, string configurationEndpoint) + public async Task TestConfigWithCustomPropertiesIsValid() { - GraphQLRuntimeOptions graphqlOptions = new(AllowIntrospection: enableIntrospection); - RestRuntimeOptions restRuntimeOptions = new(); - McpRuntimeOptions mcpRuntimeOptions = new(); - - DataSource dataSource = new(DatabaseType.MSSQL, GetConnectionStringFromEnvironmentConfig(environment: TestCategory.MSSQL), Options: null); + string configJson = @"{ + // Link for latest draft schema. + ""$schema"":""https://github.com/Azure/data-api-builder/releases/download/vmajor.minor.patch-alpha/dab.draft.schema.json"", + ""data-source"": { + ""database-type"": ""mssql"", + ""connection-string"": ""sample-conn-string"" + }, + ""runtime"": { + ""rest"": { + ""enabled"": true, + ""path"": ""/api"" + }, + ""graphql"": { + ""enabled"": true, + ""path"": ""/graphql"", + ""allow-introspection"": true + }, + ""host"": { + ""cors"": { + ""origins"": [ + ""http://localhost:5000"" + ], + ""allow-credentials"": false + } + } + }, + ""entities"":{ }, + ""description"": ""This is a custom config for testing."", + ""extraProperty"": ""Some extra value"" + }"; - RuntimeConfig configuration = InitMinimalRuntimeConfig(dataSource, graphqlOptions, restRuntimeOptions, mcpRuntimeOptions); - const string CUSTOM_CONFIG = "custom-config.json"; - File.WriteAllText(CUSTOM_CONFIG, configuration.ToJson()); + // Act + bool configParsed = RuntimeConfigLoader.TryParseConfig(configJson, out RuntimeConfig deserializedConfig); - string[] args = new[] - { - $"--ConfigFileName={CUSTOM_CONFIG}" - }; + // Assert + Assert.IsTrue(configParsed, "Config with custom properties should be valid."); + } - using (TestServer server = new(Program.CreateWebHostBuilder(args))) - using (HttpClient client = server.CreateClient()) + /// + /// This test method validates that an exception is thrown if there's a null model in filter parser. + /// + public void VerifyExceptionOnNullModelinFilterParser() + { + ODataParser parser = new(); + try { - await ExecuteGraphQLIntrospectionQueries(server, client, expectError); + // FilterParser has no model so we expect exception + parser.GetFilterClause(filterQueryString: string.Empty, resourcePath: string.Empty); + Assert.Fail(); } - - // Instantiate new server with no runtime config for post-startup configuration hydration tests. - using (TestServer server = new(Program.CreateWebHostFromInMemoryUpdatableConfBuilder(Array.Empty()))) - using (HttpClient client = server.CreateClient()) + catch (DataApiBuilderException exception) { - JsonContent content = GetPostStartupConfigParams(MSSQL_ENVIRONMENT, configuration, configurationEndpoint); - HttpStatusCode responseCode = await HydratePostStartupConfiguration(client, content, configurationEndpoint, configuration.Runtime.Rest); - - Assert.AreEqual(expected: HttpStatusCode.OK, actual: responseCode, message: "Configuration hydration failed."); - - await ExecuteGraphQLIntrospectionQueries(server, client, expectError); + Assert.AreEqual("The runtime has not been initialized with an Edm model.", exception.Message); + Assert.AreEqual(HttpStatusCode.InternalServerError, exception.StatusCode); + Assert.AreEqual(DataApiBuilderException.SubStatusCodes.UnexpectedError, exception.SubStatusCode); } } @@ -3906,7 +2622,7 @@ public void TestInvalidDatabaseColumnNameHandling( string[] args = new[] { $"--ConfigFileName={CUSTOM_CONFIG}" - }; + }; try { @@ -3971,8 +2687,8 @@ public async Task OpenApi_InteractiveSwaggerUI( }; const string CUSTOM_CONFIG = "custom-config.json"; File.WriteAllText( - CUSTOM_CONFIG, - configuration.ToJson()); + path: CUSTOM_CONFIG, + contents: configuration.ToJson()); string[] args = new[] { @@ -4013,1131 +2729,777 @@ public async Task OpenApi_InteractiveSwaggerUI( } /// - /// Test different loglevel values that are avaliable by deserializing RuntimeConfig with specified LogLevel - /// and checks if value exists properly inside the deserialized RuntimeConfig. - /// - [DataTestMethod] - [TestCategory(TestCategory.MSSQL)] - [DataRow(LogLevel.Trace, DisplayName = "Validates that log level Trace deserialized correctly")] - [DataRow(LogLevel.Debug, DisplayName = "Validates log level Debug deserialized correctly")] - [DataRow(LogLevel.Information, DisplayName = "Validates log level Information deserialized correctly")] - [DataRow(LogLevel.Warning, DisplayName = "Validates log level Warning deserialized correctly")] - [DataRow(LogLevel.Error, DisplayName = "Validates log level Error deserialized correctly")] - [DataRow(LogLevel.Critical, DisplayName = "Validates log level Critical deserialized correctly")] - [DataRow(LogLevel.None, DisplayName = "Validates log level None deserialized correctly")] - [DataRow(null, DisplayName = "Validates log level Null deserialized correctly")] - public void TestExistingLogLevels(LogLevel expectedLevel) - { - Dictionary logLevelOptions = new(); - logLevelOptions.Add("default", expectedLevel); - RuntimeConfig configWithCustomLogLevel = InitializeRuntimeWithLogLevel(logLevelOptions); - - string configWithCustomLogLevelJson = configWithCustomLogLevel.ToJson(); - Assert.IsTrue(RuntimeConfigLoader.TryParseConfig(configWithCustomLogLevelJson, out RuntimeConfig deserializedRuntimeConfig)); - - Assert.AreEqual(expectedLevel, deserializedRuntimeConfig.Runtime.Telemetry.LoggerLevel["default"]); - } - - /// - /// Test different loglevel values that do not exist to ensure that the build fails when they are trying to be set up - /// - [DataTestMethod] - [TestCategory(TestCategory.MSSQL)] - [DataRow(-1, DisplayName = "Validates that a negative log level value, fails to build")] - [DataRow(7, DisplayName = "Validates that a positive log level value that does not exist, fails to build")] - [DataRow(12, DisplayName = "Validates that a bigger positive log level value that does not exist, fails to build")] - public void TestNonExistingLogLevels(LogLevel expectedLevel) - { - Dictionary logLevelOptions = new(); - logLevelOptions.Add("default", expectedLevel); - RuntimeConfig configWithCustomLogLevel = InitializeRuntimeWithLogLevel(logLevelOptions); - - // Try should fail and go to catch exception - try - { - string configWithCustomLogLevelJson = configWithCustomLogLevel.ToJson(); - Assert.Fail(); - } - // Catch verifies that the exception is due to LogLevel having a value that does not exist - catch (Exception ex) - { - Assert.AreEqual(typeof(KeyNotFoundException), ex.GetType()); - } - } - - /// - /// Tests different loglevel values to see if they are serialized correctly to the Json config - /// - [DataTestMethod] - [TestCategory(TestCategory.MSSQL)] - [DataRow(LogLevel.Debug)] - [DataRow(LogLevel.Warning)] - [DataRow(LogLevel.None)] - [DataRow(null)] - public void LogLevelSerialization(LogLevel expectedLevel) - { - Dictionary logLevelOptions = new(); - logLevelOptions.Add("default", expectedLevel); - RuntimeConfig configWithCustomLogLevel = InitializeRuntimeWithLogLevel(logLevelOptions); - string configWithCustomLogLevelJson = configWithCustomLogLevel.ToJson(); - Assert.IsTrue(RuntimeConfigLoader.TryParseConfig(configWithCustomLogLevelJson, out RuntimeConfig deserializedRuntimeConfig)); - - string serializedConfig = deserializedRuntimeConfig.ToJson(); - - using (JsonDocument parsedDocument = JsonDocument.Parse(serializedConfig)) - { - JsonElement root = parsedDocument.RootElement; - JsonElement runtimeElement = root.GetProperty("runtime"); - - //Validate log-level property exists in runtime - JsonElement telemetryElement = runtimeElement.GetProperty("telemetry"); - bool logLevelPropertyExists = telemetryElement.TryGetProperty("log-level", out JsonElement logLevelElement); - Assert.AreEqual(expected: true, actual: logLevelPropertyExists); - - //Validate the dictionary inside the log-level property is of expected value - bool dictionaryLogLevelExists = logLevelElement.TryGetProperty("default", out JsonElement levelElement); - Assert.AreEqual(expected: true, actual: dictionaryLogLevelExists); - Assert.AreEqual(expectedLevel.ToString().ToLower(), levelElement.GetString()); - } - } - - /// - /// Tests different log level filters that are valid and check that they are deserialized correctly - /// - [Ignore] - [DataTestMethod] - [TestCategory(TestCategory.MSSQL)] - [DataRow(LogLevel.Trace, typeof(RuntimeConfigValidator))] - [DataRow(LogLevel.Debug, typeof(SqlQueryEngine))] - [DataRow(LogLevel.Information, typeof(IQueryExecutor))] - [DataRow(LogLevel.Warning, typeof(ISqlMetadataProvider))] - [DataRow(LogLevel.Error, typeof(BasicHealthReportResponseWriter))] - [DataRow(LogLevel.Critical, typeof(ComprehensiveHealthReportResponseWriter))] - [DataRow(LogLevel.None, typeof(RestController))] - [DataRow(LogLevel.Trace, typeof(ClientRoleHeaderAuthenticationMiddleware))] - [DataRow(LogLevel.Debug, typeof(ConfigurationController))] - [DataRow(LogLevel.Information, typeof(IAuthorizationHandler))] - [DataRow(LogLevel.Warning, typeof(IAuthorizationResolver))] - public void ValidLogLevelFilters(LogLevel logLevel, Type loggingType) - { - string loggingFilter = loggingType.FullName; - ValidateLogLevelFilters(logLevel, loggingFilter); - } - - /// - /// Tests different log level filters that are valid and check that they are deserialized correctly - /// This test uses strings as we are checking for values that are not avaliable using the typeof() function - /// It is the same test as ValidLogLevelFilters. - /// - [DataTestMethod] - [TestCategory(TestCategory.MSSQL)] - [DataRow(LogLevel.Trace, "default")] - [DataRow(LogLevel.Debug, "Azure")] - [DataRow(LogLevel.Information, "Azure.DataApiBuilder")] - [DataRow(LogLevel.Warning, "Azure.DataApiBuilder.Core")] - [DataRow(LogLevel.Error, "Azure.DataApiBuilder.Core.Configurations")] - [DataRow(LogLevel.Critical, "Azure.DataApiBuilder.Core.Resolvers")] - [DataRow(LogLevel.None, "Azure.DataApiBuilder.Core.Services")] - [DataRow(LogLevel.Trace, "Azure.DataApiBuilder.Service")] - [DataRow(LogLevel.Debug, "Azure.DataApiBuilder.Service.HealthCheck")] - [DataRow(LogLevel.Information, "Azure.DataApiBuilder.Service.Controllers")] - [DataRow(LogLevel.Warning, "Microsoft.AspNetCore")] - public void ValidStringLogLevelFilters(LogLevel logLevel, string loggingFilter) - { - ValidateLogLevelFilters(logLevel, loggingFilter); - } - - /// - /// General method that is used to test the valid log level filters. - /// - /// - /// - private static void ValidateLogLevelFilters(LogLevel logLevel, string loggingFilter) - { - // Arrange - Dictionary logLevelOptions = new(); - logLevelOptions.Add(loggingFilter, logLevel); - RuntimeConfig configWithCustomLogLevel = InitializeRuntimeWithLogLevel(logLevelOptions); - - File.WriteAllText(CUSTOM_CONFIG_FILENAME, configWithCustomLogLevel.ToJson()); - string[] args = new[] - { - $"--ConfigFileName={CUSTOM_CONFIG_FILENAME}" - }; - - // Start a new server with the custom log level to ensure the - // instantiation of the valid log level filters works as expected. - TestServer server = new(Program.CreateWebHostBuilder(args)); - RuntimeConfigProvider runtimeConfigProvider = server.Services.GetService(); - - // RuntimeConfig with instantiated log level filters. - RuntimeConfig serverRuntimeConfig = runtimeConfigProvider.GetConfig(); - - // Act - try - { - RuntimeConfigValidator.ValidateLoggerFilters(serverRuntimeConfig); - } - catch - { - Assert.Fail(); - } - - // Assert - string configWithCustomLogLevelJson = configWithCustomLogLevel.ToJson(); - Assert.IsTrue(RuntimeConfigLoader.TryParseConfig(configWithCustomLogLevelJson, out RuntimeConfig deserializedRuntimeConfig)); - - Dictionary actualLoggerLevel = deserializedRuntimeConfig.Runtime.Telemetry.LoggerLevel; - Assert.IsTrue(actualLoggerLevel.ContainsKey(loggingFilter) && actualLoggerLevel.Count == 1); - Assert.IsTrue(actualLoggerLevel[loggingFilter] == logLevel); - } - - /// - /// Tests that between multiple log level filters, - /// the one that is more specific is always given priority. - /// - [DataTestMethod] - [TestCategory(TestCategory.MSSQL)] - [DataRow(LogLevel.Debug, "Azure", LogLevel.Warning, "default", typeof(IQueryExecutor))] - [DataRow(LogLevel.Information, "Azure.DataApiBuilder", LogLevel.Error, "Azure", typeof(IQueryExecutor))] - [DataRow(LogLevel.Warning, "Azure.DataApiBuilder.Core", LogLevel.Critical, "Azure.DataApiBuilder", typeof(RuntimeConfigValidator))] - [DataRow(LogLevel.Error, "Azure.DataApiBuilder.Core.Configurations", LogLevel.None, "Azure.DataApiBuilder.Core", typeof(RuntimeConfigValidator))] - public void PriorityLogLevelFilters(LogLevel highPriLevel, string highPriFilter, LogLevel lowPriLevel, string lowPriFilter, Type type) - { - string classString = type.FullName; - Startup.AddValidFilters(); - Dictionary logLevelOptions = new(); - logLevelOptions.Add(highPriFilter, highPriLevel); - logLevelOptions.Add(lowPriFilter, lowPriLevel); - RuntimeConfig configWithCustomLogLevel = InitializeRuntimeWithLogLevel(logLevelOptions); - try - { - RuntimeConfigValidator.ValidateLoggerFilters(configWithCustomLogLevel); - } - catch - { - Assert.Fail(); - } - - string configWithCustomLogLevelJson = configWithCustomLogLevel.ToJson(); - Assert.IsTrue(RuntimeConfigLoader.TryParseConfig(configWithCustomLogLevelJson, out RuntimeConfig deserializedRuntimeConfig)); - - // If filters are not a subsection from the classString, then the test will not work. - LogLevel actualLogLevel = deserializedRuntimeConfig.GetConfiguredLogLevel(classString); - - Assert.AreEqual(expected: highPriLevel, actual: actualLogLevel); - } - - /// - /// Tests log level filters that are not available and checks that they give the correct error. - /// - [DataTestMethod] - [TestCategory(TestCategory.MSSQL)] - [DataRow("Azure.DataApiBuilder.Core.Configur", DisplayName = "Validates that an incomplete log level keyword fails to build")] - [DataRow("Azure.DataApiBuilder.Core.Configurations.RuntimeConfigVldtr", DisplayName = "Validates that a wrong name at end of log level keyword fails to build")] - [DataRow("Azre.DataApiBuilder.Core.Configurations.RuntimeConfigValidator", DisplayName = "Validates that a wrong name at start of log level keyword fails to build")] - [DataRow("Azure.DataApiBuilder.Core.Configurations.RuntimeConfigValidator.Extra", DisplayName = "Validates that log level keyword with additional path fails to build")] - [DataRow("Microsoft.AspNetCore.Authorizatin.IAuthorizationHandler", DisplayName = "Validates that a wrong name inside of log level keyword fails to build")] - [DataRow("defult", DisplayName = "Validates that a wrong name for 'default' log level keyword fails to build")] - public void InvalidLogLevelFilters(string loggingFilter) - { - Dictionary logLevelOptions = new(); - logLevelOptions.Add(loggingFilter, LogLevel.Debug); - RuntimeConfig configWithCustomLogLevel = InitializeRuntimeWithLogLevel(logLevelOptions); - - // Try should fail and go to catch exception - try - { - RuntimeConfigValidator.ValidateLoggerFilters(configWithCustomLogLevel); - Assert.Fail(); - } - // Catch verifies that the exception is due to LogLevel having a key that is invalid - catch (Exception ex) - { - Assert.AreEqual(typeof(NotSupportedException), ex.GetType()); - } - } - - /// - /// Helper method to create RuntimeConfig with specificed LogLevel value - /// - private static RuntimeConfig InitializeRuntimeWithLogLevel(Dictionary logLevelOptions) - { - TestHelper.SetupDatabaseEnvironment(MSSQL_ENVIRONMENT); - - FileSystemRuntimeConfigLoader baseLoader = TestHelper.GetRuntimeConfigLoader(); - baseLoader.TryLoadKnownConfig(out RuntimeConfig baseConfig); - - RuntimeConfig config = new( - Schema: baseConfig.Schema, - DataSource: baseConfig.DataSource, - Runtime: new( - Rest: new(), - GraphQL: new(), - Mcp: new(), - Host: new(null, null), - Telemetry: new(LoggerLevel: logLevelOptions) - ), - Entities: baseConfig.Entities - ); - - return config; - } - -#nullable enable - - /// - /// Tests different Azure Log Analytics values to see if they are serialized and deserialized correctly to the Json config + /// Tests that the custom path rewriting middleware properly rewrites the + /// first segment of a path (/segment1/.../segmentN) when the segment matches + /// the custom configured GraphQLEndpoint. + /// Note: The GraphQL service is always internally mapped to /graphql /// + /// The custom configured GraphQL path in configuration + /// The path used in the web request executed in the test. + /// Expected Http success/error code [DataTestMethod] [TestCategory(TestCategory.MSSQL)] - [DataRow(true, "CustomTableName", "DcrImmutableId", "DceEndpoint", "TestDabLog", 1, true, "TestDabLog", 1)] - [DataRow(false, "", null, "", "", 10, false, "", 10)] - [DataRow(null, null, null, null, null, null, false, "DabLogs", 5)] - public void AzureLogAnalyticsSerialization( - bool? enabled, - string? customTableName, - string? dcrImmutableId, - string? dceEndpoint, - string? dabIdentifier, - int? flushIntSec, - bool expectedEnabled, - string expectedDabIdentifier, - int expectedFlushIntSec) + [DataRow("/graphql", "/gql", HttpStatusCode.BadRequest, DisplayName = "Request to non-configured graphQL endpoint is handled by REST controller.")] + [DataRow("/graphql", "/graphql", HttpStatusCode.OK, DisplayName = "Request to configured default GraphQL endpoint succeeds, path not rewritten.")] + [DataRow("/gql", "/gql/additionalURLsegment", HttpStatusCode.OK, DisplayName = "GraphQL request path (with extra segments) rewritten to match internally set GraphQL endpoint /graphql.")] + [DataRow("/gql", "/gql", HttpStatusCode.OK, DisplayName = "GraphQL request path rewritten to match internally set GraphQL endpoint /graphql.")] + [DataRow("/gql", "/api/book", HttpStatusCode.NotFound, DisplayName = "Non-GraphQL request's path is not rewritten and is handled by REST controller.")] + [DataRow("/gql", "/graphql", HttpStatusCode.NotFound, DisplayName = "Requests to default/internally set graphQL endpoint fail when configured endpoint differs.")] + public async Task TestPathRewriteMiddlewareForGraphQL( + string graphQLConfiguredPath, + string requestPath, + HttpStatusCode expectedStatusCode) { - // Check if auth property and its values are expected to exist - bool expectedExistEnabled = enabled is not null; - bool expectedExistDabIdentifier = dabIdentifier is not null; - bool expectedExistFlushIntSec = flushIntSec is not null; - bool expectedExistCustomTableName = customTableName is not null; - bool expectedExistDcrImmutableId = dcrImmutableId is not null; - bool expectedExistDceEndpoint = dceEndpoint is not null; - - AzureLogAnalyticsAuthOptions authOptions = new(customTableName, dcrImmutableId, dceEndpoint); - AzureLogAnalyticsOptions azureLogAnalyticsOptions = new(enabled, authOptions, dabIdentifier, flushIntSec); - TelemetryOptions telemetryOptions = new(AzureLogAnalytics: azureLogAnalyticsOptions); - RuntimeConfig configWithCustomLogLevel = InitializeRuntimeWithTelemetry(telemetryOptions); - string configWithCustomLogLevelJson = configWithCustomLogLevel.ToJson(); - Assert.IsTrue(RuntimeConfigLoader.TryParseConfig(configWithCustomLogLevelJson, out RuntimeConfig? deserializedRuntimeConfig)); + GraphQLRuntimeOptions graphqlOptions = new(Enabled: true); + RestRuntimeOptions restRuntimeOptions = new(Enabled: true); + McpRuntimeOptions mcpOptions = new(Enabled: true); - string serializedConfig = deserializedRuntimeConfig.ToJson(); + DataSource dataSource = new(DatabaseType.MSSQL, GetConnectionStringFromEnvironmentConfig(environment: TestCategory.MSSQL), Options: null); - using (JsonDocument parsedDocument = JsonDocument.Parse(serializedConfig)) - { - JsonElement root = parsedDocument.RootElement; - JsonElement runtimeElement = root.GetProperty("runtime"); - - //Validate azure-log-analytics property exists in runtime - JsonElement telemetryElement = runtimeElement.GetProperty("telemetry"); - bool azureLogAnalyticsPropertyExists = telemetryElement.TryGetProperty("azure-log-analytics", out JsonElement azureLogAnalyticsElement); - Assert.AreEqual(expected: true, actual: azureLogAnalyticsPropertyExists); - - //Validate the values inside the azure-log-analytics properties are of expected value - bool enabledExists = azureLogAnalyticsElement.TryGetProperty("enabled", out JsonElement enabledElement); - Assert.AreEqual(expected: expectedExistEnabled, actual: enabledExists); - if (enabledExists) - { - Assert.AreEqual(expectedEnabled, enabledElement.GetBoolean()); - } + // Configure Entity for testing + Entity entity = new( + Source: new("graphql_incompatible", EntitySourceType.Table, null, null), + Fields: null, + Rest: new(Enabled: false), + GraphQL: new("graphql_incompatible", "graphql_incompatibles", enabled: true), + Permissions: new[] { GetMinimalPermissionConfig(AuthorizationResolver.ROLE_ANONYMOUS) }, + Relationships: null, + Mappings: null); - bool dabIdentifierExists = azureLogAnalyticsElement.TryGetProperty("dab-identifier", out JsonElement dabIdentifierElement); - Assert.AreEqual(expected: expectedExistDabIdentifier, actual: dabIdentifierExists); - if (dabIdentifierExists) - { - Assert.AreEqual(expectedDabIdentifier, dabIdentifierElement.GetString()); - } + entity = entity with + { + Source = new("books", EntitySourceType.Table, null, null) + }; - bool flushIntSecExists = azureLogAnalyticsElement.TryGetProperty("flush-interval-seconds", out JsonElement flushIntSecElement); - Assert.AreEqual(expected: expectedExistFlushIntSec, actual: flushIntSecExists); - if (flushIntSecExists) - { - Assert.AreEqual(expectedFlushIntSec, flushIntSecElement.GetInt32()); - } + RuntimeConfig configuration = InitMinimalRuntimeConfig(dataSource, graphqlOptions, restRuntimeOptions, mcpOptions, entity, "graphql_incompatible"); + const string CUSTOM_CONFIG = "custom-config.json"; + File.WriteAllText(CUSTOM_CONFIG, configuration.ToJson()); - // Validate auth property exists inside of azure-log-analytics - bool authExists = azureLogAnalyticsElement.TryGetProperty("auth", out JsonElement authElement); + string[] args = new[] + { + $"--ConfigFileName={CUSTOM_CONFIG}" + }; - // Validate the values inside the auth properties are of expected value - if (authExists) - { - bool customTableNameExists = authElement.TryGetProperty("custom-table-name", out JsonElement customTableNameElement); - Assert.AreEqual(expectedExistCustomTableName, customTableNameExists); - if (customTableNameExists) - { - Assert.AreEqual(expected: customTableName, customTableNameElement.GetString()); - } + using TestServer server = new(Program.CreateWebHostBuilder(args)) + { + BaseAddress = new Uri("http://localhost") + }; + using HttpClient client = server.CreateClient(); - bool dcrImmutableIdExists = authElement.TryGetProperty("dcr-immutable-id", out JsonElement dcrImmutableIdElement); - Assert.AreEqual(expectedExistDcrImmutableId, dcrImmutableIdExists); - if (dcrImmutableIdExists) - { - Assert.AreEqual(expected: dcrImmutableId, dcrImmutableIdElement.GetString()); - } + // Act + // Send request that matches the custom configured GraphQL path + HttpRequestMessage request = new(HttpMethod.Get, requestPath); + HttpResponseMessage response = await client.SendAsync(request); - bool dceEndpointExists = authElement.TryGetProperty("dce-endpoint", out JsonElement dceEndpointElement); - Assert.AreEqual(expectedExistDceEndpoint, dceEndpointExists); - if (dceEndpointExists) - { - Assert.AreEqual(expected: dceEndpoint, dceEndpointElement.GetString()); - } - } - } + // Assert + Assert.AreEqual(expectedStatusCode, response.StatusCode); } /// - /// Tests different File Sink values to see if they are serialized and deserialized correctly to the Json config + /// Validates the error message that is returned for REST requests with incorrect parameter type + /// when the engine is running in Production mode. The error messages in Production mode is + /// very generic to not reveal information about the underlying database objects backing the entity. + /// This test runs against a MsSql database. However, generic error messages will be returned in Production + /// mode when run against PostgreSql and MySql databases. /// + /// Type of REST request + /// Endpoint for the REST request + /// Right error message that should be shown to the end user [DataTestMethod] [TestCategory(TestCategory.MSSQL)] - [DataRow(true, "/file/path/exists.txt", RollingInterval.Minute, 27, 256, true, "/file/path/exists.txt", RollingInterval.Minute, 27, 256)] - [DataRow(true, "/test/path.csv", RollingInterval.Hour, 10, 3000, true, "/test/path.csv", RollingInterval.Hour, 10, 3000)] - [DataRow(false, "C://absolute/file/path.log", RollingInterval.Month, 2147483647, 2048, false, "C://absolute/file/path.log", RollingInterval.Month, 2147483647, 2048)] - [DataRow(false, "D://absolute/test/path.txt", RollingInterval.Year, 10, 2147483647, false, "D://absolute/test/path.txt", RollingInterval.Year, 10, 2147483647)] - [DataRow(false, "", RollingInterval.Infinite, 5, 512, false, "", RollingInterval.Infinite, 5, 512)] - [DataRow(null, null, null, null, null, false, "/logs/dab-log.txt", RollingInterval.Day, 1, 1048576)] - public void FileSinkSerialization( - bool? enabled, - string? path, - RollingInterval? rollingInterval, - int? retainedFileCountLimit, - int? fileSizeLimitBytes, - bool expectedEnabled, - string expectedPath, - RollingInterval expectedRollingInterval, - int expectedRetainedFileCountLimit, - int expectedFileSizeLimitBytes) + [DataRow(SupportedHttpVerb.Get, "/api/Book/id/one", null, "Invalid value provided for field: id", DisplayName = "Validates the error message for a GET request with incorrect primary key parameter type on a table in production mode")] + [DataRow(SupportedHttpVerb.Get, "/api/books_view_all/id/one", null, "Invalid value provided for field: id", DisplayName = "Validates the error message for a GET request with incorrect primary key parameter type on a view in production mode")] + [DataRow(SupportedHttpVerb.Get, "/api/GetBook?id=one", REQUEST_BODY_WITH_CORRECT_PARAM_TYPES, "Invalid value provided for field: id", DisplayName = "Validates the error message for a GET request on a stored-procedure with incorrect parameter type in production mode")] + [DataRow(SupportedHttpVerb.Get, "/api/GQLmappings/column1/one", null, "Invalid value provided for field: column1", DisplayName = "Validates the error message for a GET request with incorrect primary key parameter type with alias defined for primary key column on a table in production mode")] + [DataRow(SupportedHttpVerb.Post, "/api/Book", REQUEST_BODY_WITH_INCORRECT_PARAM_TYPES, "Invalid value provided for field: publisher_id", DisplayName = "Validates the error message for a POST request with incorrect parameter type in the request body on a table in production mode")] + [DataRow(SupportedHttpVerb.Put, "/api/Book/id/one", REQUEST_BODY_WITH_CORRECT_PARAM_TYPES, "Invalid value provided for field: id", DisplayName = "Validates the error message for a PUT request with incorrect primary key parameter type on a table in production mode")] + [DataRow(SupportedHttpVerb.Put, "/api/Book/id/1", REQUEST_BODY_WITH_INCORRECT_PARAM_TYPES, "Invalid value provided for field: publisher_id", DisplayName = "Validates the error message for a bad PUT request with incorrect parameter type in the request body on a table in production mode")] + [DataRow(SupportedHttpVerb.Patch, "/api/Book/id/one", REQUEST_BODY_WITH_CORRECT_PARAM_TYPES, "Invalid value provided for field: id", DisplayName = "Validates the error message for a PATCH request with incorrect primary key parameter type on a table in production mode")] + [DataRow(SupportedHttpVerb.Patch, "/api/Book/id/1", REQUEST_BODY_WITH_INCORRECT_PARAM_TYPES, "Invalid value provided for field: publisher_id", DisplayName = "Validates the error message for a PATCH request with incorrect parameter type in the request body on a table in production mode")] + [DataRow(SupportedHttpVerb.Delete, "/api/Book/id/one", REQUEST_BODY_WITH_CORRECT_PARAM_TYPES, "Invalid value provided for field: id", DisplayName = "Validates the error message for a DELETE request with incorrect primary key parameter type on a table in production mode")] + [DataRow(SupportedHttpVerb.Delete, "/api/Book/id/1", REQUEST_BODY_WITH_INCORRECT_PARAM_TYPES, "Invalid value provided for field: publisher_id", DisplayName = "Validates the error message for a DELETE request with incorrect parameter type in the request body on a table in production mode")] + [DataRow(SupportedHttpVerb.Post, "/api/GetBooks", REQUEST_BODY_WITH_INCORRECT_PARAM_TYPES, "Invalid value provided for field: publisher_id", DisplayName = "Validates the error message for a POST request with incorrect parameter type in the request body on a stored-procedure in production mode")] + public async Task TestGenericErrorMessageForRestApiInProductionMode( + SupportedHttpVerb requestType, + string requestPath, + string requestBody, + string expectedErrorMessage) { - // Check if file values are expected to exist - bool isEnabledNull = enabled is null; - bool isPathNull = path is null; - bool isRollingIntervalNull = rollingInterval is null; - bool isRetainedFileCountLimitNull = retainedFileCountLimit is null; - bool isFileSizeLimitBytesNull = fileSizeLimitBytes is null; - - FileSinkOptions fileOptions = new(enabled, path, rollingInterval, retainedFileCountLimit, fileSizeLimitBytes); - TelemetryOptions telemetryOptions = new(File: fileOptions); - RuntimeConfig configWithCustomLogLevel = InitializeRuntimeWithTelemetry(telemetryOptions); - string configWithCustomLogLevelJson = configWithCustomLogLevel.ToJson(); - Assert.IsTrue(RuntimeConfigLoader.TryParseConfig(configWithCustomLogLevelJson, out RuntimeConfig? deserializedRuntimeConfig)); - - string serializedConfig = deserializedRuntimeConfig.ToJson(); - - using (JsonDocument parsedDocument = JsonDocument.Parse(serializedConfig)) + const string CUSTOM_CONFIG = "custom-config.json"; + TestHelper.ConstructNewConfigWithSpecifiedHostMode(CUSTOM_CONFIG, HostMode.Production, TestCategory.MSSQL); + string[] args = new[] { - JsonElement root = parsedDocument.RootElement; - JsonElement runtimeElement = root.GetProperty("runtime"); - - // Validate file property exists in runtime - JsonElement telemetryElement = runtimeElement.GetProperty("telemetry"); - bool filePropertyExists = telemetryElement.TryGetProperty("file", out JsonElement fileElement); - Assert.AreEqual(expected: true, actual: filePropertyExists); - - // Validate the values inside the file properties are of expected value - bool enabledExists = fileElement.TryGetProperty("enabled", out JsonElement enabledElement); - Assert.AreEqual(expected: !isEnabledNull, actual: enabledExists); - if (enabledExists) - { - Assert.AreEqual(expectedEnabled, enabledElement.GetBoolean()); - } - - bool pathExists = fileElement.TryGetProperty("path", out JsonElement pathElement); - Assert.AreEqual(expected: !isPathNull, actual: pathExists); - if (pathExists) - { - Assert.AreEqual(expectedPath, pathElement.GetString()); - } + $"--ConfigFileName={CUSTOM_CONFIG}" + }; - bool rollingIntervalExists = fileElement.TryGetProperty("rolling-interval", out JsonElement rollingIntervalElement); - Assert.AreEqual(expected: !isRollingIntervalNull, actual: rollingIntervalExists); - if (rollingIntervalExists) + using (TestServer server = new(Program.CreateWebHostBuilder(args))) + using (HttpClient client = server.CreateClient()) + { + HttpMethod httpMethod = SqlTestHelper.ConvertRestMethodToHttpMethod(requestType); + HttpRequestMessage request; + if (requestType is SupportedHttpVerb.Get || requestType is SupportedHttpVerb.Delete) { - Assert.AreEqual(expectedRollingInterval.ToString(), rollingIntervalElement.GetString()); + request = new(httpMethod, requestPath); } - - bool retainedFileCountLimitExists = fileElement.TryGetProperty("retained-file-count-limit", out JsonElement retainedFileCountLimitElement); - Assert.AreEqual(expected: !isRetainedFileCountLimitNull, actual: retainedFileCountLimitExists); - if (retainedFileCountLimitExists) + else { - Assert.AreEqual(expectedRetainedFileCountLimit, retainedFileCountLimitElement.GetInt32()); + request = new(httpMethod, requestPath) + { + Content = JsonContent.Create(requestBody) + }; } - bool fileSizeLimitBytesExists = fileElement.TryGetProperty("file-size-limit-bytes", out JsonElement fileSizeLimitBytesElement); - Assert.AreEqual(expected: !isFileSizeLimitBytesNull, actual: fileSizeLimitBytesExists); - if (fileSizeLimitBytesExists) - { - Assert.AreEqual(expectedFileSizeLimitBytes, fileSizeLimitBytesElement.GetInt32()); - } + HttpResponseMessage response = await client.SendAsync(request); + string body = await response.Content.ReadAsStringAsync(); + Assert.AreEqual(HttpStatusCode.BadRequest, response.StatusCode); + Assert.IsTrue(body.Contains(expectedErrorMessage)); } } /// - /// Test validates that autoentities section can be deserialized and serialized correctly. + /// Validates the REST HTTP methods that are enabled for Stored Procedures when + /// some of the default fields are absent in the config file. + /// When methods section is not defined explicitly in the config file, only POST + /// method should be enabled for Stored Procedures. /// [DataTestMethod] [TestCategory(TestCategory.MSSQL)] - [DataRow(null, null, null, null, null, null, null, null, null, "anonymous", EntityActionOperation.Read)] - [DataRow(new[] { "%.%" }, new[] { "%.%" }, "{object}", true, true, true, false, 5, EntityCacheLevel.L1L2, "anonymous", EntityActionOperation.Read)] - [DataRow(new[] { "books.%" }, new[] { "books.pages.%" }, "books_{object}", false, false, false, true, 2147483647, EntityCacheLevel.L1, "test-user", EntityActionOperation.Delete)] - [DataRow(new[] { "books.%" }, null, "books_{object}", false, null, false, null, 2147483647, null, "test-user", EntityActionOperation.Delete)] - [DataRow(null, new[] { "books.pages.%" }, null, null, false, null, true, null, EntityCacheLevel.L1, "test-user", EntityActionOperation.Delete)] - [DataRow(new[] { "title.%", "books.%", "names.%" }, new[] { "names.%", "%.%" }, "{schema}.{object}", true, false, false, true, 1, null, "second-test-user", EntityActionOperation.Create)] - public void TestAutoEntitiesSerializationDeserialization( - string[]? include, - string[]? exclude, - string? name, - bool? restEnabled, - bool? graphqlEnabled, - bool? healthCheckEnabled, - bool? cacheEnabled, - int? cacheTTL, - EntityCacheLevel? cacheLevel, - string role, - EntityActionOperation entityActionOp) + [DataRow(SP_CONFIG_WITH_NO_REST_SETTINGS, SupportedHttpVerb.Post, "/api/GetBooks", HttpStatusCode.Created, DisplayName = "SP - REST POST enabled when no REST section is present")] + [DataRow(SP_CONFIG_WITH_NO_REST_SETTINGS, SupportedHttpVerb.Get, "/api/GetBooks", HttpStatusCode.MethodNotAllowed, DisplayName = "SP - REST GET disabled when no REST section is present")] + [DataRow(SP_CONFIG_WITH_NO_REST_SETTINGS, SupportedHttpVerb.Patch, "/api/GetBooks", HttpStatusCode.MethodNotAllowed, DisplayName = "SP - REST PATCH disabled when no REST section is present")] + [DataRow(SP_CONFIG_WITH_NO_REST_SETTINGS, SupportedHttpVerb.Put, "/api/GetBooks", HttpStatusCode.MethodNotAllowed, DisplayName = "SP - REST PUT disabled when no REST section is present")] + [DataRow(SP_CONFIG_WITH_NO_REST_SETTINGS, SupportedHttpVerb.Delete, "/api/GetBooks", HttpStatusCode.MethodNotAllowed, DisplayName = "SP - REST DELETE disabled when no REST section is present")] + [DataRow(SP_CONFIG_WITH_ONLY_PATH_IN_REST_SETTINGS, SupportedHttpVerb.Post, "/api/get_books/", HttpStatusCode.Created, DisplayName = "SP - REST POST enabled when only a custom path is defined")] + [DataRow(SP_CONFIG_WITH_ONLY_PATH_IN_REST_SETTINGS, SupportedHttpVerb.Get, "/api/get_books/", HttpStatusCode.MethodNotAllowed, DisplayName = "SP - REST GET disabled when only a custom path is defined")] + [DataRow(SP_CONFIG_WITH_ONLY_PATH_IN_REST_SETTINGS, SupportedHttpVerb.Patch, "/api/get_books/", HttpStatusCode.MethodNotAllowed, DisplayName = "SP - REST PATCH disabled when only a custom path is defined")] + [DataRow(SP_CONFIG_WITH_ONLY_PATH_IN_REST_SETTINGS, SupportedHttpVerb.Delete, "/api/get_books/", HttpStatusCode.MethodNotAllowed, DisplayName = "SP - REST DELETE disabled when only a custom path is defined")] + [DataRow(SP_CONFIG_WITH_JUST_METHODS_IN_REST_SETTINGS, SupportedHttpVerb.Post, "/api/GetBooks/", HttpStatusCode.MethodNotAllowed, DisplayName = "SP - REST POST disabled by not specifying in the methods section")] + [DataRow(SP_CONFIG_WITH_JUST_METHODS_IN_REST_SETTINGS, SupportedHttpVerb.Get, "/api/GetBooks/", HttpStatusCode.OK, DisplayName = "SP - REST GET enabled by specifying in the methods section")] + [DataRow(SP_CONFIG_WITH_JUST_METHODS_IN_REST_SETTINGS, SupportedHttpVerb.Patch, "/api/GetBooks/", HttpStatusCode.MethodNotAllowed, DisplayName = "SP - REST PATCH disabled by not specifying in the methods section")] + [DataRow(SP_CONFIG_WITH_JUST_METHODS_IN_REST_SETTINGS, SupportedHttpVerb.Put, "/api/GetBooks/", HttpStatusCode.MethodNotAllowed, DisplayName = "SP - REST PUT disabled by not specifying in the methods section")] + [DataRow(SP_CONFIG_WITH_JUST_METHODS_IN_REST_SETTINGS, SupportedHttpVerb.Delete, "/api/GetBooks/", HttpStatusCode.MethodNotAllowed, DisplayName = "SP - REST DELETE disabled by not specifying in the methods section")] + [DataRow(SP_CONFIG_WITH_REST_DISABLED, SupportedHttpVerb.Get, "/api/GetBooks", HttpStatusCode.NotFound, DisplayName = "SP - REST GET disabled by configuring enabled as false")] + [DataRow(SP_CONFIG_WITH_REST_DISABLED, SupportedHttpVerb.Post, "/api/GetBooks", HttpStatusCode.NotFound, DisplayName = "SP - REST POST disabled by configuring enabled as false")] + [DataRow(SP_CONFIG_WITH_REST_DISABLED, SupportedHttpVerb.Patch, "/api/GetBooks", HttpStatusCode.NotFound, DisplayName = "SP - REST PATCH disabled by configuring enabled as false")] + [DataRow(SP_CONFIG_WITH_REST_DISABLED, SupportedHttpVerb.Put, "/api/GetBooks", HttpStatusCode.NotFound, DisplayName = "SP - REST PUT disabled by configuring enabled as false")] + [DataRow(SP_CONFIG_WITH_REST_DISABLED, SupportedHttpVerb.Delete, "/api/GetBooks", HttpStatusCode.NotFound, DisplayName = "SP - REST DELETE disabled by configuring enabled as false")] + [DataRow(SP_CONFIG_WITH_JUST_REST_ENABLED, SupportedHttpVerb.Get, "/api/GetBooks", HttpStatusCode.MethodNotAllowed, DisplayName = "SP - REST GET is disabled when enabled flag is configured to true")] + [DataRow(SP_CONFIG_WITH_JUST_REST_ENABLED, SupportedHttpVerb.Post, "/api/GetBooks", HttpStatusCode.Created, DisplayName = "SP - REST POST is enabled when enabled flag is configured to true")] + [DataRow(SP_CONFIG_WITH_JUST_REST_ENABLED, SupportedHttpVerb.Patch, "/api/GetBooks", HttpStatusCode.MethodNotAllowed, DisplayName = "SP - REST PATCH is disabled when enabled flag is configured to true")] + [DataRow(SP_CONFIG_WITH_JUST_REST_ENABLED, SupportedHttpVerb.Put, "/api/GetBooks", HttpStatusCode.MethodNotAllowed, DisplayName = "SP - REST PUT disabled when enabled flag is configured to true")] + [DataRow(SP_CONFIG_WITH_JUST_REST_ENABLED, SupportedHttpVerb.Delete, "/api/GetBooks", HttpStatusCode.MethodNotAllowed, DisplayName = "SP - REST DELETE disabled when enabled flag is configured to true")] + public async Task TestSPRestDefaultsForManuallyConstructedConfigs( + string entityJson, + SupportedHttpVerb requestType, + string requestPath, + HttpStatusCode expectedResponseStatusCode) { - TestHelper.SetupDatabaseEnvironment(MSSQL_ENVIRONMENT); - - Dictionary createdAutoentity = new(); - createdAutoentity.Add("test-entity", - new Autoentity( - Patterns: new AutoentityPatterns(include, exclude, name), - Template: new AutoentityTemplate( - Rest: restEnabled == null ? null : new EntityRestOptions(Enabled: (bool)restEnabled), - GraphQL: graphqlEnabled == null ? null : new EntityGraphQLOptions(Singular: string.Empty, Plural: string.Empty, Enabled: (bool)graphqlEnabled), - Health: healthCheckEnabled == null ? null : new EntityHealthCheckConfig(healthCheckEnabled), - Cache: (cacheEnabled == null && cacheTTL == null && cacheLevel == null) ? null : new EntityCacheOptions(Enabled: cacheEnabled, TtlSeconds: cacheTTL, Level: cacheLevel) - ), - Permissions: new EntityPermission[1])); - - EntityAction[] entityActions = new EntityAction[] { new(entityActionOp, null, null) }; - createdAutoentity["test-entity"].Permissions[0] = new EntityPermission(role, entityActions); - RuntimeAutoentities autoentities = new(createdAutoentity); - - FileSystemRuntimeConfigLoader baseLoader = TestHelper.GetRuntimeConfigLoader(); - baseLoader.TryLoadKnownConfig(out RuntimeConfig? baseConfig); + string configJson = TestHelper.AddPropertiesToJson(TestHelper.BASE_CONFIG, entityJson); + RuntimeConfigLoader.TryParseConfig( + configJson, + out RuntimeConfig deserializedConfig, + replacementSettings: new(), + logger: null, + GetConnectionStringFromEnvironmentConfig(environment: TestCategory.MSSQL)); + string configFileName = "custom-config.json"; + File.WriteAllText(configFileName, deserializedConfig.ToJson()); + string[] args = new[] + { + $"--ConfigFileName={configFileName}" + }; - RuntimeConfig config = new( - Schema: baseConfig!.Schema, - DataSource: baseConfig.DataSource, - Runtime: new( - Rest: new(), - GraphQL: new(), - Mcp: new(), - Host: new(null, null), - Telemetry: new() - ), - Entities: baseConfig.Entities, - Autoentities: autoentities - ); + using (TestServer server = new(Program.CreateWebHostBuilder(args))) + using (HttpClient client = server.CreateClient()) + { + HttpMethod httpMethod = SqlTestHelper.ConvertRestMethodToHttpMethod(requestType); + HttpRequestMessage request = new(httpMethod, requestPath); + HttpResponseMessage response = await client.SendAsync(request); + Assert.AreEqual(expectedResponseStatusCode, response.StatusCode); + } + } - string configWithCustomJson = config.ToJson(); - Assert.IsTrue(RuntimeConfigLoader.TryParseConfig(configWithCustomJson, out RuntimeConfig? deserializedRuntimeConfig)); + /// + /// Validates that deserialization of config file is successful for the following scenarios: + /// 1. Multiple Mutations section is null + /// { + /// "multiple-mutations": null + /// } + /// + /// 2. Multiple Mutations section is empty. + /// { + /// "multiple-mutations": {} + /// } + /// + /// 3. Create field within Multiple Mutation section is null. + /// { + /// "multiple-mutations": { + /// "create": null + /// } + /// } + /// + /// 4. Create field within Multiple Mutation section is empty. + /// { + /// "multiple-mutations": { + /// "create": {} + /// } + /// } + /// + /// For all the above mentioned scenarios, the expected value for MultipleMutationOptions field is null. + /// + /// Base Config Json string. + [DataTestMethod] + [DataRow(TestHelper.BASE_CONFIG_NULL_MULTIPLE_MUTATIONS_FIELD, DisplayName = "MultipleMutationOptions field deserialized as null when multiple mutation section is null")] + [DataRow(TestHelper.BASE_CONFIG_EMPTY_MULTIPLE_MUTATIONS_FIELD, DisplayName = "MultipleMutationOptions field deserialized as null when multiple mutation section is empty")] + [DataRow(TestHelper.BASE_CONFIG_NULL_MULTIPLE_CREATE_FIELD, DisplayName = "MultipleMutationOptions field deserialized as null when create field within multiple mutation section is null")] + [DataRow(TestHelper.BASE_CONFIG_EMPTY_MULTIPLE_CREATE_FIELD, DisplayName = "MultipleMutationOptions field deserialized as null when create field within multiple mutation section is empty")] + public void ValidateDeserializationOfConfigWithNullOrEmptyInvalidMultipleMutationSection(string baseConfig) + { + string configJson = TestHelper.AddPropertiesToJson(baseConfig, BOOK_ENTITY_JSON); + Assert.IsTrue(RuntimeConfigLoader.TryParseConfig(configJson, out RuntimeConfig deserializedConfig)); + Assert.IsNotNull(deserializedConfig.Runtime); + Assert.IsNotNull(deserializedConfig.Runtime.GraphQL); + Assert.IsNull(deserializedConfig.Runtime.GraphQL.MultipleMutationOptions); + } - string serializedConfig = deserializedRuntimeConfig.ToJson(); + /// + /// Sanity check to validate that DAB engine starts successfully when used with a config file without the multiple + /// mutations feature flag section. + /// The runtime graphql section of the config file used looks like this: + /// + /// "graphql": { + /// "path": "/graphql", + /// "allow-introspection": true + /// } + /// + /// Without the multiple mutations feature flag section, DAB engine should be able to + /// 1. Successfully deserialize the config file without multiple mutation section. + /// 2. Process REST and GraphQL API requests. + /// + /// + [TestMethod] + [TestCategory(TestCategory.MSSQL)] + public async Task SanityTestForRestAndGQLRequestsWithoutMultipleMutationFeatureFlagSection() + { + // The configuration file is constructed by merging hard-coded JSON strings to simulate the scenario where users manually edit the + // configuration file (instead of using CLI). + string configJson = TestHelper.AddPropertiesToJson(TestHelper.BASE_CONFIG, BOOK_ENTITY_JSON); + Assert.IsTrue(RuntimeConfigLoader.TryParseConfig( + configJson, + out RuntimeConfig deserializedConfig, + replacementSettings: new(), + logger: null, + GetConnectionStringFromEnvironmentConfig(environment: TestCategory.MSSQL))); + string configFileName = "custom-config.json"; + File.WriteAllText(configFileName, deserializedConfig.ToJson()); + string[] args = new[] + { + $"--ConfigFileName={configFileName}" + }; - using (JsonDocument parsedDocument = JsonDocument.Parse(serializedConfig)) + using (TestServer server = new(Program.CreateWebHostBuilder(args))) + using (HttpClient client = server.CreateClient()) { - JsonElement root = parsedDocument.RootElement; - JsonElement autoentitiesElement = root.GetProperty("autoentities"); - - bool entityExists = autoentitiesElement.TryGetProperty("test-entity", out JsonElement entityElement); - Assert.AreEqual(expected: true, actual: entityExists); - - // Validate patterns properties and their values exists in autoentities - bool expectedPatternsExist = include != null || exclude != null || name != null; - bool patternsExists = entityElement.TryGetProperty("patterns", out JsonElement patternsElement); - Assert.AreEqual(expected: expectedPatternsExist, actual: patternsExists); + // Act + RuntimeConfigProvider configProvider = server.Services.GetService(); + using HttpRequestMessage restRequest = new(HttpMethod.Get, "/api/Book"); + using HttpResponseMessage restResponse = await client.SendAsync(restRequest); - if (patternsExists) + string graphqlQuery = @" { - bool includeExists = patternsElement.TryGetProperty("include", out JsonElement includeElement); - Assert.AreEqual(expected: (include != null), actual: includeExists); - if (includeExists) - { - CollectionAssert.AreEqual(expected: include, actual: includeElement.EnumerateArray().Select(e => e.GetString()).ToArray()); - } - - bool excludeExists = patternsElement.TryGetProperty("exclude", out JsonElement excludeElement); - Assert.AreEqual(expected: (exclude != null), actual: excludeExists); - if (excludeExists) - { - CollectionAssert.AreEqual(expected: exclude, actual: excludeElement.EnumerateArray().Select(e => e.GetString()).ToArray()); - } - - bool nameExists = patternsElement.TryGetProperty("name", out JsonElement nameElement); - Assert.AreEqual(expected: (name != null), actual: nameExists); - if (nameExists) - { - Assert.AreEqual(expected: name, actual: nameElement.GetString()); + books { + items { + id + title + } } - } - - // Validate template properties and their values exists in autoentities - bool expectedTemplateExist = restEnabled != null || graphqlEnabled != null || healthCheckEnabled != null - || cacheEnabled != null || cacheLevel != null || cacheTTL != null; - bool templateExists = entityElement.TryGetProperty("template", out JsonElement templateElement); - Assert.AreEqual(expected: expectedTemplateExist, actual: templateExists); + }"; - if (templateExists) + object graphqlPayload = new { query = graphqlQuery }; + HttpRequestMessage graphqlRequest = new(HttpMethod.Post, "/graphql") { - bool restPropertyExists = templateElement.TryGetProperty("rest", out JsonElement restElement); - Assert.AreEqual(expected: (restEnabled != null), actual: restPropertyExists); - if (restPropertyExists) - { - Assert.IsTrue(restElement.TryGetProperty("enabled", out JsonElement restEnabledElement)); - Assert.AreEqual(expected: restEnabled, actual: restEnabledElement.GetBoolean()); - } - - bool graphqlPropertyExists = templateElement.TryGetProperty("graphql", out JsonElement graphqlElement); - Assert.AreEqual(expected: (graphqlEnabled != null), actual: graphqlPropertyExists); - if (graphqlPropertyExists) - { - Assert.IsTrue(graphqlElement.TryGetProperty("enabled", out JsonElement graphqlEnabledElement)); - Assert.AreEqual(expected: graphqlEnabled, actual: graphqlEnabledElement.GetBoolean()); - } - - bool healthPropertyExists = templateElement.TryGetProperty("health", out JsonElement healthElement); - Assert.AreEqual(expected: (healthCheckEnabled != null), actual: healthPropertyExists); - if (healthPropertyExists) - { - Assert.IsTrue(healthElement.TryGetProperty("enabled", out JsonElement healthEnabledElement)); - Assert.AreEqual(expected: healthCheckEnabled, actual: healthEnabledElement.GetBoolean()); - } + Content = JsonContent.Create(graphqlPayload) + }; + HttpResponseMessage graphqlResponse = await client.SendAsync(graphqlRequest); - bool expectedCacheExist = cacheEnabled != null || cacheTTL != null || cacheLevel != null; - bool cachePropertyExists = templateElement.TryGetProperty("cache", out JsonElement cacheElement); - Assert.AreEqual(expected: expectedCacheExist, actual: cachePropertyExists); - if (cacheEnabled != null) - { - Assert.IsTrue(cacheElement.TryGetProperty("enabled", out JsonElement cacheEnabledElement)); - Assert.AreEqual(expected: cacheEnabled, actual: cacheEnabledElement.GetBoolean()); - } + // Assert + string expectedResponseFragment = @"{""id"":1156,""title"":""The First Publisher""}"; - if (cacheTTL != null) - { - Assert.IsTrue(cacheElement.TryGetProperty("ttl-seconds", out JsonElement cacheTtlElement)); - Assert.AreEqual(expected: cacheTTL, actual: cacheTtlElement.GetInt32()); - } + // Verify number of entities + Assert.AreEqual(expectedEntityCount, configProvider.GetConfig().Entities.Entities.Count, "Number of generated entities is not what is expected"); - if (cacheLevel != null) - { - Assert.IsTrue(cacheElement.TryGetProperty("level", out JsonElement cacheLevelElement)); - Assert.IsTrue(string.Equals(cacheLevel.ToString(), cacheLevelElement.GetString(), StringComparison.OrdinalIgnoreCase)); - } - } + // Verify REST response + Assert.AreEqual(HttpStatusCode.OK, restResponse.StatusCode, "REST request to auto-generated entity should succeed"); - // Validate permissions properties and their values exists in autoentities - JsonElement permissionsElement = entityElement.GetProperty("permissions"); + string restResponseBody = await restResponse.Content.ReadAsStringAsync(); + Assert.IsTrue(!string.IsNullOrEmpty(restResponseBody), "REST response should contain data"); + Assert.IsTrue(restResponseBody.Contains(expectedResponseFragment)); - bool roleExists = permissionsElement[0].TryGetProperty("role", out JsonElement roleElement); - Assert.AreEqual(expected: true, actual: roleExists); - Assert.AreEqual(expected: role, actual: roleElement.GetString()); + // Verify GraphQL response + Assert.AreEqual(HttpStatusCode.OK, graphqlResponse.StatusCode, "GraphQL request to auto-generated entity should succeed"); - bool entityActionsExists = permissionsElement[0].TryGetProperty("actions", out JsonElement entityActionsElement); - Assert.AreEqual(expected: true, actual: entityActionsExists); - bool entityActionOpExists = entityActionsElement[0].TryGetProperty("action", out JsonElement entityActionOpElement); - Assert.AreEqual(expected: true, actual: entityActionOpExists); - Assert.IsTrue(string.Equals(entityActionOp.ToString(), entityActionOpElement.GetString(), StringComparison.OrdinalIgnoreCase)); + string graphqlResponseBody = await graphqlResponse.Content.ReadAsStringAsync(); + Assert.IsTrue(!string.IsNullOrEmpty(graphqlResponseBody), "GraphQL response should contain data"); + Assert.IsFalse(graphqlResponseBody.Contains("errors"), "GraphQL response should not contain errors"); + Assert.IsTrue(graphqlResponseBody.Contains(expectedResponseFragment)); } } -#nullable disable - /// - /// Helper method to create RuntimeConfig with specified Telemetry options + /// Executing MCP POST requests against the engine until a non-503 error is received. /// - private static RuntimeConfig InitializeRuntimeWithTelemetry(TelemetryOptions telemetryOptions) + /// Client used for request execution. + /// ServiceUnavailable if service is not successfully hydrated with config, + /// else the response code from the MCP request + public static async Task GetMcpResponse(HttpClient httpClient, McpRuntimeOptions mcp) { - TestHelper.SetupDatabaseEnvironment(MSSQL_ENVIRONMENT); + // Retry request RETRY_COUNT times in exponential increments to allow + // required services time to instantiate and hydrate permissions because + // the DAB services may take an unpredictable amount of time to become ready. + // + // The service might still fail due to the service not being available yet, + // but it is highly unlikely to be the case. + int retryCount = 0; + HttpStatusCode responseCode = HttpStatusCode.ServiceUnavailable; + while (retryCount < RETRY_COUNT) + { + // Minimal MCP request (initialize) - valid JSON-RPC request. + // Using 'initialize' because 'tools/list' requires an active session + // in the MCP Streamable HTTP transport (ModelContextProtocol 1.0.0). + object payload = new + { + jsonrpc = "2.0", + id = 1, + method = "initialize", + @params = new + { + protocolVersion = "2025-03-26", + capabilities = new { }, + clientInfo = new { name = "dab-test", version = "1.0.0" } + } + }; + HttpRequestMessage mcpRequest = new(HttpMethod.Post, mcp.Path) + { + Content = JsonContent.Create(payload) + }; + mcpRequest.Headers.Add("Accept", "application/json, text/event-stream"); - FileSystemRuntimeConfigLoader baseLoader = TestHelper.GetRuntimeConfigLoader(); - baseLoader.TryLoadKnownConfig(out RuntimeConfig baseConfig); + HttpResponseMessage mcpResponse = await httpClient.SendAsync(mcpRequest); + responseCode = mcpResponse.StatusCode; - RuntimeConfig config = new( - Schema: baseConfig.Schema, - DataSource: baseConfig.DataSource, - Runtime: new( - Rest: new(), - GraphQL: new(), - Mcp: new(), - Host: new(null, null), - Telemetry: telemetryOptions - ), - Entities: baseConfig.Entities - ); + if (responseCode == HttpStatusCode.ServiceUnavailable || responseCode == HttpStatusCode.NotFound) + { + retryCount++; + await Task.Delay(TimeSpan.FromSeconds(Math.Pow(RETRY_WAIT_SECONDS, retryCount))); + continue; + } - return config; + break; + } + + return responseCode; } /// - /// Validates the OpenAPI documentor behavior when enabling and disabling the global REST endpoint - /// for the DAB engine. - /// Global REST enabled: - /// - GET to /openapi returns the created OpenAPI document and succeeds with 200 OK. - /// Global REST disabled: - /// - GET to /openapi fails with 404 Not Found. + /// Helper method to instantiate RuntimeConfig object needed for multiple create tests. /// - [DataTestMethod] - [DataRow(true, false, DisplayName = "Global REST endpoint enabled - successful OpenAPI doc retrieval")] - [DataRow(false, true, DisplayName = "Global REST endpoint disabled - OpenAPI doc does not exist - HTTP404 NotFound.")] - [TestCategory(TestCategory.MSSQL)] - public async Task OpenApi_GlobalEntityRestPath(bool globalRestEnabled, bool expectsError) - { - // At least one entity is required in the runtime config for the engine to start. - // Even though this entity is not under test, it must be supplied to the config - // file creation function. - Entity requiredEntity = new( - Source: new("books", EntitySourceType.Table, null, null), - Fields: null, - Rest: new(Enabled: false), - GraphQL: new("book", "books"), - Permissions: new[] { GetMinimalPermissionConfig(AuthorizationResolver.ROLE_ANONYMOUS) }, - Relationships: null, - Mappings: null); - - Dictionary entityMap = new() + public static RuntimeConfig InitialzieRuntimeConfigForMultipleCreateTests(bool isMultipleCreateOperationEnabled) { - { "Book", requiredEntity } - }; + // Multiple create operations are enabled. + GraphQLRuntimeOptions graphqlOptions = new(Enabled: true, MultipleMutationOptions: new(new(enabled: isMultipleCreateOperationEnabled))); - CreateCustomConfigFile(entityMap, globalRestEnabled); + RestRuntimeOptions restRuntimeOptions = new(Enabled: false); - string[] args = new[] - { - $"--ConfigFileName={CUSTOM_CONFIG_FILENAME}" - }; + McpRuntimeOptions mcpRuntimeOptions = new(Enabled: false); - using TestServer server = new(Program.CreateWebHostBuilder(args)); - using HttpClient client = server.CreateClient(); - // Setup and send GET request - HttpRequestMessage readOpenApiDocumentRequest = new(HttpMethod.Get, $"{RestRuntimeOptions.DEFAULT_PATH}/{OPENAPI_DOCUMENT_ENDPOINT}"); - HttpResponseMessage response = await client.SendAsync(readOpenApiDocumentRequest); + DataSource dataSource = new(DatabaseType.MSSQL, GetConnectionStringFromEnvironmentConfig(environment: TestCategory.MSSQL), Options: null); - // Validate response - if (expectsError) - { - Assert.AreEqual(HttpStatusCode.NotFound, response.StatusCode); - } - else - { - // Process response body - string responseBody = await response.Content.ReadAsStringAsync(); - Dictionary responseProperties = JsonSerializer.Deserialize>(responseBody); + EntityAction createAction = new( + Action: EntityActionOperation.Create, + Fields: null, + Policy: new()); - // Validate response body - Assert.AreEqual(HttpStatusCode.OK, response.StatusCode); - ValidateOpenApiDocTopLevelPropertiesExist(responseProperties); - } - } + EntityAction readAction = new( + Action: EntityActionOperation.Read, + Fields: null, + Policy: new()); - /// - /// Simulates a GET request to DAB's health check endpoint ('/') and validates the contents of the response. - /// The expected format of the response is: - /// { - /// "status": "Healthy", - /// "version": "0.12.0", - /// "appName": "dab_oss_0.12.0" - /// } - /// - the 'version' property format is 'major.minor.patch' - /// - [TestMethod] - [TestCategory(TestCategory.MSSQL)] - public async Task HealthEndpoint_ValidateContents() - { - // Arrange - // At least one entity is required in the runtime config for the engine to start. - // Even though this entity is not under test, it must be supplied enable successfull - // config file creation. - Entity requiredEntity = new( - Source: new("books", EntitySourceType.Table, null, null), + EntityAction deleteAction = new( + Action: EntityActionOperation.Delete, Fields: null, - Rest: new(Enabled: false), - GraphQL: new("book", "books"), - Permissions: new[] { GetMinimalPermissionConfig(AuthorizationResolver.ROLE_ANONYMOUS) }, - Relationships: null, - Mappings: null); + Policy: new()); - Dictionary entityMap = new() - { - { "Book", requiredEntity } - }; + EntityPermission[] permissions = new[] {new EntityPermission( Role: AuthorizationResolver.ROLE_ANONYMOUS , Actions: new[] { createAction }), + new EntityPermission( Role: AuthorizationResolver.ROLE_AUTHENTICATED , Actions: new[] { readAction, createAction, deleteAction })}; - CreateCustomConfigFile(entityMap, enableGlobalRest: true); + Entity entity = new(Source: new("stocks", EntitySourceType.Table, null, null), + Fields: null, + Rest: null, + GraphQL: new(Singular: "Stock", Plural: "Stocks"), + Permissions: permissions, + Relationships: null, + Mappings: null); + + string entityName = "Stock"; + RuntimeConfig configuration = InitMinimalRuntimeConfig(dataSource, graphqlOptions, restRuntimeOptions, mcpRuntimeOptions, entity, entityName); + const string CUSTOM_CONFIG = "custom-config.json"; + File.WriteAllText(CUSTOM_CONFIG, configuration.ToJson()); string[] args = new[] { - $"--ConfigFileName={CUSTOM_CONFIG_FILENAME}" + $"--ConfigFileName={CUSTOM_CONFIG}" }; - using TestServer server = new(Program.CreateWebHostBuilder(args)); - using HttpClient client = server.CreateClient(); - - // Setup and send GET request to root path. - HttpRequestMessage getHealthEndpointContents = new(HttpMethod.Get, $"/"); - - // Act - Exercise the health check endpoint code by requesting the health endpoint path '/'. - HttpResponseMessage response = await client.SendAsync(getHealthEndpointContents); - - // Assert - Process response body and validate contents. - // Validate HTTP return code. - string responseBody = await response.Content.ReadAsStringAsync(); - Dictionary responseProperties = JsonSerializer.Deserialize>(responseBody); - Assert.AreEqual(expected: HttpStatusCode.OK, actual: response.StatusCode, message: "Received unexpected HTTP code from health check endpoint."); - - HealthEndpointTests.ValidateBasicDetailsHealthCheckResponse(responseProperties); - } - - /// - /// Validates the behavior of the OpenApiDocumentor when the runtime config has entities with - /// REST endpoint enabled and disabled. - /// Enabled -> path should be created - /// Disabled -> path not created and is excluded from OpenApi document. - /// - [TestCategory(TestCategory.MSSQL)] - [TestMethod] - public async Task OpenApi_EntityLevelRestEndpoint() - { - // Create the entities under test. - Entity restEnabledEntity = new( - Source: new("books", EntitySourceType.Table, null, null), - Fields: null, - Rest: new(Enabled: true), - GraphQL: new("", "", false), - Permissions: new[] { GetMinimalPermissionConfig(AuthorizationResolver.ROLE_ANONYMOUS) }, - Relationships: null, - Mappings: null); - - Entity restDisabledEntity = new( - Source: new("publishers", EntitySourceType.Table, null, null), - Fields: null, - Rest: new(Enabled: false), - GraphQL: new("publisher", "publishers", true), - Permissions: new[] { GetMinimalPermissionConfig(AuthorizationResolver.ROLE_ANONYMOUS) }, - Relationships: null, - Mappings: null); + using (TestServer server = new(Program.CreateWebHostBuilder(args))) + using (HttpClient client = server.CreateClient()) + { - Dictionary entityMap = new() - { - { "Book", restEnabledEntity }, - { "Publisher", restDisabledEntity } - }; + // When multiple create operation is disabled, fields belonging to related entities are not generated for the input type objects of create operation. + // Executing a create mutation with fields belonging to related entities should be caught by Hotchocolate as unrecognized fields. + string pointMultipleCreateOperation = @"mutation createbook{ + createbook(item: { title: ""Book #1"", publishers: { name: ""The First Publisher"" } }) { + id + title + } + }"; - CreateCustomConfigFile(entityMap, enableGlobalRest: true); + JsonElement mutationResponse = await GraphQLRequestExecutor.PostGraphQLRequestAsync(client, + server.Services.GetRequiredService(), + query: pointMultipleCreateOperation, + queryName: "createbook", + variables: null, + clientRoleHeader: null); - string[] args = new[] - { - $"--ConfigFileName={CUSTOM_CONFIG_FILENAME}" - }; + Assert.IsNotNull(mutationResponse); - using TestServer server = new(Program.CreateWebHostBuilder(args)); - using HttpClient client = server.CreateClient(); - // Setup and send GET request - HttpRequestMessage readOpenApiDocumentRequest = new(HttpMethod.Get, $"{RestRuntimeOptions.DEFAULT_PATH}/{OpenApiDocumentor.OPENAPI_ROUTE}"); - HttpResponseMessage response = await client.SendAsync(readOpenApiDocumentRequest); + SqlTestHelper.TestForErrorInGraphQLResponse(mutationResponse.ToString(), + message: "The specified input object field `publishers` does not exist.", + path: @"[""createbook""]"); - // Parse response metadata - string responseBody = await response.Content.ReadAsStringAsync(); - Dictionary responseProperties = JsonSerializer.Deserialize>(responseBody); + // Sanity test to validate that executing a point create mutation with multiple create operation disabled, + // a) Creates the new item successfully. + // b) Returns the expected response. + string pointCreateOperation = @"mutation createbook{ + createbook(item: { title: ""Book #1"", publisher_id: 1234 }) { + title + publisher_id + } + }"; + + mutationResponse = await GraphQLRequestExecutor.PostGraphQLRequestAsync(client, + server.Services.GetRequiredService(), + query: pointCreateOperation, + queryName: "createbook", + variables: null, + clientRoleHeader: null); - // Validate response metadata - ValidateOpenApiDocTopLevelPropertiesExist(responseProperties); - JsonElement pathsElement = responseProperties[OpenApiDocumentorConstants.TOPLEVELPROPERTY_PATHS]; + string expectedResponse = @"{ ""title"":""Book #1"",""publisher_id"":1234}"; - // Validate that paths were created for the entity with REST enabled. - Assert.IsTrue(pathsElement.TryGetProperty("/Book", out _)); - Assert.IsTrue(pathsElement.TryGetProperty("/Book/id/{id}", out _)); + Assert.IsNotNull(mutationResponse); + SqlTestHelper.PerformTestEqualJsonStrings(expectedResponse, mutationResponse.ToString()); - // Validate that paths were not created for the entity with REST disabled. - Assert.IsFalse(pathsElement.TryGetProperty("/Publisher", out _)); - Assert.IsFalse(pathsElement.TryGetProperty("/Publisher/id/{id}", out _)); + // When a create multiple operation is enabled, the "publisher_id" field will be generated as an optional field in the schema. But, when multiple create operation is disabled, + // "publisher_id" should be a required field. + // With multiple create operation disabled, executing a create mutation operation without the "publisher_id" field is expected to be caught by HotChocolate + // as the schema should be generated with "publisher_id" as a required field. + string pointCreateOperationWithMissingFields = @"mutation createbook{ + createbook(item: { title: ""Book #1""}) { + title + publisher_id + } + }"; - JsonElement componentsElement = responseProperties[OpenApiDocumentorConstants.TOPLEVELPROPERTY_COMPONENTS]; - Assert.IsTrue(componentsElement.TryGetProperty(OpenApiDocumentorConstants.PROPERTY_SCHEMAS, out JsonElement componentSchemasElement)); - // Validate that components were created for the entity with REST enabled. - Assert.IsTrue(componentSchemasElement.TryGetProperty("Book_NoPK", out _)); - Assert.IsTrue(componentSchemasElement.TryGetProperty("Book", out _)); + mutationResponse = await GraphQLRequestExecutor.PostGraphQLRequestAsync(client, + server.Services.GetRequiredService(), + query: pointCreateOperationWithMissingFields, + queryName: "createbook", + variables: null, + clientRoleHeader: null); - // Validate that components were not created for the entity with REST disabled. - Assert.IsFalse(componentSchemasElement.TryGetProperty("Publisher_NoPK", out _)); - Assert.IsFalse(componentSchemasElement.TryGetProperty("Publisher", out _)); + Assert.IsNotNull(mutationResponse); + SqlTestHelper.TestForErrorInGraphQLResponse(response: mutationResponse.ToString(), + message: "Missing value for required column: publisher_id for entity: Book at level: 1."); + } } /// - /// This test validates that DAB properly creates and returns a nextLink with a single $after - /// query parameter when sending paging requests. - /// The first request initiates a paging workload, meaning the response is expected to have a nextLink. - /// The validation occurs after the second request which uses the previously acquired nextLink - /// This test ensures that the second request's response body contains the expected nextLink which: - /// - is base64 encoded and NOT URI escaped e.g. the trailing "==" are not URI escaped to "%3D%3D" - /// - is not the same as the first response's nextLink -> DAB is properly injecting a new $after query param - /// and updating the new nextLink - /// - does not contain a comma (,) indicating that the URI namevaluecollection tracking the query parameters - /// did not come across two $after query parameters. This addresses a customer raised issue where two $after - /// query parameters were returned by DAB. + /// For mutation operations, the respective mutation operation type(create/update/delete) + read permissions are needed to receive a valid response. + /// For graphQL requests, if read permission is configured for Anonymous role, then it is inherited by other roles. + /// In this test, Anonymous role has read permission configured. Authenticated role has only create permission configured. + /// A create mutation operation is executed in the context of Authenticated role and the response is expected to have no errors because + /// the read permission is inherited from Anonymous role. /// - [DataTestMethod] - [DataRow(false, DisplayName = "NextLinkRelative is false")] - [DataRow(true, DisplayName = "NextLinkRelative is true")] + [TestMethod] [TestCategory(TestCategory.MSSQL)] - public async Task ValidateNextLinkUsage(bool isNextLinkRelative) + public async Task ValidateInheritanceOfReadPermissionFromAnonymous() { - // Arrange - Setup test server with entity that has >1 record so that results can be paged. - // A short cut to using an entity with >100 records is to just include the $first=1 filter - // as done in this test, so that paging behavior can be invoked. + GraphQLRuntimeOptions graphqlOptions = new(Enabled: true); + RestRuntimeOptions restRuntimeOptions = new(Enabled: false); + McpRuntimeOptions mcpRuntimeOptions = new(Enabled: false); - const string ENTITY_NAME = "Bookmark"; + DataSource dataSource = new(DatabaseType.MSSQL, + GetConnectionStringFromEnvironmentConfig(environment: TestCategory.MSSQL), Options: null); - // At least one entity is required in the runtime config for the engine to start. - // Even though this entity is not under test, it must be supplied to the config - // file creation function. - Entity requiredEntity = new( - Source: new("bookmarks", EntitySourceType.Table, null, null), + EntityAction createAction = new( + Action: EntityActionOperation.Create, Fields: null, - Rest: new(Enabled: true), - GraphQL: new(Singular: "", Plural: "", Enabled: false), - Permissions: new[] { GetMinimalPermissionConfig(AuthorizationResolver.ROLE_ANONYMOUS) }, - Relationships: null, - Mappings: null); + Policy: new()); - Dictionary entityMap = new() - { - { ENTITY_NAME, requiredEntity } - }; + EntityAction readAction = new( + Action: EntityActionOperation.Read, + Fields: null, + Policy: new()); - PaginationOptions paginationOptions = null; + EntityAction deleteAction = new( + Action: EntityActionOperation.Delete, + Fields: null, + Policy: new()); - if (isNextLinkRelative) - { - paginationOptions = new PaginationOptions - { - DefaultPageSize = 1, - MaxPageSize = 1, - UserProvidedDefaultPageSize = true, - UserProvidedMaxPageSize = true, - NextLinkRelative = true - }; - } + EntityPermission[] permissions = new[] {new EntityPermission( Role: AuthorizationResolver.ROLE_ANONYMOUS , Actions: new[] { createAction, readAction, deleteAction }), + new EntityPermission( Role: AuthorizationResolver.ROLE_AUTHENTICATED , Actions: new[] { createAction })}; - CreateCustomConfigFile(entityMap, enableGlobalRest: true, paginationOptions: paginationOptions); + Entity entity = new(Source: new("stocks", EntitySourceType.Table, null, null), + Fields: null, + Rest: null, + GraphQL: new(Singular: "Stock", Plural: "Stocks"), + Permissions: permissions, + Relationships: null, + Mappings: null); + + string entityName = "Stock"; + RuntimeConfig configuration = InitMinimalRuntimeConfig(dataSource, graphqlOptions, restRuntimeOptions, mcpRuntimeOptions, entity, entityName); + const string CUSTOM_CONFIG = "custom-config.json"; + File.WriteAllText(CUSTOM_CONFIG, configuration.ToJson()); string[] args = new[] { - $"--ConfigFileName={CUSTOM_CONFIG_FILENAME}" + $"--ConfigFileName={CUSTOM_CONFIG}" }; - using TestServer server = new(Program.CreateWebHostBuilder(args)); - using HttpClient client = server.CreateClient(); - - // Setup and send GET request - HttpRequestMessage initialPaginationRequest = new(HttpMethod.Get, $"{RestRuntimeOptions.DEFAULT_PATH}/{ENTITY_NAME}?$first=1"); - HttpResponseMessage initialPaginationResponse = await client.SendAsync(initialPaginationRequest); - - // Process response body for first request and get the nextLink to use on subsequent request - // which represents what this test is validating. - string responseBody = await initialPaginationResponse.Content.ReadAsStringAsync(); - Dictionary responseProperties = JsonSerializer.Deserialize>(responseBody); - string nextLinkUri = responseProperties["nextLink"].ToString(); - - // Act - Submit request with nextLink uri as target and capture response - - HttpRequestMessage followNextLinkRequest = new(HttpMethod.Get, nextLinkUri); - HttpResponseMessage followNextLinkResponse = await client.SendAsync(followNextLinkRequest); - - // Assert - - Assert.AreEqual(HttpStatusCode.OK, followNextLinkResponse.StatusCode, message: "Expected request to succeed."); - - // Process the response body and inspect the "nextLink" property for expected contents. - string followNextLinkResponseBody = await followNextLinkResponse.Content.ReadAsStringAsync(); - Dictionary followNextLinkResponseProperties = JsonSerializer.Deserialize>(followNextLinkResponseBody); - - string followUpResponseNextLink = followNextLinkResponseProperties["nextLink"].ToString(); - - // Build the Uri from nextLink string for query parsing. - // If relative, combine with base; if absolute, use as is. - Uri nextLink = null; - if (Uri.IsWellFormedUriString(followUpResponseNextLink, UriKind.Absolute)) - { - nextLink = new(followUpResponseNextLink, UriKind.Absolute); - } - else if (Uri.IsWellFormedUriString(followUpResponseNextLink, UriKind.Relative)) - { - nextLink = new(new("http://localhost:5000"), followUpResponseNextLink); - } - else + using (TestServer server = new(Program.CreateWebHostBuilder(args))) + using (HttpClient client = server.CreateClient()) { - Assert.Fail($"Invalid nextLink URI format: {followUpResponseNextLink}"); - } + try + { + // A create mutation operation is executed in the context of Authenticated role and the response is expected to be a valid + // response without any errors. + string graphQLMutation = @" + mutation { + createStock( + item: { + categoryid: 5001 + pieceid: 5001 + categoryName: ""SciFi"" + piecesAvailable: 100 + piecesRequired: 50 + } + ) { + categoryid + pieceid + } + }"; - NameValueCollection parsedQueryParameters = HttpUtility.ParseQueryString(query: nextLink.Query); - Assert.AreEqual(expected: false, actual: parsedQueryParameters["$after"].Contains(','), message: "nextLink erroneously contained two $after query parameters that were joined by HttpUtility.ParseQueryString(queryString)."); - Assert.AreNotEqual(notExpected: nextLinkUri, actual: followUpResponseNextLink, message: "The follow up request erroneously returned the same nextLink value."); + JsonElement mutationResponse = await GraphQLRequestExecutor.PostGraphQLRequestAsync( + client, + server.Services.GetRequiredService(), + query: graphQLMutation, + queryName: "createStock", + variables: null, + authToken: null, + clientRoleHeader: AuthorizationResolver.ROLE_ANONYMOUS + ); - // Do not use SqlPaginationUtils.Base64Encode()/Decode() here to eliminate test dependency on engine code to perform an assert. - try - { - Convert.FromBase64String(parsedQueryParameters["$after"]); - } - catch (FormatException) - { - Assert.Fail(message: "$after query parameter was not a valid base64 encoded value."); - } + Assert.IsNotNull(mutationResponse); + Assert.IsTrue(mutationResponse.ToString().Contains("The mutation operation createStock was successful but the current user is unauthorized to view the response due to lack of read permissions")); - // Validate nextLink is relative if nextLinkRelative is true or false otherwise. - // The assertion is now done directly on the original string, not on the parsed Uri object. - if (isNextLinkRelative) - { - // The server returned a relative URL, so it should NOT start with http/https - Assert.IsFalse(Uri.IsWellFormedUriString(followUpResponseNextLink, UriKind.Absolute), - $"nextLink was expected to be relative but was absolute: {followUpResponseNextLink}"); - Assert.IsTrue(followUpResponseNextLink.StartsWith("/"), - $"nextLink was expected to start with '/' (relative), got: {followUpResponseNextLink}"); - } - else - { - Assert.IsTrue(Uri.IsWellFormedUriString(followUpResponseNextLink, UriKind.Absolute), - $"nextLink was expected to be absolute but was relative: {followUpResponseNextLink}"); - Assert.IsTrue(followUpResponseNextLink.StartsWith("http"), - $"nextLink was expected to start with http/https, got: {followUpResponseNextLink}"); + // pk_query is executed in the context of Authenticated role to validate that the create mutation executed in the context of Anonymous role + // resulted in the creation of a new record in the database. + string graphQLQuery = @" + { + stock_by_pk(categoryid: 5001, pieceid: 5001) { + categoryid + pieceid + categoryName + } + }"; + string queryName = "stock_by_pk"; + + ValidateMutationSucceededAtDbLayer(server, client, graphQLQuery, queryName, AuthTestHelper.CreateAppServiceEasyAuthToken(), AuthorizationResolver.ROLE_AUTHENTICATED); + } + finally + { + // Clean-up steps. The record created by the create mutation operation is deleted to reset the database + // back to its original state. + string deleteMutation = @" + mutation { + deleteStock(categoryid: 5001, pieceid: 5001) { + categoryid + pieceid + } + }"; + + _ = await GraphQLRequestExecutor.PostGraphQLRequestAsync( + client, + server.Services.GetRequiredService(), + query: deleteMutation, + queryName: "deleteStock", + variables: null, + authToken: AuthTestHelper.CreateAppServiceEasyAuthToken(), + clientRoleHeader: AuthorizationResolver.ROLE_AUTHENTICATED); + } } } /// - /// Validates X-Forwarded headers for nextLink in Pagination + /// Validates the Location header field returned for a POST request when a 201 response is returned. The idea behind returning + /// a Location header is to provide a URL against which a GET request can be performed to fetch the details of the new item. + /// Base Route is not configured in the config file used for this test. If base-route is configured, the Location header URL should contain the base-route. + /// This test performs a POST request, and in the event that it results in a 201 response, it performs a subsequent GET request + /// with the Location header to validate the correctness of the URL. + /// Currently ignored as it is part of the setof flakey tests that are being investigated, see: https://github.com/Azure/data-api-builder/issues/2010 /// - /// The X-Forwarded-Host value - /// The X-Forwarded-Proto value + /// Type of the entity + /// Request path for performing POST API requests on the entity + /// Configured base route + /// Expected value for Location field in the response header. Since, the PK of the new record is not known beforehand, + /// the expectedLocationHeader excludes the PK. Because of this, the actual location header is validated by checking if it starts with the expectedLocationHeader. + [Ignore] [DataTestMethod] - [DataRow("localhost:5000", "http", DisplayName = "Forwarded Host and HTTP Protocol")] - [DataRow("myhost.com", "https", DisplayName = "Forwarded Host and HTTPS Protocol")] [TestCategory(TestCategory.MSSQL)] - public async Task ValidateNextLinkRespectsXForwardedHostAndProto(string forwardedHost, string forwardedProto) + [DataRow(EntitySourceType.Table, "/api/Book", "/data-api", "http://localhost/data-api/api/Book/id/", DisplayName = "Location Header validation - Table, Base Route not configured")] + [DataRow(EntitySourceType.StoredProcedure, "/api/GetBooks", "/data-api", "http://localhost/data-api/api/GetBooks", DisplayName = "Location Header validation - Stored Procedures, Base Route not configured")] + public async Task ValidateLocationHeaderFieldForPostRequests(EntitySourceType entityType, string requestPath, string baseRoute, string expectedLocationHeader) { - // Arrange - Setup test server with entity that has >1 record so that results can be paged. - const string ENTITY_NAME = "Bookmark"; - Entity requiredEntity = new( - Source: new("bookmarks", EntitySourceType.Table, null, null), - Fields: null, - Rest: new(Enabled: true), - GraphQL: new(Singular: "", Plural: "", Enabled: false), - Permissions: new[] { GetMinimalPermissionConfig(AuthorizationResolver.ROLE_ANONYMOUS) }, - Relationships: null, - Mappings: null); + GraphQLRuntimeOptions graphqlOptions = new(Enabled: false); + RestRuntimeOptions restRuntimeOptions = new(Enabled: true); + McpRuntimeOptions mcpRuntimeOptions = new(Enabled: false); - Dictionary entityMap = new() + DataSource dataSource = new(DatabaseType.MSSQL, + GetConnectionStringFromEnvironmentConfig(environment: TestCategory.MSSQL), Options: null); + + RuntimeConfig configuration; + + if (entityType is EntitySourceType.StoredProcedure) { - { ENTITY_NAME, requiredEntity } - }; + Entity entity = new(Source: new("get_books", EntitySourceType.StoredProcedure, null, null), + Fields: null, + Rest: new(new SupportedHttpVerb[] { SupportedHttpVerb.Get, SupportedHttpVerb.Post }), + GraphQL: null, + Permissions: new[] { GetMinimalPermissionConfig(AuthorizationResolver.ROLE_ANONYMOUS) }, + Relationships: null, + Mappings: null); - PaginationOptions paginationOptions = new() + string entityName = "GetBooks"; + configuration = InitMinimalRuntimeConfig(dataSource, graphqlOptions, restRuntimeOptions, mcpRuntimeOptions, entity, entityName); + } + else { - DefaultPageSize = 1, - MaxPageSize = 1, - UserProvidedDefaultPageSize = true, - UserProvidedMaxPageSize = true, - NextLinkRelative = false // Absolute nextLink required for this test - }; + configuration = InitMinimalRuntimeConfig(dataSource, graphqlOptions, restRuntimeOptions, mcpRuntimeOptions); + } - CreateCustomConfigFile(entityMap, enableGlobalRest: true, paginationOptions: paginationOptions); + const string CUSTOM_CONFIG = "custom-config.json"; + + Config.ObjectModel.AuthenticationOptions authenticationOptions = new(Provider: EasyAuthType.StaticWebApps.ToString(), null); + HostOptions staticWebAppsHostOptions = new(null, authenticationOptions); + + RuntimeOptions baseRouteEnabledRuntimeOptions = new(runtimeOptions.Rest, runtimeOptions.GraphQL, runtimeOptions.Mcp, staticWebAppsHostOptions, "/data-api"); + RuntimeConfig baseRouteEnabledConfig = configuration with { Runtime = baseRouteEnabledRuntimeOptions }; + File.WriteAllText(CUSTOM_CONFIG, baseRouteEnabledConfig.ToJson()); string[] args = new[] { - $"--ConfigFileName={CUSTOM_CONFIG_FILENAME}" + $"--ConfigFileName={CUSTOM_CONFIG}" }; - using TestServer server = new(Program.CreateWebHostBuilder(args)); - using HttpClient client = server.CreateClient(); - - // Setup and send GET request with X-Forwarded-* headers - HttpRequestMessage initialPaginationRequest = new(HttpMethod.Get, $"{RestRuntimeOptions.DEFAULT_PATH}/{ENTITY_NAME}?$first=1"); - initialPaginationRequest.Headers.Add("X-Forwarded-Host", forwardedHost); - initialPaginationRequest.Headers.Add("X-Forwarded-Proto", forwardedProto); - - HttpResponseMessage initialPaginationResponse = await client.SendAsync(initialPaginationRequest); - - // Assert - Assert.AreEqual(HttpStatusCode.OK, initialPaginationResponse.StatusCode, message: "Expected request to succeed."); + using (TestServer server = new(Program.CreateWebHostBuilder(args))) + using (HttpClient client = server.CreateClient()) + { + HttpMethod httpMethod = SqlTestHelper.ConvertRestMethodToHttpMethod(SupportedHttpVerb.Post); + HttpRequestMessage request = new(httpMethod, requestPath); + if (entityType is not EntitySourceType.StoredProcedure) + { + string requestBody = @"{ + ""title"": ""Harry Potter and the Order of Phoenix"", + ""publisher_id"": 1234 + }"; - // Process response body and get nextLink - string responseBody = await initialPaginationResponse.Content.ReadAsStringAsync(); - Dictionary responseProperties = JsonSerializer.Deserialize>(responseBody); - string nextLinkUri = responseProperties.ContainsKey("nextLink") ? responseProperties["nextLink"].ToString() : null; + JsonElement requestBodyElement = JsonDocument.Parse(requestBody).RootElement.Clone(); + request = new(httpMethod, requestPath) + { + Content = JsonContent.Create(requestBodyElement) + }; + } - Assert.IsNotNull(nextLinkUri, "nextLink missing in initial response."); + HttpResponseMessage response = await client.SendAsync(request); + Assert.AreEqual(HttpStatusCode.Created, response.StatusCode); - // Assert that nextLink uses the forwarded host and proto - Uri nextLink = new(nextLinkUri, UriKind.Absolute); + string locationHeader = response.Headers.Location.AbsoluteUri; + Assert.IsTrue(locationHeader.StartsWith(expectedLocationHeader)); - // Split host/port if present - string expectedHost; - int expectedPort = -1; - string[] hostParts = forwardedHost.Split(':'); + // The URL to perform the GET request is constructed by skipping the base-route. + // Base Route field is applicable only in SWA-DAB integrated scenario. When DAB engine is run independently, all the + // APIs are hosted on /api. But, the returned Location header in this test will contain the configured base-route. So, this needs to be + // removed before performing a subsequent GET request. + string path = response.Headers.Location.AbsolutePath; + string completeUrl = path.Substring(baseRoute.Length); - if (hostParts.Length == 2 && int.TryParse(hostParts[1], out int port)) - { - expectedHost = hostParts[0]; - expectedPort = port; - } - else - { - expectedHost = forwardedHost; - } + HttpRequestMessage followUpRequest = new(HttpMethod.Get, completeUrl); + HttpResponseMessage followUpResponse = await client.SendAsync(followUpRequest); + Assert.AreEqual(HttpStatusCode.OK, followUpResponse.StatusCode); - Assert.AreEqual(forwardedProto, nextLink.Scheme, $"nextLink scheme should be '{forwardedProto}' but was '{nextLink.Scheme}'"); - Assert.AreEqual(expectedHost, nextLink.Host, $"nextLink host should be '{expectedHost}' but was '{nextLink.Host}'"); + // Delete the new record created as part of this test + if (entityType is EntitySourceType.Table) + { + HttpRequestMessage cleanupRequest = new(HttpMethod.Delete, completeUrl); + await client.SendAsync(cleanupRequest); + } - if (expectedPort != -1) - { - Assert.AreEqual(expectedPort, nextLink.Port, $"nextLink port should be '{expectedPort}' but was '{nextLink.Port}'"); } } /// - /// 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 - /// } + /// Test to validate that when the property rest.request-body-strict is absent from the rest runtime section in config file, DAB runs in strict mode. + /// In strict mode, presence of extra fields in the request body is not permitted and leads to HTTP 400 - BadRequest error. /// - /// The maximum allowed depth for GraphQL queries and mutations. - /// Indicates whether the operation is a mutation or a query. - /// The expected HTTP status code for the operation. + /// Boolean value indicating whether or not to include extraneous field in request body. [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) + [DataRow(false, DisplayName = "Mutation operation passes when no extraneous field is included in request body and rest.request-body-strict is omitted from the rest runtime section in the config file.")] + [DataRow(true, DisplayName = "Mutation operation fails when an extraneous field is included in request body and rest.request-body-strict is omitted from the rest runtime section in the config file.")] + public async Task ValidateStrictModeAsDefaultForRestRequestBody(bool includeExtraneousFieldInRequestBody) { - // Arrange - GraphQLRuntimeOptions graphqlOptions = new(DepthLimit: depthLimit); - graphqlOptions = graphqlOptions with { UserProvidedDepthLimit = true }; - - DataSource dataSource = new(DatabaseType.MSSQL, - GetConnectionStringFromEnvironmentConfig(environment: TestCategory.MSSQL), Options: null); + string entityJson = @" + { + ""entities"": { + ""Book"": { + ""source"": { + ""object"": ""books"", + ""type"": ""table"" + }, + ""permissions"": [ + { + ""role"": ""anonymous"", + ""actions"": [ + { + ""action"": ""*"" + } + ] + } + ] + } + } + }"; - RuntimeConfig configuration = InitMinimalRuntimeConfig(dataSource, graphqlOptions, restOptions: new(), mcpOptions: new()); + // The BASE_CONFIG omits the rest.request-body-strict option in the runtime section. + string configJson = TestHelper.AddPropertiesToJson(TestHelper.BASE_CONFIG, entityJson); + RuntimeConfigLoader.TryParseConfig( + configJson, + out RuntimeConfig deserializedConfig, + replacementSettings: new(), + logger: null, + GetConnectionStringFromEnvironmentConfig(environment: TestCategory.MSSQL)); const string CUSTOM_CONFIG = "custom-config.json"; - File.WriteAllText(CUSTOM_CONFIG, configuration.ToJson()); - + File.WriteAllText(CUSTOM_CONFIG, deserializedConfig.ToJson()); string[] args = new[] { $"--ConfigFileName={CUSTOM_CONFIG}" @@ -5146,124 +3508,89 @@ public async Task TestDepthLimitRestrictionOnGraphQLInNonHostedMode( 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 + HttpMethod httpMethod = SqlTestHelper.ConvertRestMethodToHttpMethod(SupportedHttpVerb.Post); + string requestBody = @"{ + ""title"": ""Harry Potter and the Order of Phoenix"", + ""publisher_id"": 1234 "; + + if (includeExtraneousFieldInRequestBody) { - // requested query operation has depth of 2 - query = @"query book_by_pk{ - book_by_pk(id: 1) { - id, - title, - publisher_id - } - }"; + requestBody += @", + ""extraField"": 12"; } - object payload = new { query }; - - HttpRequestMessage graphQLRequest = new(HttpMethod.Post, "/graphql") + requestBody += "}"; + JsonElement requestBodyElement = JsonDocument.Parse(requestBody).RootElement.Clone(); + HttpRequestMessage request = new(httpMethod, "api/Book") { - Content = JsonContent.Create(payload) + Content = JsonContent.Create(requestBodyElement) }; - // Act - HttpResponseMessage graphQLResponse = await client.SendAsync(graphQLRequest); - - // Assert - Assert.AreEqual(expectedStatusCodeForGraphQL, graphQLResponse.StatusCode); - string body = await graphQLResponse.Content.ReadAsStringAsync(); - JsonElement responseJson = JsonSerializer.Deserialize(body); - if (graphQLResponse.StatusCode == HttpStatusCode.OK) + HttpResponseMessage response = await client.SendAsync(request); + if (includeExtraneousFieldInRequestBody) { - 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."); + string responseBody = await response.Content.ReadAsStringAsync(); + // Assert that including an extraneous field in request body while operating in strict mode leads to a bad request exception. + Assert.AreEqual(HttpStatusCode.BadRequest, response.StatusCode); + Assert.IsTrue(responseBody.Contains("Invalid request body. Contained unexpected fields in body: extraField")); } 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."); + // When no extraneous fields are included in request body, the operation executes successfully. + Assert.AreEqual(HttpStatusCode.Created, response.StatusCode); + string locationHeader = response.Headers.Location.AbsoluteUri; + + // Delete the new record created as part of this test. + HttpRequestMessage cleanupRequest = new(HttpMethod.Delete, locationHeader); + await client.SendAsync(cleanupRequest); } } } /// - /// 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 - /// } - /// } - /// } - /// } + /// Engine supports config with some views that do not have keyfields specified in the config for MsSQL. + /// This Test validates that support. It creates a custom config with a view and no keyfields specified. + /// It checks both Rest and GraphQL queries are tested to return Success. /// - [TestCategory(TestCategory.MSSQL)] - [TestMethod] - public async Task TestGraphQLIntrospectionQueriesAreNotImpactedByDepthLimit() + [TestMethod, TestCategory(TestCategory.MSSQL)] + public async Task TestEngineSupportViewsWithoutKeyFieldsInConfigForMsSQL() { - // Arrange - GraphQLRuntimeOptions graphqlOptions = new(DepthLimit: 2); - graphqlOptions = graphqlOptions with { UserProvidedDepthLimit = true }; - DataSource dataSource = new(DatabaseType.MSSQL, GetConnectionStringFromEnvironmentConfig(environment: TestCategory.MSSQL), Options: null); + Entity viewEntity = new( + Source: new("books_view_all", EntitySourceType.Table, null, null), + Fields: null, + Rest: new(Enabled: true), + GraphQL: new("", ""), + Permissions: new[] { GetMinimalPermissionConfig(AuthorizationResolver.ROLE_ANONYMOUS) }, + Relationships: null, + Mappings: null + ); + + RuntimeConfig configuration = InitMinimalRuntimeConfig(dataSource, new(), new(), new(), viewEntity, "books_view_all"); - RuntimeConfig configuration = InitMinimalRuntimeConfig(dataSource, graphqlOptions, restOptions: new(), mcpOptions: new()); const string CUSTOM_CONFIG = "custom-config.json"; - File.WriteAllText(CUSTOM_CONFIG, configuration.ToJson()); + + 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 - } - } - } - } - } - }"; + books_view_alls { + items{ + id + title + } + } + }"; object payload = new { query }; @@ -5272,199 +3599,34 @@ public async Task TestGraphQLIntrospectionQueriesAreNotImpactedByDepthLimit() Content = JsonContent.Create(payload) }; - // Act HttpResponseMessage graphQLResponse = await client.SendAsync(graphQLRequest); - - // Assert Assert.AreEqual(HttpStatusCode.OK, graphQLResponse.StatusCode); string body = await graphQLResponse.Content.ReadAsStringAsync(); + Assert.IsFalse(body.Contains("errors")); // In GraphQL, All errors end up in the errors array, no matter what kind of error they are. - JsonElement responseJson = JsonSerializer.Deserialize(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."); - } - } - - [TestCategory(TestCategory.MSSQL)] - [DataTestMethod] - [DataRow(true, 4, DisplayName = "Test Autoentities with additional entities")] - [DataRow(false, 2, DisplayName = "Test Autoentities without additional entities")] - public async Task TestAutoentitiesAreGeneratedIntoEntities(bool useEntities, int expectedEntityCount) - { - // Arrange - EntityRelationship bookRelationship = new(Cardinality: Cardinality.One, - TargetEntity: "BookPublisher", - SourceFields: new string[] { }, - TargetFields: new string[] { }, - LinkingObject: null, - LinkingSourceFields: null, - LinkingTargetFields: null); - - Entity bookEntity = new(Source: new("books", EntitySourceType.Table, null, null), - Fields: null, - Rest: null, - GraphQL: new(Singular: "book", Plural: "books"), - Permissions: new[] { GetMinimalPermissionConfig(AuthorizationResolver.ROLE_ANONYMOUS) }, - Relationships: new Dictionary() { { "publishers", bookRelationship } }, - Mappings: null); - - EntityRelationship publisherRelationship = new(Cardinality: Cardinality.Many, - TargetEntity: "Book", - SourceFields: new string[] { }, - TargetFields: new string[] { }, - LinkingObject: null, - LinkingSourceFields: null, - LinkingTargetFields: null); - - Entity publisherEntity = new( - Source: new("publishers", EntitySourceType.Table, null, null), - Fields: null, - Rest: null, - GraphQL: new(Singular: "bookpublisher", Plural: "bookpublishers"), - Permissions: new[] { GetMinimalPermissionConfig(AuthorizationResolver.ROLE_ANONYMOUS) }, - Relationships: new Dictionary() { { "books", publisherRelationship } }, - Mappings: null); - - Dictionary entityMap = new() - { - { "Book", bookEntity }, - { "BookPublisher", publisherEntity } - }; - - Dictionary autoentityMap = new() - { - { - "PublisherAutoEntity", new Autoentity( - Patterns: new AutoentityPatterns( - Include: new[] { "%publishers%" }, - Exclude: null, - Name: null - ), - Template: new AutoentityTemplate( - Rest: new EntityRestOptions(Enabled: true), - GraphQL: new EntityGraphQLOptions( - Singular: string.Empty, - Plural: string.Empty, - Enabled: true - ), - Health: null, - Cache: null - ), - Permissions: new[] { GetMinimalPermissionConfig(AuthorizationResolver.ROLE_ANONYMOUS) } - ) - } - }; - - // Create DataSource for MSSQL connection - DataSource dataSource = new(DatabaseType.MSSQL, - GetConnectionStringFromEnvironmentConfig(environment: TestCategory.MSSQL), Options: null); - - // Build complete runtime configuration with autoentities - RuntimeConfig configuration = new( - Schema: "TestAutoentitiesSchema", - DataSource: dataSource, - Runtime: new( - Rest: new(Enabled: true), - GraphQL: new(Enabled: true), - Mcp: new(Enabled: false), - Host: new( - Cors: null, - Authentication: new Config.ObjectModel.AuthenticationOptions( - Provider: nameof(EasyAuthType.StaticWebApps), - Jwt: null - ) - ) - ), - Entities: new(useEntities ? entityMap : new Dictionary()), - Autoentities: new RuntimeAutoentities(autoentityMap) - ); - - File.WriteAllText(CUSTOM_CONFIG_FILENAME, configuration.ToJson()); - - string[] args = new[] { $"--ConfigFileName={CUSTOM_CONFIG_FILENAME}" }; - - using (TestServer server = new(Program.CreateWebHostBuilder(args))) - using (HttpClient client = server.CreateClient()) - { - // Act - RuntimeConfigProvider configProvider = server.Services.GetService(); - using HttpRequestMessage restRequest = new(HttpMethod.Get, "/api/publishers"); - using HttpResponseMessage restResponse = await client.SendAsync(restRequest); - - string graphqlQuery = @" - { - publishers { - items { - id - name - } - } - }"; - - object graphqlPayload = new { query = graphqlQuery }; - HttpRequestMessage graphqlRequest = new(HttpMethod.Post, "/graphql") - { - Content = JsonContent.Create(graphqlPayload) - }; - HttpResponseMessage graphqlResponse = await client.SendAsync(graphqlRequest); - - // Assert - string expectedResponseFragment = @"{""id"":1156,""name"":""The First Publisher""}"; - - // Verify number of entities - Assert.AreEqual(expectedEntityCount, configProvider.GetConfig().Entities.Entities.Count, "Number of generated entities is not what is expected"); - - // Verify REST response - Assert.AreEqual(HttpStatusCode.OK, restResponse.StatusCode, "REST request to auto-generated entity should succeed"); - - string restResponseBody = await restResponse.Content.ReadAsStringAsync(); - Assert.IsTrue(!string.IsNullOrEmpty(restResponseBody), "REST response should contain data"); - Assert.IsTrue(restResponseBody.Contains(expectedResponseFragment)); - - // Verify GraphQL response - Assert.AreEqual(HttpStatusCode.OK, graphqlResponse.StatusCode, "GraphQL request to auto-generated entity should succeed"); - - string graphqlResponseBody = await graphqlResponse.Content.ReadAsStringAsync(); - Assert.IsTrue(!string.IsNullOrEmpty(graphqlResponseBody), "GraphQL response should contain data"); - Assert.IsFalse(graphqlResponseBody.Contains("errors"), "GraphQL response should not contain errors"); - Assert.IsTrue(graphqlResponseBody.Contains(expectedResponseFragment)); + HttpRequestMessage restRequest = new(HttpMethod.Get, "/api/books_view_all"); + HttpResponseMessage restResponse = await client.SendAsync(restRequest); + Assert.AreEqual(HttpStatusCode.OK, restResponse.StatusCode); } } /// - /// 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 - /// } - /// } + /// Validates that DAB supports a configuration without authentication, as it's optional. + /// Ensures both REST and GraphQL queries return success when authentication is not configured. /// - /// - [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) + [TestMethod, TestCategory(TestCategory.MSSQL)] + public async Task TestEngineSupportConfigWithNoAuthentication() { - // 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(), mcpOptions: new()); + RuntimeConfig configuration = CreateBasicRuntimeConfigWithSingleEntityAndAuthOptions(dataSource: dataSource, authenticationOptions: null); + const string CUSTOM_CONFIG = "custom-config.json"; - File.WriteAllText(CUSTOM_CONFIG, configuration.ToJson()); + + File.WriteAllText( + CUSTOM_CONFIG, + configuration.ToJson()); string[] args = new[] { @@ -5474,14 +3636,14 @@ public async Task TestNoDepthLimitOnGrahQLInNonHostedMode(int? depthLimit) 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 - } - }"; + books { + items{ + id + title + } + } + }"; object payload = new { query }; @@ -5490,119 +3652,150 @@ public async Task TestNoDepthLimitOnGrahQLInNonHostedMode(int? depthLimit) Content = JsonContent.Create(payload) }; - // Act HttpResponseMessage graphQLResponse = await client.SendAsync(graphQLRequest); - - // Assert Assert.AreEqual(HttpStatusCode.OK, graphQLResponse.StatusCode); string body = await graphQLResponse.Content.ReadAsStringAsync(); + Assert.IsFalse(body.Contains("errors")); // In GraphQL, All errors end up in the errors array, no matter what kind of error they are. - JsonElement responseJson = JsonSerializer.Deserialize(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."); + HttpRequestMessage restRequest = new(HttpMethod.Get, "/api/Book"); + HttpResponseMessage restResponse = await client.SendAsync(restRequest); + Assert.AreEqual(HttpStatusCode.OK, restResponse.StatusCode); } } /// - /// Helper function to write custom configuration file. with minimal REST/GraphQL global settings - /// using the supplied entities. + /// In CosmosDB, we store data in the form of JSON. Practically, JSON can be very complex. + /// But DAB doesn't support JSON with circular references e.g if 'Character.Moon' is a valid JSON Path, then + /// 'Moon.Character' should not be there, DAB would throw an exception during the load itself. /// - /// Collection of entityName -> Entity object. - /// flag to enable or disabled REST globally. - /// Optional pagination options to use in the runtime config. - private static void CreateCustomConfigFile(Dictionary entityMap, bool enableGlobalRest = true, PaginationOptions paginationOptions = null) + /// + [TestMethod, TestCategory(TestCategory.COSMOSDBNOSQL)] + [DataRow(GRAPHQL_SCHEMA_WITH_CYCLE_OBJECT, DisplayName = "When Circular Reference is there with Object type (i.e. 'Moon' in 'Character' Entity")] + [DataRow(GRAPHQL_SCHEMA_WITH_CYCLE_ARRAY, DisplayName = "When Circular Reference is there with Array type (i.e. '[Moon]' in 'Character' Entity")] + public void ValidateGraphQLSchemaForCircularReference(string schema) { - DataSource dataSource = new( - DatabaseType.MSSQL, - GetConnectionStringFromEnvironmentConfig(environment: TestCategory.MSSQL), - Options: null); + // Read the base config from the file + TestHelper.SetupDatabaseEnvironment(TestCategory.COSMOSDBNOSQL); + FileSystemRuntimeConfigLoader baseLoader = TestHelper.GetRuntimeConfigLoader(); + if (!baseLoader.TryLoadKnownConfig(out RuntimeConfig baseConfig)) + { + throw new ApplicationException("Failed to load the default CosmosDB_NoSQL config and cannot continue with tests."); + } - HostOptions hostOptions = new(Cors: null, Authentication: new() { Provider = nameof(EasyAuthType.StaticWebApps) }); - - RuntimeOptions runtime = paginationOptions != null - ? new( - Rest: new(Enabled: enableGlobalRest), - GraphQL: new(Enabled: true), - Mcp: new(Enabled: true), - Host: hostOptions, - Pagination: paginationOptions) - : new( - Rest: new(Enabled: enableGlobalRest), - GraphQL: new(Enabled: true), - Mcp: new(Enabled: true), - Host: hostOptions); - - RuntimeConfig runtimeConfig = new( - Schema: string.Empty, - DataSource: dataSource, - Runtime: runtime, - Entities: new(entityMap)); + // Setup a mock file system, and use that one with the loader/provider for the config + MockFileSystem fileSystem = new(new Dictionary() + { + { @"../schema.gql", new MockFileData(schema) }, + { DEFAULT_CONFIG_FILE_NAME, new MockFileData(baseConfig.ToJson()) } + }); + FileSystemRuntimeConfigLoader loader = new(fileSystem); + RuntimeConfigProvider provider = new(loader); - File.WriteAllText( - path: CUSTOM_CONFIG_FILENAME, - contents: runtimeConfig.ToJson()); + DataApiBuilderException exception = + Assert.ThrowsException(() => new CosmosSqlMetadataProvider(provider, fileSystem)); + Assert.AreEqual("Circular reference detected in the provided GraphQL schema for entity 'Character'.", exception.Message); + Assert.AreEqual(HttpStatusCode.InternalServerError, exception.StatusCode); + Assert.AreEqual(DataApiBuilderException.SubStatusCodes.ErrorInInitialization, exception.SubStatusCode); } - /// - /// Validates that all the OpenAPI description document's top level properties exist. - /// A failure here indicates that there was an undetected failure creating the OpenAPI document. - /// - /// Represent a deserialized JSON result from retrieving the OpenAPI document - private static void ValidateOpenApiDocTopLevelPropertiesExist(Dictionary responseProperties) - { - Assert.IsTrue(responseProperties.ContainsKey(OpenApiDocumentorConstants.TOPLEVELPROPERTY_OPENAPI)); - Assert.IsTrue(responseProperties.ContainsKey(OpenApiDocumentorConstants.TOPLEVELPROPERTY_INFO)); - Assert.IsTrue(responseProperties.ContainsKey(OpenApiDocumentorConstants.TOPLEVELPROPERTY_SERVERS)); - Assert.IsTrue(responseProperties.ContainsKey(OpenApiDocumentorConstants.TOPLEVELPROPERTY_PATHS)); - Assert.IsTrue(responseProperties.ContainsKey(OpenApiDocumentorConstants.TOPLEVELPROPERTY_COMPONENTS)); + /// + /// GraphQL Schema types defined -> Character and Planet + /// DAB runtime config entities defined -> Planet(Not defined: Character) + /// Mismatch of entities and types between provided GraphQL schema and DAB config results in actionable error message. + /// + /// + [TestMethod, TestCategory(TestCategory.COSMOSDBNOSQL)] + public void ValidateGraphQLSchemaEntityPresentInConfig() + { + string GRAPHQL_SCHEMA = @" +type Character { + id : ID, + name : String, +} + +type Planet @model(name:""PlanetAlias"") { + id : ID!, + name : String, + character: Character +} +"; + // Read the base config from the file + TestHelper.SetupDatabaseEnvironment(TestCategory.COSMOSDBNOSQL); + FileSystemRuntimeConfigLoader baseLoader = TestHelper.GetRuntimeConfigLoader(); + if (!baseLoader.TryLoadKnownConfig(out RuntimeConfig baseConfig)) + { + throw new ApplicationException("Failed to load the default CosmosDB_NoSQL config and cannot continue with tests."); + } + + Dictionary entities = new(baseConfig.Entities); + entities.Remove("Character"); + + RuntimeConfig runtimeConfig = new(Schema: baseConfig.Schema, + DataSource: baseConfig.DataSource, + Runtime: baseConfig.Runtime, + Entities: new(entities)); + + // Setup a mock file system, and use that one with the loader/provider for the config + MockFileSystem fileSystem = new(new Dictionary() + { + { @"../schema.gql", new MockFileData(GRAPHQL_SCHEMA) }, + { DEFAULT_CONFIG_FILE_NAME, new MockFileData(runtimeConfig.ToJson()) } + }); + FileSystemRuntimeConfigLoader loader = new(fileSystem); + RuntimeConfigProvider provider = new(loader); + + DataApiBuilderException exception = + Assert.ThrowsException(() => new CosmosSqlMetadataProvider(provider, fileSystem)); + Assert.AreEqual("The entity 'Character' was not found in the runtime config.", exception.Message); + Assert.AreEqual(HttpStatusCode.ServiceUnavailable, exception.StatusCode); + Assert.AreEqual(DataApiBuilderException.SubStatusCodes.ConfigValidationError, exception.SubStatusCode); } /// - /// Validates that schema introspection requests fail when allow-introspection is false in the runtime configuration. + /// Integration test that validates schema introspection requests fail + /// when allow-introspection is false in the runtime configuration. + /// TestCategory is required for CI/CD pipeline to inject a connection string. /// /// - private static async Task ExecuteGraphQLIntrospectionQueries(TestServer server, HttpClient client, bool expectError) + [TestCategory(TestCategory.MSSQL)] + [DataTestMethod] + [DataRow(false, true, "Introspection is not allowed for the current request.", CONFIGURATION_ENDPOINT, DisplayName = "Disabled introspection returns GraphQL error.")] + [DataRow(true, false, null, CONFIGURATION_ENDPOINT, DisplayName = "Enabled introspection does not return introspection forbidden error.")] + [DataRow(false, true, "Introspection is not allowed for the current request.", CONFIGURATION_ENDPOINT_V2, DisplayName = "Disabled introspection returns GraphQL error.")] + [DataRow(true, false, null, CONFIGURATION_ENDPOINT_V2, DisplayName = "Enabled introspection does not return introspection forbidden error.")] + public async Task TestSchemaIntrospectionQuery(bool enableIntrospection, bool expectError, string errorMessage, string configurationEndpoint) { - string graphQLQueryName = "__schema"; - string graphQLQuery = @"{ - __schema { - types { - name - } - } - }"; + GraphQLRuntimeOptions graphqlOptions = new(AllowIntrospection: enableIntrospection); + RestRuntimeOptions restRuntimeOptions = new(); + McpRuntimeOptions mcpRuntimeOptions = new(); - string expectedErrorMessageFragment = "Introspection is not allowed for the current request."; + DataSource dataSource = new(DatabaseType.MSSQL, GetConnectionStringFromEnvironmentConfig(environment: TestCategory.MSSQL), Options: null); - try - { - RuntimeConfigProvider configProvider = server.Services.GetRequiredService(); + RuntimeConfig configuration = InitMinimalRuntimeConfig(dataSource, graphqlOptions, restRuntimeOptions, mcpRuntimeOptions); + const string CUSTOM_CONFIG = "custom-config.json"; + File.WriteAllText(CUSTOM_CONFIG, configuration.ToJson()); - JsonElement actual = await GraphQLRequestExecutor.PostGraphQLRequestAsync( - client, - configProvider, - query: graphQLQuery, - queryName: graphQLQueryName, - variables: null, - clientRoleHeader: null - ); + string[] args = new[] + { + $"--ConfigFileName={CUSTOM_CONFIG}" + }; - if (expectError) - { - SqlTestHelper.TestForErrorInGraphQLResponse( - response: actual.ToString(), - message: expectedErrorMessageFragment, - statusCode: ErrorCodes.Validation.IntrospectionNotAllowed - ); - } + using (TestServer server = new(Program.CreateWebHostBuilder(args))) + using (HttpClient client = server.CreateClient()) + { + await ExecuteGraphQLIntrospectionQueries(server, client, expectError); } - catch (Exception ex) + + // Instantiate new server with no runtime config for post-startup configuration hydration tests. + using (TestServer server = new(Program.CreateWebHostFromInMemoryUpdatableConfBuilder(Array.Empty()))) + using (HttpClient client = server.CreateClient()) { - // ExecuteGraphQLRequestAsync will raise an exception when no "data" key - // exists in the GraphQL JSON response. - Assert.Fail(message: "No schema metadata in GraphQL response." + ex.Message); + JsonContent content = GetPostStartupConfigParams(MSSQL_ENVIRONMENT, configuration, configurationEndpoint); + HttpStatusCode responseCode = await HydratePostStartupConfiguration(client, content, configurationEndpoint, configuration.Runtime.Rest); + + Assert.AreEqual(expected: HttpStatusCode.OK, actual: responseCode, message: "Configuration hydration failed."); + + await ExecuteGraphQLIntrospectionQueries(server, client, expectError); } } @@ -5644,9 +3837,9 @@ private static JsonContent GetJsonContentForCosmosConfigRequest(string endpoint, // initiating a client. Uses a valid fake JWT access token for testing purposes. RuntimeConfig overrides = new( Schema: null, - DataSource: new DataSource(DatabaseType.CosmosDB_NoSQL, "AccountEndpoint=https://localhost:8081/;", new()), - Runtime: null, - Entities: new(new Dictionary())); + DataSource: new DataSource(DatabaseType.CosmosDB_NoSQL, $"AccountEndpoint=https://localhost:8081/;AccountKey=C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw==;Database={COSMOS_DATABASE_NAME}", new()), + Entities: new(new Dictionary()), + Runtime: null); configParams = configParams with { @@ -5698,8 +3891,8 @@ private static ConfigurationPostParametersV2 GetCosmosConfigurationParametersV2( RuntimeConfig overrides = new( Schema: null, DataSource: new DataSource(DatabaseType.CosmosDB_NoSQL, $"AccountEndpoint=https://localhost:8081/;AccountKey=C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw==;Database={COSMOS_DATABASE_NAME}", new()), - Runtime: null, - Entities: new(new Dictionary())); + Entities: new(new Dictionary()), + Runtime: null); return new( configuration.ToJson(), @@ -5734,7 +3927,7 @@ private static JsonContent GetPostStartupConfigParams(string environment, Runtim RuntimeConfig overrides = new( Schema: null, DataSource: new DataSource(DatabaseType.MSSQL, connectionString, new()), - Entities: new(new Dictionary()), + Entities: new(new Dictionary), Runtime: null); ConfigurationPostParametersV2 returnParams = new( @@ -5743,455 +3936,661 @@ private static JsonContent GetPostStartupConfigParams(string environment, Runtim Schema: null, AccessToken: null); - return JsonContent.Create(returnParams); - } - else + return JsonContent.Create(returnParams); + } + else + { + throw new InvalidOperationException("Invalid configurationEndpoint"); + } + } + + /// + /// Helper method to create RuntimeConfig with specificed LogLevel value + /// + private static RuntimeConfig InitializeRuntimeWithLogLevel(Dictionary logLevelOptions) + { + TestHelper.SetupDatabaseEnvironment(MSSQL_ENVIRONMENT); + + FileSystemRuntimeConfigLoader baseLoader = TestHelper.GetRuntimeConfigLoader(); + baseLoader.TryLoadKnownConfig(out RuntimeConfig baseConfig); + + RuntimeConfig config = new( + Schema: baseConfig.Schema, + DataSource: baseConfig.DataSource, + Runtime: new( + Rest: new(), + GraphQL: new(), + Mcp: new(), + Host: new(null, null), + Telemetry: new(LoggerLevel: logLevelOptions) + ), + Entities: baseConfig.Entities + ); + + return config; + } + + /// + /// Tests that between multiple log level filters, + /// the one that is more specific is always given priority. + /// + [DataTestMethod] + [TestCategory(TestCategory.MSSQL)] + [DataRow(LogLevel.Debug, "Azure", LogLevel.Warning, "default", typeof(IQueryExecutor))] + [DataRow(LogLevel.Information, "Azure.DataApiBuilder", LogLevel.Error, "Azure", typeof(IQueryExecutor))] + [DataRow(LogLevel.Warning, "Azure.DataApiBuilder.Core", LogLevel.Critical, "Azure.DataApiBuilder", typeof(RuntimeConfigValidator))] + [DataRow(LogLevel.Error, "Azure.DataApiBuilder.Core.Configurations", LogLevel.None, "Azure.DataApiBuilder.Core", typeof(RuntimeConfigValidator))] + public void PriorityLogLevelFilters(LogLevel highPriLevel, string highPriFilter, LogLevel lowPriLevel, string lowPriFilter, Type type) + { + string classString = type.FullName; + Startup.AddValidFilters(); + Dictionary logLevelOptions = new(); + logLevelOptions.Add(highPriFilter, highPriLevel); + logLevelOptions.Add(lowPriFilter, lowPriLevel); + RuntimeConfig configWithCustomLogLevel = InitializeRuntimeWithLogLevel(logLevelOptions); + try + { + RuntimeConfigValidator.ValidateLoggerFilters(configWithCustomLogLevel); + } + catch + { + Assert.Fail(); + } + + string configWithCustomLogLevelJson = configWithCustomLogLevel.ToJson(); + Assert.IsTrue(RuntimeConfigLoader.TryParseConfig(configWithCustomLogLevelJson, out RuntimeConfig deserializedRuntimeConfig)); + + // If filters are not a subsection from the classString, then the test will not work. + LogLevel actualLogLevel = deserializedRuntimeConfig.GetConfiguredLogLevel(classString); + + Assert.AreEqual(expected: highPriLevel, actual: actualLogLevel); + } + + /// + /// Tests that the when Rest or GraphQL is disabled Globally, + /// any requests made will get a 404 response. + /// + /// The custom configured REST enabled property in configuration. + /// The custom configured GraphQL enabled property in configuration. + /// Expected HTTP status code code for the Rest request + /// Expected HTTP status code code for the GraphQL request + [DataTestMethod] + [TestCategory(TestCategory.MSSQL)] + [DataRow(true, true, true, HttpStatusCode.OK, HttpStatusCode.OK, HttpStatusCode.OK, CONFIGURATION_ENDPOINT, DisplayName = "V1 - Rest, GraphQL, and MCP enabled globally")] + [DataRow(true, true, false, HttpStatusCode.OK, HttpStatusCode.OK, HttpStatusCode.NotFound, CONFIGURATION_ENDPOINT, DisplayName = "V1 - Rest and GraphQL enabled, MCP disabled globally")] + [DataRow(true, false, true, HttpStatusCode.OK, HttpStatusCode.NotFound, HttpStatusCode.OK, CONFIGURATION_ENDPOINT, DisplayName = "V1 - Rest enabled, GraphQL disabled, and MCP enabled globally")] + [DataRow(true, false, false, HttpStatusCode.OK, HttpStatusCode.NotFound, HttpStatusCode.NotFound, CONFIGURATION_ENDPOINT, DisplayName = "V1 - Rest enabled, GraphQL and MCP disabled globally")] + [DataRow(false, true, true, HttpStatusCode.NotFound, HttpStatusCode.OK, HttpStatusCode.OK, CONFIGURATION_ENDPOINT, DisplayName = "V1 - Rest disabled, GraphQL and MCP enabled globally")] + [DataRow(false, true, false, HttpStatusCode.NotFound, HttpStatusCode.OK, HttpStatusCode.NotFound, CONFIGURATION_ENDPOINT, DisplayName = "V1 - Rest disabled, GraphQL enabled, and MCP disabled globally")] + [DataRow(false, false, true, HttpStatusCode.NotFound, HttpStatusCode.NotFound, HttpStatusCode.OK, CONFIGURATION_ENDPOINT, DisplayName = "V1 - Rest and GraphQL disabled, MCP enabled globally")] + [DataRow(true, true, true, HttpStatusCode.OK, HttpStatusCode.OK, HttpStatusCode.OK, CONFIGURATION_ENDPOINT_V2, DisplayName = "V2 - Rest, GraphQL, and MCP enabled globally")] + [DataRow(true, true, false, HttpStatusCode.OK, HttpStatusCode.OK, HttpStatusCode.NotFound, CONFIGURATION_ENDPOINT_V2, DisplayName = "V2 - Rest and GraphQL enabled, MCP disabled globally")] + [DataRow(true, false, true, HttpStatusCode.OK, HttpStatusCode.NotFound, HttpStatusCode.OK, CONFIGURATION_ENDPOINT_V2, DisplayName = "V2 - Rest enabled, GraphQL disabled, and MCP enabled globally")] + [DataRow(true, false, false, HttpStatusCode.OK, HttpStatusCode.NotFound, HttpStatusCode.NotFound, CONFIGURATION_ENDPOINT_V2, DisplayName = "V2 - Rest enabled, GraphQL and MCP disabled globally")] + [DataRow(false, true, true, HttpStatusCode.NotFound, HttpStatusCode.OK, HttpStatusCode.OK, CONFIGURATION_ENDPOINT_V2, DisplayName = "V2 - Rest disabled, GraphQL and MCP enabled globally")] + [DataRow(false, true, false, HttpStatusCode.NotFound, HttpStatusCode.OK, HttpStatusCode.NotFound, CONFIGURATION_ENDPOINT_V2, DisplayName = "V2 - Rest disabled, GraphQL enabled, and MCP disabled globally")] + [DataRow(false, false, true, HttpStatusCode.NotFound, HttpStatusCode.NotFound, HttpStatusCode.OK, CONFIGURATION_ENDPOINT_V2, DisplayName = "V2 - Rest and GraphQL disabled, MCP enabled globally")] + public async Task TestGlobalFlagToEnableRestGraphQLAndMcpForHostedAndNonHostedEnvironment( + bool isRestEnabled, + bool isGraphQLEnabled, + bool isMcpEnabled, + HttpStatusCode expectedStatusCodeForREST, + HttpStatusCode expectedStatusCodeForGraphQL, + HttpStatusCode expectedStatusCodeForMcp, + string configurationEndpoint) + { + GraphQLRuntimeOptions graphqlOptions = new(AllowIntrospection: isGraphQLEnabled); + RestRuntimeOptions restRuntimeOptions = new(Enabled: isRestEnabled); + McpRuntimeOptions mcpRuntimeOptions = new(Enabled: isMcpEnabled); + + DataSource dataSource = new(DatabaseType.MSSQL, GetConnectionStringFromEnvironmentConfig(environment: TestCategory.MSSQL), Options: null); + + RuntimeConfig configuration = InitMinimalRuntimeConfig(dataSource, graphqlOptions, restRuntimeOptions, mcpRuntimeOptions); + const string CUSTOM_CONFIG = "custom-config.json"; + File.WriteAllText(CUSTOM_CONFIG, configuration.ToJson()); + + string[] args = new[] + { + $"--ConfigFileName={CUSTOM_CONFIG}" + }; + + // Non-Hosted Scenario + using (TestServer server = new(Program.CreateWebHostBuilder(args))) + using (HttpClient client = server.CreateClient()) + { + string query = @"{ + book_by_pk(id: 1) { + id, + title, + publisher_id + } + }"; + + object payload = new { query }; + + // GraphQL request + HttpRequestMessage graphQLRequest = new(HttpMethod.Post, "/graphql") + { + Content = JsonContent.Create(payload) + }; + + HttpResponseMessage graphQLResponse = await client.SendAsync(graphQLRequest); + Assert.AreEqual(expectedStatusCodeForGraphQL, graphQLResponse.StatusCode, "The GraphQL response is different from the expected result."); + + // REST request + HttpRequestMessage restRequest = new(HttpMethod.Get, $"{configuration.Runtime.Rest.Path}/Book"); + HttpResponseMessage restResponse = await client.SendAsync(restRequest); + Assert.AreEqual(expectedStatusCodeForREST, restResponse.StatusCode, "The REST response is different from the expected result."); + + // MCP request + HttpStatusCode mcpResponseCode = await GetMcpResponse(client, configuration.Runtime.Mcp); + Assert.AreEqual(expectedStatusCodeForMcp, mcpResponseCode, "The MCP response is different from the expected result."); + } + + // Hosted Scenario + // Instantiate new server with no runtime config for post-startup configuration hydration tests. + using (TestServer server = new(Program.CreateWebHostFromInMemoryUpdatableConfBuilder(Array.Empty()))) + using (HttpClient client = server.CreateClient()) + { + JsonContent content = GetPostStartupConfigParams(MSSQL_ENVIRONMENT, configuration, configurationEndpoint); + + HttpResponseMessage postResult = await client.PostAsync(configurationEndpoint, content); + Assert.AreEqual(HttpStatusCode.OK, postResult.StatusCode, "The hydration post-response is different from the expected result."); + + HttpStatusCode restResponseCode = await GetRestResponsePostConfigHydration(client, configuration.Runtime.Rest); + Assert.AreEqual(expected: expectedStatusCodeForREST, actual: restResponseCode, "The REST hydration post-response is different from the expected result."); + + HttpStatusCode graphqlResponseCode = await GetGraphQLResponsePostConfigHydration(client, configuration.Runtime.GraphQL); + Assert.AreEqual(expected: expectedStatusCodeForGraphQL, actual: graphqlResponseCode, "The GraphQL hydration post-response is different from the expected result."); + + // TODO: Issue #3012 - Currently DAB is unable to start MCP with the hydration post-response. + // This needs to be fixed before uncommenting the MCP check + // HttpStatusCode mcpResponseCode = await GetMcpResponse(client, configuration.Runtime.Mcp); + // Assert.AreEqual(expected: expectedStatusCodeForMcp, actual: mcpResponseCode, "The MCP hydration post-response is different from the expected result."); + } + } + + /// + /// Tests that the when Rest or GraphQL is disabled Globally, + /// any requests made will get a 404 response. + /// + /// The custom configured REST enabled property in configuration. + /// The custom configured GraphQL enabled property in configuration. + /// Expected HTTP status code code for the request + [DataTestMethod] + [TestCategory(TestCategory.MSSQL)] + [DataRow(true, true, HttpStatusCode.OK, DisplayName = "Rest and GraphQL enabled globally")] + [DataRow(true, false, HttpStatusCode.OK, DisplayName = "Rest enabled, GraphQL disabled globally")] + [DataRow(false, true, HttpStatusCode.NotFound, DisplayName = "Rest disabled, GraphQL enabled globally")] + [DataRow(false, false, HttpStatusCode.NotFound, DisplayName = "Rest and GraphQL disabled globally")] + public async Task TestGlobalFlagToEnableRestGraphQL( + bool isRestEnabled, + bool isGraphQLEnabled, + HttpStatusCode expectedStatusCode) + { + GraphQLRuntimeOptions graphqlOptions = new(Enabled: isGraphQLEnabled); + RestRuntimeOptions restRuntimeOptions = new(Enabled: isRestEnabled); + McpRuntimeOptions mcpRuntimeOptions = new(Enabled: false); + + DataSource dataSource = new(DatabaseType.MSSQL, GetConnectionStringFromEnvironmentConfig(environment: TestCategory.MSSQL), Options: null); + + RuntimeConfig configuration = InitMinimalRuntimeConfig(dataSource, graphqlOptions, restRuntimeOptions, mcpRuntimeOptions); + 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()) { - throw new InvalidOperationException("Invalid configurationEndpoint"); + // Setup and send GET request + HttpRequestMessage readOpenApiDocumentRequest = new(HttpMethod.Get, $"{RestRuntimeOptions.DEFAULT_PATH}/{OpenApiDocumentor.OPENAPI_ROUTE}"); + HttpResponseMessage response = await client.SendAsync(readOpenApiDocumentRequest); + + // Assert response + Assert.AreEqual(expectedStatusCode, response.StatusCode); } } /// - /// Hydrates configuration after engine has started and triggers service instantiation - /// by executing HTTP requests against the engine until a non-503 error is received. + /// Validates the behavior of the OpenApiDocumentor when the runtime config has entities with + /// REST endpoint enabled and disabled. + /// Enabled -> path should be created + /// Disabled -> path not created and is excluded from OpenApi document. /// - /// Client used for request execution. - /// New config file content that will be added to DAB. - /// Endpoint through which content will be sent to DAB." - /// Global settings used at runtime for REST APIs. - /// ServiceUnavailable if service is not successfully hydrated with config - private static async Task HydratePostStartupConfiguration(HttpClient httpClient, JsonContent content, string configurationEndpoint, RestRuntimeOptions rest) + [TestCategory(TestCategory.MSSQL)] + [TestMethod] + public async Task OpenApi_EntityLevelRestEndpoint() { - string appServiceTokenPayload = AuthTestHelper.CreateAppServiceEasyAuthToken( - roleClaimType: Config.ObjectModel.AuthenticationOptions.ROLE_CLAIM_TYPE, - additionalClaims: - [ - new AppServiceClaim - { - Typ = Config.ObjectModel.AuthenticationOptions.ROLE_CLAIM_TYPE, - Val = POST_STARTUP_CONFIG_ROLE - } - ]); + // Create the entities under test. + Entity restEnabledEntity = new( + Source: new("books", EntitySourceType.Table, null, null), + Fields: null, + Rest: new(Enabled: true), + GraphQL: new("", "", Enabled: false), + Permissions: new[] { GetMinimalPermissionConfig(AuthorizationResolver.ROLE_ANONYMOUS) }, + Relationships: null, + Mappings: null); - using HttpRequestMessage postRequest = new(HttpMethod.Post, configurationEndpoint) - { - Content = content - }; + Entity restDisabledEntity = new( + Source: new("publishers", EntitySourceType.Table, null, null), + Fields: null, + Rest: new(Enabled: false), + GraphQL: new("publisher", "publishers", true), + Permissions: new[] { GetMinimalPermissionConfig(AuthorizationResolver.ROLE_ANONYMOUS) }, + Relationships: null, + Mappings: null); - postRequest.Headers.Add( - Config.ObjectModel.AuthenticationOptions.CLIENT_PRINCIPAL_HEADER, - appServiceTokenPayload); + Dictionary entityMap = new() + { + { "Book", restEnabledEntity }, + { "Publisher", restDisabledEntity } + }; - HttpResponseMessage postResult = await httpClient.SendAsync(postRequest); - string body = await postResult.Content.ReadAsStringAsync(); - Assert.AreEqual(HttpStatusCode.OK, postResult.StatusCode, body); + CreateCustomConfigFile(entityMap, enableGlobalRest: true); - return await GetRestResponsePostConfigHydration(httpClient, rest); - } + string[] args = new[] + { + $"--ConfigFileName={CUSTOM_CONFIG_FILENAME}" + }; - /// - /// Executing REST requests against the engine until a non-503 error is received. - /// - /// Client used for request execution. - /// Global settings used at runtime for REST APIs. - /// ServiceUnavailable if service is not successfully hydrated with config, - /// else the response code from the REST request - private static async Task GetRestResponsePostConfigHydration(HttpClient httpClient, RestRuntimeOptions rest) - { - // Retry request RETRY_COUNT times in exponential increments to allow - // required services time to instantiate and hydrate permissions because - // the DAB services may take an unpredictable amount of time to become ready. - // - // The service might still fail due to the service not being available yet, - // but it is highly unlikely to be the case. - int retryCount = 0; - HttpStatusCode responseCode = HttpStatusCode.ServiceUnavailable; - while (retryCount < RETRY_COUNT) + using TestServer server = new(Program.CreateWebHostBuilder(args)) + using (HttpClient client = server.CreateClient()) { - // Spot test authorization resolver utilization to ensure configuration is used. - HttpResponseMessage postConfigHydrationResult = - await httpClient.GetAsync($"{rest.Path}/{POST_STARTUP_CONFIG_ENTITY}"); - responseCode = postConfigHydrationResult.StatusCode; + // Setup and send GET request + HttpRequestMessage readOpenApiDocumentRequest = new(HttpMethod.Get, $"{RestRuntimeOptions.DEFAULT_PATH}/{OpenApiDocumentor.OPENAPI_ROUTE}"); + HttpResponseMessage response = await client.SendAsync(readOpenApiDocumentRequest); - if (postConfigHydrationResult.StatusCode == HttpStatusCode.ServiceUnavailable) - { - retryCount++; - await Task.Delay(TimeSpan.FromSeconds(Math.Pow(RETRY_WAIT_SECONDS, retryCount))); - continue; - } + // Parse response metadata + string responseBody = await response.Content.ReadAsStringAsync(); + Dictionary responseProperties = JsonSerializer.Deserialize>(responseBody); - break; - } + // Validate response metadata + ValidateOpenApiDocTopLevelPropertiesExist(responseProperties); + JsonElement pathsElement = responseProperties[OpenApiDocumentorConstants.TOPLEVELPROPERTY_PATHS]; - return responseCode; + // Validate that paths were created for the entity with REST enabled. + Assert.IsTrue(pathsElement.TryGetProperty("/Book", out _)); + Assert.IsTrue(pathsElement.TryGetProperty("/Book/id/{id}", out _)); + + // Validate that paths were not created for the entity with REST disabled. + Assert.IsFalse(pathsElement.TryGetProperty("/Publisher", out _)); + Assert.IsFalse(pathsElement.TryGetProperty("/Publisher/id/{id}", out _)); + + JsonElement componentsElement = responseProperties[OpenApiDocumentorConstants.TOPLEVELPROPERTY_COMPONENTS]; + Assert.IsTrue(componentsElement.TryGetProperty(OpenApiDocumentorConstants.PROPERTY_SCHEMAS, out JsonElement componentSchemasElement)); + // Validate that components were created for the entity with REST enabled. + Assert.IsTrue(componentSchemasElement.TryGetProperty("Book_NoPK", out _)); + Assert.IsTrue(componentSchemasElement.TryGetProperty("Book", out _)); + + // Validate that components were not created for the entity with REST disabled. + Assert.IsFalse(componentSchemasElement.TryGetProperty("Publisher_NoPK", out _)); + Assert.IsFalse(componentSchemasElement.TryGetProperty("Publisher", out _)); + } } /// - /// Executing GraphQL POST requests against the engine until a non-503 error is received. + /// Tests that the files specified in data-source-file are read and used to create the database objects. + /// In this case, the config is using data-source-file to specify the table and its relationships. + /// The test ensures that after the configuration is loaded, the application can successfully query the table and also the related object (linking table). /// - /// Client used for request execution. - /// ServiceUnavailable if service is not successfully hydrated with config, - /// else the response code from the GRAPHQL request - private static async Task GetGraphQLResponsePostConfigHydration(HttpClient httpClient, GraphQLRuntimeOptions graphQL) + [TestMethod, TestCategory(TestCategory.MSSQL)] + public async Task TestDataSourceFileReadsAndCreatesDatabaseObjects() { - // Retry request RETRY_COUNT times in exponential increments to allow - // required services time to instantiate and hydrate permissions because - // the DAB services may take an unpredictable amount of time to become ready. - // - // The service might still fail due to the service not being available yet, - // but it is highly unlikely to be the case. - int retryCount = 0; - HttpStatusCode responseCode = HttpStatusCode.ServiceUnavailable; - while (retryCount < RETRY_COUNT) - { - string query = @"{ - book_by_pk(id: 1) { - id, - title, - publisher_id - } - }"; - - object payload = new { query }; - - HttpRequestMessage graphQLRequest = new(HttpMethod.Post, graphQL.Path) - { - Content = JsonContent.Create(payload) - }; - - HttpResponseMessage graphQLResponse = await httpClient.SendAsync(graphQLRequest); - responseCode = graphQLResponse.StatusCode; - - if (responseCode == HttpStatusCode.ServiceUnavailable) - { - retryCount++; - await Task.Delay(TimeSpan.FromSeconds(Math.Pow(RETRY_WAIT_SECONDS, retryCount))); - continue; - } + string dataSourceFile = @"../data-source-files/sample-books-data-source.json"; + string configFile = @"{ + // Link for latest draft schema. + ""$schema"":""https://github.com/Azure/data-api-builder/releases/download/vmajor.minor.patch-alpha/dab.draft.schema.json"", + ""data-source"": { + ""database-type"": ""mssql"", + ""connection-string"": ""sample-conn-string"" + }, + ""runtime"": { + ""rest"": { + ""enabled"": true, + ""path"": ""/api"" + }, + ""graphql"": { + ""enabled"": true, + ""path"": ""/graphql"", + ""allow-introspection"": true + }, + ""host"": { + ""cors"": { + ""origins"": [ + ""http://localhost:5000"" + ], + ""allow-credentials"": false + } + } + }, + ""entities"":{ }, + ""data-source-file"": [ + ""%24{dataSourceFile}%"" + ] + }"; - break; - } + // Act + bool configParsed = RuntimeConfigLoader.TryParseConfig(configFile, out RuntimeConfig deserializedConfig); - return responseCode; - } + // Assert + Assert.IsTrue(configParsed, "Config with data-source-file should be valid."); - /// - /// Executing MCP POST requests against the engine until a non-503 error is received. - /// - /// Client used for request execution. - /// ServiceUnavailable if service is not successfully hydrated with config, - /// else the response code from the MCP request - public static async Task GetMcpResponse(HttpClient httpClient, McpRuntimeOptions mcp) - { - // Retry request RETRY_COUNT times in exponential increments to allow - // required services time to instantiate and hydrate permissions because - // the DAB services may take an unpredictable amount of time to become ready. - // - // The service might still fail due to the service not being available yet, - // but it is highly unlikely to be the case. - int retryCount = 0; - HttpStatusCode responseCode = HttpStatusCode.ServiceUnavailable; - while (retryCount < RETRY_COUNT) + string serializedConfig = deserializedConfig.ToJson(); + using JsonDocument parsedDocument = JsonDocument.Parse(serializedConfig); { - // Minimal MCP request (initialize) - valid JSON-RPC request. - // Using 'initialize' because 'tools/list' requires an active session - // in the MCP Streamable HTTP transport (ModelContextProtocol 1.0.0). - object payload = new - { - jsonrpc = "2.0", - id = 1, - method = "initialize", - @params = new - { - protocolVersion = "2025-03-26", - capabilities = new { }, - clientInfo = new { name = "dab-test", version = "1.0.0" } - } - }; - HttpRequestMessage mcpRequest = new(HttpMethod.Post, mcp.Path) - { - Content = JsonContent.Create(payload) - }; - mcpRequest.Headers.Add("Accept", "application/json, text/event-stream"); + // Validate the data-source-file property exists + JsonElement dataSourceFileElement = parsedDocument.RootElement.GetProperty("data-source-file"); + Assert.IsTrue(dataSourceFileElement.ValueKind == JsonValueKind.Array && dataSourceFileElement.GetArrayLength() == 1); - HttpResponseMessage mcpResponse = await httpClient.SendAsync(mcpRequest); - responseCode = mcpResponse.StatusCode; + string dataSourceFilePath = dataSourceFileElement[0].GetString(); + Assert.IsFalse(string.IsNullOrWhiteSpace(dataSourceFilePath)); - if (responseCode == HttpStatusCode.ServiceUnavailable || responseCode == HttpStatusCode.NotFound) - { - retryCount++; - await Task.Delay(TimeSpan.FromSeconds(Math.Pow(RETRY_WAIT_SECONDS, retryCount))); - continue; - } + // Manually invoke the DataSourceFile configuration processor. + // This is usually triggered by the engine during startup. + TestServer server = new(Program.CreateWebHostBuilder(Array.Empty())); + RuntimeConfigProvider configProvider = server.Services.GetService(); - break; + configProvider.ProcessDataSourceFile(dataSourceFilePath); + + Assert.AreEqual(2, configProvider.GetConfig().Entities.Entities.Count, "Expected two entities to be created from data-source-file."); } - return responseCode; + // Clean up + await Task.CompletedTask; } /// - /// Helper method to instantiate RuntimeConfig object needed for multiple create tests. + /// This test validates that DAB properly creates and returns a nextLink with a single $after + /// query parameter when sending paging requests. + /// The first request initiates a paging workload, meaning the response is expected to have a nextLink. + /// The validation occurs after the second request which uses the previously acquired nextLink + /// This test ensures that the second request's response body contains the expected nextLink which: + /// - is base64 encoded and NOT URI escaped e.g. the trailing "==" are not URI escaped to "%3D%3D" + /// - is not the same as the first response's nextLink -> DAB is properly injecting a new $after query param + /// and updating the new nextLink + /// - does not contain a comma (,) indicating that the URI namevaluecollection tracking the query parameters + /// did not come across two $after query parameters. This addresses a customer raised issue where two $after + /// query parameters were returned by DAB. /// - public static RuntimeConfig InitialzieRuntimeConfigForMultipleCreateTests(bool isMultipleCreateOperationEnabled) + [TestMethod] + [TestCategory(TestCategory.MSSQL)] + public async Task ValidateNextLinkUsage() { - // Multiple create operations are enabled. - GraphQLRuntimeOptions graphqlOptions = new(Enabled: true, MultipleMutationOptions: new(new(enabled: isMultipleCreateOperationEnabled))); - - RestRuntimeOptions restRuntimeOptions = new(Enabled: false); - - McpRuntimeOptions mcpRuntimeOptions = new(Enabled: false); - - DataSource dataSource = new(DatabaseType.MSSQL, GetConnectionStringFromEnvironmentConfig(environment: TestCategory.MSSQL), Options: null); + // Arrange - Setup test server with entity that has >1 record so that results can be paged. + // A short cut to using an entity with >100 records is to just include the $first=1 filter + // as done in this test, so that paging behavior can be invoked. - EntityAction createAction = new( - Action: EntityActionOperation.Create, - Fields: null, - Policy: new()); + const string ENTITY_NAME = "Bookmark"; - EntityAction readAction = new( - Action: EntityActionOperation.Read, + // At least one entity is required in the runtime config for the engine to start. + // Even though this entity is not under test, it must be supplied to enable successfull + // config file creation. + Entity requiredEntity = new( + Source: new("bookmarks", EntitySourceType.Table, null, null), Fields: null, - Policy: new()); - - EntityPermission[] permissions = new[] { new EntityPermission(Role: AuthorizationResolver.ROLE_ANONYMOUS, Actions: new[] { readAction, createAction }) }; + Rest: new(Enabled: true), + GraphQL: new(Singular: "", Plural: "", Enabled: false), + Permissions: new[] { GetMinimalPermissionConfig(AuthorizationResolver.ROLE_ANONYMOUS) }, + Relationships: null, + Mappings: null); - EntityRelationship bookRelationship = new(Cardinality: Cardinality.One, - TargetEntity: "Publisher", - SourceFields: new string[] { }, - TargetFields: new string[] { }, - LinkingObject: null, - LinkingSourceFields: null, - LinkingTargetFields: null); + Dictionary entityMap = new() + { + { ENTITY_NAME, requiredEntity } + }; - Entity bookEntity = new(Source: new("books", EntitySourceType.Table, null, null), - Fields: null, - Rest: null, - GraphQL: new(Singular: "book", Plural: "books"), - Permissions: permissions, - Relationships: new Dictionary() { { "publishers", bookRelationship } }, - Mappings: null); + PaginationOptions paginationOptions = new() + { + DefaultPageSize = 1, + MaxPageSize = 1, + UserProvidedDefaultPageSize = true, + UserProvidedMaxPageSize = true, + NextLinkRelative = false // Absolute nextLink required for this test + }; - string bookEntityName = "Book"; + CreateCustomConfigFile(entityMap, enableGlobalRest: true, paginationOptions: paginationOptions); - Dictionary entityMap = new() + string[] args = new[] { - { bookEntityName, bookEntity } + $"--ConfigFileName={CUSTOM_CONFIG_FILENAME}" }; - EntityRelationship publisherRelationship = new(Cardinality: Cardinality.Many, - TargetEntity: "Book", - SourceFields: new string[] { }, - TargetFields: new string[] { }, - LinkingObject: null, - LinkingSourceFields: null, - LinkingTargetFields: null); + using TestServer server = new(Program.CreateWebHostBuilder(args)); + using HttpClient client = server.CreateClient(); + + // Setup and send GET request + HttpRequestMessage initialPaginationRequest = new(HttpMethod.Get, $"{RestRuntimeOptions.DEFAULT_PATH}/{ENTITY_NAME}?$first=1"); + HttpResponseMessage initialPaginationResponse = await client.SendAsync(initialPaginationRequest); + + // Process response body for first request and get the nextLink to use on subsequent request + // which represents what this test is validating. + string responseBody = await initialPaginationResponse.Content.ReadAsStringAsync(); + Dictionary responseProperties = JsonSerializer.Deserialize>(responseBody); + string nextLinkUri = responseProperties["nextLink"].ToString(); - Entity publisherEntity = new( - Source: new("publishers", EntitySourceType.Table, null, null), - Fields: null, - Rest: null, - GraphQL: new(Singular: "publisher", Plural: "publishers"), - Permissions: permissions, - Relationships: new Dictionary() { { "books", publisherRelationship } }, - Mappings: null); + // Act - Submit request with nextLink uri as target and capture response - entityMap.Add("Publisher", publisherEntity); + HttpRequestMessage followNextLinkRequest = new(HttpMethod.Get, nextLinkUri); + HttpResponseMessage followNextLinkResponse = await client.SendAsync(followNextLinkRequest); - Config.ObjectModel.AuthenticationOptions authenticationOptions = new(Provider: nameof(EasyAuthType.StaticWebApps), null); + // Assert - RuntimeConfig runtimeConfig = new(Schema: "IntegrationTestMinimalSchema", - DataSource: dataSource, - Runtime: new(restRuntimeOptions, graphqlOptions, mcpRuntimeOptions, Host: new(Cors: null, Authentication: authenticationOptions, Mode: HostMode.Development), Cache: null), - Entities: new(entityMap)); - return runtimeConfig; - } + Assert.AreEqual(HttpStatusCode.OK, followNextLinkResponse.StatusCode, message: "Expected request to succeed."); - /// - /// Instantiate minimal runtime config with custom global settings. - /// - /// DataSource to pull connection string required for engine start. - public static RuntimeConfig InitMinimalRuntimeConfig( - DataSource dataSource, - GraphQLRuntimeOptions graphqlOptions, - RestRuntimeOptions restOptions, - McpRuntimeOptions mcpOptions, - Entity entity = null, - string entityName = null, - RuntimeCacheOptions cacheOptions = null - ) - { - entity ??= new( - Source: new("books", EntitySourceType.Table, null, null), - Fields: null, - Rest: null, - GraphQL: new(Singular: "book", Plural: "books"), - Permissions: new[] { GetMinimalPermissionConfig(AuthorizationResolver.ROLE_ANONYMOUS) }, - Relationships: null, - Mappings: null - ); + // Process the response body and inspect the "nextLink" property for expected contents. + string followNextLinkResponseBody = await followNextLinkResponse.Content.ReadAsStringAsync(); + Dictionary followNextLinkResponseProperties = JsonSerializer.Deserialize>(followNextLinkResponseBody); - entityName ??= "Book"; + string followUpResponseNextLink = followNextLinkResponseProperties["nextLink"].ToString(); - Dictionary entityMap = new() + // Build the Uri from nextLink string for query parsing. + // If relative, combine with base; if absolute, use as is. + Uri nextLink = null; + if (Uri.IsWellFormedUriString(followUpResponseNextLink, UriKind.Absolute)) { - { entityName, entity } - }; + nextLink = new(followUpResponseNextLink, UriKind.Absolute); + } + else if (Uri.IsWellFormedUriString(followUpResponseNextLink, UriKind.Relative)) + { + nextLink = new(new("http://localhost:5000"), followUpResponseNextLink); + } + else + { + Assert.Fail($"Invalid nextLink URI format: {followUpResponseNextLink}"); + } - // Adding an entity with only Authorized Access - Entity anotherEntity = new( - Source: new("publishers", EntitySourceType.Table, null, null), - Fields: null, - Rest: null, - GraphQL: new(Singular: "publisher", Plural: "publishers"), - Permissions: new[] { GetMinimalPermissionConfig(AuthorizationResolver.ROLE_AUTHENTICATED) }, - Relationships: null, - Mappings: null - ); - entityMap.Add("Publisher", anotherEntity); + NameValueCollection parsedQueryParameters = HttpUtility.ParseQueryString(query: nextLink.Query); + Assert.AreEqual(expected: false, actual: parsedQueryParameters["$after"].Contains(','), message: "nextLink erroneously contained two $after query parameters that were joined by HttpUtility.ParseQueryString(queryString)."); + Assert.AreNotEqual(notExpected: nextLinkUri, actual: followUpResponseNextLink, message: "The follow up request erroneously returned the same nextLink value."); - Config.ObjectModel.AuthenticationOptions authenticationOptions = new(Provider: nameof(EasyAuthType.AppService), null); + // Do not use SqlPaginationUtils.Base64Encode()/Decode() here to eliminate test dependency on engine code to perform an assert. + try + { + Convert.FromBase64String(parsedQueryParameters["$after"]); + } + catch (FormatException) + { + Assert.Fail(message: "$after query parameter was not a valid base64 encoded value."); + } - return new( - Schema: "IntegrationTestMinimalSchema", - DataSource: dataSource, - Runtime: new(restOptions, graphqlOptions, mcpOptions, - Host: new(Cors: null, Authentication: authenticationOptions, Mode: HostMode.Development), - Cache: cacheOptions - ), - Entities: new(entityMap) - ); + // Validate nextLink is relative if nextLinkRelative is true or false otherwise. + // The assertion is now done directly on the original string, not on the parsed Uri object. + if (isNextLinkRelative) + { + // The server returned a relative URL, so it should NOT start with http/https + Assert.IsFalse(Uri.IsWellFormedUriString(followUpResponseNextLink, UriKind.Absolute), + $"nextLink was expected to be relative but was absolute: {followUpResponseNextLink}"); + Assert.IsTrue(followUpResponseNextLink.StartsWith("/"), + $"nextLink was expected to start with '/' (relative), got: {followUpResponseNextLink}"); + } + else + { + Assert.IsTrue(Uri.IsWellFormedUriString(followUpResponseNextLink, UriKind.Absolute), + $"nextLink was expected to be absolute but was relative: {followUpResponseNextLink}"); + Assert.IsTrue(followUpResponseNextLink.StartsWith("http"), + $"nextLink was expected to start with http/https, got: {followUpResponseNextLink}"); + } } /// - /// Gets PermissionSetting object allowed to perform all actions. + /// 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 + /// } /// - /// Name of role to assign to permission - /// PermissionSetting - public static EntityPermission GetMinimalPermissionConfig(string roleName) + /// The maximum allowed depth for GraphQL queries and mutations. + /// Indicates whether the operation is a mutation or a query. + /// The expected HTTP status code for the operation. + [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) { - EntityAction actionForRole = new( - Action: EntityActionOperation.All, - Fields: null, - Policy: new() - ); + // Arrange + GraphQLRuntimeOptions graphqlOptions = new(DepthLimit: depthLimit); + graphqlOptions = graphqlOptions with { UserProvidedDepthLimit = true }; - return new EntityPermission( - Role: roleName, - Actions: new[] { actionForRole } - ); - } + DataSource dataSource = new(DatabaseType.MSSQL, + GetConnectionStringFromEnvironmentConfig(environment: TestCategory.MSSQL), Options: null); - /// - /// Reads configuration file for defined environment to acquire the connection string. - /// CI/CD Pipelines and local environments may not have connection string set as environment variable. - /// - /// Environment such as TestCategory.MSSQL - /// Connection string - public static string GetConnectionStringFromEnvironmentConfig(string environment) - { - FileSystem fileSystem = new(); - string sqlFile = new FileSystemRuntimeConfigLoader(fileSystem).GetFileNameForEnvironment(environment, considerOverrides: true); - string configPayload = File.ReadAllText(sqlFile); + RuntimeConfig configuration = InitMinimalRuntimeConfig(dataSource, graphqlOptions, restOptions: new(), mcpOptions: new()); + const string CUSTOM_CONFIG = "custom-config.json"; + File.WriteAllText(CUSTOM_CONFIG, configuration.ToJson()); - RuntimeConfigLoader.TryParseConfig(configPayload, out RuntimeConfig runtimeConfig, replacementSettings: new()); + string[] args = new[] + { + $"--ConfigFileName={CUSTOM_CONFIG}" + }; - return runtimeConfig.DataSource.ConnectionString; - } + 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 + } + }"; + } - private static void ValidateCosmosDbSetup(TestServer server) - { - QueryEngineFactory queryEngineFactory = (QueryEngineFactory)server.Services.GetService(typeof(IQueryEngineFactory)); - Assert.IsInstanceOfType(queryEngineFactory.GetQueryEngine(DatabaseType.CosmosDB_NoSQL), typeof(CosmosQueryEngine)); + object payload = new { query }; - MutationEngineFactory mutationEngineFactory = (MutationEngineFactory)server.Services.GetService(typeof(IMutationEngineFactory)); - Assert.IsInstanceOfType(mutationEngineFactory.GetMutationEngine(DatabaseType.CosmosDB_NoSQL), typeof(CosmosMutationEngine)); + HttpRequestMessage graphQLRequest = new(HttpMethod.Post, "/graphql") + { + Content = JsonContent.Create(payload) + }; - MetadataProviderFactory metadataProviderFactory = (MetadataProviderFactory)server.Services.GetService(typeof(IMetadataProviderFactory)); - Assert.IsTrue(metadataProviderFactory.ListMetadataProviders().Any(x => x.GetType() == typeof(CosmosSqlMetadataProvider))); + // Act + HttpResponseMessage graphQLResponse = await client.SendAsync(graphQLRequest); - CosmosClientProvider cosmosClientProvider = server.Services.GetService(typeof(CosmosClientProvider)) as CosmosClientProvider; - Assert.IsNotNull(cosmosClientProvider); - Assert.IsNotNull(cosmosClientProvider.Clients); - Assert.IsTrue(cosmosClientProvider.Clients.Any()); + // Assert + Assert.AreEqual(expectedStatusCodeForGraphQL, graphQLResponse.StatusCode); + string body = await graphQLResponse.Content.ReadAsStringAsync(); + JsonElement responseJson = JsonSerializer.Deserialize(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."); + } } /// - /// Create basic runtime config with given DatabaseType and connectionString with no entity. + /// 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. /// - private static RuntimeConfig CreateBasicRuntimeConfigWithNoEntity( - DatabaseType dbType = DatabaseType.MSSQL, - string connectionString = "") + [TestCategory(TestCategory.MSSQL)] + [TestMethod] + public async Task TestGraphQLIntrospectionQueriesAreNotImpactedByDepthLimit() { - DataSource dataSource = new(dbType, connectionString, new()); - - RuntimeConfig runtimeConfig = new( - Schema: "testSchema.json", - DataSource: dataSource, - Runtime: new( - Rest: new(), - GraphQL: new(), - Mcp: new(), - Host: new(null, null) - ), - Entities: new(new Dictionary()) - ); - - return runtimeConfig; - } + // Arrange + GraphQLRuntimeOptions graphqlOptions = new(DepthLimit: 2); + graphqlOptions = graphqlOptions with { UserProvidedDepthLimit = true }; - /// - /// Create basic runtime config with a single entity and given auth options. - /// - private static RuntimeConfig CreateBasicRuntimeConfigWithSingleEntityAndAuthOptions( - DataSource dataSource, - AuthenticationOptions authenticationOptions = null) - { - Entity entity = new( - Source: new("books", EntitySourceType.Table, null, null), - Fields: null, - Rest: null, - GraphQL: new(Singular: "book", Plural: "books"), - Permissions: new[] { GetMinimalPermissionConfig(AuthorizationResolver.ROLE_ANONYMOUS) }, - Relationships: null, - Mappings: null - ); + DataSource dataSource = new(DatabaseType.MSSQL, + GetConnectionStringFromEnvironmentConfig(environment: TestCategory.MSSQL), Options: null); - string entityName = "Book"; + RuntimeConfig configuration = InitMinimalRuntimeConfig(dataSource, graphqlOptions, restOptions: new(), mcpOptions: new()); + const string CUSTOM_CONFIG = "custom-config.json"; + File.WriteAllText(CUSTOM_CONFIG, configuration.ToJson()); - Dictionary entityMap = new() + string[] args = new[] { - { entityName, entity } + $"--ConfigFileName={CUSTOM_CONFIG}" }; - RuntimeConfig runtimeConfig = new( - Schema: "testSchema.json", - DataSource: dataSource, - Runtime: new( - Rest: new(), - GraphQL: new(), - Mcp: new(), - Host: new(Cors: null, Authentication: authenticationOptions) - ), - Entities: new(entityMap) - ); + using (TestServer server = new(Program.CreateWebHostBuilder(args))) + using (HttpClient client = server.CreateClient()) + { + // nested depth:6 + string query = @"{ + __schema { + types { + name + } + } + }"; - return runtimeConfig; - } + object payload = new { query }; - private bool HandleException(Exception e) where T : Exception - { - if (e is AggregateException aggregateException) - { - aggregateException.Handle(HandleException); - return true; - } - else if (e is T) - { - return true; - } + 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(); - return false; + JsonElement responseJson = JsonSerializer.Deserialize(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("__schema", out _), "The response data should contain __schema data."); + } } } } From ca3579f07781d5b89f33634cd3dcc210e37bdcb1 Mon Sep 17 00:00:00 2001 From: aaron burtle Date: Tue, 10 Mar 2026 11:06:01 -0700 Subject: [PATCH 15/19] fix build errors merge fix --- src/Product/ProductInfo.cs | 1 + .../Configuration/ConfigurationTests.cs | 6045 ++++++++++++++--- .../UnitTests/RequestParserUnitTests.cs | 4 +- .../UnitTests/SqlQueryExecutorUnitTests.cs | 5 +- 4 files changed, 5268 insertions(+), 787 deletions(-) diff --git a/src/Product/ProductInfo.cs b/src/Product/ProductInfo.cs index 781317b157..f42d7802c0 100644 --- a/src/Product/ProductInfo.cs +++ b/src/Product/ProductInfo.cs @@ -9,6 +9,7 @@ namespace Azure.DataApiBuilder.Product; public static class ProductInfo { public const string DAB_APP_NAME_ENV = "DAB_APP_NAME_ENV"; + public const string COSMOSDB_DATABASE_NAME = "COSMOSDB_DATABASE_NAME"; public static readonly string DAB_USER_AGENT = $"dab_oss_{GetProductVersion()}"; public static readonly string CLOUD_ROLE_NAME = "DataApiBuilder"; diff --git a/src/Service.Tests/Configuration/ConfigurationTests.cs b/src/Service.Tests/Configuration/ConfigurationTests.cs index d1a99b0b0a..0ef9b67a4b 100644 --- a/src/Service.Tests/Configuration/ConfigurationTests.cs +++ b/src/Service.Tests/Configuration/ConfigurationTests.cs @@ -906,7 +906,7 @@ public void MsSqlConnStringSupplementedWithAppNameProperty( /// Whether DAB_APP_NAME_ENV is set in environment. (Always present in hosted scenario or if user supplies value.) [DataTestMethod] [DataRow("Host=foo;Username=testuser;", "Host=foo;Username=testuser;Application Name=", false, DisplayName = "[PGSQL]:DAB adds version 'dab_oss_major_minor_patch' to non-provided connection string property 'ApplicationName']")] - [DataRow("Host=foo;Username=testuser;", "Host=foo;Username=testuser;Application Name=", true, DisplayName = "[PGSQL]:DAB adds DAB_APP_NAME_ENV value 'dab_hosted' and version suffix '_major_minor_patch' to non-provided connection string property 'ApplicationName'.")] + [DataRow("Host=foo;Username=testuser;", "Host=foo;Username=testuser;Application Name=", true, DisplayName = "[PGSQL]:DAB adds DAB_APP_NAME_ENV value 'dab_hosted' and version suffix '_major_minor_patch' to non-provided connection string property 'ApplicationName'.]")] [DataRow("Host=foo;Username=testuser;Application Name=UserAppName", "Host=foo;Username=testuser;Application Name=UserAppName,", false, DisplayName = "[PGSQL]:DAB appends version 'dab_oss_major_minor_patch' to user supplied 'Application Name' property.]")] [DataRow("Host=foo;Username=testuser;Application Name=UserAppName", "Host=foo;Username=testuser;Application Name=UserAppName,", true, DisplayName = "[PGSQL]:DAB appends version string 'dab_hosted' and version suffix '_major_minor_patch' to user supplied 'ApplicationName' property.]")] public void PgSqlConnStringSupplementedWithAppNameProperty( @@ -1012,911 +1012,5386 @@ public void TestConnectionStringIsCorrectlyUpdatedWithApplicationName( message: "DAB did not properly set the 'Application Name' connection string property."); } + [TestMethod("Validates that once the configuration is set, the config controller isn't reachable."), TestCategory(TestCategory.COSMOSDBNOSQL)] + [DataRow(CONFIGURATION_ENDPOINT)] + [DataRow(CONFIGURATION_ENDPOINT_V2)] + public async Task TestConflictAlreadySetConfiguration(string configurationEndpoint) + { + TestServer server = new(Program.CreateWebHostFromInMemoryUpdatableConfBuilder(Array.Empty())); + HttpClient httpClient = server.CreateClient(); + + JsonContent content = GetJsonContentForCosmosConfigRequest(configurationEndpoint); + + _ = await httpClient.PostAsync(configurationEndpoint, content); + ValidateCosmosDbSetup(server); + + HttpResponseMessage result = await httpClient.PostAsync(configurationEndpoint, content); + Assert.AreEqual(HttpStatusCode.Conflict, result.StatusCode); + } + + [TestMethod("Validates that the config controller returns a conflict when using local configuration."), TestCategory(TestCategory.COSMOSDBNOSQL)] + [DataRow(CONFIGURATION_ENDPOINT)] + [DataRow(CONFIGURATION_ENDPOINT_V2)] + public async Task TestConflictLocalConfiguration(string configurationEndpoint) + { + Environment.SetEnvironmentVariable + (ASP_NET_CORE_ENVIRONMENT_VAR_NAME, COSMOS_ENVIRONMENT); + TestServer server = new(Program.CreateWebHostBuilder(Array.Empty())); + HttpClient httpClient = server.CreateClient(); + + ValidateCosmosDbSetup(server); + + JsonContent content = GetJsonContentForCosmosConfigRequest(configurationEndpoint); + + HttpResponseMessage result = + await httpClient.PostAsync(configurationEndpoint, content); + Assert.AreEqual(HttpStatusCode.Conflict, result.StatusCode); + } + + [TestMethod("Validates setting the configuration at runtime."), TestCategory(TestCategory.COSMOSDBNOSQL)] + [DataRow(CONFIGURATION_ENDPOINT)] + [DataRow(CONFIGURATION_ENDPOINT_V2)] + public async Task TestSettingConfigurations(string configurationEndpoint) + { + TestServer server = new(Program.CreateWebHostFromInMemoryUpdatableConfBuilder(Array.Empty())); + HttpClient httpClient = server.CreateClient(); + + JsonContent content = GetJsonContentForCosmosConfigRequest(configurationEndpoint); + + HttpResponseMessage postResult = + await httpClient.PostAsync(configurationEndpoint, content); + Assert.AreEqual(HttpStatusCode.OK, postResult.StatusCode); + } + + [TestMethod("Validates an invalid configuration returns a bad request."), TestCategory(TestCategory.COSMOSDBNOSQL)] + [DataRow(CONFIGURATION_ENDPOINT)] + [DataRow(CONFIGURATION_ENDPOINT_V2)] + public async Task TestInvalidConfigurationAtRuntime(string configurationEndpoint) + { + TestServer server = new(Program.CreateWebHostFromInMemoryUpdatableConfBuilder(Array.Empty())); + HttpClient httpClient = server.CreateClient(); + + JsonContent content = GetJsonContentForCosmosConfigRequest(configurationEndpoint, "invalidString"); + + HttpResponseMessage postResult = + await httpClient.PostAsync(configurationEndpoint, content); + Assert.AreEqual(HttpStatusCode.BadRequest, postResult.StatusCode); + } + + [TestMethod("Validates a failure in one of the config updated handlers returns a bad request."), TestCategory(TestCategory.COSMOSDBNOSQL)] + [DataRow(CONFIGURATION_ENDPOINT)] + [DataRow(CONFIGURATION_ENDPOINT_V2)] + public async Task TestSettingFailureConfigurations(string configurationEndpoint) + { + TestServer server = new(Program.CreateWebHostFromInMemoryUpdatableConfBuilder(Array.Empty())); + HttpClient httpClient = server.CreateClient(); + + JsonContent content = GetJsonContentForCosmosConfigRequest(configurationEndpoint); + + RuntimeConfigProvider runtimeConfigProvider = server.Services.GetService(); + runtimeConfigProvider.RuntimeConfigLoadedHandlers.Add((_, _) => + { + return Task.FromResult(false); + }); + + HttpResponseMessage postResult = + await httpClient.PostAsync(configurationEndpoint, content); + + Assert.AreEqual(HttpStatusCode.BadRequest, postResult.StatusCode); + } + + [TestMethod("Validates that the configuration endpoint doesn't return until all configuration loaded handlers have executed."), TestCategory(TestCategory.COSMOSDBNOSQL)] + [DataRow(CONFIGURATION_ENDPOINT)] + [DataRow(CONFIGURATION_ENDPOINT_V2)] + public async Task TestLongRunningConfigUpdatedHandlerConfigurations(string configurationEndpoint) + { + TestServer server = new(Program.CreateWebHostFromInMemoryUpdatableConfBuilder(Array.Empty())); + HttpClient httpClient = server.CreateClient(); + + JsonContent content = GetJsonContentForCosmosConfigRequest(configurationEndpoint); + + RuntimeConfigProvider runtimeConfigProvider = server.Services.GetService(); + bool taskHasCompleted = false; + runtimeConfigProvider.RuntimeConfigLoadedHandlers.Add(async (_, _) => + { + await Task.Delay(1000); + taskHasCompleted = true; + return true; + }); + + HttpResponseMessage postResult = + await httpClient.PostAsync(configurationEndpoint, content); + + Assert.AreEqual(HttpStatusCode.OK, postResult.StatusCode); + Assert.IsTrue(taskHasCompleted); + } + + /// + /// Tests that sending configuration to the DAB engine post-startup will properly hydrate + /// the AuthorizationResolver by: + /// 1. Validate that pre-configuration hydration requests result in 503 Service Unavailable + /// 2. Validate that custom configuration hydration succeeds. + /// 3. Validate that request to protected entity without role membership triggers Authorization Resolver + /// to reject the request with HTTP 403 Forbidden. + /// 4. Validate that request to protected entity with required role membership passes authorization requirements + /// and succeeds with HTTP 200 OK. + /// Note: This test is database engine agnostic, though requires denoting a database environment to fetch a usable + /// connection string to complete the test. Most applicable to CI/CD test execution. + /// + [TestCategory(TestCategory.MSSQL)] + [TestMethod("Validates setting the AuthN/Z configuration post-startup during runtime.")] + [DataRow(CONFIGURATION_ENDPOINT)] + [DataRow(CONFIGURATION_ENDPOINT_V2)] + public async Task TestSqlSettingPostStartupConfigurations(string configurationEndpoint) + { + TestServer server = new(Program.CreateWebHostFromInMemoryUpdatableConfBuilder(Array.Empty())); + HttpClient httpClient = server.CreateClient(); + + RuntimeConfig configuration = AuthorizationHelpers.InitRuntimeConfig( + entityName: POST_STARTUP_CONFIG_ENTITY, + entitySource: POST_STARTUP_CONFIG_ENTITY_SOURCE, + roleName: POST_STARTUP_CONFIG_ROLE, + operation: EntityActionOperation.Read, + includedCols: new HashSet() { "*" }); + + JsonContent content = GetPostStartupConfigParams(MSSQL_ENVIRONMENT, configuration, configurationEndpoint); + + HttpResponseMessage preConfigHydrationResult = + await httpClient.GetAsync($"/{POST_STARTUP_CONFIG_ENTITY}"); + Assert.AreEqual(HttpStatusCode.ServiceUnavailable, preConfigHydrationResult.StatusCode); + + HttpResponseMessage preConfigOpenApiDocumentExistence = + await httpClient.GetAsync($"{RestRuntimeOptions.DEFAULT_PATH}/{OPENAPI_DOCUMENT_ENDPOINT}"); + Assert.AreEqual(HttpStatusCode.ServiceUnavailable, preConfigOpenApiDocumentExistence.StatusCode); + + // SwaggerUI (OpenAPI user interface) is not made available in production/hosting mode. + HttpResponseMessage preConfigOpenApiSwaggerEndpointAvailability = + await httpClient.GetAsync($"/{OPENAPI_SWAGGER_ENDPOINT}"); + Assert.AreEqual(HttpStatusCode.ServiceUnavailable, preConfigOpenApiSwaggerEndpointAvailability.StatusCode); + + HttpStatusCode responseCode = await HydratePostStartupConfiguration(httpClient, content, configurationEndpoint, configuration.Runtime.Rest); + + // When the authorization resolver is properly configured, authorization will have failed + // because no auth headers are present. + Assert.AreEqual( + expected: HttpStatusCode.Forbidden, + actual: responseCode, + message: "Configuration not yet hydrated after retry attempts.."); + + // Sends a GET request to a protected entity which requires a specific role to access. + // Authorization will pass because proper auth headers are present. + HttpRequestMessage message = new(method: HttpMethod.Get, requestUri: $"api/{POST_STARTUP_CONFIG_ENTITY}"); + + // Use an AppService EasyAuth principal carrying the required role when + // authentication is configured to use AppService. + string appServiceTokenPayload = AuthTestHelper.CreateAppServiceEasyAuthToken( + roleClaimType: Config.ObjectModel.AuthenticationOptions.ROLE_CLAIM_TYPE, + additionalClaims: + [ + new AppServiceClaim + { + Typ = Config.ObjectModel.AuthenticationOptions.ROLE_CLAIM_TYPE, + Val = POST_STARTUP_CONFIG_ROLE + } + ]); + + message.Headers.Add(Config.ObjectModel.AuthenticationOptions.CLIENT_PRINCIPAL_HEADER, appServiceTokenPayload); + message.Headers.Add(AuthorizationResolver.CLIENT_ROLE_HEADER, POST_STARTUP_CONFIG_ROLE); + HttpResponseMessage authorizedResponse = await httpClient.SendAsync(message); + Assert.AreEqual(expected: HttpStatusCode.OK, actual: authorizedResponse.StatusCode); + + // OpenAPI document is created during config hydration and + // is made available after config hydration completes. + HttpResponseMessage postConfigOpenApiDocumentExistence = + await httpClient.GetAsync($"{RestRuntimeOptions.DEFAULT_PATH}/{OPENAPI_DOCUMENT_ENDPOINT}"); + Assert.AreEqual(HttpStatusCode.OK, postConfigOpenApiDocumentExistence.StatusCode); + + // SwaggerUI (OpenAPI user interface) is not made available in production/hosting mode. + // HTTP 400 - BadRequest because when SwaggerUI is disabled, the endpoint is not mapped + // and the request is processed and failed by the RestService. + HttpResponseMessage postConfigOpenApiSwaggerEndpointAvailability = + await httpClient.GetAsync($"/{OPENAPI_SWAGGER_ENDPOINT}"); + Assert.AreEqual(HttpStatusCode.BadRequest, postConfigOpenApiSwaggerEndpointAvailability.StatusCode); + } + + /// + /// Tests that sending configuration to the DAB engine post-startup will properly hydrate even with data-source-files specified. + /// + [TestCategory(TestCategory.MSSQL)] + [TestMethod("Validates RuntimeConfig setup for post-configuraiton hydration with datasource-files specified.")] + [DataRow(CONFIGURATION_ENDPOINT)] + [DataRow(CONFIGURATION_ENDPOINT_V2)] + public async Task TestValidMultiSourceRunTimePostStartupConfigurations(string configurationEndpoint) + { + TestServer server = new(Program.CreateWebHostFromInMemoryUpdatableConfBuilder(Array.Empty())); + HttpClient httpClient = server.CreateClient(); + + RuntimeConfig config = AuthorizationHelpers.InitRuntimeConfig( + entityName: POST_STARTUP_CONFIG_ENTITY, + entitySource: POST_STARTUP_CONFIG_ENTITY_SOURCE, + roleName: POST_STARTUP_CONFIG_ROLE, + operation: EntityActionOperation.Read, + includedCols: new HashSet() { "*" }); + + // Set up Configuration with DataSource files. + config = config with { DataSourceFiles = new DataSourceFiles(new List() { "file1", "file2" }) }; + + JsonContent content = GetPostStartupConfigParams(MSSQL_ENVIRONMENT, config, configurationEndpoint); + + HttpResponseMessage postResult = await httpClient.PostAsync(configurationEndpoint, content); + Assert.AreEqual(HttpStatusCode.OK, postResult.StatusCode); + + RuntimeConfigProvider configProvider = server.Services.GetService(); + + Assert.IsNotNull(configProvider, "Configuration Provider shouldn't be null after setting the configuration at runtime."); + Assert.IsTrue(configProvider.TryGetConfig(out RuntimeConfig configuration), "TryGetConfig should return true when the config is set."); + Assert.IsNotNull(configuration, "Config returned should not be null."); + + Assert.IsNotNull(configuration.DataSource, "The base datasource should get populated in case of late hydration of config in-spite of invalid multi-db files."); + Assert.AreEqual(1, configuration.ListAllDataSources().Count(), "There should be only 1 datasource populated for late hydration of config with invalid multi-db files."); + } + + [TestMethod("Validates that local CosmosDB_NoSQL settings can be loaded and the correct classes are in the service provider."), TestCategory(TestCategory.COSMOSDBNOSQL)] + public void TestLoadingLocalCosmosSettings() + { + Environment.SetEnvironmentVariable(ASP_NET_CORE_ENVIRONMENT_VAR_NAME, COSMOS_ENVIRONMENT); + TestServer server = new(Program.CreateWebHostBuilder(Array.Empty())); + + ValidateCosmosDbSetup(server); + } + + [TestMethod("Validates access token is correctly loaded when Account Key is not present for Cosmos."), TestCategory(TestCategory.COSMOSDBNOSQL)] + [DataRow(CONFIGURATION_ENDPOINT)] + [DataRow(CONFIGURATION_ENDPOINT_V2)] + public async Task TestLoadingAccessTokenForCosmosClient(string configurationEndpoint) + { + TestServer server = new(Program.CreateWebHostFromInMemoryUpdatableConfBuilder(Array.Empty())); + HttpClient httpClient = server.CreateClient(); + + JsonContent content = GetJsonContentForCosmosConfigRequest(configurationEndpoint, null, true); + + HttpResponseMessage authorizedResponse = await httpClient.PostAsync(configurationEndpoint, content); + + Assert.AreEqual(expected: HttpStatusCode.OK, actual: authorizedResponse.StatusCode); + CosmosClientProvider cosmosClientProvider = server.Services.GetService(typeof(CosmosClientProvider)) as CosmosClientProvider; + Assert.IsNotNull(cosmosClientProvider); + Assert.IsNotNull(cosmosClientProvider.Clients); + Assert.IsTrue(cosmosClientProvider.Clients.Any()); + } + + [TestMethod("Validates that local MsSql settings can be loaded and the correct classes are in the service provider."), TestCategory(TestCategory.MSSQL)] + public void TestLoadingLocalMsSqlSettings() + { + Environment.SetEnvironmentVariable(ASP_NET_CORE_ENVIRONMENT_VAR_NAME, MSSQL_ENVIRONMENT); + TestServer server = new(Program.CreateWebHostBuilder(Array.Empty())); + + QueryEngineFactory queryEngineFactory = (QueryEngineFactory)server.Services.GetService(typeof(IQueryEngineFactory)); + Assert.IsInstanceOfType(queryEngineFactory.GetQueryEngine(DatabaseType.MSSQL), typeof(SqlQueryEngine)); + + MutationEngineFactory mutationEngineFactory = (MutationEngineFactory)server.Services.GetService(typeof(IMutationEngineFactory)); + Assert.IsInstanceOfType(mutationEngineFactory.GetMutationEngine(DatabaseType.MSSQL), typeof(SqlMutationEngine)); + + QueryManagerFactory queryManagerFactory = (QueryManagerFactory)server.Services.GetService(typeof(IAbstractQueryManagerFactory)); + Assert.IsInstanceOfType(queryManagerFactory.GetQueryBuilder(DatabaseType.MSSQL), typeof(MsSqlQueryBuilder)); + Assert.IsInstanceOfType(queryManagerFactory.GetQueryExecutor(DatabaseType.MSSQL), typeof(MsSqlQueryExecutor)); + + MetadataProviderFactory metadataProviderFactory = (MetadataProviderFactory)server.Services.GetService(typeof(IMetadataProviderFactory)); + Assert.IsTrue(metadataProviderFactory.ListMetadataProviders().Any(x => x.GetType() == typeof(MsSqlMetadataProvider))); + } + + [TestMethod("Validates that local PostgreSql settings can be loaded and the correct classes are in the service provider."), TestCategory(TestCategory.POSTGRESQL)] + public void TestLoadingLocalPostgresSettings() + { + Environment.SetEnvironmentVariable(ASP_NET_CORE_ENVIRONMENT_VAR_NAME, POSTGRESQL_ENVIRONMENT); + TestServer server = new(Program.CreateWebHostBuilder(Array.Empty())); + + QueryEngineFactory queryEngineFactory = (QueryEngineFactory)server.Services.GetService(typeof(IQueryEngineFactory)); + Assert.IsInstanceOfType(queryEngineFactory.GetQueryEngine(DatabaseType.PostgreSQL), typeof(SqlQueryEngine)); + + MutationEngineFactory mutationEngineFactory = (MutationEngineFactory)server.Services.GetService(typeof(IMutationEngineFactory)); + Assert.IsInstanceOfType(mutationEngineFactory.GetMutationEngine(DatabaseType.PostgreSQL), typeof(SqlMutationEngine)); + + QueryManagerFactory queryManagerFactory = (QueryManagerFactory)server.Services.GetService(typeof(IAbstractQueryManagerFactory)); + Assert.IsInstanceOfType(queryManagerFactory.GetQueryBuilder(DatabaseType.PostgreSQL), typeof(PostgresQueryBuilder)); + Assert.IsInstanceOfType(queryManagerFactory.GetQueryExecutor(DatabaseType.PostgreSQL), typeof(PostgreSqlQueryExecutor)); + + MetadataProviderFactory metadataProviderFactory = (MetadataProviderFactory)server.Services.GetService(typeof(IMetadataProviderFactory)); + Assert.IsTrue(metadataProviderFactory.ListMetadataProviders().Any(x => x.GetType() == typeof(PostgreSqlMetadataProvider))); + } + + [TestMethod("Validates that local MySql settings can be loaded and the correct classes are in the service provider."), TestCategory(TestCategory.MYSQL)] + public void TestLoadingLocalMySqlSettings() + { + Environment.SetEnvironmentVariable(ASP_NET_CORE_ENVIRONMENT_VAR_NAME, MYSQL_ENVIRONMENT); + TestServer server = new(Program.CreateWebHostBuilder(Array.Empty())); + + QueryEngineFactory queryEngineFactory = (QueryEngineFactory)server.Services.GetService(typeof(IQueryEngineFactory)); + Assert.IsInstanceOfType(queryEngineFactory.GetQueryEngine(DatabaseType.MySQL), typeof(SqlQueryEngine)); + + MutationEngineFactory mutationEngineFactory = (MutationEngineFactory)server.Services.GetService(typeof(IMutationEngineFactory)); + Assert.IsInstanceOfType(mutationEngineFactory.GetMutationEngine(DatabaseType.MySQL), typeof(SqlMutationEngine)); + + QueryManagerFactory queryManagerFactory = (QueryManagerFactory)server.Services.GetService(typeof(IAbstractQueryManagerFactory)); + Assert.IsInstanceOfType(queryManagerFactory.GetQueryBuilder(DatabaseType.MySQL), typeof(MySqlQueryBuilder)); + Assert.IsInstanceOfType(queryManagerFactory.GetQueryExecutor(DatabaseType.MySQL), typeof(MySqlQueryExecutor)); + + MetadataProviderFactory metadataProviderFactory = (MetadataProviderFactory)server.Services.GetService(typeof(IMetadataProviderFactory)); + Assert.IsTrue(metadataProviderFactory.ListMetadataProviders().Any(x => x.GetType() == typeof(MySqlMetadataProvider))); + } + + [TestMethod("Validates that trying to override configs that are already set fail."), TestCategory(TestCategory.COSMOSDBNOSQL)] + [DataRow(CONFIGURATION_ENDPOINT)] + [DataRow(CONFIGURATION_ENDPOINT_V2)] + public async Task TestOverridingLocalSettingsFails(string configurationEndpoint) + { + Environment.SetEnvironmentVariable(ASP_NET_CORE_ENVIRONMENT_VAR_NAME, COSMOS_ENVIRONMENT); + TestServer server = new(Program.CreateWebHostBuilder(Array.Empty())); + HttpClient client = server.CreateClient(); + + JsonContent config = GetJsonContentForCosmosConfigRequest(configurationEndpoint); + + HttpResponseMessage postResult = await client.PostAsync(configurationEndpoint, config); + Assert.AreEqual(HttpStatusCode.Conflict, postResult.StatusCode); + } + + [TestMethod("Validates that setting the configuration at runtime will instantiate the proper classes."), TestCategory(TestCategory.COSMOSDBNOSQL)] + [DataRow(CONFIGURATION_ENDPOINT)] + [DataRow(CONFIGURATION_ENDPOINT_V2)] + public async Task TestSettingConfigurationCreatesCorrectClasses(string configurationEndpoint) + { + TestServer server = new(Program.CreateWebHostFromInMemoryUpdatableConfBuilder(Array.Empty())); + HttpClient client = server.CreateClient(); + + JsonContent content = GetJsonContentForCosmosConfigRequest(configurationEndpoint); + + HttpResponseMessage postResult = await client.PostAsync(configurationEndpoint, content); + Assert.AreEqual(HttpStatusCode.OK, postResult.StatusCode); + + ValidateCosmosDbSetup(server); + RuntimeConfigProvider configProvider = server.Services.GetService(); + + Assert.IsNotNull(configProvider, "Configuration Provider shouldn't be null after setting the configuration at runtime."); + Assert.IsTrue(configProvider.TryGetConfig(out RuntimeConfig configuration), "TryGetConfig should return true when the config is set."); + Assert.IsNotNull(configuration, "Config returned should not be null."); + + ConfigurationPostParameters expectedParameters = GetCosmosConfigurationParameters(); + Assert.AreEqual(DatabaseType.CosmosDB_NoSQL, configuration.DataSource.DatabaseType, "Expected CosmosDB_NoSQL database type after configuring the runtime with CosmosDB_NoSQL settings."); + CosmosDbNoSQLDataSourceOptions options = configuration.DataSource.GetTypedOptions(); + Assert.IsNotNull(options); + Assert.AreEqual(expectedParameters.Schema, options.GraphQLSchema, "Expected the schema in the configuration to match the one sent to the configuration endpoint."); + + // Don't use Assert.AreEqual, because a failure will print the entire connection string in the error message. + Assert.IsTrue(expectedParameters.ConnectionString == configuration.DataSource.ConnectionString, "Expected the connection string in the configuration to match the one sent to the configuration endpoint."); + string db = options.Database; + Assert.AreEqual(COSMOS_DATABASE_NAME, db, "Expected the database name in the runtime config to match the one sent to the configuration endpoint."); + } + + [TestMethod("Validates that an exception is thrown if there's a null model in filter parser.")] + public void VerifyExceptionOnNullModelinFilterParser() + { + ODataParser parser = new(); + try + { + // FilterParser has no model so we expect exception + parser.GetFilterClause(filterQueryString: string.Empty, resourcePath: string.Empty); + Assert.Fail(); + } + catch (DataApiBuilderException exception) + { + Assert.AreEqual("The runtime has not been initialized with an Edm model.", exception.Message); + Assert.AreEqual(HttpStatusCode.InternalServerError, exception.StatusCode); + Assert.AreEqual(DataApiBuilderException.SubStatusCodes.UnexpectedError, exception.SubStatusCode); + } + } + + /// + /// This test reads the dab-config.MsSql.json file and validates that the + /// deserialization succeeds. + /// + [TestMethod("Validates if deserialization of MsSql config file succeeds."), TestCategory(TestCategory.MSSQL)] + public Task TestReadingRuntimeConfigForMsSql() + { + return ConfigFileDeserializationValidationHelper(File.ReadAllText($"{CONFIGFILE_NAME}.{MSSQL_ENVIRONMENT}{CONFIG_EXTENSION}")); + } + + /// + /// This test reads the dab-config.MySql.json file and validates that the + /// deserialization succeeds. + /// + [TestMethod("Validates if deserialization of MySql config file succeeds."), TestCategory(TestCategory.MYSQL)] + public Task TestReadingRuntimeConfigForMySql() + { + return ConfigFileDeserializationValidationHelper(File.ReadAllText($"{CONFIGFILE_NAME}.{MYSQL_ENVIRONMENT}{CONFIG_EXTENSION}")); + } + + /// + /// This test reads the dab-config.PostgreSql.json file and validates that the + /// deserialization succeeds. + /// + [TestMethod("Validates if deserialization of PostgreSql config file succeeds."), TestCategory(TestCategory.POSTGRESQL)] + public Task TestReadingRuntimeConfigForPostgreSql() + { + return ConfigFileDeserializationValidationHelper(File.ReadAllText($"{CONFIGFILE_NAME}.{POSTGRESQL_ENVIRONMENT}{CONFIG_EXTENSION}")); + } + + /// + /// This test reads the dab-config.CosmosDb_NoSql.json file and validates that the + /// deserialization succeeds. + /// + [TestMethod("Validates if deserialization of the CosmosDB_NoSQL config file succeeds."), TestCategory(TestCategory.COSMOSDBNOSQL)] + public Task TestReadingRuntimeConfigForCosmos() + { + return ConfigFileDeserializationValidationHelper(File.ReadAllText($"{CONFIGFILE_NAME}.{COSMOS_ENVIRONMENT}{CONFIG_EXTENSION}")); + } + + /// + /// Helper method to validate the deserialization of the "entities" section of the config file + /// This is used in unit tests that validate the deserialization of the config files + /// + /// + private Task ConfigFileDeserializationValidationHelper(string jsonString) + { + Assert.IsTrue(RuntimeConfigLoader.TryParseConfig(jsonString, out RuntimeConfig runtimeConfig), "Deserialization of the config file failed."); + return Verify(runtimeConfig); + } + + /// + /// This function verifies command line configuration provider takes higher + /// precedence than default configuration file dab-config.json + /// + [TestMethod("Validates command line configuration provider."), TestCategory(TestCategory.COSMOSDBNOSQL)] + public void TestCommandLineConfigurationProvider() + { + Environment.SetEnvironmentVariable(ASP_NET_CORE_ENVIRONMENT_VAR_NAME, MSSQL_ENVIRONMENT); + string[] args = new[] + { + $"--ConfigFileName={CONFIGFILE_NAME}." + + $"{COSMOS_ENVIRONMENT}{CONFIG_EXTENSION}" + }; + + TestServer server = new(Program.CreateWebHostBuilder(args)); + + ValidateCosmosDbSetup(server); + } + + /// + /// This function verifies the environment variable DAB_ENVIRONMENT + /// takes precedence than ASPNETCORE_ENVIRONMENT for the configuration file. + /// + [TestMethod("Validates precedence is given to DAB_ENVIRONMENT environment variable name."), TestCategory(TestCategory.COSMOSDBNOSQL)] + public void TestRuntimeEnvironmentVariable() + { + Environment.SetEnvironmentVariable( + ASP_NET_CORE_ENVIRONMENT_VAR_NAME, MSSQL_ENVIRONMENT); + Environment.SetEnvironmentVariable( + RUNTIME_ENVIRONMENT_VAR_NAME, COSMOS_ENVIRONMENT); + + TestServer server = new(Program.CreateWebHostBuilder(Array.Empty())); + + ValidateCosmosDbSetup(server); + } + + /// + /// This method tests the config properties like data-source, runtime settings and entities. + /// + [TestMethod("Validates the runtime configuration file properties."), TestCategory(TestCategory.MSSQL)] + public void TestConfigPropertiesAreValid() + { + TestHelper.SetupDatabaseEnvironment(MSSQL_ENVIRONMENT); + FileSystemRuntimeConfigLoader configPath = TestHelper.GetRuntimeConfigLoader(); + RuntimeConfigProvider configProvider = TestHelper.GetRuntimeConfigProvider(configPath); + + Mock> configValidatorLogger = new(); + RuntimeConfigValidator configValidator = + new( + configProvider, + new MockFileSystem(), + configValidatorLogger.Object); + + configValidator.ValidateConfigProperties(); + } + + /// + /// This method tests that config file is validated correctly and no exceptions are thrown. + /// This tests gets the json from the integration test config file and then uses that + /// to validate the complete config file. + /// + [TestMethod("Validates the complete config."), TestCategory(TestCategory.MSSQL)] + public async Task TestConfigIsValid() + { + // Fetch the MS_SQL integration test config file. + TestHelper.SetupDatabaseEnvironment(MSSQL_ENVIRONMENT); + FileSystemRuntimeConfigLoader testConfigPath = TestHelper.GetRuntimeConfigLoader(); + RuntimeConfig configuration = TestHelper.GetRuntimeConfigProvider(testConfigPath).GetConfig(); + const string CUSTOM_CONFIG = "custom-config.json"; + + MockFileSystem fileSystem = new(); + + // write it to the custom-config file and add it to the filesystem. + fileSystem.AddFile(CUSTOM_CONFIG, new MockFileData(configuration.ToJson())); + FileSystemRuntimeConfigLoader configLoader = new(fileSystem); + configLoader.UpdateConfigFilePath(CUSTOM_CONFIG); + RuntimeConfigProvider configProvider = TestHelper.GetRuntimeConfigProvider(configLoader); + + Mock> configValidatorLogger = new(); + RuntimeConfigValidator configValidator = + new( + configProvider, + fileSystem, + configValidatorLogger.Object, + true); + + try + { + // Run the validate on the custom config json file. + Assert.IsTrue(await configValidator.TryValidateConfig(CUSTOM_CONFIG, TestHelper.ProvisionLoggerFactory())); + } + catch (Exception e) + { + Assert.Fail(e.Message); + } + } + + /// + /// 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`. + /// + [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); + } + + /// + /// 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. + /// + [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); + } + + /// + /// This method validates that depth-limit outside the valid range should fail validation + /// during `dab validate` and `dab start`. + /// + /// + /// + 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> 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); + } + + /// + /// This test method checks a valid config's entities against + /// the database and ensures they are valid. + /// + [TestMethod("Validation passes for valid entities against database."), TestCategory(TestCategory.MSSQL)] + public async Task TestSqlMetadataForValidConfigEntities() + { + TestHelper.SetupDatabaseEnvironment(MSSQL_ENVIRONMENT); + FileSystemRuntimeConfigLoader configPath = TestHelper.GetRuntimeConfigLoader(); + RuntimeConfigProvider configProvider = TestHelper.GetRuntimeConfigProvider(configPath); + + Mock> configValidatorLogger = new(); + ILoggerFactory mockLoggerFactory = TestHelper.ProvisionLoggerFactory(); + + RuntimeConfigValidator configValidator = + new( + configProvider, + new MockFileSystem(), + configValidatorLogger.Object, + isValidateOnly: true); + + configValidator.ValidateRelationshipConfigCorrectness(configProvider.GetConfig()); + await configValidator.ValidateEntitiesMetadata(configProvider.GetConfig(), mockLoggerFactory); + Assert.IsTrue(EnumerableUtilities.IsNullOrEmpty(configValidator.ConfigValidationExceptions)); + } + + /// + /// This test method checks a valid config's entities against + /// the database and ensures they are valid. + /// The config contains an entity source object not present in the database. + /// It also contains an entity whose source is incorrectly specified as a stored procedure. + /// + [TestMethod("Validation fails for invalid entities against database."), TestCategory(TestCategory.MSSQL)] + public async Task TestSqlMetadataForInvalidConfigEntities() + { + TestHelper.SetupDatabaseEnvironment(MSSQL_ENVIRONMENT); + + DataSource dataSource = new(DatabaseType.MSSQL, + GetConnectionStringFromEnvironmentConfig(environment: TestCategory.MSSQL), + Options: null); + + RuntimeConfig configuration = InitMinimalRuntimeConfig(dataSource, new(), new(), new()); + + // creating an entity with invalid table name + Entity entityWithInvalidSourceName = new( + Source: new("bokos", EntitySourceType.Table, null, null), + Fields: null, + Rest: null, + GraphQL: new(Singular: "book", Plural: "books"), + Permissions: new[] { GetMinimalPermissionConfig(AuthorizationResolver.ROLE_ANONYMOUS) }, + Relationships: null, + Mappings: null + ); + + Entity entityWithInvalidSourceType = new( + Source: new("publishers", EntitySourceType.StoredProcedure, null, null), + Fields: null, + Rest: null, + GraphQL: new(Singular: "publisher", Plural: "publishers"), + Permissions: new[] { GetMinimalPermissionConfig(AuthorizationResolver.ROLE_AUTHENTICATED) }, + Relationships: null, + Mappings: null + ); + + configuration = configuration with + { + Entities = new RuntimeEntities(new Dictionary() + { + { "Book", entityWithInvalidSourceName }, + { "Publisher", entityWithInvalidSourceType} + }) + }; + + const string CUSTOM_CONFIG = "custom-config.json"; + File.WriteAllText(CUSTOM_CONFIG, configuration.ToJson()); + + FileSystemRuntimeConfigLoader configLoader = TestHelper.GetRuntimeConfigLoader(); + configLoader.UpdateConfigFilePath(CUSTOM_CONFIG); + RuntimeConfigProvider configProvider = TestHelper.GetRuntimeConfigProvider(configLoader); + + Mock> configValidatorLogger = new(); + RuntimeConfigValidator configValidator = + new( + configProvider, + new MockFileSystem(), + configValidatorLogger.Object, + isValidateOnly: true); + + ILoggerFactory mockLoggerFactory = TestHelper.ProvisionLoggerFactory(); + + configValidator.ValidateRelationshipConfigCorrectness(configProvider.GetConfig()); + await configValidator.ValidateEntitiesMetadata(configProvider.GetConfig(), mockLoggerFactory); + + Assert.IsTrue(configValidator.ConfigValidationExceptions.Any()); + Assert.AreEqual(2, configValidator.ConfigValidationExceptions.Count); + List exceptionsList = configValidator.ConfigValidationExceptions; + Assert.AreEqual("Cannot obtain Schema for entity Book with underlying database " + + "object source: dbo.bokos due to: Invalid object name 'dbo.bokos'.", exceptionsList[0].Message); + Assert.AreEqual("No stored procedure definition found for the given database object publishers", exceptionsList[1].Message); + } + + /// + /// This Test validates that when the entities in the runtime config have source object as null, + /// the validation exception handler collects the message and exits gracefully. + /// + [TestMethod("Validate Exception handling for Entities with Source object as null."), TestCategory(TestCategory.MSSQL)] + public async Task TestSqlMetadataValidationForEntitiesWithInvalidSource() + { + TestHelper.SetupDatabaseEnvironment(MSSQL_ENVIRONMENT); + + DataSource dataSource = new(DatabaseType.MSSQL, + GetConnectionStringFromEnvironmentConfig(environment: TestCategory.MSSQL), + Options: null); + + RuntimeConfig configuration = InitMinimalRuntimeConfig(dataSource, new(), new(), new()); + + // creating an entity with invalid table name + Entity entityWithInvalidSource = new( + Source: new(null, EntitySourceType.Table, null, null), + Fields: null, + Rest: null, + GraphQL: new(Singular: "book", Plural: "books"), + Permissions: new[] { GetMinimalPermissionConfig(AuthorizationResolver.ROLE_ANONYMOUS) }, + Relationships: null, + Mappings: null + ); + + // creating an entity with invalid source object and adding relationship with an entity with invalid source + Entity entityWithInvalidSourceAndRelationship = new( + Source: new(null, EntitySourceType.Table, null, null), + Fields: null, + Rest: null, + GraphQL: new(Singular: "publisher", Plural: "publishers"), + Permissions: new[] { GetMinimalPermissionConfig(AuthorizationResolver.ROLE_ANONYMOUS) }, + Relationships: new Dictionary() { {"books", new ( + Cardinality: Cardinality.Many, + TargetEntity: "Book", + SourceFields: null, + TargetFields: null, + LinkingObject: null, + LinkingSourceFields: null, + LinkingTargetFields: null + )}}, + Mappings: null + ); + + configuration = configuration with + { + Entities = new RuntimeEntities(new Dictionary() + { + { "Book", entityWithInvalidSource }, + { "Publisher", entityWithInvalidSourceAndRelationship} + }) + }; + + const string CUSTOM_CONFIG = "custom-config.json"; + File.WriteAllText(CUSTOM_CONFIG, configuration.ToJson()); + + FileSystemRuntimeConfigLoader configLoader = TestHelper.GetRuntimeConfigLoader(); + configLoader.UpdateConfigFilePath(CUSTOM_CONFIG); + RuntimeConfigProvider configProvider = TestHelper.GetRuntimeConfigProvider(configLoader); + + Mock> configValidatorLogger = new(); + RuntimeConfigValidator configValidator = + new( + configProvider, + new MockFileSystem(), + configValidatorLogger.Object, + isValidateOnly: true); + + ILoggerFactory mockLoggerFactory = TestHelper.ProvisionLoggerFactory(); + + try + { + configValidator.ValidateRelationshipConfigCorrectness(configProvider.GetConfig()); + await configValidator.ValidateEntitiesMetadata(configProvider.GetConfig(), mockLoggerFactory); + } + catch + { + Assert.Fail("Execution of dab validate should not result in unhandled exceptions."); + } + + Assert.IsTrue(configValidator.ConfigValidationExceptions.Any()); + List exceptionMessagesList = configValidator.ConfigValidationExceptions.Select(x => x.Message).ToList(); + Assert.IsTrue(exceptionMessagesList.Contains("The entity Book does not have a valid source object.")); + Assert.IsTrue(exceptionMessagesList.Contains("The entity Publisher does not have a valid source object.")); + Assert.IsTrue(exceptionMessagesList.Contains("Table Definition for Book has not been inferred.")); + Assert.IsTrue(exceptionMessagesList.Contains("Table Definition for Publisher has not been inferred.")); + Assert.IsTrue(exceptionMessagesList.Contains("Could not infer database object for source entity: Publisher in relationship: books. Check if the entity: Publisher is correctly defined in the config.")); + Assert.IsTrue(exceptionMessagesList.Contains("Could not infer database object for target entity: Book in relationship: books. Check if the entity: Book is correctly defined in the config.")); + } + + /// + /// This test method validates a sample DAB runtime config file against DAB's JSON schema definition. + /// It asserts that the validation is successful and there are no validation failures. + /// It also verifies that the expected log message is logged. + /// + [TestMethod("Validates the config file schema."), TestCategory(TestCategory.MSSQL)] + public void TestConfigSchemaIsValid() + { + TestHelper.SetupDatabaseEnvironment(MSSQL_ENVIRONMENT); + FileSystemRuntimeConfigLoader configLoader = TestHelper.GetRuntimeConfigLoader(); + + Mock> schemaValidatorLogger = new(); + + string jsonSchema = File.ReadAllText("dab.draft.schema.json"); + string jsonData = File.ReadAllText(configLoader.ConfigFilePath); + + JsonConfigSchemaValidator jsonSchemaValidator = new(schemaValidatorLogger.Object, new MockFileSystem()); + + JsonSchemaValidationResult result = jsonSchemaValidator.ValidateJsonConfigWithSchema(jsonSchema, jsonData); + Assert.IsTrue(result.IsValid); + Assert.IsTrue(EnumerableUtilities.IsNullOrEmpty(result.ValidationErrors)); + schemaValidatorLogger.Verify( + x => x.Log( + LogLevel.Information, + It.IsAny(), + It.Is((o, t) => o.ToString()!.Contains($"The config satisfies the schema requirements.")), + It.IsAny(), + (Func)It.IsAny()), + Times.Once); + } + + /// + /// This test method validates a sample DAB runtime config file against DAB's JSON schema definition. + /// It asserts that the validation is successful and there are no validation failures when no optional fields are used. + /// It also verifies that the expected log message is logged. + /// + [DataTestMethod] + [DataRow(CONFIG_FILE_WITH_NO_OPTIONAL_FIELD, DisplayName = "Validates schema of the config file with no optional fields.")] + [DataRow(CONFIG_FILE_WITH_NO_AUTHENTICATION_FIELD, DisplayName = "Validates schema of the config file with no Authentication field.")] + [DataRow(CONFIG_FILE_WITH_NO_CORS_FIELD, DisplayName = "Validates schema of the config file with no Cors field.")] + public void TestBasicConfigSchemaWithNoOptionalFieldsIsValid(string jsonData) + { + Mock> schemaValidatorLogger = new(); + + string jsonSchema = File.ReadAllText("dab.draft.schema.json"); + + JsonConfigSchemaValidator jsonSchemaValidator = new(schemaValidatorLogger.Object, new MockFileSystem()); + + JsonSchemaValidationResult result = jsonSchemaValidator.ValidateJsonConfigWithSchema(jsonSchema, jsonData); + Assert.AreEqual("", String.Join('\n', result.ValidationErrors?.Select(s => $"{s.Message} at {s.Path} {s.LineNumber} {s.LinePosition}") ?? []), "Expected no validation errors."); + Assert.IsTrue(EnumerableUtilities.IsNullOrEmpty(result.ValidationErrors)); + + Assert.IsTrue(result.IsValid); + schemaValidatorLogger.Verify( + x => x.Log( + LogLevel.Information, + It.IsAny(), + It.Is((o, t) => o.ToString()!.Contains($"The config satisfies the schema requirements.")), + It.IsAny(), + (Func)It.IsAny()), + Times.Once); + } + + [DataTestMethod] + [DataRow("true", DisplayName = "Validates variable boolean schema for true value")] + [DataRow("false", DisplayName = "Validates variable boolean schema for false value.")] + [DataRow("\"true\"", DisplayName = "Validates variable boolean schema for true as string.")] + [DataRow("\"false\"", DisplayName = "Validates variable boolean schema for false as string.")] + [DataRow("\"1\"", DisplayName = "Validates variable boolean schema for 1 as string.")] + [DataRow("\"0\"", DisplayName = "Validates variable boolean schema for 0as string.")] + [DataRow("\"@env('SAMPLE')\"", DisplayName = "Validates variable boolean schema for environment variables.")] + [DataRow("\"@akv('SAMPLE')\"", DisplayName = "Validates variable boolean schema for keyvaul variables.")] + public void TestBasicConfigSchemaWithFlexibleBoolean(string Value) + { + Mock> schemaValidatorLogger = new(); + + string jsonSchema = File.ReadAllText("dab.draft.schema.json"); + + JsonConfigSchemaValidator jsonSchemaValidator = new(schemaValidatorLogger.Object, new MockFileSystem()); + + string jsonData = CONFIG_FILE_WITH_BOOLEAN_AS_ENV.Replace("", Value); + JsonSchemaValidationResult result = jsonSchemaValidator.ValidateJsonConfigWithSchema(jsonSchema, jsonData); + Assert.AreEqual("", String.Join('\n', result.ValidationErrors?.Select(s => $"{s.Message} at {s.Path} {s.LineNumber} {s.LinePosition}") ?? []), "Expected no validation errors."); + + Assert.IsTrue(EnumerableUtilities.IsNullOrEmpty(result.ValidationErrors), "Validation Erros null of empty"); + + Assert.IsTrue(result.IsValid, "Result should be valid"); + schemaValidatorLogger.Verify( + x => x.Log( + LogLevel.Information, + It.IsAny(), + It.Is((o, t) => o.ToString()!.Contains($"The config satisfies the schema requirements.")), + It.IsAny(), + (Func)It.IsAny()), + Times.Once); + } + + /// + /// This test method validates that the JSON schema validates that only known auth providers can be used. + /// + [DataTestMethod] + [DataRow(CONFIG_FILE_WITH_UNKNOWN_AUTHENTICATION_PROVIDER, DisplayName = "Validates schema of the config file when there is an unknown authentication provider.")] + [DataRow(CONFIG_FILE_WITH_MISSING_JWT_PROPERTY, DisplayName = "Validates schema of the config file a missing JWT property")] + [DataRow(CONFIG_FILE_WITH_MISSING_JWT_CHILD_PROPERTIES, DisplayName = "Validates schema of the config file with missing JWT child properties.")] + [DataRow(CONFIG_FILE_WITH_AUTHENTICATION_PROVIDER_THAT_SHOULD_NOT_HAVE_JWT, DisplayName = "Validates schema of the config file when an auth provider is chosen WITH a JWT property, " + + "even though the JWT property should not exist.")] + public void TestConfigWithInvalidAuthProviders(string jsonData) + { + Mock> schemaValidatorLogger = new(); + + string jsonSchema = File.ReadAllText("dab.draft.schema.json"); + + JsonConfigSchemaValidator jsonSchemaValidator = new(schemaValidatorLogger.Object, new MockFileSystem()); + + JsonSchemaValidationResult result = jsonSchemaValidator.ValidateJsonConfigWithSchema(jsonSchema, jsonData); + Assert.IsFalse(result.IsValid); + Assert.IsFalse(EnumerableUtilities.IsNullOrEmpty(result.ValidationErrors)); + schemaValidatorLogger.Verify( + x => x.Log( + LogLevel.Information, + It.IsAny(), + It.Is((o, t) => o.ToString()!.Contains($"The config satisfies the schema requirements.")), + It.IsAny(), + (Func)It.IsAny()), + Times.Never); + } + + /// + /// The config file does not contain any entity fields, which is expected to be invalid according to the schema. + /// The test asserts that the validation fails and there are validation errors. + /// It also verifies that the expected error message is logged, indicating that the 'entities' property is required. + [TestMethod] + public void TestBasicConfigSchemaWithNoEntityFieldsIsInvalid() + { + string jsonData = @"{ + ""$schema"":""https://github.com/Azure/data-api-builder/releases/download/vmajor.minor.patch-alpha/dab.draft.schema.json"", + ""data-source"": { + ""database-type"": ""mssql"", + ""connection-string"": ""sample-conn-string"" + } + }"; + + Mock> schemaValidatorLogger = new(); + + string jsonSchema = File.ReadAllText("dab.draft.schema.json"); + + JsonConfigSchemaValidator jsonSchemaValidator = new(schemaValidatorLogger.Object, new MockFileSystem()); + + JsonSchemaValidationResult result = jsonSchemaValidator.ValidateJsonConfigWithSchema(jsonSchema, jsonData); + Assert.IsFalse(result.IsValid); + Assert.IsFalse(EnumerableUtilities.IsNullOrEmpty(result.ValidationErrors)); + Assert.AreEqual(1, result.ErrorCount); + Assert.IsTrue(result.ErrorMessage.Contains("Total schema validation errors: 1\n> Required properties are missing from object: entities.")); + } + + /// + /// Validates that the JSON schema correctly validates entity cache configuration properties. + /// Tests both valid configurations (proper level values, ttl >= 1) and invalid configurations + /// (invalid level values, ttl = 0). + /// + [DataTestMethod] + [DataRow("L1", 10, true, DisplayName = "Valid cache config with L1 and ttl=10")] + [DataRow("L1L2", 1, true, DisplayName = "Valid cache config with L1L2 and minimum ttl=1")] + [DataRow("L1L2", 3600, true, DisplayName = "Valid cache config with L1L2 and ttl=3600")] + [DataRow("L3", 10, false, DisplayName = "Invalid cache config with invalid level L3")] + [DataRow("L1", 0, false, DisplayName = "Invalid cache config with ttl=0 (below minimum)")] + [DataRow("L1L2", -1, false, DisplayName = "Invalid cache config with negative ttl")] + public void TestEntityCacheSchemaValidation(string level, int ttlSeconds, bool shouldBeValid) + { + string jsonData = $@"{{ + ""$schema"": ""https://github.com/Azure/data-api-builder/releases/download/vmajor.minor.patch/dab.draft.schema.json"", + ""data-source"": {{ + ""database-type"": ""mssql"", + ""connection-string"": ""Server=test;Database=test;"" + }}, + ""entities"": {{ + ""Book"": {{ + ""source"": {{ + ""object"": ""books"", + ""type"": ""table"" + }}, + ""permissions"": [{{ + ""role"": ""anonymous"", + ""actions"": [""read""] + }}], + ""cache"": {{ + ""enabled"": true, + ""ttl-seconds"": {ttlSeconds}, + ""level"": ""{level}"" + }} + }} + }} + }}"; + + Mock> schemaValidatorLogger = new(); + string jsonSchema = File.ReadAllText("dab.draft.schema.json"); + JsonConfigSchemaValidator jsonSchemaValidator = new(schemaValidatorLogger.Object, new MockFileSystem()); + + JsonSchemaValidationResult result = jsonSchemaValidator.ValidateJsonConfigWithSchema(jsonSchema, jsonData); + + if (shouldBeValid) + { + Assert.IsTrue(result.IsValid, $"Expected valid config but got errors: {result.ErrorMessage}"); + Assert.IsTrue(EnumerableUtilities.IsNullOrEmpty(result.ValidationErrors)); + } + else + { + Assert.IsFalse(result.IsValid, "Expected validation to fail but it passed"); + Assert.IsFalse(EnumerableUtilities.IsNullOrEmpty(result.ValidationErrors)); + } + } + + /// + /// This test tries to validate a runtime config file that is not compliant with the runtime config JSON schema. + /// It validates no additional properties are defined in the config file. + /// The config file used here contains `data-source-file` instead of `data-source-files`, + /// and `graphql` property in runtime is written as `GraphQL` in the Global runtime section. + /// It also contains an entity where `rest` property is written as `rst`. + /// + [TestMethod("Validates the invalid config file schema."), TestCategory(TestCategory.MSSQL)] + public void TestConfigSchemaIsInvalid() + { + Mock> schemaValidatorLogger = new(); + + string jsonSchema = File.ReadAllText("dab.draft.schema.json"); + + JsonConfigSchemaValidator jsonSchemaValidator = new(schemaValidatorLogger.Object, new MockFileSystem()); + JsonSchemaValidationResult result = jsonSchemaValidator.ValidateJsonConfigWithSchema(jsonSchema, CONFIG_WITH_INVALID_SCHEMA); + Assert.IsFalse(result.IsValid); + Assert.AreEqual(3, result.ValidationErrors.Count); + + string errorMessage = result.ErrorMessage; + Assert.IsTrue(errorMessage.Contains("Total schema validation errors: 3")); + Assert.IsTrue(errorMessage.Contains("Property 'data-source-file' has not been defined and the schema does not allow additional properties. at 7:31")); + Assert.IsTrue(errorMessage.Contains("Property 'Graphql' has not been defined and the schema does not allow additional properties. at 13:26")); + Assert.IsTrue(errorMessage.Contains("Property 'rst' has not been defined and the schema does not allow additional properties. at 44:26")); + } + + /// + /// DAB config doesn't support additional properties in it's config. This test validates that + /// a config file with additional properties fails the schema validation but still has no effect on engine startup. + /// + [TestMethod("Validates the config with custom properties works with the engine."), TestCategory(TestCategory.MSSQL)] + public async Task TestEngineCanStartConfigWithCustomProperties() + { + const string CUSTOM_CONFIG = "custom-config.json"; + TestHelper.SetupDatabaseEnvironment(MSSQL_ENVIRONMENT); + FileSystem fileSystem = new(); + FileSystemRuntimeConfigLoader loader = new(fileSystem); + loader.TryLoadKnownConfig(out RuntimeConfig config); + + string customProperty = @" + { + ""description"": ""This is a custom property"" + } + "; + + string combinedJson = TestHelper.AddPropertiesToJson(config.ToJson(), customProperty); + + Mock> schemaValidatorLogger = new(); + + string jsonSchema = File.ReadAllText("dab.draft.schema.json"); + + JsonConfigSchemaValidator jsonSchemaValidator = new(schemaValidatorLogger.Object, new MockFileSystem()); + JsonSchemaValidationResult result = jsonSchemaValidator.ValidateJsonConfigWithSchema(jsonSchema, combinedJson); + Assert.IsFalse(result.IsValid); + Assert.IsTrue(result.ErrorMessage.Contains("Total schema validation errors: 1")); + Assert.IsTrue(result.ErrorMessage.Contains("Property 'description' has not been defined and the schema does not allow additional properties.")); + + File.WriteAllText(CUSTOM_CONFIG, combinedJson); + string[] args = new[] + { + $"--ConfigFileName={CUSTOM_CONFIG}" + }; + + // Non-Hosted Scenario + using (TestServer server = new(Program.CreateWebHostBuilder(args))) + using (HttpClient client = server.CreateClient()) + { + 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) + }; + + HttpResponseMessage graphQLResponse = await client.SendAsync(graphQLRequest); + Assert.AreEqual(HttpStatusCode.OK, graphQLResponse.StatusCode); + + HttpRequestMessage restRequest = new(HttpMethod.Get, "/api/Book"); + HttpResponseMessage restResponse = await client.SendAsync(restRequest); + Assert.AreEqual(HttpStatusCode.OK, restResponse.StatusCode); + } + } + + /// + /// This test checks that the GetJsonSchema method of the JsonConfigSchemaValidator class + /// correctly downloads a JSON schema from a given URL, and that the downloaded schema matches the expected schema. + /// + [TestMethod] + public async Task GetJsonSchema_DownloadsSchemaFromUrl() + { + // Arrange + Mock handlerMock = new(MockBehavior.Strict); + string jsonSchemaContent = "{\"type\": \"object\", \"properties\": {\"property1\": {\"type\": \"string\"}}}"; + handlerMock + .Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny() + ) + .ReturnsAsync(new HttpResponseMessage() + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent(jsonSchemaContent, Encoding.UTF8, "application/json"), + }) + .Verifiable(); + + HttpClient mockHttpClient = new(handlerMock.Object); + Mock> schemaValidatorLogger = new(); + JsonConfigSchemaValidator jsonConfigSchemaValidator = new(schemaValidatorLogger.Object, new MockFileSystem(), mockHttpClient); + + string url = "http://example.com/schema.json"; + RuntimeConfig runtimeConfig = new( + Schema: url, + DataSource: new(DatabaseType.MSSQL, "connectionString", null), + new RuntimeEntities(new Dictionary()) + ); + + // Act + string receivedJsonSchema = await jsonConfigSchemaValidator.GetJsonSchema(runtimeConfig); + + // Assert + Assert.AreEqual(jsonSchemaContent, receivedJsonSchema); + handlerMock.Protected().Verify( + "SendAsync", + Times.Exactly(1), + ItExpr.Is(req => + req.Method == HttpMethod.Get + && req.RequestUri == new Uri(url)), + ItExpr.IsAny()); + } + + /// + /// This test checks that even when the schema download fails, the GetJsonSchema method + /// fetches the schema from the package succesfully. + /// + [TestMethod] + public async Task GetJsonSchema_DownloadsSchemaFromUrlFailure() + { + // Arrange + Mock handlerMock = new(MockBehavior.Strict); + handlerMock + .Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny() + ) + .ReturnsAsync(new HttpResponseMessage() + { + StatusCode = HttpStatusCode.InternalServerError, // Simulate a failure + Content = new StringContent("", Encoding.UTF8, "application/json"), + }) + .Verifiable(); + + HttpClient mockHttpClient = new(handlerMock.Object); + Mock> schemaValidatorLogger = new(); + JsonConfigSchemaValidator jsonConfigSchemaValidator = new(schemaValidatorLogger.Object, new MockFileSystem(), mockHttpClient); + + string url = "http://example.com/schema.json"; + RuntimeConfig runtimeConfig = new( + Schema: url, + DataSource: new(DatabaseType.MSSQL, "connectionString", null), + new RuntimeEntities(new Dictionary()) + ); + + // Act + string receivedJsonSchema = await jsonConfigSchemaValidator.GetJsonSchema(runtimeConfig); + + // Assert + Assert.IsFalse(string.IsNullOrEmpty(receivedJsonSchema)); + + // Sanity check to ensure the schema is valid + Assert.IsTrue(receivedJsonSchema.Contains("$schema")); + Assert.IsTrue(receivedJsonSchema.Contains("data-source")); + Assert.IsTrue(receivedJsonSchema.Contains("entities")); + } + + /// + /// Set the connection string to an invalid value and expect the service to be unavailable + /// since without this env var, it would be available - guaranteeing this env variable + /// has highest precedence irrespective of what the connection string is in the config file. + /// Verifying the Exception thrown. + /// + [TestMethod($"Validates that environment variable {RUNTIME_ENV_CONNECTION_STRING} has highest precedence."), TestCategory(TestCategory.COSMOSDBNOSQL)] + public void TestConnectionStringEnvVarHasHighestPrecedence() + { + Environment.SetEnvironmentVariable(ASP_NET_CORE_ENVIRONMENT_VAR_NAME, COSMOS_ENVIRONMENT); + Environment.SetEnvironmentVariable( + RUNTIME_ENV_CONNECTION_STRING, + "Invalid Connection String"); + + try + { + TestServer server = new(Program.CreateWebHostBuilder(Array.Empty())); + _ = server.Services.GetService(typeof(CosmosClientProvider)) as CosmosClientProvider; + Assert.Fail($"{RUNTIME_ENV_CONNECTION_STRING} is not given highest precedence"); + } + catch (Exception e) + { + Assert.AreEqual(typeof(ArgumentException), e.GetType()); + Assert.AreEqual( + $"Format of the initialization string does not conform to specification starting at index 0.", + e.Message); + } + } + + /// + /// Test to verify the precedence logic for config file based on Environment variables. + /// + [DataTestMethod] + [DataRow("HostTest", "Test", false, $"{CONFIGFILE_NAME}.Test{CONFIG_EXTENSION}", DisplayName = "hosting and dab environment set, without considering overrides.")] + [DataRow("HostTest", "", false, $"{CONFIGFILE_NAME}.HostTest{CONFIG_EXTENSION}", DisplayName = "only hosting environment set, without considering overrides.")] + [DataRow("", "Test1", false, $"{CONFIGFILE_NAME}.Test1{CONFIG_EXTENSION}", DisplayName = "only dab environment set, without considering overrides.")] + [DataRow("", "Test2", true, $"{CONFIGFILE_NAME}.Test2.overrides{CONFIG_EXTENSION}", DisplayName = "only dab environment set, considering overrides.")] + [DataRow("HostTest1", "", true, $"{CONFIGFILE_NAME}.HostTest1.overrides{CONFIG_EXTENSION}", DisplayName = "only hosting environment set, considering overrides.")] + public void TestGetConfigFileNameForEnvironment( + string hostingEnvironmentValue, + string environmentValue, + bool considerOverrides, + string expectedRuntimeConfigFile) + { + MockFileSystem fileSystem = new(); + fileSystem.AddFile(expectedRuntimeConfigFile, new MockFileData(string.Empty)); + FileSystemRuntimeConfigLoader runtimeConfigLoader = new(fileSystem); + + Environment.SetEnvironmentVariable(ASP_NET_CORE_ENVIRONMENT_VAR_NAME, hostingEnvironmentValue); + Environment.SetEnvironmentVariable(RUNTIME_ENVIRONMENT_VAR_NAME, environmentValue); + string actualRuntimeConfigFile = runtimeConfigLoader.GetFileNameForEnvironment(hostingEnvironmentValue, considerOverrides); + Assert.AreEqual(expectedRuntimeConfigFile, actualRuntimeConfigFile); + } + + /// + /// Test different graphql endpoints in different host modes + /// when accessed interactively via browser. Note that the + /// branding for "Banana Cake Pop" has changed to "Nitro", and + /// we have updated the graphql endpoint test for dev mode to reflect + /// this change, but it may need to be updated again in the future. + /// + /// The endpoint route + /// The mode in which the service is executing. + /// Expected Status Code. + /// The expected phrase in the response body. + [DataTestMethod] + [TestCategory(TestCategory.MSSQL)] + [DataRow("/graphql/", HostMode.Development, HttpStatusCode.OK, "Nitro", + DisplayName = "GraphQL endpoint with no query in development mode.")] + [DataRow("/graphql", HostMode.Production, HttpStatusCode.NotFound, + DisplayName = "GraphQL endpoint with no query in production mode.")] + [DataRow("/graphql/ui", HostMode.Development, HttpStatusCode.NotFound, + DisplayName = "Default BananaCakePop in development mode.")] + [DataRow("/graphql/ui", HostMode.Production, HttpStatusCode.NotFound, + DisplayName = "Default BananaCakePop in production mode.")] + [DataRow("/graphql?query={book_by_pk(id: 1){title}}", + HostMode.Development, HttpStatusCode.OK, + DisplayName = "GraphQL endpoint with query in development mode.")] + [DataRow("/graphql?query={book_by_pk(id: 1){title}}", + HostMode.Production, HttpStatusCode.OK, "data", + DisplayName = "GraphQL endpoint with query in production mode.")] + [DataRow(RestController.REDIRECTED_ROUTE, HostMode.Development, HttpStatusCode.NotFound, "Not Found", + DisplayName = "Redirected endpoint in development mode.")] + [DataRow(RestController.REDIRECTED_ROUTE, HostMode.Production, HttpStatusCode.NotFound, "Not Found", + DisplayName = "Redirected endpoint in production mode.")] + public async Task TestInteractiveGraphQLEndpoints( + string endpoint, + HostMode HostMode, + HttpStatusCode expectedStatusCode, + string expectedContent = "") + { + const string CUSTOM_CONFIG = "custom-config.json"; + TestHelper.SetupDatabaseEnvironment(MSSQL_ENVIRONMENT); + FileSystem fileSystem = new(); + FileSystemRuntimeConfigLoader loader = new(fileSystem); + loader.TryLoadKnownConfig(out RuntimeConfig config); + + RuntimeConfig configWithCustomHostMode = config with + { + Runtime = config.Runtime with + { + Host = config.Runtime.Host with { Mode = HostMode } + } + }; + File.WriteAllText(CUSTOM_CONFIG, configWithCustomHostMode.ToJson()); + string[] args = new[] + { + $"--ConfigFileName={CUSTOM_CONFIG}" + }; + + using TestServer server = new(Program.CreateWebHostBuilder(args)); + using HttpClient client = server.CreateClient(); + { + HttpRequestMessage request = new(HttpMethod.Get, endpoint); + + // Adding the following headers simulates an interactive browser request. + request.Headers.Add("user-agent", BROWSER_USER_AGENT_HEADER); + request.Headers.Add("accept", BROWSER_ACCEPT_HEADER); + + HttpResponseMessage response = await client.SendAsync(request); + Assert.AreEqual(expectedStatusCode, response.StatusCode); + string actualBody = await response.Content.ReadAsStringAsync(); + Assert.IsTrue(actualBody.Contains(expectedContent)); + } + } + + /// + /// Tests that the custom path rewriting middleware properly rewrites the + /// first segment of a path (/segment1/.../segmentN) when the segment matches + /// the custom configured GraphQLEndpoint. + /// Note: The GraphQL service is always internally mapped to /graphql + /// + /// The custom configured GraphQL path in configuration + /// The path used in the web request executed in the test. + /// Expected Http success/error code + [DataTestMethod] + [TestCategory(TestCategory.MSSQL)] + [DataRow("/graphql", "/gql", HttpStatusCode.BadRequest, DisplayName = "Request to non-configured graphQL endpoint is handled by REST controller.")] + [DataRow("/graphql", "/graphql", HttpStatusCode.OK, DisplayName = "Request to configured default GraphQL endpoint succeeds, path not rewritten.")] + [DataRow("/gql", "/gql/additionalURLsegment", HttpStatusCode.OK, DisplayName = "GraphQL request path (with extra segments) rewritten to match internally set GraphQL endpoint /graphql.")] + [DataRow("/gql", "/gql", HttpStatusCode.OK, DisplayName = "GraphQL request path rewritten to match internally set GraphQL endpoint /graphql.")] + [DataRow("/gql", "/api/book", HttpStatusCode.NotFound, DisplayName = "Non-GraphQL request's path is not rewritten and is handled by REST controller.")] + [DataRow("/gql", "/graphql", HttpStatusCode.NotFound, DisplayName = "Requests to default/internally set graphQL endpoint fail when configured endpoint differs.")] + public async Task TestPathRewriteMiddlewareForGraphQL( + string graphQLConfiguredPath, + string requestPath, + HttpStatusCode expectedStatusCode) + { + GraphQLRuntimeOptions graphqlOptions = new(Path: graphQLConfiguredPath); + + DataSource dataSource = new(DatabaseType.MSSQL, + GetConnectionStringFromEnvironmentConfig(environment: TestCategory.MSSQL), + Options: null); + + RuntimeConfig configuration = InitMinimalRuntimeConfig(dataSource, graphqlOptions, new(), 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 = @"{ + book_by_pk(id: 1) { + id, + title, + publisher_id + } + }"; + + var payload = new { query }; + + HttpRequestMessage request = new(HttpMethod.Post, requestPath) + { + Content = JsonContent.Create(payload) + }; + + HttpResponseMessage response = await client.SendAsync(request); + string body = await response.Content.ReadAsStringAsync(); + + Assert.AreEqual(expectedStatusCode, response.StatusCode); + } + + /// + /// Validates the error message that is returned for REST requests with incorrect parameter type + /// when the engine is running in Production mode. The error messages in Production mode is + /// very generic to not reveal information about the underlying database objects backing the entity. + /// This test runs against a MsSql database. However, generic error messages will be returned in Production + /// mode when run against PostgreSql and MySql databases. + /// + /// Type of REST request + /// Endpoint for the REST request + /// Right error message that should be shown to the end user + [DataTestMethod] + [TestCategory(TestCategory.MSSQL)] + [DataRow(SupportedHttpVerb.Get, "/api/Book/id/one", null, "Invalid value provided for field: id", DisplayName = "Validates the error message for a GET request with incorrect primary key parameter type on a table in production mode")] + [DataRow(SupportedHttpVerb.Get, "/api/books_view_all/id/one", null, "Invalid value provided for field: id", DisplayName = "Validates the error message for a GET request with incorrect primary key parameter type on a view in production mode")] + [DataRow(SupportedHttpVerb.Get, "/api/GetBook?id=one", REQUEST_BODY_WITH_CORRECT_PARAM_TYPES, "Invalid value provided for field: id", DisplayName = "Validates the error message for a GET request on a stored-procedure with incorrect parameter type in production mode")] + [DataRow(SupportedHttpVerb.Get, "/api/GQLmappings/column1/one", null, "Invalid value provided for field: column1", DisplayName = "Validates the error message for a GET request with incorrect primary key parameter type with alias defined for primary key column on a table in production mode")] + [DataRow(SupportedHttpVerb.Post, "/api/Book", REQUEST_BODY_WITH_INCORRECT_PARAM_TYPES, "Invalid value provided for field: publisher_id", DisplayName = "Validates the error message for a POST request with incorrect parameter type in the request body on a table in production mode")] + [DataRow(SupportedHttpVerb.Put, "/api/Book/id/one", REQUEST_BODY_WITH_CORRECT_PARAM_TYPES, "Invalid value provided for field: id", DisplayName = "Validates the error message for a PUT request with incorrect primary key parameter type on a table in production mode")] + [DataRow(SupportedHttpVerb.Put, "/api/Book/id/1", REQUEST_BODY_WITH_INCORRECT_PARAM_TYPES, "Invalid value provided for field: publisher_id", DisplayName = "Validates the error message for a bad PUT request with incorrect parameter type in the request body on a table in production mode")] + [DataRow(SupportedHttpVerb.Patch, "/api/Book/id/one", REQUEST_BODY_WITH_CORRECT_PARAM_TYPES, "Invalid value provided for field: id", DisplayName = "Validates the error message for a PATCH request with incorrect primary key parameter type on a table in production mode")] + [DataRow(SupportedHttpVerb.Patch, "/api/Book/id/1", REQUEST_BODY_WITH_INCORRECT_PARAM_TYPES, "Invalid value provided for field: publisher_id", DisplayName = "Validates the error message for a PATCH request with incorrect parameter type in the request body on a table in production mode")] + [DataRow(SupportedHttpVerb.Delete, "/api/Book/id/one", REQUEST_BODY_WITH_CORRECT_PARAM_TYPES, "Invalid value provided for field: id", DisplayName = "Validates the error message for a DELETE request with incorrect primary key parameter type on a table in production mode")] + public async Task TestGenericErrorMessageForRestApiInProductionMode( + SupportedHttpVerb requestType, + string requestPath, + string requestBody, + string expectedErrorMessage) + { + const string CUSTOM_CONFIG = "custom-config.json"; + TestHelper.ConstructNewConfigWithSpecifiedHostMode(CUSTOM_CONFIG, HostMode.Production, TestCategory.MSSQL); + string[] args = new[] + { + $"--ConfigFileName={CUSTOM_CONFIG}" + }; + + using (TestServer server = new(Program.CreateWebHostBuilder(args))) + using (HttpClient client = server.CreateClient()) + { + HttpMethod httpMethod = SqlTestHelper.ConvertRestMethodToHttpMethod(requestType); + HttpRequestMessage request; + if (requestType is SupportedHttpVerb.Get || requestType is SupportedHttpVerb.Delete) + { + request = new(httpMethod, requestPath); + } + else + { + request = new(httpMethod, requestPath) + { + Content = JsonContent.Create(requestBody) + }; + } + + HttpResponseMessage response = await client.SendAsync(request); + string body = await response.Content.ReadAsStringAsync(); + Assert.AreEqual(HttpStatusCode.BadRequest, response.StatusCode); + Assert.IsTrue(body.Contains(expectedErrorMessage)); + } + } + + /// + /// Validates the REST HTTP methods that are enabled for Stored Procedures when + /// some of the default fields are absent in the config file. + /// When methods section is not defined explicitly in the config file, only POST + /// method should be enabled for Stored Procedures. + /// + [DataTestMethod] + [TestCategory(TestCategory.MSSQL)] + [DataRow(SP_CONFIG_WITH_NO_REST_SETTINGS, SupportedHttpVerb.Post, "/api/GetBooks", HttpStatusCode.Created, DisplayName = "SP - REST POST enabled when no REST section is present")] + [DataRow(SP_CONFIG_WITH_NO_REST_SETTINGS, SupportedHttpVerb.Get, "/api/GetBooks", HttpStatusCode.MethodNotAllowed, DisplayName = "SP - REST GET disabled when no REST section is present")] + [DataRow(SP_CONFIG_WITH_NO_REST_SETTINGS, SupportedHttpVerb.Patch, "/api/GetBooks", HttpStatusCode.MethodNotAllowed, DisplayName = "SP - REST PATCH disabled when no REST section is present")] + [DataRow(SP_CONFIG_WITH_NO_REST_SETTINGS, SupportedHttpVerb.Put, "/api/GetBooks", HttpStatusCode.MethodNotAllowed, DisplayName = "SP - REST PUT disabled when no REST section is present")] + [DataRow(SP_CONFIG_WITH_NO_REST_SETTINGS, SupportedHttpVerb.Delete, "/api/GetBooks", HttpStatusCode.MethodNotAllowed, DisplayName = "SP - REST DELETE disabled when no REST section is present")] + [DataRow(SP_CONFIG_WITH_ONLY_PATH_IN_REST_SETTINGS, SupportedHttpVerb.Post, "/api/get_books/", HttpStatusCode.Created, DisplayName = "SP - REST POST enabled when only a custom path is defined")] + [DataRow(SP_CONFIG_WITH_ONLY_PATH_IN_REST_SETTINGS, SupportedHttpVerb.Get, "/api/get_books/", HttpStatusCode.MethodNotAllowed, DisplayName = "SP - REST GET disabled when only a custom path is defined")] + [DataRow(SP_CONFIG_WITH_ONLY_PATH_IN_REST_SETTINGS, SupportedHttpVerb.Patch, "/api/get_books/", HttpStatusCode.MethodNotAllowed, DisplayName = "SP - REST PATCH disabled when only a custom path is defined")] + [DataRow(SP_CONFIG_WITH_ONLY_PATH_IN_REST_SETTINGS, SupportedHttpVerb.Delete, "/api/get_books/", HttpStatusCode.MethodNotAllowed, DisplayName = "SP - REST DELETE disabled when only a custom path is defined")] + [DataRow(SP_CONFIG_WITH_ONLY_PATH_IN_REST_SETTINGS, SupportedHttpVerb.Put, "/api/get_books/", HttpStatusCode.MethodNotAllowed, DisplayName = "SP - REST PUT disabled when a custom path is defined")] + [DataRow(SP_CONFIG_WITH_JUST_METHODS_IN_REST_SETTINGS, SupportedHttpVerb.Post, "/api/GetBooks/", HttpStatusCode.MethodNotAllowed, DisplayName = "SP - REST POST disabled by not specifying in the methods section")] + [DataRow(SP_CONFIG_WITH_JUST_METHODS_IN_REST_SETTINGS, SupportedHttpVerb.Get, "/api/GetBooks/", HttpStatusCode.OK, DisplayName = "SP - REST GET enabled by specifying in the methods section")] + [DataRow(SP_CONFIG_WITH_JUST_METHODS_IN_REST_SETTINGS, SupportedHttpVerb.Patch, "/api/GetBooks/", HttpStatusCode.MethodNotAllowed, DisplayName = "SP - REST PATCH disabled by not specifying in the methods section")] + [DataRow(SP_CONFIG_WITH_JUST_METHODS_IN_REST_SETTINGS, SupportedHttpVerb.Put, "/api/GetBooks/", HttpStatusCode.MethodNotAllowed, DisplayName = "SP - REST PUT disabled by not specifying in the methods section")] + [DataRow(SP_CONFIG_WITH_JUST_METHODS_IN_REST_SETTINGS, SupportedHttpVerb.Delete, "/api/GetBooks/", HttpStatusCode.MethodNotAllowed, DisplayName = "SP - REST DELETE disabled by not specifying in the methods section")] + [DataRow(SP_CONFIG_WITH_REST_DISABLED, SupportedHttpVerb.Get, "/api/GetBooks", HttpStatusCode.NotFound, DisplayName = "SP - REST GET disabled by configuring enabled as false")] + [DataRow(SP_CONFIG_WITH_REST_DISABLED, SupportedHttpVerb.Post, "/api/GetBooks", HttpStatusCode.NotFound, DisplayName = "SP - REST POST disabled by configuring enabled as false")] + [DataRow(SP_CONFIG_WITH_REST_DISABLED, SupportedHttpVerb.Patch, "/api/GetBooks", HttpStatusCode.NotFound, DisplayName = "SP - REST PATCH disabled by configuring enabled as false")] + [DataRow(SP_CONFIG_WITH_REST_DISABLED, SupportedHttpVerb.Put, "/api/GetBooks", HttpStatusCode.NotFound, DisplayName = "SP - REST PUT disabled by configuring enabled as false")] + [DataRow(SP_CONFIG_WITH_REST_DISABLED, SupportedHttpVerb.Delete, "/api/GetBooks", HttpStatusCode.NotFound, DisplayName = "SP - REST DELETE disabled by configuring enabled as false")] + [DataRow(SP_CONFIG_WITH_JUST_REST_ENABLED, SupportedHttpVerb.Get, "/api/GetBooks", HttpStatusCode.MethodNotAllowed, DisplayName = "SP - REST GET is disabled when enabled flag is configured to true")] + [DataRow(SP_CONFIG_WITH_JUST_REST_ENABLED, SupportedHttpVerb.Post, "/api/GetBooks", HttpStatusCode.Created, DisplayName = "SP - REST POST is enabled when enabled flag is configured to true")] + [DataRow(SP_CONFIG_WITH_JUST_REST_ENABLED, SupportedHttpVerb.Patch, "/api/GetBooks", HttpStatusCode.MethodNotAllowed, DisplayName = "SP - REST PATCH is disabled when enabled flag is configured to true")] + [DataRow(SP_CONFIG_WITH_JUST_REST_ENABLED, SupportedHttpVerb.Put, "/api/GetBooks", HttpStatusCode.MethodNotAllowed, DisplayName = "SP - REST PUT is disabled when enabled flag is configured to true")] + [DataRow(SP_CONFIG_WITH_JUST_REST_ENABLED, SupportedHttpVerb.Delete, "/api/GetBooks", HttpStatusCode.MethodNotAllowed, DisplayName = "SP - REST DELETE is disabled when enabled flag is configured to true")] + public async Task TestSPRestDefaultsForManuallyConstructedConfigs( + string entityJson, + SupportedHttpVerb requestType, + string requestPath, + HttpStatusCode expectedResponseStatusCode) + { + string configJson = TestHelper.AddPropertiesToJson(TestHelper.BASE_CONFIG, entityJson); + RuntimeConfigLoader.TryParseConfig( + configJson, + out RuntimeConfig deserializedConfig, + replacementSettings: new(), + logger: null, + GetConnectionStringFromEnvironmentConfig(environment: TestCategory.MSSQL)); + string configFileName = "custom-config.json"; + File.WriteAllText(configFileName, deserializedConfig.ToJson()); + string[] args = new[] + { + $"--ConfigFileName={configFileName}" + }; + + using (TestServer server = new(Program.CreateWebHostBuilder(args))) + using (HttpClient client = server.CreateClient()) + { + HttpMethod httpMethod = SqlTestHelper.ConvertRestMethodToHttpMethod(requestType); + HttpRequestMessage request = new(httpMethod, requestPath); + HttpResponseMessage response = await client.SendAsync(request); + Assert.AreEqual(expectedResponseStatusCode, response.StatusCode); + } + } + + /// + /// Validates that deserialization of config file is successful for the following scenarios: + /// 1. Multiple Mutations section is null + /// { + /// "multiple-mutations": null + /// } + /// + /// 2. Multiple Mutations section is empty. + /// { + /// "multiple-mutations": {} + /// } + /// + /// 3. Create field within Multiple Mutation section is null. + /// { + /// "multiple-mutations": { + /// "create": null + /// } + /// } + /// + /// 4. Create field within Multiple Mutation section is empty. + /// { + /// "multiple-mutations": { + /// "create": {} + /// } + /// } + /// + /// For all the above mentioned scenarios, the expected value for MultipleMutationOptions field is null. + /// + /// Base Config Json string. + [DataTestMethod] + [DataRow(TestHelper.BASE_CONFIG_NULL_MULTIPLE_MUTATIONS_FIELD, DisplayName = "MultipleMutationOptions field deserialized as null when multiple mutation section is null")] + [DataRow(TestHelper.BASE_CONFIG_EMPTY_MULTIPLE_MUTATIONS_FIELD, DisplayName = "MultipleMutationOptions field deserialized as null when multiple mutation section is empty")] + [DataRow(TestHelper.BASE_CONFIG_NULL_MULTIPLE_CREATE_FIELD, DisplayName = "MultipleMutationOptions field deserialized as null when create field within multiple mutation section is null")] + [DataRow(TestHelper.BASE_CONFIG_EMPTY_MULTIPLE_CREATE_FIELD, DisplayName = "MultipleMutationOptions field deserialized as null when create field within multiple mutation section is empty")] + public void ValidateDeserializationOfConfigWithNullOrEmptyInvalidMultipleMutationSection(string baseConfig) + { + string configJson = TestHelper.AddPropertiesToJson(baseConfig, BOOK_ENTITY_JSON); + Assert.IsTrue(RuntimeConfigLoader.TryParseConfig(configJson, out RuntimeConfig deserializedConfig)); + Assert.IsNotNull(deserializedConfig.Runtime); + Assert.IsNotNull(deserializedConfig.Runtime.GraphQL); + Assert.IsNull(deserializedConfig.Runtime.GraphQL.MultipleMutationOptions); + } + + /// + /// Sanity check to validate that DAB engine starts successfully when used with a config file without the multiple + /// mutations feature flag section. + /// The runtime graphql section of the config file used looks like this: + /// + /// "graphql": { + /// "path": "/graphql", + /// "allow-introspection": true + /// } + /// + /// Without the multiple mutations feature flag section, DAB engine should be able to + /// 1. Successfully deserialize the config file without multiple mutation section. + /// 2. Process REST and GraphQL API requests. + /// + /// + [TestMethod] + [TestCategory(TestCategory.MSSQL)] + public async Task SanityTestForRestAndGQLRequestsWithoutMultipleMutationFeatureFlagSection() + { + // The configuration file is constructed by merging hard-coded JSON strings to simulate the scenario where users manually edit the + // configuration file (instead of using CLI). + string configJson = TestHelper.AddPropertiesToJson(TestHelper.BASE_CONFIG, BOOK_ENTITY_JSON); + Assert.IsTrue(RuntimeConfigLoader.TryParseConfig( + configJson, + out RuntimeConfig deserializedConfig, + replacementSettings: new(), + logger: null, + GetConnectionStringFromEnvironmentConfig(environment: TestCategory.MSSQL))); + string configFileName = "custom-config.json"; + File.WriteAllText(configFileName, deserializedConfig.ToJson()); + string[] args = new[] + { + $"--ConfigFileName={configFileName}" + }; + + using (TestServer server = new(Program.CreateWebHostBuilder(args))) + using (HttpClient client = server.CreateClient()) + { + try + { + + // Perform a REST GET API request to validate that REST GET API requests are executed correctly. + HttpRequestMessage restRequest = new(HttpMethod.Get, "api/Book"); + HttpResponseMessage restResponse = await client.SendAsync(restRequest); + Assert.AreEqual(HttpStatusCode.OK, restResponse.StatusCode); + + // Perform a GraphQL API request to validate that DAB engine executes GraphQL requests successfully. + 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) + }; + + HttpResponseMessage graphQLResponse = await client.SendAsync(graphQLRequest); + Assert.AreEqual(HttpStatusCode.OK, graphQLResponse.StatusCode); + Assert.IsNotNull(graphQLResponse.Content); + string body = await graphQLResponse.Content.ReadAsStringAsync(); + Assert.IsFalse(body.Contains("errors")); + } + catch (Exception ex) + { + Assert.Fail($"Unexpected exception : {ex}"); + } + } + } + + /// + /// Test to validate that when an entity which will return a paginated response is queried, and a custom runtime base route is configured in the runtime configuration, + /// then the generated nextLink in the response would contain the rest base-route just before the rest path. For the subsequent query, the rest base-route will be trimmed + /// by the upstream before the request lands at DAB. + /// + [TestMethod] + [TestCategory(TestCategory.MSSQL)] + public async Task TestRuntimeBaseRouteInNextLinkForPaginatedRestResponse() + { + const string CUSTOM_CONFIG = "custom-config.json"; + string runtimeBaseRoute = "/base-route"; + TestHelper.ConstructNewConfigWithSpecifiedHostMode(CUSTOM_CONFIG, HostMode.Production, TestCategory.MSSQL, runtimeBaseRoute: runtimeBaseRoute, "StaticWebApps"); + string[] args = new[] + { + $"--ConfigFileName={CUSTOM_CONFIG}" + }; + + using (TestServer server = new(Program.CreateWebHostBuilder(args))) + using (HttpClient client = server.CreateClient()) + { + string requestPath = "/api/MappedBookmarks"; + HttpMethod httpMethod = SqlTestHelper.ConvertRestMethodToHttpMethod(SupportedHttpVerb.Get); + HttpRequestMessage request = new(httpMethod, requestPath); + + HttpResponseMessage response = await client.SendAsync(request); + string responseBody = await response.Content.ReadAsStringAsync(); + Assert.IsTrue(response.StatusCode is HttpStatusCode.OK); + + JsonElement responseElement = JsonSerializer.Deserialize(responseBody); + JsonElement responseValue = responseElement.GetProperty(SqlTestHelper.jsonResultTopLevelKey); + string nextLink = responseElement.GetProperty("nextLink").ToString(); + + // Assert that we got an array response with length equal to the maximum allowed records in a paginated response. + Assert.AreEqual(JsonValueKind.Array, responseValue.ValueKind); + Assert.AreEqual(100, responseValue.GetArrayLength()); + + // Assert that the nextLink contains the rest base-route just before the request path. + StringAssert.Contains(nextLink, runtimeBaseRoute + requestPath); + } + } + + /// + /// Tests that the when Rest or GraphQL is disabled Globally, + /// any requests made will get a 404 response. + /// + /// The custom configured REST enabled property in configuration. + /// The custom configured GraphQL enabled property in configuration. + /// Expected HTTP status code code for the Rest request + /// Expected HTTP status code code for the GraphQL request + [DataTestMethod] + [TestCategory(TestCategory.MSSQL)] + [DataRow(true, true, true, HttpStatusCode.OK, HttpStatusCode.OK, HttpStatusCode.OK, CONFIGURATION_ENDPOINT, DisplayName = "V1 - Rest, GraphQL, and MCP enabled globally")] + [DataRow(true, true, false, HttpStatusCode.OK, HttpStatusCode.OK, HttpStatusCode.NotFound, CONFIGURATION_ENDPOINT, DisplayName = "V1 - Rest and GraphQL enabled, MCP disabled globally")] + [DataRow(true, false, true, HttpStatusCode.OK, HttpStatusCode.NotFound, HttpStatusCode.OK, CONFIGURATION_ENDPOINT, DisplayName = "V1 - Rest enabled, GraphQL disabled, and MCP enabled globally")] + [DataRow(true, false, false, HttpStatusCode.OK, HttpStatusCode.NotFound, HttpStatusCode.NotFound, CONFIGURATION_ENDPOINT, DisplayName = "V1 - Rest enabled, GraphQL and MCP disabled globally")] + [DataRow(false, true, true, HttpStatusCode.NotFound, HttpStatusCode.OK, HttpStatusCode.OK, CONFIGURATION_ENDPOINT, DisplayName = "V1 - Rest disabled, GraphQL and MCP enabled globally")] + [DataRow(false, true, false, HttpStatusCode.NotFound, HttpStatusCode.OK, HttpStatusCode.NotFound, CONFIGURATION_ENDPOINT, DisplayName = "V1 - Rest disabled, GraphQL enabled, and MCP disabled globally")] + [DataRow(false, false, true, HttpStatusCode.NotFound, HttpStatusCode.NotFound, HttpStatusCode.OK, CONFIGURATION_ENDPOINT, DisplayName = "V1 - Rest and GraphQL disabled, MCP enabled globally")] + [DataRow(true, true, true, HttpStatusCode.OK, HttpStatusCode.OK, HttpStatusCode.OK, CONFIGURATION_ENDPOINT_V2, DisplayName = "V2 - Rest, GraphQL, and MCP enabled globally")] + [DataRow(true, true, false, HttpStatusCode.OK, HttpStatusCode.OK, HttpStatusCode.NotFound, CONFIGURATION_ENDPOINT_V2, DisplayName = "V2 - Rest and GraphQL enabled, MCP disabled globally")] + [DataRow(true, false, true, HttpStatusCode.OK, HttpStatusCode.NotFound, HttpStatusCode.OK, CONFIGURATION_ENDPOINT_V2, DisplayName = "V2 - Rest enabled, GraphQL disabled, and MCP enabled globally")] + [DataRow(true, false, false, HttpStatusCode.OK, HttpStatusCode.NotFound, HttpStatusCode.NotFound, CONFIGURATION_ENDPOINT_V2, DisplayName = "V2 - Rest enabled, GraphQL and MCP disabled globally")] + [DataRow(false, true, true, HttpStatusCode.NotFound, HttpStatusCode.OK, HttpStatusCode.OK, CONFIGURATION_ENDPOINT_V2, DisplayName = "V2 - Rest disabled, GraphQL and MCP enabled globally")] + [DataRow(false, true, false, HttpStatusCode.NotFound, HttpStatusCode.OK, HttpStatusCode.NotFound, CONFIGURATION_ENDPOINT_V2, DisplayName = "V2 - Rest disabled, GraphQL enabled, and MCP disabled globally")] + [DataRow(false, false, true, HttpStatusCode.NotFound, HttpStatusCode.NotFound, HttpStatusCode.OK, CONFIGURATION_ENDPOINT_V2, DisplayName = "V2 - Rest and GraphQL disabled, MCP enabled globally")] + public async Task TestGlobalFlagToEnableRestGraphQLAndMcpForHostedAndNonHostedEnvironment( + bool isRestEnabled, + bool isGraphQLEnabled, + bool isMcpEnabled, + HttpStatusCode expectedStatusCodeForREST, + HttpStatusCode expectedStatusCodeForGraphQL, + HttpStatusCode expectedStatusCodeForMcp, + string configurationEndpoint) + { + GraphQLRuntimeOptions graphqlOptions = new(Enabled: isGraphQLEnabled); + RestRuntimeOptions restRuntimeOptions = new(Enabled: isRestEnabled); + McpRuntimeOptions mcpRuntimeOptions = new(Enabled: isMcpEnabled); + + DataSource dataSource = new(DatabaseType.MSSQL, + GetConnectionStringFromEnvironmentConfig(environment: TestCategory.MSSQL), Options: null); + + RuntimeConfig configuration = InitMinimalRuntimeConfig(dataSource, graphqlOptions, restRuntimeOptions, mcpRuntimeOptions); + const string CUSTOM_CONFIG = "custom-config.json"; + File.WriteAllText(CUSTOM_CONFIG, configuration.ToJson()); + + string[] args = new[] + { + $"--ConfigFileName={CUSTOM_CONFIG}" + }; + + // Non-Hosted Scenario + using (TestServer server = new(Program.CreateWebHostBuilder(args))) + using (HttpClient client = server.CreateClient()) + { + string query = @"{ + book_by_pk(id: 1) { + id, + title, + publisher_id + } + }"; + + object payload = new { query }; + + // GraphQL request + HttpRequestMessage graphQLRequest = new(HttpMethod.Post, configuration.Runtime.GraphQL.Path) + { + Content = JsonContent.Create(payload) + }; + + HttpResponseMessage graphQLResponse = await client.SendAsync(graphQLRequest); + Assert.AreEqual(expectedStatusCodeForGraphQL, graphQLResponse.StatusCode, "The GraphQL response is different from the expected result."); + + // REST request + HttpRequestMessage restRequest = new(HttpMethod.Get, $"{configuration.Runtime.Rest.Path}/Book"); + HttpResponseMessage restResponse = await client.SendAsync(restRequest); + Assert.AreEqual(expectedStatusCodeForREST, restResponse.StatusCode, "The REST response is different from the expected result."); + + // MCP request + HttpStatusCode mcpResponseCode = await GetMcpResponse(client, configuration.Runtime.Mcp); + Assert.AreEqual(expectedStatusCodeForMcp, mcpResponseCode, "The MCP response is different from the expected result."); + } + + // Hosted Scenario + // Instantiate new server with no runtime config for post-startup configuration hydration tests. + using (TestServer server = new(Program.CreateWebHostFromInMemoryUpdatableConfBuilder(Array.Empty()))) + using (HttpClient client = server.CreateClient()) + { + JsonContent content = GetPostStartupConfigParams(MSSQL_ENVIRONMENT, configuration, configurationEndpoint); + + HttpResponseMessage postResult = await client.PostAsync(configurationEndpoint, content); + Assert.AreEqual(HttpStatusCode.OK, postResult.StatusCode, "The hydration post-response is different from the expected result."); + + HttpStatusCode restResponseCode = await GetRestResponsePostConfigHydration(client, configuration.Runtime.Rest); + Assert.AreEqual(expected: expectedStatusCodeForREST, actual: restResponseCode, "The REST hydration post-response is different from the expected result."); + + HttpStatusCode graphqlResponseCode = await GetGraphQLResponsePostConfigHydration(client, configuration.Runtime.GraphQL); + Assert.AreEqual(expected: expectedStatusCodeForGraphQL, actual: graphqlResponseCode, "The GraphQL hydration post-response is different from the expected result."); + + // TODO: Issue #3012 - Currently DAB is unable to start MCP with the hydration post-response. + // This needs to be fixed before uncommenting the MCP check + // HttpStatusCode mcpResponseCode = await GetMcpResponse(client, configuration.Runtime.Mcp); + // Assert.AreEqual(expected: expectedStatusCodeForMcp, actual: mcpResponseCode, "The MCP hydration post-response is different from the expected result."); + } + } + + /// + /// For mutation operations, both the respective operation(create/update/delete) + read permissions are needed to receive a valid response. + /// In this test, Anonymous role is configured with only create permission. + /// So, a create mutation executed in the context of Anonymous role is expected to result in + /// 1) Creation of a new item in the database + /// 2) An error response containing the error message : "The mutation operation {operation_name} was successful but the current user is unauthorized to view the response due to lack of read permissions" + /// + /// A create mutation operation in the context of Anonymous role is executed and the expected error message is validated. + /// Authenticated role has read permission configured. A pk query is executed in the context of Authenticated role to validate that a new + /// record was created in the database. + /// + [TestMethod] + [TestCategory(TestCategory.MSSQL)] + public async Task ValidateErrorMessageForMutationWithoutReadPermission() + { + GraphQLRuntimeOptions graphqlOptions = new(Enabled: true); + RestRuntimeOptions restRuntimeOptions = new(Enabled: false); + McpRuntimeOptions mcpRuntimeOptions = new(Enabled: false); + + DataSource dataSource = new(DatabaseType.MSSQL, + GetConnectionStringFromEnvironmentConfig(environment: TestCategory.MSSQL), Options: null); + + EntityAction createAction = new( + Action: EntityActionOperation.Create, + Fields: null, + Policy: new()); + + EntityAction readAction = new( + Action: EntityActionOperation.Read, + Fields: null, + Policy: new()); + + EntityAction deleteAction = new( + Action: EntityActionOperation.Delete, + Fields: null, + Policy: new()); + + EntityPermission[] permissions = new[] {new EntityPermission( Role: AuthorizationResolver.ROLE_ANONYMOUS , Actions: new[] { createAction }), + new EntityPermission( Role: AuthorizationResolver.ROLE_AUTHENTICATED , Actions: new[] { readAction, createAction, deleteAction })}; + + Entity entity = new(Source: new("stocks", EntitySourceType.Table, null, null), + Fields: null, + Rest: null, + GraphQL: new(Singular: "Stock", Plural: "Stocks"), + Permissions: permissions, + Relationships: null, + Mappings: null); + + string entityName = "Stock"; + RuntimeConfig configuration = InitMinimalRuntimeConfig(dataSource, graphqlOptions, restRuntimeOptions, mcpRuntimeOptions, entity, entityName); + + const string CUSTOM_CONFIG = "custom-config.json"; + File.WriteAllText(CUSTOM_CONFIG, configuration.ToJson()); + string[] args = new[] + { + $"--ConfigFileName={CUSTOM_CONFIG}" + }; + + string authToken = AuthTestHelper.CreateAppServiceEasyAuthToken(); + using (TestServer server = new(Program.CreateWebHostBuilder(args))) + using (HttpClient client = server.CreateClient()) + { + try + { + // Pre-clean to avoid PK violation if a previous run left the row behind. + string preCleanupDeleteMutation = @" + mutation { + deleteStock(categoryid: 5001, pieceid: 5001) { + categoryid + pieceid + } + }"; + + _ = await GraphQLRequestExecutor.PostGraphQLRequestAsync( + client, + server.Services.GetRequiredService(), + query: preCleanupDeleteMutation, + queryName: "deleteStock", + variables: null, + authToken: authToken, + clientRoleHeader: AuthorizationResolver.ROLE_AUTHENTICATED); + + // A create mutation operation is executed in the context of Anonymous role. The Anonymous role has create action configured but lacks + // read action. As a result, a new record should be created in the database but the mutation operation should return an error message. + string graphQLMutation = @" + mutation { + createStock( + item: { + categoryid: 5001 + pieceid: 5001 + categoryName: ""SciFi"" + piecesAvailable: 100 + piecesRequired: 50 + } + ) { + categoryid + pieceid + } + }"; + + JsonElement mutationResponse = await GraphQLRequestExecutor.PostGraphQLRequestAsync( + client, + server.Services.GetRequiredService(), + query: graphQLMutation, + queryName: "createStock", + variables: null, + authToken: null, + clientRoleHeader: AuthorizationResolver.ROLE_ANONYMOUS + ); + + Assert.IsNotNull(mutationResponse); + Assert.IsTrue(mutationResponse.ToString().Contains("The mutation operation createStock was successful but the current user is unauthorized to view the response due to lack of read permissions")); + + // pk_query is executed in the context of Authenticated role to validate that the create mutation executed in the context of Anonymous role + // resulted in the creation of a new record in the database. + string graphQLQuery = @" + { + stock_by_pk(categoryid: 5001, pieceid: 5001) { + categoryid + pieceid + categoryName + } + }"; + string queryName = "stock_by_pk"; + + await ValidateMutationSucceededAtDbLayer(server, client, graphQLQuery, queryName, authToken, AuthorizationResolver.ROLE_AUTHENTICATED); + } + finally + { + // Clean-up steps. The record created by the create mutation operation is deleted to reset the database + // back to its original state. + string deleteMutation = @" + mutation { + deleteStock(categoryid: 5001, pieceid: 5001) { + categoryid + pieceid + } + }"; + + _ = await GraphQLRequestExecutor.PostGraphQLRequestAsync( + client, + server.Services.GetRequiredService(), + query: deleteMutation, + queryName: "deleteStock", + variables: null, + authToken: authToken, + clientRoleHeader: AuthorizationResolver.ROLE_AUTHENTICATED); + } + } + } + + /// + /// Multiple mutation operations are disabled through the configuration properties. + /// + /// Test to validate that when multiple-create is disabled: + /// 1. Including a relationship field in the input for create mutation for an entity returns an exception as when multiple mutations are disabled, + /// we don't add fields for relationships in the input type schema and hence users should not be able to do insertion in the related entities. + /// + /// 2. Excluding all the relationship fields i.e. performing insertion in just the top-level entity executes successfully. + /// + /// 3. Relationship fields are marked as optional fields in the schema when multiple create operation is enabled. However, when multiple create operations + /// are disabled, the relationship fields should continue to be marked as required fields. + /// With multiple create operation disabled, executing a create mutation operation without a relationship field ("publisher_id" in createbook mutation operation) should be caught by + /// HotChocolate since it is a required field. + /// + [TestMethod] + [TestCategory(TestCategory.MSSQL)] + public async Task ValidateMultipleCreateAndCreateMutationWhenMultipleCreateOperationIsDisabled() + { + // Generate a custom config file with multiple create operation disabled. + RuntimeConfig runtimeConfig = InitialzieRuntimeConfigForMultipleCreateTests(isMultipleCreateOperationEnabled: false); + + const string CUSTOM_CONFIG = "custom-config.json"; + + File.WriteAllText(CUSTOM_CONFIG, runtimeConfig.ToJson()); + string[] args = new[] + { + $"--ConfigFileName={CUSTOM_CONFIG}" + }; + + using (TestServer server = new(Program.CreateWebHostBuilder(args))) + using (HttpClient client = server.CreateClient()) + { + // When multiple create operation is disabled, fields belonging to related entities are not generated for the input type objects of create operation. + // Executing a create mutation with fields belonging to related entities should be caught by Hotchocolate as unrecognized fields. + string pointMultipleCreateOperation = @"mutation createbook{ + createbook(item: { title: ""Book #1"", publishers: { name: ""The First Publisher"" } }) { + id + title + } + }"; + + JsonElement mutationResponse = await GraphQLRequestExecutor.PostGraphQLRequestAsync(client, + server.Services.GetRequiredService(), + query: pointMultipleCreateOperation, + queryName: "createbook", + variables: null, + clientRoleHeader: null); + + Assert.IsNotNull(mutationResponse); + + SqlTestHelper.TestForErrorInGraphQLResponse(mutationResponse.ToString(), + message: "The specified input object field `publishers` does not exist.", + path: @"[""createbook""]"); + + // When multiple create operation is enabled, two types of create mutation operations are generated 1) Point create mutation operation 2) Many type create mutation operation. + // When multiple create operation is disabled, only point create mutation operation is generated. + // With multiple create operation disabled, executing a many type multiple create operation should be caught by HotChocolate as the many type mutation operation should not exist in the schema. + string manyTypeMultipleCreateOperation = @"mutation { + createbooks( + items: [ + { title: ""Book #1"", publishers: { name: ""Publisher #1"" } } + { title: ""Book #2"", publisher_id: 1234 } + ] + ) { + items { + id + title + } + } + }"; + + mutationResponse = await GraphQLRequestExecutor.PostGraphQLRequestAsync(client, + server.Services.GetRequiredService(), + query: manyTypeMultipleCreateOperation, + queryName: "createbook", + variables: null, + clientRoleHeader: null); + + Assert.IsNotNull(mutationResponse); + SqlTestHelper.TestForErrorInGraphQLResponse(mutationResponse.ToString(), + message: "The field `createbooks` does not exist on the type `Mutation`."); + + // Sanity test to validate that executing a point create mutation with multiple create operation disabled, + // a) Creates the new item successfully. + // b) Returns the expected response. + string pointCreateOperation = @"mutation createbook{ + createbook(item: { title: ""Book #1"", publisher_id: 1234 }) { + title + publisher_id + } + }"; + + mutationResponse = await GraphQLRequestExecutor.PostGraphQLRequestAsync(client, + server.Services.GetRequiredService(), + query: pointCreateOperation, + queryName: "createbook", + variables: null, + clientRoleHeader: null); + + string expectedResponse = @"{ ""title"":""Book #1"",""publisher_id"":1234}"; + + Assert.IsNotNull(mutationResponse); + SqlTestHelper.PerformTestEqualJsonStrings(expectedResponse, mutationResponse.ToString()); + + // When a create multiple operation is enabled, the "publisher_id" field will be generated as an optional field in the schema. But, when multiple create operation is disabled, + // "publisher_id" should be a required field. + // With multiple create operation disabled, executing a createbook mutation operation without the "publisher_id" field is expected to be caught by HotChocolate + // as the schema should be generated with "publisher_id" as a required field. + string pointCreateOperationWithMissingFields = @"mutation createbook{ + createbook(item: { title: ""Book #1""}) { + title + publisher_id + } + }"; + + mutationResponse = await GraphQLRequestExecutor.PostGraphQLRequestAsync(client, + server.Services.GetRequiredService(), + query: pointCreateOperationWithMissingFields, + queryName: "createbook", + variables: null, + clientRoleHeader: null); + + Assert.IsNotNull(mutationResponse); + SqlTestHelper.TestForErrorInGraphQLResponse(response: mutationResponse.ToString(), + message: "`publisher_id` is a required field and cannot be null."); + } + } + + /// + /// When multiple create operation is enabled, the relationship fields are generated as optional fields in the schema. + /// However, when not providing the relationship field as well the related object in the create mutation request should result in an error from the database layer. + /// + [TestMethod] + [TestCategory(TestCategory.MSSQL)] + public async Task ValidateCreateMutationWithMissingFieldsFailWithMultipleCreateEnabled() + { + // Multiple create operations are enabled. + RuntimeConfig runtimeConfig = InitialzieRuntimeConfigForMultipleCreateTests(isMultipleCreateOperationEnabled: true); + + const string CUSTOM_CONFIG = "custom-config.json"; + + File.WriteAllText(CUSTOM_CONFIG, runtimeConfig.ToJson()); + string[] args = new[] + { + $"--ConfigFileName={CUSTOM_CONFIG}" + }; + + using (TestServer server = new(Program.CreateWebHostBuilder(args))) + using (HttpClient client = server.CreateClient()) + { + + // When a create multiple operation is enabled, the "publisher_id" field will generated as an optional field in the schema. But, when multiple create operation is disabled, + // "publisher_id" should be a required field. + // With multiple create operation disabled, executing a createbook mutation operation without the "publisher_id" field is expected to be caught by HotChocolate + // as the schema should be generated with "publisher_id" as a required field. + string pointCreateOperationWithMissingFields = @"mutation createbook{ + createbook(item: { title: ""Book #1""}) { + title + publisher_id + } + }"; + + JsonElement mutationResponse = await GraphQLRequestExecutor.PostGraphQLRequestAsync(client, + server.Services.GetRequiredService(), + query: pointCreateOperationWithMissingFields, + queryName: "createbook", + variables: null, + clientRoleHeader: null); + + Assert.IsNotNull(mutationResponse); + SqlTestHelper.TestForErrorInGraphQLResponse(response: mutationResponse.ToString(), + message: "Missing value for required column: publisher_id for entity: Book at level: 1."); + } + } + + /// + /// For mutation operations, the respective mutation operation type(create/update/delete) + read permissions are needed to receive a valid response. + /// For graphQL requests, if read permission is configured for Anonymous role, then it is inherited by other roles. + /// In this test, Anonymous role has read permission configured. Authenticated role has only create permission configured. + /// A create mutation operation is executed in the context of Authenticated role and the response is expected to have no errors because + /// the read permission is inherited from Anonymous role. + /// + [TestMethod] + [TestCategory(TestCategory.MSSQL)] + public async Task ValidateInheritanceOfReadPermissionFromAnonymous() + { + GraphQLRuntimeOptions graphqlOptions = new(Enabled: true); + RestRuntimeOptions restRuntimeOptions = new(Enabled: false); + McpRuntimeOptions mcpRuntimeOptions = new(Enabled: false); + + DataSource dataSource = new(DatabaseType.MSSQL, + GetConnectionStringFromEnvironmentConfig(environment: TestCategory.MSSQL), Options: null); + + EntityAction createAction = new( + Action: EntityActionOperation.Create, + Fields: null, + Policy: new()); + + EntityAction readAction = new( + Action: EntityActionOperation.Read, + Fields: null, + Policy: new()); + + EntityAction deleteAction = new( + Action: EntityActionOperation.Delete, + Fields: null, + Policy: new()); + + EntityPermission[] permissions = new[] {new EntityPermission( Role: AuthorizationResolver.ROLE_ANONYMOUS , Actions: new[] { createAction, readAction, deleteAction }), + new EntityPermission( Role: AuthorizationResolver.ROLE_AUTHENTICATED , Actions: new[] { createAction })}; + + Entity entity = new(Source: new("stocks", EntitySourceType.Table, null, null), + Fields: null, + Rest: null, + GraphQL: new(Singular: "Stock", Plural: "Stocks"), + Permissions: permissions, + Relationships: null, + Mappings: null); + + string entityName = "Stock"; + RuntimeConfig configuration = InitMinimalRuntimeConfig(dataSource, graphqlOptions, restRuntimeOptions, mcpRuntimeOptions, entity, entityName); + + 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()) + { + try + { + // A create mutation operation is executed in the context of Authenticated role and the response is expected to be a valid + // response without any errors. + string graphQLMutation = @" + mutation { + createStock( + item: { + categoryid: 5001 + pieceid: 5001 + categoryName: ""SciFi"" + piecesAvailable: 100 + piecesRequired: 50 + } + ) { + categoryid + pieceid + } + }"; + + JsonElement mutationResponse = await GraphQLRequestExecutor.PostGraphQLRequestAsync( + client, + server.Services.GetRequiredService(), + query: graphQLMutation, + queryName: "createStock", + variables: null, + authToken: AuthTestHelper.CreateAppServiceEasyAuthToken(), + clientRoleHeader: AuthorizationResolver.ROLE_AUTHENTICATED + ); + + Assert.IsNotNull(mutationResponse); + Assert.IsFalse(mutationResponse.TryGetProperty("errors", out _)); + } + finally + { + // Clean-up steps. The record created by the create mutation operation is deleted to reset the database + // back to its original state. + string deleteMutation = @" + mutation { + deleteStock(categoryid: 5001, pieceid: 5001) { + categoryid + pieceid + } + }"; + + _ = await GraphQLRequestExecutor.PostGraphQLRequestAsync( + client, + server.Services.GetRequiredService(), + query: deleteMutation, + queryName: "deleteStock", + variables: null, + clientRoleHeader: null); + } + } + } + + /// + /// Helper method to validate that the mutation operation succeded at the database layer by executing a graphQL pk query. + /// + /// Test server created for the test + /// HTTP client + /// GraphQL query/mutation text + /// GraphQL query/mutation name + /// Auth token for the graphQL request + private static async Task ValidateMutationSucceededAtDbLayer(TestServer server, HttpClient client, string query, string queryName, string authToken, string clientRoleHeader) + { + JsonElement queryResponse = await GraphQLRequestExecutor.PostGraphQLRequestAsync( + client, + server.Services.GetRequiredService(), + query: query, + queryName: queryName, + variables: null, + authToken: authToken, + clientRoleHeader: clientRoleHeader); + + Assert.IsNotNull(queryResponse); + Assert.AreNotEqual(JsonValueKind.Null, queryResponse.ValueKind, "Expected a JSON object response but received null."); + Assert.IsFalse(queryResponse.TryGetProperty("errors", out _)); + } + + /// + /// Validates the Location header field returned for a POST request when a 201 response is returned. The idea behind returning + /// a Location header is to provide a URL against which a GET request can be performed to fetch the details of the new item. + /// Base Route is not configured in the config file used for this test. If base-route is configured, the Location header URL should contain the base-route. + /// This test performs a POST request, and in the event that it results in a 201 response, it performs a subsequent GET request + /// with the Location header to validate the correctness of the URL. + /// Currently ignored as it is part of the setof flakey tests that are being investigated, see: https://github.com/Azure/data-api-builder/issues/2010 + /// + /// Type of the entity + /// Request path for performing POST API requests on the entity + [Ignore] + [DataTestMethod] + [TestCategory(TestCategory.MSSQL)] + [DataRow(EntitySourceType.Table, "/api/Book", DisplayName = "Location Header validation - Table, Base Route not configured")] + [DataRow(EntitySourceType.StoredProcedure, "/api/GetBooks", DisplayName = "Location Header validation - Stored Procedures, Base Route not configured")] + public async Task ValidateLocationHeaderFieldForPostRequests(EntitySourceType entityType, string requestPath) + { + + GraphQLRuntimeOptions graphqlOptions = new(Enabled: false); + RestRuntimeOptions restRuntimeOptions = new(Enabled: true); + McpRuntimeOptions mcpRuntimeOptions = new(Enabled: false); + + DataSource dataSource = new(DatabaseType.MSSQL, + GetConnectionStringFromEnvironmentConfig(environment: TestCategory.MSSQL), Options: null); + + RuntimeConfig configuration; + + if (entityType is EntitySourceType.StoredProcedure) + { + Entity entity = new(Source: new("get_books", EntitySourceType.StoredProcedure, null, null), + Fields: null, + Rest: new(new SupportedHttpVerb[] { SupportedHttpVerb.Get, SupportedHttpVerb.Post }), + GraphQL: null, + Permissions: new[] { GetMinimalPermissionConfig(AuthorizationResolver.ROLE_ANONYMOUS) }, + Relationships: null, + Mappings: null + ); + + string entityName = "GetBooks"; + configuration = InitMinimalRuntimeConfig(dataSource, graphqlOptions, restRuntimeOptions, mcpRuntimeOptions, entity, entityName); + } + else + { + configuration = InitMinimalRuntimeConfig(dataSource, graphqlOptions, restRuntimeOptions, mcpRuntimeOptions); + } + + 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()) + { + HttpMethod httpMethod = SqlTestHelper.ConvertRestMethodToHttpMethod(SupportedHttpVerb.Post); + HttpRequestMessage request = new(httpMethod, requestPath); + if (entityType is not EntitySourceType.StoredProcedure) + { + string requestBody = @"{ + ""title"": ""Harry Potter and the Order of Phoenix"", + ""publisher_id"": 1234 + }"; + + JsonElement requestBodyElement = JsonDocument.Parse(requestBody).RootElement.Clone(); + request = new(httpMethod, requestPath) + { + Content = JsonContent.Create(requestBodyElement) + }; + } + + HttpResponseMessage response = await client.SendAsync(request); + + // Location header field is expected only when POST request results in the creation of a new item + Assert.AreEqual(HttpStatusCode.Created, response.StatusCode); + + string locationHeader = response.Headers.Location.AbsoluteUri; + + // GET request performed using the Location header should be successful. + HttpRequestMessage followUpRequest = new(HttpMethod.Get, response.Headers.Location); + HttpResponseMessage followUpResponse = await client.SendAsync(followUpRequest); + Assert.AreEqual(HttpStatusCode.OK, followUpResponse.StatusCode); + + // Delete the new record created as part of this test + if (entityType is EntitySourceType.Table) + { + HttpRequestMessage cleanupRequest = new(HttpMethod.Delete, locationHeader); + await client.SendAsync(cleanupRequest); + } + } + } + + /// + /// Validates the Location header field returned for a POST request when it results in a 201 response. The idea behind returning + /// a Location header is to provide a URL against which a GET request can be performed to fetch the details of the new item. + /// Base Route is configured in the config file used for this test. So, it is expected that the Location header returned will contain the base-route. + /// This test performs a POST request, and checks if it results in a 201 response. If so, the test validates the correctness of the Location header in two steps. + /// Since base-route has significance only in the SWA-DAB integrated scenario and this test is executed against DAB running independently, + /// a subsequent GET request against the Location header will result in an error. So, the correctness of the base-route returned is validated with the help of + /// an expected location header value. The correctness of the PK part of the Location string is validated by performing a GET request after stripping off + /// the base-route from the Location URL. + /// + /// Type of the entity + /// Request path for performing POST API requests on the entity + /// Configured base route + /// Expected value for Location field in the response header. Since, the PK of the new record is not known beforehand, + /// the expectedLocationHeader excludes the PK. Because of this, the actual location header is validated by checking if it starts with the expectedLocationHeader. + [DataTestMethod] + [TestCategory(TestCategory.MSSQL)] + [DataRow(EntitySourceType.Table, "/api/Book", "/data-api", "http://localhost/data-api/api/Book/id/", DisplayName = "Location Header validation - Table, Base Route configured")] + [DataRow(EntitySourceType.StoredProcedure, "/api/GetBooks", "/data-api", "http://localhost/data-api/api/GetBooks", DisplayName = "Location Header validation - Stored Procedure, Base Route configured")] + public async Task ValidateLocationHeaderWhenBaseRouteIsConfigured( + EntitySourceType entityType, + string requestPath, + string baseRoute, + string expectedLocationHeader) + { + GraphQLRuntimeOptions graphqlOptions = new(Enabled: false); + RestRuntimeOptions restRuntimeOptions = new(Enabled: true); + McpRuntimeOptions mcpRuntimeOptions = new(Enabled: false); + + DataSource dataSource = new(DatabaseType.MSSQL, + GetConnectionStringFromEnvironmentConfig(environment: TestCategory.MSSQL), Options: null); + + RuntimeConfig configuration; + + if (entityType is EntitySourceType.StoredProcedure) + { + Entity entity = new(Source: new("get_books", EntitySourceType.StoredProcedure, null, null), + Fields: null, + Rest: new(new SupportedHttpVerb[] { SupportedHttpVerb.Get, SupportedHttpVerb.Post }), + GraphQL: null, + Permissions: new[] { GetMinimalPermissionConfig(AuthorizationResolver.ROLE_ANONYMOUS) }, + Relationships: null, + Mappings: null + ); + + string entityName = "GetBooks"; + configuration = InitMinimalRuntimeConfig(dataSource, graphqlOptions, restRuntimeOptions, mcpRuntimeOptions, entity, entityName); + } + else + { + configuration = InitMinimalRuntimeConfig(dataSource, graphqlOptions, restRuntimeOptions, mcpRuntimeOptions); + } + + const string CUSTOM_CONFIG = "custom-config.json"; + + Config.ObjectModel.AuthenticationOptions authenticationOptions = new(Provider: EasyAuthType.StaticWebApps.ToString(), null); + HostOptions staticWebAppsHostOptions = new(null, authenticationOptions); + + RuntimeOptions runtimeOptions = configuration.Runtime; + RuntimeOptions baseRouteEnabledRuntimeOptions = new(runtimeOptions?.Rest, runtimeOptions?.GraphQL, runtimeOptions?.Mcp, staticWebAppsHostOptions, "/data-api"); + RuntimeConfig baseRouteEnabledConfig = configuration with { Runtime = baseRouteEnabledRuntimeOptions }; + File.WriteAllText(CUSTOM_CONFIG, baseRouteEnabledConfig.ToJson()); + + string[] args = new[] + { + $"--ConfigFileName={CUSTOM_CONFIG}" + }; + + using (TestServer server = new(Program.CreateWebHostBuilder(args))) + using (HttpClient client = server.CreateClient()) + { + HttpMethod httpMethod = SqlTestHelper.ConvertRestMethodToHttpMethod(SupportedHttpVerb.Post); + HttpRequestMessage request = new(httpMethod, requestPath); + if (entityType is not EntitySourceType.StoredProcedure) + { + string requestBody = @"{ + ""title"": ""Harry Potter and the Order of Phoenix"", + ""publisher_id"": 1234 + }"; + + JsonElement requestBodyElement = JsonDocument.Parse(requestBody).RootElement.Clone(); + request = new(httpMethod, requestPath) + { + Content = JsonContent.Create(requestBodyElement) + }; + } + + HttpResponseMessage response = await client.SendAsync(request); + Assert.AreEqual(HttpStatusCode.Created, response.StatusCode); + + string locationHeader = response.Headers.Location.AbsoluteUri; + Assert.IsTrue(locationHeader.StartsWith(expectedLocationHeader)); + + // The URL to perform the GET request is constructed by skipping the base-route. + // Base Route field is applicable only in SWA-DAB integrated scenario. When DAB engine is run independently, all the + // APIs are hosted on /api. But, the returned Location header in this test will contain the configured base-route. So, this needs to be + // removed before performing a subsequent GET request. + string path = response.Headers.Location.AbsolutePath; + string completeUrl = path.Substring(baseRoute.Length); + + HttpRequestMessage followUpRequest = new(HttpMethod.Get, completeUrl); + HttpResponseMessage followUpResponse = await client.SendAsync(followUpRequest); + Assert.AreEqual(HttpStatusCode.OK, followUpResponse.StatusCode); + + // Delete the new record created as part of this test + if (entityType is EntitySourceType.Table) + { + HttpRequestMessage cleanupRequest = new(HttpMethod.Delete, completeUrl); + await client.SendAsync(cleanupRequest); + } + + } + } + + /// + /// Test to validate that when the property rest.request-body-strict is absent from the rest runtime section in config file, DAB runs in strict mode. + /// In strict mode, presence of extra fields in the request body is not permitted and leads to HTTP 400 - BadRequest error. + /// + /// Boolean value indicating whether or not to include extraneous field in request body. + [DataTestMethod] + [TestCategory(TestCategory.MSSQL)] + [DataRow(false, DisplayName = "Mutation operation passes when no extraneous field is included in request body and rest.request-body-strict is omitted from the rest runtime section in the config file.")] + [DataRow(true, DisplayName = "Mutation operation fails when an extraneous field is included in request body and rest.request-body-strict is omitted from the rest runtime section in the config file.")] + public async Task ValidateStrictModeAsDefaultForRestRequestBody(bool includeExtraneousFieldInRequestBody) + { + string entityJson = @" + { + ""entities"": { + ""Book"": { + ""source"": { + ""object"": ""books"", + ""type"": ""table"" + }, + ""permissions"": [ + { + ""role"": ""anonymous"", + ""actions"": [ + { + ""action"": ""*"" + } + ] + } + ] + } + } + }"; + + // The BASE_CONFIG omits the rest.request-body-strict option in the runtime section. + string configJson = TestHelper.AddPropertiesToJson(TestHelper.BASE_CONFIG, entityJson); + RuntimeConfigLoader.TryParseConfig( + configJson, + out RuntimeConfig deserializedConfig, + replacementSettings: new(), + logger: null, + GetConnectionStringFromEnvironmentConfig(environment: TestCategory.MSSQL)); + const string CUSTOM_CONFIG = "custom-config.json"; + File.WriteAllText(CUSTOM_CONFIG, deserializedConfig.ToJson()); + string[] args = new[] + { + $"--ConfigFileName={CUSTOM_CONFIG}" + }; + + using (TestServer server = new(Program.CreateWebHostBuilder(args))) + using (HttpClient client = server.CreateClient()) + { + HttpMethod httpMethod = SqlTestHelper.ConvertRestMethodToHttpMethod(SupportedHttpVerb.Post); + string requestBody = @"{ + ""title"": ""Harry Potter and the Order of Phoenix"", + ""publisher_id"": 1234 "; + + if (includeExtraneousFieldInRequestBody) + { + requestBody += @", + ""extraField"": 12"; + } + + requestBody += "}"; + JsonElement requestBodyElement = JsonDocument.Parse(requestBody).RootElement.Clone(); + HttpRequestMessage request = new(httpMethod, "api/Book") + { + Content = JsonContent.Create(requestBodyElement) + }; + + HttpResponseMessage response = await client.SendAsync(request); + if (includeExtraneousFieldInRequestBody) + { + string responseBody = await response.Content.ReadAsStringAsync(); + // Assert that including an extraneous field in request body while operating in strict mode leads to a bad request exception. + Assert.AreEqual(HttpStatusCode.BadRequest, response.StatusCode); + Assert.IsTrue(responseBody.Contains("Invalid request body. Contained unexpected fields in body: extraField")); + } + else + { + // When no extraneous fields are included in request body, the operation executes successfully. + Assert.AreEqual(HttpStatusCode.Created, response.StatusCode); + string locationHeader = response.Headers.Location.AbsoluteUri; + + // Delete the new record created as part of this test. + HttpRequestMessage cleanupRequest = new(HttpMethod.Delete, locationHeader); + await client.SendAsync(cleanupRequest); + } + } + } + + /// + /// Engine supports config with some views that do not have keyfields specified in the config for MsSQL. + /// This Test validates that support. It creates a custom config with a view and no keyfields specified. + /// It checks both Rest and GraphQL queries are tested to return Success. + /// + [TestMethod, TestCategory(TestCategory.MSSQL)] + public async Task TestEngineSupportViewsWithoutKeyFieldsInConfigForMsSQL() + { + DataSource dataSource = new(DatabaseType.MSSQL, + GetConnectionStringFromEnvironmentConfig(environment: TestCategory.MSSQL), Options: null); + Entity viewEntity = new( + Source: new("books_view_all", EntitySourceType.Table, null, null), + Fields: null, + Rest: new(Enabled: true), + GraphQL: new("", ""), + Permissions: new[] { GetMinimalPermissionConfig(AuthorizationResolver.ROLE_ANONYMOUS) }, + Relationships: null, + Mappings: null + ); + + RuntimeConfig configuration = InitMinimalRuntimeConfig(dataSource, new(), new(), new(), viewEntity, "books_view_all"); + + 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 = @"{ + books_view_alls { + items{ + id + title + } + } + }"; + + object payload = new { query }; + + HttpRequestMessage graphQLRequest = new(HttpMethod.Post, "/graphql") + { + Content = JsonContent.Create(payload) + }; + + HttpResponseMessage graphQLResponse = await client.SendAsync(graphQLRequest); + Assert.AreEqual(HttpStatusCode.OK, graphQLResponse.StatusCode); + string body = await graphQLResponse.Content.ReadAsStringAsync(); + Assert.IsFalse(body.Contains("errors")); // In GraphQL, All errors end up in the errors array, no matter what kind of error they are. + + HttpRequestMessage restRequest = new(HttpMethod.Get, "/api/books_view_all"); + HttpResponseMessage restResponse = await client.SendAsync(restRequest); + Assert.AreEqual(HttpStatusCode.OK, restResponse.StatusCode); + } + } + + /// + /// Validates that DAB supports a configuration without authentication, as it's optional. + /// Ensures both REST and GraphQL queries return success when authentication is not configured. + /// + [TestMethod, TestCategory(TestCategory.MSSQL)] + public async Task TestEngineSupportConfigWithNoAuthentication() + { + DataSource dataSource = new(DatabaseType.MSSQL, + GetConnectionStringFromEnvironmentConfig(environment: TestCategory.MSSQL), Options: null); + + RuntimeConfig configuration = CreateBasicRuntimeConfigWithSingleEntityAndAuthOptions(dataSource: dataSource, authenticationOptions: null); + + 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 = @"{ + books { + items{ + id + title + } + } + }"; + + object payload = new { query }; + + HttpRequestMessage graphQLRequest = new(HttpMethod.Post, "/graphql") + { + Content = JsonContent.Create(payload) + }; + + HttpResponseMessage graphQLResponse = await client.SendAsync(graphQLRequest); + Assert.AreEqual(HttpStatusCode.OK, graphQLResponse.StatusCode); + string body = await graphQLResponse.Content.ReadAsStringAsync(); + Assert.IsFalse(body.Contains("errors")); // In GraphQL, All errors end up in the errors array, no matter what kind of error they are. + + HttpRequestMessage restRequest = new(HttpMethod.Get, "/api/Book"); + HttpResponseMessage restResponse = await client.SendAsync(restRequest); + Assert.AreEqual(HttpStatusCode.OK, restResponse.StatusCode); + } + } + + /// + /// In CosmosDB NoSQL, we store data in the form of JSON. Practically, JSON can be very complex. + /// But DAB doesn't support JSON with circular references e.g if 'Character.Moon' is a valid JSON Path, then + /// 'Moon.Character' should not be there, DAB would throw an exception during the load itself. + /// + /// + [TestMethod, TestCategory(TestCategory.COSMOSDBNOSQL)] + [DataRow(GRAPHQL_SCHEMA_WITH_CYCLE_OBJECT, DisplayName = "When Circular Reference is there with Object type (i.e. 'Moon' in 'Character' Entity")] + [DataRow(GRAPHQL_SCHEMA_WITH_CYCLE_ARRAY, DisplayName = "When Circular Reference is there with Array type (i.e. '[Moon]' in 'Character' Entity")] + public void ValidateGraphQLSchemaForCircularReference(string schema) + { + // Read the base config from the file system + TestHelper.SetupDatabaseEnvironment(TestCategory.COSMOSDBNOSQL); + FileSystemRuntimeConfigLoader baseLoader = TestHelper.GetRuntimeConfigLoader(); + if (!baseLoader.TryLoadKnownConfig(out RuntimeConfig baseConfig)) + { + throw new ApplicationException("Failed to load the default CosmosDB_NoSQL config and cannot continue with tests."); + } + + // Setup a mock file system, and use that one with the loader/provider for the config + MockFileSystem fileSystem = new(new Dictionary() + { + { @"../schema.gql", new MockFileData(schema) }, + { DEFAULT_CONFIG_FILE_NAME, new MockFileData(baseConfig.ToJson()) } + }); + FileSystemRuntimeConfigLoader loader = new(fileSystem); + RuntimeConfigProvider provider = new(loader); + + Mock> loggerValidator = new(); + RuntimeConfigValidator validator = new(provider, fileSystem, loggerValidator.Object); + + DataApiBuilderException exception = + Assert.ThrowsException(() => new CosmosSqlMetadataProvider(provider, validator, fileSystem)); + Assert.AreEqual("Circular reference detected in the provided GraphQL schema for entity 'Character'.", exception.Message); + Assert.AreEqual(HttpStatusCode.InternalServerError, exception.StatusCode); + Assert.AreEqual(DataApiBuilderException.SubStatusCodes.ErrorInInitialization, exception.SubStatusCode); + } + + /// + /// GraphQL Schema types defined -> Character and Planet + /// DAB runtime config entities defined -> Planet(Not defined: Character) + /// Mismatch of entities and types between provided GraphQL schema file and DAB config results in actionable error message. + /// + /// + [TestMethod, TestCategory(TestCategory.COSMOSDBNOSQL)] + public void ValidateGraphQLSchemaEntityPresentInConfig() + { + string GRAPHQL_SCHEMA = @" +type Character { + id : ID, + name : String, +} + +type Planet @model(name:""PlanetAlias"") { + id : ID!, + name : String, + characters : [Character] +}"; + // Read the base config from the file system + TestHelper.SetupDatabaseEnvironment(TestCategory.COSMOSDBNOSQL); + FileSystemRuntimeConfigLoader baseLoader = TestHelper.GetRuntimeConfigLoader(); + if (!baseLoader.TryLoadKnownConfig(out RuntimeConfig baseConfig)) + { + throw new ApplicationException("Failed to load the default CosmosDB_NoSQL config and cannot continue with tests."); + } + + Dictionary entities = new(baseConfig.Entities); + entities.Remove("Character"); + + RuntimeConfig runtimeConfig = new(Schema: baseConfig.Schema, + DataSource: baseConfig.DataSource, + Runtime: baseConfig.Runtime, + Entities: new(entities)); + + // Setup a mock file system, and use that one with the loader/provider for the config + MockFileSystem fileSystem = new(new Dictionary() + { + { @"../schema.gql", new MockFileData(GRAPHQL_SCHEMA) }, + { DEFAULT_CONFIG_FILE_NAME, new MockFileData(runtimeConfig.ToJson()) } + }); + FileSystemRuntimeConfigLoader loader = new(fileSystem); + RuntimeConfigProvider provider = new(loader); + Mock> loggerValidator = new(); + RuntimeConfigValidator validator = new(provider, fileSystem, loggerValidator.Object); + + DataApiBuilderException exception = + Assert.ThrowsException(() => new CosmosSqlMetadataProvider(provider, validator, fileSystem)); + Assert.AreEqual("The entity 'Character' was not found in the runtime config.", exception.Message); + Assert.AreEqual(HttpStatusCode.ServiceUnavailable, exception.StatusCode); + Assert.AreEqual(DataApiBuilderException.SubStatusCodes.ConfigValidationError, exception.SubStatusCode); + } + + /// + /// Tests that Startup.cs properly handles EasyAuth authentication configuration. + /// AppService as Identity Provider while in Production mode will result in startup error. + /// An Azure AppService environment has environment variables on the host which indicate + /// the environment is, in fact, an AppService environment. + /// + /// HostMode in Runtime config - Development or Production. + /// EasyAuth auth type - AppService or StaticWebApps. + /// Whether to set the AppService host environment variables. + /// Whether an error is expected. + [DataTestMethod] + [TestCategory(TestCategory.MSSQL)] + [DataRow(HostMode.Development, EasyAuthType.AppService, false, false, DisplayName = "AppService Dev - No EnvVars - No Error")] + [DataRow(HostMode.Development, EasyAuthType.AppService, true, false, DisplayName = "AppService Dev - EnvVars - No Error")] + [DataRow(HostMode.Production, EasyAuthType.AppService, false, false, DisplayName = "AppService Prod - No EnvVars - Error")] + [DataRow(HostMode.Production, EasyAuthType.AppService, true, false, DisplayName = "AppService Prod - EnvVars - Error")] + [DataRow(HostMode.Development, EasyAuthType.StaticWebApps, false, false, DisplayName = "SWA Dev - No EnvVars - No Error")] + [DataRow(HostMode.Development, EasyAuthType.StaticWebApps, true, false, DisplayName = "SWA Dev - EnvVars - No Error")] + [DataRow(HostMode.Production, EasyAuthType.StaticWebApps, false, false, DisplayName = "SWA Prod - No EnvVars - No Error")] + [DataRow(HostMode.Production, EasyAuthType.StaticWebApps, true, false, DisplayName = "SWA Prod - EnvVars - No Error")] + public void TestProductionModeAppServiceEnvironmentCheck(HostMode hostMode, EasyAuthType authType, bool setEnvVars, bool expectError) + { + // Clears or sets App Service Environment Variables based on test input. + Environment.SetEnvironmentVariable(AppServiceAuthenticationInfo.APPSERVICESAUTH_ENABLED_ENVVAR, setEnvVars ? "true" : null); + Environment.SetEnvironmentVariable(AppServiceAuthenticationInfo.APPSERVICESAUTH_IDENTITYPROVIDER_ENVVAR, setEnvVars ? "AzureActiveDirectory" : null); + TestHelper.SetupDatabaseEnvironment(TestCategory.MSSQL); + + FileSystem fileSystem = new(); + FileSystemRuntimeConfigLoader loader = new(fileSystem); + + RuntimeConfigProvider configProvider = TestHelper.GetRuntimeConfigProvider(loader); + RuntimeConfig config = configProvider.GetConfig(); + + // Setup configuration + Config.ObjectModel.AuthenticationOptions authenticationOptions = new(Provider: authType.ToString(), Jwt: null); + RuntimeOptions runtimeOptions = new( + Rest: new(), + GraphQL: new(), + Mcp: new(), + Host: new(Cors: null, authenticationOptions, hostMode) + ); + RuntimeConfig configWithCustomHostMode = config with { Runtime = runtimeOptions }; + + const string CUSTOM_CONFIG = "custom-config.json"; + File.WriteAllText(CUSTOM_CONFIG, configWithCustomHostMode.ToJson()); + string[] args = new[] + { + $"--ConfigFileName={CUSTOM_CONFIG}" + }; + + // When host is in Production mode with AppService as Identity Provider and the environment variables are not set + // we do not throw an exception any longer(PR: 2943), instead log a warning to the user. In this case expectError is false. + // This test only checks for startup errors, so no requests are sent to the test server. + try + { + using TestServer server = new(Program.CreateWebHostBuilder(args)); + Assert.IsFalse(expectError, message: "Expected error faulting AppService config in production mode."); + } + catch (DataApiBuilderException ex) + { + Assert.IsTrue(expectError, message: ex.Message); + Assert.AreEqual(AppServiceAuthenticationInfo.APPSERVICE_PROD_MISSING_ENV_CONFIG, ex.Message); + } + } + + /// + /// Integration test that validates schema introspection requests fail + /// when allow-introspection is false in the runtime configuration. + /// TestCategory is required for CI/CD pipeline to inject a connection string. + /// + /// + [TestCategory(TestCategory.MSSQL)] + [DataTestMethod] + [DataRow(false, true, "Introspection is not allowed for the current request.", CONFIGURATION_ENDPOINT, DisplayName = "Disabled introspection returns GraphQL error.")] + [DataRow(true, false, null, CONFIGURATION_ENDPOINT, DisplayName = "Enabled introspection does not return introspection forbidden error.")] + [DataRow(false, true, "Introspection is not allowed for the current request.", CONFIGURATION_ENDPOINT_V2, DisplayName = "Disabled introspection returns GraphQL error.")] + [DataRow(true, false, null, CONFIGURATION_ENDPOINT_V2, DisplayName = "Enabled introspection does not return introspection forbidden error.")] + public async Task TestSchemaIntrospectionQuery(bool enableIntrospection, bool expectError, string errorMessage, string configurationEndpoint) + { + GraphQLRuntimeOptions graphqlOptions = new(AllowIntrospection: enableIntrospection); + RestRuntimeOptions restRuntimeOptions = new(); + McpRuntimeOptions mcpRuntimeOptions = new(); + + DataSource dataSource = new(DatabaseType.MSSQL, GetConnectionStringFromEnvironmentConfig(environment: TestCategory.MSSQL), Options: null); + + RuntimeConfig configuration = InitMinimalRuntimeConfig(dataSource, graphqlOptions, restRuntimeOptions, mcpRuntimeOptions); + 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()) + { + await ExecuteGraphQLIntrospectionQueries(server, client, expectError); + } + + // Instantiate new server with no runtime config for post-startup configuration hydration tests. + using (TestServer server = new(Program.CreateWebHostFromInMemoryUpdatableConfBuilder(Array.Empty()))) + using (HttpClient client = server.CreateClient()) + { + JsonContent content = GetPostStartupConfigParams(MSSQL_ENVIRONMENT, configuration, configurationEndpoint); + HttpStatusCode responseCode = await HydratePostStartupConfiguration(client, content, configurationEndpoint, configuration.Runtime.Rest); + + Assert.AreEqual(expected: HttpStatusCode.OK, actual: responseCode, message: "Configuration hydration failed."); + + await ExecuteGraphQLIntrospectionQueries(server, client, expectError); + } + } + + /// + /// Indirectly tests IsGraphQLReservedName(). Runtime config provided to engine which will + /// trigger SqlMetadataProvider PopulateSourceDefinitionAsync() to pull column metadata from + /// the table "graphql_incompatible." That table contains columns which collide with reserved GraphQL + /// introspection field names which begin with double underscore (__). + /// + [TestCategory(TestCategory.MSSQL)] + [DataTestMethod] + [DataRow(true, true, "__typeName", "__introspectionField", true, DisplayName = "Name violation, fails since no proper mapping set.")] + [DataRow(true, true, "__typeName", "columnMapping", false, DisplayName = "Name violation, but OK since proper mapping set.")] + [DataRow(false, true, null, null, false, DisplayName = "Name violation, but OK since GraphQL globally disabled.")] + [DataRow(true, false, null, null, false, DisplayName = "Name violation, but OK since GraphQL disabled for entity.")] + public void TestInvalidDatabaseColumnNameHandling( + bool globalGraphQLEnabled, + bool entityGraphQLEnabled, + string columnName, + string columnMapping, + bool expectError) + { + GraphQLRuntimeOptions graphqlOptions = new(Enabled: globalGraphQLEnabled); + RestRuntimeOptions restRuntimeOptions = new(Enabled: true); + McpRuntimeOptions mcpOptions = new(Enabled: true); + + DataSource dataSource = new(DatabaseType.MSSQL, GetConnectionStringFromEnvironmentConfig(environment: TestCategory.MSSQL), Options: null); + + // Configure Entity for testing + Dictionary mappings = new() + { + { "__introspectionName", "conformingIntrospectionName" } + }; + + if (!string.IsNullOrWhiteSpace(columnMapping)) + { + mappings.Add(columnName, columnMapping); + } + + Entity entity = new( + Source: new("graphql_incompatible", EntitySourceType.Table, null, null), + Fields: null, + Rest: new(Enabled: false), + GraphQL: new("graphql_incompatible", "graphql_incompatibles", entityGraphQLEnabled), + Permissions: new[] { GetMinimalPermissionConfig(AuthorizationResolver.ROLE_ANONYMOUS) }, + Relationships: null, + Mappings: mappings + ); + + RuntimeConfig configuration = InitMinimalRuntimeConfig(dataSource, graphqlOptions, restRuntimeOptions, mcpOptions, entity, "graphqlNameCompat"); + const string CUSTOM_CONFIG = "custom-config.json"; + File.WriteAllText(CUSTOM_CONFIG, configuration.ToJson()); + + string[] args = new[] + { + $"--ConfigFileName={CUSTOM_CONFIG}" + }; + + try + { + using TestServer server = new(Program.CreateWebHostBuilder(args)); + Assert.IsFalse(expectError, message: "Expected startup to fail."); + } + catch (Exception ex) + { + Assert.IsTrue(expectError, message: "Startup was not expected to fail. " + ex.Message); + } + } + + /// + /// Test different Swagger endpoints in different host modes when accessed interactively via browser. + /// Two pass request scheme: + /// 1 - Send get request to expected Swagger endpoint /swagger + /// Response - Internally Swagger sends HTTP 301 Moved Permanently with Location header + /// pointing to exact Swagger page (/swagger/index.html) + /// 2 - Send GET request to path referred to by Location header in previous response + /// Response - Successful loading of SwaggerUI HTML, with reference to endpoint used + /// to retrieve OpenAPI document. This test ensures that Swagger components load, but + /// does not confirm that a proper OpenAPI document was created. + /// + /// The custom REST route + /// The mode in which the service is executing. + /// Whether to expect an error. + /// Expected Status Code. + /// Snippet of expected HTML to be emitted from successful page load. + /// This should note the openapi route that Swagger will use to retrieve the OpenAPI document. + [DataTestMethod] + [TestCategory(TestCategory.MSSQL)] + [DataRow("/api", HostMode.Development, false, HttpStatusCode.OK, "{\"urls\":[{\"url\":\"/api/openapi\"", DisplayName = "SwaggerUI enabled in development mode.")] + [DataRow("/custompath", HostMode.Development, false, HttpStatusCode.OK, "{\"urls\":[{\"url\":\"/custompath/openapi\"", DisplayName = "SwaggerUI enabled with custom REST path in development mode.")] + [DataRow("/api", HostMode.Production, true, HttpStatusCode.BadRequest, "", DisplayName = "SwaggerUI disabled in production mode.")] + [DataRow("/custompath", HostMode.Production, true, HttpStatusCode.BadRequest, "", DisplayName = "SwaggerUI disabled in production mode with custom REST path.")] + public async Task OpenApi_InteractiveSwaggerUI( + string customRestPath, + HostMode hostModeType, + bool expectsError, + HttpStatusCode expectedStatusCode, + string expectedOpenApiTargetContent) + { + string swaggerEndpoint = "/swagger"; + DataSource dataSource = new(DatabaseType.MSSQL, GetConnectionStringFromEnvironmentConfig(environment: TestCategory.MSSQL), Options: null); + + RuntimeConfig configuration = InitMinimalRuntimeConfig( + dataSource: dataSource, + graphqlOptions: new(), + restOptions: new(Path: customRestPath), + mcpOptions: new()); + + configuration = configuration + with + { + Runtime = configuration.Runtime + with + { + Host = configuration.Runtime?.Host + with + { Mode = hostModeType } + } + }; + 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()) + { + HttpRequestMessage initialRequest = new(HttpMethod.Get, swaggerEndpoint); + + // Adding the following headers simulates an interactive browser request. + initialRequest.Headers.Add("user-agent", BROWSER_USER_AGENT_HEADER); + initialRequest.Headers.Add("accept", BROWSER_ACCEPT_HEADER); + + HttpResponseMessage response = await client.SendAsync(initialRequest); + if (expectsError) + { + // Redirect(HTTP 301) and follow up request to the returned path + // do not occur in a failure scenario. Only HTTP 400 (Bad Request) + // is expected. + Assert.AreEqual(expectedStatusCode, response.StatusCode); + } + else + { + // Swagger endpoint internally configured to reroute from /swagger to /swagger/index.html + Assert.AreEqual(HttpStatusCode.MovedPermanently, response.StatusCode); + + HttpRequestMessage followUpRequest = new(HttpMethod.Get, response.Headers.Location); + HttpResponseMessage followUpResponse = await client.SendAsync(followUpRequest); + Assert.AreEqual(expectedStatusCode, followUpResponse.StatusCode); + + // Validate that Swagger requests OpenAPI document using REST path defined in runtime config. + string actualBody = await followUpResponse.Content.ReadAsStringAsync(); + Assert.AreEqual(true, actualBody.Contains(expectedOpenApiTargetContent)); + } + } + } + + /// + /// Test different loglevel values that are avaliable by deserializing RuntimeConfig with specified LogLevel + /// and checks if value exists properly inside the deserialized RuntimeConfig. + /// + [DataTestMethod] + [TestCategory(TestCategory.MSSQL)] + [DataRow(LogLevel.Trace, DisplayName = "Validates that log level Trace deserialized correctly")] + [DataRow(LogLevel.Debug, DisplayName = "Validates log level Debug deserialized correctly")] + [DataRow(LogLevel.Information, DisplayName = "Validates log level Information deserialized correctly")] + [DataRow(LogLevel.Warning, DisplayName = "Validates log level Warning deserialized correctly")] + [DataRow(LogLevel.Error, DisplayName = "Validates log level Error deserialized correctly")] + [DataRow(LogLevel.Critical, DisplayName = "Validates log level Critical deserialized correctly")] + [DataRow(LogLevel.None, DisplayName = "Validates log level None deserialized correctly")] + [DataRow(null, DisplayName = "Validates log level Null deserialized correctly")] + public void TestExistingLogLevels(LogLevel expectedLevel) + { + Dictionary logLevelOptions = new(); + logLevelOptions.Add("default", expectedLevel); + RuntimeConfig configWithCustomLogLevel = InitializeRuntimeWithLogLevel(logLevelOptions); + + string configWithCustomLogLevelJson = configWithCustomLogLevel.ToJson(); + Assert.IsTrue(RuntimeConfigLoader.TryParseConfig(configWithCustomLogLevelJson, out RuntimeConfig deserializedRuntimeConfig)); + + Assert.AreEqual(expectedLevel, deserializedRuntimeConfig.Runtime.Telemetry.LoggerLevel["default"]); + } + + /// + /// Test different loglevel values that do not exist to ensure that the build fails when they are trying to be set up + /// + [DataTestMethod] + [TestCategory(TestCategory.MSSQL)] + [DataRow(-1, DisplayName = "Validates that a negative log level value, fails to build")] + [DataRow(7, DisplayName = "Validates that a positive log level value that does not exist, fails to build")] + [DataRow(12, DisplayName = "Validates that a bigger positive log level value that does not exist, fails to build")] + public void TestNonExistingLogLevels(LogLevel expectedLevel) + { + Dictionary logLevelOptions = new(); + logLevelOptions.Add("default", expectedLevel); + RuntimeConfig configWithCustomLogLevel = InitializeRuntimeWithLogLevel(logLevelOptions); + + // Try should fail and go to catch exception + try + { + string configWithCustomLogLevelJson = configWithCustomLogLevel.ToJson(); + Assert.Fail(); + } + // Catch verifies that the exception is due to LogLevel having a value that does not exist + catch (Exception ex) + { + Assert.AreEqual(typeof(KeyNotFoundException), ex.GetType()); + } + } + + /// + /// Tests different loglevel values to see if they are serialized correctly to the Json config + /// + [DataTestMethod] + [TestCategory(TestCategory.MSSQL)] + [DataRow(LogLevel.Debug)] + [DataRow(LogLevel.Warning)] + [DataRow(LogLevel.None)] + [DataRow(null)] + public void LogLevelSerialization(LogLevel expectedLevel) + { + Dictionary logLevelOptions = new(); + logLevelOptions.Add("default", expectedLevel); + RuntimeConfig configWithCustomLogLevel = InitializeRuntimeWithLogLevel(logLevelOptions); + string configWithCustomLogLevelJson = configWithCustomLogLevel.ToJson(); + Assert.IsTrue(RuntimeConfigLoader.TryParseConfig(configWithCustomLogLevelJson, out RuntimeConfig deserializedRuntimeConfig)); + + string serializedConfig = deserializedRuntimeConfig.ToJson(); + + using (JsonDocument parsedDocument = JsonDocument.Parse(serializedConfig)) + { + JsonElement root = parsedDocument.RootElement; + JsonElement runtimeElement = root.GetProperty("runtime"); + + //Validate log-level property exists in runtime + JsonElement telemetryElement = runtimeElement.GetProperty("telemetry"); + bool logLevelPropertyExists = telemetryElement.TryGetProperty("log-level", out JsonElement logLevelElement); + Assert.AreEqual(expected: true, actual: logLevelPropertyExists); + + //Validate the dictionary inside the log-level property is of expected value + bool dictionaryLogLevelExists = logLevelElement.TryGetProperty("default", out JsonElement levelElement); + Assert.AreEqual(expected: true, actual: dictionaryLogLevelExists); + Assert.AreEqual(expectedLevel.ToString().ToLower(), levelElement.GetString()); + } + } + + /// + /// Tests different log level filters that are valid and check that they are deserialized correctly + /// + [Ignore] + [DataTestMethod] + [TestCategory(TestCategory.MSSQL)] + [DataRow(LogLevel.Trace, typeof(RuntimeConfigValidator))] + [DataRow(LogLevel.Debug, typeof(SqlQueryEngine))] + [DataRow(LogLevel.Information, typeof(IQueryExecutor))] + [DataRow(LogLevel.Warning, typeof(ISqlMetadataProvider))] + [DataRow(LogLevel.Error, typeof(BasicHealthReportResponseWriter))] + [DataRow(LogLevel.Critical, typeof(ComprehensiveHealthReportResponseWriter))] + [DataRow(LogLevel.None, typeof(RestController))] + [DataRow(LogLevel.Trace, typeof(ClientRoleHeaderAuthenticationMiddleware))] + [DataRow(LogLevel.Debug, typeof(ConfigurationController))] + [DataRow(LogLevel.Information, typeof(IAuthorizationHandler))] + [DataRow(LogLevel.Warning, typeof(IAuthorizationResolver))] + public void ValidLogLevelFilters(LogLevel logLevel, Type loggingType) + { + string loggingFilter = loggingType.FullName; + ValidateLogLevelFilters(logLevel, loggingFilter); + } + + /// + /// Tests different log level filters that are valid and check that they are deserialized correctly + /// This test uses strings as we are checking for values that are not avaliable using the typeof() function + /// It is the same test as ValidLogLevelFilters. + /// + [DataTestMethod] + [TestCategory(TestCategory.MSSQL)] + [DataRow(LogLevel.Trace, "default")] + [DataRow(LogLevel.Debug, "Azure")] + [DataRow(LogLevel.Information, "Azure.DataApiBuilder")] + [DataRow(LogLevel.Warning, "Azure.DataApiBuilder.Core")] + [DataRow(LogLevel.Error, "Azure.DataApiBuilder.Core.Configurations")] + [DataRow(LogLevel.Critical, "Azure.DataApiBuilder.Core.Resolvers")] + [DataRow(LogLevel.None, "Azure.DataApiBuilder.Core.Services")] + [DataRow(LogLevel.Trace, "Azure.DataApiBuilder.Service")] + [DataRow(LogLevel.Debug, "Azure.DataApiBuilder.Service.HealthCheck")] + [DataRow(LogLevel.Information, "Azure.DataApiBuilder.Service.Controllers")] + [DataRow(LogLevel.Warning, "Microsoft.AspNetCore")] + public void ValidStringLogLevelFilters(LogLevel logLevel, string loggingFilter) + { + ValidateLogLevelFilters(logLevel, loggingFilter); + } + + /// + /// General method that is used to test the valid log level filters. + /// + /// + /// + private static void ValidateLogLevelFilters(LogLevel logLevel, string loggingFilter) + { + // Arrange + Dictionary logLevelOptions = new(); + logLevelOptions.Add(loggingFilter, logLevel); + RuntimeConfig configWithCustomLogLevel = InitializeRuntimeWithLogLevel(logLevelOptions); + + File.WriteAllText(CUSTOM_CONFIG_FILENAME, configWithCustomLogLevel.ToJson()); + string[] args = new[] + { + $"--ConfigFileName={CUSTOM_CONFIG_FILENAME}" + }; + + // Start a new server with the custom log level to ensure the + // instantiation of the valid log level filters works as expected. + TestServer server = new(Program.CreateWebHostBuilder(args)); + RuntimeConfigProvider runtimeConfigProvider = server.Services.GetService(); + + // RuntimeConfig with instantiated log level filters. + RuntimeConfig serverRuntimeConfig = runtimeConfigProvider.GetConfig(); + + // Act + try + { + RuntimeConfigValidator.ValidateLoggerFilters(serverRuntimeConfig); + } + catch + { + Assert.Fail(); + } + + // Assert + string configWithCustomLogLevelJson = configWithCustomLogLevel.ToJson(); + Assert.IsTrue(RuntimeConfigLoader.TryParseConfig(configWithCustomLogLevelJson, out RuntimeConfig deserializedRuntimeConfig)); + + Dictionary actualLoggerLevel = deserializedRuntimeConfig.Runtime.Telemetry.LoggerLevel; + Assert.IsTrue(actualLoggerLevel.ContainsKey(loggingFilter) && actualLoggerLevel.Count == 1); + Assert.IsTrue(actualLoggerLevel[loggingFilter] == logLevel); + } + + /// + /// Tests that between multiple log level filters, + /// the one that is more specific is always given priority. + /// + [DataTestMethod] + [TestCategory(TestCategory.MSSQL)] + [DataRow(LogLevel.Debug, "Azure", LogLevel.Warning, "default", typeof(IQueryExecutor))] + [DataRow(LogLevel.Information, "Azure.DataApiBuilder", LogLevel.Error, "Azure", typeof(IQueryExecutor))] + [DataRow(LogLevel.Warning, "Azure.DataApiBuilder.Core", LogLevel.Critical, "Azure.DataApiBuilder", typeof(RuntimeConfigValidator))] + [DataRow(LogLevel.Error, "Azure.DataApiBuilder.Core.Configurations", LogLevel.None, "Azure.DataApiBuilder.Core", typeof(RuntimeConfigValidator))] + public void PriorityLogLevelFilters(LogLevel highPriLevel, string highPriFilter, LogLevel lowPriLevel, string lowPriFilter, Type type) + { + string classString = type.FullName; + Startup.AddValidFilters(); + Dictionary logLevelOptions = new(); + logLevelOptions.Add(highPriFilter, highPriLevel); + logLevelOptions.Add(lowPriFilter, lowPriLevel); + RuntimeConfig configWithCustomLogLevel = InitializeRuntimeWithLogLevel(logLevelOptions); + try + { + RuntimeConfigValidator.ValidateLoggerFilters(configWithCustomLogLevel); + } + catch + { + Assert.Fail(); + } + + string configWithCustomLogLevelJson = configWithCustomLogLevel.ToJson(); + Assert.IsTrue(RuntimeConfigLoader.TryParseConfig(configWithCustomLogLevelJson, out RuntimeConfig deserializedRuntimeConfig)); + + // If filters are not a subsection from the classString, then the test will not work. + LogLevel actualLogLevel = deserializedRuntimeConfig.GetConfiguredLogLevel(classString); + + Assert.AreEqual(expected: highPriLevel, actual: actualLogLevel); + } + + /// + /// Tests log level filters that are not available and checks that they give the correct error. + /// + [DataTestMethod] + [TestCategory(TestCategory.MSSQL)] + [DataRow("Azure.DataApiBuilder.Core.Configur", DisplayName = "Validates that an incomplete log level keyword fails to build")] + [DataRow("Azure.DataApiBuilder.Core.Configurations.RuntimeConfigVldtr", DisplayName = "Validates that a wrong name at end of log level keyword fails to build")] + [DataRow("Azre.DataApiBuilder.Core.Configurations.RuntimeConfigValidator", DisplayName = "Validates that a wrong name at start of log level keyword fails to build")] + [DataRow("Azure.DataApiBuilder.Core.Configurations.RuntimeConfigValidator.Extra", DisplayName = "Validates that log level keyword with additional path fails to build")] + [DataRow("Microsoft.AspNetCore.Authorizatin.IAuthorizationHandler", DisplayName = "Validates that a wrong name inside of log level keyword fails to build")] + [DataRow("defult", DisplayName = "Validates that a wrong name for 'default' log level keyword fails to build")] + public void InvalidLogLevelFilters(string loggingFilter) + { + Dictionary logLevelOptions = new(); + logLevelOptions.Add(loggingFilter, LogLevel.Debug); + RuntimeConfig configWithCustomLogLevel = InitializeRuntimeWithLogLevel(logLevelOptions); + + // Try should fail and go to catch exception + try + { + RuntimeConfigValidator.ValidateLoggerFilters(configWithCustomLogLevel); + Assert.Fail(); + } + // Catch verifies that the exception is due to LogLevel having a key that is invalid + catch (Exception ex) + { + Assert.AreEqual(typeof(NotSupportedException), ex.GetType()); + } + } + + /// + /// Helper method to create RuntimeConfig with specificed LogLevel value + /// + private static RuntimeConfig InitializeRuntimeWithLogLevel(Dictionary logLevelOptions) + { + TestHelper.SetupDatabaseEnvironment(MSSQL_ENVIRONMENT); + + FileSystemRuntimeConfigLoader baseLoader = TestHelper.GetRuntimeConfigLoader(); + baseLoader.TryLoadKnownConfig(out RuntimeConfig baseConfig); + + RuntimeConfig config = new( + Schema: baseConfig.Schema, + DataSource: baseConfig.DataSource, + Runtime: new( + Rest: new(), + GraphQL: new(), + Mcp: new(), + Host: new(null, null), + Telemetry: new(LoggerLevel: logLevelOptions) + ), + Entities: baseConfig.Entities + ); + + return config; + } + +#nullable enable + + /// + /// Tests different Azure Log Analytics values to see if they are serialized and deserialized correctly to the Json config + /// + [DataTestMethod] + [TestCategory(TestCategory.MSSQL)] + [DataRow(true, "CustomTableName", "DcrImmutableId", "DceEndpoint", "TestDabLog", 1, true, "TestDabLog", 1)] + [DataRow(false, "", null, "", "", 10, false, "", 10)] + [DataRow(null, null, null, null, null, null, false, "DabLogs", 5)] + public void AzureLogAnalyticsSerialization( + bool? enabled, + string? customTableName, + string? dcrImmutableId, + string? dceEndpoint, + string? dabIdentifier, + int? flushIntSec, + bool expectedEnabled, + string expectedDabIdentifier, + int expectedFlushIntSec) + { + // Check if auth property and its values are expected to exist + bool expectedExistEnabled = enabled is not null; + bool expectedExistDabIdentifier = dabIdentifier is not null; + bool expectedExistFlushIntSec = flushIntSec is not null; + bool expectedExistCustomTableName = customTableName is not null; + bool expectedExistDcrImmutableId = dcrImmutableId is not null; + bool expectedExistDceEndpoint = dceEndpoint is not null; + + AzureLogAnalyticsAuthOptions authOptions = new(customTableName, dcrImmutableId, dceEndpoint); + AzureLogAnalyticsOptions azureLogAnalyticsOptions = new(enabled, authOptions, dabIdentifier, flushIntSec); + TelemetryOptions telemetryOptions = new(AzureLogAnalytics: azureLogAnalyticsOptions); + RuntimeConfig configWithCustomLogLevel = InitializeRuntimeWithTelemetry(telemetryOptions); + string configWithCustomLogLevelJson = configWithCustomLogLevel.ToJson(); + Assert.IsTrue(RuntimeConfigLoader.TryParseConfig(configWithCustomLogLevelJson, out RuntimeConfig? deserializedRuntimeConfig)); + + string serializedConfig = deserializedRuntimeConfig.ToJson(); + + using (JsonDocument parsedDocument = JsonDocument.Parse(serializedConfig)) + { + JsonElement root = parsedDocument.RootElement; + JsonElement runtimeElement = root.GetProperty("runtime"); + + //Validate azure-log-analytics property exists in runtime + JsonElement telemetryElement = runtimeElement.GetProperty("telemetry"); + bool azureLogAnalyticsPropertyExists = telemetryElement.TryGetProperty("azure-log-analytics", out JsonElement azureLogAnalyticsElement); + Assert.AreEqual(expected: true, actual: azureLogAnalyticsPropertyExists); + + //Validate the values inside the azure-log-analytics properties are of expected value + bool enabledExists = azureLogAnalyticsElement.TryGetProperty("enabled", out JsonElement enabledElement); + Assert.AreEqual(expected: expectedExistEnabled, actual: enabledExists); + if (enabledExists) + { + Assert.AreEqual(expectedEnabled, enabledElement.GetBoolean()); + } + + bool dabIdentifierExists = azureLogAnalyticsElement.TryGetProperty("dab-identifier", out JsonElement dabIdentifierElement); + Assert.AreEqual(expected: expectedExistDabIdentifier, actual: dabIdentifierExists); + if (dabIdentifierExists) + { + Assert.AreEqual(expectedDabIdentifier, dabIdentifierElement.GetString()); + } + + bool flushIntSecExists = azureLogAnalyticsElement.TryGetProperty("flush-interval-seconds", out JsonElement flushIntSecElement); + Assert.AreEqual(expected: expectedExistFlushIntSec, actual: flushIntSecExists); + if (flushIntSecExists) + { + Assert.AreEqual(expectedFlushIntSec, flushIntSecElement.GetInt32()); + } + + // Validate auth property exists inside of azure-log-analytics + bool authExists = azureLogAnalyticsElement.TryGetProperty("auth", out JsonElement authElement); + + // Validate the values inside the auth properties are of expected value + if (authExists) + { + bool customTableNameExists = authElement.TryGetProperty("custom-table-name", out JsonElement customTableNameElement); + Assert.AreEqual(expectedExistCustomTableName, customTableNameExists); + if (customTableNameExists) + { + Assert.AreEqual(expected: customTableName, customTableNameElement.GetString()); + } + + bool dcrImmutableIdExists = authElement.TryGetProperty("dcr-immutable-id", out JsonElement dcrImmutableIdElement); + Assert.AreEqual(expectedExistDcrImmutableId, dcrImmutableIdExists); + if (dcrImmutableIdExists) + { + Assert.AreEqual(expected: dcrImmutableId, dcrImmutableIdElement.GetString()); + } + + bool dceEndpointExists = authElement.TryGetProperty("dce-endpoint", out JsonElement dceEndpointElement); + Assert.AreEqual(expectedExistDceEndpoint, dceEndpointExists); + if (dceEndpointExists) + { + Assert.AreEqual(expected: dceEndpoint, dceEndpointElement.GetString()); + } + } + } + } + + /// + /// Tests different File Sink values to see if they are serialized and deserialized correctly to the Json config + /// + [DataTestMethod] + [TestCategory(TestCategory.MSSQL)] + [DataRow(true, "/file/path/exists.txt", RollingInterval.Minute, 27, 256, true, "/file/path/exists.txt", RollingInterval.Minute, 27, 256)] + [DataRow(true, "/test/path.csv", RollingInterval.Hour, 10, 3000, true, "/test/path.csv", RollingInterval.Hour, 10, 3000)] + [DataRow(false, "C://absolute/file/path.log", RollingInterval.Month, 2147483647, 2048, false, "C://absolute/file/path.log", RollingInterval.Month, 2147483647, 2048)] + [DataRow(false, "D://absolute/test/path.txt", RollingInterval.Year, 10, 2147483647, false, "D://absolute/test/path.txt", RollingInterval.Year, 10, 2147483647)] + [DataRow(false, "", RollingInterval.Infinite, 5, 512, false, "", RollingInterval.Infinite, 5, 512)] + [DataRow(null, null, null, null, null, false, "/logs/dab-log.txt", RollingInterval.Day, 1, 1048576)] + public void FileSinkSerialization( + bool? enabled, + string? path, + RollingInterval? rollingInterval, + int? retainedFileCountLimit, + int? fileSizeLimitBytes, + bool expectedEnabled, + string expectedPath, + RollingInterval expectedRollingInterval, + int expectedRetainedFileCountLimit, + int expectedFileSizeLimitBytes) + { + // Check if file values are expected to exist + bool isEnabledNull = enabled is null; + bool isPathNull = path is null; + bool isRollingIntervalNull = rollingInterval is null; + bool isRetainedFileCountLimitNull = retainedFileCountLimit is null; + bool isFileSizeLimitBytesNull = fileSizeLimitBytes is null; + + FileSinkOptions fileOptions = new(enabled, path, rollingInterval, retainedFileCountLimit, fileSizeLimitBytes); + TelemetryOptions telemetryOptions = new(File: fileOptions); + RuntimeConfig configWithCustomLogLevel = InitializeRuntimeWithTelemetry(telemetryOptions); + string configWithCustomLogLevelJson = configWithCustomLogLevel.ToJson(); + Assert.IsTrue(RuntimeConfigLoader.TryParseConfig(configWithCustomLogLevelJson, out RuntimeConfig? deserializedRuntimeConfig)); + + string serializedConfig = deserializedRuntimeConfig.ToJson(); + + using (JsonDocument parsedDocument = JsonDocument.Parse(serializedConfig)) + { + JsonElement root = parsedDocument.RootElement; + JsonElement runtimeElement = root.GetProperty("runtime"); + + // Validate file property exists in runtime + JsonElement telemetryElement = runtimeElement.GetProperty("telemetry"); + bool filePropertyExists = telemetryElement.TryGetProperty("file", out JsonElement fileElement); + Assert.AreEqual(expected: true, actual: filePropertyExists); + + // Validate the values inside the file properties are of expected value + bool enabledExists = fileElement.TryGetProperty("enabled", out JsonElement enabledElement); + Assert.AreEqual(expected: !isEnabledNull, actual: enabledExists); + if (enabledExists) + { + Assert.AreEqual(expectedEnabled, enabledElement.GetBoolean()); + } + + bool pathExists = fileElement.TryGetProperty("path", out JsonElement pathElement); + Assert.AreEqual(expected: !isPathNull, actual: pathExists); + if (pathExists) + { + Assert.AreEqual(expectedPath, pathElement.GetString()); + } + + bool rollingIntervalExists = fileElement.TryGetProperty("rolling-interval", out JsonElement rollingIntervalElement); + Assert.AreEqual(expected: !isRollingIntervalNull, actual: rollingIntervalExists); + if (rollingIntervalExists) + { + Assert.AreEqual(expectedRollingInterval.ToString(), rollingIntervalElement.GetString()); + } + + bool retainedFileCountLimitExists = fileElement.TryGetProperty("retained-file-count-limit", out JsonElement retainedFileCountLimitElement); + Assert.AreEqual(expected: !isRetainedFileCountLimitNull, actual: retainedFileCountLimitExists); + if (retainedFileCountLimitExists) + { + Assert.AreEqual(expectedRetainedFileCountLimit, retainedFileCountLimitElement.GetInt32()); + } + + bool fileSizeLimitBytesExists = fileElement.TryGetProperty("file-size-limit-bytes", out JsonElement fileSizeLimitBytesElement); + Assert.AreEqual(expected: !isFileSizeLimitBytesNull, actual: fileSizeLimitBytesExists); + if (fileSizeLimitBytesExists) + { + Assert.AreEqual(expectedFileSizeLimitBytes, fileSizeLimitBytesElement.GetInt32()); + } + } + } + + /// + /// Test validates that autoentities section can be deserialized and serialized correctly. + /// + [DataTestMethod] + [TestCategory(TestCategory.MSSQL)] + [DataRow(null, null, null, null, null, null, null, null, null, "anonymous", EntityActionOperation.Read)] + [DataRow(new[] { "%.%" }, new[] { "%.%" }, "{object}", true, true, true, false, 5, EntityCacheLevel.L1L2, "anonymous", EntityActionOperation.Read)] + [DataRow(new[] { "books.%" }, new[] { "books.pages.%" }, "books_{object}", false, false, false, true, 2147483647, EntityCacheLevel.L1, "test-user", EntityActionOperation.Delete)] + [DataRow(new[] { "books.%" }, null, "books_{object}", false, null, false, null, 2147483647, null, "test-user", EntityActionOperation.Delete)] + [DataRow(null, new[] { "books.pages.%" }, null, null, false, null, true, null, EntityCacheLevel.L1, "test-user", EntityActionOperation.Delete)] + [DataRow(new[] { "title.%", "books.%", "names.%" }, new[] { "names.%", "%.%" }, "{schema}.{object}", true, false, false, true, 1, null, "second-test-user", EntityActionOperation.Create)] + public void TestAutoEntitiesSerializationDeserialization( + string[]? include, + string[]? exclude, + string? name, + bool? restEnabled, + bool? graphqlEnabled, + bool? healthCheckEnabled, + bool? cacheEnabled, + int? cacheTTL, + EntityCacheLevel? cacheLevel, + string role, + EntityActionOperation entityActionOp) + { + TestHelper.SetupDatabaseEnvironment(MSSQL_ENVIRONMENT); + + Dictionary createdAutoentity = new(); + createdAutoentity.Add("test-entity", + new Autoentity( + Patterns: new AutoentityPatterns(include, exclude, name), + Template: new AutoentityTemplate( + Rest: restEnabled == null ? null : new EntityRestOptions(Enabled: (bool)restEnabled), + GraphQL: graphqlEnabled == null ? null : new EntityGraphQLOptions(Singular: string.Empty, Plural: string.Empty, Enabled: (bool)graphqlEnabled), + Health: healthCheckEnabled == null ? null : new EntityHealthCheckConfig(healthCheckEnabled), + Cache: (cacheEnabled == null && cacheTTL == null && cacheLevel == null) ? null : new EntityCacheOptions(Enabled: cacheEnabled, TtlSeconds: cacheTTL, Level: cacheLevel) + ), + Permissions: new EntityPermission[1])); + + EntityAction[] entityActions = new EntityAction[] { new(entityActionOp, null, null) }; + createdAutoentity["test-entity"].Permissions[0] = new EntityPermission(role, entityActions); + RuntimeAutoentities autoentities = new(createdAutoentity); + + FileSystemRuntimeConfigLoader baseLoader = TestHelper.GetRuntimeConfigLoader(); + baseLoader.TryLoadKnownConfig(out RuntimeConfig? baseConfig); + + RuntimeConfig config = new( + Schema: baseConfig!.Schema, + DataSource: baseConfig.DataSource, + Runtime: new( + Rest: new(), + GraphQL: new(), + Mcp: new(), + Host: new(null, null), + Telemetry: new() + ), + Entities: baseConfig.Entities, + Autoentities: autoentities + ); + + string configWithCustomJson = config.ToJson(); + Assert.IsTrue(RuntimeConfigLoader.TryParseConfig(configWithCustomJson, out RuntimeConfig? deserializedRuntimeConfig)); + + string serializedConfig = deserializedRuntimeConfig.ToJson(); + + using (JsonDocument parsedDocument = JsonDocument.Parse(serializedConfig)) + { + JsonElement root = parsedDocument.RootElement; + JsonElement autoentitiesElement = root.GetProperty("autoentities"); + + bool entityExists = autoentitiesElement.TryGetProperty("test-entity", out JsonElement entityElement); + Assert.AreEqual(expected: true, actual: entityExists); + + // Validate patterns properties and their values exists in autoentities + bool expectedPatternsExist = include != null || exclude != null || name != null; + bool patternsExists = entityElement.TryGetProperty("patterns", out JsonElement patternsElement); + Assert.AreEqual(expected: expectedPatternsExist, actual: patternsExists); + + if (patternsExists) + { + bool includeExists = patternsElement.TryGetProperty("include", out JsonElement includeElement); + Assert.AreEqual(expected: (include != null), actual: includeExists); + if (includeExists) + { + CollectionAssert.AreEqual(expected: include, actual: includeElement.EnumerateArray().Select(e => e.GetString()).ToArray()); + } + + bool excludeExists = patternsElement.TryGetProperty("exclude", out JsonElement excludeElement); + Assert.AreEqual(expected: (exclude != null), actual: excludeExists); + if (excludeExists) + { + CollectionAssert.AreEqual(expected: exclude, actual: excludeElement.EnumerateArray().Select(e => e.GetString()).ToArray()); + } + + bool nameExists = patternsElement.TryGetProperty("name", out JsonElement nameElement); + Assert.AreEqual(expected: (name != null), actual: nameExists); + if (nameExists) + { + Assert.AreEqual(expected: name, actual: nameElement.GetString()); + } + } + + // Validate template properties and their values exists in autoentities + bool expectedTemplateExist = restEnabled != null || graphqlEnabled != null || healthCheckEnabled != null + || cacheEnabled != null || cacheLevel != null || cacheTTL != null; + bool templateExists = entityElement.TryGetProperty("template", out JsonElement templateElement); + Assert.AreEqual(expected: expectedTemplateExist, actual: templateExists); + + if (templateExists) + { + bool restPropertyExists = templateElement.TryGetProperty("rest", out JsonElement restElement); + Assert.AreEqual(expected: (restEnabled != null), actual: restPropertyExists); + if (restPropertyExists) + { + Assert.IsTrue(restElement.TryGetProperty("enabled", out JsonElement restEnabledElement)); + Assert.AreEqual(expected: restEnabled, actual: restEnabledElement.GetBoolean()); + } + + bool graphqlPropertyExists = templateElement.TryGetProperty("graphql", out JsonElement graphqlElement); + Assert.AreEqual(expected: (graphqlEnabled != null), actual: graphqlPropertyExists); + if (graphqlPropertyExists) + { + Assert.IsTrue(graphqlElement.TryGetProperty("enabled", out JsonElement graphqlEnabledElement)); + Assert.AreEqual(expected: graphqlEnabled, actual: graphqlEnabledElement.GetBoolean()); + } + + bool healthPropertyExists = templateElement.TryGetProperty("health", out JsonElement healthElement); + Assert.AreEqual(expected: (healthCheckEnabled != null), actual: healthPropertyExists); + if (healthPropertyExists) + { + Assert.IsTrue(healthElement.TryGetProperty("enabled", out JsonElement healthEnabledElement)); + Assert.AreEqual(expected: healthCheckEnabled, actual: healthEnabledElement.GetBoolean()); + } + + bool expectedCacheExist = cacheEnabled != null || cacheTTL != null || cacheLevel != null; + bool cachePropertyExists = templateElement.TryGetProperty("cache", out JsonElement cacheElement); + Assert.AreEqual(expected: expectedCacheExist, actual: cachePropertyExists); + if (cacheEnabled != null) + { + Assert.IsTrue(cacheElement.TryGetProperty("enabled", out JsonElement cacheEnabledElement)); + Assert.AreEqual(expected: cacheEnabled, actual: cacheEnabledElement.GetBoolean()); + } + + if (cacheTTL != null) + { + Assert.IsTrue(cacheElement.TryGetProperty("ttl-seconds", out JsonElement cacheTtlElement)); + Assert.AreEqual(expected: cacheTTL, actual: cacheTtlElement.GetInt32()); + } + + if (cacheLevel != null) + { + Assert.IsTrue(cacheElement.TryGetProperty("level", out JsonElement cacheLevelElement)); + Assert.IsTrue(string.Equals(cacheLevel.ToString(), cacheLevelElement.GetString(), StringComparison.OrdinalIgnoreCase)); + } + } + + // Validate permissions properties and their values exists in autoentities + JsonElement permissionsElement = entityElement.GetProperty("permissions"); + + bool roleExists = permissionsElement[0].TryGetProperty("role", out JsonElement roleElement); + Assert.AreEqual(expected: true, actual: roleExists); + Assert.AreEqual(expected: role, actual: roleElement.GetString()); + + bool entityActionsExists = permissionsElement[0].TryGetProperty("actions", out JsonElement entityActionsElement); + Assert.AreEqual(expected: true, actual: entityActionsExists); + bool entityActionOpExists = entityActionsElement[0].TryGetProperty("action", out JsonElement entityActionOpElement); + Assert.AreEqual(expected: true, actual: entityActionOpExists); + Assert.IsTrue(string.Equals(entityActionOp.ToString(), entityActionOpElement.GetString(), StringComparison.OrdinalIgnoreCase)); + } + } + +#nullable disable + + /// + /// Helper method to create RuntimeConfig with specified Telemetry options + /// + private static RuntimeConfig InitializeRuntimeWithTelemetry(TelemetryOptions telemetryOptions) + { + TestHelper.SetupDatabaseEnvironment(MSSQL_ENVIRONMENT); + + FileSystemRuntimeConfigLoader baseLoader = TestHelper.GetRuntimeConfigLoader(); + baseLoader.TryLoadKnownConfig(out RuntimeConfig baseConfig); + + RuntimeConfig config = new( + Schema: baseConfig.Schema, + DataSource: baseConfig.DataSource, + Runtime: new( + Rest: new(), + GraphQL: new(), + Mcp: new(), + Host: new(null, null), + Telemetry: telemetryOptions + ), + Entities: baseConfig.Entities + ); + + return config; + } + + /// + /// Validates the OpenAPI documentor behavior when enabling and disabling the global REST endpoint + /// for the DAB engine. + /// Global REST enabled: + /// - GET to /openapi returns the created OpenAPI document and succeeds with 200 OK. + /// Global REST disabled: + /// - GET to /openapi fails with 404 Not Found. + /// + [DataTestMethod] + [DataRow(true, false, DisplayName = "Global REST endpoint enabled - successful OpenAPI doc retrieval")] + [DataRow(false, true, DisplayName = "Global REST endpoint disabled - OpenAPI doc does not exist - HTTP404 NotFound.")] + [TestCategory(TestCategory.MSSQL)] + public async Task OpenApi_GlobalEntityRestPath(bool globalRestEnabled, bool expectsError) + { + // At least one entity is required in the runtime config for the engine to start. + // Even though this entity is not under test, it must be supplied to the config + // file creation function. + Entity requiredEntity = new( + Source: new("books", EntitySourceType.Table, null, null), + Fields: null, + Rest: new(Enabled: false), + GraphQL: new("book", "books"), + Permissions: new[] { GetMinimalPermissionConfig(AuthorizationResolver.ROLE_ANONYMOUS) }, + Relationships: null, + Mappings: null); + + Dictionary entityMap = new() + { + { "Book", requiredEntity } + }; + + CreateCustomConfigFile(entityMap, globalRestEnabled); + + string[] args = new[] + { + $"--ConfigFileName={CUSTOM_CONFIG_FILENAME}" + }; + + using TestServer server = new(Program.CreateWebHostBuilder(args)); + using HttpClient client = server.CreateClient(); + // Setup and send GET request + HttpRequestMessage readOpenApiDocumentRequest = new(HttpMethod.Get, $"{RestRuntimeOptions.DEFAULT_PATH}/{OPENAPI_DOCUMENT_ENDPOINT}"); + HttpResponseMessage response = await client.SendAsync(readOpenApiDocumentRequest); + + // Validate response + if (expectsError) + { + Assert.AreEqual(HttpStatusCode.NotFound, response.StatusCode); + } + else + { + // Process response body + string responseBody = await response.Content.ReadAsStringAsync(); + Dictionary responseProperties = JsonSerializer.Deserialize>(responseBody); + + // Validate response body + Assert.AreEqual(HttpStatusCode.OK, response.StatusCode); + ValidateOpenApiDocTopLevelPropertiesExist(responseProperties); + } + } + + /// + /// Simulates a GET request to DAB's health check endpoint ('/') and validates the contents of the response. + /// The expected format of the response is: + /// { + /// "status": "Healthy", + /// "version": "0.12.0", + /// "appName": "dab_oss_0.12.0" + /// } + /// - the 'version' property format is 'major.minor.patch' + /// + [TestMethod] + [TestCategory(TestCategory.MSSQL)] + public async Task HealthEndpoint_ValidateContents() + { + // Arrange + // At least one entity is required in the runtime config for the engine to start. + // Even though this entity is not under test, it must be supplied enable successfull + // config file creation. + Entity requiredEntity = new( + Source: new("books", EntitySourceType.Table, null, null), + Fields: null, + Rest: new(Enabled: false), + GraphQL: new("book", "books"), + Permissions: new[] { GetMinimalPermissionConfig(AuthorizationResolver.ROLE_ANONYMOUS) }, + Relationships: null, + Mappings: null); + + Dictionary entityMap = new() + { + { "Book", requiredEntity } + }; + + CreateCustomConfigFile(entityMap, enableGlobalRest: true); + + string[] args = new[] + { + $"--ConfigFileName={CUSTOM_CONFIG_FILENAME}" + }; + + using TestServer server = new(Program.CreateWebHostBuilder(args)); + using HttpClient client = server.CreateClient(); + + // Setup and send GET request to root path. + HttpRequestMessage getHealthEndpointContents = new(HttpMethod.Get, $"/"); + + // Act - Exercise the health check endpoint code by requesting the health endpoint path '/'. + HttpResponseMessage response = await client.SendAsync(getHealthEndpointContents); + + // Assert - Process response body and validate contents. + // Validate HTTP return code. + string responseBody = await response.Content.ReadAsStringAsync(); + Dictionary responseProperties = JsonSerializer.Deserialize>(responseBody); + Assert.AreEqual(expected: HttpStatusCode.OK, actual: response.StatusCode, message: "Received unexpected HTTP code from health check endpoint."); + + HealthEndpointTests.ValidateBasicDetailsHealthCheckResponse(responseProperties); + } + + /// + /// Validates the behavior of the OpenApiDocumentor when the runtime config has entities with + /// REST endpoint enabled and disabled. + /// Enabled -> path should be created + /// Disabled -> path not created and is excluded from OpenApi document. + /// + [TestCategory(TestCategory.MSSQL)] + [TestMethod] + public async Task OpenApi_EntityLevelRestEndpoint() + { + // Create the entities under test. + Entity restEnabledEntity = new( + Source: new("books", EntitySourceType.Table, null, null), + Fields: null, + Rest: new(Enabled: true), + GraphQL: new("", "", false), + Permissions: new[] { GetMinimalPermissionConfig(AuthorizationResolver.ROLE_ANONYMOUS) }, + Relationships: null, + Mappings: null); + + Entity restDisabledEntity = new( + Source: new("publishers", EntitySourceType.Table, null, null), + Fields: null, + Rest: new(Enabled: false), + GraphQL: new("publisher", "publishers", true), + Permissions: new[] { GetMinimalPermissionConfig(AuthorizationResolver.ROLE_ANONYMOUS) }, + Relationships: null, + Mappings: null); + + Dictionary entityMap = new() + { + { "Book", restEnabledEntity }, + { "Publisher", restDisabledEntity } + }; + + CreateCustomConfigFile(entityMap, enableGlobalRest: true); + + string[] args = new[] + { + $"--ConfigFileName={CUSTOM_CONFIG_FILENAME}" + }; + + using TestServer server = new(Program.CreateWebHostBuilder(args)); + using HttpClient client = server.CreateClient(); + // Setup and send GET request + HttpRequestMessage readOpenApiDocumentRequest = new(HttpMethod.Get, $"{RestRuntimeOptions.DEFAULT_PATH}/{OpenApiDocumentor.OPENAPI_ROUTE}"); + HttpResponseMessage response = await client.SendAsync(readOpenApiDocumentRequest); + + // Parse response metadata + string responseBody = await response.Content.ReadAsStringAsync(); + Dictionary responseProperties = JsonSerializer.Deserialize>(responseBody); + + // Validate response metadata + ValidateOpenApiDocTopLevelPropertiesExist(responseProperties); + JsonElement pathsElement = responseProperties[OpenApiDocumentorConstants.TOPLEVELPROPERTY_PATHS]; + + // Validate that paths were created for the entity with REST enabled. + Assert.IsTrue(pathsElement.TryGetProperty("/Book", out _)); + Assert.IsTrue(pathsElement.TryGetProperty("/Book/id/{id}", out _)); + + // Validate that paths were not created for the entity with REST disabled. + Assert.IsFalse(pathsElement.TryGetProperty("/Publisher", out _)); + Assert.IsFalse(pathsElement.TryGetProperty("/Publisher/id/{id}", out _)); + + JsonElement componentsElement = responseProperties[OpenApiDocumentorConstants.TOPLEVELPROPERTY_COMPONENTS]; + Assert.IsTrue(componentsElement.TryGetProperty(OpenApiDocumentorConstants.PROPERTY_SCHEMAS, out JsonElement componentSchemasElement)); + // Validate that components were created for the entity with REST enabled. + Assert.IsTrue(componentSchemasElement.TryGetProperty("Book_NoPK", out _)); + Assert.IsTrue(componentSchemasElement.TryGetProperty("Book", out _)); + + // Validate that components were not created for the entity with REST disabled. + Assert.IsFalse(componentSchemasElement.TryGetProperty("Publisher_NoPK", out _)); + Assert.IsFalse(componentSchemasElement.TryGetProperty("Publisher", out _)); + } + + /// + /// This test validates that DAB properly creates and returns a nextLink with a single $after + /// query parameter when sending paging requests. + /// The first request initiates a paging workload, meaning the response is expected to have a nextLink. + /// The validation occurs after the second request which uses the previously acquired nextLink + /// This test ensures that the second request's response body contains the expected nextLink which: + /// - is base64 encoded and NOT URI escaped e.g. the trailing "==" are not URI escaped to "%3D%3D" + /// - is not the same as the first response's nextLink -> DAB is properly injecting a new $after query param + /// and updating the new nextLink + /// - does not contain a comma (,) indicating that the URI namevaluecollection tracking the query parameters + /// did not come across two $after query parameters. This addresses a customer raised issue where two $after + /// query parameters were returned by DAB. + /// + [DataTestMethod] + [DataRow(false, DisplayName = "NextLinkRelative is false")] + [DataRow(true, DisplayName = "NextLinkRelative is true")] + [TestCategory(TestCategory.MSSQL)] + public async Task ValidateNextLinkUsage(bool isNextLinkRelative) + { + // Arrange - Setup test server with entity that has >1 record so that results can be paged. + // A short cut to using an entity with >100 records is to just include the $first=1 filter + // as done in this test, so that paging behavior can be invoked. + + const string ENTITY_NAME = "Bookmark"; + + // At least one entity is required in the runtime config for the engine to start. + // Even though this entity is not under test, it must be supplied to the config + // file creation function. + Entity requiredEntity = new( + Source: new("bookmarks", EntitySourceType.Table, null, null), + Fields: null, + Rest: new(Enabled: true), + GraphQL: new(Singular: "", Plural: "", Enabled: false), + Permissions: new[] { GetMinimalPermissionConfig(AuthorizationResolver.ROLE_ANONYMOUS) }, + Relationships: null, + Mappings: null); + + Dictionary entityMap = new() + { + { ENTITY_NAME, requiredEntity } + }; + + PaginationOptions paginationOptions = null; + + if (isNextLinkRelative) + { + paginationOptions = new PaginationOptions + { + DefaultPageSize = 1, + MaxPageSize = 1, + UserProvidedDefaultPageSize = true, + UserProvidedMaxPageSize = true, + NextLinkRelative = true + }; + } + + CreateCustomConfigFile(entityMap, enableGlobalRest: true, paginationOptions: paginationOptions); + + string[] args = new[] + { + $"--ConfigFileName={CUSTOM_CONFIG_FILENAME}" + }; + + using TestServer server = new(Program.CreateWebHostBuilder(args)); + using HttpClient client = server.CreateClient(); + + // Setup and send GET request + HttpRequestMessage initialPaginationRequest = new(HttpMethod.Get, $"{RestRuntimeOptions.DEFAULT_PATH}/{ENTITY_NAME}?$first=1"); + HttpResponseMessage initialPaginationResponse = await client.SendAsync(initialPaginationRequest); + + // Process response body for first request and get the nextLink to use on subsequent request + // which represents what this test is validating. + string responseBody = await initialPaginationResponse.Content.ReadAsStringAsync(); + Dictionary responseProperties = JsonSerializer.Deserialize>(responseBody); + string nextLinkUri = responseProperties["nextLink"].ToString(); + + // Act - Submit request with nextLink uri as target and capture response + + HttpRequestMessage followNextLinkRequest = new(HttpMethod.Get, nextLinkUri); + HttpResponseMessage followNextLinkResponse = await client.SendAsync(followNextLinkRequest); + + // Assert + + Assert.AreEqual(HttpStatusCode.OK, followNextLinkResponse.StatusCode, message: "Expected request to succeed."); + + // Process the response body and inspect the "nextLink" property for expected contents. + string followNextLinkResponseBody = await followNextLinkResponse.Content.ReadAsStringAsync(); + Dictionary followNextLinkResponseProperties = JsonSerializer.Deserialize>(followNextLinkResponseBody); + + string followUpResponseNextLink = followNextLinkResponseProperties["nextLink"].ToString(); + + // Build the Uri from nextLink string for query parsing. + // If relative, combine with base; if absolute, use as is. + Uri nextLink = null; + if (Uri.IsWellFormedUriString(followUpResponseNextLink, UriKind.Absolute)) + { + nextLink = new(followUpResponseNextLink, UriKind.Absolute); + } + else if (Uri.IsWellFormedUriString(followUpResponseNextLink, UriKind.Relative)) + { + nextLink = new(new("http://localhost:5000"), followUpResponseNextLink); + } + else + { + Assert.Fail($"Invalid nextLink URI format: {followUpResponseNextLink}"); + } + + NameValueCollection parsedQueryParameters = HttpUtility.ParseQueryString(query: nextLink.Query); + Assert.AreEqual(expected: false, actual: parsedQueryParameters["$after"].Contains(','), message: "nextLink erroneously contained two $after query parameters that were joined by HttpUtility.ParseQueryString(queryString)."); + Assert.AreNotEqual(notExpected: nextLinkUri, actual: followUpResponseNextLink, message: "The follow up request erroneously returned the same nextLink value."); + + // Do not use SqlPaginationUtils.Base64Encode()/Decode() here to eliminate test dependency on engine code to perform an assert. + try + { + Convert.FromBase64String(parsedQueryParameters["$after"]); + } + catch (FormatException) + { + Assert.Fail(message: "$after query parameter was not a valid base64 encoded value."); + } + + // Validate nextLink is relative if nextLinkRelative is true or false otherwise. + // The assertion is now done directly on the original string, not on the parsed Uri object. + if (isNextLinkRelative) + { + // The server returned a relative URL, so it should NOT start with http/https + Assert.IsFalse(Uri.IsWellFormedUriString(followUpResponseNextLink, UriKind.Absolute), + $"nextLink was expected to be relative but was absolute: {followUpResponseNextLink}"); + Assert.IsTrue(followUpResponseNextLink.StartsWith("/"), + $"nextLink was expected to start with '/' (relative), got: {followUpResponseNextLink}"); + } + else + { + Assert.IsTrue(Uri.IsWellFormedUriString(followUpResponseNextLink, UriKind.Absolute), + $"nextLink was expected to be absolute but was relative: {followUpResponseNextLink}"); + Assert.IsTrue(followUpResponseNextLink.StartsWith("http"), + $"nextLink was expected to start with http/https, got: {followUpResponseNextLink}"); + } + } + + /// + /// Validates X-Forwarded headers for nextLink in Pagination + /// + /// The X-Forwarded-Host value + /// The X-Forwarded-Proto value + [DataTestMethod] + [DataRow("localhost:5000", "http", DisplayName = "Forwarded Host and HTTP Protocol")] + [DataRow("myhost.com", "https", DisplayName = "Forwarded Host and HTTPS Protocol")] + [TestCategory(TestCategory.MSSQL)] + public async Task ValidateNextLinkRespectsXForwardedHostAndProto(string forwardedHost, string forwardedProto) + { + // Arrange - Setup test server with entity that has >1 record so that results can be paged. + const string ENTITY_NAME = "Bookmark"; + + Entity requiredEntity = new( + Source: new("bookmarks", EntitySourceType.Table, null, null), + Fields: null, + Rest: new(Enabled: true), + GraphQL: new(Singular: "", Plural: "", Enabled: false), + Permissions: new[] { GetMinimalPermissionConfig(AuthorizationResolver.ROLE_ANONYMOUS) }, + Relationships: null, + Mappings: null); + + Dictionary entityMap = new() + { + { ENTITY_NAME, requiredEntity } + }; + + PaginationOptions paginationOptions = new() + { + DefaultPageSize = 1, + MaxPageSize = 1, + UserProvidedDefaultPageSize = true, + UserProvidedMaxPageSize = true, + NextLinkRelative = false // Absolute nextLink required for this test + }; + + CreateCustomConfigFile(entityMap, enableGlobalRest: true, paginationOptions: paginationOptions); + + string[] args = new[] + { + $"--ConfigFileName={CUSTOM_CONFIG_FILENAME}" + }; + + using TestServer server = new(Program.CreateWebHostBuilder(args)); + using HttpClient client = server.CreateClient(); + + // Setup and send GET request with X-Forwarded-* headers + HttpRequestMessage initialPaginationRequest = new(HttpMethod.Get, $"{RestRuntimeOptions.DEFAULT_PATH}/{ENTITY_NAME}?$first=1"); + initialPaginationRequest.Headers.Add("X-Forwarded-Host", forwardedHost); + initialPaginationRequest.Headers.Add("X-Forwarded-Proto", forwardedProto); + + HttpResponseMessage initialPaginationResponse = await client.SendAsync(initialPaginationRequest); + + // Assert + Assert.AreEqual(HttpStatusCode.OK, initialPaginationResponse.StatusCode, message: "Expected request to succeed."); + + // Process response body and get nextLink + string responseBody = await initialPaginationResponse.Content.ReadAsStringAsync(); + Dictionary responseProperties = JsonSerializer.Deserialize>(responseBody); + string nextLinkUri = responseProperties.ContainsKey("nextLink") ? responseProperties["nextLink"].ToString() : null; + + Assert.IsNotNull(nextLinkUri, "nextLink missing in initial response."); + + // Assert that nextLink uses the forwarded host and proto + Uri nextLink = new(nextLinkUri, UriKind.Absolute); + + // Split host/port if present + string expectedHost; + int expectedPort = -1; + string[] hostParts = forwardedHost.Split(':'); + + if (hostParts.Length == 2 && int.TryParse(hostParts[1], out int port)) + { + expectedHost = hostParts[0]; + expectedPort = port; + } + else + { + expectedHost = forwardedHost; + } + + Assert.AreEqual(forwardedProto, nextLink.Scheme, $"nextLink scheme should be '{forwardedProto}' but was '{nextLink.Scheme}'"); + Assert.AreEqual(expectedHost, nextLink.Host, $"nextLink host should be '{expectedHost}' but was '{nextLink.Host}'"); + + if (expectedPort != -1) + { + Assert.AreEqual(expectedPort, nextLink.Port, $"nextLink port should be '{expectedPort}' but was '{nextLink.Port}'"); + } + } + + /// + /// 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 + /// } + /// + /// The maximum allowed depth for GraphQL queries and mutations. + /// Indicates whether the operation is a mutation or a query. + /// The expected HTTP status code for the operation. + [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(), mcpOptions: 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(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."); + } + } + } + /// - /// Validates that DAB supplements the CosmosDB database connection strings with the property "Database" and - /// 1. Adds the property/value "Database=config_db" when the env var COSMOSDB_DATABASE_NAME is not set. - /// 2. Adds the property/value "Database=dab_hosted_Major.Minor.Patch" when the env var COSMOSDB_DATABASE_NAME is set to "dab_hosted". - /// (COSMOSDB_DATABASE_NAME is set in hosted scenario or when user sets the value.) - /// NOTE: "#pragma warning disable format" is used here to avoid removing intentional, readability promoting spacing in DataRow display names. + /// 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 + /// } + /// } + /// } + /// } /// - /// connection string provided in the config. - /// Updated connection string with Database. - /// Whether COSMOSDB_DATABASE_NAME is set in environment. (Always present in hosted scenario or if user supplies value.) - #pragma warning disable format + [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(), mcpOptions: 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(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."); + } + } + + /// + /// + /// + /// + /// + /// + [TestCategory(TestCategory.MSSQL)] [DataTestMethod] - [DataRow("AccountEndpoint=https://localhost:8081/;", "AccountEndpoint=https://localhost:8081/;Database=config_db", false, DisplayName = "[CosmosDB]: DAB adds version 'dab_oss_major_minor_patch' to non-provided connection string property 'Database'.")] - [DataRow("AccountEndpoint=https://localhost:8081/;Database=CustDbName", "AccountEndpoint=https://localhost:8081/;Database=CustDbName", false, DisplayName = "[CosmosDB]: DAB appends version 'dab_oss_major_minor_patch' to user supplied 'Database' property.")] - [DataRow("AccountEndpoint=https://localhost:8081/;App=CustDbName" , "AccountEndpoint=https://localhost:8081/;Database=CustDbName", false, DisplayName = "[CosmosDB]: DAB appends version 'dab_oss_major_minor_patch' to user supplied 'App' property and resolves property to 'Database'.")] - [DataRow("AccountEndpoint=https://localhost:8081/;", "AccountEndpoint=https://localhost:8081/;Database=dab_hosted", true , DisplayName = "[CosmosDB]: DAB adds COSMOSDB_DATABASE_NAME value 'dab_hosted' and version suffix '_major_minor_patch' to non-provided connection string property 'Database'.")] - [DataRow("AccountEndpoint=https://localhost:8081/;Database=CustDbName", "AccountEndpoint=https://localhost:8081/;Database=CustDbName", true , DisplayName = "[CosmosDB]: DAB appends COSMOSDB_DATABASE_NAME value 'dab_hosted' and version suffix '_major_minor_patch' to user supplied 'Database' property.")] - [DataRow("AccountEndpoint=https://localhost:8081/;App=CustDbName" , "AccountEndpoint=https://localhost:8081/;Database=CustDbName", true , DisplayName = "[CosmosDB]: DAB appends version string 'dab_hosted' and version suffix '_major_minor_patch' to user supplied 'App' property and resolves property to 'Database'.")] - #pragma warning restore format - public void CosmosDbConnStringSupplementedWithDbProperty( - string configProvidedConnString, - string expectedDabModifiedConnString, - bool cosmosDbEnvOverride) + [DataRow(true, 4, DisplayName = "Test Autoentities with additional entities")] + [DataRow(false, 2, DisplayName = "Test Autoentities without additional entities")] + public async Task TestAutoentitiesAreGeneratedIntoEntities(bool useEntities, int expectedEntityCount) { - // Explicitly set the COSMOSDB_DATABASE_NAME to null to ensure that the COSMOSDB_DATABASE_NAME is not set. - if (cosmosDbEnvOverride) + // Arrange + EntityRelationship bookRelationship = new(Cardinality: Cardinality.One, + TargetEntity: "BookPublisher", + SourceFields: new string[] { }, + TargetFields: new string[] { }, + LinkingObject: null, + LinkingSourceFields: null, + LinkingTargetFields: null); + + Entity bookEntity = new(Source: new("books", EntitySourceType.Table, null, null), + Fields: null, + Rest: null, + GraphQL: new(Singular: "book", Plural: "books"), + Permissions: new[] { GetMinimalPermissionConfig(AuthorizationResolver.ROLE_ANONYMOUS) }, + Relationships: new Dictionary() { { "publishers", bookRelationship } }, + Mappings: null); + + EntityRelationship publisherRelationship = new(Cardinality: Cardinality.Many, + TargetEntity: "Book", + SourceFields: new string[] { }, + TargetFields: new string[] { }, + LinkingObject: null, + LinkingSourceFields: null, + LinkingTargetFields: null); + + Entity publisherEntity = new( + Source: new("publishers", EntitySourceType.Table, null, null), + Fields: null, + Rest: null, + GraphQL: new(Singular: "bookpublisher", Plural: "bookpublishers"), + Permissions: new[] { GetMinimalPermissionConfig(AuthorizationResolver.ROLE_ANONYMOUS) }, + Relationships: new Dictionary() { { "books", publisherRelationship } }, + Mappings: null); + + Dictionary entityMap = new() + { + { "Book", bookEntity }, + { "BookPublisher", publisherEntity } + }; + + Dictionary autoentityMap = new() + { + { + "PublisherAutoEntity", new Autoentity( + Patterns: new AutoentityPatterns( + Include: new[] { "%publishers%" }, + Exclude: null, + Name: null + ), + Template: new AutoentityTemplate( + Rest: new EntityRestOptions(Enabled: true), + GraphQL: new EntityGraphQLOptions( + Singular: string.Empty, + Plural: string.Empty, + Enabled: true + ), + Health: null, + Cache: null + ), + Permissions: new[] { GetMinimalPermissionConfig(AuthorizationResolver.ROLE_ANONYMOUS) } + ) + } + }; + + // Create DataSource for MSSQL connection + DataSource dataSource = new(DatabaseType.MSSQL, + GetConnectionStringFromEnvironmentConfig(environment: TestCategory.MSSQL), Options: null); + + // Build complete runtime configuration with autoentities + RuntimeConfig configuration = new( + Schema: "TestAutoentitiesSchema", + DataSource: dataSource, + Runtime: new( + Rest: new(Enabled: true), + GraphQL: new(Enabled: true), + Mcp: new(Enabled: false), + Host: new( + Cors: null, + Authentication: new Config.ObjectModel.AuthenticationOptions( + Provider: nameof(EasyAuthType.StaticWebApps), + Jwt: null + ) + ) + ), + Entities: new(useEntities ? entityMap : new Dictionary()), + Autoentities: new RuntimeAutoentities(autoentityMap) + ); + + File.WriteAllText(CUSTOM_CONFIG_FILENAME, configuration.ToJson()); + + string[] args = new[] { $"--ConfigFileName={CUSTOM_CONFIG_FILENAME}" }; + + using (TestServer server = new(Program.CreateWebHostBuilder(args))) + using (HttpClient client = server.CreateClient()) { - Environment.SetEnvironmentVariable(ProductInfo.COSMOSDB_DATABASE_NAME, "dab_hosted"); + // Act + RuntimeConfigProvider configProvider = server.Services.GetService(); + using HttpRequestMessage restRequest = new(HttpMethod.Get, "/api/publishers"); + using HttpResponseMessage restResponse = await client.SendAsync(restRequest); + + string graphqlQuery = @" + { + publishers { + items { + id + name + } + } + }"; + + object graphqlPayload = new { query = graphqlQuery }; + HttpRequestMessage graphqlRequest = new(HttpMethod.Post, "/graphql") + { + Content = JsonContent.Create(graphqlPayload) + }; + HttpResponseMessage graphqlResponse = await client.SendAsync(graphqlRequest); + + // Assert + string expectedResponseFragment = @"{""id"":1156,""name"":""The First Publisher""}"; + + // Verify number of entities + Assert.AreEqual(expectedEntityCount, configProvider.GetConfig().Entities.Entities.Count, "Number of generated entities is not what is expected"); + + // Verify REST response + Assert.AreEqual(HttpStatusCode.OK, restResponse.StatusCode, "REST request to auto-generated entity should succeed"); + + string restResponseBody = await restResponse.Content.ReadAsStringAsync(); + Assert.IsTrue(!string.IsNullOrEmpty(restResponseBody), "REST response should contain data"); + Assert.IsTrue(restResponseBody.Contains(expectedResponseFragment)); + + // Verify GraphQL response + Assert.AreEqual(HttpStatusCode.OK, graphqlResponse.StatusCode, "GraphQL request to auto-generated entity should succeed"); + + string graphqlResponseBody = await graphqlResponse.Content.ReadAsStringAsync(); + Assert.IsTrue(!string.IsNullOrEmpty(graphqlResponseBody), "GraphQL response should contain data"); + Assert.IsFalse(graphqlResponseBody.Contains("errors"), "GraphQL response should not contain errors"); + Assert.IsTrue(graphqlResponseBody.Contains(expectedResponseFragment)); } - else + } + + /// + /// + /// + /// + /// + /// + /// + /// + /// + [TestCategory(TestCategory.MSSQL)] + [DataTestMethod] + [DataRow("publishers", "uniqueSingularPublisher", "uniquePluralPublishers", "/unique/publisher", "Entity with name 'publishers' already exists. Cannot create new entity from autoentity pattern with definition-name 'PublisherAutoEntity'.", DisplayName = "Autoentities fail due to entity name")] + [DataRow("UniquePublisher", "publishers", "uniquePluralPublishers", "/unique/publisher", "Entity publishers generates queries/mutation that already exist", DisplayName = "Autoentities fail due to graphql singular type")] + [DataRow("UniquePublisher", "uniqueSingularPublisher", "publishers", "/unique/publisher", "Entity publishers generates queries/mutation that already exist", DisplayName = "Autoentities fail due to graphql plural type")] + [DataRow("UniquePublisher", "uniqueSingularPublisher", "uniquePluralPublishers", "/publishers", "The rest path: publishers specified for entity: publishers is already used by another entity.", DisplayName = "Autoentities fail due to rest path")] + public async Task ValidateAutoentityGenerationConflicts(string entityName, string singular, string plural, string path, string exceptionMessage) + { + // Arrange + Entity publisherEntity = new( + Source: new("publishers", EntitySourceType.Table, null, null), + Fields: null, + Rest: new(Path: path), + GraphQL: new(Singular: singular, Plural: plural), + Permissions: new[] { GetMinimalPermissionConfig(AuthorizationResolver.ROLE_ANONYMOUS) }, + Relationships: null, + Mappings: null); + + Dictionary entityMap = new() + { + { entityName, publisherEntity } + }; + + Dictionary autoentityMap = new() + { + { + "PublisherAutoEntity", new Autoentity( + Patterns: new AutoentityPatterns( + Include: new[] { "%publishers%" }, + Exclude: null, + Name: null + ), + Template: new AutoentityTemplate( + Rest: new EntityRestOptions( + Enabled: true), + GraphQL: new EntityGraphQLOptions( + Singular: string.Empty, + Plural: string.Empty, + Enabled: true + ), + Health: null, + Cache: null + ), + Permissions: new[] { GetMinimalPermissionConfig(AuthorizationResolver.ROLE_ANONYMOUS) } + ) + } + }; + + // Create DataSource for MSSQL connection + DataSource dataSource = new(DatabaseType.MSSQL, + GetConnectionStringFromEnvironmentConfig(environment: TestCategory.MSSQL), Options: null); + + // Build complete runtime configuration with autoentities + RuntimeConfig configuration = new( + Schema: "TestAutoentitiesSchema", + DataSource: dataSource, + Runtime: new( + Rest: new(Enabled: true), + GraphQL: new(Enabled: true), + Mcp: new(Enabled: false), + Host: new( + Mode: HostMode.Development, + Cors: null, + Authentication: new Config.ObjectModel.AuthenticationOptions( + Provider: nameof(EasyAuthType.StaticWebApps), + Jwt: null + ) + ) + ), + Entities: new(entityMap), + Autoentities: new RuntimeAutoentities(autoentityMap) + ); + + File.WriteAllText(CUSTOM_CONFIG_FILENAME, configuration.ToJson()); + + ILoggerFactory loggerFactory = new LoggerFactory(); + IFileSystem fileSystem = new FileSystem(); + + FileSystemRuntimeConfigLoader configLoader = new(fileSystem) + { + RuntimeConfig = configuration + }; + + RuntimeConfigProvider configProvider = new(configLoader); + + RuntimeConfigValidator configValidator = new(configProvider, fileSystem, loggerFactory.CreateLogger()); + + QueryManagerFactory queryManagerFactory = new( + runtimeConfigProvider: configProvider, + logger: loggerFactory.CreateLogger(), + contextAccessor: null!, + handler: null); + + MsSqlMetadataProvider provider = new( + configProvider, + configValidator, + queryManagerFactory, + loggerFactory.CreateLogger(), + configLoader.RuntimeConfig.DefaultDataSourceName, + false); + + try { - Environment.SetEnvironmentVariable(ProductInfo.COSMOSDB_DATABASE_NAME, null); + await provider.InitializeAsync(); + Assert.Fail("It is expected for DAB to fail due to entities not containing unique parameters."); } + catch (DataApiBuilderException ex) + { + Assert.AreEqual(exceptionMessage, ex.Message); + } + } - // Resolve assembly version. Not possible to do in DataRow as DataRows expect compile-time constants. - string resolvedAssemblyVersion = ProductInfo.GetDataApiBuilderUserAgent(); - expectedDabModifiedConnString += resolvedAssemblyVersion; + /// + /// Validates the autoentity configuration inside the configuration file and also + /// validates that entities created from the autoentity configuration do not generate + /// duplicate entities and paths for REST and GraphQL. + /// + /// + [TestCategory(TestCategory.MSSQL)] + [TestMethod] + public async Task ValidateAutoentitiesConfiguration() + { + EntityAction entityAction = new(EntityActionOperation.Read, null, null); - RuntimeConfig runtimeConfig = CreateBasicRuntimeConfigWithNoEntity(DatabaseType.CosmosDB_NoSQL, configProvidedConnString); - bool configParsed = RuntimeConfigLoader.TryParseConfig( - json: runtimeConfig.ToJson(), - config: out RuntimeConfig updatedRuntimeConfig, - replacementSettings: new(doReplaceEnvVar: true)); + Dictionary autoentityMap = new(); + string autoentityName = "AutoentityA"; - // Assert - Assert.AreEqual( - expected: true, - actual: configParsed, - message: "Runtime config unexpectedly failed parsing."); - Assert.AreEqual( - expected: expectedDabModifiedConnString, - actual: updatedRuntimeConfig.DataSource.ConnectionString, - message: "DAB did not properly set the 'Database' connection string property."); + Autoentity autoentity = new( + Patterns: new AutoentityPatterns( + Include: new[] { "%patterns%" }, + Exclude: new[] { "%books%" }, + Name: "{object}"), + Template: new AutoentityTemplate( + Rest: new(Enabled: false), + GraphQL: new(Enabled: true, Singular: string.Empty, Plural: string.Empty), + Health: new(enabled: true), + Cache: new(Enabled: true, TtlSeconds: 50)), + Permissions: new EntityPermission[] { new("anonymous", new EntityAction[] { entityAction }) }); + + autoentityMap.Add(autoentityName, autoentity); + + DataSource dataSource = new(DatabaseType.MSSQL, + GetConnectionStringFromEnvironmentConfig(environment: TestCategory.MSSQL), Options: null); + + RuntimeConfig runtimeConfig = new( + Schema: "TestAutoentitiesSchema", + DataSource: dataSource, + Runtime: new( + Rest: new(), + GraphQL: new(), + Mcp: new(), + Host: new(null, null, HostMode.Development)), + Entities: new(new Dictionary()), + Autoentities: new(autoentityMap)); + + const string CUSTOM_CONFIG = "autoentities-validation-config.json"; + + File.WriteAllText(CUSTOM_CONFIG, runtimeConfig.ToJson()); + IFileSystem fileSystem = new FileSystem(); + + FileSystemRuntimeConfigLoader loader = new(fileSystem) + { + RuntimeConfig = runtimeConfig + }; + + RuntimeConfigProvider provider = new(loader); + Mock> loggerMock = new(); + RuntimeConfigValidator configValidator = new(provider, fileSystem, loggerMock.Object); + + try + { + await configValidator.TryValidateConfig(CUSTOM_CONFIG, TestHelper.ProvisionLoggerFactory()); + } + catch (Exception ex) + { + Assert.Fail(ex.Message); + } } /// - /// Invalidates the config if the required datasource property is missing. - /// Validates that an appropriate error message is returned in the response. + /// 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 + /// } + /// } /// + /// [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 TestMissingDataSourceInConfig() + public async Task TestNoDepthLimitOnGrahQLInNonHostedMode(int? depthLimit) { - string configMissingDataSource = @"{ - ""$schema"":""https://github.com/Azure/data-api-builder/releases/download/vmajor.minor.patch-alpha/dab.draft.schema.json"", - ""runtime"": { - ""rest"": { - ""enabled"": true, - ""path"": ""/api"" - }, - ""graphql"": { - ""enabled"": true, - ""path"": ""/graphql"", - ""allow-introspection"": true - }, - ""host"": { - ""cors"": { - ""origins"": [ - ""http://localhost:5000"" - ], - ""allow-credentials"": false - } - } - }, - ""entities"": { - ""Book"": { - ""source"": { - ""object"": ""books"", - ""type"": ""table"" - }, - ""graphql"": { - ""enabled"": true, - ""type"": { - ""singular"": ""book"", - ""plural"": ""books"" - } - }, - ""permissions"": [ - { - ""role"": ""anonymous"", - ""actions"": [ - { - ""action"": ""read"" + // 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(), mcpOptions: 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 } - ] - } - ], - ""mappings"": null, - ""relationships"": null + }"; + + 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(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."); + } + } + + /// + /// Helper function to write custom configuration file. with minimal REST/GraphQL global settings + /// using the supplied entities. + /// + /// Collection of entityName -> Entity object. + /// flag to enable or disabled REST globally. + /// Optional pagination options to use in the runtime config. + private static void CreateCustomConfigFile(Dictionary entityMap, bool enableGlobalRest = true, PaginationOptions paginationOptions = null) + { + DataSource dataSource = new( + DatabaseType.MSSQL, + GetConnectionStringFromEnvironmentConfig(environment: TestCategory.MSSQL), + Options: null); + + HostOptions hostOptions = new(Cors: null, Authentication: new() { Provider = nameof(EasyAuthType.StaticWebApps) }); + + RuntimeOptions runtime = paginationOptions != null + ? new( + Rest: new(Enabled: enableGlobalRest), + GraphQL: new(Enabled: true), + Mcp: new(Enabled: true), + Host: hostOptions, + Pagination: paginationOptions) + : new( + Rest: new(Enabled: enableGlobalRest), + GraphQL: new(Enabled: true), + Mcp: new(Enabled: true), + Host: hostOptions); + + RuntimeConfig runtimeConfig = new( + Schema: string.Empty, + DataSource: dataSource, + Runtime: runtime, + Entities: new(entityMap)); + + File.WriteAllText( + path: CUSTOM_CONFIG_FILENAME, + contents: runtimeConfig.ToJson()); + } + + /// + /// Validates that all the OpenAPI description document's top level properties exist. + /// A failure here indicates that there was an undetected failure creating the OpenAPI document. + /// + /// Represent a deserialized JSON result from retrieving the OpenAPI document + private static void ValidateOpenApiDocTopLevelPropertiesExist(Dictionary responseProperties) + { + Assert.IsTrue(responseProperties.ContainsKey(OpenApiDocumentorConstants.TOPLEVELPROPERTY_OPENAPI)); + Assert.IsTrue(responseProperties.ContainsKey(OpenApiDocumentorConstants.TOPLEVELPROPERTY_INFO)); + Assert.IsTrue(responseProperties.ContainsKey(OpenApiDocumentorConstants.TOPLEVELPROPERTY_SERVERS)); + Assert.IsTrue(responseProperties.ContainsKey(OpenApiDocumentorConstants.TOPLEVELPROPERTY_PATHS)); + Assert.IsTrue(responseProperties.ContainsKey(OpenApiDocumentorConstants.TOPLEVELPROPERTY_COMPONENTS)); + } + + /// + /// Validates that schema introspection requests fail when allow-introspection is false in the runtime configuration. + /// + /// + private static async Task ExecuteGraphQLIntrospectionQueries(TestServer server, HttpClient client, bool expectError) + { + string graphQLQueryName = "__schema"; + string graphQLQuery = @"{ + __schema { + types { + name } } }"; - string configWithInvalidDataSource = TestHelper.AddPropertiesToJson(TestHelper.BASE_CONFIG, configMissingDataSource); + string expectedErrorMessageFragment = "Introspection is not allowed for the current request."; - // Only need to check if exception is thrown. The exact message/content is not important. try { - RuntimeConfigLoader.TryParseConfig(configWithInvalidDataSource, out RuntimeConfig runtimeConfig, replacementSettings: new()); + RuntimeConfigProvider configProvider = server.Services.GetRequiredService(); + + JsonElement actual = await GraphQLRequestExecutor.PostGraphQLRequestAsync( + client, + configProvider, + query: graphQLQuery, + queryName: graphQLQueryName, + variables: null, + clientRoleHeader: null + ); + + if (expectError) + { + SqlTestHelper.TestForErrorInGraphQLResponse( + response: actual.ToString(), + message: expectedErrorMessageFragment, + statusCode: ErrorCodes.Validation.IntrospectionNotAllowed + ); + } + } + catch (Exception ex) + { + // ExecuteGraphQLRequestAsync will raise an exception when no "data" key + // exists in the GraphQL JSON response. + Assert.Fail(message: "No schema metadata in GraphQL response." + ex.Message); + } + } + + private static JsonContent GetJsonContentForCosmosConfigRequest(string endpoint, string config = null, bool useAccessToken = false) + { + if (CONFIGURATION_ENDPOINT == endpoint) + { + ConfigurationPostParameters configParams = GetCosmosConfigurationParameters(); + if (config is not null) + { + configParams = configParams with { Configuration = config }; + } + + if (useAccessToken) + { + configParams = configParams with + { + ConnectionString = "AccountEndpoint=https://localhost:8081/;", + AccessToken = GenerateMockJwtToken() + }; + } + + return JsonContent.Create(configParams); + } + else if (CONFIGURATION_ENDPOINT_V2 == endpoint) + { + ConfigurationPostParametersV2 configParams = GetCosmosConfigurationParametersV2(); + if (config != null) + { + configParams = configParams with { Configuration = config }; + } + + if (useAccessToken) + { + // With an invalid access token, when a new instance of CosmosClient is created with that token, it + // won't throw an exception. But when a graphql request is coming in, that's when it throws a 401 + // exception. To prevent this, CosmosClientProvider parses the token and retrieves the "exp" property + // from the token, if it's not valid, then we will throw an exception from our code before it + // initiating a client. Uses a valid fake JWT access token for testing purposes. + RuntimeConfig overrides = new( + Schema: null, + DataSource: new DataSource(DatabaseType.CosmosDB_NoSQL, "AccountEndpoint=https://localhost:8081/;", new()), + Runtime: null, + Entities: new(new Dictionary())); + + configParams = configParams with + { + ConfigurationOverrides = overrides.ToJson(), + AccessToken = GenerateMockJwtToken() + }; + } + + return JsonContent.Create(configParams); + } + else + { + throw new ArgumentException($"Unexpected configuration endpoint. {endpoint}"); } - catch (Exception e) + } + + private static string GenerateMockJwtToken() + { + string mySecret = "PlaceholderPlaceholderPlaceholder"; + SymmetricSecurityKey mySecurityKey = new(Encoding.ASCII.GetBytes(mySecret)); + + JwtSecurityTokenHandler tokenHandler = new(); + SecurityTokenDescriptor tokenDescriptor = new() { - Assert.AreEqual("The following required properties are missing from the configuration: data-source.", e.Message); - Assert.AreEqual(HttpStatusCode.ServiceUnavailable, (e as DataApiBuilderException).StatusCode); - Assert.AreEqual(DataApiBuilderException.SubStatusCodes.ConfigValidationError, (e as DataApiBuilderException).SubStatusCode); - return; - } + Subject = new ClaimsIdentity(new Claim[] { }), + Expires = DateTime.UtcNow.AddMinutes(5), + Issuer = "http://mysite.com", + Audience = "http://myaudience.com", + SigningCredentials = new SigningCredentials(mySecurityKey, SecurityAlgorithms.HmacSha256Signature) + }; + + SecurityToken token = tokenHandler.CreateToken(tokenDescriptor); + return tokenHandler.WriteToken(token); + } + + private static ConfigurationPostParameters GetCosmosConfigurationParameters() + { + RuntimeConfig configuration = ReadCosmosConfigurationFromFile(); + return new( + configuration.ToJson(), + File.ReadAllText("schema.gql"), + $"AccountEndpoint=https://localhost:8081/;AccountKey=C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw==;Database={COSMOS_DATABASE_NAME}", + AccessToken: null); + } - Assert.Fail("Config with missing data-source did not result in an exception."); + private static ConfigurationPostParametersV2 GetCosmosConfigurationParametersV2() + { + RuntimeConfig configuration = ReadCosmosConfigurationFromFile(); + RuntimeConfig overrides = new( + Schema: null, + DataSource: new DataSource(DatabaseType.CosmosDB_NoSQL, $"AccountEndpoint=https://localhost:8081/;AccountKey=C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw==;Database={COSMOS_DATABASE_NAME}", new()), + Runtime: null, + Entities: new(new Dictionary())); + + return new( + configuration.ToJson(), + overrides.ToJson(), + File.ReadAllText("schema.gql"), + AccessToken: null); } /// - /// Validates that the config file with an invalid JSON schema returns a 400 Bad Request response. - /// The test confirms that the error is detected and reported by the configuration loader. - /// And also tests that a valid subsequent configuration can be applied after a bad config. + /// Helper used to create the post-startup configuration payload sent to configuration controller. + /// Adds entity used to hydrate authorization resolver post-startup and validate that hydration succeeds. + /// Additional pre-processing performed acquire database connection string from a local file. /// - [DataTestMethod] - [TestCategory(TestCategory.MSSQL)] - public async Task TestInvalidConfigFileSchema() + /// ConfigurationPostParameters object. + private static JsonContent GetPostStartupConfigParams(string environment, RuntimeConfig runtimeConfig, string configurationEndpoint) { - string badConfig = @"{ - ""$schema"":""https://github.com/Azure/data-api-builder/releases/download/vmajor.minor.patch-alpha/dab.draft.schema.json"", - ""data-source"": { - ""database-type"": ""mssql"", - ""connection-string"": ""sample-conn-string"" - }, - ""runtime"": { - ""rest"": { - ""enabled"": true, - ""path"": ""/api"" - }, - ""graphql"": { - ""enabled"": true, - ""path"": ""/graphql"", - ""allow-introspection"": true - }, - ""host"": { - ""cors"": { - ""origins"": [ - ""http://localhost:5000"" - ], - ""allow-credentials"": false - }, - ""authentication"": { - ""provider"": ""AppService"" - }, - ""mode"": ""development"" - } - }, - ""entities"": { } - }"; + string connectionString = GetConnectionStringFromEnvironmentConfig(environment); - // Use a bad config with an extra comma before closing brace '}' to invalidate the JSON. - string configWithInvalidJsonSchema = TestHelper.AddPropertiesToJson(TestHelper.BASE_CONFIG, badConfig); - try + string serializedConfiguration = runtimeConfig.ToJson(); + + if (configurationEndpoint == CONFIGURATION_ENDPOINT) { - RuntimeConfigLoader.TryParseConfig(configWithInvalidJsonSchema, out RuntimeConfig runtimeConfig, replacementSettings: new()); - Assert.Fail("Config with invalid JSON schema did not result in an exception."); + ConfigurationPostParameters returnParams = new( + Configuration: serializedConfiguration, + Schema: null, + ConnectionString: connectionString, + AccessToken: null); + return JsonContent.Create(returnParams); } - catch (JsonException jsonEx) + else if (configurationEndpoint == CONFIGURATION_ENDPOINT_V2) { - // Expected exception - Assert.IsTrue(jsonEx.Message.Contains("Line 17, column 8"), jsonEx.Message); - } + RuntimeConfig overrides = new( + Schema: null, + DataSource: new DataSource(DatabaseType.MSSQL, connectionString, new()), + Entities: new(new Dictionary()), + Runtime: null); + + ConfigurationPostParametersV2 returnParams = new( + Configuration: serializedConfiguration, + ConfigurationOverrides: overrides.ToJson(), + Schema: null, + AccessToken: null); - // Sanity test to validate that a correct config file can be loaded after a bad config file. - string goodConfig = TestHelper.BASE_CONFIG; - RuntimeConfigLoader.TryParseConfig(goodConfig, out RuntimeConfig runtimeConfig2, replacementSettings: new()); - Assert.IsNotNull(runtimeConfig2); - Assert.AreEqual(DatabaseType.MSSQL, runtimeConfig2.DataSource.DatabaseType); + return JsonContent.Create(returnParams); + } + else + { + throw new InvalidOperationException("Invalid configurationEndpoint"); + } } /// - /// This test validates the following scenario: - /// 1. Start with a valid config with a single entity. - /// 2. Update the config to add a new entity. - /// 3. Validate that the new entity is correctly added and the old entity is unaffected. - /// 4. Validate that the changes are reflected in the OpenAPI document. + /// Hydrates configuration after engine has started and triggers service instantiation + /// by executing HTTP requests against the engine until a non-503 error is received. /// - [TestMethod] - [TestCategory(TestCategory.MSSQL)] - public async Task TestHotSwapAddNewEntityToConfig() - { - // 1. Start with a valid config with a single entity. - string initialEntityConfig = @"{ - ""$schema"":""https://github.com/Azure/data-api-builder/releases/download/vmajor.minor.patch-alpha/dab.draft.schema.json"", - ""data-source"": { - ""database-type"": ""mssql"", - ""connection-string"": ""sample-conn-string"" - }, - ""runtime"": { - ""rest"": { - ""enabled"": true, - ""path"": ""/api"" - }, - ""graphql"": { - ""enabled"": true, - ""path"": ""/graphql"", - ""allow-introspection"": true - }, - ""host"": { - ""cors"": { - ""origins"": [ - ""http://localhost:5000"" - ], - ""allow-credentials"": false - } - } - }, - ""entities"": { - ""Book"": { - ""source"": { - ""object"": ""books"", - ""type"": ""table"" - }, - ""graphql"": { - ""enabled"": true, - ""type"": { - ""singular"": ""book"", - ""plural"": ""books"" - } - }, - ""permissions"": [ - { - ""role"": ""anonymous"", - ""actions"": [ - { - ""action"": ""read"" - } - ] - } - ], - ""mappings"": null, - ""relationships"": null - } - } - }"; - - // 2. Update the config to add a new entity. - string updatedEntityConfig = @"{ - ""$schema"":""https://github.com/Azure/data-api-builder/releases/download/vmajor.minor.patch-alpha/dab.draft.schema.json"", - ""data-source"": { - ""database-type"": ""mssql"", - ""connection-string"": ""sample-conn-string"" - }, - ""runtime"": { - ""rest"": { - ""enabled"": true, - ""path"": ""/api"" - }, - ""graphql"": { - ""enabled"": true, - ""path"": ""/graphql"", - ""allow-introspection"": true - }, - ""host"": { - ""cors"": { - ""origins"": [ - ""http://localhost:5000"" - ], - ""allow-credentials"": false - } - } - }, - ""entities"": { - ""Book"": { - ""source"": { - ""object"": ""books"", - ""type"": ""table"" - }, - ""graphql"": { - ""enabled"": true, - ""type"": { - ""singular"": ""book"", - ""plural"": ""books"" - } - }, - ""permissions"": [ - { - ""role"": ""anonymous"", - ""actions"": [ - { - ""action"": ""read"" - } - ] - } - ], - ""mappings"": null, - ""relationships"": null - }, - ""Author"": { - ""source"": { - ""object"": ""authors"", - ""type"": ""table"" - }, - ""graphql"": { - ""enabled"": true, - ""type"": { - ""singular"": ""author"", - ""plural"": ""authors"" - } - }, - ""permissions"": [ - { - ""role"": ""anonymous"", - ""actions"": [ - { - ""action"": ""read"" - } - ] - } - ], - ""mappings"": null, - ""relationships"": null - } - } - }"; - - string validConfigFile = TestHelper.AddPropertiesToJson(TestHelper.BASE_CONFIG, initialEntityConfig); - RuntimeConfigLoader.TryParseConfig(validConfigFile, out RuntimeConfig runtimeConfig, replacementSettings: new()); - - const string CUSTOM_CONFIG = "custom-config.json"; - File.WriteAllText(CUSTOM_CONFIG, runtimeConfig.ToJson()); + /// Client used for request execution. + /// New config file content that will be added to DAB. + /// Endpoint through which content will be sent to DAB." + /// Global settings used at runtime for REST APIs. + /// ServiceUnavailable if service is not successfully hydrated with config + private static async Task HydratePostStartupConfiguration(HttpClient httpClient, JsonContent content, string configurationEndpoint, RestRuntimeOptions rest) + { + string appServiceTokenPayload = AuthTestHelper.CreateAppServiceEasyAuthToken( + roleClaimType: Config.ObjectModel.AuthenticationOptions.ROLE_CLAIM_TYPE, + additionalClaims: + [ + new AppServiceClaim + { + Typ = Config.ObjectModel.AuthenticationOptions.ROLE_CLAIM_TYPE, + Val = POST_STARTUP_CONFIG_ROLE + } + ]); - string[] args = new[] + using HttpRequestMessage postRequest = new(HttpMethod.Post, configurationEndpoint) { - $"--ConfigFileName={CUSTOM_CONFIG}" + Content = content }; - using TestServer server = new(Program.CreateWebHostBuilder(args)); - using HttpClient client = server.CreateClient(); - - // Act - // validate initial state - await ValidateServiceConfigState(server, client, dataSource, "Book", expectBookExists: true, expectAuthorExists: false); - - // 2. Update the config to add a new entity. - File.WriteAllText(CUSTOM_CONFIG, updatedEntityConfig); + postRequest.Headers.Add( + Config.ObjectModel.AuthenticationOptions.CLIENT_PRINCIPAL_HEADER, + appServiceTokenPayload); - // Simulate a pause to allow the service to process the config update. - await Task.Delay(5000); + HttpResponseMessage postResult = await httpClient.SendAsync(postRequest); + string body = await postResult.Content.ReadAsStringAsync(); + Assert.AreEqual(HttpStatusCode.OK, postResult.StatusCode, body); - // validate updated state - await ValidateServiceConfigState(server, client, dataSource, entityName: "Book", expectBookExists: true, expectAuthorExists: true); + return await GetRestResponsePostConfigHydration(httpClient, rest); } /// - /// Performs GET requests for both REST and GraphQL and validates the response. - /// The expected response is that the REST request succeeds but the GraphQL request fails - /// with an appropriate authorization error message. + /// Executing REST requests against the engine until a non-503 error is received. /// - /// Test server created for the test - /// HTTP client - /// GraphQL query text - /// GraphQL query name - /// Auth token for the requests - /// Client role header for the requests - private static async Task ValidateRestAndGraphQLAccessWithAuth( - TestServer server, - HttpClient client, - string graphqlQuery, - string queryName, - string authToken, - string clientRoleHeader) - { - // REST request - HttpRequestMessage restRequest = new(HttpMethod.Get, "/api/Book"); - HttpResponseMessage restResponse = await client.SendAsync(restRequest); - Assert.AreEqual(HttpStatusCode.OK, restResponse.StatusCode); - - // GraphQL request - object payload = new { query = graphqlQuery }; - HttpRequestMessage graphqlRequest = new(HttpMethod.Post, "/graphql") + /// Client used for request execution. + /// Global settings used at runtime for REST APIs. + /// ServiceUnavailable if service is not successfully hydrated with config, + /// else the response code from the REST request + private static async Task GetRestResponsePostConfigHydration(HttpClient httpClient, RestRuntimeOptions rest) + { + // Retry request RETRY_COUNT times in exponential increments to allow + // required services time to instantiate and hydrate permissions because + // the DAB services may take an unpredictable amount of time to become ready. + // + // The service might still fail due to the service not being available yet, + // but it is highly unlikely to be the case. + int retryCount = 0; + HttpStatusCode responseCode = HttpStatusCode.ServiceUnavailable; + while (retryCount < RETRY_COUNT) { - Content = JsonContent.Create(payload) - }; + // Spot test authorization resolver utilization to ensure configuration is used. + HttpResponseMessage postConfigHydrationResult = + await httpClient.GetAsync($"{rest.Path}/{POST_STARTUP_CONFIG_ENTITY}"); + responseCode = postConfigHydrationResult.StatusCode; + + if (postConfigHydrationResult.StatusCode == HttpStatusCode.ServiceUnavailable) + { + retryCount++; + await Task.Delay(TimeSpan.FromSeconds(Math.Pow(RETRY_WAIT_SECONDS, retryCount))); + continue; + } + + break; + } - HttpResponseMessage graphqlResponse = await client.SendAsync(graphqlRequest); - Assert.AreEqual(HttpStatusCode.Forbidden, graphqlResponse.StatusCode); - string body = await graphqlResponse.Content.ReadAsStringAsync(); - Assert.IsTrue(body.Contains("not have permission"), body); + return responseCode; } /// - /// Validate that the service is configured correctly with the expected entities and paths, - /// and that the service responds as expected to requests for these entities. + /// Executing GraphQL POST requests against the engine until a non-503 error is received. /// - /// The test server - /// The HTTP client - /// The data source used in the runtime config - /// The name of the entity to check - /// Whether the book entity should exist - /// Whether the author entity should exist - private static async Task ValidateServiceConfigState(TestServer server, HttpClient client, DataSource dataSource, string entityName, bool expectBookExists, bool expectAuthorExists) + /// Client used for request execution. + /// ServiceUnavailable if service is not successfully hydrated with config, + /// else the response code from the GRAPHQL request + private static async Task GetGraphQLResponsePostConfigHydration(HttpClient httpClient, GraphQLRuntimeOptions graphQL) { - RuntimeConfigProvider configProvider = server.Services.GetService(); - RuntimeConfig config = configProvider.GetConfig(); + // Retry request RETRY_COUNT times in exponential increments to allow + // required services time to instantiate and hydrate permissions because + // the DAB services may take an unpredictable amount of time to become ready. + // + // The service might still fail due to the service not being available yet, + // but it is highly unlikely to be the case. + int retryCount = 0; + HttpStatusCode responseCode = HttpStatusCode.ServiceUnavailable; + while (retryCount < RETRY_COUNT) + { + string query = @"{ + book_by_pk(id: 1) { + id, + title, + publisher_id + } + }"; - // Validate number of entities - Assert.AreEqual(2, config.Entities.Entities.Count, "Number of entities is not what is expected"); + object payload = new { query }; - // Validate REST API response - HttpResponseMessage restResponse = await client.GetAsync($"/api/{entityName}"); - Assert.AreEqual(HttpStatusCode.OK, restResponse.StatusCode, "REST request did not return OK"); + HttpRequestMessage graphQLRequest = new(HttpMethod.Post, graphQL.Path) + { + Content = JsonContent.Create(payload) + }; - string restResponseBody = await restResponse.Content.ReadAsStringAsync(); - Assert.IsTrue(restResponseBody.Contains("items"), "REST response does not contain expected items array"); + HttpResponseMessage graphQLResponse = await httpClient.SendAsync(graphQLRequest); + responseCode = graphQLResponse.StatusCode; - // Validate GraphQL API response - string graphqlQuery = $@"{{ - {entityName.ToCamelCase()} {{ - items {{ - id - title - }} - }} - }}"; + if (responseCode == HttpStatusCode.ServiceUnavailable) + { + retryCount++; + await Task.Delay(TimeSpan.FromSeconds(Math.Pow(RETRY_WAIT_SECONDS, retryCount))); + continue; + } + + break; + } - object payload = new { query = graphqlQuery }; - HttpRequestMessage graphqlRequest = new(HttpMethod.Post, "/graphql") + return responseCode; + } + + /// + /// Executing MCP POST requests against the engine until a non-503 error is received. + /// + /// Client used for request execution. + /// ServiceUnavailable if service is not successfully hydrated with config, + /// else the response code from the MCP request + public static async Task GetMcpResponse(HttpClient httpClient, McpRuntimeOptions mcp) + { + // Retry request RETRY_COUNT times in exponential increments to allow + // required services time to instantiate and hydrate permissions because + // the DAB services may take an unpredictable amount of time to become ready. + // + // The service might still fail due to the service not being available yet, + // but it is highly unlikely to be the case. + int retryCount = 0; + HttpStatusCode responseCode = HttpStatusCode.ServiceUnavailable; + while (retryCount < RETRY_COUNT) { - Content = JsonContent.Create(payload) - }; + // Minimal MCP request (initialize) - valid JSON-RPC request. + // Using 'initialize' because 'tools/list' requires an active session + // in the MCP Streamable HTTP transport (ModelContextProtocol 1.0.0). + object payload = new + { + jsonrpc = "2.0", + id = 1, + method = "initialize", + @params = new + { + protocolVersion = "2025-03-26", + capabilities = new { }, + clientInfo = new { name = "dab-test", version = "1.0.0" } + } + }; + HttpRequestMessage mcpRequest = new(HttpMethod.Post, mcp.Path) + { + Content = JsonContent.Create(payload) + }; + mcpRequest.Headers.Add("Accept", "application/json, text/event-stream"); - HttpResponseMessage graphqlResponse = await client.SendAsync(graphqlRequest); - Assert.AreEqual(HttpStatusCode.OK, graphqlResponse.StatusCode, "GraphQL request did not return OK"); + HttpResponseMessage mcpResponse = await httpClient.SendAsync(mcpRequest); + responseCode = mcpResponse.StatusCode; - string graphqlResponseBody = await graphqlResponse.Content.ReadAsStringAsync(); - Assert.IsTrue(graphqlResponseBody.Contains("data"), "GraphQL response does not contain expected data field"); - Assert.IsTrue(graphqlResponseBody.Contains($"\"{entityName}\": {{"), "GraphQL response does not contain expected entity field"); + if (responseCode == HttpStatusCode.ServiceUnavailable || responseCode == HttpStatusCode.NotFound) + { + retryCount++; + await Task.Delay(TimeSpan.FromSeconds(Math.Pow(RETRY_WAIT_SECONDS, retryCount))); + continue; + } - // Specific checks for book entity - if (expectBookExists) - { - Assert.IsTrue(graphqlResponseBody.Contains("id"), "GraphQL response for book does not contain expected id field"); - Assert.IsTrue(graphqlResponseBody.Contains("title"), "GraphQL response for book does not contain expected title field"); + break; } - // Specific checks for author entity - if (expectAuthorExists) - { - Assert.IsTrue(graphqlResponseBody.Contains("publisher_id"), "GraphQL response for author does not contain expected publisher_id field"); - } + return responseCode; } /// - /// Regression test for issue #376 - /// Validates that a config with an entity having a source set to an empty string - /// does not cause the engine to crash and burn. + /// Helper method to instantiate RuntimeConfig object needed for multiple create tests. /// - [TestMethod] - [TestCategory(TestCategory.MSSQL)] - public async Task TestEmptySourceConfigDoesNotCauseCrash() + public static RuntimeConfig InitialzieRuntimeConfigForMultipleCreateTests(bool isMultipleCreateOperationEnabled) { - string emptySourceConfig = @"{ - ""$schema"":""https://github.com/Azure/data-api-builder/releases/download/vmajor.minor.patch-alpha/dab.draft.schema.json"", - ""data-source"": { - ""database-type"": ""mssql"", - ""connection-string"": ""sample-conn-string"" - }, - ""runtime"": { - ""rest"": { - ""enabled"": true, - ""path"": ""/api"" - }, - ""graphql"": { - ""enabled"": true, - ""path"": ""/graphql"", - ""allow-introspection"": true - }, - ""host"": { - ""cors"": { - ""origins"": [ - ""http://localhost:5000"" - ], - ""allow-credentials"": false - } - } - }, - ""entities"": { - ""Book"": { - ""source"": { - ""object"": """", - ""type"": ""table"" - }, - ""graphql"": { - ""enabled"": true, - ""type"": { - ""singular"": ""book"", - ""plural"": ""books"" - } - }, - ""permissions"": [ - { - ""role"": ""anonymous"", - ""actions"": [ - { - ""action"": ""read"" - } - ] - } - ], - ""mappings"": null, - ""relationships"": null - } - } - }"; + // Multiple create operations are enabled. + GraphQLRuntimeOptions graphqlOptions = new(Enabled: true, MultipleMutationOptions: new(new(enabled: isMultipleCreateOperationEnabled))); - string configWithEmptySource = TestHelper.AddPropertiesToJson(TestHelper.BASE_CONFIG, emptySourceConfig); - RuntimeConfigLoader.TryParseConfig(configWithEmptySource, out RuntimeConfig runtimeConfig, replacementSettings: new()); + RestRuntimeOptions restRuntimeOptions = new(Enabled: false); - const string CUSTOM_CONFIG = "custom-config.json"; - File.WriteAllText(CUSTOM_CONFIG, runtimeConfig.ToJson()); + McpRuntimeOptions mcpRuntimeOptions = new(Enabled: false); - using TestServer server = new(Program.CreateWebHostBuilder(new[] { $"--ConfigFileName={CUSTOM_CONFIG}" })); - using HttpClient client = server.CreateClient(); + DataSource dataSource = new(DatabaseType.MSSQL, GetConnectionStringFromEnvironmentConfig(environment: TestCategory.MSSQL), Options: null); + + EntityAction createAction = new( + Action: EntityActionOperation.Create, + Fields: null, + Policy: new()); + + EntityAction readAction = new( + Action: EntityActionOperation.Read, + Fields: null, + Policy: new()); + + EntityPermission[] permissions = new[] { new EntityPermission(Role: AuthorizationResolver.ROLE_ANONYMOUS, Actions: new[] { readAction, createAction }) }; + + EntityRelationship bookRelationship = new(Cardinality: Cardinality.One, + TargetEntity: "Publisher", + SourceFields: new string[] { }, + TargetFields: new string[] { }, + LinkingObject: null, + LinkingSourceFields: null, + LinkingTargetFields: null); + + Entity bookEntity = new(Source: new("books", EntitySourceType.Table, null, null), + Fields: null, + Rest: null, + GraphQL: new(Singular: "book", Plural: "books"), + Permissions: permissions, + Relationships: new Dictionary() { { "publishers", bookRelationship } }, + Mappings: null); + + string bookEntityName = "Book"; + + Dictionary entityMap = new() { - // Act - calling the health endpoint should work if the engine has started successfully. - HttpResponseMessage healthResponse = await client.GetAsync("/"); - Assert.AreEqual(HttpStatusCode.OK, healthResponse.StatusCode); - string responseBody = await healthResponse.Content.ReadAsStringAsync(); - Assert.IsTrue(responseBody.Contains(@"""status"":""Healthy"""), responseBody); - } + { bookEntityName, bookEntity } + }; + + EntityRelationship publisherRelationship = new(Cardinality: Cardinality.Many, + TargetEntity: "Book", + SourceFields: new string[] { }, + TargetFields: new string[] { }, + LinkingObject: null, + LinkingSourceFields: null, + LinkingTargetFields: null); + + Entity publisherEntity = new( + Source: new("publishers", EntitySourceType.Table, null, null), + Fields: null, + Rest: null, + GraphQL: new(Singular: "publisher", Plural: "publishers"), + Permissions: permissions, + Relationships: new Dictionary() { { "books", publisherRelationship } }, + Mappings: null); + + entityMap.Add("Publisher", publisherEntity); + + Config.ObjectModel.AuthenticationOptions authenticationOptions = new(Provider: nameof(EasyAuthType.StaticWebApps), null); + + RuntimeConfig runtimeConfig = new(Schema: "IntegrationTestMinimalSchema", + DataSource: dataSource, + Runtime: new(restRuntimeOptions, graphqlOptions, mcpRuntimeOptions, Host: new(Cors: null, Authentication: authenticationOptions, Mode: HostMode.Development), Cache: null), + Entities: new(entityMap)); + return runtimeConfig; } /// - /// Regression test for issue #3012 - /// Validates that DAB doesn't fail with unhandled exceptions when there is an error in one of the config updated handlers. - /// A bad request response is expected instead. + /// Instantiate minimal runtime config with custom global settings. /// - [TestMethod] - [TestCategory(TestCategory.MSSQL)] - public async Task TestConfigUpdateHandlerErrorDoesNotCauseCrash() + /// DataSource to pull connection string required for engine start. + public static RuntimeConfig InitMinimalRuntimeConfig( + DataSource dataSource, + GraphQLRuntimeOptions graphqlOptions, + RestRuntimeOptions restOptions, + McpRuntimeOptions mcpOptions, + Entity entity = null, + string entityName = null, + RuntimeCacheOptions cacheOptions = null + ) { - string initialConfig = @"{ - ""$schema"":""https://github.com/Azure/data-api-builder/releases/download/vmajor.minor.patch-alpha/dab.draft.schema.json"", - ""data-source"": { - ""database-type"": ""mssql"", - ""connection-string"": ""sample-conn-string"" - }, - ""runtime"": { - ""rest"": { - ""enabled"": true, - ""path"": ""/api"" - }, - ""graphql"": { - ""enabled"": true, - ""path"": ""/graphql"", - ""allow-introspection"": true - }, - ""host"": { - ""cors"": { - ""origins"": [ - ""http://localhost:5000"" - ], - ""allow-credentials"": false - } - } - }, - ""entities"": { - ""Book"": { - ""source"": { - ""object"": ""books"", - ""type"": ""table"" - }, - ""graphql"": { - ""enabled"": true, - ""type"": { - ""singular"": ""book"", - ""plural"": ""books"" - } - }, - ""permissions"": [ - { - ""role"": ""anonymous"", - ""actions"": [ - { - ""action"": ""read"" - } - ] - } - ], - ""mappings"": null, - ""relationships"": null - } - } - }"; - - string configWithBadHandler = TestHelper.AddPropertiesToJson(TestHelper.BASE_CONFIG, @"{ - ""$schema"":""https://github.com/Azure/data-api-builder/releases/download/vmajor.minor.patch-alpha/dab.draft.schema.json"", - ""data-source"": { - ""database-type"": ""mssql"", - ""connection-string"": ""sample-conn-string"" - }, - ""runtime"": { - ""rest"": { - ""enabled"": true, - ""path"": ""/api"" - }, - ""graphql"": { - ""enabled"": true, - ""path"": ""/graphql"", - ""allow-introspection"": true - }, - ""host"": { - ""cors"": { - ""origins"": [ - ""http://localhost:5000"" - ], - ""allow-credentials"": false - } - } - }, - ""entities"": { - ""Book"": { - ""source"": { - ""object"": ""books"", - ""type"": ""table"" - }, - ""graphql"": { - ""enabled"": true, - ""type"": { - ""singular"": ""book"", - ""plural"": ""books"" - } - }, - ""permissions"": [ - { - ""role"": ""anonymous"", - ""actions"": [ - { - ""action"": ""read"" - } - ] - } - ], - ""mappings"": null, - ""relationships"": null - } - }, - ""runtime-config-updated-handlers"": [ - { - ""handler"": ""http://bad-url"", - ""status-code"": 400, - ""timeout-seconds"": 2 - } - ] - }"); + entity ??= new( + Source: new("books", EntitySourceType.Table, null, null), + Fields: null, + Rest: null, + GraphQL: new(Singular: "book", Plural: "books"), + Permissions: new[] { GetMinimalPermissionConfig(AuthorizationResolver.ROLE_ANONYMOUS) }, + Relationships: null, + Mappings: null + ); - string[] args = new[] + entityName ??= "Book"; + + Dictionary entityMap = new() { - $"--ConfigFileName={CUSTOM_CONFIG_FILENAME}" + { entityName, entity } }; - using TestServer server = new(Program.CreateWebHostBuilder(args)); - using HttpClient client = server.CreateClient(); - { - // Simulate a short delay to allow the config update to be processed - await Task.Delay(3000); + // Adding an entity with only Authorized Access + Entity anotherEntity = new( + Source: new("publishers", EntitySourceType.Table, null, null), + Fields: null, + Rest: null, + GraphQL: new(Singular: "publisher", Plural: "publishers"), + Permissions: new[] { GetMinimalPermissionConfig(AuthorizationResolver.ROLE_AUTHENTICATED) }, + Relationships: null, + Mappings: null + ); + entityMap.Add("Publisher", anotherEntity); - string response = await (await client.GetAsync("/graphql")).Content.ReadAsStringAsync(); - Assert.IsTrue(response.Contains(@"""status"":""Healthy"""), response); - } + Config.ObjectModel.AuthenticationOptions authenticationOptions = new(Provider: nameof(EasyAuthType.AppService), null); + + return new( + Schema: "IntegrationTestMinimalSchema", + DataSource: dataSource, + Runtime: new(restOptions, graphqlOptions, mcpOptions, + Host: new(Cors: null, Authentication: authenticationOptions, Mode: HostMode.Development), + Cache: cacheOptions + ), + Entities: new(entityMap) + ); } /// - /// Tests behavior when a config with same entity name but different case is applied. - /// Start with a config with a single entity: Book. A second config with the same entity but different case: - /// book is applied. Verify that the entity is updated to be book but that the original entity remains. - /// Verify that the OpenAPI document reflects the current state of the config. + /// Gets PermissionSetting object allowed to perform all actions. /// - /// - [TestMethod] - [TestCategory(TestCategory.MSSQL)] - public async Task TestHotSwapChangeEntityCaseInConfig() + /// Name of role to assign to permission + /// PermissionSetting + public static EntityPermission GetMinimalPermissionConfig(string roleName) { - // 1. Start with a valid config with a single entity. - string initialConfig = @"{ - ""$schema"":""https://github.com/Azure/data-api-builder/releases/download/vmajor.minor.patch-alpha/dab.draft.schema.json"", - ""data-source"": { - ""database-type"": ""mssql"", - ""connection-string"": ""sample-conn-string"" - }, - ""runtime"": { - ""rest"": { - ""enabled"": true, - ""path"": ""/api"" - }, - ""graphql"": { - ""enabled"": true, - ""path"": ""/graphql"", - ""allow-introspection"": true - }, - ""host"": { - ""cors"": { - ""origins"": [ - ""http://localhost:5000"" - ], - ""allow-credentials"": false - } - } - }, - ""entities"": { - ""Book"": { - ""source"": { - ""object"": ""books"", - ""type"": ""table"" - }, - ""graphql"": { - ""enabled"": true, - ""type"": { - ""singular"": ""book"", - ""plural"": ""books"" - } - }, - ""permissions"": [ - { - ""role"": ""anonymous"", - ""actions"": [ - { - ""action"": ""read"" - } - ] - } - ], - ""mappings"": null, - ""relationships"": null - } - } - }"; - - // 2. Update the config to change entity name to different case. - string updatedConfig = @"{ - ""$schema"":""https://github.com/Azure/data-api-builder/releases/download/vmajor.minor.patch-alpha/dab.draft.schema.json"", - ""data-source"": { - ""database-type"": ""mssql"", - ""connection-string"": ""sample-conn-string"" - }, - ""runtime"": { - ""rest"": { - ""enabled"": true, - ""path"": ""/api"" - }, - ""graphql"": { - ""enabled"": true, - ""path"": ""/graphql"", - ""allow-introspection"": true - }, - ""host"": { - ""cors"": { - ""origins"": [ - ""http://localhost:5000"" - ], - ""allow-credentials"": false - } - } - }, - ""entities"": { - ""book"": { - ""source"": { - ""object"": ""books"", - ""type"": ""table"" - }, - ""graphql"": { - ""enabled"": true, - ""type"": { - ""singular"": ""book"", - ""plural"": ""books"" - } - }, - ""permissions"": [ - { - ""role"": ""anonymous"", - ""actions"": [ - { - ""action"": ""read"" - } - ] - } - ], - ""mappings"": null, - ""relationships"": null - } - } - }"; + EntityAction actionForRole = new( + Action: EntityActionOperation.All, + Fields: null, + Policy: new() + ); - string validConfigFile = TestHelper.AddPropertiesToJson(TestHelper.BASE_CONFIG, initialConfig); - RuntimeConfigLoader.TryParseConfig(validConfigFile, out RuntimeConfig runtimeConfig, replacementSettings: new()); + return new EntityPermission( + Role: roleName, + Actions: new[] { actionForRole } + ); + } - const string CUSTOM_CONFIG = "custom-config.json"; - File.WriteAllText(CUSTOM_CONFIG, runtimeConfig.ToJson()); + /// + /// Reads configuration file for defined environment to acquire the connection string. + /// CI/CD Pipelines and local environments may not have connection string set as environment variable. + /// + /// Environment such as TestCategory.MSSQL + /// Connection string + public static string GetConnectionStringFromEnvironmentConfig(string environment) + { + FileSystem fileSystem = new(); + string sqlFile = new FileSystemRuntimeConfigLoader(fileSystem).GetFileNameForEnvironment(environment, considerOverrides: true); + string configPayload = File.ReadAllText(sqlFile); - string[] args = new[] - { - $"--ConfigFileName={CUSTOM_CONFIG}" - }; + RuntimeConfigLoader.TryParseConfig(configPayload, out RuntimeConfig runtimeConfig, replacementSettings: new()); - using TestServer server = new(Program.CreateWebHostBuilder(args)); - using HttpClient client = server.CreateClient(); + return runtimeConfig.DataSource.ConnectionString; + } - // Act - // validate initial state - await ValidateServiceConfigState(server, client, dataSource, "Book", expectBookExists: true, expectAuthorExists: false); + private static void ValidateCosmosDbSetup(TestServer server) + { + QueryEngineFactory queryEngineFactory = (QueryEngineFactory)server.Services.GetService(typeof(IQueryEngineFactory)); + Assert.IsInstanceOfType(queryEngineFactory.GetQueryEngine(DatabaseType.CosmosDB_NoSQL), typeof(CosmosQueryEngine)); - // 2. Update the config to change entity name to different case. - File.WriteAllText(CUSTOM_CONFIG, updatedConfig); + MutationEngineFactory mutationEngineFactory = (MutationEngineFactory)server.Services.GetService(typeof(IMutationEngineFactory)); + Assert.IsInstanceOfType(mutationEngineFactory.GetMutationEngine(DatabaseType.CosmosDB_NoSQL), typeof(CosmosMutationEngine)); - // Simulate a pause to allow the service to process the config update. - await Task.Delay(5000); + MetadataProviderFactory metadataProviderFactory = (MetadataProviderFactory)server.Services.GetService(typeof(IMetadataProviderFactory)); + Assert.IsTrue(metadataProviderFactory.ListMetadataProviders().Any(x => x.GetType() == typeof(CosmosSqlMetadataProvider))); - // validate updated state - await ValidateServiceConfigState(server, client, dataSource, entityName: "Book", expectBookExists: true, expectAuthorExists: true); + CosmosClientProvider cosmosClientProvider = server.Services.GetService(typeof(CosmosClientProvider)) as CosmosClientProvider; + Assert.IsNotNull(cosmosClientProvider); + Assert.IsNotNull(cosmosClientProvider.Clients); + Assert.IsTrue(cosmosClientProvider.Clients.Any()); } /// - /// Conversion for string values that are to be used as URL path segments. - /// Space, ?, #, [, ], {, }, |, \, ^, ~, and % are to be escaped. - /// Escaped byte values are prefixed with a dot (.) to form %xx hex sequences. - /// Escape sequence "%20" is converted to "+" + /// Create basic runtime config with given DatabaseType and connectionString with no entity. /// - /// - /// - /// - /// - private static async Task ValidateUriEscaping(string uRI, string expectedConvertedURI, string message) + private static RuntimeConfig CreateBasicRuntimeConfigWithNoEntity( + DatabaseType dbType = DatabaseType.MSSQL, + string connectionString = "") { - HttpRequestMessage request = new(HttpMethod.Get, uRI); - HttpResponseMessage response = await new HttpClient().SendAsync(request); - string body = await response.Content.ReadAsStringAsync(); - Assert.AreEqual(HttpStatusCode.OK, response.StatusCode, body); + DataSource dataSource = new(dbType, connectionString, new()); - JsonElement responseJson = JsonSerializer.Deserialize(body); - _ = responseJson.TryGetProperty("data", out JsonElement data); - _ = data.TryGetProperty("book_by_pk", out JsonElement bookByPK); + RuntimeConfig runtimeConfig = new( + Schema: "testSchema.json", + DataSource: dataSource, + Runtime: new( + Rest: new(), + GraphQL: new(), + Mcp: new(), + Host: new(null, null) + ), + Entities: new(new Dictionary()) + ); - Assert.AreEqual(expectedConvertedURI, bookByPK.GetProperty("id").ToString(), message); + return runtimeConfig; } /// - /// Validate that the error response for REST requests with invalid request body (non-JSON) returns - /// HTTP 400 - BadRequest and contains the right error message. + /// Create basic runtime config with a single entity and given auth options. /// - /// Type of REST request - /// Endpoint for the REST request - /// Request body - /// Right error message that should be shown to the end user - [DataTestMethod] - [TestCategory(TestCategory.MSSQL)] - [DataRow(SupportedHttpVerb.Post, "/api/Book", "invalid json", "Invalid request body. Contained unexpected fields in body: invalid json", DisplayName = "Malformed JSON in request body")] - [DataRow(SupportedHttpVerb.Post, "/api/Book", "", "Invalid request body. Contained unexpected fields in body: ", DisplayName = "Empty JSON request body")] - // PUT and PATCH with application/x-www-form-urlencoded - [DataRow(SupportedHttpVerb.Put, "/api/Book/id/1", "id=1&title=New+Title", "Invalid request body. Contained unexpected fields in body: id, title", DisplayName = "Invalid request body for PUT operation")] - [DataRow(SupportedHttpVerb.Patch, "/api/Book/id/1", "title=New+Title", "Invalid request body. Contained unexpected fields in body: title", DisplayName = "Invalid request body for PATCH operation")] - public async Task TestInvalidRequestBodyErrorMessage(SupportedHttpVerb requestType, string requestPath, string requestBody, string expectedErrorMessage) + private static RuntimeConfig CreateBasicRuntimeConfigWithSingleEntityAndAuthOptions( + DataSource dataSource, + AuthenticationOptions authenticationOptions = null) { - const string CUSTOM_CONFIG = "custom-config.json"; - TestHelper.ConstructNewConfigWithSpecifiedHostMode(CUSTOM_CONFIG, HostMode.Production, TestCategory.MSSQL); - string[] args = new[] + Entity entity = new( + Source: new("books", EntitySourceType.Table, null, null), + Fields: null, + Rest: null, + GraphQL: new(Singular: "book", Plural: "books"), + Permissions: new[] { GetMinimalPermissionConfig(AuthorizationResolver.ROLE_ANONYMOUS) }, + Relationships: null, + Mappings: null + ); + + string entityName = "Book"; + + Dictionary entityMap = new() { - $"--ConfigFileName={CUSTOM_CONFIG}" + { entityName, entity } }; - using (TestServer server = new(Program.CreateWebHostBuilder(args))) - using (HttpClient client = server.CreateClient()) - { - HttpMethod httpMethod = SqlTestHelper.ConvertRestMethodToHttpMethod(requestType); - HttpRequestMessage request; - if (requestType is SupportedHttpVerb.Get || requestType is SupportedHttpVerb.Delete) - { - request = new(httpMethod, requestPath); - } - else - { - request = new(httpMethod, requestPath) - { - Content = JsonContent.Create(requestBody) - }; - } + RuntimeConfig runtimeConfig = new( + Schema: "testSchema.json", + DataSource: dataSource, + Runtime: new( + Rest: new(), + GraphQL: new(), + Mcp: new(), + Host: new(Cors: null, Authentication: authenticationOptions) + ), + Entities: new(entityMap) + ); - HttpResponseMessage response = await client.SendAsync(request); - string body = await response.Content.ReadAsStringAsync(); + return runtimeConfig; + } - Assert.AreEqual(HttpStatusCode.BadRequest, response.StatusCode); - Assert.IsTrue(body.Contains(expectedErrorMessage), body); + private bool HandleException(Exception e) where T : Exception + { + if (e is AggregateException aggregateException) + { + aggregateException.Handle(HandleException); + return true; + } + else if (e is T) + { + return true; } + + return false; } } } diff --git a/src/Service.Tests/UnitTests/RequestParserUnitTests.cs b/src/Service.Tests/UnitTests/RequestParserUnitTests.cs index 4da3266271..6cc6b6b7ad 100644 --- a/src/Service.Tests/UnitTests/RequestParserUnitTests.cs +++ b/src/Service.Tests/UnitTests/RequestParserUnitTests.cs @@ -1,6 +1,8 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +#nullable enable + using Azure.DataApiBuilder.Core.Parsers; using Microsoft.VisualStudio.TestTools.UnitTesting; @@ -52,7 +54,7 @@ public void ExtractRawQueryParameter_PreservesEncoding(string queryString, strin public void ExtractRawQueryParameter_ReturnsNull_WhenParameterNotFound(string? queryString, string parameterName) { // Call the internal method directly (no reflection needed) - string? result = RequestParser.ExtractRawQueryParameter(queryString, parameterName); + string? result = RequestParser.ExtractRawQueryParameter(queryString!, parameterName); Assert.IsNull(result, $"Expected null but got '{result}' for parameter '{parameterName}' in query '{queryString}'"); diff --git a/src/Service.Tests/UnitTests/SqlQueryExecutorUnitTests.cs b/src/Service.Tests/UnitTests/SqlQueryExecutorUnitTests.cs index bd1cd28b88..5745c9c477 100644 --- a/src/Service.Tests/UnitTests/SqlQueryExecutorUnitTests.cs +++ b/src/Service.Tests/UnitTests/SqlQueryExecutorUnitTests.cs @@ -707,7 +707,8 @@ private static (MsSqlQueryExecutor QueryExecutor, RuntimeConfigProvider Provider Mcp: new(), Host: new(null, null) ), - Entities: new(new Dictionary())); + Entities: new(new Dictionary()) + ); MockFileSystem fileSystem = new(); fileSystem.AddFile(FileSystemRuntimeConfigLoader.DEFAULT_CONFIG_FILE_NAME, new MockFileData(mockConfig.ToJson())); @@ -948,6 +949,7 @@ public void TestOboNoUserContext_UsesBaseConnectionString() DisplayName = "Authenticated user with oid but missing iss throws OboAuthenticationFailure")] [DataRow(null, null, "iss and oid/sub", DisplayName = "Authenticated user with no claims throws OboAuthenticationFailure")] +#nullable enable public void TestOboEnabled_AuthenticatedUserMissingClaims_ThrowsException( string? issuer, string? objectId, @@ -1010,6 +1012,7 @@ private static Mock CreateHttpContextAccessorWithAuthentic return httpContextAccessor; } +#nullable restore #endregion From 72b703dc2c88892234e6936fb760b7f8ca89675f Mon Sep 17 00:00:00 2001 From: aaron burtle Date: Tue, 10 Mar 2026 14:16:10 -0700 Subject: [PATCH 16/19] missing test --- src/Cli.Tests/ConfigureOptionsTests.cs | 28 ++++++++++++++++++++++++++ src/old_block.txt | 10 +++++++++ 2 files changed, 38 insertions(+) create mode 100644 src/old_block.txt diff --git a/src/Cli.Tests/ConfigureOptionsTests.cs b/src/Cli.Tests/ConfigureOptionsTests.cs index 07dc4d86d4..65849d789d 100644 --- a/src/Cli.Tests/ConfigureOptionsTests.cs +++ b/src/Cli.Tests/ConfigureOptionsTests.cs @@ -1052,6 +1052,34 @@ public void TestUpdateDataSourceHealthName(string healthName) Assert.AreEqual(2000, config.DataSource.Health.ThresholdMs); } + /// + /// Tests that running "dab configure --runtime.mcp.description {value}" on a config with various values results + /// in runtime config update. Takes in updated value for mcp.description and + /// validates whether the runtime config reflects those updated values + /// + [DataTestMethod] + [DataRow("This MCP provides access to the Products database and should be used to answer product-related or inventory-related questions from the user.", DisplayName = "Set MCP description.")] + [DataRow("Use this server for customer data queries.", DisplayName = "Set MCP description with short text.")] + public void TestConfigureDescriptionForMcpSettings(string descriptionValue) + { + // Arrange -> all the setup which includes creating options. + SetupFileSystemWithInitialConfig(INITIAL_CONFIG); + + // Act: Attempts to update mcp.description value + ConfigureOptions options = new( + runtimeMcpDescription: descriptionValue, + config: TEST_RUNTIME_CONFIG_FILE + ); + bool isSuccess = TryConfigureSettings(options, _runtimeConfigLoader!, _fileSystem!); + + // Assert: Validate the Description is updated + Assert.IsTrue(isSuccess); + string updatedConfig = _fileSystem!.File.ReadAllText(TEST_RUNTIME_CONFIG_FILE); + Assert.IsTrue(RuntimeConfigLoader.TryParseConfig(updatedConfig, out RuntimeConfig? runtimeConfig)); + Assert.IsNotNull(runtimeConfig.Runtime?.Mcp?.Description); + Assert.AreEqual(descriptionValue, runtimeConfig.Runtime.Mcp.Description); + } + /// /// Validates that `dab configure --show-effective-permissions` correctly displays /// effective permissions without modifying the config file. diff --git a/src/old_block.txt b/src/old_block.txt new file mode 100644 index 0000000000..790ad26019 --- /dev/null +++ b/src/old_block.txt @@ -0,0 +1,10 @@ +- task: NuGetToolInstaller@1 + + - task: NuGetCommand@2 + displayName: Restore NuGet packages + inputs: + restoreSolution: '$(solution)' + feedsToUse: config + nugetConfigPath: Nuget.config + + From 88c52459bc0fdb2c34a456b4837fab0705ab514b Mon Sep 17 00:00:00 2001 From: aaron burtle Date: Tue, 10 Mar 2026 16:45:54 -0700 Subject: [PATCH 17/19] removed accidently added file --- src/old_block.txt | 10 ---------- 1 file changed, 10 deletions(-) delete mode 100644 src/old_block.txt diff --git a/src/old_block.txt b/src/old_block.txt deleted file mode 100644 index 790ad26019..0000000000 --- a/src/old_block.txt +++ /dev/null @@ -1,10 +0,0 @@ -- task: NuGetToolInstaller@1 - - - task: NuGetCommand@2 - displayName: Restore NuGet packages - inputs: - restoreSolution: '$(solution)' - feedsToUse: config - nugetConfigPath: Nuget.config - - From 395877af70eacbea9e142b8cbdbe650a9e17b4c3 Mon Sep 17 00:00:00 2001 From: aaron burtle Date: Tue, 10 Mar 2026 16:53:08 -0700 Subject: [PATCH 18/19] revert nullable guards --- src/Service.Tests/UnitTests/RequestParserUnitTests.cs | 2 +- src/Service.Tests/UnitTests/SqlQueryExecutorUnitTests.cs | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/src/Service.Tests/UnitTests/RequestParserUnitTests.cs b/src/Service.Tests/UnitTests/RequestParserUnitTests.cs index 6cc6b6b7ad..46bd5b05ec 100644 --- a/src/Service.Tests/UnitTests/RequestParserUnitTests.cs +++ b/src/Service.Tests/UnitTests/RequestParserUnitTests.cs @@ -54,7 +54,7 @@ public void ExtractRawQueryParameter_PreservesEncoding(string queryString, strin public void ExtractRawQueryParameter_ReturnsNull_WhenParameterNotFound(string? queryString, string parameterName) { // Call the internal method directly (no reflection needed) - string? result = RequestParser.ExtractRawQueryParameter(queryString!, parameterName); + string? result = RequestParser.ExtractRawQueryParameter(queryString, parameterName); Assert.IsNull(result, $"Expected null but got '{result}' for parameter '{parameterName}' in query '{queryString}'"); diff --git a/src/Service.Tests/UnitTests/SqlQueryExecutorUnitTests.cs b/src/Service.Tests/UnitTests/SqlQueryExecutorUnitTests.cs index 5745c9c477..778144c6d2 100644 --- a/src/Service.Tests/UnitTests/SqlQueryExecutorUnitTests.cs +++ b/src/Service.Tests/UnitTests/SqlQueryExecutorUnitTests.cs @@ -949,7 +949,6 @@ public void TestOboNoUserContext_UsesBaseConnectionString() DisplayName = "Authenticated user with oid but missing iss throws OboAuthenticationFailure")] [DataRow(null, null, "iss and oid/sub", DisplayName = "Authenticated user with no claims throws OboAuthenticationFailure")] -#nullable enable public void TestOboEnabled_AuthenticatedUserMissingClaims_ThrowsException( string? issuer, string? objectId, @@ -1012,7 +1011,6 @@ private static Mock CreateHttpContextAccessorWithAuthentic return httpContextAccessor; } -#nullable restore #endregion From 02d132760fa957a4ce4c342cb32066516bd13760 Mon Sep 17 00:00:00 2001 From: aaron burtle Date: Tue, 10 Mar 2026 16:55:15 -0700 Subject: [PATCH 19/19] revert nullable local fix --- src/Service.Tests/UnitTests/RequestParserUnitTests.cs | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/Service.Tests/UnitTests/RequestParserUnitTests.cs b/src/Service.Tests/UnitTests/RequestParserUnitTests.cs index 46bd5b05ec..4da3266271 100644 --- a/src/Service.Tests/UnitTests/RequestParserUnitTests.cs +++ b/src/Service.Tests/UnitTests/RequestParserUnitTests.cs @@ -1,8 +1,6 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -#nullable enable - using Azure.DataApiBuilder.Core.Parsers; using Microsoft.VisualStudio.TestTools.UnitTesting;