diff --git a/BiatecTokensApi/Controllers/TokenController.cs b/BiatecTokensApi/Controllers/TokenController.cs index 51bc428..9400931 100644 --- a/BiatecTokensApi/Controllers/TokenController.cs +++ b/BiatecTokensApi/Controllers/TokenController.cs @@ -90,6 +90,7 @@ public TokenController( /// If a request with the same key is received within 24 hours, the cached response will be returned. /// This prevents accidental duplicate deployments. /// + [TokenDeploymentSubscription] [IdempotencyKey] [HttpPost("erc20-mintable/create")] [ProducesResponseType(typeof(EVMTokenDeploymentResponse), StatusCodes.Status200OK)] @@ -153,6 +154,7 @@ public async Task ERC20MintableTokenCreate([FromBody] ERC20Mintab /// response with an if the deployment is successful. Returns a 400 Bad /// Request response if the request model is invalid. Returns a 500 Internal Server Error response if an error /// occurs during deployment. + [TokenDeploymentSubscription] [IdempotencyKey] [HttpPost("erc20-preminted/create")] [ProducesResponseType(typeof(EVMTokenDeploymentResponse), StatusCodes.Status200OK)] @@ -212,6 +214,7 @@ public async Task ERC20PremnitedTokenCreate([FromBody] ERC20Premi /// if the token is created successfully. Returns a 400 Bad Request /// response if the request model is invalid. Returns a 500 Internal Server Error response if an error occurs /// during token creation. + [TokenDeploymentSubscription] [IdempotencyKey] [HttpPost("asa-ft/create")] [ProducesResponseType(typeof(ASATokenDeploymentResponse), StatusCodes.Status200OK)] @@ -269,6 +272,7 @@ public async Task CreateASAToken([FromBody] ASAFungibleTokenDeplo /// if the token is created successfully. Returns a 400 Bad Request /// response if the request is invalid. Returns a 500 Internal Server Error response if an unexpected error /// occurs during the operation. + [TokenDeploymentSubscription] [IdempotencyKey] [HttpPost("asa-nft/create")] [ProducesResponseType(typeof(ASATokenDeploymentResponse), StatusCodes.Status200OK)] @@ -328,6 +332,7 @@ public async Task CreateASANFT([FromBody] ASANonFungibleTokenDepl /// if the token is created successfully. Returns a 400 Bad Request /// response if the request is invalid. Returns a 500 Internal Server Error response if an unexpected error /// occurs during the operation. + [TokenDeploymentSubscription] [IdempotencyKey] [HttpPost("asa-fnft/create")] [ProducesResponseType(typeof(ASATokenDeploymentResponse), StatusCodes.Status200OK)] @@ -384,6 +389,7 @@ public async Task CreateASAFNFT([FromBody] ASAFractionalNonFungib /// /// ARC3 token creation parameters including metadata /// Creation result with asset ID and transaction details + [TokenDeploymentSubscription] [IdempotencyKey] [HttpPost("arc3-ft/create")] [ProducesResponseType(typeof(ARC3TokenDeploymentResponse), StatusCodes.Status200OK)] @@ -443,6 +449,7 @@ public async Task CreateARC3FungibleToken([FromBody] ARC3Fungible /// created successfully. A 400 Bad Request response if the request /// model is invalid. A 500 Internal Server Error response if an /// unexpected error occurs during token creation. + [TokenDeploymentSubscription] [IdempotencyKey] [HttpPost("arc3-nft/create")] [ProducesResponseType(typeof(ARC3TokenDeploymentResponse), StatusCodes.Status200OK)] @@ -501,6 +508,7 @@ public async Task CreateARC3NFT([FromBody] ARC3NonFungibleTokenDe /// An containing the result of the operation: - A 200 OK response with an if the token is successfully created. - A 400 Bad Request response if /// the model state is invalid. - A 500 Internal Server Error response if an error occurs during token creation. + [TokenDeploymentSubscription] [IdempotencyKey] [HttpPost("arc3-fnft/create")] [ProducesResponseType(typeof(ARC3TokenDeploymentResponse), StatusCodes.Status200OK)] @@ -558,6 +566,7 @@ public async Task CreateARC3FractionalNFT([FromBody] ARC3Fraction /// with an if the token is successfully created. - A 400 Bad Request /// response if the request is invalid. - A 500 Internal Server Error response if an unexpected error occurs /// during the operation. + [TokenDeploymentSubscription] [IdempotencyKey] [HttpPost("arc200-mintable/create")] [ProducesResponseType(typeof(ARC200TokenDeploymentResponse), StatusCodes.Status200OK)] @@ -615,6 +624,7 @@ public async Task ARC200MintableTokenDeploymentRequest([FromBody] /// response with an if the token is created successfully, a 400 Bad /// Request response if the request is invalid, or a 500 Internal Server Error response if an unexpected error /// occurs. + [TokenDeploymentSubscription] [IdempotencyKey] [HttpPost("arc200-preminted/create")] [ProducesResponseType(typeof(ARC3TokenDeploymentResponse), StatusCodes.Status200OK)] @@ -672,6 +682,7 @@ public async Task CreateARC200Preminted([FromBody] ARC200Preminte /// with an if the token is successfully created. - A 400 Bad Request /// response if the request is invalid. - A 500 Internal Server Error response if an unexpected error occurs /// during the operation. + [TokenDeploymentSubscription] [IdempotencyKey] [HttpPost("arc1400-mintable/create")] [ProducesResponseType(typeof(ARC200TokenDeploymentResponse), StatusCodes.Status200OK)] diff --git a/BiatecTokensApi/Filters/IdempotencyAttribute.cs b/BiatecTokensApi/Filters/IdempotencyAttribute.cs index 11b3ef9..3cc705d 100644 --- a/BiatecTokensApi/Filters/IdempotencyAttribute.cs +++ b/BiatecTokensApi/Filters/IdempotencyAttribute.cs @@ -2,6 +2,8 @@ using Microsoft.AspNetCore.Mvc.Filters; using System.Collections.Concurrent; using System.Text.Json; +using System.Security.Cryptography; +using System.Text; namespace BiatecTokensApi.Filters { @@ -12,6 +14,10 @@ namespace BiatecTokensApi.Filters /// This filter ensures that duplicate requests with the same idempotency key return the same response. /// It stores the response in memory for a configurable duration (default 24 hours). /// + /// **Security:** The filter validates that cached requests match current request parameters. + /// If the same idempotency key is used with different parameters, a warning is logged and + /// the request is rejected to prevent bypassing business logic. + /// /// Usage: /// [IdempotencyKey] /// [HttpPost("create")] @@ -49,28 +55,57 @@ public override async Task OnActionExecutionAsync(ActionExecutingContext context } var key = idempotencyKey.ToString(); + var logger = context.HttpContext.RequestServices.GetService>(); // Clean up expired entries periodically CleanupExpiredEntries(); + // Compute hash of request parameters for validation + var requestHash = ComputeRequestHash(context, context.ActionArguments); + // Check if we've seen this key before if (_cache.TryGetValue(key, out var record)) { // Check if the record has expired if (DateTime.UtcNow - record.Timestamp < Expiration) { + // Validate that the request parameters match + if (record.RequestHash != requestHash) + { + logger?.LogWarning( + "Idempotency key reused with different parameters. Key: {Key}, CorrelationId: {CorrelationId}", + key, context.HttpContext.TraceIdentifier); + + context.Result = new BadRequestObjectResult(new + { + success = false, + errorCode = "IDEMPOTENCY_KEY_MISMATCH", + errorMessage = "The provided idempotency key has been used with different request parameters. Please use a unique key for this request or reuse the same parameters.", + correlationId = context.HttpContext.TraceIdentifier + }); + return; + } + // Return cached response context.Result = new ObjectResult(record.Response) { StatusCode = record.StatusCode }; - context.HttpContext.Response.Headers.Add("X-Idempotency-Hit", "true"); + context.HttpContext.Response.Headers["X-Idempotency-Hit"] = "true"; + + logger?.LogDebug( + "Idempotency cache hit. Key: {Key}, CorrelationId: {CorrelationId}", + key, context.HttpContext.TraceIdentifier); + return; } else { // Expired - remove it and continue _cache.TryRemove(key, out _); + logger?.LogDebug( + "Idempotency record expired and removed. Key: {Key}", + key); } } @@ -85,11 +120,49 @@ public override async Task OnActionExecutionAsync(ActionExecutingContext context Key = key, Response = objectResult.Value, StatusCode = objectResult.StatusCode ?? 200, - Timestamp = DateTime.UtcNow + Timestamp = DateTime.UtcNow, + RequestHash = requestHash }; _cache.TryAdd(key, newRecord); - executedContext.HttpContext.Response.Headers.Add("X-Idempotency-Hit", "false"); + executedContext.HttpContext.Response.Headers["X-Idempotency-Hit"] = "false"; + + logger?.LogDebug( + "Idempotency record cached. Key: {Key}, StatusCode: {StatusCode}", + key, newRecord.StatusCode); + } + } + + /// + /// Computes a hash of the request parameters for validation + /// + /// Action executing context + /// Action arguments + /// Hash string + private string ComputeRequestHash(ActionExecutingContext context, IDictionary arguments) + { + try + { + // Serialize arguments to JSON for consistent hashing + var json = JsonSerializer.Serialize(arguments, new JsonSerializerOptions + { + WriteIndented = false, + DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.Never + }); + + // Compute SHA256 hash + using var sha256 = SHA256.Create(); + var hashBytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(json)); + return Convert.ToBase64String(hashBytes); + } + catch (Exception ex) + { + // Log serialization failure and return empty hash + var logger = context.HttpContext?.RequestServices?.GetService>(); + logger?.LogWarning(ex, "Failed to compute request hash for idempotency check. Returning empty hash."); + + // Return empty hash (will not match cached requests, treating as new request) + return string.Empty; } } @@ -122,6 +195,7 @@ private class IdempotencyRecord public object? Response { get; set; } public int StatusCode { get; set; } public DateTime Timestamp { get; set; } + public string RequestHash { get; set; } = string.Empty; } } } diff --git a/BiatecTokensApi/Filters/TokenDeploymentSubscriptionAttribute.cs b/BiatecTokensApi/Filters/TokenDeploymentSubscriptionAttribute.cs new file mode 100644 index 0000000..8d33c8d --- /dev/null +++ b/BiatecTokensApi/Filters/TokenDeploymentSubscriptionAttribute.cs @@ -0,0 +1,124 @@ +using BiatecTokensApi.Helpers; +using BiatecTokensApi.Models; +using BiatecTokensApi.Services.Interface; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; + +namespace BiatecTokensApi.Filters +{ + /// + /// Action filter that validates subscription tier entitlements for token deployment operations + /// + /// + /// This filter enforces subscription-based access control by validating that users have + /// appropriate tier permissions and available deployment quota before proceeding with token deployment. + /// When validation fails, it returns a clear error response that prompts users to upgrade their subscription. + /// + /// **Enforcement Policy:** + /// - Free tier: Limited token deployments (3) + /// - Basic tier: Moderate token deployments (10) + /// - Premium tier: Generous token deployments (50) + /// - Enterprise tier: Unlimited token deployments + /// + /// **Usage:** + /// Apply this attribute to token deployment endpoints: + /// [TokenDeploymentSubscription] + /// [HttpPost("create")] + /// public async Task<IActionResult> CreateToken(...) { ... } + /// + [AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = true)] + public class TokenDeploymentSubscriptionAttribute : ActionFilterAttribute + { + /// + /// Called before the action executes to validate subscription tier and deployment quota + /// + /// The action executing context + public override async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) + { + // Get required services from DI + var tierService = context.HttpContext.RequestServices.GetService(); + var logger = context.HttpContext.RequestServices.GetService>(); + + if (tierService == null || logger == null) + { + logger?.LogWarning("TokenDeploymentSubscriptionAttribute: Required services not available"); + await next(); + return; + } + + // Get user address from claims + var userAddress = context.HttpContext.User.Identity?.Name; + if (string.IsNullOrEmpty(userAddress)) + { + logger.LogWarning("TokenDeploymentSubscriptionAttribute: No user identity found"); + context.Result = new UnauthorizedObjectResult(new ApiErrorResponse + { + Success = false, + ErrorCode = ErrorCodes.UNAUTHORIZED, + ErrorMessage = "Authentication required", + RemediationHint = "Provide a valid ARC-0014 authentication token in the Authorization header.", + Timestamp = DateTime.UtcNow, + CorrelationId = context.HttpContext.TraceIdentifier, + Path = LoggingHelper.SanitizeLogInput(context.HttpContext.Request.Path) + }); + return; + } + + // Sanitize user address for logging + var sanitizedUserAddress = LoggingHelper.SanitizeLogInput(userAddress); + + // Check if user can deploy tokens + var canDeploy = await tierService.CanDeployTokenAsync(userAddress); + + if (!canDeploy) + { + // Get tier details for error message + var currentTier = await tierService.GetUserTierAsync(userAddress); + var tierLimits = tierService.GetTierLimits(currentTier); + var currentCount = await tierService.GetTokenDeploymentCountAsync(userAddress); + + logger.LogWarning( + "Token deployment denied for user {UserAddress}: Deployment limit reached. Current tier: {Tier}, Current count: {Count}, Max: {Max}. CorrelationId: {CorrelationId}", + sanitizedUserAddress, currentTier, currentCount, tierLimits.MaxTokenDeployments, context.HttpContext.TraceIdentifier); + + context.Result = new ObjectResult(new ApiErrorResponse + { + Success = false, + ErrorCode = ErrorCodes.SUBSCRIPTION_LIMIT_REACHED, + ErrorMessage = $"Token deployment limit reached for {tierLimits.TierName} tier. You have deployed {currentCount} of {tierLimits.MaxTokenDeployments} allowed tokens.", + RemediationHint = $"Upgrade to a higher tier to deploy more tokens. Current tier: {tierLimits.TierName}. Visit the billing page to upgrade your subscription.", + Details = new Dictionary + { + { "currentTier", currentTier.ToString() }, + { "currentDeployments", currentCount }, + { "maxDeployments", tierLimits.MaxTokenDeployments }, + { "tierDescription", tierLimits.Description } + }, + Timestamp = DateTime.UtcNow, + CorrelationId = context.HttpContext.TraceIdentifier, + Path = LoggingHelper.SanitizeLogInput(context.HttpContext.Request.Path) + }) + { + StatusCode = StatusCodes.Status402PaymentRequired + }; + return; + } + + // Execute the action + var executedContext = await next(); + + // If the action was successful (2xx status code), record the deployment + var statusCode = executedContext.HttpContext.Response.StatusCode; + if (statusCode >= 200 && statusCode < 300) + { + // Record the deployment + await tierService.RecordTokenDeploymentAsync(userAddress); + + var newCount = await tierService.GetTokenDeploymentCountAsync(userAddress); + logger.LogInformation( + "Token deployment recorded for user {UserAddress}. New deployment count: {Count}. CorrelationId: {CorrelationId}", + sanitizedUserAddress, newCount, context.HttpContext.TraceIdentifier); + } + } + } +} diff --git a/BiatecTokensApi/Models/Subscription/SubscriptionTier.cs b/BiatecTokensApi/Models/Subscription/SubscriptionTier.cs index 9812ef3..437bdc4 100644 --- a/BiatecTokensApi/Models/Subscription/SubscriptionTier.cs +++ b/BiatecTokensApi/Models/Subscription/SubscriptionTier.cs @@ -57,6 +57,11 @@ public class SubscriptionTierLimits /// public int MaxAddressesPerAsset { get; set; } + /// + /// Maximum number of token deployments allowed (-1 for unlimited) + /// + public int MaxTokenDeployments { get; set; } = -1; + /// /// Human-readable tier name /// @@ -81,6 +86,11 @@ public class SubscriptionTierLimits /// Whether bulk operations are available in this tier /// public bool BulkOperationsEnabled { get; set; } = true; + + /// + /// Whether token deployment is enabled in this tier + /// + public bool TokenDeploymentEnabled { get; set; } = true; } /// @@ -99,11 +109,13 @@ public class SubscriptionTierConfiguration { Tier = SubscriptionTier.Free, MaxAddressesPerAsset = 10, + MaxTokenDeployments = 3, // Free tier: limited token deployments for testing TierName = "Free", - Description = "Free tier with up to 10 whitelisted addresses per asset", + Description = "Free tier with up to 10 whitelisted addresses per asset and 3 token deployments", TransferValidationEnabled = true, AuditLogEnabled = false, - BulkOperationsEnabled = false + BulkOperationsEnabled = false, + TokenDeploymentEnabled = true } }, { @@ -112,11 +124,13 @@ public class SubscriptionTierConfiguration { Tier = SubscriptionTier.Basic, MaxAddressesPerAsset = 100, + MaxTokenDeployments = 10, // Basic tier: moderate token deployments TierName = "Basic", - Description = "Basic tier with up to 100 whitelisted addresses per asset", + Description = "Basic tier with up to 100 whitelisted addresses per asset and 10 token deployments", TransferValidationEnabled = true, AuditLogEnabled = true, - BulkOperationsEnabled = false + BulkOperationsEnabled = false, + TokenDeploymentEnabled = true } }, { @@ -125,11 +139,13 @@ public class SubscriptionTierConfiguration { Tier = SubscriptionTier.Premium, MaxAddressesPerAsset = 1000, + MaxTokenDeployments = 50, // Premium tier: generous token deployments TierName = "Premium", - Description = "Premium tier with up to 1,000 whitelisted addresses per asset", + Description = "Premium tier with up to 1,000 whitelisted addresses per asset and 50 token deployments", TransferValidationEnabled = true, AuditLogEnabled = true, - BulkOperationsEnabled = true + BulkOperationsEnabled = true, + TokenDeploymentEnabled = true } }, { @@ -138,11 +154,13 @@ public class SubscriptionTierConfiguration { Tier = SubscriptionTier.Enterprise, MaxAddressesPerAsset = -1, // Unlimited + MaxTokenDeployments = -1, // Unlimited TierName = "Enterprise", - Description = "Enterprise tier with unlimited whitelisted addresses per asset", + Description = "Enterprise tier with unlimited whitelisted addresses and token deployments", TransferValidationEnabled = true, AuditLogEnabled = true, - BulkOperationsEnabled = true + BulkOperationsEnabled = true, + TokenDeploymentEnabled = true } } }; diff --git a/BiatecTokensApi/Services/Interface/ISubscriptionTierService.cs b/BiatecTokensApi/Services/Interface/ISubscriptionTierService.cs index ab90b88..e208217 100644 --- a/BiatecTokensApi/Services/Interface/ISubscriptionTierService.cs +++ b/BiatecTokensApi/Services/Interface/ISubscriptionTierService.cs @@ -56,6 +56,27 @@ Task ValidateOperationAsync( /// Current number of whitelisted addresses /// Remaining capacity (-1 for unlimited) Task GetRemainingCapacityAsync(string userAddress, int currentCount); + + /// + /// Checks if token deployment is allowed for the user's tier + /// + /// The user's Algorand address + /// True if token deployment is allowed + Task CanDeployTokenAsync(string userAddress); + + /// + /// Records a token deployment for the user + /// + /// The user's Algorand address + /// True if deployment was recorded successfully + Task RecordTokenDeploymentAsync(string userAddress); + + /// + /// Gets the token deployment count for the user + /// + /// The user's Algorand address + /// Number of token deployments + Task GetTokenDeploymentCountAsync(string userAddress); } /// diff --git a/BiatecTokensApi/Services/SubscriptionTierService.cs b/BiatecTokensApi/Services/SubscriptionTierService.cs index 754877d..29fc12a 100644 --- a/BiatecTokensApi/Services/SubscriptionTierService.cs +++ b/BiatecTokensApi/Services/SubscriptionTierService.cs @@ -21,6 +21,10 @@ public class SubscriptionTierService : ISubscriptionTierService // Key: user address, Value: subscription tier private readonly ConcurrentDictionary _userTiers; + // In-memory storage for token deployment counts + // Key: user address, Value: deployment count + private readonly ConcurrentDictionary _tokenDeploymentCounts; + /// /// Initializes a new instance of the class. /// @@ -29,6 +33,7 @@ public SubscriptionTierService(ILogger logger) { _logger = logger; _userTiers = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); + _tokenDeploymentCounts = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); } /// @@ -162,5 +167,83 @@ public void SetUserTier(string userAddress, SubscriptionTier tier) "Set subscription tier for user {UserAddress} to {Tier}", userAddress, tier); } + + /// + public async Task CanDeployTokenAsync(string userAddress) + { + if (string.IsNullOrWhiteSpace(userAddress)) + { + _logger.LogWarning("CanDeployTokenAsync called with null or empty userAddress"); + return false; + } + + var tier = await GetUserTierAsync(userAddress); + var limits = SubscriptionTierConfiguration.GetTierLimits(tier); + + if (!limits.TokenDeploymentEnabled) + { + _logger.LogWarning( + "Token deployment not enabled for user {UserAddress} with tier {Tier}", + userAddress, tier); + return false; + } + + // Unlimited deployments (-1) always allowed + if (limits.MaxTokenDeployments == -1) + { + _logger.LogDebug( + "Token deployment allowed for user {UserAddress} (unlimited tier {Tier})", + userAddress, tier); + return true; + } + + var currentCount = await GetTokenDeploymentCountAsync(userAddress); + var canDeploy = currentCount < limits.MaxTokenDeployments; + + _logger.LogDebug( + "Token deployment check for user {UserAddress}: {CanDeploy} (Tier: {Tier}, Current: {Current}, Max: {Max})", + userAddress, canDeploy, tier, currentCount, limits.MaxTokenDeployments); + + return canDeploy; + } + + /// + public Task RecordTokenDeploymentAsync(string userAddress) + { + if (string.IsNullOrWhiteSpace(userAddress)) + { + _logger.LogWarning("RecordTokenDeploymentAsync called with null or empty userAddress"); + return Task.FromResult(false); + } + + var normalizedAddress = userAddress.ToUpperInvariant(); + _tokenDeploymentCounts.AddOrUpdate(normalizedAddress, 1, (key, oldValue) => oldValue + 1); + + var newCount = _tokenDeploymentCounts[normalizedAddress]; + _logger.LogInformation( + "Recorded token deployment for user {UserAddress}. New count: {Count}", + userAddress, newCount); + + return Task.FromResult(true); + } + + /// + public Task GetTokenDeploymentCountAsync(string userAddress) + { + if (string.IsNullOrWhiteSpace(userAddress)) + { + _logger.LogWarning("GetTokenDeploymentCountAsync called with null or empty userAddress"); + return Task.FromResult(0); + } + + var normalizedAddress = userAddress.ToUpperInvariant(); + var count = _tokenDeploymentCounts.GetOrAdd(normalizedAddress, 0); + + _logger.LogDebug( + "Retrieved token deployment count for user {UserAddress}: {Count}", + userAddress, count); + + return Task.FromResult(count); + } } } diff --git a/BiatecTokensApi/doc/documentation.xml b/BiatecTokensApi/doc/documentation.xml index 4d194a4..876c476 100644 --- a/BiatecTokensApi/doc/documentation.xml +++ b/BiatecTokensApi/doc/documentation.xml @@ -3396,6 +3396,10 @@ This filter ensures that duplicate requests with the same idempotency key return the same response. It stores the response in memory for a configurable duration (default 24 hours). + **Security:** The filter validates that cached requests match current request parameters. + If the same idempotency key is used with different parameters, a warning is logged and + the request is rejected to prevent bypassing business logic. + Usage: [IdempotencyKey] [HttpPost("create")] @@ -3417,6 +3421,14 @@ Executes before the action method runs + + + Computes a hash of the request parameters for validation + + Action executing context + Action arguments + Hash string + Removes expired entries from the cache @@ -3454,6 +3466,34 @@ The action executing context + + + Action filter that validates subscription tier entitlements for token deployment operations + + + This filter enforces subscription-based access control by validating that users have + appropriate tier permissions and available deployment quota before proceeding with token deployment. + When validation fails, it returns a clear error response that prompts users to upgrade their subscription. + + **Enforcement Policy:** + - Free tier: Limited token deployments (3) + - Basic tier: Moderate token deployments (10) + - Premium tier: Generous token deployments (50) + - Enterprise tier: Unlimited token deployments + + **Usage:** + Apply this attribute to token deployment endpoints: + [TokenDeploymentSubscription] + [HttpPost("create")] + public async Task<IActionResult> CreateToken(...) { ... } + + + + + Called before the action executes to validate subscription tier and deployment quota + + The action executing context + Action filter attribute that enforces whitelist validation for token operations @@ -12282,6 +12322,11 @@ Maximum number of whitelisted addresses per asset (-1 for unlimited) + + + Maximum number of token deployments allowed (-1 for unlimited) + + Human-readable tier name @@ -12307,6 +12352,11 @@ Whether bulk operations are available in this tier + + + Whether token deployment is enabled in this tier + + Configuration for subscription tier settings @@ -17575,6 +17625,27 @@ Current number of whitelisted addresses Remaining capacity (-1 for unlimited) + + + Checks if token deployment is allowed for the user's tier + + The user's Algorand address + True if token deployment is allowed + + + + Records a token deployment for the user + + The user's Algorand address + True if deployment was recorded successfully + + + + Gets the token deployment count for the user + + The user's Algorand address + Number of token deployments + Result of subscription tier validation @@ -17982,6 +18053,15 @@ or integrated with a billing/subscription management system. + + + + + + + + + Service for managing webhook subscriptions and event delivery diff --git a/BiatecTokensTests/IdempotencySecurityTests.cs b/BiatecTokensTests/IdempotencySecurityTests.cs new file mode 100644 index 0000000..84da7cf --- /dev/null +++ b/BiatecTokensTests/IdempotencySecurityTests.cs @@ -0,0 +1,310 @@ +using BiatecTokensApi.Filters; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Abstractions; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Moq; +using NUnit.Framework; + +namespace BiatecTokensTests +{ + /// + /// Unit tests for idempotency key security and functionality + /// + [TestFixture] + public class IdempotencySecurityTests + { + private Mock> _mockLogger = null!; + + [SetUp] + public void Setup() + { + _mockLogger = new Mock>(); + } + + #region Basic Idempotency Tests + + [Test] + public async Task Idempotency_NoKey_ProceedsNormally() + { + // Arrange + var attribute = new IdempotencyKeyAttribute(); + var context = CreateActionExecutingContext(null, new { name = "Test Token" }); + var next = new ActionExecutionDelegate(() => Task.FromResult(CreateSuccessfulActionExecutedContext(context))); + + // Act + await attribute.OnActionExecutionAsync(context, next); + + // Assert + Assert.That(context.Result, Is.Null, "Request without idempotency key should proceed normally"); + } + + [Test] + public async Task Idempotency_SameKeyAndParameters_ReturnsCachedResponse() + { + // Arrange + var attribute = new IdempotencyKeyAttribute(); + var idempotencyKey = "test-key-123"; + var requestParams = new { name = "Test Token", symbol = "TST" }; + + // First request + var context1 = CreateActionExecutingContext(idempotencyKey, requestParams); + var next1 = new ActionExecutionDelegate(() => Task.FromResult(CreateSuccessfulActionExecutedContext(context1, new { assetId = 12345 }))); + await attribute.OnActionExecutionAsync(context1, next1); + + // Second request with same key and parameters + var context2 = CreateActionExecutingContext(idempotencyKey, requestParams); + var next2 = new ActionExecutionDelegate(() => Task.FromResult(CreateSuccessfulActionExecutedContext(context2, new { assetId = 67890 }))); + + // Act + await attribute.OnActionExecutionAsync(context2, next2); + + // Assert + Assert.That(context2.Result, Is.Not.Null, "Second request should return cached response"); + Assert.That(context2.Result, Is.InstanceOf()); + + var objectResult = context2.Result as ObjectResult; + var response = objectResult!.Value as dynamic; + + // Should return the first response (12345), not the second (67890) + Assert.That((int)response!.assetId, Is.EqualTo(12345), "Should return cached response from first request"); + + // Check idempotency hit header + Assert.That(context2.HttpContext.Response.Headers["X-Idempotency-Hit"].ToString(), Is.EqualTo("true")); + } + + #endregion + + #region Security Tests - Parameter Validation + + [Test] + public async Task Idempotency_SameKeyDifferentParameters_RejectsRequest() + { + // Arrange + var attribute = new IdempotencyKeyAttribute(); + var idempotencyKey = "test-key-456"; + + // First request + var context1 = CreateActionExecutingContext(idempotencyKey, new { name = "Token A", symbol = "TKA" }); + var next1 = new ActionExecutionDelegate(() => Task.FromResult(CreateSuccessfulActionExecutedContext(context1))); + await attribute.OnActionExecutionAsync(context1, next1); + + // Second request with same key but different parameters + var context2 = CreateActionExecutingContext(idempotencyKey, new { name = "Token B", symbol = "TKB" }); + var next2 = new ActionExecutionDelegate(() => Task.FromResult(CreateSuccessfulActionExecutedContext(context2))); + + // Act + await attribute.OnActionExecutionAsync(context2, next2); + + // Assert + Assert.That(context2.Result, Is.Not.Null, "Request with mismatched parameters should be rejected"); + Assert.That(context2.Result, Is.InstanceOf()); + + var badRequestResult = context2.Result as BadRequestObjectResult; + var json = System.Text.Json.JsonSerializer.Serialize(badRequestResult!.Value); + var doc = System.Text.Json.JsonDocument.Parse(json); + var root = doc.RootElement; + + Assert.That(root.GetProperty("success").GetBoolean(), Is.False); + Assert.That(root.GetProperty("errorCode").GetString(), Is.EqualTo("IDEMPOTENCY_KEY_MISMATCH")); + Assert.That(root.GetProperty("errorMessage").GetString(), Does.Contain("different request parameters")); + } + + [Test] + public async Task Idempotency_DifferentParameterOrder_TreatedAsDifferent() + { + // Arrange + var attribute = new IdempotencyKeyAttribute(); + var idempotencyKey = "test-key-789"; + + // First request + var context1 = CreateActionExecutingContext(idempotencyKey, new { name = "Test", amount = 100 }); + var next1 = new ActionExecutionDelegate(() => Task.FromResult(CreateSuccessfulActionExecutedContext(context1))); + await attribute.OnActionExecutionAsync(context1, next1); + + // Second request with parameters in different order but same values + // Note: In C#, anonymous objects with different property order create different types + // So we need to test with a dictionary instead + var context2 = CreateActionExecutingContext(idempotencyKey, new { amount = 100, name = "Test" }); + var next2 = new ActionExecutionDelegate(() => Task.FromResult(CreateSuccessfulActionExecutedContext(context2))); + + // Act + await attribute.OnActionExecutionAsync(context2, next2); + + // Assert + // Different parameter order should be treated as different request + Assert.That(context2.Result, Is.InstanceOf()); + } + + [Test] + public async Task Idempotency_SameKeyNullVsEmptyString_TreatedAsDifferent() + { + // Arrange + var attribute = new IdempotencyKeyAttribute(); + var idempotencyKey = "test-key-null"; + + // First request with null + var context1 = CreateActionExecutingContext(idempotencyKey, new { name = (string?)null }); + var next1 = new ActionExecutionDelegate(() => Task.FromResult(CreateSuccessfulActionExecutedContext(context1))); + await attribute.OnActionExecutionAsync(context1, next1); + + // Second request with empty string + var context2 = CreateActionExecutingContext(idempotencyKey, new { name = "" }); + var next2 = new ActionExecutionDelegate(() => Task.FromResult(CreateSuccessfulActionExecutedContext(context2))); + + // Act + await attribute.OnActionExecutionAsync(context2, next2); + + // Assert + Assert.That(context2.Result, Is.InstanceOf(), + "null and empty string should be treated as different values"); + } + + #endregion + + #region Expiration Tests + + [Test] + public async Task Idempotency_ExpiredEntry_AllowsNewRequest() + { + // Arrange + var attribute = new IdempotencyKeyAttribute + { + Expiration = TimeSpan.FromMilliseconds(100) // Very short expiration for testing + }; + var idempotencyKey = "test-key-expire"; + var requestParams = new { name = "Test" }; + + // First request + var context1 = CreateActionExecutingContext(idempotencyKey, requestParams); + var next1 = new ActionExecutionDelegate(() => Task.FromResult(CreateSuccessfulActionExecutedContext(context1, new { id = 1 }))); + await attribute.OnActionExecutionAsync(context1, next1); + + // Verify first request result + var result1 = context1.HttpContext.Response.Headers["X-Idempotency-Hit"].ToString(); + Assert.That(result1, Is.EqualTo("false"), "First request should be a cache miss"); + + // Wait for expiration + await Task.Delay(150); + + // Second request after expiration + var context2 = CreateActionExecutingContext(idempotencyKey, requestParams); + var next2 = new ActionExecutionDelegate(() => Task.FromResult(CreateSuccessfulActionExecutedContext(context2, new { id = 2 }))); + + // Act + await attribute.OnActionExecutionAsync(context2, next2); + + // Assert - After expiration, should execute as new request (cache miss) + var result2 = context2.HttpContext.Response.Headers["X-Idempotency-Hit"].ToString(); + Assert.That(result2, Is.EqualTo("false"), "Expired entry should allow new request (cache miss)"); + } + + #endregion + + #region Header Tests + + [Test] + public async Task Idempotency_CacheHit_SetsHeaderToTrue() + { + // Arrange + var attribute = new IdempotencyKeyAttribute(); + var idempotencyKey = "test-header-hit"; + var requestParams = new { name = "Test" }; + + // First request + var context1 = CreateActionExecutingContext(idempotencyKey, requestParams); + var next1 = new ActionExecutionDelegate(() => Task.FromResult(CreateSuccessfulActionExecutedContext(context1))); + await attribute.OnActionExecutionAsync(context1, next1); + + // Second request (cache hit) + var context2 = CreateActionExecutingContext(idempotencyKey, requestParams); + var next2 = new ActionExecutionDelegate(() => Task.FromResult(CreateSuccessfulActionExecutedContext(context2))); + + // Act + await attribute.OnActionExecutionAsync(context2, next2); + + // Assert + Assert.That(context2.HttpContext.Response.Headers["X-Idempotency-Hit"].ToString(), Is.EqualTo("true")); + } + + [Test] + public async Task Idempotency_CacheMiss_SetsHeaderToFalse() + { + // Arrange + var attribute = new IdempotencyKeyAttribute(); + var idempotencyKey = "test-header-miss"; + var requestParams = new { name = "Test" }; + + var context = CreateActionExecutingContext(idempotencyKey, requestParams); + var next = new ActionExecutionDelegate(() => Task.FromResult(CreateSuccessfulActionExecutedContext(context))); + + // Act + await attribute.OnActionExecutionAsync(context, next); + + // Assert + Assert.That(context.HttpContext.Response.Headers["X-Idempotency-Hit"].ToString(), Is.EqualTo("false")); + } + + #endregion + + #region Helper Methods + + private ActionExecutingContext CreateActionExecutingContext(string? idempotencyKey, object? arguments) + { + var httpContext = new DefaultHttpContext(); + + // Set idempotency key header if provided + if (!string.IsNullOrEmpty(idempotencyKey)) + { + httpContext.Request.Headers["Idempotency-Key"] = idempotencyKey; + } + + // Set correlation ID + httpContext.TraceIdentifier = Guid.NewGuid().ToString(); + + // Set up services + var services = new ServiceCollection(); + services.AddSingleton(_mockLogger.Object); + httpContext.RequestServices = services.BuildServiceProvider(); + + var actionContext = new ActionContext( + httpContext, + new RouteData(), + new ActionDescriptor() + ); + + // Create action arguments dictionary + var actionArguments = new Dictionary(); + if (arguments != null) + { + actionArguments["request"] = arguments; + } + + return new ActionExecutingContext( + actionContext, + new List(), + actionArguments, + controller: null! + ); + } + + private ActionExecutedContext CreateSuccessfulActionExecutedContext(ActionExecutingContext executingContext, object? response = null) + { + return new ActionExecutedContext( + executingContext, + new List(), + controller: null!) + { + Result = new ObjectResult(response ?? new { success = true }) + { + StatusCode = StatusCodes.Status200OK + } + }; + } + + #endregion + } +} diff --git a/BiatecTokensTests/TokenDeploymentSubscriptionTests.cs b/BiatecTokensTests/TokenDeploymentSubscriptionTests.cs new file mode 100644 index 0000000..d4c667b --- /dev/null +++ b/BiatecTokensTests/TokenDeploymentSubscriptionTests.cs @@ -0,0 +1,436 @@ +using BiatecTokensApi.Filters; +using BiatecTokensApi.Models; +using BiatecTokensApi.Models.Subscription; +using BiatecTokensApi.Services.Interface; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Abstractions; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Moq; +using NUnit.Framework; +using System.Security.Claims; + +namespace BiatecTokensTests +{ + /// + /// Unit tests for token deployment subscription enforcement + /// + [TestFixture] + public class TokenDeploymentSubscriptionTests + { + private Mock _mockTierService = null!; + private Mock> _mockLogger = null!; + + [SetUp] + public void Setup() + { + _mockTierService = new Mock(); + _mockLogger = new Mock>(); + } + + #region Free Tier Tests + + [Test] + public async Task TokenDeployment_FreeTier_WithinLimit_AllowsDeployment() + { + // Arrange + var attribute = new TokenDeploymentSubscriptionAttribute(); + var context = CreateActionExecutingContext("test-user@example.com"); + var next = new ActionExecutionDelegate(() => Task.FromResult(CreateSuccessfulActionExecutedContext(context))); + + _mockTierService.Setup(x => x.CanDeployTokenAsync(It.IsAny())) + .ReturnsAsync(true); + + _mockTierService.Setup(x => x.GetUserTierAsync(It.IsAny())) + .ReturnsAsync(SubscriptionTier.Free); + + _mockTierService.Setup(x => x.RecordTokenDeploymentAsync(It.IsAny())) + .ReturnsAsync(true); + + _mockTierService.Setup(x => x.GetTokenDeploymentCountAsync(It.IsAny())) + .ReturnsAsync(2); // After deployment: 2 deployments + + // Act + await attribute.OnActionExecutionAsync(context, next); + + // Assert + Assert.That(context.Result, Is.Null, "Free tier should allow deployment within limit"); + + // Verify deployment was recorded + _mockTierService.Verify(x => x.RecordTokenDeploymentAsync("test-user@example.com"), Times.Once); + } + + [Test] + public async Task TokenDeployment_FreeTier_ExceedsLimit_BlocksDeployment() + { + // Arrange + var attribute = new TokenDeploymentSubscriptionAttribute(); + var context = CreateActionExecutingContext("test-user@example.com"); + var next = new ActionExecutionDelegate(() => Task.FromResult(CreateSuccessfulActionExecutedContext(context))); + + _mockTierService.Setup(x => x.CanDeployTokenAsync(It.IsAny())) + .ReturnsAsync(false); + + _mockTierService.Setup(x => x.GetUserTierAsync(It.IsAny())) + .ReturnsAsync(SubscriptionTier.Free); + + _mockTierService.Setup(x => x.GetTierLimits(SubscriptionTier.Free)) + .Returns(new SubscriptionTierLimits + { + Tier = SubscriptionTier.Free, + TierName = "Free", + MaxTokenDeployments = 3, + Description = "Free tier with up to 3 token deployments" + }); + + _mockTierService.Setup(x => x.GetTokenDeploymentCountAsync(It.IsAny())) + .ReturnsAsync(3); // Already at limit + + // Act + await attribute.OnActionExecutionAsync(context, next); + + // Assert + Assert.That(context.Result, Is.Not.Null, "Free tier should block deployment when limit reached"); + Assert.That(context.Result, Is.InstanceOf()); + + var objectResult = context.Result as ObjectResult; + Assert.That(objectResult!.StatusCode, Is.EqualTo(StatusCodes.Status402PaymentRequired)); + Assert.That(objectResult.Value, Is.InstanceOf()); + + var errorResponse = objectResult.Value as ApiErrorResponse; + Assert.That(errorResponse!.ErrorCode, Is.EqualTo(ErrorCodes.SUBSCRIPTION_LIMIT_REACHED)); + Assert.That(errorResponse.ErrorMessage, Does.Contain("Free")); + Assert.That(errorResponse.ErrorMessage, Does.Contain("3")); + Assert.That(errorResponse.RemediationHint, Does.Contain("upgrade")); + + // Verify deployment was NOT recorded + _mockTierService.Verify(x => x.RecordTokenDeploymentAsync(It.IsAny()), Times.Never); + } + + #endregion + + #region Basic Tier Tests + + [Test] + public async Task TokenDeployment_BasicTier_WithinLimit_AllowsDeployment() + { + // Arrange + var attribute = new TokenDeploymentSubscriptionAttribute(); + var context = CreateActionExecutingContext("basic-user@example.com"); + var next = new ActionExecutionDelegate(() => Task.FromResult(CreateSuccessfulActionExecutedContext(context))); + + _mockTierService.Setup(x => x.CanDeployTokenAsync(It.IsAny())) + .ReturnsAsync(true); + + _mockTierService.Setup(x => x.RecordTokenDeploymentAsync(It.IsAny())) + .ReturnsAsync(true); + + _mockTierService.Setup(x => x.GetTokenDeploymentCountAsync(It.IsAny())) + .ReturnsAsync(8); // After deployment: 8 of 10 + + // Act + await attribute.OnActionExecutionAsync(context, next); + + // Assert + Assert.That(context.Result, Is.Null, "Basic tier should allow deployment within limit"); + _mockTierService.Verify(x => x.RecordTokenDeploymentAsync("basic-user@example.com"), Times.Once); + } + + #endregion + + #region Premium Tier Tests + + [Test] + public async Task TokenDeployment_PremiumTier_WithinLimit_AllowsDeployment() + { + // Arrange + var attribute = new TokenDeploymentSubscriptionAttribute(); + var context = CreateActionExecutingContext("premium-user@example.com"); + var next = new ActionExecutionDelegate(() => Task.FromResult(CreateSuccessfulActionExecutedContext(context))); + + _mockTierService.Setup(x => x.CanDeployTokenAsync(It.IsAny())) + .ReturnsAsync(true); + + _mockTierService.Setup(x => x.RecordTokenDeploymentAsync(It.IsAny())) + .ReturnsAsync(true); + + _mockTierService.Setup(x => x.GetTokenDeploymentCountAsync(It.IsAny())) + .ReturnsAsync(40); // After deployment: 40 of 50 + + // Act + await attribute.OnActionExecutionAsync(context, next); + + // Assert + Assert.That(context.Result, Is.Null, "Premium tier should allow deployment within limit"); + _mockTierService.Verify(x => x.RecordTokenDeploymentAsync("premium-user@example.com"), Times.Once); + } + + #endregion + + #region Enterprise Tier Tests + + [Test] + public async Task TokenDeployment_EnterpriseTier_UnlimitedDeployments() + { + // Arrange + var attribute = new TokenDeploymentSubscriptionAttribute(); + var context = CreateActionExecutingContext("enterprise-user@example.com"); + var next = new ActionExecutionDelegate(() => Task.FromResult(CreateSuccessfulActionExecutedContext(context))); + + _mockTierService.Setup(x => x.CanDeployTokenAsync(It.IsAny())) + .ReturnsAsync(true); + + _mockTierService.Setup(x => x.GetUserTierAsync(It.IsAny())) + .ReturnsAsync(SubscriptionTier.Enterprise); + + _mockTierService.Setup(x => x.RecordTokenDeploymentAsync(It.IsAny())) + .ReturnsAsync(true); + + _mockTierService.Setup(x => x.GetTokenDeploymentCountAsync(It.IsAny())) + .ReturnsAsync(100); // Even with high count, should allow + + // Act + await attribute.OnActionExecutionAsync(context, next); + + // Assert + Assert.That(context.Result, Is.Null, "Enterprise tier should allow unlimited deployments"); + _mockTierService.Verify(x => x.RecordTokenDeploymentAsync("enterprise-user@example.com"), Times.Once); + } + + #endregion + + #region Authentication Tests + + [Test] + public async Task TokenDeployment_NoAuthentication_ReturnsUnauthorized() + { + // Arrange + var attribute = new TokenDeploymentSubscriptionAttribute(); + var context = CreateActionExecutingContext(null); // No user identity + var next = new ActionExecutionDelegate(() => Task.FromResult(CreateSuccessfulActionExecutedContext(context))); + + // Act + await attribute.OnActionExecutionAsync(context, next); + + // Assert + Assert.That(context.Result, Is.Not.Null); + Assert.That(context.Result, Is.InstanceOf()); + + var result = context.Result as UnauthorizedObjectResult; + Assert.That(result!.Value, Is.InstanceOf()); + + var errorResponse = result.Value as ApiErrorResponse; + Assert.That(errorResponse!.ErrorCode, Is.EqualTo(ErrorCodes.UNAUTHORIZED)); + Assert.That(errorResponse.RemediationHint, Does.Contain("ARC-0014")); + } + + #endregion + + #region Error Response Tests + + [Test] + public async Task TokenDeployment_LimitReached_IncludesCorrelationId() + { + // Arrange + var attribute = new TokenDeploymentSubscriptionAttribute(); + var correlationId = "test-correlation-123"; + var context = CreateActionExecutingContext("test-user@example.com", correlationId); + var next = new ActionExecutionDelegate(() => Task.FromResult(CreateSuccessfulActionExecutedContext(context))); + + _mockTierService.Setup(x => x.CanDeployTokenAsync(It.IsAny())) + .ReturnsAsync(false); + + _mockTierService.Setup(x => x.GetUserTierAsync(It.IsAny())) + .ReturnsAsync(SubscriptionTier.Free); + + _mockTierService.Setup(x => x.GetTierLimits(SubscriptionTier.Free)) + .Returns(new SubscriptionTierLimits + { + Tier = SubscriptionTier.Free, + TierName = "Free", + MaxTokenDeployments = 3 + }); + + _mockTierService.Setup(x => x.GetTokenDeploymentCountAsync(It.IsAny())) + .ReturnsAsync(3); + + // Act + await attribute.OnActionExecutionAsync(context, next); + + // Assert + var objectResult = context.Result as ObjectResult; + var errorResponse = objectResult!.Value as ApiErrorResponse; + Assert.That(errorResponse!.CorrelationId, Is.EqualTo(correlationId), + "Error response should include correlation ID"); + } + + [Test] + public async Task TokenDeployment_LimitReached_IncludesDetailedInformation() + { + // Arrange + var attribute = new TokenDeploymentSubscriptionAttribute(); + var context = CreateActionExecutingContext("test-user@example.com"); + var next = new ActionExecutionDelegate(() => Task.FromResult(CreateSuccessfulActionExecutedContext(context))); + + _mockTierService.Setup(x => x.CanDeployTokenAsync(It.IsAny())) + .ReturnsAsync(false); + + _mockTierService.Setup(x => x.GetUserTierAsync(It.IsAny())) + .ReturnsAsync(SubscriptionTier.Basic); + + _mockTierService.Setup(x => x.GetTierLimits(SubscriptionTier.Basic)) + .Returns(new SubscriptionTierLimits + { + Tier = SubscriptionTier.Basic, + TierName = "Basic", + MaxTokenDeployments = 10, + Description = "Basic tier with 10 deployments" + }); + + _mockTierService.Setup(x => x.GetTokenDeploymentCountAsync(It.IsAny())) + .ReturnsAsync(10); + + // Act + await attribute.OnActionExecutionAsync(context, next); + + // Assert + var objectResult = context.Result as ObjectResult; + var errorResponse = objectResult!.Value as ApiErrorResponse; + + Assert.That(errorResponse!.Details, Is.Not.Null); + Assert.That(errorResponse.Details, Contains.Key("currentTier")); + Assert.That(errorResponse.Details, Contains.Key("currentDeployments")); + Assert.That(errorResponse.Details, Contains.Key("maxDeployments")); + Assert.That(errorResponse.Details, Contains.Key("tierDescription")); + + Assert.That(errorResponse.Details!["currentDeployments"], Is.EqualTo(10)); + Assert.That(errorResponse.Details["maxDeployments"], Is.EqualTo(10)); + } + + #endregion + + #region Recording Tests + + [Test] + public async Task TokenDeployment_FailedRequest_DoesNotRecordDeployment() + { + // Arrange + var attribute = new TokenDeploymentSubscriptionAttribute(); + var context = CreateActionExecutingContext("test-user@example.com"); + var next = new ActionExecutionDelegate(() => Task.FromResult(CreateFailedActionExecutedContext(context))); + + _mockTierService.Setup(x => x.CanDeployTokenAsync(It.IsAny())) + .ReturnsAsync(true); + + // Act + await attribute.OnActionExecutionAsync(context, next); + + // Assert + _mockTierService.Verify(x => x.RecordTokenDeploymentAsync(It.IsAny()), Times.Never, + "Failed deployment should not be recorded"); + } + + [Test] + public async Task TokenDeployment_SuccessfulRequest_RecordsDeployment() + { + // Arrange + var attribute = new TokenDeploymentSubscriptionAttribute(); + var context = CreateActionExecutingContext("test-user@example.com"); + var next = new ActionExecutionDelegate(() => Task.FromResult(CreateSuccessfulActionExecutedContext(context))); + + _mockTierService.Setup(x => x.CanDeployTokenAsync(It.IsAny())) + .ReturnsAsync(true); + + _mockTierService.Setup(x => x.RecordTokenDeploymentAsync(It.IsAny())) + .ReturnsAsync(true); + + _mockTierService.Setup(x => x.GetTokenDeploymentCountAsync(It.IsAny())) + .ReturnsAsync(1); + + // Act + await attribute.OnActionExecutionAsync(context, next); + + // Assert + _mockTierService.Verify(x => x.RecordTokenDeploymentAsync("test-user@example.com"), Times.Once, + "Successful deployment should be recorded"); + } + + #endregion + + #region Helper Methods + + private ActionExecutingContext CreateActionExecutingContext(string? userName, string? correlationId = null) + { + var httpContext = new DefaultHttpContext(); + + // Set up user identity if provided + if (!string.IsNullOrEmpty(userName)) + { + var claims = new List { new Claim(ClaimTypes.Name, userName) }; + var identity = new ClaimsIdentity(claims, "TestAuth"); + httpContext.User = new ClaimsPrincipal(identity); + } + + // Set correlation ID + httpContext.TraceIdentifier = correlationId ?? Guid.NewGuid().ToString(); + + // Set up services + var services = new ServiceCollection(); + services.AddSingleton(_mockTierService.Object); + services.AddSingleton(_mockLogger.Object); + httpContext.RequestServices = services.BuildServiceProvider(); + + var actionContext = new ActionContext( + httpContext, + new RouteData(), + new ActionDescriptor() + ); + + return new ActionExecutingContext( + actionContext, + new List(), + new Dictionary(), + controller: null! + ); + } + + private ActionExecutedContext CreateSuccessfulActionExecutedContext(ActionExecutingContext executingContext) + { + // Set the response status code on the HttpContext + executingContext.HttpContext.Response.StatusCode = StatusCodes.Status200OK; + + return new ActionExecutedContext( + executingContext, + new List(), + controller: null!) + { + Result = new ObjectResult(new { success = true }) + { + StatusCode = StatusCodes.Status200OK + } + }; + } + + private ActionExecutedContext CreateFailedActionExecutedContext(ActionExecutingContext executingContext) + { + // Set the response status code on the HttpContext + executingContext.HttpContext.Response.StatusCode = StatusCodes.Status500InternalServerError; + + return new ActionExecutedContext( + executingContext, + new List(), + controller: null!) + { + Result = new ObjectResult(new { success = false, error = "Deployment failed" }) + { + StatusCode = StatusCodes.Status500InternalServerError + } + }; + } + + #endregion + } +}