mirror of
https://github.com/bitwarden/server
synced 2025-12-30 23:23:37 +00:00
SRE-3582 billing cleanup (#6772)
This commit is contained in:
@@ -9,10 +9,7 @@ public class BillingSettings
|
||||
public virtual string StripeWebhookKey { get; set; }
|
||||
public virtual string StripeWebhookSecret20250827Basil { get; set; }
|
||||
public virtual string AppleWebhookKey { get; set; }
|
||||
public virtual FreshDeskSettings FreshDesk { get; set; } = new FreshDeskSettings();
|
||||
public virtual string FreshsalesApiKey { get; set; }
|
||||
public virtual PayPalSettings PayPal { get; set; } = new PayPalSettings();
|
||||
public virtual OnyxSettings Onyx { get; set; } = new OnyxSettings();
|
||||
|
||||
public class PayPalSettings
|
||||
{
|
||||
@@ -21,35 +18,4 @@ public class BillingSettings
|
||||
public virtual string WebhookKey { get; set; }
|
||||
}
|
||||
|
||||
public class FreshDeskSettings
|
||||
{
|
||||
public virtual string ApiKey { get; set; }
|
||||
public virtual string WebhookKey { get; set; }
|
||||
/// <summary>
|
||||
/// Indicates the data center region. Valid values are "US" and "EU"
|
||||
/// </summary>
|
||||
public virtual string Region { get; set; }
|
||||
public virtual string UserFieldName { get; set; }
|
||||
public virtual string OrgFieldName { get; set; }
|
||||
|
||||
public virtual bool RemoveNewlinesInReplies { get; set; } = false;
|
||||
public virtual string AutoReplyGreeting { get; set; } = string.Empty;
|
||||
public virtual string AutoReplySalutation { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public class OnyxSettings
|
||||
{
|
||||
public virtual string ApiKey { get; set; }
|
||||
public virtual string BaseUrl { get; set; }
|
||||
public virtual string Path { get; set; }
|
||||
public virtual int PersonaId { get; set; }
|
||||
public virtual bool UseAnswerWithCitationModels { get; set; } = true;
|
||||
|
||||
public virtual SearchSettings SearchSettings { get; set; } = new SearchSettings();
|
||||
}
|
||||
public class SearchSettings
|
||||
{
|
||||
public virtual string RunSearch { get; set; } = "auto"; // "always", "never", "auto"
|
||||
public virtual bool RealTime { get; set; } = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,395 +0,0 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Web;
|
||||
using Bit.Billing.Models;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Settings;
|
||||
using Bit.Core.Utilities;
|
||||
using Markdig;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Bit.Billing.Controllers;
|
||||
|
||||
[Route("freshdesk")]
|
||||
public class FreshdeskController : Controller
|
||||
{
|
||||
private readonly BillingSettings _billingSettings;
|
||||
private readonly IUserRepository _userRepository;
|
||||
private readonly IOrganizationRepository _organizationRepository;
|
||||
private readonly ILogger<FreshdeskController> _logger;
|
||||
private readonly GlobalSettings _globalSettings;
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
|
||||
public FreshdeskController(
|
||||
IUserRepository userRepository,
|
||||
IOrganizationRepository organizationRepository,
|
||||
IOptions<BillingSettings> billingSettings,
|
||||
ILogger<FreshdeskController> logger,
|
||||
GlobalSettings globalSettings,
|
||||
IHttpClientFactory httpClientFactory)
|
||||
{
|
||||
_billingSettings = billingSettings?.Value ?? throw new ArgumentNullException(nameof(billingSettings));
|
||||
_userRepository = userRepository;
|
||||
_organizationRepository = organizationRepository;
|
||||
_logger = logger;
|
||||
_globalSettings = globalSettings;
|
||||
_httpClientFactory = httpClientFactory;
|
||||
}
|
||||
|
||||
[HttpPost("webhook")]
|
||||
public async Task<IActionResult> PostWebhook([FromQuery, Required] string key,
|
||||
[FromBody, Required] FreshdeskWebhookModel model)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(key) || !CoreHelpers.FixedTimeEquals(key, _billingSettings.FreshDesk.WebhookKey))
|
||||
{
|
||||
return new BadRequestResult();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var ticketId = model.TicketId;
|
||||
var ticketContactEmail = model.TicketContactEmail;
|
||||
var ticketTags = model.TicketTags;
|
||||
if (string.IsNullOrWhiteSpace(ticketId) || string.IsNullOrWhiteSpace(ticketContactEmail))
|
||||
{
|
||||
return new BadRequestResult();
|
||||
}
|
||||
|
||||
var updateBody = new Dictionary<string, object>();
|
||||
var note = string.Empty;
|
||||
note += $"<li>Region: {_billingSettings.FreshDesk.Region}</li>";
|
||||
var customFields = new Dictionary<string, object>();
|
||||
var user = await _userRepository.GetByEmailAsync(ticketContactEmail);
|
||||
if (user == null)
|
||||
{
|
||||
note += $"<li>No user found: {ticketContactEmail}</li>";
|
||||
await CreateNote(ticketId, note);
|
||||
}
|
||||
|
||||
if (user != null)
|
||||
{
|
||||
var userLink = $"{_globalSettings.BaseServiceUri.Admin}/users/edit/{user.Id}";
|
||||
note += $"<li>User, {user.Email}: {userLink}</li>";
|
||||
customFields.Add(_billingSettings.FreshDesk.UserFieldName, userLink);
|
||||
var tags = new HashSet<string>();
|
||||
if (user.Premium)
|
||||
{
|
||||
tags.Add("Premium");
|
||||
}
|
||||
var orgs = await _organizationRepository.GetManyByUserIdAsync(user.Id);
|
||||
|
||||
foreach (var org in orgs)
|
||||
{
|
||||
// Prevent org names from injecting any additional HTML
|
||||
var orgName = HttpUtility.HtmlEncode(org.Name);
|
||||
var orgNote = $"{orgName} ({org.Seats.GetValueOrDefault()}): " +
|
||||
$"{_globalSettings.BaseServiceUri.Admin}/organizations/edit/{org.Id}";
|
||||
note += $"<li>Org, {orgNote}</li>";
|
||||
if (!customFields.Any(kvp => kvp.Key == _billingSettings.FreshDesk.OrgFieldName))
|
||||
{
|
||||
customFields.Add(_billingSettings.FreshDesk.OrgFieldName, orgNote);
|
||||
}
|
||||
else
|
||||
{
|
||||
customFields[_billingSettings.FreshDesk.OrgFieldName] += $"\n{orgNote}";
|
||||
}
|
||||
|
||||
var displayAttribute = GetAttribute<DisplayAttribute>(org.PlanType);
|
||||
var planName = displayAttribute?.Name?.Split(" ").FirstOrDefault();
|
||||
if (!string.IsNullOrWhiteSpace(planName))
|
||||
{
|
||||
tags.Add(string.Format("Org: {0}", planName));
|
||||
}
|
||||
}
|
||||
if (tags.Any())
|
||||
{
|
||||
var tagsToUpdate = tags.ToList();
|
||||
if (!string.IsNullOrWhiteSpace(ticketTags))
|
||||
{
|
||||
var splitTicketTags = ticketTags.Split(',');
|
||||
for (var i = 0; i < splitTicketTags.Length; i++)
|
||||
{
|
||||
tagsToUpdate.Insert(i, splitTicketTags[i]);
|
||||
}
|
||||
}
|
||||
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);
|
||||
await CreateNote(ticketId, note);
|
||||
}
|
||||
|
||||
return new OkResult();
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
_logger.LogError(e, "Error processing freshdesk webhook.");
|
||||
return new BadRequestResult();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("webhook-onyx-ai")]
|
||||
public async Task<IActionResult> PostWebhookOnyxAi([FromQuery, Required] string key,
|
||||
[FromBody, Required] FreshdeskOnyxAiWebhookModel model)
|
||||
{
|
||||
// ensure that the key is from Freshdesk
|
||||
if (!IsValidRequestFromFreshdesk(key))
|
||||
{
|
||||
return new BadRequestResult();
|
||||
}
|
||||
|
||||
// if there is no description, then we don't send anything to onyx
|
||||
if (string.IsNullOrEmpty(model.TicketDescriptionText.Trim()))
|
||||
{
|
||||
return Ok();
|
||||
}
|
||||
|
||||
// Get response from Onyx AI
|
||||
var (onyxRequest, onyxResponse) = await GetAnswerFromOnyx(model);
|
||||
|
||||
// the CallOnyxApi will return a null if we have an error response
|
||||
if (onyxResponse?.Answer == null || !string.IsNullOrEmpty(onyxResponse?.ErrorMsg))
|
||||
{
|
||||
_logger.LogWarning("Error getting answer from Onyx AI. Freshdesk model: {model}\r\n Onyx query {query}\r\nresponse: {response}. ",
|
||||
JsonSerializer.Serialize(model),
|
||||
JsonSerializer.Serialize(onyxRequest),
|
||||
JsonSerializer.Serialize(onyxResponse));
|
||||
|
||||
return Ok(); // return ok so we don't retry
|
||||
}
|
||||
|
||||
// add the answer as a note to the ticket
|
||||
await AddAnswerNoteToTicketAsync(onyxResponse?.Answer ?? string.Empty, model.TicketId);
|
||||
|
||||
return Ok();
|
||||
}
|
||||
|
||||
[HttpPost("webhook-onyx-ai-reply")]
|
||||
public async Task<IActionResult> PostWebhookOnyxAiReply([FromQuery, Required] string key,
|
||||
[FromBody, Required] FreshdeskOnyxAiWebhookModel model)
|
||||
{
|
||||
// NOTE:
|
||||
// at this time, this endpoint is a duplicate of `webhook-onyx-ai`
|
||||
// eventually, we will merge both endpoints into one webhook for Freshdesk
|
||||
|
||||
// ensure that the key is from Freshdesk
|
||||
if (!IsValidRequestFromFreshdesk(key) || !ModelState.IsValid)
|
||||
{
|
||||
return new BadRequestResult();
|
||||
}
|
||||
|
||||
// if there is no description, then we don't send anything to onyx
|
||||
if (string.IsNullOrEmpty(model.TicketDescriptionText.Trim()))
|
||||
{
|
||||
return Ok();
|
||||
}
|
||||
|
||||
// create the onyx `answer-with-citation` request
|
||||
var (onyxRequest, onyxResponse) = await GetAnswerFromOnyx(model);
|
||||
|
||||
// the CallOnyxApi will return a null if we have an error response
|
||||
if (onyxResponse?.Answer == null || !string.IsNullOrEmpty(onyxResponse?.ErrorMsg))
|
||||
{
|
||||
_logger.LogWarning("Error getting answer from Onyx AI. Freshdesk model: {model}\r\n Onyx query {query}\r\nresponse: {response}. ",
|
||||
JsonSerializer.Serialize(model),
|
||||
JsonSerializer.Serialize(onyxRequest),
|
||||
JsonSerializer.Serialize(onyxResponse));
|
||||
|
||||
return Ok(); // return ok so we don't retry
|
||||
}
|
||||
|
||||
// add the reply to the ticket
|
||||
await AddReplyToTicketAsync(onyxResponse?.Answer ?? string.Empty, model.TicketId);
|
||||
|
||||
return Ok();
|
||||
}
|
||||
|
||||
private bool IsValidRequestFromFreshdesk(string key)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(key)
|
||||
|| !CoreHelpers.FixedTimeEquals(key, _billingSettings.FreshDesk.WebhookKey))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task CreateNote(string ticketId, string note)
|
||||
{
|
||||
var noteBody = new Dictionary<string, object>
|
||||
{
|
||||
{ "body", $"<ul>{note}</ul>" },
|
||||
{ "private", true }
|
||||
};
|
||||
var noteRequest = new HttpRequestMessage(HttpMethod.Post,
|
||||
string.Format("https://bitwarden.freshdesk.com/api/v2/tickets/{0}/notes", ticketId))
|
||||
{
|
||||
Content = JsonContent.Create(noteBody),
|
||||
};
|
||||
await CallFreshdeskApiAsync(noteRequest);
|
||||
}
|
||||
|
||||
private async Task AddAnswerNoteToTicketAsync(string note, string ticketId)
|
||||
{
|
||||
// if there is no content, then we don't need to add a note
|
||||
if (string.IsNullOrWhiteSpace(note))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var noteBody = new Dictionary<string, object>
|
||||
{
|
||||
{ "body", $"<b>Onyx AI:</b><ul>{note}</ul>" },
|
||||
{ "private", true }
|
||||
};
|
||||
|
||||
var noteRequest = new HttpRequestMessage(HttpMethod.Post,
|
||||
string.Format("https://bitwarden.freshdesk.com/api/v2/tickets/{0}/notes", ticketId))
|
||||
{
|
||||
Content = JsonContent.Create(noteBody),
|
||||
};
|
||||
|
||||
var addNoteResponse = await CallFreshdeskApiAsync(noteRequest);
|
||||
if (addNoteResponse.StatusCode != System.Net.HttpStatusCode.Created)
|
||||
{
|
||||
_logger.LogError("Error adding note to Freshdesk ticket. Ticket Id: {0}. Status: {1}",
|
||||
ticketId, addNoteResponse.ToString());
|
||||
}
|
||||
}
|
||||
|
||||
private async Task AddReplyToTicketAsync(string note, string ticketId)
|
||||
{
|
||||
// if there is no content, then we don't need to add a note
|
||||
if (string.IsNullOrWhiteSpace(note))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// convert note from markdown to html
|
||||
var htmlNote = note;
|
||||
try
|
||||
{
|
||||
var pipeline = new MarkdownPipelineBuilder().UseAdvancedExtensions().Build();
|
||||
htmlNote = Markdig.Markdown.ToHtml(note, pipeline);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error converting markdown to HTML for Freshdesk reply. Ticket Id: {0}. Note: {1}",
|
||||
ticketId, note);
|
||||
htmlNote = note; // fallback to the original note
|
||||
}
|
||||
|
||||
// clear out any new lines that Freshdesk doesn't like
|
||||
if (_billingSettings.FreshDesk.RemoveNewlinesInReplies)
|
||||
{
|
||||
htmlNote = htmlNote.Replace(Environment.NewLine, string.Empty);
|
||||
}
|
||||
|
||||
var replyBody = new FreshdeskReplyRequestModel
|
||||
{
|
||||
Body = $"{_billingSettings.FreshDesk.AutoReplyGreeting}{htmlNote}{_billingSettings.FreshDesk.AutoReplySalutation}",
|
||||
};
|
||||
|
||||
var replyRequest = new HttpRequestMessage(HttpMethod.Post,
|
||||
string.Format("https://bitwarden.freshdesk.com/api/v2/tickets/{0}/reply", ticketId))
|
||||
{
|
||||
Content = JsonContent.Create(replyBody),
|
||||
};
|
||||
|
||||
var addReplyResponse = await CallFreshdeskApiAsync(replyRequest);
|
||||
if (addReplyResponse.StatusCode != System.Net.HttpStatusCode.Created)
|
||||
{
|
||||
_logger.LogError("Error adding reply to Freshdesk ticket. Ticket Id: {0}. Status: {1}",
|
||||
ticketId, addReplyResponse.ToString());
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<HttpResponseMessage> CallFreshdeskApiAsync(HttpRequestMessage request, int retriedCount = 0)
|
||||
{
|
||||
try
|
||||
{
|
||||
var freshdeskAuthkey = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{_billingSettings.FreshDesk.ApiKey}:X"));
|
||||
var httpClient = _httpClientFactory.CreateClient("FreshdeskApi");
|
||||
request.Headers.Add("Authorization", $"Basic {freshdeskAuthkey}");
|
||||
var response = await httpClient.SendAsync(request);
|
||||
if (response.StatusCode != System.Net.HttpStatusCode.TooManyRequests || retriedCount > 3)
|
||||
{
|
||||
return response;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
if (retriedCount > 3)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
}
|
||||
await Task.Delay(30000 * (retriedCount + 1));
|
||||
return await CallFreshdeskApiAsync(request, retriedCount++);
|
||||
}
|
||||
|
||||
async Task<(OnyxRequestModel onyxRequest, OnyxResponseModel onyxResponse)> GetAnswerFromOnyx(FreshdeskOnyxAiWebhookModel model)
|
||||
{
|
||||
// TODO: remove the use of the deprecated answer-with-citation models after we are sure
|
||||
if (_billingSettings.Onyx.UseAnswerWithCitationModels)
|
||||
{
|
||||
var onyxRequest = new OnyxAnswerWithCitationRequestModel(model.TicketDescriptionText, _billingSettings.Onyx);
|
||||
var onyxAnswerWithCitationRequest = new HttpRequestMessage(HttpMethod.Post,
|
||||
string.Format("{0}/query/answer-with-citation", _billingSettings.Onyx.BaseUrl))
|
||||
{
|
||||
Content = JsonContent.Create(onyxRequest, mediaType: new MediaTypeHeaderValue("application/json")),
|
||||
};
|
||||
var onyxResponse = await CallOnyxApi<OnyxResponseModel>(onyxAnswerWithCitationRequest);
|
||||
return (onyxRequest, onyxResponse);
|
||||
}
|
||||
|
||||
var request = new OnyxSendMessageSimpleApiRequestModel(model.TicketDescriptionText, _billingSettings.Onyx);
|
||||
var onyxSimpleRequest = new HttpRequestMessage(HttpMethod.Post,
|
||||
string.Format("{0}{1}", _billingSettings.Onyx.BaseUrl, _billingSettings.Onyx.Path))
|
||||
{
|
||||
Content = JsonContent.Create(request, mediaType: new MediaTypeHeaderValue("application/json")),
|
||||
};
|
||||
var onyxSimpleResponse = await CallOnyxApi<OnyxResponseModel>(onyxSimpleRequest);
|
||||
return (request, onyxSimpleResponse);
|
||||
}
|
||||
|
||||
private async Task<T> CallOnyxApi<T>(HttpRequestMessage request) where T : class, new()
|
||||
{
|
||||
var httpClient = _httpClientFactory.CreateClient("OnyxApi");
|
||||
var response = await httpClient.SendAsync(request);
|
||||
|
||||
if (response.StatusCode != System.Net.HttpStatusCode.OK)
|
||||
{
|
||||
_logger.LogError("Error calling Onyx AI API. Status code: {0}. Response {1}",
|
||||
response.StatusCode, JsonSerializer.Serialize(response));
|
||||
return new T();
|
||||
}
|
||||
var responseStr = await response.Content.ReadAsStringAsync();
|
||||
var responseJson = JsonSerializer.Deserialize<T>(responseStr, options: new JsonSerializerOptions
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
});
|
||||
|
||||
return responseJson ?? new T();
|
||||
}
|
||||
|
||||
private TAttribute? GetAttribute<TAttribute>(Enum enumValue) where TAttribute : Attribute
|
||||
{
|
||||
var memberInfo = enumValue.GetType().GetMember(enumValue.ToString()).FirstOrDefault();
|
||||
return memberInfo != null ? memberInfo.GetCustomAttribute<TAttribute>() : null;
|
||||
}
|
||||
}
|
||||
@@ -1,248 +0,0 @@
|
||||
// FIXME: Update this file to be null safe and then delete the line below
|
||||
#nullable disable
|
||||
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json.Serialization;
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Settings;
|
||||
using Bit.Core.Utilities;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
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> billingSettings,
|
||||
ILogger<FreshsalesController> 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<IActionResult> 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<LeadWrapper<FreshsalesLeadModel>>(
|
||||
$"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<string>();
|
||||
|
||||
if (user.Premium)
|
||||
{
|
||||
newTags.Add("Premium");
|
||||
}
|
||||
|
||||
var noteItems = new List<string>
|
||||
{
|
||||
$"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.DisplayName()}: {_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<T> CreateWrapper<T>(T lead)
|
||||
{
|
||||
return new LeadWrapper<T>
|
||||
{
|
||||
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.FamiliesAnnually2025:
|
||||
case PlanType.FamiliesAnnually2019:
|
||||
planName = "Families";
|
||||
return true;
|
||||
case PlanType.TeamsAnnually:
|
||||
case PlanType.TeamsAnnually2023:
|
||||
case PlanType.TeamsAnnually2020:
|
||||
case PlanType.TeamsAnnually2019:
|
||||
case PlanType.TeamsMonthly:
|
||||
case PlanType.TeamsMonthly2023:
|
||||
case PlanType.TeamsMonthly2020:
|
||||
case PlanType.TeamsMonthly2019:
|
||||
case PlanType.TeamsStarter:
|
||||
case PlanType.TeamsStarter2023:
|
||||
planName = "Teams";
|
||||
return true;
|
||||
case PlanType.EnterpriseAnnually:
|
||||
case PlanType.EnterpriseAnnually2023:
|
||||
case PlanType.EnterpriseAnnually2020:
|
||||
case PlanType.EnterpriseAnnually2019:
|
||||
case PlanType.EnterpriseMonthly:
|
||||
case PlanType.EnterpriseMonthly2023:
|
||||
case PlanType.EnterpriseMonthly2020:
|
||||
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<T>
|
||||
{
|
||||
[JsonPropertyName("lead")]
|
||||
public T Lead { get; set; }
|
||||
|
||||
public static LeadWrapper<TItem> Create<TItem>(TItem lead)
|
||||
{
|
||||
return new LeadWrapper<TItem>
|
||||
{
|
||||
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; }
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Bit.Billing.Models;
|
||||
|
||||
public class FreshdeskReplyRequestModel
|
||||
{
|
||||
[JsonPropertyName("body")]
|
||||
public required string Body { get; set; }
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
// FIXME: Update this file to be null safe and then delete the line below
|
||||
#nullable disable
|
||||
|
||||
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; }
|
||||
}
|
||||
|
||||
public class FreshdeskOnyxAiWebhookModel : FreshdeskWebhookModel
|
||||
{
|
||||
[JsonPropertyName("ticket_description_text")]
|
||||
public string TicketDescriptionText { get; set; }
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using static Bit.Billing.BillingSettings;
|
||||
|
||||
namespace Bit.Billing.Models;
|
||||
|
||||
public class OnyxRequestModel
|
||||
{
|
||||
[JsonPropertyName("persona_id")]
|
||||
public int PersonaId { get; set; } = 1;
|
||||
|
||||
[JsonPropertyName("retrieval_options")]
|
||||
public RetrievalOptions RetrievalOptions { get; set; } = new RetrievalOptions();
|
||||
|
||||
public OnyxRequestModel(OnyxSettings onyxSettings)
|
||||
{
|
||||
PersonaId = onyxSettings.PersonaId;
|
||||
RetrievalOptions.RunSearch = onyxSettings.SearchSettings.RunSearch;
|
||||
RetrievalOptions.RealTime = onyxSettings.SearchSettings.RealTime;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// This is used with the onyx endpoint /query/answer-with-citation
|
||||
/// which has been deprecated. This can be removed once later
|
||||
/// </summary>
|
||||
public class OnyxAnswerWithCitationRequestModel : OnyxRequestModel
|
||||
{
|
||||
[JsonPropertyName("messages")]
|
||||
public List<Message> Messages { get; set; } = new List<Message>();
|
||||
|
||||
public OnyxAnswerWithCitationRequestModel(string message, OnyxSettings onyxSettings) : base(onyxSettings)
|
||||
{
|
||||
message = message.Replace(Environment.NewLine, " ").Replace('\r', ' ').Replace('\n', ' ');
|
||||
Messages = new List<Message>() { new Message() { MessageText = message } };
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// This is used with the onyx endpoint /chat/send-message-simple-api
|
||||
/// </summary>
|
||||
public class OnyxSendMessageSimpleApiRequestModel : OnyxRequestModel
|
||||
{
|
||||
[JsonPropertyName("message")]
|
||||
public string Message { get; set; } = string.Empty;
|
||||
|
||||
public OnyxSendMessageSimpleApiRequestModel(string message, OnyxSettings onyxSettings) : base(onyxSettings)
|
||||
{
|
||||
Message = message.Replace(Environment.NewLine, " ").Replace('\r', ' ').Replace('\n', ' ');
|
||||
}
|
||||
}
|
||||
|
||||
public class Message
|
||||
{
|
||||
[JsonPropertyName("message")]
|
||||
public string MessageText { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("sender")]
|
||||
public string Sender { get; set; } = "user";
|
||||
}
|
||||
|
||||
public class RetrievalOptions
|
||||
{
|
||||
[JsonPropertyName("run_search")]
|
||||
public string RunSearch { get; set; } = RetrievalOptionsRunSearch.Auto;
|
||||
|
||||
[JsonPropertyName("real_time")]
|
||||
public bool RealTime { get; set; } = true;
|
||||
}
|
||||
|
||||
public class RetrievalOptionsRunSearch
|
||||
{
|
||||
public const string Always = "always";
|
||||
public const string Never = "never";
|
||||
public const string Auto = "auto";
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Bit.Billing.Models;
|
||||
|
||||
public class OnyxResponseModel
|
||||
{
|
||||
[JsonPropertyName("answer")]
|
||||
public string Answer { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("answer_citationless")]
|
||||
public string AnswerCitationless { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("error_msg")]
|
||||
public string ErrorMsg { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -2,7 +2,6 @@
|
||||
#nullable disable
|
||||
|
||||
using System.Globalization;
|
||||
using System.Net.Http.Headers;
|
||||
using Bit.Billing.Services;
|
||||
using Bit.Billing.Services.Implementations;
|
||||
using Bit.Commercial.Core.Utilities;
|
||||
@@ -98,13 +97,6 @@ public class Startup
|
||||
// Authentication
|
||||
services.AddAuthentication();
|
||||
|
||||
// Set up HttpClients
|
||||
services.AddHttpClient("FreshdeskApi");
|
||||
services.AddHttpClient("OnyxApi", client =>
|
||||
{
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", billingSettings.Onyx.ApiKey);
|
||||
});
|
||||
|
||||
services.AddScoped<IStripeFacade, StripeFacade>();
|
||||
services.AddScoped<IStripeEventService, StripeEventService>();
|
||||
services.AddScoped<IProviderEventService, ProviderEventService>();
|
||||
|
||||
@@ -32,10 +32,5 @@
|
||||
"connectionString": "UseDevelopmentStorage=true"
|
||||
}
|
||||
},
|
||||
"billingSettings": {
|
||||
"onyx": {
|
||||
"personaId": 68
|
||||
}
|
||||
},
|
||||
"pricingUri": "https://billingpricing.qa.bitwarden.pw"
|
||||
}
|
||||
|
||||
@@ -26,10 +26,7 @@
|
||||
"payPal": {
|
||||
"production": true,
|
||||
"businessId": "4ZDA7DLUUJGMN"
|
||||
},
|
||||
"onyx": {
|
||||
"personaId": 7
|
||||
}
|
||||
}
|
||||
},
|
||||
"Logging": {
|
||||
"IncludeScopes": false,
|
||||
|
||||
@@ -61,27 +61,6 @@
|
||||
"production": false,
|
||||
"businessId": "AD3LAUZSNVPJY",
|
||||
"webhookKey": "SECRET"
|
||||
},
|
||||
"freshdesk": {
|
||||
"apiKey": "SECRET",
|
||||
"webhookKey": "SECRET",
|
||||
"region": "US",
|
||||
"userFieldName": "cf_user",
|
||||
"orgFieldName": "cf_org",
|
||||
"removeNewlinesInReplies": true,
|
||||
"autoReplyGreeting": "<b>Greetings,</b><br /><br />Thank you for contacting Bitwarden. The reply below was generated by our AI agent based on your message:<br /><br />",
|
||||
"autoReplySalutation": "<br /><br />If this response doesn’t fully address your question, simply reply to this email and a member of our Customer Success team will be happy to assist you further.<br /><p><b>Best Regards,</b><br />The Bitwarden Customer Success Team</p>"
|
||||
},
|
||||
"onyx": {
|
||||
"apiKey": "SECRET",
|
||||
"baseUrl": "https://cloud.onyx.app/api",
|
||||
"path": "/chat/send-message-simple-api",
|
||||
"useAnswerWithCitationModels": true,
|
||||
"personaId": 7,
|
||||
"searchSettings": {
|
||||
"runSearch": "always",
|
||||
"realTime": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,251 +0,0 @@
|
||||
using System.Text.Json;
|
||||
using Bit.Billing.Controllers;
|
||||
using Bit.Billing.Models;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
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.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using NSubstitute;
|
||||
using NSubstitute.ReceivedExtensions;
|
||||
using Xunit;
|
||||
|
||||
namespace Bit.Billing.Test.Controllers;
|
||||
|
||||
[ControllerCustomize(typeof(FreshdeskController))]
|
||||
[SutProviderCustomize]
|
||||
public class FreshdeskControllerTests
|
||||
{
|
||||
private const string ApiKey = "TESTFRESHDESKAPIKEY";
|
||||
private const string WebhookKey = "TESTKEY";
|
||||
|
||||
private const string UserFieldName = "cf_user";
|
||||
private const string OrgFieldName = "cf_org";
|
||||
|
||||
[Theory]
|
||||
[BitAutoData((string)null, null)]
|
||||
[BitAutoData((string)null)]
|
||||
[BitAutoData(WebhookKey, null)]
|
||||
public async Task PostWebhook_NullRequiredParameters_BadRequest(string freshdeskWebhookKey, FreshdeskWebhookModel model,
|
||||
BillingSettings billingSettings, SutProvider<FreshdeskController> sutProvider)
|
||||
{
|
||||
sutProvider.GetDependency<IOptions<BillingSettings>>().Value.FreshDesk.WebhookKey.Returns(billingSettings.FreshDesk.WebhookKey);
|
||||
|
||||
var response = await sutProvider.Sut.PostWebhook(freshdeskWebhookKey, model);
|
||||
|
||||
var statusCodeResult = Assert.IsAssignableFrom<StatusCodeResult>(response);
|
||||
Assert.Equal(StatusCodes.Status400BadRequest, statusCodeResult.StatusCode);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task PostWebhook_Success(User user, FreshdeskWebhookModel model,
|
||||
List<Organization> organizations, SutProvider<FreshdeskController> sutProvider)
|
||||
{
|
||||
model.TicketContactEmail = user.Email;
|
||||
|
||||
sutProvider.GetDependency<IUserRepository>().GetByEmailAsync(user.Email).Returns(user);
|
||||
sutProvider.GetDependency<IOrganizationRepository>().GetManyByUserIdAsync(user.Id).Returns(organizations);
|
||||
|
||||
var mockHttpMessageHandler = Substitute.ForPartsOf<MockHttpMessageHandler>();
|
||||
var mockResponse = new HttpResponseMessage(System.Net.HttpStatusCode.OK);
|
||||
mockHttpMessageHandler.Send(Arg.Any<HttpRequestMessage>(), Arg.Any<CancellationToken>())
|
||||
.Returns(mockResponse);
|
||||
var httpClient = new HttpClient(mockHttpMessageHandler);
|
||||
|
||||
sutProvider.GetDependency<IHttpClientFactory>().CreateClient("FreshdeskApi").Returns(httpClient);
|
||||
|
||||
sutProvider.GetDependency<IOptions<BillingSettings>>().Value.FreshDesk.WebhookKey.Returns(WebhookKey);
|
||||
sutProvider.GetDependency<IOptions<BillingSettings>>().Value.FreshDesk.ApiKey.Returns(ApiKey);
|
||||
sutProvider.GetDependency<IOptions<BillingSettings>>().Value.FreshDesk.UserFieldName.Returns(UserFieldName);
|
||||
sutProvider.GetDependency<IOptions<BillingSettings>>().Value.FreshDesk.OrgFieldName.Returns(OrgFieldName);
|
||||
|
||||
var response = await sutProvider.Sut.PostWebhook(WebhookKey, model);
|
||||
|
||||
var statusCodeResult = Assert.IsAssignableFrom<StatusCodeResult>(response);
|
||||
Assert.Equal(StatusCodes.Status200OK, statusCodeResult.StatusCode);
|
||||
|
||||
_ = mockHttpMessageHandler.Received(1).Send(Arg.Is<HttpRequestMessage>(m => m.Method == HttpMethod.Put && m.RequestUri.ToString().EndsWith(model.TicketId)), Arg.Any<CancellationToken>());
|
||||
_ = mockHttpMessageHandler.Received(1).Send(Arg.Is<HttpRequestMessage>(m => m.Method == HttpMethod.Post && m.RequestUri.ToString().EndsWith($"{model.TicketId}/notes")), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData(WebhookKey)]
|
||||
public async Task PostWebhook_add_note_when_user_is_invalid(
|
||||
string freshdeskWebhookKey, FreshdeskWebhookModel model,
|
||||
SutProvider<FreshdeskController> sutProvider)
|
||||
{
|
||||
// Arrange - for an invalid user
|
||||
model.TicketContactEmail = "invalid@user";
|
||||
sutProvider.GetDependency<IUserRepository>().GetByEmailAsync(model.TicketContactEmail).Returns((User)null);
|
||||
sutProvider.GetDependency<IOptions<BillingSettings>>().Value.FreshDesk.WebhookKey.Returns(WebhookKey);
|
||||
|
||||
var mockHttpMessageHandler = Substitute.ForPartsOf<MockHttpMessageHandler>();
|
||||
var mockResponse = new HttpResponseMessage(System.Net.HttpStatusCode.OK);
|
||||
mockHttpMessageHandler.Send(Arg.Any<HttpRequestMessage>(), Arg.Any<CancellationToken>())
|
||||
.Returns(mockResponse);
|
||||
var httpClient = new HttpClient(mockHttpMessageHandler);
|
||||
sutProvider.GetDependency<IHttpClientFactory>().CreateClient("FreshdeskApi").Returns(httpClient);
|
||||
|
||||
// Act
|
||||
var response = await sutProvider.Sut.PostWebhook(freshdeskWebhookKey, model);
|
||||
|
||||
// Assert
|
||||
var statusCodeResult = Assert.IsAssignableFrom<StatusCodeResult>(response);
|
||||
Assert.Equal(StatusCodes.Status200OK, statusCodeResult.StatusCode);
|
||||
|
||||
await mockHttpMessageHandler
|
||||
.Received(1).Send(
|
||||
Arg.Is<HttpRequestMessage>(
|
||||
m => m.Method == HttpMethod.Post
|
||||
&& m.RequestUri.ToString().EndsWith($"{model.TicketId}/notes")
|
||||
&& m.Content.ReadAsStringAsync().Result.Contains("No user found")),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
|
||||
[Theory]
|
||||
[BitAutoData((string)null, null)]
|
||||
[BitAutoData((string)null)]
|
||||
[BitAutoData(WebhookKey, null)]
|
||||
public async Task PostWebhookOnyxAi_InvalidWebhookKey_results_in_BadRequest(
|
||||
string freshdeskWebhookKey, FreshdeskOnyxAiWebhookModel model,
|
||||
BillingSettings billingSettings, SutProvider<FreshdeskController> sutProvider)
|
||||
{
|
||||
sutProvider.GetDependency<IOptions<BillingSettings>>()
|
||||
.Value.FreshDesk.WebhookKey.Returns(billingSettings.FreshDesk.WebhookKey);
|
||||
|
||||
var response = await sutProvider.Sut.PostWebhookOnyxAi(freshdeskWebhookKey, model);
|
||||
|
||||
var statusCodeResult = Assert.IsAssignableFrom<StatusCodeResult>(response);
|
||||
Assert.Equal(StatusCodes.Status400BadRequest, statusCodeResult.StatusCode);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData(WebhookKey)]
|
||||
public async Task PostWebhookOnyxAi_invalid_onyx_response_results_is_logged(
|
||||
string freshdeskWebhookKey, FreshdeskOnyxAiWebhookModel model,
|
||||
SutProvider<FreshdeskController> sutProvider)
|
||||
{
|
||||
var billingSettings = sutProvider.GetDependency<IOptions<BillingSettings>>().Value;
|
||||
billingSettings.FreshDesk.WebhookKey.Returns(freshdeskWebhookKey);
|
||||
billingSettings.Onyx.BaseUrl.Returns("http://simulate-onyx-api.com/api");
|
||||
|
||||
// mocking freshdesk Api request for ticket info
|
||||
var mockFreshdeskHttpMessageHandler = Substitute.ForPartsOf<MockHttpMessageHandler>();
|
||||
var freshdeskHttpClient = new HttpClient(mockFreshdeskHttpMessageHandler);
|
||||
|
||||
// mocking Onyx api response given a ticket description
|
||||
var mockOnyxHttpMessageHandler = Substitute.ForPartsOf<MockHttpMessageHandler>();
|
||||
var mockOnyxResponse = new HttpResponseMessage(System.Net.HttpStatusCode.BadRequest);
|
||||
mockOnyxHttpMessageHandler.Send(Arg.Any<HttpRequestMessage>(), Arg.Any<CancellationToken>())
|
||||
.Returns(mockOnyxResponse);
|
||||
var onyxHttpClient = new HttpClient(mockOnyxHttpMessageHandler);
|
||||
|
||||
sutProvider.GetDependency<IHttpClientFactory>().CreateClient("FreshdeskApi").Returns(freshdeskHttpClient);
|
||||
sutProvider.GetDependency<IHttpClientFactory>().CreateClient("OnyxApi").Returns(onyxHttpClient);
|
||||
|
||||
var response = await sutProvider.Sut.PostWebhookOnyxAi(freshdeskWebhookKey, model);
|
||||
|
||||
var statusCodeResult = Assert.IsAssignableFrom<StatusCodeResult>(response);
|
||||
Assert.Equal(StatusCodes.Status200OK, statusCodeResult.StatusCode);
|
||||
|
||||
var _logger = sutProvider.GetDependency<ILogger<FreshdeskController>>();
|
||||
|
||||
// workaround because _logger.Received(1).LogWarning(...) does not work
|
||||
_logger.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Log" && c.GetArguments()[1].ToString().Contains("Error getting answer from Onyx AI"));
|
||||
|
||||
// sent call to Onyx API - but we got an error response
|
||||
_ = mockOnyxHttpMessageHandler.Received(1).Send(Arg.Any<HttpRequestMessage>(), Arg.Any<CancellationToken>());
|
||||
// did not call freshdesk to add a note since onyx failed
|
||||
_ = mockFreshdeskHttpMessageHandler.DidNotReceive().Send(Arg.Any<HttpRequestMessage>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData(WebhookKey)]
|
||||
public async Task PostWebhookOnyxAi_success(
|
||||
string freshdeskWebhookKey, FreshdeskOnyxAiWebhookModel model,
|
||||
OnyxResponseModel onyxResponse,
|
||||
SutProvider<FreshdeskController> sutProvider)
|
||||
{
|
||||
var billingSettings = sutProvider.GetDependency<IOptions<BillingSettings>>().Value;
|
||||
billingSettings.FreshDesk.WebhookKey.Returns(freshdeskWebhookKey);
|
||||
billingSettings.Onyx.BaseUrl.Returns("http://simulate-onyx-api.com/api");
|
||||
|
||||
// mocking freshdesk api add note request (POST)
|
||||
var mockFreshdeskHttpMessageHandler = Substitute.ForPartsOf<MockHttpMessageHandler>();
|
||||
var mockFreshdeskAddNoteResponse = new HttpResponseMessage(System.Net.HttpStatusCode.BadRequest);
|
||||
mockFreshdeskHttpMessageHandler.Send(
|
||||
Arg.Is<HttpRequestMessage>(_ => _.Method == HttpMethod.Post),
|
||||
Arg.Any<CancellationToken>())
|
||||
.Returns(mockFreshdeskAddNoteResponse);
|
||||
var freshdeskHttpClient = new HttpClient(mockFreshdeskHttpMessageHandler);
|
||||
|
||||
// mocking Onyx api response given a ticket description
|
||||
var mockOnyxHttpMessageHandler = Substitute.ForPartsOf<MockHttpMessageHandler>();
|
||||
onyxResponse.ErrorMsg = "string.Empty";
|
||||
var mockOnyxResponse = new HttpResponseMessage(System.Net.HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(JsonSerializer.Serialize(onyxResponse))
|
||||
};
|
||||
mockOnyxHttpMessageHandler.Send(Arg.Any<HttpRequestMessage>(), Arg.Any<CancellationToken>())
|
||||
.Returns(mockOnyxResponse);
|
||||
var onyxHttpClient = new HttpClient(mockOnyxHttpMessageHandler);
|
||||
|
||||
sutProvider.GetDependency<IHttpClientFactory>().CreateClient("FreshdeskApi").Returns(freshdeskHttpClient);
|
||||
sutProvider.GetDependency<IHttpClientFactory>().CreateClient("OnyxApi").Returns(onyxHttpClient);
|
||||
|
||||
var response = await sutProvider.Sut.PostWebhookOnyxAi(freshdeskWebhookKey, model);
|
||||
|
||||
var result = Assert.IsAssignableFrom<OkResult>(response);
|
||||
Assert.Equal(StatusCodes.Status200OK, result.StatusCode);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData(WebhookKey)]
|
||||
public async Task PostWebhookOnyxAi_ticket_description_is_empty_return_success(
|
||||
string freshdeskWebhookKey, FreshdeskOnyxAiWebhookModel model,
|
||||
SutProvider<FreshdeskController> sutProvider)
|
||||
{
|
||||
var billingSettings = sutProvider.GetDependency<IOptions<BillingSettings>>().Value;
|
||||
billingSettings.FreshDesk.WebhookKey.Returns(freshdeskWebhookKey);
|
||||
billingSettings.Onyx.BaseUrl.Returns("http://simulate-onyx-api.com/api");
|
||||
|
||||
model.TicketDescriptionText = " "; // empty description
|
||||
|
||||
// mocking freshdesk api add note request (POST)
|
||||
var mockFreshdeskHttpMessageHandler = Substitute.ForPartsOf<MockHttpMessageHandler>();
|
||||
var freshdeskHttpClient = new HttpClient(mockFreshdeskHttpMessageHandler);
|
||||
|
||||
// mocking Onyx api response given a ticket description
|
||||
var mockOnyxHttpMessageHandler = Substitute.ForPartsOf<MockHttpMessageHandler>();
|
||||
var onyxHttpClient = new HttpClient(mockOnyxHttpMessageHandler);
|
||||
|
||||
sutProvider.GetDependency<IHttpClientFactory>().CreateClient("FreshdeskApi").Returns(freshdeskHttpClient);
|
||||
sutProvider.GetDependency<IHttpClientFactory>().CreateClient("OnyxApi").Returns(onyxHttpClient);
|
||||
|
||||
var response = await sutProvider.Sut.PostWebhookOnyxAi(freshdeskWebhookKey, model);
|
||||
|
||||
var result = Assert.IsAssignableFrom<OkResult>(response);
|
||||
Assert.Equal(StatusCodes.Status200OK, result.StatusCode);
|
||||
_ = mockFreshdeskHttpMessageHandler.DidNotReceive().Send(Arg.Any<HttpRequestMessage>(), Arg.Any<CancellationToken>());
|
||||
_ = mockOnyxHttpMessageHandler.DidNotReceive().Send(Arg.Any<HttpRequestMessage>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
public class MockHttpMessageHandler : HttpMessageHandler
|
||||
{
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
return Send(request, cancellationToken);
|
||||
}
|
||||
|
||||
public new virtual Task<HttpResponseMessage> Send(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
using Bit.Billing.Controllers;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.Entities;
|
||||
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<IUserRepository>();
|
||||
var organizationRepository = Substitute.For<IOrganizationRepository>();
|
||||
|
||||
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<ILogger<FreshsalesController>>(),
|
||||
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<Organization>
|
||||
{
|
||||
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<StatusCodeResult>(response);
|
||||
Assert.Equal(StatusCodes.Status204NoContent, statusCodeResult.StatusCode);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user