using GB5Shared.DTO.Framework.Login;
using GB5Shared.EventLogPublish;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using SwBLL.ChangeRequest;
using SwBLL.ClientDatabase;
using SwBLL.DbServer;
using SwBLL.DdlScript;
using SwBLL.Provisioning;
using SwBLL.UserResourceRole;
using SwDAL.CustomCode.ChangeRequest;
using SwDAL.DTO.ChangeRequest;
using SwDAL.DTO.ClientDatabase;
using SwDAL.DTO.DbServer;
using SwDAL.DTO.DdlScript;
using SwDAL.Enums;
using Xunit;
namespace SwTests;
///
/// Covers ChangeRequestBLL.Execute()'s least-privilege connection selection — each
/// ChangeRequestQueryType must route through TargetDbExecutor.BuildConnectionStringForRoleAsync
/// with the matching ClientDbLoginRole, never the flat DbServer-admin BuildConnectionString
/// (except for ClientProvisioning, which bootstraps the database before contained users exist).
///
public class ChangeRequestBLLExecutionRoleTests
{
private static (ChangeRequestBLL Svc, Mock Dal, Mock TargetDbExecutor)
BuildService()
{
var dal = new Mock();
var clientDatabaseBLL = new Mock();
var dbServerBLL = new Mock();
var targetDbExecutor = new Mock();
var ddlScriptBLL = new Mock();
var provisioner = new Mock();
dal.Setup(d => d.UpdateStatus(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()))
.Returns(Task.CompletedTask);
dal.Setup(d => d.AppendTimeline(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()))
.Returns(Task.CompletedTask);
clientDatabaseBLL.Setup(c => c.GetById(It.IsAny(), It.IsAny(), It.IsAny()))
.ReturnsAsync(new ClientDatabaseDTO { ClientDbId = 5, ClientDbCode = "acme01", DatabaseName = "acme01", DbServerId = 1, DatabaseRole = 0 });
dbServerBLL.Setup(d => d.GetByIdWithCredentials(It.IsAny(), It.IsAny(), It.IsAny()))
.ReturnsAsync(new DbServerDTO { DbServerId = 1, DbType = 0, HostName = "sql01" });
targetDbExecutor.Setup(t => t.BuildConnectionString(It.IsAny(), It.IsAny()))
.Returns("admin-conn-str");
targetDbExecutor.Setup(t => t.BuildConnectionStringForRoleAsync(
It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()))
.ReturnsAsync("role-conn-str");
var eventLogPublish = new EventLogPublish(null!, NullLogger.Instance);
var svc = new ChangeRequestBLL(
dal.Object, clientDatabaseBLL.Object, dbServerBLL.Object, targetDbExecutor.Object,
ddlScriptBLL.Object, provisioner.Object, new Mock().Object,
eventLogPublish, NullLogger.Instance);
return (svc, dal, targetDbExecutor);
}
private static LoginDTO TestLogin() => new() { ClientId = 42, UserId = -1 };
// Mirrors BuildService() but parameterizes ClientDatabaseDTO.TenantId — needed only by the
// {{TENANTID}} substitution test below, kept separate rather than widening BuildService()'s
// own return tuple and touching every existing test in this file.
private static (ChangeRequestBLL Svc, Mock Dal, Mock TargetDbExecutor)
BuildServiceWithTenant(int tenantId)
{
var dal = new Mock();
var clientDatabaseBLL = new Mock();
var dbServerBLL = new Mock();
var targetDbExecutor = new Mock();
var ddlScriptBLL = new Mock();
var provisioner = new Mock();
dal.Setup(d => d.UpdateStatus(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()))
.Returns(Task.CompletedTask);
dal.Setup(d => d.AppendTimeline(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()))
.Returns(Task.CompletedTask);
clientDatabaseBLL.Setup(c => c.GetById(It.IsAny(), It.IsAny(), It.IsAny()))
.ReturnsAsync(new ClientDatabaseDTO { ClientDbId = 5, ClientDbCode = "acme01", DatabaseName = "acme01", DbServerId = 1, DatabaseRole = 0, TenantId = tenantId });
dbServerBLL.Setup(d => d.GetByIdWithCredentials(It.IsAny(), It.IsAny(), It.IsAny()))
.ReturnsAsync(new DbServerDTO { DbServerId = 1, DbType = 0, HostName = "sql01" });
targetDbExecutor.Setup(t => t.BuildConnectionStringForRoleAsync(
It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()))
.ReturnsAsync("role-conn-str");
var eventLogPublish = new EventLogPublish(null!, NullLogger.Instance);
var svc = new ChangeRequestBLL(
dal.Object, clientDatabaseBLL.Object, dbServerBLL.Object, targetDbExecutor.Object,
ddlScriptBLL.Object, provisioner.Object, new Mock().Object,
eventLogPublish, NullLogger.Instance);
return (svc, dal, targetDbExecutor);
}
[Fact]
public async Task Test_Execute_DdlCr_ScriptContainsTenantIdToken_SubstitutesRealTenantIdBeforeExecuting()
{
var (svc, dal, targetDbExecutor) = BuildServiceWithTenant(tenantId: -1399999706);
var cr = new ChangeRequestDTO
{
ChangeRequestId = 203, ClientDatabaseId = 5, CrStatus = ChangeRequestStatus.Approved,
Category = ChangeRequestCategory.DataMigration, QueryType = ChangeRequestQueryType.DDL
};
dal.Setup(d => d.GetById(cr.ChangeRequestId, It.IsAny(), It.IsAny())).ReturnsAsync(cr);
dal.Setup(d => d.GetLinkedApprovedDdlScripts(cr.ChangeRequestId, It.IsAny(), It.IsAny()))
.ReturnsAsync(new List { new() { DdlScriptId = 1, SqlScript = "INSERT INTO MCURRENCY (TENANTID) VALUES ({{TENANTID}})" } });
string? capturedSql = null;
targetDbExecutor.Setup(t => t.ExecuteScriptAsync(It.IsAny(), It.IsAny(), It.IsAny()))
.Callback((_, sql, _) => capturedSql = sql)
.ReturnsAsync(1);
await svc.Execute(cr.ChangeRequestId, TestLogin(), CancellationToken.None);
Assert.Equal("INSERT INTO MCURRENCY (TENANTID) VALUES (-1399999706)", capturedSql);
}
[Fact]
public async Task Test_Execute_DdlQueryType_UsesDbaRole()
{
var (svc, dal, targetDbExecutor) = BuildService();
var cr = new ChangeRequestDTO
{
ChangeRequestId = 200, ClientDatabaseId = 5, CrStatus = ChangeRequestStatus.Approved,
Category = ChangeRequestCategory.SchemaChange, QueryType = ChangeRequestQueryType.DDL
};
dal.Setup(d => d.GetById(cr.ChangeRequestId, It.IsAny(), It.IsAny())).ReturnsAsync(cr);
dal.Setup(d => d.GetLinkedApprovedDdlScripts(cr.ChangeRequestId, It.IsAny(), It.IsAny()))
.ReturnsAsync(new List { new() { DdlScriptId = 1, SqlScript = "ALTER TABLE Foo ADD Bar INT" } });
await svc.Execute(cr.ChangeRequestId, TestLogin(), CancellationToken.None);
targetDbExecutor.Verify(t => t.BuildConnectionStringForRoleAsync(
It.IsAny(), It.IsAny(), ClientDbLoginRole.Dba, It.IsAny()), Times.Once);
targetDbExecutor.Verify(t => t.BuildConnectionString(It.IsAny(), It.IsAny()), Times.Never);
}
[Theory]
[InlineData(ChangeRequestQueryType.Insert)]
[InlineData(ChangeRequestQueryType.Update)]
[InlineData(ChangeRequestQueryType.Delete)]
public async Task Test_Execute_DmlQueryTypes_UseAppRole(ChangeRequestQueryType queryType)
{
var (svc, dal, targetDbExecutor) = BuildService();
var cr = new ChangeRequestDTO
{
ChangeRequestId = 201, ClientDatabaseId = 5, CrStatus = ChangeRequestStatus.Approved,
Category = ChangeRequestCategory.DataMigration, QueryType = queryType, Sql = "UPDATE Foo SET X = 1"
};
dal.Setup(d => d.GetById(cr.ChangeRequestId, It.IsAny(), It.IsAny())).ReturnsAsync(cr);
await svc.Execute(cr.ChangeRequestId, TestLogin(), CancellationToken.None);
targetDbExecutor.Verify(t => t.BuildConnectionStringForRoleAsync(
It.IsAny(), It.IsAny(), ClientDbLoginRole.App, It.IsAny()), Times.Once);
}
[Theory]
[InlineData(ChangeRequestQueryType.Select)]
[InlineData(ChangeRequestQueryType.Other)]
public async Task Test_Execute_SelectOrOtherQueryTypes_UseReadOnlyRole(ChangeRequestQueryType queryType)
{
var (svc, dal, targetDbExecutor) = BuildService();
var cr = new ChangeRequestDTO
{
ChangeRequestId = 202, ClientDatabaseId = 5, CrStatus = ChangeRequestStatus.Approved,
Category = ChangeRequestCategory.Performance, QueryType = queryType, Sql = "SELECT 1"
};
dal.Setup(d => d.GetById(cr.ChangeRequestId, It.IsAny(), It.IsAny())).ReturnsAsync(cr);
await svc.Execute(cr.ChangeRequestId, TestLogin(), CancellationToken.None);
targetDbExecutor.Verify(t => t.BuildConnectionStringForRoleAsync(
It.IsAny(), It.IsAny(), ClientDbLoginRole.ReadOnly, It.IsAny()), Times.Once);
}
// ── Per-user role-grant capping (SW.MSWUSERRESOURCEROLE) ────────────────
// Mirrors BuildService() but exposes the IUserResourceRoleBLL mock so these tests can
// configure a grant — kept separate rather than widening BuildService()'s own return tuple
// and touching every existing test in this file (same rationale as BuildServiceWithTenant).
private static (ChangeRequestBLL Svc, Mock Dal, Mock TargetDbExecutor, Mock UserResourceRoleBLL)
BuildServiceWithGrant()
{
var dal = new Mock();
var clientDatabaseBLL = new Mock();
var dbServerBLL = new Mock();
var targetDbExecutor = new Mock();
var ddlScriptBLL = new Mock();
var provisioner = new Mock();
var userResourceRoleBLL = new Mock();
dal.Setup(d => d.UpdateStatus(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()))
.Returns(Task.CompletedTask);
dal.Setup(d => d.AppendTimeline(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()))
.Returns(Task.CompletedTask);
clientDatabaseBLL.Setup(c => c.GetById(It.IsAny(), It.IsAny(), It.IsAny()))
.ReturnsAsync(new ClientDatabaseDTO { ClientDbId = 5, ClientDbCode = "acme01", DatabaseName = "acme01", DbServerId = 1, DatabaseRole = 0 });
dbServerBLL.Setup(d => d.GetByIdWithCredentials(It.IsAny(), It.IsAny(), It.IsAny()))
.ReturnsAsync(new DbServerDTO { DbServerId = 1, DbType = 0, HostName = "sql01" });
targetDbExecutor.Setup(t => t.BuildConnectionStringForRoleAsync(
It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()))
.ReturnsAsync("role-conn-str");
var eventLogPublish = new EventLogPublish(null!, NullLogger.Instance);
var svc = new ChangeRequestBLL(
dal.Object, clientDatabaseBLL.Object, dbServerBLL.Object, targetDbExecutor.Object,
ddlScriptBLL.Object, provisioner.Object, userResourceRoleBLL.Object,
eventLogPublish, NullLogger.Instance);
return (svc, dal, targetDbExecutor, userResourceRoleBLL);
}
[Fact]
public async Task Test_Execute_NoGrantConfigured_FailsOpen_UsesRequestedRoleUncapped()
{
var (svc, dal, targetDbExecutor, userResourceRoleBLL) = BuildServiceWithGrant();
userResourceRoleBLL.Setup(u => u.GetEffectiveRole(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()))
.ReturnsAsync((ClientDbLoginRole?)null);
var cr = new ChangeRequestDTO
{
ChangeRequestId = 210, ClientDatabaseId = 5, CrStatus = ChangeRequestStatus.Approved,
Category = ChangeRequestCategory.SchemaChange, QueryType = ChangeRequestQueryType.DDL
};
dal.Setup(d => d.GetById(cr.ChangeRequestId, It.IsAny(), It.IsAny())).ReturnsAsync(cr);
dal.Setup(d => d.GetLinkedApprovedDdlScripts(cr.ChangeRequestId, It.IsAny(), It.IsAny()))
.ReturnsAsync(new List { new() { DdlScriptId = 1, SqlScript = "ALTER TABLE Foo ADD Bar INT" } });
await svc.Execute(cr.ChangeRequestId, TestLogin(), CancellationToken.None);
// No grant configured at all — brand-new authorization layer, must not lock out a user
// with nothing configured yet — the DDL request runs uncapped, as Dba, same as before
// this feature existed.
targetDbExecutor.Verify(t => t.BuildConnectionStringForRoleAsync(
It.IsAny(), It.IsAny(), ClientDbLoginRole.Dba, It.IsAny()), Times.Once);
}
[Fact]
public async Task Test_Execute_ReadOnlyGrant_CapsDdlRequestDownToReadOnly()
{
var (svc, dal, targetDbExecutor, userResourceRoleBLL) = BuildServiceWithGrant();
userResourceRoleBLL.Setup(u => u.GetEffectiveRole(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()))
.ReturnsAsync((ClientDbLoginRole?)ClientDbLoginRole.ReadOnly);
var cr = new ChangeRequestDTO
{
ChangeRequestId = 211, ClientDatabaseId = 5, CrStatus = ChangeRequestStatus.Approved,
Category = ChangeRequestCategory.SchemaChange, QueryType = ChangeRequestQueryType.DDL
};
dal.Setup(d => d.GetById(cr.ChangeRequestId, It.IsAny(), It.IsAny())).ReturnsAsync(cr);
dal.Setup(d => d.GetLinkedApprovedDdlScripts(cr.ChangeRequestId, It.IsAny(), It.IsAny()))
.ReturnsAsync(new List { new() { DdlScriptId = 1, SqlScript = "ALTER TABLE Foo ADD Bar INT" } });
await svc.Execute(cr.ChangeRequestId, TestLogin(), CancellationToken.None);
// DDL naturally requires Dba, but this user is only granted ReadOnly — capped down, not
// upgraded to what the operation would otherwise need. The DDL script itself will then
// fail against a ReadOnly connection at the DB level, which is the correct outcome for
// an under-privileged user attempting a schema change.
targetDbExecutor.Verify(t => t.BuildConnectionStringForRoleAsync(
It.IsAny(), It.IsAny(), ClientDbLoginRole.ReadOnly, It.IsAny()), Times.Once);
}
[Fact]
public async Task Test_Execute_DbaGrant_DoesNotUpgradeASelectRequest()
{
var (svc, dal, targetDbExecutor, userResourceRoleBLL) = BuildServiceWithGrant();
userResourceRoleBLL.Setup(u => u.GetEffectiveRole(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()))
.ReturnsAsync((ClientDbLoginRole?)ClientDbLoginRole.Dba);
var cr = new ChangeRequestDTO
{
ChangeRequestId = 212, ClientDatabaseId = 5, CrStatus = ChangeRequestStatus.Approved,
Category = ChangeRequestCategory.Performance, QueryType = ChangeRequestQueryType.Select, Sql = "SELECT 1"
};
dal.Setup(d => d.GetById(cr.ChangeRequestId, It.IsAny(), It.IsAny())).ReturnsAsync(cr);
await svc.Execute(cr.ChangeRequestId, TestLogin(), CancellationToken.None);
// A broad Dba grant elsewhere never upgrades a request that only naturally needs
// ReadOnly — capping only ever restricts, it's not a privilege-elevation mechanism.
targetDbExecutor.Verify(t => t.BuildConnectionStringForRoleAsync(
It.IsAny(), It.IsAny(), ClientDbLoginRole.ReadOnly, It.IsAny()), Times.Once);
}
[Fact]
public async Task Test_Execute_ClientProvisioningCategory_UsesAdminConnectionNotRole()
{
var (svc, dal, targetDbExecutor) = BuildService();
var cr = new ChangeRequestDTO
{
ChangeRequestId = 203, ClientDatabaseId = 5, CrStatus = ChangeRequestStatus.Approved,
Category = ChangeRequestCategory.ClientProvisioning, QueryType = ChangeRequestQueryType.Other,
ProvisioningMode = ProvisioningMode.FromScripts, BaselineUpgradePackageId = 77
};
dal.Setup(d => d.GetById(cr.ChangeRequestId, It.IsAny(), It.IsAny())).ReturnsAsync(cr);
dal.Setup(d => d.GetLinkedApprovedDdlScripts(cr.ChangeRequestId, It.IsAny(), It.IsAny()))
.ReturnsAsync(Enumerable.Empty());
await svc.Execute(cr.ChangeRequestId, TestLogin(), CancellationToken.None);
targetDbExecutor.Verify(t => t.BuildConnectionString(It.IsAny(), It.IsAny()), Times.Once);
targetDbExecutor.Verify(t => t.BuildConnectionStringForRoleAsync(
It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never);
}
}