diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b35a7a..eba36e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,12 @@ # Changelog +## [v0.4.0](https://github.com/contentstack/contentstack-management-dotnet/tree/v0.4.0) + - Feat + - **MFA Support**: Added Multi-Factor Authentication (MFA) support for login operations + - Added `mfaSecret` parameter to `Login` and `LoginAsync` methods for TOTP generation + - Automatic TOTP token generation from Base32-encoded MFA secrets using Otp.NET library + - Comprehensive test coverage for MFA functionality including unit and integration tests + - Supports both explicit token and MFA secret-based authentication flows + ## [v0.3.2](https://github.com/contentstack/contentstack-management-dotnet/tree/v0.3.2) - Fix - Added Test cases for the Release diff --git a/Contentstack.Management.Core.Tests/IntegrationTest/Contentstack001_LoginTest.cs b/Contentstack.Management.Core.Tests/IntegrationTest/Contentstack001_LoginTest.cs index c500203..cf5824e 100644 --- a/Contentstack.Management.Core.Tests/IntegrationTest/Contentstack001_LoginTest.cs +++ b/Contentstack.Management.Core.Tests/IntegrationTest/Contentstack001_LoginTest.cs @@ -175,5 +175,123 @@ public void Test007_Should_Return_Loggedin_User_With_Organizations_detail() Assert.Fail(e.Message); } } + + [TestMethod] + [DoNotParallelize] + public void Test008_Should_Fail_Login_With_Invalid_MfaSecret() + { + ContentstackClient client = new ContentstackClient(); + NetworkCredential credentials = new NetworkCredential("test_user", "test_password"); + string invalidMfaSecret = "INVALID_BASE32_SECRET!@#"; + + try + { + ContentstackResponse contentstackResponse = client.Login(credentials, null, invalidMfaSecret); + Assert.Fail("Expected exception for invalid MFA secret"); + } + catch (ArgumentException) + { + // Expected exception for invalid Base32 encoding + Assert.IsTrue(true); + } + catch (Exception e) + { + Assert.Fail($"Unexpected exception type: {e.GetType().Name} - {e.Message}"); + } + } + + [TestMethod] + [DoNotParallelize] + public void Test009_Should_Generate_TOTP_Token_With_Valid_MfaSecret() + { + ContentstackClient client = new ContentstackClient(); + NetworkCredential credentials = new NetworkCredential("test_user", "test_password"); + string validMfaSecret = "JBSWY3DPEHPK3PXP"; // Valid Base32 test secret + + try + { + // This should fail due to invalid credentials, but should succeed in generating TOTP + ContentstackResponse contentstackResponse = client.Login(credentials, null, validMfaSecret); + } + catch (ContentstackErrorException errorException) + { + // Expected to fail due to invalid credentials, but we verify it processed the MFA secret + // The error should be about credentials, not about MFA secret format + Assert.AreEqual(HttpStatusCode.UnprocessableEntity, errorException.StatusCode); + Assert.IsTrue(errorException.Message.Contains("email or password") || + errorException.Message.Contains("credentials") || + errorException.Message.Contains("authentication")); + } + catch (ArgumentException) + { + Assert.Fail("Should not throw ArgumentException for valid MFA secret"); + } + catch (Exception e) + { + Assert.Fail($"Unexpected exception type: {e.GetType().Name} - {e.Message}"); + } + } + + [TestMethod] + [DoNotParallelize] + public async System.Threading.Tasks.Task Test010_Should_Generate_TOTP_Token_With_Valid_MfaSecret_Async() + { + ContentstackClient client = new ContentstackClient(); + NetworkCredential credentials = new NetworkCredential("test_user", "test_password"); + string validMfaSecret = "JBSWY3DPEHPK3PXP"; // Valid Base32 test secret + + try + { + // This should fail due to invalid credentials, but should succeed in generating TOTP + ContentstackResponse contentstackResponse = await client.LoginAsync(credentials, null, validMfaSecret); + } + catch (ContentstackErrorException errorException) + { + // Expected to fail due to invalid credentials, but we verify it processed the MFA secret + // The error should be about credentials, not about MFA secret format + Assert.AreEqual(HttpStatusCode.UnprocessableEntity, errorException.StatusCode); + Assert.IsTrue(errorException.Message.Contains("email or password") || + errorException.Message.Contains("credentials") || + errorException.Message.Contains("authentication")); + } + catch (ArgumentException) + { + Assert.Fail("Should not throw ArgumentException for valid MFA secret"); + } + catch (Exception e) + { + Assert.Fail($"Unexpected exception type: {e.GetType().Name} - {e.Message}"); + } + } + + [TestMethod] + [DoNotParallelize] + public void Test011_Should_Prefer_Explicit_Token_Over_MfaSecret() + { + ContentstackClient client = new ContentstackClient(); + NetworkCredential credentials = new NetworkCredential("test_user", "test_password"); + string validMfaSecret = "JBSWY3DPEHPK3PXP"; + string explicitToken = "123456"; + + try + { + // This should fail due to invalid credentials, but should use explicit token + ContentstackResponse contentstackResponse = client.Login(credentials, explicitToken, validMfaSecret); + } + catch (ContentstackErrorException errorException) + { + // Expected to fail due to invalid credentials + // The important thing is that it didn't throw an exception about MFA secret processing + Assert.AreEqual(HttpStatusCode.UnprocessableEntity, errorException.StatusCode); + } + catch (ArgumentException) + { + Assert.Fail("Should not throw ArgumentException when explicit token is provided"); + } + catch (Exception e) + { + Assert.Fail($"Unexpected exception type: {e.GetType().Name} - {e.Message}"); + } + } } } diff --git a/Contentstack.Management.Core.Tests/IntegrationTest/Contentstack016_VariantsTest.cs b/Contentstack.Management.Core.Tests/IntegrationTest/Contentstack016_VariantsTest.cs new file mode 100644 index 0000000..4d64f52 --- /dev/null +++ b/Contentstack.Management.Core.Tests/IntegrationTest/Contentstack016_VariantsTest.cs @@ -0,0 +1,258 @@ +using System; +using System.Threading.Tasks; +using Contentstack.Management.Core.Models; +using Contentstack.Management.Core.Tests.Model; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Contentstack.Management.Core.Tests.IntegrationTest +{ + [TestClass] + public class Contentstack016_VariantsTest + { + private Stack _stack; + + [TestInitialize] + public void Initialize() + { + StackResponse response = StackResponse.getStack(Contentstack.Client.serializer); + _stack = Contentstack.Client.Stack(response.Stack.APIKey); + } + + [TestMethod] + [DoNotParallelize] + public async Task Test001_Should_Create_Variants() + { + try + { + VariantsModel variantsModel = new VariantsModel(); + ContentstackResponse response = await _stack.Variants().CreateAsync(variantsModel); + Assert.AreEqual(System.Net.HttpStatusCode.Created, response.StatusCode); + } + catch (Exception e) + { + Assert.Fail(e.Message); + } + } + + [TestMethod] + [DoNotParallelize] + public async Task Test002_Should_Fetch_Variants_By_Uid() + { + try + { + // First create a variant to ensure we have something to fetch + VariantsModel variantsModel = new VariantsModel(); + ContentstackResponse createResponse = await _stack.Variants().CreateAsync(variantsModel); + Assert.AreEqual(System.Net.HttpStatusCode.Created, createResponse.StatusCode); + + // Extract UID from created variant + var createdVariant = createResponse.OpenJObjectResponse(); + string variantUid = createdVariant["variant"]["uid"].ToString(); + + // Test fetching by UID + ContentstackResponse fetchResponse = await _stack.Variants(variantUid).FetchAsync(); + Assert.IsTrue(fetchResponse.StatusCode == System.Net.HttpStatusCode.OK || + fetchResponse.StatusCode == System.Net.HttpStatusCode.NotFound); + } + catch (Exception e) + { + Assert.Fail(e.Message); + } + } + + [TestMethod] + [DoNotParallelize] + public async Task Test003_Should_FetchByUid_Multiple_Variants() + { + try + { + // Create multiple variants first + VariantsModel variantsModel1 = new VariantsModel(); + VariantsModel variantsModel2 = new VariantsModel(); + + ContentstackResponse createResponse1 = await _stack.Variants().CreateAsync(variantsModel1); + ContentstackResponse createResponse2 = await _stack.Variants().CreateAsync(variantsModel2); + + Assert.AreEqual(System.Net.HttpStatusCode.Created, createResponse1.StatusCode); + Assert.AreEqual(System.Net.HttpStatusCode.Created, createResponse2.StatusCode); + + // Extract UIDs from created variants + var createdVariant1 = createResponse1.OpenJObjectResponse(); + var createdVariant2 = createResponse2.OpenJObjectResponse(); + string variantUid1 = createdVariant1["variant"]["uid"].ToString(); + string variantUid2 = createdVariant2["variant"]["uid"].ToString(); + + // Test fetching multiple variants by UIDs + string[] uids = { variantUid1, variantUid2 }; + ContentstackResponse fetchResponse = await _stack.Variants().FetchByUidAsync(uids); + + Assert.IsTrue(fetchResponse.StatusCode == System.Net.HttpStatusCode.OK || + fetchResponse.StatusCode == System.Net.HttpStatusCode.NotFound); + } + catch (Exception e) + { + Assert.Fail(e.Message); + } + } + + [TestMethod] + [DoNotParallelize] + public async Task Test004_Should_FetchByUid_Single_Variant() + { + try + { + // Create a variant first + VariantsModel variantsModel = new VariantsModel(); + ContentstackResponse createResponse = await _stack.Variants().CreateAsync(variantsModel); + Assert.AreEqual(System.Net.HttpStatusCode.Created, createResponse.StatusCode); + + // Extract UID from created variant + var createdVariant = createResponse.OpenJObjectResponse(); + string variantUid = createdVariant["variant"]["uid"].ToString(); + + // Test fetching single variant using FetchByUid + string[] uids = { variantUid }; + ContentstackResponse fetchResponse = await _stack.Variants().FetchByUidAsync(uids); + + Assert.IsTrue(fetchResponse.StatusCode == System.Net.HttpStatusCode.OK || + fetchResponse.StatusCode == System.Net.HttpStatusCode.NotFound); + } + catch (Exception e) + { + Assert.Fail(e.Message); + } + } + + [TestMethod] + [DoNotParallelize] + public async Task Test005_Should_Handle_FetchByUid_With_Nonexistent_Uids() + { + try + { + // Test fetching with non-existent UIDs + string[] nonExistentUids = { "nonexistent_uid_1", "nonexistent_uid_2" }; + ContentstackResponse fetchResponse = await _stack.Variants().FetchByUidAsync(nonExistentUids); + + // Should return 404 or empty result, not crash + Assert.IsTrue(fetchResponse.StatusCode == System.Net.HttpStatusCode.NotFound || + fetchResponse.StatusCode == System.Net.HttpStatusCode.OK); + } + catch (Exception e) + { + Assert.Fail(e.Message); + } + } + + [TestMethod] + [DoNotParallelize] + public async Task Test006_Should_FetchByUid_Sync_Method() + { + try + { + // Create a variant first + VariantsModel variantsModel = new VariantsModel(); + ContentstackResponse createResponse = _stack.Variants().Create(variantsModel); + Assert.AreEqual(System.Net.HttpStatusCode.Created, createResponse.StatusCode); + + // Extract UID from created variant + var createdVariant = createResponse.OpenJObjectResponse(); + string variantUid = createdVariant["variant"]["uid"].ToString(); + + // Test synchronous FetchByUid + string[] uids = { variantUid }; + ContentstackResponse fetchResponse = _stack.Variants().FetchByUid(uids); + + Assert.IsTrue(fetchResponse.StatusCode == System.Net.HttpStatusCode.OK || + fetchResponse.StatusCode == System.Net.HttpStatusCode.NotFound); + } + catch (Exception e) + { + Assert.Fail(e.Message); + } + } + + [TestMethod] + [DoNotParallelize] + public async Task Test007_Should_Delete_Variant() + { + try + { + // Create a variant first + VariantsModel variantsModel = new VariantsModel(); + ContentstackResponse createResponse = await _stack.Variants().CreateAsync(variantsModel); + Assert.AreEqual(System.Net.HttpStatusCode.Created, createResponse.StatusCode); + + // Extract UID from created variant + var createdVariant = createResponse.OpenJObjectResponse(); + string variantUid = createdVariant["variant"]["uid"].ToString(); + + // Test deleting the variant + ContentstackResponse deleteResponse = await _stack.Variants(variantUid).DeleteAsync(); + Assert.IsTrue(deleteResponse.StatusCode == System.Net.HttpStatusCode.OK || + deleteResponse.StatusCode == System.Net.HttpStatusCode.NoContent || + deleteResponse.StatusCode == System.Net.HttpStatusCode.NotFound); + } + catch (Exception e) + { + Assert.Fail(e.Message); + } + } + + [TestMethod] + [DoNotParallelize] + public void Test008_Should_Validate_FetchByUid_Parameters() + { + try + { + // Test with null UIDs + Assert.ThrowsException(() => _stack.Variants().FetchByUid(null)); + + // Test with empty UIDs array + string[] emptyUids = new string[0]; + Assert.ThrowsException(() => _stack.Variants().FetchByUid(emptyUids)); + } + catch (Exception e) + { + Assert.Fail(e.Message); + } + } + + [TestMethod] + [DoNotParallelize] + public async Task Test009_Should_Validate_FetchByUidAsync_Parameters() + { + try + { + // Test with null UIDs + await Assert.ThrowsExceptionAsync(() => _stack.Variants().FetchByUidAsync(null)); + + // Test with empty UIDs array + string[] emptyUids = new string[0]; + await Assert.ThrowsExceptionAsync(() => _stack.Variants().FetchByUidAsync(emptyUids)); + } + catch (Exception e) + { + Assert.Fail(e.Message); + } + } + + [TestMethod] + [DoNotParallelize] + public void Test010_Should_Validate_Instance_With_Uid_Cannot_Use_FetchByUid() + { + try + { + // Test that an instance with UID cannot call FetchByUid + string instanceUid = "some_uid"; + string[] uids = { "uid1", "uid2" }; + + Assert.ThrowsException(() => + _stack.Variants(instanceUid).FetchByUid(uids)); + } + catch (Exception e) + { + Assert.Fail(e.Message); + } + } + } +} diff --git a/Contentstack.Management.Core.Unit.Tests/Core/Services/User/LoginServiceTest.cs b/Contentstack.Management.Core.Unit.Tests/Core/Services/User/LoginServiceTest.cs index 2f0f946..ed7dfdd 100644 --- a/Contentstack.Management.Core.Unit.Tests/Core/Services/User/LoginServiceTest.cs +++ b/Contentstack.Management.Core.Unit.Tests/Core/Services/User/LoginServiceTest.cs @@ -50,6 +50,107 @@ public void Should_Allow_Credentials_With_Token() Assert.AreEqual("{\"user\":{\"email\":\"name\",\"password\":\"password\",\"tfa_token\":\"token\"}}", Encoding.Default.GetString(loginService.ByteContent)); } + [TestMethod] + public void Should_Allow_Credentials_With_MfaSecret() + { + + string testMfaSecret = "JBSWY3DPEHPK3PXP"; // Base32 encoded "Hello!" + var loginService = new LoginService(serializer, credentials, null, testMfaSecret); + loginService.ContentBody(); + + Assert.IsNotNull(loginService); + var contentString = Encoding.Default.GetString(loginService.ByteContent); + + Assert.IsTrue(contentString.Contains("\"email\":\"name\"")); + Assert.IsTrue(contentString.Contains("\"password\":\"password\"")); + Assert.IsTrue(contentString.Contains("\"tfa_token\":")); + + // Verify the tfa_token is not null or empty in the JSON + Assert.IsFalse(contentString.Contains("\"tfa_token\":null")); + Assert.IsFalse(contentString.Contains("\"tfa_token\":\"\"")); + } + + [TestMethod] + public void Should_Generate_TOTP_Token_When_MfaSecret_Provided() + { + string testMfaSecret = "JBSWY3DPEHPK3PXP"; // Base32 encoded "Hello!" + var loginService1 = new LoginService(serializer, credentials, null, testMfaSecret); + var loginService2 = new LoginService(serializer, credentials, null, testMfaSecret); + + loginService1.ContentBody(); + loginService2.ContentBody(); + + var content1 = Encoding.Default.GetString(loginService1.ByteContent); + var content2 = Encoding.Default.GetString(loginService2.ByteContent); + + // Both should contain tfa_token + Assert.IsTrue(content1.Contains("\"tfa_token\":")); + Assert.IsTrue(content2.Contains("\"tfa_token\":")); + + // Extract the tokens for comparison (tokens should be 6 digits) + var token1Match = System.Text.RegularExpressions.Regex.Match(content1, "\"tfa_token\":\"(\\d{6})\""); + var token2Match = System.Text.RegularExpressions.Regex.Match(content2, "\"tfa_token\":\"(\\d{6})\""); + + Assert.IsTrue(token1Match.Success); + Assert.IsTrue(token2Match.Success); + + // Tokens should be valid 6-digit numbers + Assert.AreEqual(6, token1Match.Groups[1].Value.Length); + Assert.AreEqual(6, token2Match.Groups[1].Value.Length); + } + + [TestMethod] + public void Should_Prefer_Explicit_Token_Over_MfaSecret() + { + string testMfaSecret = "JBSWY3DPEHPK3PXP"; + // file deepcode ignore NoHardcodedCredentials/test: random test token + string explicitToken = "123456"; + + var loginService = new LoginService(serializer, credentials, explicitToken, testMfaSecret); + loginService.ContentBody(); + + var contentString = Encoding.Default.GetString(loginService.ByteContent); + + // Should use the explicit token, not generate one from MFA secret + Assert.IsTrue(contentString.Contains("\"tfa_token\":\"123456\"")); + } + + [TestMethod] + [ExpectedException(typeof(ArgumentException))] + public void Should_Throw_Exception_For_Invalid_Base32_MfaSecret() + { + // Invalid Base32 secret (contains invalid characters) + string invalidMfaSecret = "INVALID_BASE32_123!@#"; + + var loginService = new LoginService(serializer, credentials, null, invalidMfaSecret); + } + + [TestMethod] + public void Should_Not_Generate_Token_When_MfaSecret_Is_Empty() + { + var loginService = new LoginService(serializer, credentials, null, ""); + loginService.ContentBody(); + + var contentString = Encoding.Default.GetString(loginService.ByteContent); + + // Should not contain tfa_token when MFA secret is empty + Assert.IsFalse(contentString.Contains("\"tfa_token\":")); + Assert.AreEqual("{\"user\":{\"email\":\"name\",\"password\":\"password\"}}", contentString); + } + + [TestMethod] + public void Should_Not_Generate_Token_When_MfaSecret_Is_Null() + { + var loginService = new LoginService(serializer, credentials, null, null); + loginService.ContentBody(); + + var contentString = Encoding.Default.GetString(loginService.ByteContent); + + // Should not contain tfa_token when MFA secret is null + Assert.IsFalse(contentString.Contains("\"tfa_token\":")); + Assert.AreEqual("{\"user\":{\"email\":\"name\",\"password\":\"password\"}}", contentString); + } + [TestMethod] public void Should_Override_Authtoken_To_ContentstackOptions_On_Success() { diff --git a/Contentstack.Management.Core.Unit.Tests/Models/VariantsTest.cs b/Contentstack.Management.Core.Unit.Tests/Models/VariantsTest.cs new file mode 100644 index 0000000..9dcfc95 --- /dev/null +++ b/Contentstack.Management.Core.Unit.Tests/Models/VariantsTest.cs @@ -0,0 +1,258 @@ +using System; +using System.Threading.Tasks; +using AutoFixture; +using Contentstack.Management.Core.Models; +using Contentstack.Management.Core.Queryable; +using Contentstack.Management.Core.Unit.Tests.Mokes; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Contentstack.Management.Core.Unit.Tests.Models +{ + [TestClass] + public class VariantsTest + { + private Stack _stack; + private readonly IFixture _fixture = new Fixture(); + private ContentstackResponse _contentstackResponse; + private VariantsModel _variantsModel = new VariantsModel(); + + [TestInitialize] + public void Initialize() + { + var client = new ContentstackClient(); + _contentstackResponse = MockResponse.CreateContentstackResponse("MockResponse.txt"); + client.ContentstackPipeline.ReplaceHandler(new MockHttpHandler(_contentstackResponse)); + client.contentstackOptions.Authtoken = _fixture.Create(); + _stack = new Stack(client, _fixture.Create()); + } + + #region Initialize Tests + + [TestMethod] + public void Initialize_Variants_Without_Uid() + { + Variants variants = new Variants(_stack, null); + + Assert.IsNull(variants.Uid); + Assert.AreEqual("/variants", variants.resourcePath); + Assert.ThrowsException(() => variants.Fetch()); + Assert.ThrowsExceptionAsync(() => variants.FetchAsync()); + Assert.ThrowsException(() => variants.Delete()); + Assert.ThrowsExceptionAsync(() => variants.DeleteAsync()); + } + + [TestMethod] + public void Initialize_Variants_With_Uid() + { + string uid = _fixture.Create(); + Variants variants = new Variants(_stack, uid); + + Assert.AreEqual(uid, variants.Uid); + Assert.AreEqual($"/variants/{uid}", variants.resourcePath); + Assert.ThrowsException(() => variants.Create(_variantsModel)); + Assert.ThrowsExceptionAsync(() => variants.CreateAsync(_variantsModel)); + Assert.ThrowsException(() => variants.FetchByUid(new string[] { "uid1", "uid2" })); + Assert.ThrowsExceptionAsync(() => variants.FetchByUidAsync(new string[] { "uid1", "uid2" })); + } + + #endregion + + #region Create Tests + + [TestMethod] + public void Should_Create_Variants() + { + ContentstackResponse response = _stack.Variants().Create(_variantsModel); + + Assert.AreEqual(_contentstackResponse.OpenResponse(), response.OpenResponse()); + Assert.AreEqual(_contentstackResponse.OpenJObjectResponse().ToString(), response.OpenJObjectResponse().ToString()); + } + + [TestMethod] + public async Task Should_Create_Variants_Async() + { + ContentstackResponse response = await _stack.Variants().CreateAsync(_variantsModel); + + Assert.AreEqual(_contentstackResponse.OpenResponse(), response.OpenResponse()); + Assert.AreEqual(_contentstackResponse.OpenJObjectResponse().ToString(), response.OpenJObjectResponse().ToString()); + } + + #endregion + + #region Fetch Tests + + [TestMethod] + public void Should_Fetch_Variants() + { + ContentstackResponse response = _stack.Variants(_fixture.Create()).Fetch(); + + Assert.AreEqual(_contentstackResponse.OpenResponse(), response.OpenResponse()); + Assert.AreEqual(_contentstackResponse.OpenJObjectResponse().ToString(), response.OpenJObjectResponse().ToString()); + } + + [TestMethod] + public async Task Should_Fetch_Variants_Async() + { + ContentstackResponse response = await _stack.Variants(_fixture.Create()).FetchAsync(); + + Assert.AreEqual(_contentstackResponse.OpenResponse(), response.OpenResponse()); + Assert.AreEqual(_contentstackResponse.OpenJObjectResponse().ToString(), response.OpenJObjectResponse().ToString()); + } + + [TestMethod] + public void Should_Fetch_Variants_With_Parameters() + { + ParameterCollection collection = new ParameterCollection(); + collection.Add("include_count", true); + + ContentstackResponse response = _stack.Variants(_fixture.Create()).Fetch(collection); + + Assert.AreEqual(_contentstackResponse.OpenResponse(), response.OpenResponse()); + Assert.AreEqual(_contentstackResponse.OpenJObjectResponse().ToString(), response.OpenJObjectResponse().ToString()); + } + + [TestMethod] + public async Task Should_Fetch_Variants_With_Parameters_Async() + { + ParameterCollection collection = new ParameterCollection(); + collection.Add("include_count", true); + + ContentstackResponse response = await _stack.Variants(_fixture.Create()).FetchAsync(collection); + + Assert.AreEqual(_contentstackResponse.OpenResponse(), response.OpenResponse()); + Assert.AreEqual(_contentstackResponse.OpenJObjectResponse().ToString(), response.OpenJObjectResponse().ToString()); + } + + #endregion + + #region FetchByUid Tests + + [TestMethod] + public void Should_FetchByUid_Variants() + { + string[] uids = { _fixture.Create(), _fixture.Create(), _fixture.Create() }; + ContentstackResponse response = _stack.Variants().FetchByUid(uids); + + Assert.AreEqual(_contentstackResponse.OpenResponse(), response.OpenResponse()); + Assert.AreEqual(_contentstackResponse.OpenJObjectResponse().ToString(), response.OpenJObjectResponse().ToString()); + } + + [TestMethod] + public async Task Should_FetchByUid_Variants_Async() + { + string[] uids = { _fixture.Create(), _fixture.Create(), _fixture.Create() }; + ContentstackResponse response = await _stack.Variants().FetchByUidAsync(uids); + + Assert.AreEqual(_contentstackResponse.OpenResponse(), response.OpenResponse()); + Assert.AreEqual(_contentstackResponse.OpenJObjectResponse().ToString(), response.OpenJObjectResponse().ToString()); + } + + [TestMethod] + public void Should_Throw_Exception_When_FetchByUid_Called_With_Null_Uids() + { + Assert.ThrowsException(() => _stack.Variants().FetchByUid(null)); + } + + [TestMethod] + public async Task Should_Throw_Exception_When_FetchByUidAsync_Called_With_Null_Uids() + { + await Assert.ThrowsExceptionAsync(() => _stack.Variants().FetchByUidAsync(null)); + } + + [TestMethod] + public void Should_Throw_Exception_When_FetchByUid_Called_With_Empty_Uids() + { + string[] emptyUids = new string[0]; + Assert.ThrowsException(() => _stack.Variants().FetchByUid(emptyUids)); + } + + [TestMethod] + public async Task Should_Throw_Exception_When_FetchByUidAsync_Called_With_Empty_Uids() + { + string[] emptyUids = new string[0]; + await Assert.ThrowsExceptionAsync(() => _stack.Variants().FetchByUidAsync(emptyUids)); + } + + [TestMethod] + public void Should_Throw_Exception_When_FetchByUid_Called_On_Instance_With_Uid() + { + string[] uids = { _fixture.Create(), _fixture.Create() }; + Assert.ThrowsException(() => _stack.Variants(_fixture.Create()).FetchByUid(uids)); + } + + [TestMethod] + public async Task Should_Throw_Exception_When_FetchByUidAsync_Called_On_Instance_With_Uid() + { + string[] uids = { _fixture.Create(), _fixture.Create() }; + await Assert.ThrowsExceptionAsync(() => _stack.Variants(_fixture.Create()).FetchByUidAsync(uids)); + } + + [TestMethod] + public void Should_FetchByUid_Single_Uid() + { + string[] uids = { _fixture.Create() }; + ContentstackResponse response = _stack.Variants().FetchByUid(uids); + + Assert.AreEqual(_contentstackResponse.OpenResponse(), response.OpenResponse()); + Assert.AreEqual(_contentstackResponse.OpenJObjectResponse().ToString(), response.OpenJObjectResponse().ToString()); + } + + [TestMethod] + public async Task Should_FetchByUid_Single_Uid_Async() + { + string[] uids = { _fixture.Create() }; + ContentstackResponse response = await _stack.Variants().FetchByUidAsync(uids); + + Assert.AreEqual(_contentstackResponse.OpenResponse(), response.OpenResponse()); + Assert.AreEqual(_contentstackResponse.OpenJObjectResponse().ToString(), response.OpenJObjectResponse().ToString()); + } + + #endregion + + #region Delete Tests + + [TestMethod] + public void Should_Delete_Variants() + { + ContentstackResponse response = _stack.Variants(_fixture.Create()).Delete(); + + Assert.AreEqual(_contentstackResponse.OpenResponse(), response.OpenResponse()); + Assert.AreEqual(_contentstackResponse.OpenJObjectResponse().ToString(), response.OpenJObjectResponse().ToString()); + } + + [TestMethod] + public async Task Should_Delete_Variants_Async() + { + ContentstackResponse response = await _stack.Variants(_fixture.Create()).DeleteAsync(); + + Assert.AreEqual(_contentstackResponse.OpenResponse(), response.OpenResponse()); + Assert.AreEqual(_contentstackResponse.OpenJObjectResponse().ToString(), response.OpenJObjectResponse().ToString()); + } + + #endregion + + #region Validation Tests + + [TestMethod] + public void Should_Throw_Exception_When_APIKey_Is_Null() + { + var client = new ContentstackClient(); + client.contentstackOptions.Authtoken = _fixture.Create(); + var stackWithNullAPIKey = new Stack(client, null); + + Assert.ThrowsException(() => new Variants(stackWithNullAPIKey)); + } + + [TestMethod] + public void Should_Throw_Exception_When_APIKey_Is_Empty() + { + var client = new ContentstackClient(); + client.contentstackOptions.Authtoken = _fixture.Create(); + var stackWithEmptyAPIKey = new Stack(client, ""); + + Assert.ThrowsException(() => new Variants(stackWithEmptyAPIKey)); + } + + #endregion + } +} diff --git a/Contentstack.Management.Core/ContentstackClient.cs b/Contentstack.Management.Core/ContentstackClient.cs index ddbcc25..03904a2 100644 --- a/Contentstack.Management.Core/ContentstackClient.cs +++ b/Contentstack.Management.Core/ContentstackClient.cs @@ -334,6 +334,7 @@ public Stack Stack(string apiKey = null, string managementToken = null, string b /// /// User credentials for login. /// The optional 2FA token. + /// The optional MFA Secret for 2FA token. /// ///

         /// ContentstackClient client = new ContentstackClient("", "");
@@ -342,10 +343,10 @@ public Stack Stack(string apiKey = null, string managementToken = null, string b
         /// 
///
/// The - public ContentstackResponse Login(ICredentials credentials, string token = null) + public ContentstackResponse Login(ICredentials credentials, string token = null, string mfaSecret = null) { ThrowIfAlreadyLoggedIn(); - LoginService Login = new LoginService(serializer, credentials, token); + LoginService Login = new LoginService(serializer, credentials, token, mfaSecret); return InvokeSync(Login); } @@ -355,6 +356,7 @@ public ContentstackResponse Login(ICredentials credentials, string token = null) /// /// User credentials for login. /// The optional 2FA token. + /// The optional MFA Secret for 2FA token. /// ///

         /// ContentstackClient client = new ContentstackClient("", "");
@@ -363,10 +365,10 @@ public ContentstackResponse Login(ICredentials credentials, string token = null)
         /// 
///
/// The Task. - public Task LoginAsync(ICredentials credentials, string token = null) + public Task LoginAsync(ICredentials credentials, string token = null, string mfaSecret = null) { ThrowIfAlreadyLoggedIn(); - LoginService Login = new LoginService(serializer, credentials, token); + LoginService Login = new LoginService(serializer, credentials, token, mfaSecret); return InvokeAsync(Login); } diff --git a/Contentstack.Management.Core/Models/Stack.cs b/Contentstack.Management.Core/Models/Stack.cs index d876e4a..90a14ac 100644 --- a/Contentstack.Management.Core/Models/Stack.cs +++ b/Contentstack.Management.Core/Models/Stack.cs @@ -4,7 +4,6 @@ using Contentstack.Management.Core.Queryable; using Contentstack.Management.Core.Services.Stack; using Contentstack.Management.Core.Utils; -using Contentstack.Management.Core.Models; using Contentstack.Management.Core.Models.Token; namespace Contentstack.Management.Core.Models @@ -623,7 +622,7 @@ public ContentType ContentType(string uid = null) return new ContentType(this, uid); } /// - /// efer to all the media files (images, videos, PDFs, audio files, and so on) uploaded in your Contentstack repository for future use. + /// refer to all the media files (images, videos, PDFs, audio files, and so on) uploaded in your Contentstack repository for future use. /// /// Optional asset uid. /// @@ -763,7 +762,7 @@ public ManagementToken ManagementTokens(string uid = null) } /// - /// A collection of permissions that will be applicable to all the users who are assigned this role. + /// A is a collection of permissions that will be applicable to all the users who are assigned this role. /// /// Optional, role uid. /// @@ -773,7 +772,7 @@ public ManagementToken ManagementTokens(string uid = null) /// ContentstackResponse contentstackResponse = stack.Role("").Fetch(); /// /// - /// The + /// The public Role Role(string uid = null) { ThrowIfNotLoggedIn(); @@ -844,9 +843,9 @@ public PublishQueue PublishQueue(string uid = null) return new PublishQueue(this, uid); } /// - /// A a mechanism that sends real-time information to any third-party app or service to keep your application in sync with your Contentstack account. + /// A is a mechanism that sends real-time information to any third-party app or service to keep your application in sync with your Contentstack account. /// - /// Optional, webhook uid uid. + /// Optional, webhook uid. /// ///

         /// ContentstackClient client = new ContentstackClient("", "");
@@ -854,7 +853,7 @@ public PublishQueue PublishQueue(string uid = null)
         /// ContentstackResponse contentstackResponse = stack.Webhook("").Fetch();
         /// 
///
- /// The + /// The public Webhook Webhook(string uid = null) { ThrowIfNotLoggedIn(); @@ -912,6 +911,26 @@ public BulkOperation BulkOperation() return new BulkOperation(this); } + + /// + /// A represents different versions or variations of content entries that allow you to create and manage multiple variants of the same content. + /// + /// Optional, variant uid. + /// + ///

+        /// ContentstackClient client = new ContentstackClient("", "");
+        /// Stack stack = client.Stack("");
+        /// ContentstackResponse contentstackResponse = stack.Variants("").Fetch();
+        /// 
+ ///
+ /// The + public Variants Variants(string uid = null) + { + ThrowIfNotLoggedIn(); + ThrowIfAPIKeyEmpty(); + + return new Variants(this, uid); + } #endregion #region Throw Error diff --git a/Contentstack.Management.Core/Models/Variants.cs b/Contentstack.Management.Core/Models/Variants.cs new file mode 100644 index 0000000..b9afa58 --- /dev/null +++ b/Contentstack.Management.Core/Models/Variants.cs @@ -0,0 +1,226 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using Contentstack.Management.Core.Queryable; +using Contentstack.Management.Core.Services.Models; +using Contentstack.Management.Core.Abstractions; + +namespace Contentstack.Management.Core.Models +{ + public class Variants + { + internal Stack stack; + public string Uid { get; set; } + + internal string resourcePath; + + internal Variants(Stack stack, string uid = null) + { + stack.ThrowIfAPIKeyEmpty(); + + this.stack = stack; + Uid = uid; + resourcePath = uid == null ? "/variants" : $"/variants/{uid}"; + } + + /// + /// The Delete call is used to delete a specific variant. + /// + /// + ///

+        /// ContentstackClient client = new ContentstackClient("", "");
+        /// ContentstackResponse contentstackResponse = client.Stack("").Variants("").Delete();
+        /// 
+ ///
+ /// The containing the deletion result. + public ContentstackResponse Delete() + { + stack.ThrowIfNotLoggedIn(); + ThrowIfUidEmpty(); + + var service = new FetchDeleteService(stack.client.serializer, stack, resourcePath, "DELETE"); + return stack.client.InvokeSync(service); + } + + /// + /// The DeleteAsync call is used to asynchronously delete a specific variant. + /// + /// + ///

+        /// ContentstackClient client = new ContentstackClient("", "");
+        /// ContentstackResponse contentstackResponse = await client.Stack("").Variants("").DeleteAsync();
+        /// 
+ ///
+ /// The Task containing with the deletion result. + public Task DeleteAsync() + { + stack.ThrowIfNotLoggedIn(); + ThrowIfUidEmpty(); + + var service = new FetchDeleteService(stack.client.serializer, stack, resourcePath, "DELETE"); + return stack.client.InvokeAsync(service, true); + } + + /// + /// The Fetch call retrieves a specific variant by UID. + /// + /// Optional query parameters. + /// + ///

+        /// ContentstackClient client = new ContentstackClient("", "");
+        /// ContentstackResponse contentstackResponse = client.Stack("").Variants("").Fetch();
+        /// 
+ ///
+ /// The containing the variant data. + public ContentstackResponse Fetch(ParameterCollection collection = null) + { + stack.ThrowIfNotLoggedIn(); + ThrowIfUidEmpty(); + + var service = new FetchDeleteService(stack.client.serializer, stack, resourcePath, collection: collection); + return stack.client.InvokeSync(service); + } + + /// + /// The FetchAsync call asynchronously retrieves a specific variant by UID. + /// + /// Optional query parameters. + /// + ///

+        /// ContentstackClient client = new ContentstackClient("", "");
+        /// ContentstackResponse contentstackResponse = await client.Stack("").Variants("").FetchAsync();
+        /// 
+ ///
+ /// The Task containing with the variant data. + public Task FetchAsync(ParameterCollection collection = null) + { + stack.ThrowIfNotLoggedIn(); + ThrowIfUidEmpty(); + + var service = new FetchDeleteService(stack.client.serializer, stack, resourcePath, collection: collection); + return stack.client.InvokeAsync(service); + } + + /// + /// The Create call is used to create an entry variant in the stack. + /// + /// The containing variant details. + /// + ///

+        /// ContentstackClient client = new ContentstackClient("", "");
+        /// VariantsModel model = new VariantsModel(); 
+        /// ContentstackResponse contentstackResponse = client.Stack("").Variants().Create(model);
+        /// 
+ ///
+ /// The containing the created variant data. + public ContentstackResponse Create(VariantsModel model) + { + ThrowIfUidNotEmpty(); + + var service = new CreateUpdateService(stack.client.serializer, stack, resourcePath, model, "entry"); + return stack.client.InvokeSync(service); + } + + /// + /// The CreateAsync call is used to asynchronously create an entry variant in the stack. + /// + /// The containing variant details. + /// + ///

+        /// ContentstackClient client = new ContentstackClient("", "");
+        /// VariantsModel model = new VariantsModel();
+        /// ContentstackResponse contentstackResponse = await client.Stack("").Variants().CreateAsync(model);
+        /// 
+ ///
+ /// The Task containing with the created variant data. + public Task CreateAsync(VariantsModel model) + { + ThrowIfUidNotEmpty(); + stack.ThrowIfNotLoggedIn(); + + var service = new CreateUpdateService(stack.client.serializer, stack, resourcePath, model, "entry"); + return stack.client.InvokeAsync, ContentstackResponse>(service); + } + + /// + /// The FetchByUid call retrieves multiple variants by passing an array of their UIDs. + /// This method allows you to fetch multiple variants in a single API call. + /// + /// Array of variant UIDs to fetch. Cannot be null or empty. + /// + ///

+        /// ContentstackClient client = new ContentstackClient("", "");
+        /// string[] variantUids = {"bltvariant123", "bltvariant456", "bltvariant789"};
+        /// ContentstackResponse contentstackResponse = client.Stack("").Variants().FetchByUid(variantUids);
+        /// 
+ ///
+ /// The containing the requested variants data. + public ContentstackResponse FetchByUid(string[] uids) + { + stack.ThrowIfNotLoggedIn(); + ThrowIfUidNotEmpty(); + + if (uids == null || uids.Length == 0) + { + throw new ArgumentException("UIDs array cannot be null or empty.", nameof(uids)); + } + + var collection = new ParameterCollection(); + collection.Add("uid", uids.ToList()); + + var service = new FetchDeleteService(stack.client.serializer, stack, resourcePath, collection: collection); + return stack.client.InvokeSync(service); + } + + /// + /// The FetchByUidAsync call asynchronously retrieves multiple variants by passing an array of their UIDs. + /// This method allows you to fetch multiple variants in a single API call asynchronously. + /// + /// Array of variant UIDs to fetch. Cannot be null or empty. + /// + ///

+        /// ContentstackClient client = new ContentstackClient("", "");
+        /// string[] variantUids = {"bltvariant123", "bltvariant456", "bltvariant789"};
+        /// ContentstackResponse contentstackResponse = await client.Stack("").Variants().FetchByUidAsync(variantUids);
+        /// 
+ ///
+ /// The Task containing with the requested variants data. + public Task FetchByUidAsync(string[] uids) + { + stack.ThrowIfNotLoggedIn(); + ThrowIfUidNotEmpty(); + + if (uids == null || uids.Length == 0) + { + throw new ArgumentException("UIDs array cannot be null or empty.", nameof(uids)); + } + + var collection = new ParameterCollection(); + collection.Add("uid", uids.ToList()); + + var service = new FetchDeleteService(stack.client.serializer, stack, resourcePath, collection: collection); + return stack.client.InvokeAsync(service); + } + + #region Internal Validation + + /// Validates no UID is set for collection operations. + internal void ThrowIfUidNotEmpty() + { + if (!string.IsNullOrEmpty(this.Uid)) + { + throw new InvalidOperationException("Operation not allowed."); + } + } + + /// Validates UID is set for specific variant operations. + internal void ThrowIfUidEmpty() + { + if (string.IsNullOrEmpty(this.Uid)) + { + throw new InvalidOperationException("Uid can not be empty."); + } + } + #endregion + } +} diff --git a/Contentstack.Management.Core/Models/VariantsModel.cs b/Contentstack.Management.Core/Models/VariantsModel.cs new file mode 100644 index 0000000..0abae0c --- /dev/null +++ b/Contentstack.Management.Core/Models/VariantsModel.cs @@ -0,0 +1,12 @@ +using System; +using Newtonsoft.Json; + +namespace Contentstack.Management.Core.Models +{ + [JsonObject(ItemNullValueHandling = NullValueHandling.Ignore)] + public class VariantsModel + { + [JsonProperty(propertyName: "title")] + string Title { get; set; } + } +} diff --git a/Contentstack.Management.Core/Services/User/LoginService.cs b/Contentstack.Management.Core/Services/User/LoginService.cs index 5c5c180..9dbaf05 100644 --- a/Contentstack.Management.Core/Services/User/LoginService.cs +++ b/Contentstack.Management.Core/Services/User/LoginService.cs @@ -4,6 +4,7 @@ using Newtonsoft.Json; using System.Globalization; using Newtonsoft.Json.Linq; +using OtpNet; using Contentstack.Management.Core.Http; namespace Contentstack.Management.Core.Services.User @@ -16,7 +17,7 @@ internal class LoginService : ContentstackService #endregion #region Constructor - internal LoginService(JsonSerializer serializer, ICredentials credentials, string token = null): base(serializer) + internal LoginService(JsonSerializer serializer, ICredentials credentials, string token = null, string mfaSecret = null): base(serializer) { this.HttpMethod = "POST"; this.ResourcePath = "user-session"; @@ -27,7 +28,18 @@ internal LoginService(JsonSerializer serializer, ICredentials credentials, strin } _credentials = credentials; - _token = token; + + if (string.IsNullOrEmpty(token) && !string.IsNullOrEmpty(mfaSecret)) + { + var secretBytes = Base32Encoding.ToBytes(mfaSecret); + + var totp = new Totp(secretBytes); + _token = totp.ComputeTotp(); + } + else + { + _token = token; + } } #endregion diff --git a/Contentstack.Management.Core/contentstack.management.core.csproj b/Contentstack.Management.Core/contentstack.management.core.csproj index 1fd8f92..0f14e8c 100644 --- a/Contentstack.Management.Core/contentstack.management.core.csproj +++ b/Contentstack.Management.Core/contentstack.management.core.csproj @@ -11,7 +11,6 @@ LICENSE.txt https://github.com/contentstack/contentstack-management-dotnet README.md - ContentType query issue resolved Contentstack management API $(Version) $(Version) @@ -63,6 +62,7 @@ + diff --git a/Directory.Build.props b/Directory.Build.props index 216b2fd..2627f35 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,5 +1,5 @@ - 0.3.2 + 0.4.0