From bb34de74cbd0053b7f1454611fbd5e54d04a63d2 Mon Sep 17 00:00:00 2001 From: Justin Baur Date: Wed, 22 Dec 2021 13:27:52 -0500 Subject: [PATCH] Freshsales integration (#1782) * Add FreshsalesController * Add better errors * Fix formatting issues * Add comments * Add Billing.Test to solution files * Fix unit test * Format code * Address PR feedback --- bitwarden-server.sln | 7 + src/Billing/BillingSettings.cs | 1 + .../Controllers/FreshsalesController.cs | 244 ++++++++++++++++++ test/Billing.Test/Billing.Test.csproj | 27 ++ .../Controllers/FreshsalesControllerTests.cs | 88 +++++++ .../Attributes/EnvironmentDataAttribute.cs | 45 ++++ .../RequiredEnvironmentTheoryAttribute.cs | 39 +++ test/bitwarden.tests.sln | 14 + 8 files changed, 465 insertions(+) create mode 100644 src/Billing/Controllers/FreshsalesController.cs create mode 100644 test/Billing.Test/Billing.Test.csproj create mode 100644 test/Billing.Test/Controllers/FreshsalesControllerTests.cs create mode 100644 test/Common/AutoFixture/Attributes/EnvironmentDataAttribute.cs create mode 100644 test/Common/AutoFixture/Attributes/RequiredEnvironmentTheoryAttribute.cs diff --git a/bitwarden-server.sln b/bitwarden-server.sln index 50095d725..1e96c3aa0 100644 --- a/bitwarden-server.sln +++ b/bitwarden-server.sln @@ -76,6 +76,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PostgresMigrations", "util\ EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Common", "test\Common\Common.csproj", "{17DA09D7-0212-4009-879E-6B9CFDE5FA60}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Billing.Test", "test\Billing.Test\Billing.Test.csproj", "{B8639B10-2157-44BC-8CE1-D9EB4B50971F}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -172,6 +174,10 @@ Global {17DA09D7-0212-4009-879E-6B9CFDE5FA60}.Debug|Any CPU.Build.0 = Debug|Any CPU {17DA09D7-0212-4009-879E-6B9CFDE5FA60}.Release|Any CPU.ActiveCfg = Release|Any CPU {17DA09D7-0212-4009-879E-6B9CFDE5FA60}.Release|Any CPU.Build.0 = Release|Any CPU + {B8639B10-2157-44BC-8CE1-D9EB4B50971F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B8639B10-2157-44BC-8CE1-D9EB4B50971F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B8639B10-2157-44BC-8CE1-D9EB4B50971F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B8639B10-2157-44BC-8CE1-D9EB4B50971F}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -199,6 +205,7 @@ Global {EDC0D688-D58C-4CE1-AA07-3606AC6874B8} = {4FDB6543-F68B-4202-9EA6-7FEA984D2D0A} {0E99A21B-684B-4C59-9831-90F775CAB6F7} = {287CFF34-BBDB-4BC4-AF88-1E19A5A4679B} {17DA09D7-0212-4009-879E-6B9CFDE5FA60} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84F} + {B8639B10-2157-44BC-8CE1-D9EB4B50971F} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84F} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {E01CBF68-2E20-425F-9EDB-E0A6510CA92F} diff --git a/src/Billing/BillingSettings.cs b/src/Billing/BillingSettings.cs index 274774bd4..4c5a389ed 100644 --- a/src/Billing/BillingSettings.cs +++ b/src/Billing/BillingSettings.cs @@ -9,6 +9,7 @@ public virtual string AppleWebhookKey { get; set; } public virtual string FreshdeskWebhookKey { get; set; } public virtual string FreshdeskApiKey { get; set; } + public virtual string FreshsalesApiKey { get; set; } public virtual PayPalSettings PayPal { get; set; } = new PayPalSettings(); public class PayPalSettings diff --git a/src/Billing/Controllers/FreshsalesController.cs b/src/Billing/Controllers/FreshsalesController.cs new file mode 100644 index 000000000..f623e9796 --- /dev/null +++ b/src/Billing/Controllers/FreshsalesController.cs @@ -0,0 +1,244 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Tasks; +using Bit.Core.Enums; +using Bit.Core.Repositories; +using Bit.Core.Settings; +using Bit.Core.Utilities; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Bit.Billing.Controllers +{ + [Route("freshsales")] + public class FreshsalesController : Controller + { + private readonly IUserRepository _userRepository; + private readonly IOrganizationRepository _organizationRepository; + private readonly ILogger _logger; + private readonly GlobalSettings _globalSettings; + + private readonly string _freshsalesApiKey; + + private readonly HttpClient _httpClient; + + public FreshsalesController(IUserRepository userRepository, + IOrganizationRepository organizationRepository, + IOptions billingSettings, + ILogger logger, + GlobalSettings globalSettings) + { + _userRepository = userRepository; + _organizationRepository = organizationRepository; + _logger = logger; + _globalSettings = globalSettings; + + _httpClient = new HttpClient + { + BaseAddress = new Uri("https://bitwarden.freshsales.io/api/") + }; + + _freshsalesApiKey = billingSettings.Value.FreshsalesApiKey; + + _httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue( + "Token", + $"token={_freshsalesApiKey}"); + } + + + [HttpPost("webhook")] + public async Task PostWebhook([FromHeader(Name = "Authorization")] string key, + [FromBody] CustomWebhookRequestModel request, + CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(key) || !CoreHelpers.FixedTimeEquals(_freshsalesApiKey, key)) + { + return Unauthorized(); + } + + try + { + var leadResponse = await _httpClient.GetFromJsonAsync>( + $"leads/{request.LeadId}", + cancellationToken); + + var lead = leadResponse.Lead; + + var primaryEmail = lead.Emails + .Where(e => e.IsPrimary) + .FirstOrDefault(); + + if (primaryEmail == null) + { + return BadRequest(new { Message = "Lead has not primary email." }); + } + + var user = await _userRepository.GetByEmailAsync(primaryEmail.Value); + + if (user == null) + { + return NoContent(); + } + + var newTags = new HashSet(); + + if (user.Premium) + { + newTags.Add("Premium"); + } + + var noteItems = new List + { + $"User, {user.Email}: {_globalSettings.BaseServiceUri.Admin}/users/edit/{user.Id}" + }; + + var orgs = await _organizationRepository.GetManyByUserIdAsync(user.Id); + + foreach (var org in orgs) + { + noteItems.Add($"Org, {org.Name}: {_globalSettings.BaseServiceUri.Admin}/organizations/edit/{org.Id}"); + if (TryGetPlanName(org.PlanType, out var planName)) + { + newTags.Add($"Org: {planName}"); + } + } + + if (newTags.Any()) + { + var allTags = newTags.Concat(lead.Tags); + var updateLeadResponse = await _httpClient.PutAsJsonAsync( + $"leads/{request.LeadId}", + CreateWrapper(new { tags = allTags }), + cancellationToken); + updateLeadResponse.EnsureSuccessStatusCode(); + } + + var createNoteResponse = await _httpClient.PostAsJsonAsync( + "notes", + CreateNoteRequestModel(request.LeadId, string.Join('\n', noteItems)), cancellationToken); + createNoteResponse.EnsureSuccessStatusCode(); + return NoContent(); + } + catch (Exception ex) + { + Console.WriteLine(ex); + _logger.LogError(ex, "Error processing freshsales webhook"); + return BadRequest(new { ex.Message }); + } + } + + private static LeadWrapper CreateWrapper(T lead) + { + return new LeadWrapper + { + Lead = lead, + }; + } + + private static CreateNoteRequestModel CreateNoteRequestModel(long leadId, string content) + { + return new CreateNoteRequestModel + { + Note = new EditNoteModel + { + Description = content, + TargetableType = "Lead", + TargetableId = leadId, + }, + }; + } + + private static bool TryGetPlanName(PlanType planType, out string planName) + { + switch (planType) + { + case PlanType.Free: + planName = "Free"; + return true; + case PlanType.FamiliesAnnually: + case PlanType.FamiliesAnnually2019: + planName = "Families"; + return true; + case PlanType.TeamsAnnually: + case PlanType.TeamsAnnually2019: + case PlanType.TeamsMonthly: + case PlanType.TeamsMonthly2019: + planName = "Teams"; + return true; + case PlanType.EnterpriseAnnually: + case PlanType.EnterpriseAnnually2019: + case PlanType.EnterpriseMonthly: + case PlanType.EnterpriseMonthly2019: + planName = "Enterprise"; + return true; + case PlanType.Custom: + planName = "Custom"; + return true; + default: + planName = null; + return false; + } + } + } + + public class CustomWebhookRequestModel + { + [JsonPropertyName("leadId")] + public long LeadId { get; set; } + } + + public class LeadWrapper + { + [JsonPropertyName("lead")] + public T Lead { get; set; } + + public static LeadWrapper Create(TItem lead) + { + return new LeadWrapper + { + Lead = lead, + }; + } + } + + public class FreshsalesLeadModel + { + public string[] Tags { get; set; } + public FreshsalesEmailModel[] Emails { get; set; } + } + + public class FreshsalesEmailModel + { + [JsonPropertyName("value")] + public string Value { get; set; } + + [JsonPropertyName("is_primary")] + public bool IsPrimary { get; set; } + } + + public class CreateNoteRequestModel + { + [JsonPropertyName("note")] + public EditNoteModel Note { get; set; } + } + + public class EditNoteModel + { + [JsonPropertyName("description")] + public string Description { get; set; } + + [JsonPropertyName("targetable_type")] + public string TargetableType { get; set; } + + [JsonPropertyName("targetable_id")] + public long TargetableId { get; set; } + } +} diff --git a/test/Billing.Test/Billing.Test.csproj b/test/Billing.Test/Billing.Test.csproj new file mode 100644 index 000000000..ad14d5fb0 --- /dev/null +++ b/test/Billing.Test/Billing.Test.csproj @@ -0,0 +1,27 @@ + + + + false + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + diff --git a/test/Billing.Test/Controllers/FreshsalesControllerTests.cs b/test/Billing.Test/Controllers/FreshsalesControllerTests.cs new file mode 100644 index 000000000..d008bf894 --- /dev/null +++ b/test/Billing.Test/Controllers/FreshsalesControllerTests.cs @@ -0,0 +1,88 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Bit.Billing.Controllers; +using Bit.Core.Models.Table; +using Bit.Core.Repositories; +using Bit.Core.Settings; +using Bit.Test.Common.AutoFixture.Attributes; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using NSubstitute; +using Xunit; + +namespace Bit.Billing.Test.Controllers +{ + public class FreshsalesControllerTests + { + private const string ApiKey = "TEST_FRESHSALES_APIKEY"; + private const string TestLead = "TEST_FRESHSALES_TESTLEAD"; + + private static (FreshsalesController, IUserRepository, IOrganizationRepository) CreateSut( + string freshsalesApiKey) + { + var userRepository = Substitute.For(); + var organizationRepository = Substitute.For(); + + var billingSettings = Options.Create(new BillingSettings + { + FreshsalesApiKey = freshsalesApiKey, + }); + var globalSettings = new GlobalSettings(); + globalSettings.BaseServiceUri.Admin = "https://test.com"; + + var sut = new FreshsalesController( + userRepository, + organizationRepository, + billingSettings, + Substitute.For>(), + globalSettings + ); + + return (sut, userRepository, organizationRepository); + } + + [RequiredEnvironmentTheory(ApiKey, TestLead), EnvironmentData(ApiKey, TestLead)] + public async Task PostWebhook_Success(string freshsalesApiKey, long leadId) + { + // This test is only for development to use: + // `export TEST_FRESHSALES_APIKEY=[apikey]` + // `export TEST_FRESHSALES_TESTLEAD=[lead id]` + // `dotnet test --filter "FullyQualifiedName~FreshsalesControllerTests.PostWebhook_Success"` + var (sut, userRepository, organizationRepository) = CreateSut(freshsalesApiKey); + + var user = new User + { + Id = Guid.NewGuid(), + Email = "test@email.com", + Premium = true, + }; + + userRepository.GetByEmailAsync(user.Email) + .Returns(user); + + organizationRepository.GetManyByUserIdAsync(user.Id) + .Returns(new List + { + new Organization + { + Id = Guid.NewGuid(), + Name = "Test Org", + } + }); + + var response = await sut.PostWebhook(freshsalesApiKey, new CustomWebhookRequestModel + { + LeadId = leadId, + }, new CancellationToken(false)); + + var statusCodeResult = Assert.IsAssignableFrom(response); + Assert.Equal(StatusCodes.Status204NoContent, statusCodeResult.StatusCode); + } + } +} diff --git a/test/Common/AutoFixture/Attributes/EnvironmentDataAttribute.cs b/test/Common/AutoFixture/Attributes/EnvironmentDataAttribute.cs new file mode 100644 index 000000000..e77738d30 --- /dev/null +++ b/test/Common/AutoFixture/Attributes/EnvironmentDataAttribute.cs @@ -0,0 +1,45 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using Xunit.Sdk; + +namespace Bit.Test.Common.AutoFixture.Attributes +{ + /// + /// Used for collecting data from environment useful for when we want to test an integration with another service and + /// it might require an api key or other piece of sensitive data that we don't want slipping into the wrong hands. + /// + /// + /// It probably should be refactored to support fixtures and other customization so it can more easily be used in conjunction + /// with more parameters. Currently it attempt to match environment variable names to values of the parameter type in that positions. + /// It will start from the first parameter and go for each supplied name. + /// + public class EnvironmentDataAttribute : DataAttribute + { + private readonly string[] _environmentVariableNames; + + public EnvironmentDataAttribute(params string[] environmentVariableNames) + { + _environmentVariableNames = environmentVariableNames; + } + + public override IEnumerable GetData(MethodInfo testMethod) + { + var methodParameters = testMethod.GetParameters(); + + if (methodParameters.Length < _environmentVariableNames.Length) + { + throw new ArgumentException($"The target test method only has {methodParameters.Length} arguments but you supplied {_environmentVariableNames.Length}"); + } + + var values = new object[_environmentVariableNames.Length]; + + for (var i = 0; i < _environmentVariableNames.Length; i++) + { + values[i] = Convert.ChangeType(Environment.GetEnvironmentVariable(_environmentVariableNames[i]), methodParameters[i].ParameterType); + } + + return new[] { values }; + } + } +} diff --git a/test/Common/AutoFixture/Attributes/RequiredEnvironmentTheoryAttribute.cs b/test/Common/AutoFixture/Attributes/RequiredEnvironmentTheoryAttribute.cs new file mode 100644 index 000000000..c1be90996 --- /dev/null +++ b/test/Common/AutoFixture/Attributes/RequiredEnvironmentTheoryAttribute.cs @@ -0,0 +1,39 @@ +using System; +using Xunit; + +namespace Bit.Test.Common.AutoFixture.Attributes +{ + /// + /// Used for requiring certain environment variables exist at the time. Mostly used for more edge unit tests that shouldn't + /// be run during CI builds or should only be ran in CI builds when pieces of information are available. + /// + public class RequiredEnvironmentTheoryAttribute : TheoryAttribute + { + private readonly string[] _environmentVariableNames; + + public RequiredEnvironmentTheoryAttribute(params string[] environmentVariableNames) + { + _environmentVariableNames = environmentVariableNames; + + if (!HasRequiredEnvironmentVariables()) + { + Skip = $"Missing one or more required environment variables. ({string.Join(", ", _environmentVariableNames)})"; + } + } + + private bool HasRequiredEnvironmentVariables() + { + foreach (var env in _environmentVariableNames) + { + var value = Environment.GetEnvironmentVariable(env); + + if (value == null) + { + return false; + } + } + + return true; + } + } +} diff --git a/test/bitwarden.tests.sln b/test/bitwarden.tests.sln index cd5f7820f..a185b538f 100644 --- a/test/bitwarden.tests.sln +++ b/test/bitwarden.tests.sln @@ -11,6 +11,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Api.Test", "Api.Test\Api.Te EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Common", "Common\Common.csproj", "{E94B2922-EE05-435C-9472-FDEFEAD0AA37}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Billing.Test", "Billing.Test\Billing.Test.csproj", "{8CD044FE-3FED-4F29-858C-B06BCE70EAA6}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -72,5 +74,17 @@ Global {E94B2922-EE05-435C-9472-FDEFEAD0AA37}.Release|x64.Build.0 = Release|Any CPU {E94B2922-EE05-435C-9472-FDEFEAD0AA37}.Release|x86.ActiveCfg = Release|Any CPU {E94B2922-EE05-435C-9472-FDEFEAD0AA37}.Release|x86.Build.0 = Release|Any CPU + {8CD044FE-3FED-4F29-858C-B06BCE70EAA6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8CD044FE-3FED-4F29-858C-B06BCE70EAA6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8CD044FE-3FED-4F29-858C-B06BCE70EAA6}.Debug|x64.ActiveCfg = Debug|Any CPU + {8CD044FE-3FED-4F29-858C-B06BCE70EAA6}.Debug|x64.Build.0 = Debug|Any CPU + {8CD044FE-3FED-4F29-858C-B06BCE70EAA6}.Debug|x86.ActiveCfg = Debug|Any CPU + {8CD044FE-3FED-4F29-858C-B06BCE70EAA6}.Debug|x86.Build.0 = Debug|Any CPU + {8CD044FE-3FED-4F29-858C-B06BCE70EAA6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8CD044FE-3FED-4F29-858C-B06BCE70EAA6}.Release|Any CPU.Build.0 = Release|Any CPU + {8CD044FE-3FED-4F29-858C-B06BCE70EAA6}.Release|x64.ActiveCfg = Release|Any CPU + {8CD044FE-3FED-4F29-858C-B06BCE70EAA6}.Release|x64.Build.0 = Release|Any CPU + {8CD044FE-3FED-4F29-858C-B06BCE70EAA6}.Release|x86.ActiveCfg = Release|Any CPU + {8CD044FE-3FED-4F29-858C-B06BCE70EAA6}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection EndGlobal