Skip to content

Commit b03e1f4

Browse files
authored
Merge pull request #169 from contentstack/development
DX | 04-05-2026 | Release
2 parents 0ed993a + 52b88c1 commit b03e1f4

28 files changed

Lines changed: 39782 additions & 1016 deletions

CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,16 @@
11
# Changelog
22

3+
4+
## [v0.10.0](https://github.com/contentstack/contentstack-management-dotnet/tree/v0.9.0)
5+
- Feat
6+
- **Enhanced Error Handling and Test Coverage (DX-5436)**
7+
- Added comprehensive error handling across all models with enhanced `ContentstackErrorException`
8+
- Implemented negative test cases for all integration tests to validate error scenarios
9+
- Added testing infrastructure: `MockHttpStatusHandler`, `MockNetworkErrorHandler`, and `AssertLogger` helpers
10+
- 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
11+
- Improved exception handling in `BaseModel` and service layers
12+
13+
314
## [v0.9.0](https://github.com/contentstack/contentstack-management-dotnet/tree/v0.9.0)
415
- Fix
516
- **Variant Group HTTP method correction**: Updated variant group link/unlink operations to use PUT method instead of POST for API compliance

Contentstack.Management.Core.Tests/Contentstack.cs

Lines changed: 186 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
using System.Net;
66
using System.Net.Http;
77
using System.Reflection;
8+
using System.Threading.Tasks;
9+
using Contentstack.Management.Core.Exceptions;
810
using Contentstack.Management.Core.Tests.Helpers;
911
using Contentstack.Management.Core.Tests.Model;
1012
using Microsoft.Extensions.Configuration;
@@ -35,12 +37,195 @@ private static readonly Lazy<IConfigurationRoot>
3537
return Config.GetSection("Contentstack:Organization").Get<OrganizationModel>();
3638
});
3739

40+
private static readonly Lazy<string> mfaSecret =
41+
new Lazy<string>(() =>
42+
{
43+
return Config.GetSection("Contentstack:MfaSecret").Value;
44+
});
45+
3846
public static IConfigurationRoot Config{ get { return config.Value; } }
3947
public static NetworkCredential Credential { get { return credential.Value; } }
4048
public static OrganizationModel Organization { get { return organization.Value; } }
49+
public static string MfaSecret { get { return mfaSecret.Value; } }
4150

4251
public static StackModel Stack { get; set; }
4352

53+
// TOTP token tracking to prevent reuse
54+
private static readonly HashSet<string> _usedTotpTokens = new HashSet<string>();
55+
private static DateTime _lastTotpGeneration = DateTime.MinValue;
56+
private static readonly object _totpLock = new object();
57+
58+
/// <summary>
59+
/// Checks if the exception indicates TOTP token reuse
60+
/// </summary>
61+
public static bool IsTotpReuse(Exception exception)
62+
{
63+
if (exception is ContentstackErrorException csException)
64+
{
65+
return csException.ErrorMessage?.Contains("Totp has already been Used") == true;
66+
}
67+
return false;
68+
}
69+
70+
/// <summary>
71+
/// Checks if the exception indicates an account lockout
72+
/// </summary>
73+
public static bool IsAccountLockout(Exception exception)
74+
{
75+
if (exception is ContentstackErrorException csException)
76+
{
77+
return csException.ErrorCode == 104 &&
78+
(csException.ErrorMessage?.Contains("locked") == true ||
79+
csException.ErrorMessage?.Contains("temporarily") == true);
80+
}
81+
return false;
82+
}
83+
84+
/// <summary>
85+
/// Ensures sufficient time has passed for fresh TOTP token generation
86+
/// </summary>
87+
public static void EnsureFreshTotpWindow()
88+
{
89+
lock (_totpLock)
90+
{
91+
var timeSinceLastTotp = DateTime.UtcNow - _lastTotpGeneration;
92+
if (timeSinceLastTotp.TotalSeconds < 35)
93+
{
94+
int sleepMs = (int)(35000 - timeSinceLastTotp.TotalMilliseconds);
95+
System.Threading.Thread.Sleep(sleepMs);
96+
}
97+
98+
// Clean up old tokens (older than 2 minutes)
99+
var cutoff = DateTime.UtcNow.AddMinutes(-2);
100+
if (_lastTotpGeneration < cutoff)
101+
{
102+
_usedTotpTokens.Clear();
103+
}
104+
105+
_lastTotpGeneration = DateTime.UtcNow;
106+
}
107+
}
108+
109+
/// <summary>
110+
/// Executes login with retry logic for account lockouts
111+
/// </summary>
112+
public static ContentstackResponse LoginWithRetry(ContentstackClient client, int maxRetries = 3, int baseDelayMs = 5000)
113+
{
114+
for (int attempt = 0; attempt <= maxRetries; attempt++)
115+
{
116+
try
117+
{
118+
return client.Login(Credential, null, MfaSecret);
119+
}
120+
catch (Exception ex) when (IsAccountLockout(ex) && attempt < maxRetries)
121+
{
122+
int delay = baseDelayMs * (int)Math.Pow(2, attempt); // Exponential backoff
123+
System.Threading.Thread.Sleep(delay);
124+
}
125+
}
126+
// Final attempt without catching lockout
127+
return client.Login(Credential, null, MfaSecret);
128+
}
129+
130+
/// <summary>
131+
/// Executes async login with retry logic for account lockouts
132+
/// </summary>
133+
public static async Task<ContentstackResponse> LoginWithRetryAsync(ContentstackClient client, int maxRetries = 3, int baseDelayMs = 5000)
134+
{
135+
for (int attempt = 0; attempt <= maxRetries; attempt++)
136+
{
137+
try
138+
{
139+
return await client.LoginAsync(Credential, null, MfaSecret);
140+
}
141+
catch (Exception ex) when (IsAccountLockout(ex) && attempt < maxRetries)
142+
{
143+
int delay = baseDelayMs * (int)Math.Pow(2, attempt); // Exponential backoff
144+
await Task.Delay(delay);
145+
}
146+
}
147+
// Final attempt without catching lockout
148+
return await client.LoginAsync(Credential, null, MfaSecret);
149+
}
150+
151+
/// <summary>
152+
/// Executes login with TOTP-aware retry logic for token reuse and account lockouts
153+
/// </summary>
154+
public static ContentstackResponse LoginWithTotpRetry(ContentstackClient client, int maxRetries = 3)
155+
{
156+
for (int attempt = 0; attempt <= maxRetries; attempt++)
157+
{
158+
try
159+
{
160+
// Ensure fresh TOTP window before each attempt
161+
EnsureFreshTotpWindow();
162+
return client.Login(Credential, null, MfaSecret);
163+
}
164+
catch (Exception ex) when (attempt < maxRetries)
165+
{
166+
if (IsTotpReuse(ex))
167+
{
168+
// Wait for fresh TOTP window (35+ seconds)
169+
System.Threading.Thread.Sleep(35000);
170+
}
171+
else if (IsAccountLockout(ex))
172+
{
173+
// Exponential backoff for account lockout
174+
int delay = 5000 * (int)Math.Pow(2, attempt);
175+
System.Threading.Thread.Sleep(delay);
176+
}
177+
else
178+
{
179+
// For other errors, short delay before retry
180+
System.Threading.Thread.Sleep(1000);
181+
}
182+
}
183+
}
184+
185+
// Final attempt without catching errors
186+
EnsureFreshTotpWindow();
187+
return client.Login(Credential, null, MfaSecret);
188+
}
189+
190+
/// <summary>
191+
/// Executes async login with TOTP-aware retry logic for token reuse and account lockouts
192+
/// </summary>
193+
public static async Task<ContentstackResponse> LoginWithTotpRetryAsync(ContentstackClient client, int maxRetries = 3)
194+
{
195+
for (int attempt = 0; attempt <= maxRetries; attempt++)
196+
{
197+
try
198+
{
199+
// Ensure fresh TOTP window before each attempt
200+
EnsureFreshTotpWindow();
201+
return await client.LoginAsync(Credential, null, MfaSecret);
202+
}
203+
catch (Exception ex) when (attempt < maxRetries)
204+
{
205+
if (IsTotpReuse(ex))
206+
{
207+
// Wait for fresh TOTP window (35+ seconds)
208+
await Task.Delay(35000);
209+
}
210+
else if (IsAccountLockout(ex))
211+
{
212+
// Exponential backoff for account lockout
213+
int delay = 5000 * (int)Math.Pow(2, attempt);
214+
await Task.Delay(delay);
215+
}
216+
else
217+
{
218+
// For other errors, short delay before retry
219+
await Task.Delay(1000);
220+
}
221+
}
222+
}
223+
224+
// Final attempt without catching errors
225+
EnsureFreshTotpWindow();
226+
return await client.LoginAsync(Credential, null, MfaSecret);
227+
}
228+
44229
/// <summary>
45230
/// Creates a new ContentstackClient, logs in via the Login API (never from config),
46231
/// and returns the authenticated client. Callers are responsible for calling Logout()
@@ -53,7 +238,7 @@ public static ContentstackClient CreateAuthenticatedClient()
53238
var handler = new LoggingHttpHandler();
54239
var httpClient = new HttpClient(handler);
55240
var client = new ContentstackClient(httpClient, options);
56-
client.Login(Credential);
241+
LoginWithTotpRetry(client);
57242
return client;
58243
}
59244

Contentstack.Management.Core.Tests/Helpers/AssertLogger.cs

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,13 @@ public static void IsNotNull(object value, string name = "")
1616
Assert.IsNotNull(value);
1717
}
1818

19+
public static void IsNotNull(object value, string message, string name)
20+
{
21+
bool passed = value != null;
22+
TestOutputLogger.LogAssertion($"IsNotNull({name})", "NotNull", value?.ToString() ?? "null", passed);
23+
Assert.IsNotNull(value, message);
24+
}
25+
1926
public static void IsNull(object value, string name = "")
2027
{
2128
bool passed = value == null;
@@ -97,6 +104,31 @@ public static T ThrowsException<T>(Action action, string name = "") where T : Ex
97104
}
98105
}
99106

107+
public static async Task<T> ThrowsExceptionAsync<T>(Func<Task> action, string name = "") where T : Exception
108+
{
109+
try
110+
{
111+
await action();
112+
TestOutputLogger.LogAssertion($"ThrowsExceptionAsync<{typeof(T).Name}>({name})", typeof(T).Name, "NoException", false);
113+
throw new AssertFailedException($"Expected exception {typeof(T).Name} was not thrown.");
114+
}
115+
catch (T ex)
116+
{
117+
TestOutputLogger.LogAssertion($"ThrowsExceptionAsync<{typeof(T).Name}>({name})", typeof(T).Name, typeof(T).Name, true);
118+
return ex;
119+
}
120+
catch (AssertFailedException)
121+
{
122+
throw;
123+
}
124+
catch (Exception ex)
125+
{
126+
TestOutputLogger.LogAssertion($"ThrowsExceptionAsync<{typeof(T).Name}>({name})", typeof(T).Name, ex.GetType().Name, false);
127+
throw new AssertFailedException(
128+
$"Expected exception {typeof(T).Name} but got {ex.GetType().Name}: {ex.Message}", ex);
129+
}
130+
}
131+
100132
public static void Fail(string message)
101133
{
102134
TestOutputLogger.LogAssertion("Fail", "N/A", message ?? "", false);

0 commit comments

Comments
 (0)