using System; using System.Collections.Generic; using System.Security.Cryptography; using System.Text; using System.Threading; using System.Threading.Tasks; using Moq; using PromotionExchangeBLL; using PromotionExchangeDAL.CustomCode; using PromotionExchangeDAL.DTO; using Xunit; namespace PromotionExchangeTests; public class PromotionExchangeBLLTests { private static string HashOf(string apiKey) => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(apiKey))).ToLowerInvariant(); private static (Mock Party, Mock Package, PromotionExchangeBLL.PromotionExchangeBLL Bll) NewSut() { var party = new Mock(); var package = new Mock(); var bll = new PromotionExchangeBLL.PromotionExchangeBLL(party.Object, package.Object); return (party, package, bll); } // ── UploadPackageAsync ──────────────────────────────────────────────── [Fact] public async Task UploadPackageAsync_ValidKeyAndDestination_InsertsAndReturnsPackageId() { var (party, package, bll) = NewSut(); var apiKey = "origin-raw-key"; var hash = HashOf(apiKey); party.Setup(p => p.GetByApiKeyHash(hash, It.IsAny())) .ReturnsAsync(new ExchangePartyDTO { EnvironmentCode = "ORIGIN1", ApiKeyHash = hash }); party.Setup(p => p.GetByEnvironmentCode("DEST1", It.IsAny())) .ReturnsAsync(new ExchangePartyDTO { EnvironmentCode = "DEST1" }); ExchangePackageDTO? captured = null; package.Setup(p => p.Insert(It.IsAny(), It.IsAny())) .Callback((dto, _) => captured = dto) .ReturnsAsync(42); var bytes = new byte[] { 1, 2, 3 }; var packageId = await bll.UploadPackageAsync(apiKey, "DEST1", bytes, CancellationToken.None); Assert.Equal(42, packageId); Assert.NotNull(captured); Assert.Equal("ORIGIN1", captured!.OriginEnvironmentCode); Assert.Equal("DEST1", captured.DestinationEnvironmentCode); Assert.Equal(3, captured.PackageSizeBytes); Assert.Same(bytes, captured.PackageBytes); } [Fact] public async Task UploadPackageAsync_NeverPassesRawApiKeyToPartyLookup() { var (party, package, bll) = NewSut(); var apiKey = "super-secret-raw-key"; party.Setup(p => p.GetByApiKeyHash(It.IsAny(), It.IsAny())) .ReturnsAsync(new ExchangePartyDTO { EnvironmentCode = "ORIGIN1" }); party.Setup(p => p.GetByEnvironmentCode("DEST1", It.IsAny())) .ReturnsAsync(new ExchangePartyDTO { EnvironmentCode = "DEST1" }); package.Setup(p => p.Insert(It.IsAny(), It.IsAny())).ReturnsAsync(1); await bll.UploadPackageAsync(apiKey, "DEST1", new byte[] { 1 }, CancellationToken.None); party.Verify(p => p.GetByApiKeyHash(apiKey, It.IsAny()), Times.Never); party.Verify(p => p.GetByApiKeyHash(HashOf(apiKey), It.IsAny()), Times.Once); } [Fact] public async Task UploadPackageAsync_UnknownApiKey_ThrowsUnauthorized() { var (party, _, bll) = NewSut(); party.Setup(p => p.GetByApiKeyHash(It.IsAny(), It.IsAny())) .ReturnsAsync((ExchangePartyDTO?)null); await Assert.ThrowsAsync( () => bll.UploadPackageAsync("bad-key", "DEST1", new byte[] { 1 }, CancellationToken.None)); } [Fact] public async Task UploadPackageAsync_UnknownDestination_ThrowsInvalidOperation() { var (party, _, bll) = NewSut(); party.Setup(p => p.GetByApiKeyHash(It.IsAny(), It.IsAny())) .ReturnsAsync(new ExchangePartyDTO { EnvironmentCode = "ORIGIN1" }); party.Setup(p => p.GetByEnvironmentCode("NOPE", It.IsAny())) .ReturnsAsync((ExchangePartyDTO?)null); await Assert.ThrowsAsync( () => bll.UploadPackageAsync("origin-key", "NOPE", new byte[] { 1 }, CancellationToken.None)); } [Fact] public async Task UploadPackageAsync_EmptyBytes_ThrowsArgumentException() { var (_, _, bll) = NewSut(); await Assert.ThrowsAsync( () => bll.UploadPackageAsync("origin-key", "DEST1", Array.Empty(), CancellationToken.None)); } // ── ListPendingAsync ────────────────────────────────────────────────── [Fact] public async Task ListPendingAsync_ResolvesDestinationFromApiKey_NeverFromAnyOtherInput() { var (party, package, bll) = NewSut(); party.Setup(p => p.GetByApiKeyHash(It.IsAny(), It.IsAny())) .ReturnsAsync(new ExchangePartyDTO { EnvironmentCode = "DEST1" }); var expected = new List { new() { PackageId = 7 } }; package.Setup(p => p.GetPendingForDestination("DEST1", It.IsAny())) .ReturnsAsync(expected); var result = await bll.ListPendingAsync("dest-raw-key", CancellationToken.None); Assert.Same(expected, result); package.Verify(p => p.GetPendingForDestination("DEST1", It.IsAny()), Times.Once); } [Fact] public async Task ListPendingAsync_UnknownApiKey_ThrowsUnauthorized() { var (party, _, bll) = NewSut(); party.Setup(p => p.GetByApiKeyHash(It.IsAny(), It.IsAny())) .ReturnsAsync((ExchangePartyDTO?)null); await Assert.ThrowsAsync( () => bll.ListPendingAsync("bad-key", CancellationToken.None)); } [Fact] public async Task ListPendingAsync_BlankApiKey_ThrowsUnauthorizedWithoutHittingDal() { var (party, _, bll) = NewSut(); await Assert.ThrowsAsync( () => bll.ListPendingAsync(" ", CancellationToken.None)); party.Verify(p => p.GetByApiKeyHash(It.IsAny(), It.IsAny()), Times.Never); } // ── DownloadPackageAsync ────────────────────────────────────────────── [Fact] public async Task DownloadPackageAsync_FoundForDestination_MarksDownloadedAndReturnsPackage() { var (party, package, bll) = NewSut(); party.Setup(p => p.GetByApiKeyHash(It.IsAny(), It.IsAny())) .ReturnsAsync(new ExchangePartyDTO { EnvironmentCode = "DEST1" }); var dto = new ExchangePackageDTO { PackageId = 7, DestinationEnvironmentCode = "DEST1", PackageBytes = new byte[] { 9 } }; package.Setup(p => p.GetByIdForDestination(7, "DEST1", It.IsAny())).ReturnsAsync(dto); var result = await bll.DownloadPackageAsync("dest-raw-key", 7, CancellationToken.None); Assert.Same(dto, result); package.Verify(p => p.MarkDownloaded(7, It.IsAny()), Times.Once); } // Security-critical: a party can never fetch another party's package by guessing an id - // GetByIdForDestination is always scoped to the CALLER's own resolved EnvironmentCode, so a // package addressed to someone else comes back null here regardless of a correct packageId. [Fact] public async Task DownloadPackageAsync_NotAddressedToCaller_ThrowsInvalidOperation() { var (party, package, bll) = NewSut(); party.Setup(p => p.GetByApiKeyHash(It.IsAny(), It.IsAny())) .ReturnsAsync(new ExchangePartyDTO { EnvironmentCode = "DEST1" }); package.Setup(p => p.GetByIdForDestination(7, "DEST1", It.IsAny())) .ReturnsAsync((ExchangePackageDTO?)null); await Assert.ThrowsAsync( () => bll.DownloadPackageAsync("dest-raw-key", 7, CancellationToken.None)); package.Verify(p => p.MarkDownloaded(It.IsAny(), It.IsAny()), Times.Never); } [Fact] public async Task DownloadPackageAsync_UnknownApiKey_ThrowsUnauthorized() { var (party, package, bll) = NewSut(); party.Setup(p => p.GetByApiKeyHash(It.IsAny(), It.IsAny())) .ReturnsAsync((ExchangePartyDTO?)null); await Assert.ThrowsAsync( () => bll.DownloadPackageAsync("bad-key", 7, CancellationToken.None)); package.Verify(p => p.GetByIdForDestination(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } }