From 90d831d9efa2a74bd25e01f7b730b147035f1962 Mon Sep 17 00:00:00 2001 From: Brant DeBow <125889545+brant-livefront@users.noreply.github.com> Date: Wed, 23 Apr 2025 10:44:43 -0400 Subject: [PATCH] [PM-17562] API For Organization Integrations/Configurations, Refactored Distributed Events, Slack Integration (#5654) * [PM-17562] Slack Event Investigation * Refactored Slack and Webhook integrations to pull configurations dynamically from a new Repository * Added new TemplateProcessor and added/updated unit tests * SlackService improvements, testing, integration configurations * Refactor SlackService to use a dedicated model to parse responses * Refactored SlackOAuthController to use SlackService as an injected dependency; added tests for SlackService * Remove unnecessary methods from the IOrganizationIntegrationConfigurationRepository * Moved Slack OAuth to take into account the Organization it's being stored for. Added methods to store the top level integration for Slack * Organization integrations and configuration database schemas * Format EF files * Initial buildout of basic repositories * [PM-17562] Add Dapper Repositories For Organization Integrations and Configurations * Update Slack and Webhook handlers to use new Repositories * Update SlackOAuth tests to new signatures * Added EF Repositories * Update handlers to use latest repositories * [PM-17562] Add Dapper and EF Repositories For Ogranization Integrations and Configurations * Updated with changes from PR comments * Adjusted Handlers to new repository method names; updated tests to naming convention * Adjust URL structure; add delete for Slack, add tests * Added Webhook Integration Controller * Add tests for WebhookIntegrationController * Added Create/Delete for OrganizationIntegrationConfigurations * Prepend ConnectionTypes into IntegrationType so we don't run into issues later * Added Update to OrganizationIntegrationConfigurtionController * Moved Webhook-specific integration code to being a generic controller for everything but Slack * Removed delete from SlackController - Deletes should happen through the normal Integration controller * Fixed SlackController, reworked OIC Controller to use ids from URL and update the returned object * Added parse/type checking for integration and integration configuration JSONs, Cleaned up GlobalSettings to remove old values * Cleanup and fixes for Azure Service Bus support * Clean up naming on TemplateProcessorTests * Address SonarQube warnings/suggestions * Expanded test coverage; Cleaned up tests * Respond to PR Feedback * Rename TemplateProcessor to IntegrationTemplateProcessor --------- Co-authored-by: Matt Bishop --- dev/servicebusemulator_config.json | 6 + ...ationIntegrationConfigurationController.cs | 103 +++ .../OrganizationIntegrationController.cs | 71 ++ .../Controllers/SlackIntegrationController.cs | 77 +++ ...ionIntegrationConfigurationRequestModel.cs | 73 ++ .../OrgnizationIntegrationRequestModel.cs | 56 ++ ...onIntegrationConfigurationResponseModel.cs | 28 + .../OrganizationIntegrationResponseModel.cs | 22 + src/Api/Startup.cs | 15 + .../AdminConsole/Enums/IntegrationType.cs | 6 +- .../Data/Integrations/SlackIntegration.cs | 3 + .../SlackIntegrationConfiguration.cs | 3 + .../SlackIntegrationConfigurationDetails.cs | 3 + .../WebhookIntegrationConfiguration.cs | 3 + .../WebhookIntegrationConfigurationDetils.cs | 3 + .../Models/Slack/SlackApiResponse.cs | 57 ++ .../AdminConsole/Services/ISlackService.cs | 11 + .../Implementations/SlackEventHandler.cs | 46 ++ .../Services/Implementations/SlackService.cs | 162 +++++ .../Implementations/WebhookEventHandler.cs | 47 +- .../NoopImplementations/NoopSlackService.cs | 36 + .../Utilities/IntegrationTemplateProcessor.cs | 23 + src/Core/Settings/GlobalSettings.cs | 12 +- src/Events/Startup.cs | 36 +- src/EventsProcessor/Startup.cs | 43 +- .../DapperServiceCollectionExtensions.cs | 2 + .../OrganizationIntegrationControllerTests.cs | 201 ++++++ ...ntegrationsConfigurationControllerTests.cs | 621 ++++++++++++++++++ .../SlackIntegrationControllerTests.cs | 130 ++++ ...tegrationConfigurationRequestModelTests.cs | 120 ++++ ...rganizationIntegrationRequestModelTests.cs | 103 +++ .../Services/SlackEventHandlerTests.cs | 181 +++++ .../Services/SlackServiceTests.cs | 344 ++++++++++ .../Services/WebhookEventHandlerTests.cs | 198 +++++- .../IntegrationTemplateProcessorTests.cs | 92 +++ 35 files changed, 2880 insertions(+), 57 deletions(-) create mode 100644 src/Api/AdminConsole/Controllers/OrganizationIntegrationConfigurationController.cs create mode 100644 src/Api/AdminConsole/Controllers/OrganizationIntegrationController.cs create mode 100644 src/Api/AdminConsole/Controllers/SlackIntegrationController.cs create mode 100644 src/Api/AdminConsole/Models/Request/Organizations/OrganizationIntegrationConfigurationRequestModel.cs create mode 100644 src/Api/AdminConsole/Models/Request/Organizations/OrgnizationIntegrationRequestModel.cs create mode 100644 src/Api/AdminConsole/Models/Response/Organizations/OrganizationIntegrationConfigurationResponseModel.cs create mode 100644 src/Api/AdminConsole/Models/Response/Organizations/OrganizationIntegrationResponseModel.cs create mode 100644 src/Core/AdminConsole/Models/Data/Integrations/SlackIntegration.cs create mode 100644 src/Core/AdminConsole/Models/Data/Integrations/SlackIntegrationConfiguration.cs create mode 100644 src/Core/AdminConsole/Models/Data/Integrations/SlackIntegrationConfigurationDetails.cs create mode 100644 src/Core/AdminConsole/Models/Data/Integrations/WebhookIntegrationConfiguration.cs create mode 100644 src/Core/AdminConsole/Models/Data/Integrations/WebhookIntegrationConfigurationDetils.cs create mode 100644 src/Core/AdminConsole/Models/Slack/SlackApiResponse.cs create mode 100644 src/Core/AdminConsole/Services/ISlackService.cs create mode 100644 src/Core/AdminConsole/Services/Implementations/SlackEventHandler.cs create mode 100644 src/Core/AdminConsole/Services/Implementations/SlackService.cs create mode 100644 src/Core/AdminConsole/Services/NoopImplementations/NoopSlackService.cs create mode 100644 src/Core/AdminConsole/Utilities/IntegrationTemplateProcessor.cs create mode 100644 test/Api.Test/AdminConsole/Controllers/OrganizationIntegrationControllerTests.cs create mode 100644 test/Api.Test/AdminConsole/Controllers/OrganizationIntegrationsConfigurationControllerTests.cs create mode 100644 test/Api.Test/AdminConsole/Controllers/SlackIntegrationControllerTests.cs create mode 100644 test/Api.Test/AdminConsole/Models/Request/Organizations/OrganizationIntegrationConfigurationRequestModelTests.cs create mode 100644 test/Api.Test/AdminConsole/Models/Request/Organizations/OrganizationIntegrationRequestModelTests.cs create mode 100644 test/Core.Test/AdminConsole/Services/SlackEventHandlerTests.cs create mode 100644 test/Core.Test/AdminConsole/Services/SlackServiceTests.cs create mode 100644 test/Core.Test/AdminConsole/Utilities/IntegrationTemplateProcessorTests.cs diff --git a/dev/servicebusemulator_config.json b/dev/servicebusemulator_config.json index f0e4279b06..073a44618f 100644 --- a/dev/servicebusemulator_config.json +++ b/dev/servicebusemulator_config.json @@ -25,6 +25,12 @@ "Subscriptions": [ { "Name": "events-write-subscription" + }, + { + "Name": "events-slack-subscription" + }, + { + "Name": "events-webhook-subscription" } ] } diff --git a/src/Api/AdminConsole/Controllers/OrganizationIntegrationConfigurationController.cs b/src/Api/AdminConsole/Controllers/OrganizationIntegrationConfigurationController.cs new file mode 100644 index 0000000000..da0151067b --- /dev/null +++ b/src/Api/AdminConsole/Controllers/OrganizationIntegrationConfigurationController.cs @@ -0,0 +1,103 @@ +using Bit.Api.AdminConsole.Models.Request.Organizations; +using Bit.Api.AdminConsole.Models.Response.Organizations; +using Bit.Core.Context; +using Bit.Core.Exceptions; +using Bit.Core.Repositories; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Bit.Api.AdminConsole.Controllers; + +[Route("organizations/{organizationId:guid}/integrations/{integrationId:guid}/configurations")] +[Authorize("Application")] +public class OrganizationIntegrationConfigurationController( + ICurrentContext currentContext, + IOrganizationIntegrationRepository integrationRepository, + IOrganizationIntegrationConfigurationRepository integrationConfigurationRepository) : Controller +{ + [HttpPost("")] + public async Task CreateAsync( + Guid organizationId, + Guid integrationId, + [FromBody] OrganizationIntegrationConfigurationRequestModel model) + { + if (!await HasPermission(organizationId)) + { + throw new NotFoundException(); + } + var integration = await integrationRepository.GetByIdAsync(integrationId); + if (integration == null || integration.OrganizationId != organizationId) + { + throw new NotFoundException(); + } + if (!model.IsValidForType(integration.Type)) + { + throw new BadRequestException($"Invalid Configuration and/or Template for integration type {integration.Type}"); + } + + var organizationIntegrationConfiguration = model.ToOrganizationIntegrationConfiguration(integrationId); + var configuration = await integrationConfigurationRepository.CreateAsync(organizationIntegrationConfiguration); + return new OrganizationIntegrationConfigurationResponseModel(configuration); + } + + [HttpPut("{configurationId:guid}")] + public async Task UpdateAsync( + Guid organizationId, + Guid integrationId, + Guid configurationId, + [FromBody] OrganizationIntegrationConfigurationRequestModel model) + { + if (!await HasPermission(organizationId)) + { + throw new NotFoundException(); + } + var integration = await integrationRepository.GetByIdAsync(integrationId); + if (integration == null || integration.OrganizationId != organizationId) + { + throw new NotFoundException(); + } + if (!model.IsValidForType(integration.Type)) + { + throw new BadRequestException($"Invalid Configuration and/or Template for integration type {integration.Type}"); + } + + var configuration = await integrationConfigurationRepository.GetByIdAsync(configurationId); + if (configuration is null || configuration.OrganizationIntegrationId != integrationId) + { + throw new NotFoundException(); + } + + var newConfiguration = model.ToOrganizationIntegrationConfiguration(configuration); + await integrationConfigurationRepository.ReplaceAsync(newConfiguration); + + return new OrganizationIntegrationConfigurationResponseModel(newConfiguration); + } + + [HttpDelete("{configurationId:guid}")] + [HttpPost("{configurationId:guid}/delete")] + public async Task DeleteAsync(Guid organizationId, Guid integrationId, Guid configurationId) + { + if (!await HasPermission(organizationId)) + { + throw new NotFoundException(); + } + var integration = await integrationRepository.GetByIdAsync(integrationId); + if (integration == null || integration.OrganizationId != organizationId) + { + throw new NotFoundException(); + } + + var configuration = await integrationConfigurationRepository.GetByIdAsync(configurationId); + if (configuration is null || configuration.OrganizationIntegrationId != integrationId) + { + throw new NotFoundException(); + } + + await integrationConfigurationRepository.DeleteAsync(configuration); + } + + private async Task HasPermission(Guid organizationId) + { + return await currentContext.OrganizationOwner(organizationId); + } +} diff --git a/src/Api/AdminConsole/Controllers/OrganizationIntegrationController.cs b/src/Api/AdminConsole/Controllers/OrganizationIntegrationController.cs new file mode 100644 index 0000000000..cb96be97c7 --- /dev/null +++ b/src/Api/AdminConsole/Controllers/OrganizationIntegrationController.cs @@ -0,0 +1,71 @@ +using Bit.Api.AdminConsole.Models.Request.Organizations; +using Bit.Api.AdminConsole.Models.Response.Organizations; +using Bit.Core.Context; +using Bit.Core.Exceptions; +using Bit.Core.Repositories; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +#nullable enable + +namespace Bit.Api.AdminConsole.Controllers; + +[Route("organizations/{organizationId:guid}/integrations")] +[Authorize("Application")] +public class OrganizationIntegrationController( + ICurrentContext currentContext, + IOrganizationIntegrationRepository integrationRepository) : Controller +{ + [HttpPost("")] + public async Task CreateAsync(Guid organizationId, [FromBody] OrganizationIntegrationRequestModel model) + { + if (!await HasPermission(organizationId)) + { + throw new NotFoundException(); + } + + var integration = await integrationRepository.CreateAsync(model.ToOrganizationIntegration(organizationId)); + return new OrganizationIntegrationResponseModel(integration); + } + + [HttpPut("{integrationId:guid}")] + public async Task UpdateAsync(Guid organizationId, Guid integrationId, [FromBody] OrganizationIntegrationRequestModel model) + { + if (!await HasPermission(organizationId)) + { + throw new NotFoundException(); + } + + var integration = await integrationRepository.GetByIdAsync(integrationId); + if (integration is null || integration.OrganizationId != organizationId) + { + throw new NotFoundException(); + } + + await integrationRepository.ReplaceAsync(model.ToOrganizationIntegration(integration)); + return new OrganizationIntegrationResponseModel(integration); + } + + [HttpDelete("{integrationId:guid}")] + [HttpPost("{integrationId:guid}/delete")] + public async Task DeleteAsync(Guid organizationId, Guid integrationId) + { + if (!await HasPermission(organizationId)) + { + throw new NotFoundException(); + } + + var integration = await integrationRepository.GetByIdAsync(integrationId); + if (integration is null || integration.OrganizationId != organizationId) + { + throw new NotFoundException(); + } + + await integrationRepository.DeleteAsync(integration); + } + + private async Task HasPermission(Guid organizationId) + { + return await currentContext.OrganizationOwner(organizationId); + } +} diff --git a/src/Api/AdminConsole/Controllers/SlackIntegrationController.cs b/src/Api/AdminConsole/Controllers/SlackIntegrationController.cs new file mode 100644 index 0000000000..ed6971911b --- /dev/null +++ b/src/Api/AdminConsole/Controllers/SlackIntegrationController.cs @@ -0,0 +1,77 @@ +using System.Text.Json; +using Bit.Api.AdminConsole.Models.Response.Organizations; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Context; +using Bit.Core.Enums; +using Bit.Core.Exceptions; +using Bit.Core.Models.Data.Integrations; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Bit.Api.AdminConsole.Controllers; + +[Route("organizations/{organizationId:guid}/integrations/slack")] +[Authorize("Application")] +public class SlackIntegrationController( + ICurrentContext currentContext, + IOrganizationIntegrationRepository integrationRepository, + ISlackService slackService) : Controller +{ + [HttpGet("redirect")] + public async Task RedirectAsync(Guid organizationId) + { + if (!await currentContext.OrganizationOwner(organizationId)) + { + throw new NotFoundException(); + } + string callbackUrl = Url.RouteUrl( + nameof(CreateAsync), + new { organizationId }, + currentContext.HttpContext.Request.Scheme); + var redirectUrl = slackService.GetRedirectUrl(callbackUrl); + + if (string.IsNullOrEmpty(redirectUrl)) + { + throw new NotFoundException(); + } + + return Redirect(redirectUrl); + } + + [HttpGet("create", Name = nameof(CreateAsync))] + public async Task CreateAsync(Guid organizationId, [FromQuery] string code) + { + if (!await currentContext.OrganizationOwner(organizationId)) + { + throw new NotFoundException(); + } + + if (string.IsNullOrEmpty(code)) + { + throw new BadRequestException("Missing code from Slack."); + } + + string callbackUrl = Url.RouteUrl( + nameof(CreateAsync), + new { organizationId }, + currentContext.HttpContext.Request.Scheme); + var token = await slackService.ObtainTokenViaOAuth(code, callbackUrl); + + if (string.IsNullOrEmpty(token)) + { + throw new BadRequestException("Invalid response from Slack."); + } + + var integration = await integrationRepository.CreateAsync(new OrganizationIntegration + { + OrganizationId = organizationId, + Type = IntegrationType.Slack, + Configuration = JsonSerializer.Serialize(new SlackIntegration(token)), + }); + var location = $"/organizations/{organizationId}/integrations/{integration.Id}"; + + return Created(location, new OrganizationIntegrationResponseModel(integration)); + } +} diff --git a/src/Api/AdminConsole/Models/Request/Organizations/OrganizationIntegrationConfigurationRequestModel.cs b/src/Api/AdminConsole/Models/Request/Organizations/OrganizationIntegrationConfigurationRequestModel.cs new file mode 100644 index 0000000000..6566760e17 --- /dev/null +++ b/src/Api/AdminConsole/Models/Request/Organizations/OrganizationIntegrationConfigurationRequestModel.cs @@ -0,0 +1,73 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Enums; +using Bit.Core.Models.Data.Integrations; + +#nullable enable + +namespace Bit.Api.AdminConsole.Models.Request.Organizations; + +public class OrganizationIntegrationConfigurationRequestModel +{ + public string? Configuration { get; set; } + + [Required] + public EventType EventType { get; set; } + + public string? Template { get; set; } + + public bool IsValidForType(IntegrationType integrationType) + { + switch (integrationType) + { + case IntegrationType.CloudBillingSync or IntegrationType.Scim: + return false; + case IntegrationType.Slack: + return !string.IsNullOrWhiteSpace(Template) && IsConfigurationValid(); + case IntegrationType.Webhook: + return !string.IsNullOrWhiteSpace(Template) && IsConfigurationValid(); + default: + return false; + + } + } + + public OrganizationIntegrationConfiguration ToOrganizationIntegrationConfiguration(Guid organizationIntegrationId) + { + return new OrganizationIntegrationConfiguration() + { + OrganizationIntegrationId = organizationIntegrationId, + Configuration = Configuration, + EventType = EventType, + Template = Template + }; + } + + public OrganizationIntegrationConfiguration ToOrganizationIntegrationConfiguration(OrganizationIntegrationConfiguration currentConfiguration) + { + currentConfiguration.Configuration = Configuration; + currentConfiguration.EventType = EventType; + currentConfiguration.Template = Template; + + return currentConfiguration; + } + + private bool IsConfigurationValid() + { + if (string.IsNullOrWhiteSpace(Configuration)) + { + return false; + } + + try + { + var config = JsonSerializer.Deserialize(Configuration); + return config is not null; + } + catch + { + return false; + } + } +} diff --git a/src/Api/AdminConsole/Models/Request/Organizations/OrgnizationIntegrationRequestModel.cs b/src/Api/AdminConsole/Models/Request/Organizations/OrgnizationIntegrationRequestModel.cs new file mode 100644 index 0000000000..1a5c110254 --- /dev/null +++ b/src/Api/AdminConsole/Models/Request/Organizations/OrgnizationIntegrationRequestModel.cs @@ -0,0 +1,56 @@ +using System.ComponentModel.DataAnnotations; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Enums; + +#nullable enable + +namespace Bit.Api.AdminConsole.Models.Request.Organizations; + +public class OrganizationIntegrationRequestModel : IValidatableObject +{ + public string? Configuration { get; set; } + + public IntegrationType Type { get; set; } + + public OrganizationIntegration ToOrganizationIntegration(Guid organizationId) + { + return new OrganizationIntegration() + { + OrganizationId = organizationId, + Configuration = Configuration, + Type = Type, + }; + } + + public OrganizationIntegration ToOrganizationIntegration(OrganizationIntegration currentIntegration) + { + currentIntegration.Configuration = Configuration; + return currentIntegration; + } + + public IEnumerable Validate(ValidationContext validationContext) + { + switch (Type) + { + case IntegrationType.CloudBillingSync or IntegrationType.Scim: + yield return new ValidationResult($"{nameof(Type)} integrations are not yet supported.", new[] { nameof(Type) }); + break; + case IntegrationType.Slack: + yield return new ValidationResult($"{nameof(Type)} integrations cannot be created directly.", new[] { nameof(Type) }); + break; + case IntegrationType.Webhook: + if (Configuration is not null) + { + yield return new ValidationResult( + "Webhook integrations must not include configuration.", + new[] { nameof(Configuration) }); + } + break; + default: + yield return new ValidationResult( + $"Integration type '{Type}' is not recognized.", + new[] { nameof(Type) }); + break; + } + } +} diff --git a/src/Api/AdminConsole/Models/Response/Organizations/OrganizationIntegrationConfigurationResponseModel.cs b/src/Api/AdminConsole/Models/Response/Organizations/OrganizationIntegrationConfigurationResponseModel.cs new file mode 100644 index 0000000000..8d074509c5 --- /dev/null +++ b/src/Api/AdminConsole/Models/Response/Organizations/OrganizationIntegrationConfigurationResponseModel.cs @@ -0,0 +1,28 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Enums; +using Bit.Core.Models.Api; + +#nullable enable + +namespace Bit.Api.AdminConsole.Models.Response.Organizations; + +public class OrganizationIntegrationConfigurationResponseModel : ResponseModel +{ + public OrganizationIntegrationConfigurationResponseModel(OrganizationIntegrationConfiguration organizationIntegrationConfiguration, string obj = "organizationIntegrationConfiguration") + : base(obj) + { + ArgumentNullException.ThrowIfNull(organizationIntegrationConfiguration); + + Id = organizationIntegrationConfiguration.Id; + Configuration = organizationIntegrationConfiguration.Configuration; + CreationDate = organizationIntegrationConfiguration.CreationDate; + EventType = organizationIntegrationConfiguration.EventType; + Template = organizationIntegrationConfiguration.Template; + } + + public Guid Id { get; set; } + public string? Configuration { get; set; } + public DateTime CreationDate { get; set; } + public EventType EventType { get; set; } + public string? Template { get; set; } +} diff --git a/src/Api/AdminConsole/Models/Response/Organizations/OrganizationIntegrationResponseModel.cs b/src/Api/AdminConsole/Models/Response/Organizations/OrganizationIntegrationResponseModel.cs new file mode 100644 index 0000000000..cc6e778528 --- /dev/null +++ b/src/Api/AdminConsole/Models/Response/Organizations/OrganizationIntegrationResponseModel.cs @@ -0,0 +1,22 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Enums; +using Bit.Core.Models.Api; + +#nullable enable + +namespace Bit.Api.AdminConsole.Models.Response.Organizations; + +public class OrganizationIntegrationResponseModel : ResponseModel +{ + public OrganizationIntegrationResponseModel(OrganizationIntegration organizationIntegration, string obj = "organizationIntegration") + : base(obj) + { + ArgumentNullException.ThrowIfNull(organizationIntegration); + + Id = organizationIntegration.Id; + Type = organizationIntegration.Type; + } + + public Guid Id { get; set; } + public IntegrationType Type { get; set; } +} diff --git a/src/Api/Startup.cs b/src/Api/Startup.cs index 4f4ec057d9..2872a5b88b 100644 --- a/src/Api/Startup.cs +++ b/src/Api/Startup.cs @@ -27,8 +27,10 @@ using Bit.Core.OrganizationFeatures.OrganizationSubscriptions; using Bit.Core.Tools.Entities; using Bit.Core.Vault.Entities; using Bit.Api.Auth.Models.Request.WebAuthn; +using Bit.Core.AdminConsole.Services.NoopImplementations; using Bit.Core.Auth.Models.Data; using Bit.Core.Auth.Identity.TokenProviders; +using Bit.Core.Services; using Bit.Core.Tools.ImportFeatures; using Bit.Core.Tools.ReportFeatures; using Bit.Core.Auth.Models.Api.Request; @@ -215,6 +217,19 @@ public class Startup { services.AddHostedService(); } + + // Slack + if (CoreHelpers.SettingHasValue(globalSettings.Slack.ClientId) && + CoreHelpers.SettingHasValue(globalSettings.Slack.ClientSecret) && + CoreHelpers.SettingHasValue(globalSettings.Slack.Scopes)) + { + services.AddHttpClient(SlackService.HttpClientName); + services.AddSingleton(); + } + else + { + services.AddSingleton(); + } } public void Configure( diff --git a/src/Core/AdminConsole/Enums/IntegrationType.cs b/src/Core/AdminConsole/Enums/IntegrationType.cs index 16c7818dee..0f5123554e 100644 --- a/src/Core/AdminConsole/Enums/IntegrationType.cs +++ b/src/Core/AdminConsole/Enums/IntegrationType.cs @@ -2,6 +2,8 @@ public enum IntegrationType : int { - Slack = 1, - Webhook = 2, + CloudBillingSync = 1, + Scim = 2, + Slack = 3, + Webhook = 4, } diff --git a/src/Core/AdminConsole/Models/Data/Integrations/SlackIntegration.cs b/src/Core/AdminConsole/Models/Data/Integrations/SlackIntegration.cs new file mode 100644 index 0000000000..e6fc1440ea --- /dev/null +++ b/src/Core/AdminConsole/Models/Data/Integrations/SlackIntegration.cs @@ -0,0 +1,3 @@ +namespace Bit.Core.Models.Data.Integrations; + +public record SlackIntegration(string token); diff --git a/src/Core/AdminConsole/Models/Data/Integrations/SlackIntegrationConfiguration.cs b/src/Core/AdminConsole/Models/Data/Integrations/SlackIntegrationConfiguration.cs new file mode 100644 index 0000000000..ad25d35e7e --- /dev/null +++ b/src/Core/AdminConsole/Models/Data/Integrations/SlackIntegrationConfiguration.cs @@ -0,0 +1,3 @@ +namespace Bit.Core.Models.Data.Integrations; + +public record SlackIntegrationConfiguration(string channelId); diff --git a/src/Core/AdminConsole/Models/Data/Integrations/SlackIntegrationConfigurationDetails.cs b/src/Core/AdminConsole/Models/Data/Integrations/SlackIntegrationConfigurationDetails.cs new file mode 100644 index 0000000000..49ca9df4e0 --- /dev/null +++ b/src/Core/AdminConsole/Models/Data/Integrations/SlackIntegrationConfigurationDetails.cs @@ -0,0 +1,3 @@ +namespace Bit.Core.Models.Data.Integrations; + +public record SlackIntegrationConfigurationDetails(string channelId, string token); diff --git a/src/Core/AdminConsole/Models/Data/Integrations/WebhookIntegrationConfiguration.cs b/src/Core/AdminConsole/Models/Data/Integrations/WebhookIntegrationConfiguration.cs new file mode 100644 index 0000000000..9a7591f24b --- /dev/null +++ b/src/Core/AdminConsole/Models/Data/Integrations/WebhookIntegrationConfiguration.cs @@ -0,0 +1,3 @@ +namespace Bit.Core.Models.Data.Integrations; + +public record WebhookIntegrationConfiguration(string url); diff --git a/src/Core/AdminConsole/Models/Data/Integrations/WebhookIntegrationConfigurationDetils.cs b/src/Core/AdminConsole/Models/Data/Integrations/WebhookIntegrationConfigurationDetils.cs new file mode 100644 index 0000000000..f165828de0 --- /dev/null +++ b/src/Core/AdminConsole/Models/Data/Integrations/WebhookIntegrationConfigurationDetils.cs @@ -0,0 +1,3 @@ +namespace Bit.Core.Models.Data.Integrations; + +public record WebhookIntegrationConfigurationDetils(string url); diff --git a/src/Core/AdminConsole/Models/Slack/SlackApiResponse.cs b/src/Core/AdminConsole/Models/Slack/SlackApiResponse.cs new file mode 100644 index 0000000000..59debed746 --- /dev/null +++ b/src/Core/AdminConsole/Models/Slack/SlackApiResponse.cs @@ -0,0 +1,57 @@ + +using System.Text.Json.Serialization; + +namespace Bit.Core.Models.Slack; + +public abstract class SlackApiResponse +{ + public bool Ok { get; set; } + [JsonPropertyName("response_metadata")] + public SlackResponseMetadata ResponseMetadata { get; set; } = new(); + public string Error { get; set; } = string.Empty; +} + +public class SlackResponseMetadata +{ + [JsonPropertyName("next_cursor")] + public string NextCursor { get; set; } = string.Empty; +} + +public class SlackChannelListResponse : SlackApiResponse +{ + public List Channels { get; set; } = new(); +} + +public class SlackUserResponse : SlackApiResponse +{ + public SlackUser User { get; set; } = new(); +} + +public class SlackOAuthResponse : SlackApiResponse +{ + [JsonPropertyName("access_token")] + public string AccessToken { get; set; } = string.Empty; + public SlackTeam Team { get; set; } = new(); +} + +public class SlackTeam +{ + public string Id { get; set; } = string.Empty; +} + +public class SlackChannel +{ + public string Id { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; +} + +public class SlackUser +{ + public string Id { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; +} + +public class SlackDmResponse : SlackApiResponse +{ + public SlackChannel Channel { get; set; } = new(); +} diff --git a/src/Core/AdminConsole/Services/ISlackService.cs b/src/Core/AdminConsole/Services/ISlackService.cs new file mode 100644 index 0000000000..6c6a846f0d --- /dev/null +++ b/src/Core/AdminConsole/Services/ISlackService.cs @@ -0,0 +1,11 @@ +namespace Bit.Core.Services; + +public interface ISlackService +{ + Task GetChannelIdAsync(string token, string channelName); + Task> GetChannelIdsAsync(string token, List channelNames); + Task GetDmChannelByEmailAsync(string token, string email); + string GetRedirectUrl(string redirectUrl); + Task ObtainTokenViaOAuth(string code, string redirectUrl); + Task SendSlackMessageByChannelIdAsync(string token, string message, string channelId); +} diff --git a/src/Core/AdminConsole/Services/Implementations/SlackEventHandler.cs b/src/Core/AdminConsole/Services/Implementations/SlackEventHandler.cs new file mode 100644 index 0000000000..c81914b708 --- /dev/null +++ b/src/Core/AdminConsole/Services/Implementations/SlackEventHandler.cs @@ -0,0 +1,46 @@ +using System.Text.Json; +using Bit.Core.AdminConsole.Utilities; +using Bit.Core.Enums; +using Bit.Core.Models.Data; +using Bit.Core.Models.Data.Integrations; +using Bit.Core.Repositories; + +namespace Bit.Core.Services; + +public class SlackEventHandler( + IOrganizationIntegrationConfigurationRepository configurationRepository, + ISlackService slackService) + : IEventMessageHandler +{ + public async Task HandleEventAsync(EventMessage eventMessage) + { + var organizationId = eventMessage.OrganizationId ?? Guid.Empty; + var configurations = await configurationRepository.GetConfigurationDetailsAsync( + organizationId, + IntegrationType.Slack, + eventMessage.Type); + + foreach (var configuration in configurations) + { + var config = configuration.MergedConfiguration.Deserialize(); + if (config is null) + { + continue; + } + + await slackService.SendSlackMessageByChannelIdAsync( + config.token, + IntegrationTemplateProcessor.ReplaceTokens(configuration.Template, eventMessage), + config.channelId + ); + } + } + + public async Task HandleManyEventsAsync(IEnumerable eventMessages) + { + foreach (var eventMessage in eventMessages) + { + await HandleEventAsync(eventMessage); + } + } +} diff --git a/src/Core/AdminConsole/Services/Implementations/SlackService.cs b/src/Core/AdminConsole/Services/Implementations/SlackService.cs new file mode 100644 index 0000000000..effcfdf1ce --- /dev/null +++ b/src/Core/AdminConsole/Services/Implementations/SlackService.cs @@ -0,0 +1,162 @@ +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Web; +using Bit.Core.Models.Slack; +using Bit.Core.Settings; +using Microsoft.Extensions.Logging; + +namespace Bit.Core.Services; + +public class SlackService( + IHttpClientFactory httpClientFactory, + GlobalSettings globalSettings, + ILogger logger) : ISlackService +{ + private readonly HttpClient _httpClient = httpClientFactory.CreateClient(HttpClientName); + private readonly string _clientId = globalSettings.Slack.ClientId; + private readonly string _clientSecret = globalSettings.Slack.ClientSecret; + private readonly string _scopes = globalSettings.Slack.Scopes; + private readonly string _slackApiBaseUrl = globalSettings.Slack.ApiBaseUrl; + + public const string HttpClientName = "SlackServiceHttpClient"; + + public async Task GetChannelIdAsync(string token, string channelName) + { + return (await GetChannelIdsAsync(token, [channelName])).FirstOrDefault(); + } + + public async Task> GetChannelIdsAsync(string token, List channelNames) + { + var matchingChannelIds = new List(); + var baseUrl = $"{_slackApiBaseUrl}/conversations.list"; + var nextCursor = string.Empty; + + do + { + var uriBuilder = new UriBuilder(baseUrl); + var queryParameters = HttpUtility.ParseQueryString(uriBuilder.Query); + queryParameters["types"] = "public_channel,private_channel"; + queryParameters["limit"] = "1000"; + if (!string.IsNullOrEmpty(nextCursor)) + { + queryParameters["cursor"] = nextCursor; + } + uriBuilder.Query = queryParameters.ToString(); + + var request = new HttpRequestMessage(HttpMethod.Get, uriBuilder.Uri); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + + var response = await _httpClient.SendAsync(request); + var result = await response.Content.ReadFromJsonAsync(); + + if (result is { Ok: true }) + { + matchingChannelIds.AddRange(result.Channels + .Where(channel => channelNames.Contains(channel.Name)) + .Select(channel => channel.Id)); + nextCursor = result.ResponseMetadata.NextCursor; + } + else + { + logger.LogError("Error getting Channel Ids: {Error}", result.Error); + nextCursor = string.Empty; + } + + } while (!string.IsNullOrEmpty(nextCursor)); + + return matchingChannelIds; + } + + public async Task GetDmChannelByEmailAsync(string token, string email) + { + var userId = await GetUserIdByEmailAsync(token, email); + return await OpenDmChannel(token, userId); + } + + public string GetRedirectUrl(string redirectUrl) + { + return $"https://slack.com/oauth/v2/authorize?client_id={_clientId}&scope={_scopes}&redirect_uri={redirectUrl}"; + } + + public async Task ObtainTokenViaOAuth(string code, string redirectUrl) + { + var tokenResponse = await _httpClient.PostAsync($"{_slackApiBaseUrl}/oauth.v2.access", + new FormUrlEncodedContent(new[] + { + new KeyValuePair("client_id", _clientId), + new KeyValuePair("client_secret", _clientSecret), + new KeyValuePair("code", code), + new KeyValuePair("redirect_uri", redirectUrl) + })); + + SlackOAuthResponse result; + try + { + result = await tokenResponse.Content.ReadFromJsonAsync(); + } + catch + { + result = null; + } + + if (result == null) + { + logger.LogError("Error obtaining token via OAuth: Unknown error"); + return string.Empty; + } + if (!result.Ok) + { + logger.LogError("Error obtaining token via OAuth: {Error}", result.Error); + return string.Empty; + } + + return result.AccessToken; + } + + public async Task SendSlackMessageByChannelIdAsync(string token, string message, string channelId) + { + var payload = JsonContent.Create(new { channel = channelId, text = message }); + var request = new HttpRequestMessage(HttpMethod.Post, $"{_slackApiBaseUrl}/chat.postMessage"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + request.Content = payload; + + await _httpClient.SendAsync(request); + } + + private async Task GetUserIdByEmailAsync(string token, string email) + { + var request = new HttpRequestMessage(HttpMethod.Get, $"{_slackApiBaseUrl}/users.lookupByEmail?email={email}"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + var response = await _httpClient.SendAsync(request); + var result = await response.Content.ReadFromJsonAsync(); + + if (!result.Ok) + { + logger.LogError("Error retrieving Slack user ID: {Error}", result.Error); + return string.Empty; + } + + return result.User.Id; + } + + private async Task OpenDmChannel(string token, string userId) + { + if (string.IsNullOrEmpty(userId)) + return string.Empty; + + var payload = JsonContent.Create(new { users = userId }); + var request = new HttpRequestMessage(HttpMethod.Post, $"{_slackApiBaseUrl}/conversations.open"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + request.Content = payload; + var response = await _httpClient.SendAsync(request); + var result = await response.Content.ReadFromJsonAsync(); + + if (!result.Ok) + { + logger.LogError("Error opening DM channel: {Error}", result.Error); + return string.Empty; + } + + return result.Channel.Id; + } +} diff --git a/src/Core/AdminConsole/Services/Implementations/WebhookEventHandler.cs b/src/Core/AdminConsole/Services/Implementations/WebhookEventHandler.cs index d152f9011b..1c3b279ee5 100644 --- a/src/Core/AdminConsole/Services/Implementations/WebhookEventHandler.cs +++ b/src/Core/AdminConsole/Services/Implementations/WebhookEventHandler.cs @@ -1,30 +1,57 @@ -using System.Net.Http.Json; +using System.Text; +using System.Text.Json; +using Bit.Core.AdminConsole.Utilities; +using Bit.Core.Enums; using Bit.Core.Models.Data; -using Bit.Core.Settings; +using Bit.Core.Models.Data.Integrations; +using Bit.Core.Repositories; + +#nullable enable namespace Bit.Core.Services; public class WebhookEventHandler( IHttpClientFactory httpClientFactory, - GlobalSettings globalSettings) + IOrganizationIntegrationConfigurationRepository configurationRepository) : IEventMessageHandler { private readonly HttpClient _httpClient = httpClientFactory.CreateClient(HttpClientName); - private readonly string _webhookUrl = globalSettings.EventLogging.WebhookUrl; public const string HttpClientName = "WebhookEventHandlerHttpClient"; public async Task HandleEventAsync(EventMessage eventMessage) { - var content = JsonContent.Create(eventMessage); - var response = await _httpClient.PostAsync(_webhookUrl, content); - response.EnsureSuccessStatusCode(); + var organizationId = eventMessage.OrganizationId ?? Guid.Empty; + var configurations = await configurationRepository.GetConfigurationDetailsAsync( + organizationId, + IntegrationType.Webhook, + eventMessage.Type); + + foreach (var configuration in configurations) + { + var config = configuration.MergedConfiguration.Deserialize(); + if (config is null || string.IsNullOrEmpty(config.url)) + { + continue; + } + + var content = new StringContent( + IntegrationTemplateProcessor.ReplaceTokens(configuration.Template, eventMessage), + Encoding.UTF8, + "application/json" + ); + var response = await _httpClient.PostAsync( + config.url, + content); + response.EnsureSuccessStatusCode(); + } } public async Task HandleManyEventsAsync(IEnumerable eventMessages) { - var content = JsonContent.Create(eventMessages); - var response = await _httpClient.PostAsync(_webhookUrl, content); - response.EnsureSuccessStatusCode(); + foreach (var eventMessage in eventMessages) + { + await HandleEventAsync(eventMessage); + } } } diff --git a/src/Core/AdminConsole/Services/NoopImplementations/NoopSlackService.cs b/src/Core/AdminConsole/Services/NoopImplementations/NoopSlackService.cs new file mode 100644 index 0000000000..c34c073e87 --- /dev/null +++ b/src/Core/AdminConsole/Services/NoopImplementations/NoopSlackService.cs @@ -0,0 +1,36 @@ +using Bit.Core.Services; + +namespace Bit.Core.AdminConsole.Services.NoopImplementations; + +public class NoopSlackService : ISlackService +{ + public Task GetChannelIdAsync(string token, string channelName) + { + return Task.FromResult(string.Empty); + } + + public Task> GetChannelIdsAsync(string token, List channelNames) + { + return Task.FromResult(new List()); + } + + public Task GetDmChannelByEmailAsync(string token, string email) + { + return Task.FromResult(string.Empty); + } + + public string GetRedirectUrl(string redirectUrl) + { + return string.Empty; + } + + public Task SendSlackMessageByChannelIdAsync(string token, string message, string channelId) + { + return Task.FromResult(0); + } + + public Task ObtainTokenViaOAuth(string code, string redirectUrl) + { + return Task.FromResult(string.Empty); + } +} diff --git a/src/Core/AdminConsole/Utilities/IntegrationTemplateProcessor.cs b/src/Core/AdminConsole/Utilities/IntegrationTemplateProcessor.cs new file mode 100644 index 0000000000..178c0348d9 --- /dev/null +++ b/src/Core/AdminConsole/Utilities/IntegrationTemplateProcessor.cs @@ -0,0 +1,23 @@ +using System.Text.RegularExpressions; + +namespace Bit.Core.AdminConsole.Utilities; + +public static partial class IntegrationTemplateProcessor +{ + [GeneratedRegex(@"#(\w+)#")] + private static partial Regex TokenRegex(); + + public static string ReplaceTokens(string template, object values) + { + if (string.IsNullOrEmpty(template) || values == null) + return template; + + var type = values.GetType(); + return TokenRegex().Replace(template, match => + { + var propertyName = match.Groups[1].Value; + var property = type.GetProperty(propertyName); + return property?.GetValue(values)?.ToString() ?? match.Value; + }); + } +} diff --git a/src/Core/Settings/GlobalSettings.cs b/src/Core/Settings/GlobalSettings.cs index 6bb76eb50a..2b658c65b3 100644 --- a/src/Core/Settings/GlobalSettings.cs +++ b/src/Core/Settings/GlobalSettings.cs @@ -53,6 +53,7 @@ public class GlobalSettings : IGlobalSettings public virtual SqlSettings PostgreSql { get; set; } = new SqlSettings(); public virtual SqlSettings MySql { get; set; } = new SqlSettings(); public virtual SqlSettings Sqlite { get; set; } = new SqlSettings() { ConnectionString = "Data Source=:memory:" }; + public virtual SlackSettings Slack { get; set; } = new SlackSettings(); public virtual EventLoggingSettings EventLogging { get; set; } = new EventLoggingSettings(); public virtual MailSettings Mail { get; set; } = new MailSettings(); public virtual IConnectionStringSettings Storage { get; set; } = new ConnectionStringSettings(); @@ -271,10 +272,17 @@ public class GlobalSettings : IGlobalSettings } } + public class SlackSettings + { + public virtual string ApiBaseUrl { get; set; } = "https://slack.com/api"; + public virtual string ClientId { get; set; } + public virtual string ClientSecret { get; set; } + public virtual string Scopes { get; set; } + } + public class EventLoggingSettings { public AzureServiceBusSettings AzureServiceBus { get; set; } = new AzureServiceBusSettings(); - public virtual string WebhookUrl { get; set; } public RabbitMqSettings RabbitMq { get; set; } = new RabbitMqSettings(); public class AzureServiceBusSettings @@ -283,6 +291,7 @@ public class GlobalSettings : IGlobalSettings private string _topicName; public virtual string EventRepositorySubscriptionName { get; set; } = "events-write-subscription"; + public virtual string SlackSubscriptionName { get; set; } = "events-slack-subscription"; public virtual string WebhookSubscriptionName { get; set; } = "events-webhook-subscription"; public string ConnectionString @@ -307,6 +316,7 @@ public class GlobalSettings : IGlobalSettings public virtual string EventRepositoryQueueName { get; set; } = "events-write-queue"; public virtual string WebhookQueueName { get; set; } = "events-webhook-queue"; + public virtual string SlackQueueName { get; set; } = "events-slack-queue"; public string HostName { diff --git a/src/Events/Startup.cs b/src/Events/Startup.cs index 57af285b03..34ffed4ee6 100644 --- a/src/Events/Startup.cs +++ b/src/Events/Startup.cs @@ -1,5 +1,6 @@ using System.Globalization; using Bit.Core.AdminConsole.Services.Implementations; +using Bit.Core.AdminConsole.Services.NoopImplementations; using Bit.Core.Context; using Bit.Core.IdentityServer; using Bit.Core.Services; @@ -117,18 +118,33 @@ public class Startup globalSettings, globalSettings.EventLogging.RabbitMq.EventRepositoryQueueName)); - if (CoreHelpers.SettingHasValue(globalSettings.EventLogging.WebhookUrl)) + if (CoreHelpers.SettingHasValue(globalSettings.Slack.ClientId) && + CoreHelpers.SettingHasValue(globalSettings.Slack.ClientSecret) && + CoreHelpers.SettingHasValue(globalSettings.Slack.Scopes)) { - services.AddSingleton(); - services.AddHttpClient(WebhookEventHandler.HttpClientName); - - services.AddSingleton(provider => - new RabbitMqEventListenerService( - provider.GetRequiredService(), - provider.GetRequiredService>(), - globalSettings, - globalSettings.EventLogging.RabbitMq.WebhookQueueName)); + services.AddHttpClient(SlackService.HttpClientName); + services.AddSingleton(); } + else + { + services.AddSingleton(); + } + services.AddSingleton(); + services.AddSingleton(provider => + new RabbitMqEventListenerService( + provider.GetRequiredService(), + provider.GetRequiredService>(), + globalSettings, + globalSettings.EventLogging.RabbitMq.SlackQueueName)); + + services.AddHttpClient(WebhookEventHandler.HttpClientName); + services.AddSingleton(); + services.AddSingleton(provider => + new RabbitMqEventListenerService( + provider.GetRequiredService(), + provider.GetRequiredService>(), + globalSettings, + globalSettings.EventLogging.RabbitMq.WebhookQueueName)); } } diff --git a/src/EventsProcessor/Startup.cs b/src/EventsProcessor/Startup.cs index 65d1d36e24..e397bd326b 100644 --- a/src/EventsProcessor/Startup.cs +++ b/src/EventsProcessor/Startup.cs @@ -1,4 +1,5 @@ using System.Globalization; +using Bit.Core.AdminConsole.Services.NoopImplementations; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Settings; @@ -29,6 +30,12 @@ public class Startup // Settings var globalSettings = services.AddGlobalSettingsServices(Configuration, Environment); + // Data Protection + services.AddCustomDataProtectionServices(Environment, globalSettings); + + // Repositories + services.AddDatabaseRepositories(globalSettings); + // Hosted Services // Optional Azure Service Bus Listeners @@ -45,18 +52,34 @@ public class Startup globalSettings, globalSettings.EventLogging.AzureServiceBus.EventRepositorySubscriptionName)); - if (CoreHelpers.SettingHasValue(globalSettings.EventLogging.WebhookUrl)) + if (CoreHelpers.SettingHasValue(globalSettings.Slack.ClientId) && + CoreHelpers.SettingHasValue(globalSettings.Slack.ClientSecret) && + CoreHelpers.SettingHasValue(globalSettings.Slack.Scopes)) { - services.AddSingleton(); - services.AddHttpClient(WebhookEventHandler.HttpClientName); - - services.AddSingleton(provider => - new AzureServiceBusEventListenerService( - provider.GetRequiredService(), - provider.GetRequiredService>(), - globalSettings, - globalSettings.EventLogging.AzureServiceBus.WebhookSubscriptionName)); + services.AddHttpClient(SlackService.HttpClientName); + services.AddSingleton(); } + else + { + services.AddSingleton(); + } + services.AddSingleton(); + services.AddSingleton(provider => + new AzureServiceBusEventListenerService( + provider.GetRequiredService(), + provider.GetRequiredService>(), + globalSettings, + globalSettings.EventLogging.AzureServiceBus.SlackSubscriptionName)); + + services.AddSingleton(); + services.AddHttpClient(WebhookEventHandler.HttpClientName); + + services.AddSingleton(provider => + new AzureServiceBusEventListenerService( + provider.GetRequiredService(), + provider.GetRequiredService>(), + globalSettings, + globalSettings.EventLogging.AzureServiceBus.WebhookSubscriptionName)); } services.AddHostedService(); } diff --git a/src/Infrastructure.Dapper/DapperServiceCollectionExtensions.cs b/src/Infrastructure.Dapper/DapperServiceCollectionExtensions.cs index 26abf5632c..d48fe95096 100644 --- a/src/Infrastructure.Dapper/DapperServiceCollectionExtensions.cs +++ b/src/Infrastructure.Dapper/DapperServiceCollectionExtensions.cs @@ -41,6 +41,8 @@ public static class DapperServiceCollectionExtensions services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/test/Api.Test/AdminConsole/Controllers/OrganizationIntegrationControllerTests.cs b/test/Api.Test/AdminConsole/Controllers/OrganizationIntegrationControllerTests.cs new file mode 100644 index 0000000000..fbb3ecbfe0 --- /dev/null +++ b/test/Api.Test/AdminConsole/Controllers/OrganizationIntegrationControllerTests.cs @@ -0,0 +1,201 @@ +using Bit.Api.AdminConsole.Controllers; +using Bit.Api.AdminConsole.Models.Request.Organizations; +using Bit.Api.AdminConsole.Models.Response.Organizations; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Context; +using Bit.Core.Enums; +using Bit.Core.Exceptions; +using Bit.Core.Repositories; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Microsoft.AspNetCore.Mvc; +using NSubstitute; +using NSubstitute.ReturnsExtensions; +using Xunit; + +namespace Bit.Api.Test.AdminConsole.Controllers; + +[ControllerCustomize(typeof(OrganizationIntegrationController))] +[SutProviderCustomize] +public class OrganizationIntegrationControllerTests +{ + private OrganizationIntegrationRequestModel _webhookRequestModel = new OrganizationIntegrationRequestModel() + { + Configuration = null, + Type = IntegrationType.Webhook + }; + + [Theory, BitAutoData] + public async Task CreateAsync_Webhook_AllParamsProvided_Succeeds( + SutProvider sutProvider, + Guid organizationId) + { + sutProvider.Sut.Url = Substitute.For(); + sutProvider.GetDependency() + .OrganizationOwner(organizationId) + .Returns(true); + sutProvider.GetDependency() + .CreateAsync(Arg.Any()) + .Returns(callInfo => callInfo.Arg()); + var response = await sutProvider.Sut.CreateAsync(organizationId, _webhookRequestModel); + + await sutProvider.GetDependency().Received(1) + .CreateAsync(Arg.Any()); + Assert.IsType(response); + Assert.Equal(IntegrationType.Webhook, response.Type); + } + + [Theory, BitAutoData] + public async Task CreateAsync_UserIsNotOrganizationAdmin_ThrowsNotFound(SutProvider sutProvider, Guid organizationId) + { + sutProvider.Sut.Url = Substitute.For(); + sutProvider.GetDependency() + .OrganizationOwner(organizationId) + .Returns(false); + + await Assert.ThrowsAsync(async () => await sutProvider.Sut.CreateAsync(organizationId, _webhookRequestModel)); + } + + [Theory, BitAutoData] + public async Task DeleteAsync_AllParamsProvided_Succeeds( + SutProvider sutProvider, + Guid organizationId, + OrganizationIntegration organizationIntegration) + { + organizationIntegration.OrganizationId = organizationId; + sutProvider.Sut.Url = Substitute.For(); + sutProvider.GetDependency() + .OrganizationOwner(organizationId) + .Returns(true); + sutProvider.GetDependency() + .GetByIdAsync(Arg.Any()) + .Returns(organizationIntegration); + + await sutProvider.Sut.DeleteAsync(organizationId, organizationIntegration.Id); + + await sutProvider.GetDependency().Received(1) + .GetByIdAsync(organizationIntegration.Id); + await sutProvider.GetDependency().Received(1) + .DeleteAsync(organizationIntegration); + } + + [Theory, BitAutoData] + public async Task DeleteAsync_IntegrationDoesNotBelongToOrganization_ThrowsNotFound( + SutProvider sutProvider, + Guid organizationId, + OrganizationIntegration organizationIntegration) + { + organizationIntegration.OrganizationId = Guid.NewGuid(); + sutProvider.Sut.Url = Substitute.For(); + sutProvider.GetDependency() + .OrganizationOwner(organizationId) + .Returns(true); + sutProvider.GetDependency() + .GetByIdAsync(Arg.Any()) + .ReturnsNull(); + + await Assert.ThrowsAsync(async () => await sutProvider.Sut.DeleteAsync(organizationId, Guid.Empty)); + } + + [Theory, BitAutoData] + public async Task DeleteAsync_IntegrationDoesNotExist_ThrowsNotFound( + SutProvider sutProvider, + Guid organizationId) + { + sutProvider.Sut.Url = Substitute.For(); + sutProvider.GetDependency() + .OrganizationOwner(organizationId) + .Returns(true); + sutProvider.GetDependency() + .GetByIdAsync(Arg.Any()) + .ReturnsNull(); + + await Assert.ThrowsAsync(async () => await sutProvider.Sut.DeleteAsync(organizationId, Guid.Empty)); + } + + [Theory, BitAutoData] + public async Task DeleteAsync_UserIsNotOrganizationAdmin_ThrowsNotFound( + SutProvider sutProvider, + Guid organizationId) + { + sutProvider.Sut.Url = Substitute.For(); + sutProvider.GetDependency() + .OrganizationOwner(organizationId) + .Returns(false); + + await Assert.ThrowsAsync(async () => await sutProvider.Sut.DeleteAsync(organizationId, Guid.Empty)); + } + + [Theory, BitAutoData] + public async Task UpdateAsync_AllParamsProvided_Succeeds( + SutProvider sutProvider, + Guid organizationId, + OrganizationIntegration organizationIntegration) + { + organizationIntegration.OrganizationId = organizationId; + organizationIntegration.Type = IntegrationType.Webhook; + sutProvider.Sut.Url = Substitute.For(); + sutProvider.GetDependency() + .OrganizationOwner(organizationId) + .Returns(true); + sutProvider.GetDependency() + .GetByIdAsync(Arg.Any()) + .Returns(organizationIntegration); + + var response = await sutProvider.Sut.UpdateAsync(organizationId, organizationIntegration.Id, _webhookRequestModel); + + await sutProvider.GetDependency().Received(1) + .GetByIdAsync(organizationIntegration.Id); + await sutProvider.GetDependency().Received(1) + .ReplaceAsync(organizationIntegration); + Assert.IsType(response); + Assert.Equal(IntegrationType.Webhook, response.Type); + } + + [Theory, BitAutoData] + public async Task UpdateAsync_IntegrationDoesNotBelongToOrganization_ThrowsNotFound( + SutProvider sutProvider, + Guid organizationId, + OrganizationIntegration organizationIntegration) + { + organizationIntegration.OrganizationId = Guid.NewGuid(); + sutProvider.Sut.Url = Substitute.For(); + sutProvider.GetDependency() + .OrganizationOwner(organizationId) + .Returns(true); + sutProvider.GetDependency() + .GetByIdAsync(Arg.Any()) + .ReturnsNull(); + + await Assert.ThrowsAsync(async () => await sutProvider.Sut.UpdateAsync(organizationId, Guid.Empty, _webhookRequestModel)); + } + + [Theory, BitAutoData] + public async Task UpdateAsync_IntegrationDoesNotExist_ThrowsNotFound( + SutProvider sutProvider, + Guid organizationId) + { + sutProvider.Sut.Url = Substitute.For(); + sutProvider.GetDependency() + .OrganizationOwner(organizationId) + .Returns(true); + sutProvider.GetDependency() + .GetByIdAsync(Arg.Any()) + .ReturnsNull(); + + await Assert.ThrowsAsync(async () => await sutProvider.Sut.UpdateAsync(organizationId, Guid.Empty, _webhookRequestModel)); + } + + [Theory, BitAutoData] + public async Task UpdateAsync_UserIsNotOrganizationAdmin_ThrowsNotFound( + SutProvider sutProvider, + Guid organizationId) + { + sutProvider.Sut.Url = Substitute.For(); + sutProvider.GetDependency() + .OrganizationOwner(organizationId) + .Returns(false); + + await Assert.ThrowsAsync(async () => await sutProvider.Sut.UpdateAsync(organizationId, Guid.Empty, _webhookRequestModel)); + } +} diff --git a/test/Api.Test/AdminConsole/Controllers/OrganizationIntegrationsConfigurationControllerTests.cs b/test/Api.Test/AdminConsole/Controllers/OrganizationIntegrationsConfigurationControllerTests.cs new file mode 100644 index 0000000000..8a33e17053 --- /dev/null +++ b/test/Api.Test/AdminConsole/Controllers/OrganizationIntegrationsConfigurationControllerTests.cs @@ -0,0 +1,621 @@ +using System.Text.Json; +using Bit.Api.AdminConsole.Controllers; +using Bit.Api.AdminConsole.Models.Request.Organizations; +using Bit.Api.AdminConsole.Models.Response.Organizations; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Context; +using Bit.Core.Enums; +using Bit.Core.Exceptions; +using Bit.Core.Models.Data.Integrations; +using Bit.Core.Repositories; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Microsoft.AspNetCore.Mvc; +using NSubstitute; +using NSubstitute.ReturnsExtensions; +using Xunit; + +namespace Bit.Api.Test.AdminConsole.Controllers; + +[ControllerCustomize(typeof(OrganizationIntegrationConfigurationController))] +[SutProviderCustomize] +public class OrganizationIntegrationsConfigurationControllerTests +{ + [Theory, BitAutoData] + public async Task DeleteAsync_AllParamsProvided_Succeeds( + SutProvider sutProvider, + Guid organizationId, + OrganizationIntegration organizationIntegration, + OrganizationIntegrationConfiguration organizationIntegrationConfiguration) + { + organizationIntegration.OrganizationId = organizationId; + organizationIntegrationConfiguration.OrganizationIntegrationId = organizationIntegration.Id; + sutProvider.Sut.Url = Substitute.For(); + sutProvider.GetDependency() + .OrganizationOwner(organizationId) + .Returns(true); + sutProvider.GetDependency() + .GetByIdAsync(Arg.Any()) + .Returns(organizationIntegration); + sutProvider.GetDependency() + .GetByIdAsync(Arg.Any()) + .Returns(organizationIntegrationConfiguration); + + await sutProvider.Sut.DeleteAsync(organizationId, organizationIntegration.Id, organizationIntegrationConfiguration.Id); + + await sutProvider.GetDependency().Received(1) + .GetByIdAsync(organizationIntegration.Id); + await sutProvider.GetDependency().Received(1) + .GetByIdAsync(organizationIntegrationConfiguration.Id); + await sutProvider.GetDependency().Received(1) + .DeleteAsync(organizationIntegrationConfiguration); + } + + [Theory, BitAutoData] + public async Task DeleteAsync_IntegrationConfigurationDoesNotExist_ThrowsNotFound( + SutProvider sutProvider, + Guid organizationId, + OrganizationIntegration organizationIntegration) + { + organizationIntegration.OrganizationId = organizationId; + sutProvider.Sut.Url = Substitute.For(); + sutProvider.GetDependency() + .OrganizationOwner(organizationId) + .Returns(true); + sutProvider.GetDependency() + .GetByIdAsync(Arg.Any()) + .Returns(organizationIntegration); + sutProvider.GetDependency() + .GetByIdAsync(Arg.Any()) + .ReturnsNull(); + + await Assert.ThrowsAsync(async () => await sutProvider.Sut.DeleteAsync(organizationId, Guid.Empty, Guid.Empty)); + } + + [Theory, BitAutoData] + public async Task DeleteAsync_IntegrationDoesNotExist_ThrowsNotFound( + SutProvider sutProvider, + Guid organizationId) + { + sutProvider.Sut.Url = Substitute.For(); + sutProvider.GetDependency() + .OrganizationOwner(organizationId) + .Returns(true); + sutProvider.GetDependency() + .GetByIdAsync(Arg.Any()) + .ReturnsNull(); + + await Assert.ThrowsAsync(async () => await sutProvider.Sut.DeleteAsync(organizationId, Guid.Empty, Guid.Empty)); + } + + [Theory, BitAutoData] + public async Task DeleteAsync_IntegrationDoesNotBelongToOrganization_ThrowsNotFound( + SutProvider sutProvider, + Guid organizationId, + OrganizationIntegration organizationIntegration) + { + sutProvider.Sut.Url = Substitute.For(); + sutProvider.GetDependency() + .OrganizationOwner(organizationId) + .Returns(true); + sutProvider.GetDependency() + .GetByIdAsync(Arg.Any()) + .Returns(organizationIntegration); + + await Assert.ThrowsAsync(async () => await sutProvider.Sut.DeleteAsync(organizationId, organizationIntegration.Id, Guid.Empty)); + } + + [Theory, BitAutoData] + public async Task DeleteAsync_IntegrationConfigDoesNotBelongToIntegration_ThrowsNotFound( + SutProvider sutProvider, + Guid organizationId, + OrganizationIntegration organizationIntegration, + OrganizationIntegrationConfiguration organizationIntegrationConfiguration) + { + organizationIntegration.OrganizationId = organizationId; + organizationIntegrationConfiguration.OrganizationIntegrationId = Guid.Empty; + sutProvider.Sut.Url = Substitute.For(); + sutProvider.GetDependency() + .OrganizationOwner(organizationId) + .Returns(true); + sutProvider.GetDependency() + .GetByIdAsync(Arg.Any()) + .Returns(organizationIntegration); + sutProvider.GetDependency() + .GetByIdAsync(Arg.Any()) + .Returns(organizationIntegrationConfiguration); + + await Assert.ThrowsAsync(async () => await sutProvider.Sut.DeleteAsync(organizationId, organizationIntegration.Id, Guid.Empty)); + } + + [Theory, BitAutoData] + public async Task DeleteAsync_UserIsNotOrganizationAdmin_ThrowsNotFound( + SutProvider sutProvider, + Guid organizationId) + { + sutProvider.Sut.Url = Substitute.For(); + sutProvider.GetDependency() + .OrganizationOwner(organizationId) + .Returns(false); + + await Assert.ThrowsAsync(async () => await sutProvider.Sut.DeleteAsync(organizationId, Guid.Empty, Guid.Empty)); + } + + [Theory, BitAutoData] + public async Task PostAsync_AllParamsProvided_Slack_Succeeds( + SutProvider sutProvider, + Guid organizationId, + OrganizationIntegration organizationIntegration, + OrganizationIntegrationConfiguration organizationIntegrationConfiguration, + OrganizationIntegrationConfigurationRequestModel model) + { + organizationIntegration.OrganizationId = organizationId; + organizationIntegration.Type = IntegrationType.Slack; + var slackConfig = new SlackIntegrationConfiguration(channelId: "C123456"); + model.Configuration = JsonSerializer.Serialize(slackConfig); + model.Template = "Template String"; + + var expected = new OrganizationIntegrationConfigurationResponseModel(organizationIntegrationConfiguration); + + sutProvider.Sut.Url = Substitute.For(); + sutProvider.GetDependency() + .OrganizationOwner(organizationId) + .Returns(true); + sutProvider.GetDependency() + .GetByIdAsync(Arg.Any()) + .Returns(organizationIntegration); + sutProvider.GetDependency() + .CreateAsync(Arg.Any()) + .Returns(organizationIntegrationConfiguration); + var requestAction = await sutProvider.Sut.CreateAsync(organizationId, organizationIntegration.Id, model); + + await sutProvider.GetDependency().Received(1) + .CreateAsync(Arg.Any()); + Assert.IsType(requestAction); + Assert.Equal(expected.Id, requestAction.Id); + Assert.Equal(expected.Configuration, requestAction.Configuration); + Assert.Equal(expected.EventType, requestAction.EventType); + Assert.Equal(expected.Template, requestAction.Template); + } + + [Theory, BitAutoData] + public async Task PostAsync_AllParamsProvided_Webhook_Succeeds( + SutProvider sutProvider, + Guid organizationId, + OrganizationIntegration organizationIntegration, + OrganizationIntegrationConfiguration organizationIntegrationConfiguration, + OrganizationIntegrationConfigurationRequestModel model) + { + organizationIntegration.OrganizationId = organizationId; + organizationIntegration.Type = IntegrationType.Webhook; + var webhookConfig = new WebhookIntegrationConfiguration(url: "https://localhost"); + model.Configuration = JsonSerializer.Serialize(webhookConfig); + model.Template = "Template String"; + + var expected = new OrganizationIntegrationConfigurationResponseModel(organizationIntegrationConfiguration); + + sutProvider.Sut.Url = Substitute.For(); + sutProvider.GetDependency() + .OrganizationOwner(organizationId) + .Returns(true); + sutProvider.GetDependency() + .GetByIdAsync(Arg.Any()) + .Returns(organizationIntegration); + sutProvider.GetDependency() + .CreateAsync(Arg.Any()) + .Returns(organizationIntegrationConfiguration); + var requestAction = await sutProvider.Sut.CreateAsync(organizationId, organizationIntegration.Id, model); + + await sutProvider.GetDependency().Received(1) + .CreateAsync(Arg.Any()); + Assert.IsType(requestAction); + Assert.Equal(expected.Id, requestAction.Id); + Assert.Equal(expected.Configuration, requestAction.Configuration); + Assert.Equal(expected.EventType, requestAction.EventType); + Assert.Equal(expected.Template, requestAction.Template); + } + + [Theory, BitAutoData] + public async Task PostAsync_IntegrationTypeCloudBillingSync_ThrowsBadRequestException( + SutProvider sutProvider, + Guid organizationId, + OrganizationIntegration organizationIntegration, + OrganizationIntegrationConfiguration organizationIntegrationConfiguration, + OrganizationIntegrationConfigurationRequestModel model) + { + organizationIntegration.OrganizationId = organizationId; + organizationIntegration.Type = IntegrationType.CloudBillingSync; + + sutProvider.Sut.Url = Substitute.For(); + sutProvider.GetDependency() + .OrganizationOwner(organizationId) + .Returns(true); + sutProvider.GetDependency() + .GetByIdAsync(Arg.Any()) + .Returns(organizationIntegration); + sutProvider.GetDependency() + .CreateAsync(Arg.Any()) + .Returns(organizationIntegrationConfiguration); + + await Assert.ThrowsAsync(async () => await sutProvider.Sut.CreateAsync( + organizationId, + organizationIntegration.Id, + model)); + } + + [Theory, BitAutoData] + public async Task PostAsync_IntegrationTypeScim_ThrowsBadRequestException( + SutProvider sutProvider, + Guid organizationId, + OrganizationIntegration organizationIntegration, + OrganizationIntegrationConfiguration organizationIntegrationConfiguration, + OrganizationIntegrationConfigurationRequestModel model) + { + organizationIntegration.OrganizationId = organizationId; + organizationIntegration.Type = IntegrationType.Scim; + + sutProvider.Sut.Url = Substitute.For(); + sutProvider.GetDependency() + .OrganizationOwner(organizationId) + .Returns(true); + sutProvider.GetDependency() + .GetByIdAsync(Arg.Any()) + .Returns(organizationIntegration); + sutProvider.GetDependency() + .CreateAsync(Arg.Any()) + .Returns(organizationIntegrationConfiguration); + + await Assert.ThrowsAsync(async () => await sutProvider.Sut.CreateAsync( + organizationId, + organizationIntegration.Id, + model)); + } + + [Theory, BitAutoData] + public async Task PostAsync_IntegrationDoesNotExist_ThrowsNotFound( + SutProvider sutProvider, + Guid organizationId) + { + sutProvider.Sut.Url = Substitute.For(); + sutProvider.GetDependency() + .OrganizationOwner(organizationId) + .Returns(true); + sutProvider.GetDependency() + .GetByIdAsync(Arg.Any()) + .ReturnsNull(); + + await Assert.ThrowsAsync(async () => await sutProvider.Sut.CreateAsync( + organizationId, + Guid.Empty, + new OrganizationIntegrationConfigurationRequestModel())); + } + + [Theory, BitAutoData] + public async Task PostAsync_IntegrationDoesNotBelongToOrganization_ThrowsNotFound( + SutProvider sutProvider, + Guid organizationId, + OrganizationIntegration organizationIntegration) + { + sutProvider.Sut.Url = Substitute.For(); + sutProvider.GetDependency() + .OrganizationOwner(organizationId) + .Returns(true); + sutProvider.GetDependency() + .GetByIdAsync(Arg.Any()) + .Returns(organizationIntegration); + + await Assert.ThrowsAsync(async () => await sutProvider.Sut.CreateAsync( + organizationId, + organizationIntegration.Id, + new OrganizationIntegrationConfigurationRequestModel())); + } + + [Theory, BitAutoData] + public async Task PostAsync_InvalidConfiguration_ThrowsBadRequestException( + SutProvider sutProvider, + Guid organizationId, + OrganizationIntegration organizationIntegration, + OrganizationIntegrationConfiguration organizationIntegrationConfiguration, + OrganizationIntegrationConfigurationRequestModel model) + { + organizationIntegration.OrganizationId = organizationId; + organizationIntegration.Type = IntegrationType.Webhook; + model.Configuration = null; + model.Template = "Template String"; + + sutProvider.Sut.Url = Substitute.For(); + sutProvider.GetDependency() + .OrganizationOwner(organizationId) + .Returns(true); + sutProvider.GetDependency() + .GetByIdAsync(Arg.Any()) + .Returns(organizationIntegration); + sutProvider.GetDependency() + .CreateAsync(Arg.Any()) + .Returns(organizationIntegrationConfiguration); + + await Assert.ThrowsAsync(async () => await sutProvider.Sut.CreateAsync( + organizationId, + organizationIntegration.Id, + model)); + } + + [Theory, BitAutoData] + public async Task PostAsync_InvalidTemplate_ThrowsBadRequestException( + SutProvider sutProvider, + Guid organizationId, + OrganizationIntegration organizationIntegration, + OrganizationIntegrationConfiguration organizationIntegrationConfiguration, + OrganizationIntegrationConfigurationRequestModel model) + { + organizationIntegration.OrganizationId = organizationId; + organizationIntegration.Type = IntegrationType.Webhook; + var webhookConfig = new WebhookIntegrationConfiguration(url: "https://localhost"); + model.Configuration = JsonSerializer.Serialize(webhookConfig); + model.Template = null; + + sutProvider.Sut.Url = Substitute.For(); + sutProvider.GetDependency() + .OrganizationOwner(organizationId) + .Returns(true); + sutProvider.GetDependency() + .GetByIdAsync(Arg.Any()) + .Returns(organizationIntegration); + sutProvider.GetDependency() + .CreateAsync(Arg.Any()) + .Returns(organizationIntegrationConfiguration); + + await Assert.ThrowsAsync(async () => await sutProvider.Sut.CreateAsync( + organizationId, + organizationIntegration.Id, + model)); + } + + [Theory, BitAutoData] + public async Task PostAsync_UserIsNotOrganizationAdmin_ThrowsNotFound(SutProvider sutProvider, Guid organizationId) + { + sutProvider.Sut.Url = Substitute.For(); + sutProvider.GetDependency() + .OrganizationOwner(organizationId) + .Returns(false); + + await Assert.ThrowsAsync(async () => await sutProvider.Sut.CreateAsync(organizationId, Guid.Empty, new OrganizationIntegrationConfigurationRequestModel())); + } + + [Theory, BitAutoData] + public async Task UpdateAsync_AllParamsProvided_Slack_Succeeds( + SutProvider sutProvider, + Guid organizationId, + OrganizationIntegration organizationIntegration, + OrganizationIntegrationConfiguration organizationIntegrationConfiguration, + OrganizationIntegrationConfigurationRequestModel model) + { + organizationIntegration.OrganizationId = organizationId; + organizationIntegrationConfiguration.OrganizationIntegrationId = organizationIntegration.Id; + organizationIntegration.Type = IntegrationType.Slack; + var slackConfig = new SlackIntegrationConfiguration(channelId: "C123456"); + model.Configuration = JsonSerializer.Serialize(slackConfig); + model.Template = "Template String"; + + var expected = new OrganizationIntegrationConfigurationResponseModel(model.ToOrganizationIntegrationConfiguration(organizationIntegrationConfiguration)); + + sutProvider.Sut.Url = Substitute.For(); + sutProvider.GetDependency() + .OrganizationOwner(organizationId) + .Returns(true); + sutProvider.GetDependency() + .GetByIdAsync(Arg.Any()) + .Returns(organizationIntegration); + sutProvider.GetDependency() + .GetByIdAsync(Arg.Any()) + .Returns(organizationIntegrationConfiguration); + var requestAction = await sutProvider.Sut.UpdateAsync( + organizationId, + organizationIntegration.Id, + organizationIntegrationConfiguration.Id, + model); + + await sutProvider.GetDependency().Received(1) + .ReplaceAsync(Arg.Any()); + Assert.IsType(requestAction); + Assert.Equal(expected.Id, requestAction.Id); + Assert.Equal(expected.Configuration, requestAction.Configuration); + Assert.Equal(expected.EventType, requestAction.EventType); + Assert.Equal(expected.Template, requestAction.Template); + } + + + [Theory, BitAutoData] + public async Task UpdateAsync_AllParamsProvided_Webhook_Succeeds( + SutProvider sutProvider, + Guid organizationId, + OrganizationIntegration organizationIntegration, + OrganizationIntegrationConfiguration organizationIntegrationConfiguration, + OrganizationIntegrationConfigurationRequestModel model) + { + organizationIntegration.OrganizationId = organizationId; + organizationIntegrationConfiguration.OrganizationIntegrationId = organizationIntegration.Id; + organizationIntegration.Type = IntegrationType.Webhook; + var webhookConfig = new WebhookIntegrationConfiguration(url: "https://localhost"); + model.Configuration = JsonSerializer.Serialize(webhookConfig); + model.Template = "Template String"; + + var expected = new OrganizationIntegrationConfigurationResponseModel(model.ToOrganizationIntegrationConfiguration(organizationIntegrationConfiguration)); + + sutProvider.Sut.Url = Substitute.For(); + sutProvider.GetDependency() + .OrganizationOwner(organizationId) + .Returns(true); + sutProvider.GetDependency() + .GetByIdAsync(Arg.Any()) + .Returns(organizationIntegration); + sutProvider.GetDependency() + .GetByIdAsync(Arg.Any()) + .Returns(organizationIntegrationConfiguration); + var requestAction = await sutProvider.Sut.UpdateAsync( + organizationId, + organizationIntegration.Id, + organizationIntegrationConfiguration.Id, + model); + + await sutProvider.GetDependency().Received(1) + .ReplaceAsync(Arg.Any()); + Assert.IsType(requestAction); + Assert.Equal(expected.Id, requestAction.Id); + Assert.Equal(expected.Configuration, requestAction.Configuration); + Assert.Equal(expected.EventType, requestAction.EventType); + Assert.Equal(expected.Template, requestAction.Template); + } + + [Theory, BitAutoData] + public async Task UpdateAsync_IntegrationConfigurationDoesNotExist_ThrowsNotFound( + SutProvider sutProvider, + Guid organizationId, + OrganizationIntegration organizationIntegration, + OrganizationIntegrationConfigurationRequestModel model) + { + organizationIntegration.OrganizationId = organizationId; + organizationIntegration.Type = IntegrationType.Webhook; + var webhookConfig = new WebhookIntegrationConfiguration(url: "https://localhost"); + model.Configuration = JsonSerializer.Serialize(webhookConfig); + model.Template = "Template String"; + + sutProvider.Sut.Url = Substitute.For(); + sutProvider.GetDependency() + .OrganizationOwner(organizationId) + .Returns(true); + sutProvider.GetDependency() + .GetByIdAsync(Arg.Any()) + .Returns(organizationIntegration); + sutProvider.GetDependency() + .GetByIdAsync(Arg.Any()) + .ReturnsNull(); + + await Assert.ThrowsAsync(async () => await sutProvider.Sut.UpdateAsync( + organizationId, + organizationIntegration.Id, + Guid.Empty, + model)); + } + + [Theory, BitAutoData] + public async Task UpdateAsync_IntegrationDoesNotExist_ThrowsNotFound( + SutProvider sutProvider, + Guid organizationId) + { + sutProvider.Sut.Url = Substitute.For(); + sutProvider.GetDependency() + .OrganizationOwner(organizationId) + .Returns(true); + sutProvider.GetDependency() + .GetByIdAsync(Arg.Any()) + .ReturnsNull(); + + await Assert.ThrowsAsync(async () => await sutProvider.Sut.UpdateAsync( + organizationId, + Guid.Empty, + Guid.Empty, + new OrganizationIntegrationConfigurationRequestModel())); + } + + [Theory, BitAutoData] + public async Task UpdateAsync_IntegrationDoesNotBelongToOrganization_ThrowsNotFound( + SutProvider sutProvider, + Guid organizationId, + OrganizationIntegration organizationIntegration) + { + sutProvider.Sut.Url = Substitute.For(); + sutProvider.GetDependency() + .OrganizationOwner(organizationId) + .Returns(true); + sutProvider.GetDependency() + .GetByIdAsync(Arg.Any()) + .Returns(organizationIntegration); + + await Assert.ThrowsAsync(async () => await sutProvider.Sut.UpdateAsync( + organizationId, + organizationIntegration.Id, + Guid.Empty, + new OrganizationIntegrationConfigurationRequestModel())); + } + + [Theory, BitAutoData] + public async Task UpdateAsync_InvalidConfiguration_ThrowsBadRequestException( + SutProvider sutProvider, + Guid organizationId, + OrganizationIntegration organizationIntegration, + OrganizationIntegrationConfiguration organizationIntegrationConfiguration, + OrganizationIntegrationConfigurationRequestModel model) + { + organizationIntegration.OrganizationId = organizationId; + organizationIntegrationConfiguration.OrganizationIntegrationId = organizationIntegration.Id; + organizationIntegration.Type = IntegrationType.Slack; + model.Configuration = null; + model.Template = "Template String"; + + sutProvider.Sut.Url = Substitute.For(); + sutProvider.GetDependency() + .OrganizationOwner(organizationId) + .Returns(true); + sutProvider.GetDependency() + .GetByIdAsync(Arg.Any()) + .Returns(organizationIntegration); + sutProvider.GetDependency() + .GetByIdAsync(Arg.Any()) + .Returns(organizationIntegrationConfiguration); + + await Assert.ThrowsAsync(async () => await sutProvider.Sut.UpdateAsync( + organizationId, + organizationIntegration.Id, + organizationIntegrationConfiguration.Id, + model)); + } + + [Theory, BitAutoData] + public async Task UpdateAsync_InvalidTemplate_ThrowsBadRequestException( + SutProvider sutProvider, + Guid organizationId, + OrganizationIntegration organizationIntegration, + OrganizationIntegrationConfiguration organizationIntegrationConfiguration, + OrganizationIntegrationConfigurationRequestModel model) + { + organizationIntegration.OrganizationId = organizationId; + organizationIntegrationConfiguration.OrganizationIntegrationId = organizationIntegration.Id; + organizationIntegration.Type = IntegrationType.Slack; + var slackConfig = new SlackIntegrationConfiguration(channelId: "C123456"); + model.Configuration = JsonSerializer.Serialize(slackConfig); + model.Template = null; + + sutProvider.Sut.Url = Substitute.For(); + sutProvider.GetDependency() + .OrganizationOwner(organizationId) + .Returns(true); + sutProvider.GetDependency() + .GetByIdAsync(Arg.Any()) + .Returns(organizationIntegration); + sutProvider.GetDependency() + .GetByIdAsync(Arg.Any()) + .Returns(organizationIntegrationConfiguration); + + await Assert.ThrowsAsync(async () => await sutProvider.Sut.UpdateAsync( + organizationId, + organizationIntegration.Id, + organizationIntegrationConfiguration.Id, + model)); + } + + [Theory, BitAutoData] + public async Task UpdateAsync_UserIsNotOrganizationAdmin_ThrowsNotFound(SutProvider sutProvider, Guid organizationId) + { + sutProvider.Sut.Url = Substitute.For(); + sutProvider.GetDependency() + .OrganizationOwner(organizationId) + .Returns(false); + + await Assert.ThrowsAsync(async () => await sutProvider.Sut.UpdateAsync( + organizationId, + Guid.Empty, + Guid.Empty, + new OrganizationIntegrationConfigurationRequestModel())); + } +} diff --git a/test/Api.Test/AdminConsole/Controllers/SlackIntegrationControllerTests.cs b/test/Api.Test/AdminConsole/Controllers/SlackIntegrationControllerTests.cs new file mode 100644 index 0000000000..9bbc8a77c0 --- /dev/null +++ b/test/Api.Test/AdminConsole/Controllers/SlackIntegrationControllerTests.cs @@ -0,0 +1,130 @@ +using Bit.Api.AdminConsole.Controllers; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Context; +using Bit.Core.Exceptions; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Microsoft.AspNetCore.Mvc; +using NSubstitute; +using Xunit; + +namespace Bit.Api.Test.AdminConsole.Controllers; + +[ControllerCustomize(typeof(SlackIntegrationController))] +[SutProviderCustomize] +public class SlackIntegrationControllerTests +{ + [Theory, BitAutoData] + public async Task CreateAsync_AllParamsProvided_Succeeds(SutProvider sutProvider, Guid organizationId) + { + var token = "xoxb-test-token"; + sutProvider.Sut.Url = Substitute.For(); + sutProvider.GetDependency() + .OrganizationOwner(organizationId) + .Returns(true); + sutProvider.GetDependency() + .ObtainTokenViaOAuth(Arg.Any(), Arg.Any()) + .Returns(token); + sutProvider.GetDependency() + .CreateAsync(Arg.Any()) + .Returns(callInfo => callInfo.Arg()); + var requestAction = await sutProvider.Sut.CreateAsync(organizationId, "A_test_code"); + + await sutProvider.GetDependency().Received(1) + .CreateAsync(Arg.Any()); + Assert.IsType(requestAction); + } + + [Theory, BitAutoData] + public async Task CreateAsync_CodeIsEmpty_ThrowsBadRequest(SutProvider sutProvider, Guid organizationId) + { + sutProvider.Sut.Url = Substitute.For(); + sutProvider.GetDependency() + .OrganizationOwner(organizationId) + .Returns(true); + + await Assert.ThrowsAsync(async () => await sutProvider.Sut.CreateAsync(organizationId, string.Empty)); + } + + [Theory, BitAutoData] + public async Task CreateAsync_SlackServiceReturnsEmpty_ThrowsBadRequest(SutProvider sutProvider, Guid organizationId) + { + sutProvider.Sut.Url = Substitute.For(); + sutProvider.GetDependency() + .OrganizationOwner(organizationId) + .Returns(true); + sutProvider.GetDependency() + .ObtainTokenViaOAuth(Arg.Any(), Arg.Any()) + .Returns(string.Empty); + + await Assert.ThrowsAsync(async () => await sutProvider.Sut.CreateAsync(organizationId, "A_test_code")); + } + + [Theory, BitAutoData] + public async Task CreateAsync_UserIsNotOrganizationAdmin_ThrowsNotFound(SutProvider sutProvider, Guid organizationId) + { + var token = "xoxb-test-token"; + sutProvider.Sut.Url = Substitute.For(); + sutProvider.GetDependency() + .OrganizationOwner(organizationId) + .Returns(false); + sutProvider.GetDependency() + .ObtainTokenViaOAuth(Arg.Any(), Arg.Any()) + .Returns(token); + + await Assert.ThrowsAsync(async () => await sutProvider.Sut.CreateAsync(organizationId, "A_test_code")); + } + + [Theory, BitAutoData] + public async Task RedirectAsync_Success(SutProvider sutProvider, Guid organizationId) + { + var expectedUrl = $"https://localhost/{organizationId}"; + + sutProvider.Sut.Url = Substitute.For(); + sutProvider.GetDependency().GetRedirectUrl(Arg.Any()).Returns(expectedUrl); + sutProvider.GetDependency() + .OrganizationOwner(organizationId) + .Returns(true); + sutProvider.GetDependency() + .HttpContext.Request.Scheme + .Returns("https"); + + var requestAction = await sutProvider.Sut.RedirectAsync(organizationId); + + var redirectResult = Assert.IsType(requestAction); + Assert.Equal(expectedUrl, redirectResult.Url); + } + + [Theory, BitAutoData] + public async Task RedirectAsync_SlackServiceReturnsEmpty_ThrowsNotFound(SutProvider sutProvider, Guid organizationId) + { + sutProvider.Sut.Url = Substitute.For(); + sutProvider.GetDependency().GetRedirectUrl(Arg.Any()).Returns(string.Empty); + sutProvider.GetDependency() + .OrganizationOwner(organizationId) + .Returns(true); + sutProvider.GetDependency() + .HttpContext.Request.Scheme + .Returns("https"); + + await Assert.ThrowsAsync(async () => await sutProvider.Sut.RedirectAsync(organizationId)); + } + + [Theory, BitAutoData] + public async Task RedirectAsync_UserIsNotOrganizationAdmin_ThrowsNotFound(SutProvider sutProvider, + Guid organizationId) + { + sutProvider.Sut.Url = Substitute.For(); + sutProvider.GetDependency().GetRedirectUrl(Arg.Any()).Returns(string.Empty); + sutProvider.GetDependency() + .OrganizationOwner(organizationId) + .Returns(false); + sutProvider.GetDependency() + .HttpContext.Request.Scheme + .Returns("https"); + + await Assert.ThrowsAsync(async () => await sutProvider.Sut.RedirectAsync(organizationId)); + } +} diff --git a/test/Api.Test/AdminConsole/Models/Request/Organizations/OrganizationIntegrationConfigurationRequestModelTests.cs b/test/Api.Test/AdminConsole/Models/Request/Organizations/OrganizationIntegrationConfigurationRequestModelTests.cs new file mode 100644 index 0000000000..0076d8bca1 --- /dev/null +++ b/test/Api.Test/AdminConsole/Models/Request/Organizations/OrganizationIntegrationConfigurationRequestModelTests.cs @@ -0,0 +1,120 @@ +using System.Text.Json; +using Bit.Api.AdminConsole.Models.Request.Organizations; +using Bit.Core.Enums; +using Bit.Core.Models.Data.Integrations; +using Xunit; + +namespace Bit.Api.Test.AdminConsole.Models.Request.Organizations; + +public class OrganizationIntegrationConfigurationRequestModelTests +{ + [Fact] + public void IsValidForType_CloudBillingSyncIntegration_ReturnsFalse() + { + var model = new OrganizationIntegrationConfigurationRequestModel + { + Configuration = "{}", + Template = "template" + }; + + Assert.False(model.IsValidForType(IntegrationType.CloudBillingSync)); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void IsValidForType_EmptyConfiguration_ReturnsFalse(string? config) + { + var model = new OrganizationIntegrationConfigurationRequestModel + { + Configuration = config, + Template = "template" + }; + + var result = model.IsValidForType(IntegrationType.Slack); + + Assert.False(result); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void IsValidForType_EmptyTemplate_ReturnsFalse(string? template) + { + var config = JsonSerializer.Serialize(new WebhookIntegrationConfiguration("https://example.com")); + var model = new OrganizationIntegrationConfigurationRequestModel + { + Configuration = config, + Template = template + }; + + Assert.False(model.IsValidForType(IntegrationType.Webhook)); + } + + [Fact] + public void IsValidForType_InvalidJsonConfiguration_ReturnsFalse() + { + var model = new OrganizationIntegrationConfigurationRequestModel + { + Configuration = "{not valid json}", + Template = "template" + }; + + Assert.False(model.IsValidForType(IntegrationType.Webhook)); + } + + [Fact] + public void IsValidForType_ScimIntegration_ReturnsFalse() + { + var model = new OrganizationIntegrationConfigurationRequestModel + { + Configuration = "{}", + Template = "template" + }; + + Assert.False(model.IsValidForType(IntegrationType.Scim)); + } + + [Fact] + public void IsValidForType_ValidSlackConfiguration_ReturnsTrue() + { + var config = JsonSerializer.Serialize(new SlackIntegrationConfiguration("C12345")); + + var model = new OrganizationIntegrationConfigurationRequestModel + { + Configuration = config, + Template = "template" + }; + + Assert.True(model.IsValidForType(IntegrationType.Slack)); + } + + [Fact] + public void IsValidForType_ValidWebhookConfiguration_ReturnsTrue() + { + var config = JsonSerializer.Serialize(new WebhookIntegrationConfiguration("https://example.com")); + var model = new OrganizationIntegrationConfigurationRequestModel + { + Configuration = config, + Template = "template" + }; + + Assert.True(model.IsValidForType(IntegrationType.Webhook)); + } + + [Fact] + public void IsValidForType_UnknownIntegrationType_ReturnsFalse() + { + var model = new OrganizationIntegrationConfigurationRequestModel + { + Configuration = "{}", + Template = "template" + }; + + var unknownType = (IntegrationType)999; + + Assert.False(model.IsValidForType(unknownType)); + } +} diff --git a/test/Api.Test/AdminConsole/Models/Request/Organizations/OrganizationIntegrationRequestModelTests.cs b/test/Api.Test/AdminConsole/Models/Request/Organizations/OrganizationIntegrationRequestModelTests.cs new file mode 100644 index 0000000000..fc9b399abd --- /dev/null +++ b/test/Api.Test/AdminConsole/Models/Request/Organizations/OrganizationIntegrationRequestModelTests.cs @@ -0,0 +1,103 @@ +using System.ComponentModel.DataAnnotations; +using Bit.Api.AdminConsole.Models.Request.Organizations; +using Bit.Core.Enums; +using Xunit; + +namespace Bit.Api.Test.AdminConsole.Models.Request.Organizations; + +public class OrganizationIntegrationRequestModelTests +{ + [Fact] + public void Validate_CloudBillingSync_ReturnsNotYetSupportedError() + { + var model = new OrganizationIntegrationRequestModel + { + Type = IntegrationType.CloudBillingSync, + Configuration = null + }; + + var results = model.Validate(new ValidationContext(model)).ToList(); + + Assert.Single(results); + Assert.Contains(nameof(model.Type), results[0].MemberNames); + Assert.Contains("not yet supported", results[0].ErrorMessage); + } + + [Fact] + public void Validate_Scim_ReturnsNotYetSupportedError() + { + var model = new OrganizationIntegrationRequestModel + { + Type = IntegrationType.Scim, + Configuration = null + }; + + var results = model.Validate(new ValidationContext(model)).ToList(); + + Assert.Single(results); + Assert.Contains(nameof(model.Type), results[0].MemberNames); + Assert.Contains("not yet supported", results[0].ErrorMessage); + } + + [Fact] + public void Validate_Slack_ReturnsCannotBeCreatedDirectlyError() + { + var model = new OrganizationIntegrationRequestModel + { + Type = IntegrationType.Slack, + Configuration = null + }; + + var results = model.Validate(new ValidationContext(model)).ToList(); + + Assert.Single(results); + Assert.Contains(nameof(model.Type), results[0].MemberNames); + Assert.Contains("cannot be created directly", results[0].ErrorMessage); + } + + [Fact] + public void Validate_Webhook_WithNullConfiguration_ReturnsNoErrors() + { + var model = new OrganizationIntegrationRequestModel + { + Type = IntegrationType.Webhook, + Configuration = null + }; + + var results = model.Validate(new ValidationContext(model)).ToList(); + + Assert.Empty(results); + } + + [Fact] + public void Validate_Webhook_WithConfiguration_ReturnsConfigurationError() + { + var model = new OrganizationIntegrationRequestModel + { + Type = IntegrationType.Webhook, + Configuration = "something" + }; + + var results = model.Validate(new ValidationContext(model)).ToList(); + + Assert.Single(results); + Assert.Contains(nameof(model.Configuration), results[0].MemberNames); + Assert.Contains("must not include configuration", results[0].ErrorMessage); + } + + [Fact] + public void Validate_UnknownIntegrationType_ReturnsUnrecognizedError() + { + var model = new OrganizationIntegrationRequestModel + { + Type = (IntegrationType)999, + Configuration = null + }; + + var results = model.Validate(new ValidationContext(model)).ToList(); + + Assert.Single(results); + Assert.Contains(nameof(model.Type), results[0].MemberNames); + Assert.Contains("not recognized", results[0].ErrorMessage); + } +} diff --git a/test/Core.Test/AdminConsole/Services/SlackEventHandlerTests.cs b/test/Core.Test/AdminConsole/Services/SlackEventHandlerTests.cs new file mode 100644 index 0000000000..798ba219eb --- /dev/null +++ b/test/Core.Test/AdminConsole/Services/SlackEventHandlerTests.cs @@ -0,0 +1,181 @@ +using System.Text.Json; +using Bit.Core.Enums; +using Bit.Core.Models.Data; +using Bit.Core.Models.Data.Organizations; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Bit.Test.Common.Helpers; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.Services; + +[SutProviderCustomize] +public class SlackEventHandlerTests +{ + private readonly IOrganizationIntegrationConfigurationRepository _repository = Substitute.For(); + private readonly ISlackService _slackService = Substitute.For(); + private readonly string _channelId = "C12345"; + private readonly string _channelId2 = "C67890"; + private readonly string _token = "xoxb-test-token"; + private readonly string _token2 = "xoxb-another-test-token"; + + private SutProvider GetSutProvider( + List integrationConfigurations) + { + _repository.GetConfigurationDetailsAsync(Arg.Any(), + IntegrationType.Slack, Arg.Any()) + .Returns(integrationConfigurations); + + return new SutProvider() + .SetDependency(_repository) + .SetDependency(_slackService) + .Create(); + } + + private List NoConfigurations() + { + return []; + } + + private List OneConfiguration() + { + var config = Substitute.For(); + config.Configuration = JsonSerializer.Serialize(new { token = _token }); + config.IntegrationConfiguration = JsonSerializer.Serialize(new { channelId = _channelId }); + config.Template = "Date: #Date#, Type: #Type#, UserId: #UserId#"; + + return [config]; + } + + private List TwoConfigurations() + { + var config = Substitute.For(); + config.Configuration = JsonSerializer.Serialize(new { token = _token }); + config.IntegrationConfiguration = JsonSerializer.Serialize(new { channelId = _channelId }); + config.Template = "Date: #Date#, Type: #Type#, UserId: #UserId#"; + var config2 = Substitute.For(); + config2.Configuration = JsonSerializer.Serialize(new { token = _token2 }); + config2.IntegrationConfiguration = JsonSerializer.Serialize(new { channelId = _channelId2 }); + config2.Template = "Date: #Date#, Type: #Type#, UserId: #UserId#"; + + return [config, config2]; + } + + private List WrongConfiguration() + { + var config = Substitute.For(); + config.Configuration = JsonSerializer.Serialize(new { }); + config.IntegrationConfiguration = JsonSerializer.Serialize(new { }); + config.Template = "Date: #Date#, Type: #Type#, UserId: #UserId#"; + + return [config]; + } + + [Theory, BitAutoData] + public async Task HandleEventAsync_NoConfigurations_DoesNothing(EventMessage eventMessage) + { + var sutProvider = GetSutProvider(NoConfigurations()); + + await sutProvider.Sut.HandleEventAsync(eventMessage); + sutProvider.GetDependency().DidNotReceiveWithAnyArgs(); + } + + [Theory, BitAutoData] + public async Task HandleEventAsync_OneConfiguration_SendsEventViaSlackService(EventMessage eventMessage) + { + var sutProvider = GetSutProvider(OneConfiguration()); + + await sutProvider.Sut.HandleEventAsync(eventMessage); + sutProvider.GetDependency().Received(1).SendSlackMessageByChannelIdAsync( + Arg.Is(AssertHelper.AssertPropertyEqual(_token)), + Arg.Is(AssertHelper.AssertPropertyEqual( + $"Date: {eventMessage.Date}, Type: {eventMessage.Type}, UserId: {eventMessage.UserId}")), + Arg.Is(AssertHelper.AssertPropertyEqual(_channelId)) + ); + } + + [Theory, BitAutoData] + public async Task HandleEventAsync_TwoConfigurations_SendsMultipleEvents(EventMessage eventMessage) + { + var sutProvider = GetSutProvider(TwoConfigurations()); + + await sutProvider.Sut.HandleEventAsync(eventMessage); + sutProvider.GetDependency().Received(1).SendSlackMessageByChannelIdAsync( + Arg.Is(AssertHelper.AssertPropertyEqual(_token)), + Arg.Is(AssertHelper.AssertPropertyEqual( + $"Date: {eventMessage.Date}, Type: {eventMessage.Type}, UserId: {eventMessage.UserId}")), + Arg.Is(AssertHelper.AssertPropertyEqual(_channelId)) + ); + sutProvider.GetDependency().Received(1).SendSlackMessageByChannelIdAsync( + Arg.Is(AssertHelper.AssertPropertyEqual(_token2)), + Arg.Is(AssertHelper.AssertPropertyEqual( + $"Date: {eventMessage.Date}, Type: {eventMessage.Type}, UserId: {eventMessage.UserId}")), + Arg.Is(AssertHelper.AssertPropertyEqual(_channelId2)) + ); + } + + [Theory, BitAutoData] + public async Task HandleEventAsync_WrongConfiguration_DoesNothing(EventMessage eventMessage) + { + var sutProvider = GetSutProvider(WrongConfiguration()); + + await sutProvider.Sut.HandleEventAsync(eventMessage); + sutProvider.GetDependency().DidNotReceiveWithAnyArgs(); + } + + [Theory, BitAutoData] + public async Task HandleManyEventsAsync_OneConfiguration_SendsEventsViaSlackService(List eventMessages) + { + var sutProvider = GetSutProvider(OneConfiguration()); + + await sutProvider.Sut.HandleManyEventsAsync(eventMessages); + + var received = sutProvider.GetDependency().ReceivedCalls(); + using var calls = received.GetEnumerator(); + + Assert.Equal(eventMessages.Count, received.Count()); + + foreach (var eventMessage in eventMessages) + { + Assert.True(calls.MoveNext()); + var arguments = calls.Current.GetArguments(); + Assert.Equal(_token, arguments[0] as string); + Assert.Equal($"Date: {eventMessage.Date}, Type: {eventMessage.Type}, UserId: {eventMessage.UserId}", + arguments[1] as string); + Assert.Equal(_channelId, arguments[2] as string); + } + } + + [Theory, BitAutoData] + public async Task HandleManyEventsAsync_TwoConfigurations_SendsMultipleEvents(List eventMessages) + { + var sutProvider = GetSutProvider(TwoConfigurations()); + + await sutProvider.Sut.HandleManyEventsAsync(eventMessages); + + var received = sutProvider.GetDependency().ReceivedCalls(); + using var calls = received.GetEnumerator(); + + Assert.Equal(eventMessages.Count * 2, received.Count()); + + foreach (var eventMessage in eventMessages) + { + Assert.True(calls.MoveNext()); + var arguments = calls.Current.GetArguments(); + Assert.Equal(_token, arguments[0] as string); + Assert.Equal($"Date: {eventMessage.Date}, Type: {eventMessage.Type}, UserId: {eventMessage.UserId}", + arguments[1] as string); + Assert.Equal(_channelId, arguments[2] as string); + + Assert.True(calls.MoveNext()); + var arguments2 = calls.Current.GetArguments(); + Assert.Equal(_token2, arguments2[0] as string); + Assert.Equal($"Date: {eventMessage.Date}, Type: {eventMessage.Type}, UserId: {eventMessage.UserId}", + arguments2[1] as string); + Assert.Equal(_channelId2, arguments2[2] as string); + } + } +} diff --git a/test/Core.Test/AdminConsole/Services/SlackServiceTests.cs b/test/Core.Test/AdminConsole/Services/SlackServiceTests.cs new file mode 100644 index 0000000000..93c0aa8dd4 --- /dev/null +++ b/test/Core.Test/AdminConsole/Services/SlackServiceTests.cs @@ -0,0 +1,344 @@ +using System.Net; +using System.Text.Json; +using Bit.Core.Services; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Bit.Test.Common.MockedHttpClient; +using NSubstitute; +using Xunit; +using GlobalSettings = Bit.Core.Settings.GlobalSettings; + +namespace Bit.Core.Test.Services; + +[SutProviderCustomize] +public class SlackServiceTests +{ + private readonly MockedHttpMessageHandler _handler; + private readonly HttpClient _httpClient; + private const string _token = "xoxb-test-token"; + + public SlackServiceTests() + { + _handler = new MockedHttpMessageHandler(); + _httpClient = _handler.ToHttpClient(); + } + + private SutProvider GetSutProvider() + { + var clientFactory = Substitute.For(); + clientFactory.CreateClient(SlackService.HttpClientName).Returns(_httpClient); + + var globalSettings = Substitute.For(); + globalSettings.Slack.ApiBaseUrl.Returns("https://slack.com/api"); + + return new SutProvider() + .SetDependency(clientFactory) + .SetDependency(globalSettings) + .Create(); + } + + [Fact] + public async Task GetChannelIdsAsync_ReturnsCorrectChannelIds() + { + var response = JsonSerializer.Serialize( + new + { + ok = true, + channels = + new[] { + new { id = "C12345", name = "general" }, + new { id = "C67890", name = "random" } + }, + response_metadata = new { next_cursor = "" } + } + ); + _handler.When(HttpMethod.Get) + .RespondWith(HttpStatusCode.OK) + .WithContent(new StringContent(response)); + + var sutProvider = GetSutProvider(); + var channelNames = new List { "general", "random" }; + var result = await sutProvider.Sut.GetChannelIdsAsync(_token, channelNames); + + Assert.Equal(2, result.Count); + Assert.Contains("C12345", result); + Assert.Contains("C67890", result); + } + + [Fact] + public async Task GetChannelIdsAsync_WithPagination_ReturnsCorrectChannelIds() + { + var firstPageResponse = JsonSerializer.Serialize( + new + { + ok = true, + channels = new[] { new { id = "C12345", name = "general" } }, + response_metadata = new { next_cursor = "next_cursor_value" } + } + ); + var secondPageResponse = JsonSerializer.Serialize( + new + { + ok = true, + channels = new[] { new { id = "C67890", name = "random" } }, + response_metadata = new { next_cursor = "" } + } + ); + + _handler.When("https://slack.com/api/conversations.list?types=public_channel%2cprivate_channel&limit=1000") + .RespondWith(HttpStatusCode.OK) + .WithContent(new StringContent(firstPageResponse)); + _handler.When("https://slack.com/api/conversations.list?types=public_channel%2cprivate_channel&limit=1000&cursor=next_cursor_value") + .RespondWith(HttpStatusCode.OK) + .WithContent(new StringContent(secondPageResponse)); + + var sutProvider = GetSutProvider(); + var channelNames = new List { "general", "random" }; + + var result = await sutProvider.Sut.GetChannelIdsAsync(_token, channelNames); + + Assert.Equal(2, result.Count); + Assert.Contains("C12345", result); + Assert.Contains("C67890", result); + } + + [Fact] + public async Task GetChannelIdsAsync_ApiError_ReturnsEmptyResult() + { + var errorResponse = JsonSerializer.Serialize( + new { ok = false, error = "rate_limited" } + ); + + _handler.When(HttpMethod.Get) + .RespondWith(HttpStatusCode.TooManyRequests) + .WithContent(new StringContent(errorResponse)); + + var sutProvider = GetSutProvider(); + var channelNames = new List { "general", "random" }; + + var result = await sutProvider.Sut.GetChannelIdsAsync(_token, channelNames); + + Assert.Empty(result); + } + + [Fact] + public async Task GetChannelIdsAsync_NoChannelsFound_ReturnsEmptyResult() + { + var emptyResponse = JsonSerializer.Serialize( + new + { + ok = true, + channels = Array.Empty(), + response_metadata = new { next_cursor = "" } + }); + + _handler.When(HttpMethod.Get) + .RespondWith(HttpStatusCode.OK) + .WithContent(new StringContent(emptyResponse)); + + var sutProvider = GetSutProvider(); + var channelNames = new List { "general", "random" }; + var result = await sutProvider.Sut.GetChannelIdsAsync(_token, channelNames); + + Assert.Empty(result); + } + + [Fact] + public async Task GetChannelIdAsync_ReturnsCorrectChannelId() + { + var sutProvider = GetSutProvider(); + var response = new + { + ok = true, + channels = new[] + { + new { id = "C12345", name = "general" }, + new { id = "C67890", name = "random" } + }, + response_metadata = new { next_cursor = "" } + }; + + _handler.When(HttpMethod.Get) + .RespondWith(HttpStatusCode.OK) + .WithContent(new StringContent(JsonSerializer.Serialize(response))); + + var result = await sutProvider.Sut.GetChannelIdAsync(_token, "general"); + + Assert.Equal("C12345", result); + } + + [Fact] + public async Task GetDmChannelByEmailAsync_ReturnsCorrectDmChannelId() + { + var sutProvider = GetSutProvider(); + var email = "user@example.com"; + var userId = "U12345"; + var dmChannelId = "D67890"; + + var userResponse = new + { + ok = true, + user = new { id = userId } + }; + + var dmResponse = new + { + ok = true, + channel = new { id = dmChannelId } + }; + + _handler.When($"https://slack.com/api/users.lookupByEmail?email={email}") + .RespondWith(HttpStatusCode.OK) + .WithContent(new StringContent(JsonSerializer.Serialize(userResponse))); + + _handler.When("https://slack.com/api/conversations.open") + .RespondWith(HttpStatusCode.OK) + .WithContent(new StringContent(JsonSerializer.Serialize(dmResponse))); + + var result = await sutProvider.Sut.GetDmChannelByEmailAsync(_token, email); + + Assert.Equal(dmChannelId, result); + } + + [Fact] + public async Task GetDmChannelByEmailAsync_ApiErrorDmResponse_ReturnsEmptyString() + { + var sutProvider = GetSutProvider(); + var email = "user@example.com"; + var userId = "U12345"; + + var userResponse = new + { + ok = true, + user = new { id = userId } + }; + + var dmResponse = new + { + ok = false, + error = "An error occured" + }; + + _handler.When($"https://slack.com/api/users.lookupByEmail?email={email}") + .RespondWith(HttpStatusCode.OK) + .WithContent(new StringContent(JsonSerializer.Serialize(userResponse))); + + _handler.When("https://slack.com/api/conversations.open") + .RespondWith(HttpStatusCode.OK) + .WithContent(new StringContent(JsonSerializer.Serialize(dmResponse))); + + var result = await sutProvider.Sut.GetDmChannelByEmailAsync(_token, email); + + Assert.Equal(string.Empty, result); + } + + [Fact] + public async Task GetDmChannelByEmailAsync_ApiErrorUserResponse_ReturnsEmptyString() + { + var sutProvider = GetSutProvider(); + var email = "user@example.com"; + + var userResponse = new + { + ok = false, + error = "An error occured" + }; + + _handler.When($"https://slack.com/api/users.lookupByEmail?email={email}") + .RespondWith(HttpStatusCode.OK) + .WithContent(new StringContent(JsonSerializer.Serialize(userResponse))); + + var result = await sutProvider.Sut.GetDmChannelByEmailAsync(_token, email); + + Assert.Equal(string.Empty, result); + } + + [Fact] + public void GetRedirectUrl_ReturnsCorrectUrl() + { + var sutProvider = GetSutProvider(); + var ClientId = sutProvider.GetDependency().Slack.ClientId; + var Scopes = sutProvider.GetDependency().Slack.Scopes; + var redirectUrl = "https://example.com/callback"; + var expectedUrl = $"https://slack.com/oauth/v2/authorize?client_id={ClientId}&scope={Scopes}&redirect_uri={redirectUrl}"; + var result = sutProvider.Sut.GetRedirectUrl(redirectUrl); + Assert.Equal(expectedUrl, result); + } + + [Fact] + public async Task ObtainTokenViaOAuth_ReturnsAccessToken_WhenSuccessful() + { + var sutProvider = GetSutProvider(); + var jsonResponse = JsonSerializer.Serialize(new + { + ok = true, + access_token = "test-access-token" + }); + + _handler.When("https://slack.com/api/oauth.v2.access") + .RespondWith(HttpStatusCode.OK) + .WithContent(new StringContent(jsonResponse)); + + var result = await sutProvider.Sut.ObtainTokenViaOAuth("test-code", "https://example.com/callback"); + + Assert.Equal("test-access-token", result); + } + + [Fact] + public async Task ObtainTokenViaOAuth_ReturnsEmptyString_WhenErrorResponse() + { + var sutProvider = GetSutProvider(); + var jsonResponse = JsonSerializer.Serialize(new + { + ok = false, + error = "invalid_code" + }); + + _handler.When("https://slack.com/api/oauth.v2.access") + .RespondWith(HttpStatusCode.OK) + .WithContent(new StringContent(jsonResponse)); + + var result = await sutProvider.Sut.ObtainTokenViaOAuth("test-code", "https://example.com/callback"); + + Assert.Equal(string.Empty, result); + } + + [Fact] + public async Task ObtainTokenViaOAuth_ReturnsEmptyString_WhenHttpCallFails() + { + var sutProvider = GetSutProvider(); + _handler.When("https://slack.com/api/oauth.v2.access") + .RespondWith(HttpStatusCode.InternalServerError) + .WithContent(new StringContent(string.Empty)); + + var result = await sutProvider.Sut.ObtainTokenViaOAuth("test-code", "https://example.com/callback"); + + Assert.Equal(string.Empty, result); + } + + [Fact] + public async Task SendSlackMessageByChannelId_Sends_Correct_Message() + { + var sutProvider = GetSutProvider(); + var channelId = "C12345"; + var message = "Hello, Slack!"; + + _handler.When(HttpMethod.Post) + .RespondWith(HttpStatusCode.OK) + .WithContent(new StringContent(string.Empty)); + + await sutProvider.Sut.SendSlackMessageByChannelIdAsync(_token, message, channelId); + + Assert.Single(_handler.CapturedRequests); + var request = _handler.CapturedRequests[0]; + Assert.NotNull(request); + Assert.Equal(HttpMethod.Post, request.Method); + Assert.NotNull(request.Headers.Authorization); + Assert.Equal($"Bearer {_token}", request.Headers.Authorization.ToString()); + Assert.NotNull(request.Content); + var returned = (await request.Content.ReadAsStringAsync()); + var json = JsonDocument.Parse(returned); + Assert.Equal(message, json.RootElement.GetProperty("text").GetString() ?? string.Empty); + Assert.Equal(channelId, json.RootElement.GetProperty("channel").GetString() ?? string.Empty); + } +} diff --git a/test/Core.Test/AdminConsole/Services/WebhookEventHandlerTests.cs b/test/Core.Test/AdminConsole/Services/WebhookEventHandlerTests.cs index 6c7d7178c1..c426f8eaad 100644 --- a/test/Core.Test/AdminConsole/Services/WebhookEventHandlerTests.cs +++ b/test/Core.Test/AdminConsole/Services/WebhookEventHandlerTests.cs @@ -1,6 +1,10 @@ using System.Net; using System.Net.Http.Json; +using System.Text.Json; +using Bit.Core.Enums; using Bit.Core.Models.Data; +using Bit.Core.Models.Data.Organizations; +using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; @@ -8,7 +12,6 @@ using Bit.Test.Common.Helpers; using Bit.Test.Common.MockedHttpClient; using NSubstitute; using Xunit; -using GlobalSettings = Bit.Core.Settings.GlobalSettings; namespace Bit.Core.Test.Services; @@ -16,9 +19,18 @@ namespace Bit.Core.Test.Services; public class WebhookEventHandlerTests { private readonly MockedHttpMessageHandler _handler; - private HttpClient _httpClient; + private readonly HttpClient _httpClient; + private const string _template = + """ + { + "Date": "#Date#", + "Type": "#Type#", + "UserId": "#UserId#" + } + """; private const string _webhookUrl = "http://localhost/test/event"; + private const string _webhookUrl2 = "http://localhost/another/event"; public WebhookEventHandlerTests() { @@ -29,57 +41,195 @@ public class WebhookEventHandlerTests _httpClient = _handler.ToHttpClient(); } - public SutProvider GetSutProvider() + private SutProvider GetSutProvider( + List configurations) { var clientFactory = Substitute.For(); clientFactory.CreateClient(WebhookEventHandler.HttpClientName).Returns(_httpClient); - var globalSettings = new GlobalSettings(); - globalSettings.EventLogging.WebhookUrl = _webhookUrl; + var repository = Substitute.For(); + repository.GetConfigurationDetailsAsync(Arg.Any(), + IntegrationType.Webhook, Arg.Any()).Returns(configurations); return new SutProvider() - .SetDependency(globalSettings) + .SetDependency(repository) .SetDependency(clientFactory) .Create(); } - [Theory, BitAutoData] - public async Task HandleEventAsync_PostsEventToUrl(EventMessage eventMessage) + private static List NoConfigurations() { - var sutProvider = GetSutProvider(); + return []; + } + + private static List OneConfiguration() + { + var config = Substitute.For(); + config.Configuration = null; + config.IntegrationConfiguration = JsonSerializer.Serialize(new { url = _webhookUrl }); + config.Template = _template; + + return [config]; + } + + private static List TwoConfigurations() + { + var config = Substitute.For(); + config.Configuration = null; + config.IntegrationConfiguration = JsonSerializer.Serialize(new { url = _webhookUrl }); + config.Template = _template; + var config2 = Substitute.For(); + config2.Configuration = null; + config2.IntegrationConfiguration = JsonSerializer.Serialize(new { url = _webhookUrl2 }); + config2.Template = _template; + + return [config, config2]; + } + + private static List WrongConfiguration() + { + var config = Substitute.For(); + config.Configuration = null; + config.IntegrationConfiguration = JsonSerializer.Serialize(new { error = string.Empty }); + config.Template = _template; + + return [config]; + } + + [Theory, BitAutoData] + public async Task HandleEventAsync_NoConfigurations_DoesNothing(EventMessage eventMessage) + { + var sutProvider = GetSutProvider(NoConfigurations()); await sutProvider.Sut.HandleEventAsync(eventMessage); sutProvider.GetDependency().Received(1).CreateClient( - Arg.Is(AssertHelper.AssertPropertyEqual(WebhookEventHandler.HttpClientName)) + Arg.Is(AssertHelper.AssertPropertyEqual(WebhookEventHandler.HttpClientName)) ); - Assert.Single(_handler.CapturedRequests); - var request = _handler.CapturedRequests[0]; - Assert.NotNull(request); - var returned = await request.Content.ReadFromJsonAsync(); - - Assert.Equal(HttpMethod.Post, request.Method); - Assert.Equal(_webhookUrl, request.RequestUri.ToString()); - AssertHelper.AssertPropertyEqual(eventMessage, returned, new[] { "IdempotencyId" }); + Assert.Empty(_handler.CapturedRequests); } [Theory, BitAutoData] - public async Task HandleEventManyAsync_PostsEventsToUrl(IEnumerable eventMessages) + public async Task HandleEventAsync_OneConfiguration_PostsEventToUrl(EventMessage eventMessage) { - var sutProvider = GetSutProvider(); + var sutProvider = GetSutProvider(OneConfiguration()); - await sutProvider.Sut.HandleManyEventsAsync(eventMessages); + await sutProvider.Sut.HandleEventAsync(eventMessage); sutProvider.GetDependency().Received(1).CreateClient( - Arg.Is(AssertHelper.AssertPropertyEqual(WebhookEventHandler.HttpClientName)) + Arg.Is(AssertHelper.AssertPropertyEqual(WebhookEventHandler.HttpClientName)) ); Assert.Single(_handler.CapturedRequests); var request = _handler.CapturedRequests[0]; Assert.NotNull(request); - var returned = request.Content.ReadFromJsonAsAsyncEnumerable(); + var returned = await request.Content.ReadFromJsonAsync(); + var expected = MockEvent.From(eventMessage); Assert.Equal(HttpMethod.Post, request.Method); Assert.Equal(_webhookUrl, request.RequestUri.ToString()); - AssertHelper.AssertPropertyEqual(eventMessages, returned, new[] { "IdempotencyId" }); + AssertHelper.AssertPropertyEqual(expected, returned); + } + + [Theory, BitAutoData] + public async Task HandleEventAsync_WrongConfigurations_DoesNothing(EventMessage eventMessage) + { + var sutProvider = GetSutProvider(WrongConfiguration()); + + await sutProvider.Sut.HandleEventAsync(eventMessage); + sutProvider.GetDependency().Received(1).CreateClient( + Arg.Is(AssertHelper.AssertPropertyEqual(WebhookEventHandler.HttpClientName)) + ); + + Assert.Empty(_handler.CapturedRequests); + } + + [Theory, BitAutoData] + public async Task HandleManyEventsAsync_NoConfigurations_DoesNothing(List eventMessages) + { + var sutProvider = GetSutProvider(NoConfigurations()); + + await sutProvider.Sut.HandleManyEventsAsync(eventMessages); + sutProvider.GetDependency().Received(1).CreateClient( + Arg.Is(AssertHelper.AssertPropertyEqual(WebhookEventHandler.HttpClientName)) + ); + + Assert.Empty(_handler.CapturedRequests); + } + + + [Theory, BitAutoData] + public async Task HandleManyEventsAsync_OneConfiguration_PostsEventsToUrl(List eventMessages) + { + var sutProvider = GetSutProvider(OneConfiguration()); + + await sutProvider.Sut.HandleManyEventsAsync(eventMessages); + sutProvider.GetDependency().Received(1).CreateClient( + Arg.Is(AssertHelper.AssertPropertyEqual(WebhookEventHandler.HttpClientName)) + ); + + Assert.Equal(eventMessages.Count, _handler.CapturedRequests.Count); + var index = 0; + foreach (var request in _handler.CapturedRequests) + { + Assert.NotNull(request); + var returned = await request.Content.ReadFromJsonAsync(); + var expected = MockEvent.From(eventMessages[index]); + + Assert.Equal(HttpMethod.Post, request.Method); + Assert.Equal(_webhookUrl, request.RequestUri.ToString()); + AssertHelper.AssertPropertyEqual(expected, returned); + index++; + } + } + + [Theory, BitAutoData] + public async Task HandleManyEventsAsync_TwoConfigurations_PostsEventsToMultipleUrls(List eventMessages) + { + var sutProvider = GetSutProvider(TwoConfigurations()); + + await sutProvider.Sut.HandleManyEventsAsync(eventMessages); + sutProvider.GetDependency().Received(1).CreateClient( + Arg.Is(AssertHelper.AssertPropertyEqual(WebhookEventHandler.HttpClientName)) + ); + + using var capturedRequests = _handler.CapturedRequests.GetEnumerator(); + Assert.Equal(eventMessages.Count * 2, _handler.CapturedRequests.Count); + + foreach (var eventMessage in eventMessages) + { + var expected = MockEvent.From(eventMessage); + + Assert.True(capturedRequests.MoveNext()); + var request = capturedRequests.Current; + Assert.NotNull(request); + Assert.Equal(HttpMethod.Post, request.Method); + Assert.Equal(_webhookUrl, request.RequestUri.ToString()); + var returned = await request.Content.ReadFromJsonAsync(); + AssertHelper.AssertPropertyEqual(expected, returned); + + Assert.True(capturedRequests.MoveNext()); + request = capturedRequests.Current; + Assert.NotNull(request); + Assert.Equal(HttpMethod.Post, request.Method); + Assert.Equal(_webhookUrl2, request.RequestUri.ToString()); + returned = await request.Content.ReadFromJsonAsync(); + AssertHelper.AssertPropertyEqual(expected, returned); + } + } +} + +public class MockEvent(string date, string type, string userId) +{ + public string Date { get; set; } = date; + public string Type { get; set; } = type; + public string UserId { get; set; } = userId; + + public static MockEvent From(EventMessage eventMessage) + { + return new MockEvent( + eventMessage.Date.ToString(), + eventMessage.Type.ToString(), + eventMessage.UserId.ToString() + ); } } diff --git a/test/Core.Test/AdminConsole/Utilities/IntegrationTemplateProcessorTests.cs b/test/Core.Test/AdminConsole/Utilities/IntegrationTemplateProcessorTests.cs new file mode 100644 index 0000000000..9ab3b592cb --- /dev/null +++ b/test/Core.Test/AdminConsole/Utilities/IntegrationTemplateProcessorTests.cs @@ -0,0 +1,92 @@ +using Bit.Core.AdminConsole.Utilities; +using Bit.Core.Models.Data; +using Bit.Test.Common.AutoFixture.Attributes; +using Xunit; + +namespace Bit.Core.Test.AdminConsole.Utilities; + +public class IntegrationTemplateProcessorTests +{ + [Theory, BitAutoData] + public void ReplaceTokens_ReplacesSingleToken(EventMessage eventMessage) + { + var template = "Event #Type# occurred."; + var expected = $"Event {eventMessage.Type} occurred."; + var result = IntegrationTemplateProcessor.ReplaceTokens(template, eventMessage); + + Assert.Equal(expected, result); + } + + [Theory, BitAutoData] + public void ReplaceTokens_ReplacesMultipleTokens(EventMessage eventMessage) + { + var template = "Event #Type#, User (id: #UserId#)."; + var expected = $"Event {eventMessage.Type}, User (id: {eventMessage.UserId})."; + var result = IntegrationTemplateProcessor.ReplaceTokens(template, eventMessage); + + Assert.Equal(expected, result); + } + + [Theory, BitAutoData] + public void ReplaceTokens_LeavesUnknownTokensUnchanged(EventMessage eventMessage) + { + var template = "Event #Type#, User (id: #UserId#), Details: #UnknownKey#."; + var expected = $"Event {eventMessage.Type}, User (id: {eventMessage.UserId}), Details: #UnknownKey#."; + var result = IntegrationTemplateProcessor.ReplaceTokens(template, eventMessage); + + Assert.Equal(expected, result); + } + + [Theory, BitAutoData] + public void ReplaceTokens_WithNullProperty_LeavesTokenUnchanged(EventMessage eventMessage) + { + eventMessage.UserId = null; + + var template = "Event #Type#, User (id: #UserId#)."; + var expected = $"Event {eventMessage.Type}, User (id: #UserId#)."; + var result = IntegrationTemplateProcessor.ReplaceTokens(template, eventMessage); + + Assert.Equal(expected, result); + } + + [Theory, BitAutoData] + public void ReplaceTokens_TokensWithNonmatchingCase_LeavesTokensUnchanged(EventMessage eventMessage) + { + var template = "Event #type#, User (id: #UserId#)."; + var expected = $"Event #type#, User (id: {eventMessage.UserId})."; + var result = IntegrationTemplateProcessor.ReplaceTokens(template, eventMessage); + + Assert.Equal(expected, result); + } + + [Theory, BitAutoData] + public void ReplaceTokens_NoTokensPresent_ReturnsOriginalString(EventMessage eventMessage) + { + var template = "System is running normally."; + var expected = "System is running normally."; + var result = IntegrationTemplateProcessor.ReplaceTokens(template, eventMessage); + + Assert.Equal(expected, result); + } + + [Theory, BitAutoData] + public void ReplaceTokens_TemplateIsEmpty_ReturnsOriginalString(EventMessage eventMessage) + { + var emptyTemplate = ""; + var expectedEmpty = ""; + + Assert.Equal(expectedEmpty, IntegrationTemplateProcessor.ReplaceTokens(emptyTemplate, eventMessage)); + Assert.Null(IntegrationTemplateProcessor.ReplaceTokens(null, eventMessage)); + } + + [Fact] + public void ReplaceTokens_DataObjectIsNull_ReturnsOriginalString() + { + var template = "Event #Type#, User (id: #UserId#)."; + var expected = "Event #Type#, User (id: #UserId#)."; + + var result = IntegrationTemplateProcessor.ReplaceTokens(template, null); + + Assert.Equal(expected, result); + } +}