diff --git a/src/Billing/Controllers/FreshdeskController.cs b/src/Billing/Controllers/FreshdeskController.cs index 0e458198c5..7e7b0a6b41 100644 --- a/src/Billing/Controllers/FreshdeskController.cs +++ b/src/Billing/Controllers/FreshdeskController.cs @@ -1,7 +1,7 @@ using System.ComponentModel.DataAnnotations; using System.Reflection; using System.Text; -using System.Text.Json; +using Bit.Billing.Models; using Bit.Core.Repositories; using Bit.Core.Settings; using Bit.Core.Utilities; @@ -17,18 +17,18 @@ namespace Bit.Billing.Controllers private readonly IUserRepository _userRepository; private readonly IOrganizationRepository _organizationRepository; private readonly IOrganizationUserRepository _organizationUserRepository; - private readonly ILogger _logger; + private readonly ILogger _logger; private readonly GlobalSettings _globalSettings; - private readonly HttpClient _httpClient = new HttpClient(); - private readonly string _freshdeskAuthkey; + private readonly IHttpClientFactory _httpClientFactory; public FreshdeskController( IUserRepository userRepository, IOrganizationRepository organizationRepository, IOrganizationUserRepository organizationUserRepository, IOptions billingSettings, - ILogger logger, - GlobalSettings globalSettings) + ILogger logger, + GlobalSettings globalSettings, + IHttpClientFactory httpClientFactory) { _billingSettings = billingSettings?.Value; _userRepository = userRepository; @@ -36,37 +36,23 @@ namespace Bit.Billing.Controllers _organizationUserRepository = organizationUserRepository; _logger = logger; _globalSettings = globalSettings; - _freshdeskAuthkey = Convert.ToBase64String( - Encoding.UTF8.GetBytes($"{_billingSettings.FreshdeskApiKey}:X")); + _httpClientFactory = httpClientFactory; } [HttpPost("webhook")] - public async Task PostWebhook() + public async Task PostWebhook([FromQuery, Required] string key, + [FromBody, Required] FreshdeskWebhookModel model) { - if (HttpContext?.Request?.Query == null) - { - return new BadRequestResult(); - } - - var key = HttpContext.Request.Query.ContainsKey("key") ? - HttpContext.Request.Query["key"].ToString() : null; - if (!CoreHelpers.FixedTimeEquals(key, _billingSettings.FreshdeskWebhookKey)) - { - return new BadRequestResult(); - } - - using var body = await JsonSerializer.DeserializeAsync(HttpContext.Request.Body); - var root = body.RootElement; - if (root.ValueKind != JsonValueKind.Object) + if (string.IsNullOrWhiteSpace(key) || !CoreHelpers.FixedTimeEquals(key, _billingSettings.FreshdeskWebhookKey)) { return new BadRequestResult(); } try { - var ticketId = root.GetProperty("ticket_id").GetString(); - var ticketContactEmail = root.GetProperty("ticket_contact_email").GetString(); - var ticketTags = root.GetProperty("ticket_tags").GetString(); + var ticketId = model.TicketId; + var ticketContactEmail = model.TicketContactEmail; + var ticketTags = model.TicketTags; if (string.IsNullOrWhiteSpace(ticketId) || string.IsNullOrWhiteSpace(ticketContactEmail)) { return new BadRequestResult(); @@ -74,20 +60,34 @@ namespace Bit.Billing.Controllers var updateBody = new Dictionary(); var note = string.Empty; + var customFields = new Dictionary(); var user = await _userRepository.GetByEmailAsync(ticketContactEmail); if (user != null) { - note += $"
  • User, {user.Email}: {_globalSettings.BaseServiceUri.Admin}/users/edit/{user.Id}
  • "; + var userLink = $"{_globalSettings.BaseServiceUri.Admin}/users/edit/{user.Id}"; + note += $"
  • User, {user.Email}: {userLink}
  • "; + customFields.Add("cf_user", userLink); var tags = new HashSet(); if (user.Premium) { tags.Add("Premium"); } var orgs = await _organizationRepository.GetManyByUserIdAsync(user.Id); + foreach (var org in orgs) { - note += $"
  • Org, {org.Name} ({org.Seats.GetValueOrDefault()}): " + - $"{_globalSettings.BaseServiceUri.Admin}/organizations/edit/{org.Id}
  • "; + var orgNote = $"{org.Name} ({org.Seats.GetValueOrDefault()}): " + + $"{_globalSettings.BaseServiceUri.Admin}/organizations/edit/{org.Id}"; + note += $"
  • Org, {orgNote}
  • "; + if (!customFields.Any(kvp => kvp.Key == "cf_org")) + { + customFields.Add("cf_org", orgNote); + } + else + { + customFields["cf_org"] += $"\n{orgNote}"; + } + var planName = GetAttribute(org.PlanType).Name.Split(" ").FirstOrDefault(); if (!string.IsNullOrWhiteSpace(planName)) { @@ -107,15 +107,18 @@ namespace Bit.Billing.Controllers } updateBody.Add("tags", tagsToUpdate); } + + if (customFields.Any()) + { + updateBody.Add("custom_fields", customFields); + } var updateRequest = new HttpRequestMessage(HttpMethod.Put, string.Format("https://bitwarden.freshdesk.com/api/v2/tickets/{0}", ticketId)) { Content = JsonContent.Create(updateBody), }; - await CallFreshdeskApiAsync(updateRequest); - var noteBody = new Dictionary { { "body", $"
      {note}
    " }, @@ -142,8 +145,10 @@ namespace Bit.Billing.Controllers { try { - request.Headers.Add("Authorization", _freshdeskAuthkey); - var response = await _httpClient.SendAsync(request); + var freshdeskAuthkey = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{_billingSettings.FreshdeskApiKey}:X")); + var httpClient = _httpClientFactory.CreateClient("FreshdeskApi"); + request.Headers.Add("Authorization", freshdeskAuthkey); + var response = await httpClient.SendAsync(request); if (response.StatusCode != System.Net.HttpStatusCode.TooManyRequests || retriedCount > 3) { return response; diff --git a/src/Billing/Models/FreshdeskWebhookModel.cs b/src/Billing/Models/FreshdeskWebhookModel.cs new file mode 100644 index 0000000000..c371c70fb5 --- /dev/null +++ b/src/Billing/Models/FreshdeskWebhookModel.cs @@ -0,0 +1,16 @@ +using System.Text.Json.Serialization; + +namespace Bit.Billing.Models +{ + public class FreshdeskWebhookModel + { + [JsonPropertyName("ticket_id")] + public string TicketId { get; set; } + + [JsonPropertyName("ticket_contact_email")] + public string TicketContactEmail { get; set; } + + [JsonPropertyName("ticket_tags")] + public string TicketTags { get; set; } + } +} diff --git a/src/Billing/Startup.cs b/src/Billing/Startup.cs index ab11b82e53..a2a161a88a 100644 --- a/src/Billing/Startup.cs +++ b/src/Billing/Startup.cs @@ -68,6 +68,9 @@ namespace Bit.Billing // Jobs service, uncomment when we have some jobs to run // Jobs.JobsHostedService.AddJobsServices(services); // services.AddHostedService(); + + // Set up HttpClients + services.AddHttpClient("FreshdeskApi"); } public void Configure( diff --git a/test/Api.Test/Controllers/CollectionsControllerTests.cs b/test/Api.Test/Controllers/CollectionsControllerTests.cs index aec4f28f67..ba9620a007 100644 --- a/test/Api.Test/Controllers/CollectionsControllerTests.cs +++ b/test/Api.Test/Controllers/CollectionsControllerTests.cs @@ -1,6 +1,5 @@ using Bit.Api.Controllers; using Bit.Api.Models.Request; -using Bit.Api.Test.AutoFixture.Attributes; using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Exceptions; diff --git a/test/Api.Test/Controllers/OrganizationConnectionsControllerTests.cs b/test/Api.Test/Controllers/OrganizationConnectionsControllerTests.cs index c4c4136709..88834621ab 100644 --- a/test/Api.Test/Controllers/OrganizationConnectionsControllerTests.cs +++ b/test/Api.Test/Controllers/OrganizationConnectionsControllerTests.cs @@ -2,7 +2,6 @@ using Bit.Api.Controllers; using Bit.Api.Models.Request.Organizations; using Bit.Api.Models.Response.Organizations; -using Bit.Api.Test.AutoFixture.Attributes; using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Enums; diff --git a/test/Api.Test/Controllers/OrganizationSponsorshipsControllerTests.cs b/test/Api.Test/Controllers/OrganizationSponsorshipsControllerTests.cs index d30d4328c6..0cafdf9ff1 100644 --- a/test/Api.Test/Controllers/OrganizationSponsorshipsControllerTests.cs +++ b/test/Api.Test/Controllers/OrganizationSponsorshipsControllerTests.cs @@ -1,6 +1,5 @@ using Bit.Api.Controllers; using Bit.Api.Models.Request.Organizations; -using Bit.Api.Test.AutoFixture.Attributes; using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Enums; diff --git a/test/Api.Test/Controllers/OrganizationUsersControllerTests.cs b/test/Api.Test/Controllers/OrganizationUsersControllerTests.cs index ec58055636..585508c663 100644 --- a/test/Api.Test/Controllers/OrganizationUsersControllerTests.cs +++ b/test/Api.Test/Controllers/OrganizationUsersControllerTests.cs @@ -1,6 +1,5 @@ using Bit.Api.Controllers; using Bit.Api.Models.Request.Organizations; -using Bit.Api.Test.AutoFixture.Attributes; using Bit.Core.Entities; using Bit.Core.Models.Data.Organizations.Policies; using Bit.Core.Repositories; diff --git a/test/Billing.Test/Controllers/FreshdeskControllerTests.cs b/test/Billing.Test/Controllers/FreshdeskControllerTests.cs new file mode 100644 index 0000000000..3688960029 --- /dev/null +++ b/test/Billing.Test/Controllers/FreshdeskControllerTests.cs @@ -0,0 +1,80 @@ +using Bit.Billing.Controllers; +using Bit.Billing.Models; +using Bit.Core.Entities; +using Bit.Core.Repositories; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; +using NSubstitute; +using Xunit; + +namespace Bit.Billing.Test.Controllers +{ + [ControllerCustomize(typeof(FreshdeskController))] + [SutProviderCustomize] + public class FreshdeskControllerTests + { + private const string ApiKey = "TESTFRESHDESKAPIKEY"; + private const string WebhookKey = "TESTKEY"; + + [Theory] + [BitAutoData((string)null, null)] + [BitAutoData((string)null)] + [BitAutoData(WebhookKey, null)] + public async Task PostWebhook_NullRequiredParameters_BadRequest(string freshdeskWebhookKey, FreshdeskWebhookModel model, + BillingSettings billingSettings, SutProvider sutProvider) + { + sutProvider.GetDependency>().Value.FreshdeskWebhookKey.Returns(billingSettings.FreshdeskWebhookKey); + + var response = await sutProvider.Sut.PostWebhook(freshdeskWebhookKey, model); + + var statusCodeResult = Assert.IsAssignableFrom(response); + Assert.Equal(StatusCodes.Status400BadRequest, statusCodeResult.StatusCode); + } + + [Theory] + [BitAutoData] + public async Task PostWebhook_Success(User user, FreshdeskWebhookModel model, + List organizations, SutProvider sutProvider) + { + model.TicketContactEmail = user.Email; + + sutProvider.GetDependency().GetByEmailAsync(user.Email).Returns(user); + sutProvider.GetDependency().GetManyByUserIdAsync(user.Id).Returns(organizations); + + var mockHttpMessageHandler = Substitute.ForPartsOf(); + var mockResponse = new HttpResponseMessage(System.Net.HttpStatusCode.OK); + mockHttpMessageHandler.Send(Arg.Any(), Arg.Any()) + .Returns(mockResponse); + var httpClient = new HttpClient(mockHttpMessageHandler); + + sutProvider.GetDependency().CreateClient("FreshdeskApi").Returns(httpClient); + + sutProvider.GetDependency>().Value.FreshdeskWebhookKey.Returns(WebhookKey); + sutProvider.GetDependency>().Value.FreshdeskApiKey.Returns(ApiKey); + + var response = await sutProvider.Sut.PostWebhook(WebhookKey, model); + + var statusCodeResult = Assert.IsAssignableFrom(response); + Assert.Equal(StatusCodes.Status200OK, statusCodeResult.StatusCode); + + _ = mockHttpMessageHandler.Received(1).Send(Arg.Is(m => m.Method == HttpMethod.Put && m.RequestUri.ToString().EndsWith(model.TicketId)), Arg.Any()); + _ = mockHttpMessageHandler.Received(1).Send(Arg.Is(m => m.Method == HttpMethod.Post && m.RequestUri.ToString().EndsWith($"{model.TicketId}/notes")), Arg.Any()); + } + + public class MockHttpMessageHandler : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + return Send(request, cancellationToken); + } + + public virtual Task Send(HttpRequestMessage request, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + } + } +} diff --git a/test/Api.Test/AutoFixture/Attributes/ControllerCustomizeAttribute.cs b/test/Common/AutoFixture/Attributes/ControllerCustomizeAttribute.cs similarity index 89% rename from test/Api.Test/AutoFixture/Attributes/ControllerCustomizeAttribute.cs rename to test/Common/AutoFixture/Attributes/ControllerCustomizeAttribute.cs index 61f0ff4885..6cab60bae0 100644 --- a/test/Api.Test/AutoFixture/Attributes/ControllerCustomizeAttribute.cs +++ b/test/Common/AutoFixture/Attributes/ControllerCustomizeAttribute.cs @@ -1,7 +1,6 @@ using AutoFixture; -using Bit.Test.Common.AutoFixture.Attributes; -namespace Bit.Api.Test.AutoFixture.Attributes +namespace Bit.Test.Common.AutoFixture.Attributes { /// /// Disables setting of Auto Properties on the Controller to avoid ASP.net initialization errors from a mock environment. Still sets constructor dependencies. diff --git a/test/Api.Test/AutoFixture/ControllerCustomization.cs b/test/Common/AutoFixture/ControllerCustomization.cs similarity index 94% rename from test/Api.Test/AutoFixture/ControllerCustomization.cs rename to test/Common/AutoFixture/ControllerCustomization.cs index 372594fd2f..9592466aa5 100644 --- a/test/Api.Test/AutoFixture/ControllerCustomization.cs +++ b/test/Common/AutoFixture/ControllerCustomization.cs @@ -1,9 +1,8 @@ using AutoFixture; -using Bit.Test.Common.AutoFixture; using Microsoft.AspNetCore.Mvc; using Org.BouncyCastle.Security; -namespace Bit.Api.Test.AutoFixture +namespace Bit.Test.Common.AutoFixture { /// /// Disables setting of Auto Properties on the Controller to avoid ASP.net initialization errors. Still sets constructor dependencies.