Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,16 @@
# Changelog


## [v0.10.0](https://github.com/contentstack/contentstack-management-dotnet/tree/v0.9.0)
- Feat
- **Enhanced Error Handling and Test Coverage (DX-5436)**
- Added comprehensive error handling across all models with enhanced `ContentstackErrorException`
- Implemented negative test cases for all integration tests to validate error scenarios
- Added testing infrastructure: `MockHttpStatusHandler`, `MockNetworkErrorHandler`, and `AssertLogger` helpers
- Enhanced test coverage with error validation across Login, Organization, Stack, Release, Global Field, Content Type, Nested Global Field, Asset, Entry, Bulk Operation, Delivery Token, Taxonomy, Environment, Role, Workflow, Entry Variant, and Variant Group operations
- Improved exception handling in `BaseModel` and service layers


## [v0.9.0](https://github.com/contentstack/contentstack-management-dotnet/tree/v0.9.0)
- Fix
- **Variant Group HTTP method correction**: Updated variant group link/unlink operations to use PUT method instead of POST for API compliance
Expand Down
187 changes: 186 additions & 1 deletion Contentstack.Management.Core.Tests/Contentstack.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
using System.Net;
using System.Net.Http;
using System.Reflection;
using System.Threading.Tasks;
using Contentstack.Management.Core.Exceptions;
using Contentstack.Management.Core.Tests.Helpers;
using Contentstack.Management.Core.Tests.Model;
using Microsoft.Extensions.Configuration;
Expand Down Expand Up @@ -35,12 +37,195 @@ private static readonly Lazy<IConfigurationRoot>
return Config.GetSection("Contentstack:Organization").Get<OrganizationModel>();
});

private static readonly Lazy<string> mfaSecret =
new Lazy<string>(() =>
{
return Config.GetSection("Contentstack:MfaSecret").Value;
});

public static IConfigurationRoot Config{ get { return config.Value; } }
public static NetworkCredential Credential { get { return credential.Value; } }
public static OrganizationModel Organization { get { return organization.Value; } }
public static string MfaSecret { get { return mfaSecret.Value; } }

public static StackModel Stack { get; set; }

// TOTP token tracking to prevent reuse
private static readonly HashSet<string> _usedTotpTokens = new HashSet<string>();
private static DateTime _lastTotpGeneration = DateTime.MinValue;
private static readonly object _totpLock = new object();

/// <summary>
/// Checks if the exception indicates TOTP token reuse
/// </summary>
public static bool IsTotpReuse(Exception exception)
{
if (exception is ContentstackErrorException csException)
{
return csException.ErrorMessage?.Contains("Totp has already been Used") == true;
}
return false;
}

/// <summary>
/// Checks if the exception indicates an account lockout
/// </summary>
public static bool IsAccountLockout(Exception exception)
{
if (exception is ContentstackErrorException csException)
{
return csException.ErrorCode == 104 &&
(csException.ErrorMessage?.Contains("locked") == true ||
csException.ErrorMessage?.Contains("temporarily") == true);
}
return false;
}

/// <summary>
/// Ensures sufficient time has passed for fresh TOTP token generation
/// </summary>
public static void EnsureFreshTotpWindow()
{
lock (_totpLock)
{
var timeSinceLastTotp = DateTime.UtcNow - _lastTotpGeneration;
if (timeSinceLastTotp.TotalSeconds < 35)
{
int sleepMs = (int)(35000 - timeSinceLastTotp.TotalMilliseconds);
System.Threading.Thread.Sleep(sleepMs);
}

// Clean up old tokens (older than 2 minutes)
var cutoff = DateTime.UtcNow.AddMinutes(-2);
if (_lastTotpGeneration < cutoff)
{
_usedTotpTokens.Clear();
}

_lastTotpGeneration = DateTime.UtcNow;
}
}

/// <summary>
/// Executes login with retry logic for account lockouts
/// </summary>
public static ContentstackResponse LoginWithRetry(ContentstackClient client, int maxRetries = 3, int baseDelayMs = 5000)
{
for (int attempt = 0; attempt <= maxRetries; attempt++)
{
try
{
return client.Login(Credential, null, MfaSecret);
}
catch (Exception ex) when (IsAccountLockout(ex) && attempt < maxRetries)
{
int delay = baseDelayMs * (int)Math.Pow(2, attempt); // Exponential backoff
System.Threading.Thread.Sleep(delay);
}
}
// Final attempt without catching lockout
return client.Login(Credential, null, MfaSecret);
}

/// <summary>
/// Executes async login with retry logic for account lockouts
/// </summary>
public static async Task<ContentstackResponse> LoginWithRetryAsync(ContentstackClient client, int maxRetries = 3, int baseDelayMs = 5000)
{
for (int attempt = 0; attempt <= maxRetries; attempt++)
{
try
{
return await client.LoginAsync(Credential, null, MfaSecret);
}
catch (Exception ex) when (IsAccountLockout(ex) && attempt < maxRetries)
{
int delay = baseDelayMs * (int)Math.Pow(2, attempt); // Exponential backoff
await Task.Delay(delay);
}
}
// Final attempt without catching lockout
return await client.LoginAsync(Credential, null, MfaSecret);
}

/// <summary>
/// Executes login with TOTP-aware retry logic for token reuse and account lockouts
/// </summary>
public static ContentstackResponse LoginWithTotpRetry(ContentstackClient client, int maxRetries = 3)
{
for (int attempt = 0; attempt <= maxRetries; attempt++)
{
try
{
// Ensure fresh TOTP window before each attempt
EnsureFreshTotpWindow();
return client.Login(Credential, null, MfaSecret);
}
catch (Exception ex) when (attempt < maxRetries)
{
if (IsTotpReuse(ex))
{
// Wait for fresh TOTP window (35+ seconds)
System.Threading.Thread.Sleep(35000);
}
else if (IsAccountLockout(ex))
{
// Exponential backoff for account lockout
int delay = 5000 * (int)Math.Pow(2, attempt);
System.Threading.Thread.Sleep(delay);
}
else
{
// For other errors, short delay before retry
System.Threading.Thread.Sleep(1000);
}
}
}

// Final attempt without catching errors
EnsureFreshTotpWindow();
return client.Login(Credential, null, MfaSecret);
}

/// <summary>
/// Executes async login with TOTP-aware retry logic for token reuse and account lockouts
/// </summary>
public static async Task<ContentstackResponse> LoginWithTotpRetryAsync(ContentstackClient client, int maxRetries = 3)
{
for (int attempt = 0; attempt <= maxRetries; attempt++)
{
try
{
// Ensure fresh TOTP window before each attempt
EnsureFreshTotpWindow();
return await client.LoginAsync(Credential, null, MfaSecret);
}
catch (Exception ex) when (attempt < maxRetries)
{
if (IsTotpReuse(ex))
{
// Wait for fresh TOTP window (35+ seconds)
await Task.Delay(35000);
}
else if (IsAccountLockout(ex))
{
// Exponential backoff for account lockout
int delay = 5000 * (int)Math.Pow(2, attempt);
await Task.Delay(delay);
}
else
{
// For other errors, short delay before retry
await Task.Delay(1000);
}
}
}

// Final attempt without catching errors
EnsureFreshTotpWindow();
return await client.LoginAsync(Credential, null, MfaSecret);
}

/// <summary>
/// Creates a new ContentstackClient, logs in via the Login API (never from config),
/// and returns the authenticated client. Callers are responsible for calling Logout()
Expand All @@ -53,7 +238,7 @@ public static ContentstackClient CreateAuthenticatedClient()
var handler = new LoggingHttpHandler();
var httpClient = new HttpClient(handler);
var client = new ContentstackClient(httpClient, options);
client.Login(Credential);
LoginWithTotpRetry(client);
return client;
}

Expand Down
32 changes: 32 additions & 0 deletions Contentstack.Management.Core.Tests/Helpers/AssertLogger.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,13 @@ public static void IsNotNull(object value, string name = "")
Assert.IsNotNull(value);
}

public static void IsNotNull(object value, string message, string name)
{
bool passed = value != null;
TestOutputLogger.LogAssertion($"IsNotNull({name})", "NotNull", value?.ToString() ?? "null", passed);
Assert.IsNotNull(value, message);
}

public static void IsNull(object value, string name = "")
{
bool passed = value == null;
Expand Down Expand Up @@ -97,6 +104,31 @@ public static T ThrowsException<T>(Action action, string name = "") where T : Ex
}
}

public static async Task<T> ThrowsExceptionAsync<T>(Func<Task> action, string name = "") where T : Exception
{
try
{
await action();
TestOutputLogger.LogAssertion($"ThrowsExceptionAsync<{typeof(T).Name}>({name})", typeof(T).Name, "NoException", false);
throw new AssertFailedException($"Expected exception {typeof(T).Name} was not thrown.");
}
catch (T ex)
{
TestOutputLogger.LogAssertion($"ThrowsExceptionAsync<{typeof(T).Name}>({name})", typeof(T).Name, typeof(T).Name, true);
return ex;
}
catch (AssertFailedException)
{
throw;
}
catch (Exception ex)
{
TestOutputLogger.LogAssertion($"ThrowsExceptionAsync<{typeof(T).Name}>({name})", typeof(T).Name, ex.GetType().Name, false);
throw new AssertFailedException(
$"Expected exception {typeof(T).Name} but got {ex.GetType().Name}: {ex.Message}", ex);
}
}

public static void Fail(string message)
{
TestOutputLogger.LogAssertion("Fail", "N/A", message ?? "", false);
Expand Down
Loading
Loading