using System.Data.Common;
using Dapr.Client;
using EntitlementBLL.Auth;
using EntitlementBLL.Legal;
using EntitlementBLL.Options;
using EntitlementDAL.DTOs;
using EntitlementDAL.Enums;
using EntitlementDAL.Interfaces;
using GB5Shared.DirectAction;
using GB5Shared.ActionProcessor;
using GB5Shared.DTO.Framework.CommonConfig;
using GB5Shared.DTO.Framework.Login;
using GB5Shared.EventLogPublish;
using GB5Shared.GenerateAutoNumber;
using GB5Shared.QueryExecutor;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Moq;
using Xunit;
namespace EntitlementTests;
///
/// Covers the individual-user first-login gate (tracker §50.3/§51.7):
/// ClientAuthBLL.LoginAsync computing PendingAgreementVersionIds (never blocking the login
/// itself, even on a lookup failure) and AcceptPendingAgreementsAsync recording acceptance
/// against the caller's own identity.
///
public class ClientAuthEulaGateTests
{
private static (ClientAuthBLL Svc, Mock ClientUserDal, Mock AgreementConsentProvider,
Mock ClientProvisioningDal) BuildService()
{
var clientUserDal = new Mock();
var clientUserRoleDal = new Mock();
clientUserRoleDal
.Setup(d => d.GetActiveRoleCodesAsync(It.IsAny(), It.IsAny(), It.IsAny()))
.ReturnsAsync(Enumerable.Empty());
var refreshTokenDal = new Mock();
var jwtService = new Mock();
jwtService.Setup(j => j.IssueTokenPairAsync(It.IsAny(), It.IsAny()))
.ReturnsAsync(new ClientTokenPair
{
AccessToken = "access-token", AccessTokenExpiresOn = DateTime.UtcNow.AddMinutes(15),
RefreshToken = "raw-refresh-token", RefreshTokenHash = "HASH-raw-refresh-token",
RefreshTokenExpiresOn = DateTime.UtcNow.AddDays(30)
});
clientUserDal.Setup(d => d.UpdateFailedLoginCountAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()))
.Returns(Task.CompletedTask);
clientUserDal.Setup(d => d.UpdateLastLoginOnAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()))
.Returns(Task.CompletedTask);
refreshTokenDal.Setup(d => d.SaveAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()))
.Returns(Task.CompletedTask);
var queryExecutor = new Mock();
queryExecutor
.Setup(q => q.QueryAsync(It.IsAny(), It.IsAny(), It.IsAny(),
It.IsAny(), It.IsAny(), It.IsAny()))
.ReturnsAsync(new List { 101 });
queryExecutor.Setup(q => q.BeginTransactionAsync(It.IsAny())).ReturnsAsync((DbTransaction)null!);
queryExecutor.Setup(q => q.CommitAsync(It.IsAny())).Returns(Task.CompletedTask);
queryExecutor.Setup(q => q.RollbackAsync(It.IsAny())).Returns(Task.CompletedTask);
var dbConfig = new Mock>();
dbConfig.Setup(o => o.Value).Returns(new Gb5SystemDTO { DataBaseType = 0 });
var autoNumber = new AutoNumber(queryExecutor.Object, dbConfig.Object);
var lockoutOptions = Options.Create(new ClientAccountLockoutOptions());
var passwordResetOptions = Options.Create(new ClientPasswordResetOptions());
var tokenService = new Mock();
var directActionTokenDal = new Mock();
var eventActionRunDal = new Mock();
var actionOutboxDal = new Mock();
var eventLog = new EventLogPublish(new DaprClientBuilder().Build(), NullLogger.Instance);
var agreementConsentProvider = new Mock();
var clientProvisioningDal = new Mock();
var svc = new ClientAuthBLL(
clientUserDal.Object, clientUserRoleDal.Object, refreshTokenDal.Object, jwtService.Object, autoNumber, queryExecutor.Object,
lockoutOptions, tokenService.Object, directActionTokenDal.Object, eventActionRunDal.Object,
actionOutboxDal.Object, passwordResetOptions, eventLog, agreementConsentProvider.Object, clientProvisioningDal.Object, NullLogger.Instance);
return (svc, clientUserDal, agreementConsentProvider, clientProvisioningDal);
}
private static ClientUserDTO ActiveAdmin(string password) => new()
{
ClientUserId = 1, ClientId = 42, Role = (byte)ClientUserRoleEnum.ClientAdmin,
Status = (byte)ClientUserStatusEnum.Active,
PasswordHash = GB5Shared.EncryptionHelper.PasswordHasher.Hash(password)
};
private static ClientUserDTO ActiveUser(string password) => new()
{
ClientUserId = 2, ClientId = 42, Role = (byte)ClientUserRoleEnum.ClientUser,
Status = (byte)ClientUserStatusEnum.Active,
PasswordHash = GB5Shared.EncryptionHelper.PasswordHasher.Hash(password)
};
[Fact]
public async Task Test_LoginAsync_NothingPending_ReturnsEmptyArray()
{
var (svc, clientUserDal, agreementConsentProvider, _) = BuildService();
const string password = "Correct-Horse-Battery-Staple";
clientUserDal.Setup(d => d.GetByClientAndEmailAsync(42, "admin@example.com", It.IsAny(), It.IsAny()))
.ReturnsAsync(ActiveAdmin(password));
agreementConsentProvider
.Setup(a => a.GetPendingIndividualAgreementVersionIdsAsync(AgreementSubjectType.ClientAdmin, 1, It.IsAny(), It.IsAny(), It.IsAny()))
.ReturnsAsync(Array.Empty());
var result = await svc.LoginAsync(42, "admin@example.com", password, new LoginDTO { ClientId = 42, UserId = -1 }, CancellationToken.None);
Assert.Equal(ClientLoginResultStatus.Success, result.Status);
Assert.Empty(result.PendingAgreementVersionIds);
}
[Fact]
public async Task Test_LoginAsync_ResolvesRealJurisdictionCode_PassesItToPendingLookup()
{
var (svc, clientUserDal, agreementConsentProvider, clientProvisioningDal) = BuildService();
const string password = "Correct-Horse-Battery-Staple";
clientUserDal.Setup(d => d.GetByClientAndEmailAsync(42, "admin@example.com", It.IsAny(), It.IsAny()))
.ReturnsAsync(ActiveAdmin(password));
clientProvisioningDal
.Setup(d => d.GetClientJurisdictionCodeAsync(42, It.IsAny(), It.IsAny()))
.ReturnsAsync("UAE");
agreementConsentProvider
.Setup(a => a.GetPendingIndividualAgreementVersionIdsAsync(AgreementSubjectType.ClientAdmin, 1, "UAE", It.IsAny(), It.IsAny()))
.ReturnsAsync(Array.Empty());
var result = await svc.LoginAsync(42, "admin@example.com", password, new LoginDTO { ClientId = 42, UserId = -1 }, CancellationToken.None);
Assert.Equal(ClientLoginResultStatus.Success, result.Status);
agreementConsentProvider.Verify(a => a.GetPendingIndividualAgreementVersionIdsAsync(
AgreementSubjectType.ClientAdmin, 1, "UAE", It.IsAny(), It.IsAny()), Times.Once);
}
[Fact]
public async Task Test_LoginAsync_NoJurisdictionCaptured_FallsBackToUniversalEmptyString()
{
var (svc, clientUserDal, agreementConsentProvider, clientProvisioningDal) = BuildService();
const string password = "Correct-Horse-Battery-Staple";
clientUserDal.Setup(d => d.GetByClientAndEmailAsync(42, "admin@example.com", It.IsAny(), It.IsAny()))
.ReturnsAsync(ActiveAdmin(password));
clientProvisioningDal
.Setup(d => d.GetClientJurisdictionCodeAsync(42, It.IsAny(), It.IsAny()))
.ReturnsAsync((string?)null);
agreementConsentProvider
.Setup(a => a.GetPendingIndividualAgreementVersionIdsAsync(AgreementSubjectType.ClientAdmin, 1, string.Empty, It.IsAny(), It.IsAny()))
.ReturnsAsync(Array.Empty());
var result = await svc.LoginAsync(42, "admin@example.com", password, new LoginDTO { ClientId = 42, UserId = -1 }, CancellationToken.None);
Assert.Equal(ClientLoginResultStatus.Success, result.Status);
agreementConsentProvider.Verify(a => a.GetPendingIndividualAgreementVersionIdsAsync(
AgreementSubjectType.ClientAdmin, 1, string.Empty, It.IsAny(), It.IsAny()), Times.Once);
}
[Fact]
public async Task Test_LoginAsync_ClientAdmin_PendingEula_ReturnsVersionIds_AndStillIssuesToken()
{
var (svc, clientUserDal, agreementConsentProvider, _) = BuildService();
const string password = "Correct-Horse-Battery-Staple";
clientUserDal.Setup(d => d.GetByClientAndEmailAsync(42, "admin@example.com", It.IsAny(), It.IsAny()))
.ReturnsAsync(ActiveAdmin(password));
agreementConsentProvider
.Setup(a => a.GetPendingIndividualAgreementVersionIdsAsync(AgreementSubjectType.ClientAdmin, 1, It.IsAny(), It.IsAny(), It.IsAny()))
.ReturnsAsync(new[] { -1370000301 });
var result = await svc.LoginAsync(42, "admin@example.com", password, new LoginDTO { ClientId = 42, UserId = -1 }, CancellationToken.None);
Assert.Equal(ClientLoginResultStatus.Success, result.Status);
Assert.NotNull(result.TokenPair);
Assert.Single(result.PendingAgreementVersionIds);
Assert.Equal(-1370000301, result.PendingAgreementVersionIds[0]);
}
[Fact]
public async Task Test_LoginAsync_EndUserRole_ChecksEndUserSubjectType_NotClientAdmin()
{
var (svc, clientUserDal, agreementConsentProvider, _) = BuildService();
const string password = "Correct-Horse-Battery-Staple";
clientUserDal.Setup(d => d.GetByClientAndEmailAsync(42, "user@example.com", It.IsAny(), It.IsAny()))
.ReturnsAsync(ActiveUser(password));
agreementConsentProvider
.Setup(a => a.GetPendingIndividualAgreementVersionIdsAsync(AgreementSubjectType.EndUser, 2, It.IsAny(), It.IsAny(), It.IsAny()))
.ReturnsAsync(new[] { -1370000301 });
var result = await svc.LoginAsync(42, "user@example.com", password, new LoginDTO { ClientId = 42, UserId = -1 }, CancellationToken.None);
Assert.Equal(ClientLoginResultStatus.Success, result.Status);
Assert.Single(result.PendingAgreementVersionIds);
agreementConsentProvider.Verify(a => a.GetPendingIndividualAgreementVersionIdsAsync(
AgreementSubjectType.ClientAdmin, It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never);
}
[Fact]
public async Task Test_LoginAsync_PendingLookupThrows_LoginStillSucceeds_WithEmptyPendingList()
{
var (svc, clientUserDal, agreementConsentProvider, _) = BuildService();
const string password = "Correct-Horse-Battery-Staple";
clientUserDal.Setup(d => d.GetByClientAndEmailAsync(42, "admin@example.com", It.IsAny(), It.IsAny()))
.ReturnsAsync(ActiveAdmin(password));
agreementConsentProvider
.Setup(a => a.GetPendingIndividualAgreementVersionIdsAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()))
.ThrowsAsync(new InvalidOperationException("DB unavailable"));
var result = await svc.LoginAsync(42, "admin@example.com", password, new LoginDTO { ClientId = 42, UserId = -1 }, CancellationToken.None);
// The whole point of this gate: a failure to compute pending agreements is never a
// reason to fail the login itself — a real token is still issued.
Assert.Equal(ClientLoginResultStatus.Success, result.Status);
Assert.NotNull(result.TokenPair);
Assert.Empty(result.PendingAgreementVersionIds);
}
[Fact]
public async Task Test_AcceptPendingAgreementsAsync_NoVersionIds_Throws()
{
var (svc, _, _, _) = BuildService();
await Assert.ThrowsAsync(() =>
svc.AcceptPendingAgreementsAsync(1, ClientRoleCodes.ClientAdmin, Array.Empty(), "1.2.3.4", "UA",
new LoginDTO { ClientId = 42, UserId = -1 }, CancellationToken.None));
}
[Fact]
public async Task Test_AcceptPendingAgreementsAsync_ClientAdmin_RecordsAsClientAdminSubjectType()
{
var (svc, _, agreementConsentProvider, _) = BuildService();
agreementConsentProvider
.Setup(a => a.RecordAcceptanceAsync(
AgreementSubjectType.ClientAdmin, 1, It.IsAny(), "1.2.3.4", "UA",
AgreementAcceptanceMethod.Clickwrap, It.IsAny(), It.IsAny(), It.IsAny()))
.Returns(Task.CompletedTask);
await svc.AcceptPendingAgreementsAsync(1, ClientRoleCodes.ClientAdmin, new[] { -1370000301 }, "1.2.3.4", "UA",
new LoginDTO { ClientId = 42, UserId = -1 }, CancellationToken.None);
agreementConsentProvider.Verify(a => a.RecordAcceptanceAsync(
AgreementSubjectType.ClientAdmin, 1, It.Is(ids => ids.Length == 1 && ids[0] == -1370000301),
"1.2.3.4", "UA", AgreementAcceptanceMethod.Clickwrap, It.IsAny(), It.IsAny(), It.IsAny()), Times.Once);
}
[Fact]
public async Task Test_AcceptPendingAgreementsAsync_ClientUser_RecordsAsEndUserSubjectType()
{
var (svc, _, agreementConsentProvider, _) = BuildService();
agreementConsentProvider
.Setup(a => a.RecordAcceptanceAsync(
AgreementSubjectType.EndUser, 2, It.IsAny(), null, null,
AgreementAcceptanceMethod.Clickwrap, It.IsAny(), It.IsAny(), It.IsAny()))
.Returns(Task.CompletedTask);
await svc.AcceptPendingAgreementsAsync(2, ClientRoleCodes.ClientUser, new[] { -1370000301 }, null, null,
new LoginDTO { ClientId = 42, UserId = -1 }, CancellationToken.None);
agreementConsentProvider.Verify(a => a.RecordAcceptanceAsync(
AgreementSubjectType.EndUser, 2, It.IsAny(), null, null,
AgreementAcceptanceMethod.Clickwrap, It.IsAny(), It.IsAny(), It.IsAny()), Times.Once);
}
}