using GB5Shared.DTO.Framework.Login; using Microsoft.Extensions.Logging.Abstractions; using Moq; using SwBLL.ClientDatabase; using SwBLL.DbServer; using SwBLL.Provisioning; using SwBLL.Query; using SwBLL.UserResourceRole; using SwDAL.CustomCode.QueryHistory; using SwDAL.DTO.ClientDatabase; using SwDAL.DTO.DbServer; using SwDAL.DTO.QueryHistory; using SwDAL.Enums; using Xunit; namespace SwTests; /// /// Covers SwQueryBLL.ExecuteAdHocQuery — it must always execute against the selected /// DbServer/database via ITargetDbExecutor, never against the caller's own tenant DB via /// IQueryExecutor/LoginDTO. Ad-hoc queries are inherently read-only, so a registered /// ClientDatabase routes through the least-privilege ReadOnly contained user. The unregistered- /// database fallback (raw DbServer admin credential, no contained-login role to route through) /// requires a per-user role grant (SW.MSWUSERRESOURCEROLE) to exist at all — the one enforcement /// call site in this module that is fail-closed by default, not fail-open. /// public class SwQueryBLLTests { private static (SwQueryBLL Svc, Mock DbServerBLL, Mock ClientDatabaseBLL, Mock TargetDbExecutor, Mock UserResourceRoleBLL) BuildService() { var dbServerBLL = new Mock(); var clientDatabaseBLL = new Mock(); var targetDbExecutor = new Mock(); var userResourceRoleBLL = new Mock(); var historyDal = new Mock(); dbServerBLL.Setup(d => d.GetById(It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(new DbServerDTO { DbServerId = 1, HostName = "sql01" }); historyDal.Setup(h => h.Insert(It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(1); var svc = new SwQueryBLL( dbServerBLL.Object, clientDatabaseBLL.Object, targetDbExecutor.Object, userResourceRoleBLL.Object, historyDal.Object, NullLogger.Instance); return (svc, dbServerBLL, clientDatabaseBLL, targetDbExecutor, userResourceRoleBLL); } private static LoginDTO TestLogin() => new() { ClientId = 42, UserId = -1 }; [Fact] public async Task Test_ExecuteAdHocQuery_RegisteredClientDatabase_UsesReadOnlyRole() { var (svc, _, clientDatabaseBLL, targetDbExecutor, _) = BuildService(); clientDatabaseBLL.Setup(c => c.GetByServerAndDatabaseName(1, "acme01", It.IsAny(), It.IsAny())) .ReturnsAsync(new ClientDatabaseDTO { ClientDbId = 5, ClientDbCode = "acme01", DatabaseName = "acme01", DbServerId = 1 }); targetDbExecutor.Setup(t => t.BuildConnectionStringForRoleAsync( It.IsAny(), It.IsAny(), ClientDbLoginRole.ReadOnly, It.IsAny())) .ReturnsAsync("role-conn-str"); targetDbExecutor.Setup(t => t.ExecuteQueryAsync("role-conn-str", "SELECT 1", It.IsAny>(), It.IsAny())) .ReturnsAsync(new List> { new() { ["Col"] = 1 } }); var result = await svc.ExecuteAdHocQuery("SELECT 1", new Dictionary(), 1, "acme01", TestLogin(), CancellationToken.None); Assert.Single(result); targetDbExecutor.Verify(t => t.BuildConnectionStringForRoleAsync( It.IsAny(), It.IsAny(), ClientDbLoginRole.ReadOnly, It.IsAny()), Times.Once); targetDbExecutor.Verify(t => t.BuildConnectionString(It.IsAny(), It.IsAny(), null, null), Times.Never); } [Fact] public async Task Test_ExecuteAdHocQuery_ClientDbIdProvided_TakesPrecedenceOverDbServerIdAndDatabaseName() { var (svc, dbServerBLL, clientDatabaseBLL, targetDbExecutor, _) = BuildService(); // ClientDbId resolves to a DIFFERENT DbServerId than the one passed in — proving // clientDbId is authoritative and dbServerId/databaseName are ignored once it's given // (exactly the real-world case: FE always knows ClientDbId once fixed, and any stale // DbServerId it also sends must not silently override the real target). clientDatabaseBLL.Setup(c => c.GetById(5, It.IsAny(), It.IsAny())) .ReturnsAsync(new ClientDatabaseDTO { ClientDbId = 5, ClientDbCode = "acme01", DatabaseName = "acme01", DbServerId = 99 }); dbServerBLL.Setup(d => d.GetById(99, It.IsAny(), It.IsAny())) .ReturnsAsync(new DbServerDTO { DbServerId = 99, HostName = "sql-real" }); targetDbExecutor.Setup(t => t.BuildConnectionStringForRoleAsync( It.Is(s => s.DbServerId == 99), It.IsAny(), ClientDbLoginRole.ReadOnly, It.IsAny())) .ReturnsAsync("role-conn-str"); targetDbExecutor.Setup(t => t.ExecuteQueryAsync("role-conn-str", "SELECT 1", It.IsAny>(), It.IsAny())) .ReturnsAsync(new List>()); await svc.ExecuteAdHocQuery( "SELECT 1", new Dictionary(), dbServerId: 1, databaseName: null, TestLogin(), CancellationToken.None, clientDbId: 5); clientDatabaseBLL.Verify(c => c.GetByServerAndDatabaseName( It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); targetDbExecutor.Verify(t => t.BuildConnectionStringForRoleAsync( It.Is(s => s.DbServerId == 99), It.IsAny(), ClientDbLoginRole.ReadOnly, It.IsAny()), Times.Once); } [Fact] public async Task Test_ExecuteAdHocQuery_UnregisteredDatabase_WithGrant_FallsBackToPlainConnectionString() { var (svc, _, clientDatabaseBLL, targetDbExecutor, userResourceRoleBLL) = BuildService(); clientDatabaseBLL.Setup(c => c.GetByServerAndDatabaseName(1, "master", It.IsAny(), It.IsAny())) .ReturnsAsync((ClientDatabaseDTO?)null); userResourceRoleBLL.Setup(u => u.GetEffectiveRole(It.IsAny(), 1, null, It.IsAny(), It.IsAny())) .ReturnsAsync((ClientDbLoginRole?)ClientDbLoginRole.ReadOnly); targetDbExecutor.Setup(t => t.BuildConnectionString(It.IsAny(), "master", null, null)) .Returns("admin-conn-str"); targetDbExecutor.Setup(t => t.ExecuteQueryAsync("admin-conn-str", "SELECT 1", It.IsAny>(), It.IsAny())) .ReturnsAsync(new List>()); await svc.ExecuteAdHocQuery("SELECT 1", new Dictionary(), 1, "master", TestLogin(), CancellationToken.None); targetDbExecutor.Verify(t => t.BuildConnectionString(It.IsAny(), "master", null, null), Times.Once); targetDbExecutor.Verify(t => t.BuildConnectionStringForRoleAsync( It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } [Fact] public async Task Test_ExecuteAdHocQuery_UnregisteredDatabase_NoGrant_ThrowsUnauthorized() { // Unlike ChangeRequestBLL/ProvisioningBLL (fail-open when no grant is configured), this // one call site is fail-closed by default — it's the single most dangerous path in this // BLL (a flat DbServer admin credential, no contained-login role to route through at // all), so "nothing configured yet" must not silently allow it. var (svc, _, clientDatabaseBLL, targetDbExecutor, userResourceRoleBLL) = BuildService(); clientDatabaseBLL.Setup(c => c.GetByServerAndDatabaseName(1, "master", It.IsAny(), It.IsAny())) .ReturnsAsync((ClientDatabaseDTO?)null); userResourceRoleBLL.Setup(u => u.GetEffectiveRole(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync((ClientDbLoginRole?)null); await Assert.ThrowsAsync(() => svc.ExecuteAdHocQuery("SELECT 1", new Dictionary(), 1, "master", TestLogin(), CancellationToken.None)); targetDbExecutor.Verify(t => t.BuildConnectionString(It.IsAny(), It.IsAny(), null, null), Times.Never); } }