using GB5Shared.Auth.Jwt; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using Moq; using PartnerBLL.Auth; using PartnerBLL.Options; using PartnerDAL.CustomCode.PartnerApiKey; using PartnerDAL.DTO.PartnerApiKey; using Xunit; namespace PartnerTests; /// /// Covers PartnerBLL.Auth.PartnerTokenBLL — the new Partner M2M client-credentials token /// issuance flow (Thread 7 of the plan tracker). A partner backend exchanges its existing /// TPARTNERAPIKEY secret here once for a short-lived JWT via the shared GB5Shared.Auth.Jwt /// issuer, instead of an unauthenticated PartnerProductId query parameter. /// public class PartnerTokenBLLTests { private const string TestIssuer = "GB5-Partner-Test"; private const string TestAudience = "GB5-Entitlement-Partner-M2M-Test"; private static (PartnerTokenBLL Svc, Mock ApiKeyDal, Mock Issuer) BuildService(int accessMinutes = 15) { var apiKeyDal = new Mock(); var issuer = new Mock(); issuer.Setup(i => i.IssueAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(new JwtIssuedAccessToken { AccessToken = "signed-m2m-token", ExpiresOn = DateTime.UtcNow.AddMinutes(15) }); var options = Options.Create(new PartnerM2MJwtOptions { Issuer = TestIssuer, Audience = TestAudience, AccessTokenMinutes = accessMinutes }); var svc = new PartnerTokenBLL(apiKeyDal.Object, issuer.Object, options, NullLogger.Instance); return (svc, apiKeyDal, issuer); } [Fact] public async Task Test_IssueTokenAsync_ValidApiKey_ReturnsSuccessWithToken() { var (svc, apiKeyDal, issuer) = BuildService(); apiKeyDal.Setup(d => d.GetApiKeyByHashFromSystemDbAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(new PartnerApiKeyListDTO { ApiKeyId = 5, PartnerId = 7, PartnerProductId = 42, Scopes = "partner,lic", Status = 1 }); apiKeyDal.Setup(d => d.UpdateApiKeyLastUsedInSystemDbAsync(5, It.IsAny())) .Returns(Task.CompletedTask); var result = await svc.IssueTokenAsync("some-valid-secret", CancellationToken.None); Assert.Equal(PartnerTokenResultStatus.Success, result.Status); Assert.Equal("signed-m2m-token", result.AccessToken); Assert.NotNull(result.ExpiresOn); issuer.Verify(i => i.IssueAsync( It.Is(c => ((PartnerM2MAccessTokenClaims)c).PartnerId == 7 && ((PartnerM2MAccessTokenClaims)c).PartnerProductId == 42 && ((PartnerM2MAccessTokenClaims)c).ApiKeyId == 5 && ((PartnerM2MAccessTokenClaims)c).Scopes == "partner,lic"), It.IsAny(), TestIssuer, TestAudience, 15, It.IsAny()), Times.Once); } [Fact] public async Task Test_IssueTokenAsync_UnknownApiKey_ReturnsInvalidApiKey_NoTokenIssued() { var (svc, apiKeyDal, issuer) = BuildService(); apiKeyDal.Setup(d => d.GetApiKeyByHashFromSystemDbAsync(It.IsAny(), It.IsAny())) .ReturnsAsync((PartnerApiKeyListDTO?)null); var result = await svc.IssueTokenAsync("wrong-secret", CancellationToken.None); Assert.Equal(PartnerTokenResultStatus.InvalidApiKey, result.Status); Assert.Null(result.AccessToken); issuer.Verify(i => i.IssueAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } [Theory] [InlineData("")] [InlineData(" ")] public async Task Test_IssueTokenAsync_EmptyOrWhitespaceApiKey_ReturnsInvalidApiKey_NeverQueriesDb(string blank) { var (svc, apiKeyDal, _) = BuildService(); var result = await svc.IssueTokenAsync(blank, CancellationToken.None); Assert.Equal(PartnerTokenResultStatus.InvalidApiKey, result.Status); apiKeyDal.Verify(d => d.GetApiKeyByHashFromSystemDbAsync(It.IsAny(), It.IsAny()), Times.Never); } [Fact] public async Task Test_IssueTokenAsync_ApiKeyRecordWithNullPartnerId_IsTreatedAsInvalid() { // Defensive case: the join in GET_API_KEY_BY_HASH should always populate PartnerId for an // active key (TPARTNERPRODUCT is an INNER JOIN), but if it's ever null, this must not // silently issue a token with a garbage PartnerId of 0. var (svc, apiKeyDal, issuer) = BuildService(); apiKeyDal.Setup(d => d.GetApiKeyByHashFromSystemDbAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(new PartnerApiKeyListDTO { ApiKeyId = 5, PartnerId = null, PartnerProductId = 42, Status = 1 }); var result = await svc.IssueTokenAsync("some-secret", CancellationToken.None); Assert.Equal(PartnerTokenResultStatus.InvalidApiKey, result.Status); issuer.Verify(i => i.IssueAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } }