diff --git a/dev/servicebusemulator_config.json b/dev/servicebusemulator_config.json index 294efc1897..bb50c0b1ee 100644 --- a/dev/servicebusemulator_config.json +++ b/dev/servicebusemulator_config.json @@ -3,22 +3,6 @@ "Namespaces": [ { "Name": "sbemulatorns", - "Queues": [ - { - "Name": "queue.1", - "Properties": { - "DeadLetteringOnMessageExpiration": false, - "DefaultMessageTimeToLive": "PT1H", - "DuplicateDetectionHistoryTimeWindow": "PT20S", - "ForwardDeadLetteredMessagesTo": "", - "ForwardTo": "", - "LockDuration": "PT1M", - "MaxDeliveryCount": 3, - "RequiresDuplicateDetection": false, - "RequiresSession": false - } - } - ], "Topics": [ { "Name": "event-logging", @@ -37,6 +21,9 @@ }, { "Name": "events-datadog-subscription" + }, + { + "Name": "events-teams-subscription" } ] }, @@ -98,6 +85,20 @@ } } ] + }, + { + "Name": "integration-teams-subscription", + "Rules": [ + { + "Name": "teams-integration-filter", + "Properties": { + "FilterType": "Correlation", + "CorrelationFilter": { + "Label": "teams" + } + } + } + ] } ] } diff --git a/src/Api/AdminConsole/Controllers/SlackIntegrationController.cs b/src/Api/AdminConsole/Controllers/SlackIntegrationController.cs index c8ff4f9f7c..08635878de 100644 --- a/src/Api/AdminConsole/Controllers/SlackIntegrationController.cs +++ b/src/Api/AdminConsole/Controllers/SlackIntegrationController.cs @@ -32,7 +32,7 @@ public class SlackIntegrationController( } string? callbackUrl = Url.RouteUrl( - routeName: nameof(CreateAsync), + routeName: "SlackIntegration_Create", values: null, protocol: currentContext.HttpContext.Request.Scheme, host: currentContext.HttpContext.Request.Host.ToUriComponent() @@ -76,7 +76,7 @@ public class SlackIntegrationController( return Redirect(redirectUrl); } - [HttpGet("integrations/slack/create", Name = nameof(CreateAsync))] + [HttpGet("integrations/slack/create", Name = "SlackIntegration_Create")] [AllowAnonymous] public async Task CreateAsync([FromQuery] string code, [FromQuery] string state) { @@ -103,7 +103,7 @@ public class SlackIntegrationController( // Fetch token from Slack and store to DB string? callbackUrl = Url.RouteUrl( - routeName: nameof(CreateAsync), + routeName: "SlackIntegration_Create", values: null, protocol: currentContext.HttpContext.Request.Scheme, host: currentContext.HttpContext.Request.Host.ToUriComponent() diff --git a/src/Api/AdminConsole/Controllers/TeamsIntegrationController.cs b/src/Api/AdminConsole/Controllers/TeamsIntegrationController.cs new file mode 100644 index 0000000000..8cafb6b2cf --- /dev/null +++ b/src/Api/AdminConsole/Controllers/TeamsIntegrationController.cs @@ -0,0 +1,147 @@ +using System.Text.Json; +using Bit.Api.AdminConsole.Models.Response.Organizations; +using Bit.Core; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Models.Data.EventIntegrations; +using Bit.Core.Context; +using Bit.Core.Enums; +using Bit.Core.Exceptions; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Core.Utilities; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Bot.Builder; +using Microsoft.Bot.Builder.Integration.AspNet.Core; + +namespace Bit.Api.AdminConsole.Controllers; + +[RequireFeature(FeatureFlagKeys.EventBasedOrganizationIntegrations)] +[Route("organizations")] +[Authorize("Application")] +public class TeamsIntegrationController( + ICurrentContext currentContext, + IOrganizationIntegrationRepository integrationRepository, + IBot bot, + IBotFrameworkHttpAdapter adapter, + ITeamsService teamsService, + TimeProvider timeProvider) : Controller +{ + [HttpGet("{organizationId:guid}/integrations/teams/redirect")] + public async Task RedirectAsync(Guid organizationId) + { + if (!await currentContext.OrganizationOwner(organizationId)) + { + throw new NotFoundException(); + } + + var callbackUrl = Url.RouteUrl( + routeName: "TeamsIntegration_Create", + values: null, + protocol: currentContext.HttpContext.Request.Scheme, + host: currentContext.HttpContext.Request.Host.ToUriComponent() + ); + if (string.IsNullOrEmpty(callbackUrl)) + { + throw new BadRequestException("Unable to build callback Url"); + } + + var integrations = await integrationRepository.GetManyByOrganizationAsync(organizationId); + var integration = integrations.FirstOrDefault(i => i.Type == IntegrationType.Teams); + + if (integration is null) + { + // No teams integration exists, create Initiated version + integration = await integrationRepository.CreateAsync(new OrganizationIntegration + { + OrganizationId = organizationId, + Type = IntegrationType.Teams, + Configuration = null, + }); + } + else if (integration.Configuration is not null) + { + // A Completed (fully configured) Teams integration already exists, throw to prevent overriding + throw new BadRequestException("There already exists a Teams integration for this organization"); + + } // An Initiated teams integration exits, re-use it and kick off a new OAuth flow + + var state = IntegrationOAuthState.FromIntegration(integration, timeProvider); + var redirectUrl = teamsService.GetRedirectUrl( + callbackUrl: callbackUrl, + state: state.ToString() + ); + + if (string.IsNullOrEmpty(redirectUrl)) + { + throw new NotFoundException(); + } + + return Redirect(redirectUrl); + } + + [HttpGet("integrations/teams/create", Name = "TeamsIntegration_Create")] + [AllowAnonymous] + public async Task CreateAsync([FromQuery] string code, [FromQuery] string state) + { + var oAuthState = IntegrationOAuthState.FromString(state: state, timeProvider: timeProvider); + if (oAuthState is null) + { + throw new NotFoundException(); + } + + // Fetch existing Initiated record + var integration = await integrationRepository.GetByIdAsync(oAuthState.IntegrationId); + if (integration is null || + integration.Type != IntegrationType.Teams || + integration.Configuration is not null) + { + throw new NotFoundException(); + } + + // Verify Organization matches hash + if (!oAuthState.ValidateOrg(integration.OrganizationId)) + { + throw new NotFoundException(); + } + + var callbackUrl = Url.RouteUrl( + routeName: "TeamsIntegration_Create", + values: null, + protocol: currentContext.HttpContext.Request.Scheme, + host: currentContext.HttpContext.Request.Host.ToUriComponent() + ); + if (string.IsNullOrEmpty(callbackUrl)) + { + throw new BadRequestException("Unable to build callback Url"); + } + + var token = await teamsService.ObtainTokenViaOAuth(code, callbackUrl); + if (string.IsNullOrEmpty(token)) + { + throw new BadRequestException("Invalid response from Teams."); + } + + var teams = await teamsService.GetJoinedTeamsAsync(token); + + if (!teams.Any()) + { + throw new BadRequestException("No teams were found."); + } + + var teamsIntegration = new TeamsIntegration(TenantId: teams[0].TenantId, Teams: teams); + integration.Configuration = JsonSerializer.Serialize(teamsIntegration); + await integrationRepository.UpsertAsync(integration); + + var location = $"/organizations/{integration.OrganizationId}/integrations/{integration.Id}"; + return Created(location, new OrganizationIntegrationResponseModel(integration)); + } + + [Route("integrations/teams/incoming")] + [AllowAnonymous] + [HttpPost] + public async Task IncomingPostAsync() + { + await adapter.ProcessAsync(Request, Response, bot); + } +} diff --git a/src/Api/AdminConsole/Models/Request/Organizations/OrganizationIntegrationConfigurationRequestModel.cs b/src/Api/AdminConsole/Models/Request/Organizations/OrganizationIntegrationConfigurationRequestModel.cs index 7d1efe2315..8581c4ae1f 100644 --- a/src/Api/AdminConsole/Models/Request/Organizations/OrganizationIntegrationConfigurationRequestModel.cs +++ b/src/Api/AdminConsole/Models/Request/Organizations/OrganizationIntegrationConfigurationRequestModel.cs @@ -38,6 +38,10 @@ public class OrganizationIntegrationConfigurationRequestModel return !string.IsNullOrWhiteSpace(Template) && Configuration is null && IsFiltersValid(); + case IntegrationType.Teams: + return !string.IsNullOrWhiteSpace(Template) && + Configuration is null && + IsFiltersValid(); default: return false; diff --git a/src/Api/AdminConsole/Models/Request/Organizations/OrgnizationIntegrationRequestModel.cs b/src/Api/AdminConsole/Models/Request/Organizations/OrgnizationIntegrationRequestModel.cs index 92d65ab8fe..668afe70bf 100644 --- a/src/Api/AdminConsole/Models/Request/Organizations/OrgnizationIntegrationRequestModel.cs +++ b/src/Api/AdminConsole/Models/Request/Organizations/OrgnizationIntegrationRequestModel.cs @@ -35,7 +35,7 @@ public class OrganizationIntegrationRequestModel : IValidatableObject case IntegrationType.CloudBillingSync or IntegrationType.Scim: yield return new ValidationResult($"{nameof(Type)} integrations are not yet supported.", [nameof(Type)]); break; - case IntegrationType.Slack: + case IntegrationType.Slack or IntegrationType.Teams: yield return new ValidationResult($"{nameof(Type)} integrations cannot be created directly.", [nameof(Type)]); break; case IntegrationType.Webhook: diff --git a/src/Api/AdminConsole/Models/Response/Organizations/OrganizationIntegrationResponseModel.cs b/src/Api/AdminConsole/Models/Response/Organizations/OrganizationIntegrationResponseModel.cs index 5368f78e39..0c31e07bef 100644 --- a/src/Api/AdminConsole/Models/Response/Organizations/OrganizationIntegrationResponseModel.cs +++ b/src/Api/AdminConsole/Models/Response/Organizations/OrganizationIntegrationResponseModel.cs @@ -1,4 +1,6 @@ -using Bit.Core.AdminConsole.Entities; +using System.Text.Json; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Models.Data.EventIntegrations; using Bit.Core.Enums; using Bit.Core.Models.Api; @@ -35,6 +37,16 @@ public class OrganizationIntegrationResponseModel : ResponseModel ? OrganizationIntegrationStatus.Initiated : OrganizationIntegrationStatus.Completed, + // If present and the configuration is null, OAuth has been initiated, and we are + // waiting on the return OAuth call. If Configuration is not null and IsCompleted is true, + // then we've received the app install bot callback, and it's Completed. Otherwise, + // it is In Progress while we await the app install bot callback. + IntegrationType.Teams => string.IsNullOrWhiteSpace(Configuration) + ? OrganizationIntegrationStatus.Initiated + : (JsonSerializer.Deserialize(Configuration)?.IsCompleted ?? false) + ? OrganizationIntegrationStatus.Completed + : OrganizationIntegrationStatus.InProgress, + // HEC and Datadog should only be allowed to be created non-null. // If they are null, they are Invalid IntegrationType.Hec => string.IsNullOrWhiteSpace(Configuration) diff --git a/src/Api/Startup.cs b/src/Api/Startup.cs index cc50a1b362..5d9918d1d4 100644 --- a/src/Api/Startup.cs +++ b/src/Api/Startup.cs @@ -229,8 +229,9 @@ public class Startup services.AddHostedService(); } - // Add SlackService for OAuth API requests - if configured + // Add Slack / Teams Services for OAuth API requests - if configured services.AddSlackService(globalSettings); + services.AddTeamsService(globalSettings); } public void Configure( diff --git a/src/Core/AdminConsole/Enums/IntegrationType.cs b/src/Core/AdminConsole/Enums/IntegrationType.cs index 34edc71fbe..84e4de94e9 100644 --- a/src/Core/AdminConsole/Enums/IntegrationType.cs +++ b/src/Core/AdminConsole/Enums/IntegrationType.cs @@ -7,7 +7,8 @@ public enum IntegrationType : int Slack = 3, Webhook = 4, Hec = 5, - Datadog = 6 + Datadog = 6, + Teams = 7 } public static class IntegrationTypeExtensions @@ -24,6 +25,8 @@ public static class IntegrationTypeExtensions return "hec"; case IntegrationType.Datadog: return "datadog"; + case IntegrationType.Teams: + return "teams"; default: throw new ArgumentOutOfRangeException(nameof(type), $"Unsupported integration type: {type}"); } diff --git a/src/Core/AdminConsole/Models/Data/EventIntegrations/TeamsIntegration.cs b/src/Core/AdminConsole/Models/Data/EventIntegrations/TeamsIntegration.cs new file mode 100644 index 0000000000..8390022839 --- /dev/null +++ b/src/Core/AdminConsole/Models/Data/EventIntegrations/TeamsIntegration.cs @@ -0,0 +1,12 @@ +using Bit.Core.Models.Teams; + +namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations; + +public record TeamsIntegration( + string TenantId, + IReadOnlyList Teams, + string? ChannelId = null, + Uri? ServiceUrl = null) +{ + public bool IsCompleted => !string.IsNullOrEmpty(ChannelId) && ServiceUrl is not null; +} diff --git a/src/Core/AdminConsole/Models/Data/EventIntegrations/TeamsIntegrationConfigurationDetails.cs b/src/Core/AdminConsole/Models/Data/EventIntegrations/TeamsIntegrationConfigurationDetails.cs new file mode 100644 index 0000000000..66fe558dff --- /dev/null +++ b/src/Core/AdminConsole/Models/Data/EventIntegrations/TeamsIntegrationConfigurationDetails.cs @@ -0,0 +1,3 @@ +namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations; + +public record TeamsIntegrationConfigurationDetails(string ChannelId, Uri ServiceUrl); diff --git a/src/Core/AdminConsole/Models/Data/EventIntegrations/TeamsListenerConfiguration.cs b/src/Core/AdminConsole/Models/Data/EventIntegrations/TeamsListenerConfiguration.cs new file mode 100644 index 0000000000..24cf674648 --- /dev/null +++ b/src/Core/AdminConsole/Models/Data/EventIntegrations/TeamsListenerConfiguration.cs @@ -0,0 +1,38 @@ +using Bit.Core.Enums; +using Bit.Core.Settings; + +namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations; + +public class TeamsListenerConfiguration(GlobalSettings globalSettings) : + ListenerConfiguration(globalSettings), IIntegrationListenerConfiguration +{ + public IntegrationType IntegrationType + { + get => IntegrationType.Teams; + } + + public string EventQueueName + { + get => _globalSettings.EventLogging.RabbitMq.TeamsEventsQueueName; + } + + public string IntegrationQueueName + { + get => _globalSettings.EventLogging.RabbitMq.TeamsIntegrationQueueName; + } + + public string IntegrationRetryQueueName + { + get => _globalSettings.EventLogging.RabbitMq.TeamsIntegrationRetryQueueName; + } + + public string EventSubscriptionName + { + get => _globalSettings.EventLogging.AzureServiceBus.TeamsEventSubscriptionName; + } + + public string IntegrationSubscriptionName + { + get => _globalSettings.EventLogging.AzureServiceBus.TeamsIntegrationSubscriptionName; + } +} diff --git a/src/Core/AdminConsole/Models/Slack/SlackApiResponse.cs b/src/Core/AdminConsole/Models/Slack/SlackApiResponse.cs index ede2123f7e..70d280c428 100644 --- a/src/Core/AdminConsole/Models/Slack/SlackApiResponse.cs +++ b/src/Core/AdminConsole/Models/Slack/SlackApiResponse.cs @@ -1,6 +1,4 @@ -#nullable enable - -using System.Text.Json.Serialization; +using System.Text.Json.Serialization; namespace Bit.Core.Models.Slack; diff --git a/src/Core/AdminConsole/Models/Teams/TeamsApiResponse.cs b/src/Core/AdminConsole/Models/Teams/TeamsApiResponse.cs new file mode 100644 index 0000000000..131e45264f --- /dev/null +++ b/src/Core/AdminConsole/Models/Teams/TeamsApiResponse.cs @@ -0,0 +1,41 @@ +using System.Text.Json.Serialization; + +namespace Bit.Core.Models.Teams; + +/// Represents the response returned by the Microsoft OAuth 2.0 token endpoint. +/// See Microsoft identity platform and OAuth 2.0 +/// authorization code flow. +public class TeamsOAuthResponse +{ + /// The access token issued by Microsoft, used to call the Microsoft Graph API. + [JsonPropertyName("access_token")] + public string AccessToken { get; set; } = string.Empty; +} + +/// Represents the response from the /me/joinedTeams Microsoft Graph API call. +/// See List joined teams - +/// Microsoft Graph v1.0. +public class JoinedTeamsResponse +{ + /// The collection of teams that the user has joined. + [JsonPropertyName("value")] + public List Value { get; set; } = []; +} + +/// Represents a Microsoft Teams team returned by the Graph API. +/// See Team resource type - +/// Microsoft Graph v1.0. +public class TeamInfo +{ + /// The unique identifier of the team. + [JsonPropertyName("id")] + public string Id { get; set; } = string.Empty; + + /// The name of the team. + [JsonPropertyName("displayName")] + public string DisplayName { get; set; } = string.Empty; + + /// The ID of the Microsoft Entra tenant for this team. + [JsonPropertyName("tenantId")] + public string TenantId { get; set; } = string.Empty; +} diff --git a/src/Core/AdminConsole/Models/Teams/TeamsBotCredentialProvider.cs b/src/Core/AdminConsole/Models/Teams/TeamsBotCredentialProvider.cs new file mode 100644 index 0000000000..eeb17131a3 --- /dev/null +++ b/src/Core/AdminConsole/Models/Teams/TeamsBotCredentialProvider.cs @@ -0,0 +1,28 @@ +using Microsoft.Bot.Connector.Authentication; + +namespace Bit.Core.AdminConsole.Models.Teams; + +public class TeamsBotCredentialProvider(string clientId, string clientSecret) : ICredentialProvider +{ + private const string _microsoftBotFrameworkIssuer = AuthenticationConstants.ToBotFromChannelTokenIssuer; + + public Task IsValidAppIdAsync(string appId) + { + return Task.FromResult(appId == clientId); + } + + public Task GetAppPasswordAsync(string appId) + { + return Task.FromResult(appId == clientId ? clientSecret : null); + } + + public Task IsAuthenticationDisabledAsync() + { + return Task.FromResult(false); + } + + public Task ValidateIssuerAsync(string issuer) + { + return Task.FromResult(issuer == _microsoftBotFrameworkIssuer); + } +} diff --git a/src/Core/AdminConsole/Repositories/IOrganizationIntegrationRepository.cs b/src/Core/AdminConsole/Repositories/IOrganizationIntegrationRepository.cs index 434c8ddee3..1d8b8be0ec 100644 --- a/src/Core/AdminConsole/Repositories/IOrganizationIntegrationRepository.cs +++ b/src/Core/AdminConsole/Repositories/IOrganizationIntegrationRepository.cs @@ -5,4 +5,6 @@ namespace Bit.Core.Repositories; public interface IOrganizationIntegrationRepository : IRepository { Task> GetManyByOrganizationAsync(Guid organizationId); + + Task GetByTeamsConfigurationTenantIdTeamId(string tenantId, string teamId); } diff --git a/src/Core/AdminConsole/Services/ISlackService.cs b/src/Core/AdminConsole/Services/ISlackService.cs index ff1e03f051..0577532ac2 100644 --- a/src/Core/AdminConsole/Services/ISlackService.cs +++ b/src/Core/AdminConsole/Services/ISlackService.cs @@ -1,11 +1,59 @@ namespace Bit.Core.Services; +/// Defines operations for interacting with Slack, including OAuth authentication, channel discovery, +/// and sending messages. public interface ISlackService { + /// Note: This API is not currently used (yet) by any server code. It is here to provide functionality if + /// the UI needs to be able to look up channels for a user. + /// Retrieves the ID of a Slack channel by name. + /// See conversations.list API. + /// A valid Slack OAuth access token. + /// The name of the channel to look up. + /// The channel ID if found; otherwise, an empty string. Task GetChannelIdAsync(string token, string channelName); + + /// Note: This API is not currently used (yet) by any server code. It is here to provide functionality if + /// the UI needs to be able to look up channels for a user. + /// Retrieves the IDs of multiple Slack channels by name. + /// See conversations.list API. + /// A valid Slack OAuth access token. + /// A list of channel names to look up. + /// A list of matching channel IDs. Channels that cannot be found are omitted. Task> GetChannelIdsAsync(string token, List channelNames); + + /// Note: This API is not currently used (yet) by any server code. It is here to provide functionality if + /// the UI needs to be able to look up a user by their email address. + /// Retrieves the DM channel ID for a Slack user by email. + /// See users.lookupByEmail API and + /// conversations.open API. + /// A valid Slack OAuth access token. + /// The email address of the user to open a DM with. + /// The DM channel ID if successful; otherwise, an empty string. Task GetDmChannelByEmailAsync(string token, string email); + + /// Builds the Slack OAuth 2.0 authorization URL for the app. + /// See Slack OAuth v2 documentation. + /// The absolute redirect URI that Slack will call after user authorization. + /// Must match the URI registered with the app configuration. + /// A state token used to correlate the request and callback and prevent CSRF attacks. + /// The full authorization URL to which the user should be redirected to begin the sign-in process. string GetRedirectUrl(string callbackUrl, string state); + + /// Exchanges a Slack OAuth code for an access token. + /// See oauth.v2.access API. + /// The authorization code returned by Slack via the callback URL after user authorization. + /// The redirect URI that was used in the authorization request. + /// A valid Slack access token if successful; otherwise, an empty string. Task ObtainTokenViaOAuth(string code, string redirectUrl); + + /// Sends a message to a Slack channel by ID. + /// See chat.postMessage API. + /// This is used primarily by the to send events to the + /// Slack channel. + /// A valid Slack OAuth access token. + /// The message text to send. + /// The channel ID to send the message to. + /// A task that completes when the message has been sent. Task SendSlackMessageByChannelIdAsync(string token, string message, string channelId); } diff --git a/src/Core/AdminConsole/Services/ITeamsService.cs b/src/Core/AdminConsole/Services/ITeamsService.cs new file mode 100644 index 0000000000..e3757987c3 --- /dev/null +++ b/src/Core/AdminConsole/Services/ITeamsService.cs @@ -0,0 +1,49 @@ +using Bit.Core.Models.Teams; + +namespace Bit.Core.Services; + +/// +/// Service that provides functionality relating to the Microsoft Teams integration including OAuth, +/// team discovery and sending a message to a channel in Teams. +/// +public interface ITeamsService +{ + /// + /// Generate the Microsoft Teams OAuth 2.0 authorization URL used to begin the sign-in flow. + /// + /// The absolute redirect URI that Microsoft will call after user authorization. + /// Must match the URI registered with the app configuration. + /// A state token used to correlate the request and callback and prevent CSRF attacks. + /// The full authorization URL to which the user should be redirected to begin the sign-in process. + string GetRedirectUrl(string callbackUrl, string state); + + /// + /// Exchange the OAuth code for a Microsoft Graph API access token. + /// + /// The code returned from Microsoft via the OAuth callback Url. + /// The same redirect URI that was passed to the authorization request. + /// A valid Microsoft Graph access token if the exchange succeeds; otherwise, an empty string. + Task ObtainTokenViaOAuth(string code, string redirectUrl); + + /// + /// Get the Teams to which the authenticated user belongs via Microsoft Graph API. + /// + /// A valid Microsoft Graph access token for the user (obtained via OAuth). + /// A read-only list of objects representing the user’s joined teams. + /// Returns an empty list if the request fails or if the token is invalid. + Task> GetJoinedTeamsAsync(string accessToken); + + /// + /// Send a message to a specific channel in Teams. + /// + /// This is used primarily by the to send events to the + /// Teams channel. + /// The service URI associated with the Microsoft Bot Framework connector for the target + /// team. Obtained via the bot framework callback. + /// The conversation or channel ID where the message should be delivered. Obtained via + /// the bot framework callback. + /// The message text to post to the channel. + /// A task that completes when the message has been sent. Errors during message delivery are surfaced + /// as exceptions from the underlying connector client. + Task SendMessageToChannelAsync(Uri serviceUri, string channelId, string message); +} diff --git a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/README.md b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/README.md index de7ce3f7fd..7570d47211 100644 --- a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/README.md +++ b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/README.md @@ -203,31 +203,17 @@ Currently, there are integrations / handlers for Slack, webhooks, and HTTP Event - The top-level object that enables a specific integration for the organization. - Includes any properties that apply to the entire integration across all events. - - For Slack, it consists of the token: `{ "Token": "xoxb-token-from-slack" }`. - - For webhooks, it is optional. Webhooks can either be configured at this level or the configuration level, - but the configuration level takes precedence. However, even though it is optional, an organization must - have a webhook `OrganizationIntegration` (even will a `null` `Configuration`) to enable configuration - via `OrganizationIntegrationConfiguration`. - - For HEC, it consists of the scheme, token, and URI: - -```json - { - "Scheme": "Bearer", - "Token": "Auth-token-from-HEC-service", - "Uri": "https://example.com/api" - } -``` + - For example, Slack stores the token in the `Configuration` which applies to every event, but stores the +channel id in the `Configuration` of the `OrganizationIntegrationConfiguration`. The token applies to the entire Slack +integration, but the channel could be configured differently depending on event type. + - See the table below for more examples / details on what is stored at which level. ### `OrganizationIntegrationConfiguration` - This contains the configurations specific to each `EventType` for the integration. - `Configuration` contains the event-specific configuration. - - For Slack, this would contain what channel to send the message to: `{ "channelId": "C123456" }` - - For webhooks, this is the URL the request should be sent to: `{ "url": "https://api.example.com" }` - - Optionally this also can include a `Scheme` and `Token` if this webhook needs Authentication. - - As stated above, all of this information can be specified here or at the `OrganizationIntegration` - level, but any properties declared here will take precedence over the ones above. - - For HEC, this must be null. HEC is configured only at the `OrganizationIntegration` level. + - Any properties at this level override the `Configuration` form the `OrganizationIntegration`. + - See the table below for examples of specific integrations. - `Template` contains a template string that is expected to be filled in with the contents of the actual event. - The tokens in the string are wrapped in `#` characters. For instance, the UserId would be `#UserId#`. - The `IntegrationTemplateProcessor` does the actual work of replacing these tokens with introspected values from @@ -245,6 +231,23 @@ Currently, there are integrations / handlers for Slack, webhooks, and HTTP Event - An array of `OrganizationIntegrationConfigurationDetails` is what the `EventIntegrationHandler` fetches from the database to determine what to publish at the integration level. +### Existing integrations and the configurations at each level + +The following table illustrates how each integration is configured and what exactly is stored in the `Configuration` +property at each level (`OrganizationIntegration` or `OrganizationIntegrationConfiguration`). Under +`OrganizationIntegration` the valid `OrganizationIntegrationStatus` are in bold, with an example of what would be +stored at each status. + +| **Integration** | **OrganizationIntegration** | **OrganizationIntegrationConfiguration** | +|------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------| +| CloudBillingSync | **Not Applicable** (not yet used) | **Not Applicable** (not yet used) | +| Scim | **Not Applicable** (not yet used) | **Not Applicable** (not yet used) | +| Slack | **Initiated**: `null`
**Completed**:
`{ "Token": "xoxb-token-from-slack" }` | `{ "channelId": "C123456" }` | +| Webhook | `null` or `{ "Scheme": "Bearer", "Token": "AUTH-TOKEN", "Uri": "https://example.com" }` | `null` or `{ "Scheme": "Bearer", "Token":"AUTH-TOKEN", "Uri": "https://example.com" }`

Whatever is defined at this level takes precedence | +| Hec | `{ "Scheme": "Bearer", "Token": "AUTH-TOKEN", "Uri": "https://example.com" }` | Always `null` | +| Datadog | `{ "ApiKey": "TheKey12345", "Uri": "https://api.us5.datadoghq.com/api/v1/events"}` | Always `null` | +| Teams | **Initiated**: `null`
**In Progress**:
`{ "TenantID": "tenant", "Teams": ["Id": "team", DisplayName: "MyTeam"]}`
**Completed**:
`{ "TenantID": "tenant", "Teams": ["Id": "team", DisplayName: "MyTeam"], "ServiceUrl":"https://example.com", ChannelId: "channel-1234"}` | Always `null` | + ## Filtering In addition to the ability to configure integrations mentioned above, organization admins can @@ -349,10 +352,20 @@ and event type. - This will be the deserialized version of the `MergedConfiguration` in `OrganizationIntegrationConfigurationDetails`. +A new row with the new integration should be added to this doc in the table above [Existing integrations +and the configurations at each level](#existing-integrations-and-the-configurations-at-each-level). + ## Request Models 1. Add a new case to the switch method in `OrganizationIntegrationRequestModel.Validate`. + - Additionally, add tests in `OrganizationIntegrationRequestModelTests` 2. Add a new case to the switch method in `OrganizationIntegrationConfigurationRequestModel.IsValidForType`. + - Additionally, add / update tests in `OrganizationIntegrationConfigurationRequestModelTests` + +## Response Model + +1. Add a new case to the switch method in `OrganizationIntegrationResponseModel.Status`. + - Additionally, add / update tests in `OrganizationIntegrationResponseModelTests` ## Integration Handler diff --git a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/SlackService.cs b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/SlackService.cs index 4fb74f1f44..8b691dd4bf 100644 --- a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/SlackService.cs +++ b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/SlackService.cs @@ -90,6 +90,12 @@ public class SlackService( public async Task ObtainTokenViaOAuth(string code, string redirectUrl) { + if (string.IsNullOrEmpty(code) || string.IsNullOrWhiteSpace(redirectUrl)) + { + logger.LogError("Error obtaining token via OAuth: Code and/or RedirectUrl were empty"); + return string.Empty; + } + var tokenResponse = await _httpClient.PostAsync($"{_slackApiBaseUrl}/oauth.v2.access", new FormUrlEncodedContent(new[] { diff --git a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/TeamsIntegrationHandler.cs b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/TeamsIntegrationHandler.cs new file mode 100644 index 0000000000..41d60bd69c --- /dev/null +++ b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/TeamsIntegrationHandler.cs @@ -0,0 +1,41 @@ +using Bit.Core.AdminConsole.Models.Data.EventIntegrations; +using Microsoft.Rest; + +namespace Bit.Core.Services; + +public class TeamsIntegrationHandler( + ITeamsService teamsService) + : IntegrationHandlerBase +{ + public override async Task HandleAsync( + IntegrationMessage message) + { + try + { + await teamsService.SendMessageToChannelAsync( + serviceUri: message.Configuration.ServiceUrl, + message: message.RenderedTemplate, + channelId: message.Configuration.ChannelId + ); + + return new IntegrationHandlerResult(success: true, message: message); + } + catch (HttpOperationException ex) + { + var result = new IntegrationHandlerResult(success: false, message: message); + var statusCode = (int)ex.Response.StatusCode; + result.Retryable = statusCode is 429 or >= 500 and < 600; + result.FailureReason = ex.Message; + + return result; + } + catch (Exception ex) + { + var result = new IntegrationHandlerResult(success: false, message: message); + result.Retryable = false; + result.FailureReason = ex.Message; + + return result; + } + } +} diff --git a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/TeamsService.cs b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/TeamsService.cs new file mode 100644 index 0000000000..f9911760bb --- /dev/null +++ b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/TeamsService.cs @@ -0,0 +1,182 @@ +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json; +using System.Web; +using Bit.Core.AdminConsole.Models.Data.EventIntegrations; +using Bit.Core.Models.Teams; +using Bit.Core.Repositories; +using Bit.Core.Settings; +using Microsoft.Bot.Builder; +using Microsoft.Bot.Builder.Teams; +using Microsoft.Bot.Connector; +using Microsoft.Bot.Connector.Authentication; +using Microsoft.Bot.Schema; +using Microsoft.Extensions.Logging; +using TeamInfo = Bit.Core.Models.Teams.TeamInfo; + +namespace Bit.Core.Services; + +public class TeamsService( + IHttpClientFactory httpClientFactory, + IOrganizationIntegrationRepository integrationRepository, + GlobalSettings globalSettings, + ILogger logger) : ActivityHandler, ITeamsService +{ + private readonly HttpClient _httpClient = httpClientFactory.CreateClient(HttpClientName); + private readonly string _clientId = globalSettings.Teams.ClientId; + private readonly string _clientSecret = globalSettings.Teams.ClientSecret; + private readonly string _scopes = globalSettings.Teams.Scopes; + private readonly string _graphBaseUrl = globalSettings.Teams.GraphBaseUrl; + private readonly string _loginBaseUrl = globalSettings.Teams.LoginBaseUrl; + + public const string HttpClientName = "TeamsServiceHttpClient"; + + public string GetRedirectUrl(string redirectUrl, string state) + { + var query = HttpUtility.ParseQueryString(string.Empty); + query["client_id"] = _clientId; + query["response_type"] = "code"; + query["redirect_uri"] = redirectUrl; + query["response_mode"] = "query"; + query["scope"] = string.Join(" ", _scopes); + query["state"] = state; + + return $"{_loginBaseUrl}/common/oauth2/v2.0/authorize?{query}"; + } + + public async Task ObtainTokenViaOAuth(string code, string redirectUrl) + { + if (string.IsNullOrEmpty(code) || string.IsNullOrWhiteSpace(redirectUrl)) + { + logger.LogError("Error obtaining token via OAuth: Code and/or RedirectUrl were empty"); + return string.Empty; + } + + var request = new HttpRequestMessage(HttpMethod.Post, + $"{_loginBaseUrl}/common/oauth2/v2.0/token"); + + request.Content = new FormUrlEncodedContent(new Dictionary + { + { "client_id", _clientId }, + { "client_secret", _clientSecret }, + { "code", code }, + { "redirect_uri", redirectUrl }, + { "grant_type", "authorization_code" } + }); + + using var response = await _httpClient.SendAsync(request); + if (!response.IsSuccessStatusCode) + { + var errorText = await response.Content.ReadAsStringAsync(); + logger.LogError("Teams OAuth token exchange failed: {errorText}", errorText); + return string.Empty; + } + + TeamsOAuthResponse? result; + try + { + result = await response.Content.ReadFromJsonAsync(); + } + catch + { + result = null; + } + + if (result is null) + { + logger.LogError("Error obtaining token via OAuth: Unknown error"); + return string.Empty; + } + + return result.AccessToken; + } + + public async Task> GetJoinedTeamsAsync(string accessToken) + { + using var request = new HttpRequestMessage( + HttpMethod.Get, + $"{_graphBaseUrl}/me/joinedTeams"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); + + using var response = await _httpClient.SendAsync(request); + if (!response.IsSuccessStatusCode) + { + var errorText = await response.Content.ReadAsStringAsync(); + logger.LogError("Get Teams request failed: {errorText}", errorText); + return new List(); + } + + var result = await response.Content.ReadFromJsonAsync(); + + return result?.Value ?? []; + } + + public async Task SendMessageToChannelAsync(Uri serviceUri, string channelId, string message) + { + var credentials = new MicrosoftAppCredentials(_clientId, _clientSecret); + using var connectorClient = new ConnectorClient(serviceUri, credentials); + + var activity = new Activity + { + Type = ActivityTypes.Message, + Text = message + }; + + await connectorClient.Conversations.SendToConversationAsync(channelId, activity); + } + + protected override async Task OnInstallationUpdateAddAsync(ITurnContext turnContext, + CancellationToken cancellationToken) + { + var conversationId = turnContext.Activity.Conversation.Id; + var serviceUrl = turnContext.Activity.ServiceUrl; + var teamId = turnContext.Activity.TeamsGetTeamInfo().AadGroupId; + var tenantId = turnContext.Activity.Conversation.TenantId; + + if (!string.IsNullOrWhiteSpace(conversationId) && + !string.IsNullOrWhiteSpace(serviceUrl) && + Uri.TryCreate(serviceUrl, UriKind.Absolute, out var parsedUri) && + !string.IsNullOrWhiteSpace(teamId) && + !string.IsNullOrWhiteSpace(tenantId)) + { + await HandleIncomingAppInstallAsync( + conversationId: conversationId, + serviceUrl: parsedUri, + teamId: teamId, + tenantId: tenantId + ); + } + + await base.OnInstallationUpdateAddAsync(turnContext, cancellationToken); + } + + internal async Task HandleIncomingAppInstallAsync( + string conversationId, + Uri serviceUrl, + string teamId, + string tenantId) + { + var integration = await integrationRepository.GetByTeamsConfigurationTenantIdTeamId( + tenantId: tenantId, + teamId: teamId); + + if (integration?.Configuration is null) + { + return; + } + + var teamsConfig = JsonSerializer.Deserialize(integration.Configuration); + if (teamsConfig is null || teamsConfig.IsCompleted) + { + return; + } + + integration.Configuration = JsonSerializer.Serialize(teamsConfig with + { + ChannelId = conversationId, + ServiceUrl = serviceUrl + }); + + await integrationRepository.UpsertAsync(integration); + } +} diff --git a/src/Core/AdminConsole/Services/NoopImplementations/NoopTeamsService.cs b/src/Core/AdminConsole/Services/NoopImplementations/NoopTeamsService.cs new file mode 100644 index 0000000000..fafb23f570 --- /dev/null +++ b/src/Core/AdminConsole/Services/NoopImplementations/NoopTeamsService.cs @@ -0,0 +1,27 @@ +using Bit.Core.Models.Teams; +using Bit.Core.Services; + +namespace Bit.Core.AdminConsole.Services.NoopImplementations; + +public class NoopTeamsService : ITeamsService +{ + public string GetRedirectUrl(string callbackUrl, string state) + { + return string.Empty; + } + + public Task ObtainTokenViaOAuth(string code, string redirectUrl) + { + return Task.FromResult(string.Empty); + } + + public Task> GetJoinedTeamsAsync(string accessToken) + { + return Task.FromResult>(Array.Empty()); + } + + public Task SendMessageToChannelAsync(Uri serviceUri, string channelId, string message) + { + return Task.CompletedTask; + } +} diff --git a/src/Core/Core.csproj b/src/Core/Core.csproj index e9bf1b1807..23cb885bd4 100644 --- a/src/Core/Core.csproj +++ b/src/Core/Core.csproj @@ -38,6 +38,8 @@ + + diff --git a/src/Core/Settings/GlobalSettings.cs b/src/Core/Settings/GlobalSettings.cs index 250daf0007..d79b7290ec 100644 --- a/src/Core/Settings/GlobalSettings.cs +++ b/src/Core/Settings/GlobalSettings.cs @@ -62,6 +62,7 @@ public class GlobalSettings : IGlobalSettings 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 TeamsSettings Teams { get; set; } = new TeamsSettings(); public virtual EventLoggingSettings EventLogging { get; set; } = new EventLoggingSettings(); public virtual MailSettings Mail { get; set; } = new MailSettings(); public virtual IConnectionStringSettings Storage { get; set; } = new ConnectionStringSettings(); @@ -295,6 +296,15 @@ public class GlobalSettings : IGlobalSettings public virtual string Scopes { get; set; } } + public class TeamsSettings + { + public virtual string LoginBaseUrl { get; set; } = "https://login.microsoftonline.com"; + public virtual string GraphBaseUrl { get; set; } = "https://graph.microsoft.com/v1.0"; + 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(); @@ -320,6 +330,8 @@ public class GlobalSettings : IGlobalSettings public virtual string HecIntegrationSubscriptionName { get; set; } = "integration-hec-subscription"; public virtual string DatadogEventSubscriptionName { get; set; } = "events-datadog-subscription"; public virtual string DatadogIntegrationSubscriptionName { get; set; } = "integration-datadog-subscription"; + public virtual string TeamsEventSubscriptionName { get; set; } = "events-teams-subscription"; + public virtual string TeamsIntegrationSubscriptionName { get; set; } = "integration-teams-subscription"; public string ConnectionString { @@ -364,6 +376,9 @@ public class GlobalSettings : IGlobalSettings public virtual string DatadogEventsQueueName { get; set; } = "events-datadog-queue"; public virtual string DatadogIntegrationQueueName { get; set; } = "integration-datadog-queue"; public virtual string DatadogIntegrationRetryQueueName { get; set; } = "integration-datadog-retry-queue"; + public virtual string TeamsEventsQueueName { get; set; } = "events-teams-queue"; + public virtual string TeamsIntegrationQueueName { get; set; } = "integration-teams-queue"; + public virtual string TeamsIntegrationRetryQueueName { get; set; } = "integration-teams-retry-queue"; public string HostName { diff --git a/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationIntegrationRepository.cs b/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationIntegrationRepository.cs index ece9697a31..4f8fb979d3 100644 --- a/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationIntegrationRepository.cs +++ b/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationIntegrationRepository.cs @@ -29,4 +29,17 @@ public class OrganizationIntegrationRepository : Repository GetByTeamsConfigurationTenantIdTeamId(string tenantId, string teamId) + { + using (var connection = new SqlConnection(ConnectionString)) + { + var result = await connection.QuerySingleOrDefaultAsync( + "[dbo].[OrganizationIntegration_ReadByTeamsConfigurationTenantIdTeamId]", + new { TenantId = tenantId, TeamId = teamId }, + commandType: CommandType.StoredProcedure); + + return result; + } + } } diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationIntegrationRepository.cs b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationIntegrationRepository.cs index 5670b2ae9b..c11591efcd 100644 --- a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationIntegrationRepository.cs +++ b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationIntegrationRepository.cs @@ -26,4 +26,16 @@ public class OrganizationIntegrationRepository : return await query.Run(dbContext).ToListAsync(); } } + + public async Task GetByTeamsConfigurationTenantIdTeamId( + string tenantId, + string teamId) + { + using (var scope = ServiceScopeFactory.CreateScope()) + { + var dbContext = GetDatabaseContext(scope); + var query = new OrganizationIntegrationReadByTeamsConfigurationTenantIdTeamIdQuery(tenantId: tenantId, teamId: teamId); + return await query.Run(dbContext).SingleOrDefaultAsync(); + } + } } diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/OrganizationIntegrationReadByTeamsConfigurationTenantIdTeamIdQuery.cs b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/OrganizationIntegrationReadByTeamsConfigurationTenantIdTeamIdQuery.cs new file mode 100644 index 0000000000..a1e86d9add --- /dev/null +++ b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/OrganizationIntegrationReadByTeamsConfigurationTenantIdTeamIdQuery.cs @@ -0,0 +1,36 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Enums; +using Bit.Infrastructure.EntityFramework.Repositories; +using Bit.Infrastructure.EntityFramework.Repositories.Queries; + +namespace Bit.Infrastructure.EntityFramework.AdminConsole.Repositories.Queries; + +public class OrganizationIntegrationReadByTeamsConfigurationTenantIdTeamIdQuery : IQuery +{ + private readonly string _tenantId; + private readonly string _teamId; + + public OrganizationIntegrationReadByTeamsConfigurationTenantIdTeamIdQuery(string tenantId, string teamId) + { + _tenantId = tenantId; + _teamId = teamId; + } + + public IQueryable Run(DatabaseContext dbContext) + { + var query = + from oi in dbContext.OrganizationIntegrations + where oi.Type == IntegrationType.Teams && + oi.Configuration != null && + oi.Configuration.Contains($"\"TenantId\":\"{_tenantId}\"") && + oi.Configuration.Contains($"\"id\":\"{_teamId}\"") + select new OrganizationIntegration() + { + Id = oi.Id, + OrganizationId = oi.OrganizationId, + Type = oi.Type, + Configuration = oi.Configuration, + }; + return query; + } +} diff --git a/src/SharedWeb/SharedWeb.csproj b/src/SharedWeb/SharedWeb.csproj index 8bffa285fc..d8dc61178d 100644 --- a/src/SharedWeb/SharedWeb.csproj +++ b/src/SharedWeb/SharedWeb.csproj @@ -7,6 +7,7 @@ + diff --git a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs index 58ce0466c3..bc8df87599 100644 --- a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs +++ b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs @@ -11,6 +11,7 @@ using Bit.Core; using Bit.Core.AdminConsole.AbilitiesCache; using Bit.Core.AdminConsole.Models.Business.Tokenables; using Bit.Core.AdminConsole.Models.Data.EventIntegrations; +using Bit.Core.AdminConsole.Models.Teams; using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.AdminConsole.Services; using Bit.Core.AdminConsole.Services.Implementations; @@ -69,6 +70,8 @@ using Microsoft.AspNetCore.HttpOverrides; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc.Localization; using Microsoft.Azure.Cosmos.Fluent; +using Microsoft.Bot.Builder; +using Microsoft.Bot.Builder.Integration.AspNet.Core; using Microsoft.Extensions.Caching.Cosmos; using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.Configuration; @@ -604,6 +607,33 @@ public static class ServiceCollectionExtensions return services; } + public static IServiceCollection AddTeamsService(this IServiceCollection services, GlobalSettings globalSettings) + { + if (CoreHelpers.SettingHasValue(globalSettings.Teams.ClientId) && + CoreHelpers.SettingHasValue(globalSettings.Teams.ClientSecret) && + CoreHelpers.SettingHasValue(globalSettings.Teams.Scopes)) + { + services.AddHttpClient(TeamsService.HttpClientName); + services.TryAddSingleton(); + services.TryAddSingleton(sp => sp.GetRequiredService()); + services.TryAddSingleton(sp => sp.GetRequiredService()); + services.TryAddSingleton(sp => + new BotFrameworkHttpAdapter( + new TeamsBotCredentialProvider( + clientId: globalSettings.Teams.ClientId, + clientSecret: globalSettings.Teams.ClientSecret + ) + ) + ); + } + else + { + services.TryAddSingleton(); + } + + return services; + } + public static void UseDefaultMiddleware(this IApplicationBuilder app, IWebHostEnvironment env, GlobalSettings globalSettings) { @@ -913,6 +943,7 @@ public static class ServiceCollectionExtensions // Add services in support of handlers services.AddSlackService(globalSettings); + services.AddTeamsService(globalSettings); services.TryAddSingleton(TimeProvider.System); services.AddHttpClient(WebhookIntegrationHandler.HttpClientName); services.AddHttpClient(DatadogIntegrationHandler.HttpClientName); @@ -921,12 +952,14 @@ public static class ServiceCollectionExtensions services.TryAddSingleton, SlackIntegrationHandler>(); services.TryAddSingleton, WebhookIntegrationHandler>(); services.TryAddSingleton, DatadogIntegrationHandler>(); + services.TryAddSingleton, TeamsIntegrationHandler>(); var repositoryConfiguration = new RepositoryListenerConfiguration(globalSettings); var slackConfiguration = new SlackListenerConfiguration(globalSettings); var webhookConfiguration = new WebhookListenerConfiguration(globalSettings); var hecConfiguration = new HecListenerConfiguration(globalSettings); var datadogConfiguration = new DatadogListenerConfiguration(globalSettings); + var teamsConfiguration = new TeamsListenerConfiguration(globalSettings); if (IsRabbitMqEnabled(globalSettings)) { @@ -944,6 +977,7 @@ public static class ServiceCollectionExtensions services.AddRabbitMqIntegration(webhookConfiguration); services.AddRabbitMqIntegration(hecConfiguration); services.AddRabbitMqIntegration(datadogConfiguration); + services.AddRabbitMqIntegration(teamsConfiguration); } if (IsAzureServiceBusEnabled(globalSettings)) @@ -967,6 +1001,7 @@ public static class ServiceCollectionExtensions services.AddAzureServiceBusIntegration(webhookConfiguration); services.AddAzureServiceBusIntegration(hecConfiguration); services.AddAzureServiceBusIntegration(datadogConfiguration); + services.AddAzureServiceBusIntegration(teamsConfiguration); } return services; diff --git a/src/Sql/dbo/Stored Procedures/OrganizationIntegration_ReadByTeamsConfigurationTenantIdTeamId.sql b/src/Sql/dbo/Stored Procedures/OrganizationIntegration_ReadByTeamsConfigurationTenantIdTeamId.sql new file mode 100644 index 0000000000..8e2102772b --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/OrganizationIntegration_ReadByTeamsConfigurationTenantIdTeamId.sql @@ -0,0 +1,17 @@ +CREATE PROCEDURE [dbo].[OrganizationIntegration_ReadByTeamsConfigurationTenantIdTeamId] + @TenantId NVARCHAR(200), + @TeamId NVARCHAR(200) +AS +BEGIN + SET NOCOUNT ON; + +SELECT TOP 1 * +FROM [dbo].[OrganizationIntegrationView] + CROSS APPLY OPENJSON([Configuration], '$.Teams') + WITH ( TeamId NVARCHAR(MAX) '$.id' ) t +WHERE [Type] = 7 + AND JSON_VALUE([Configuration], '$.TenantId') = @TenantId + AND t.TeamId = @TeamId + AND JSON_VALUE([Configuration], '$.ChannelId') IS NULL + AND JSON_VALUE([Configuration], '$.ServiceUrl') IS NULL; +END diff --git a/test/Api.Test/AdminConsole/Controllers/SlackIntegrationControllerTests.cs b/test/Api.Test/AdminConsole/Controllers/SlackIntegrationControllerTests.cs index 376fb01493..61d3486c51 100644 --- a/test/Api.Test/AdminConsole/Controllers/SlackIntegrationControllerTests.cs +++ b/test/Api.Test/AdminConsole/Controllers/SlackIntegrationControllerTests.cs @@ -34,7 +34,7 @@ public class SlackIntegrationControllerTests integration.Configuration = null; sutProvider.Sut.Url = Substitute.For(); sutProvider.Sut.Url - .RouteUrl(Arg.Is(c => c.RouteName == nameof(SlackIntegrationController.CreateAsync))) + .RouteUrl(Arg.Is(c => c.RouteName == "SlackIntegration_Create")) .Returns("https://localhost"); sutProvider.GetDependency() .ObtainTokenViaOAuth(_validSlackCode, Arg.Any()) @@ -60,7 +60,7 @@ public class SlackIntegrationControllerTests integration.Configuration = null; sutProvider.Sut.Url = Substitute.For(); sutProvider.Sut.Url - .RouteUrl(Arg.Is(c => c.RouteName == nameof(SlackIntegrationController.CreateAsync))) + .RouteUrl(Arg.Is(c => c.RouteName == "SlackIntegration_Create")) .Returns("https://localhost"); sutProvider.GetDependency() .GetByIdAsync(integration.Id) @@ -80,7 +80,7 @@ public class SlackIntegrationControllerTests integration.Configuration = null; sutProvider.Sut.Url = Substitute.For(); sutProvider.Sut.Url - .RouteUrl(Arg.Is(c => c.RouteName == nameof(SlackIntegrationController.CreateAsync))) + .RouteUrl(Arg.Is(c => c.RouteName == "SlackIntegration_Create")) .Returns("https://localhost"); sutProvider.GetDependency() .GetByIdAsync(integration.Id) @@ -99,13 +99,13 @@ public class SlackIntegrationControllerTests { sutProvider.Sut.Url = Substitute.For(); sutProvider.Sut.Url - .RouteUrl(Arg.Is(c => c.RouteName == nameof(SlackIntegrationController.CreateAsync))) + .RouteUrl(Arg.Is(c => c.RouteName == "SlackIntegration_Create")) .Returns("https://localhost"); sutProvider.GetDependency() .ObtainTokenViaOAuth(_validSlackCode, Arg.Any()) .Returns(_slackToken); - await Assert.ThrowsAsync(async () => await sutProvider.Sut.CreateAsync(_validSlackCode, String.Empty)); + await Assert.ThrowsAsync(async () => await sutProvider.Sut.CreateAsync(_validSlackCode, string.Empty)); } [Theory, BitAutoData] @@ -116,7 +116,7 @@ public class SlackIntegrationControllerTests var timeProvider = new FakeTimeProvider(new DateTime(2024, 4, 3, 2, 1, 0, DateTimeKind.Utc)); sutProvider.Sut.Url = Substitute.For(); sutProvider.Sut.Url - .RouteUrl(Arg.Is(c => c.RouteName == nameof(SlackIntegrationController.CreateAsync))) + .RouteUrl(Arg.Is(c => c.RouteName == "SlackIntegration_Create")) .Returns("https://localhost"); sutProvider.GetDependency() .ObtainTokenViaOAuth(_validSlackCode, Arg.Any()) @@ -135,7 +135,7 @@ public class SlackIntegrationControllerTests { sutProvider.Sut.Url = Substitute.For(); sutProvider.Sut.Url - .RouteUrl(Arg.Is(c => c.RouteName == nameof(SlackIntegrationController.CreateAsync))) + .RouteUrl(Arg.Is(c => c.RouteName == "SlackIntegration_Create")) .Returns("https://localhost"); sutProvider.GetDependency() .ObtainTokenViaOAuth(_validSlackCode, Arg.Any()) @@ -147,7 +147,7 @@ public class SlackIntegrationControllerTests } [Theory, BitAutoData] - public async Task CreateAsync_StateHasWrongOgranizationHash_ThrowsNotFound( + public async Task CreateAsync_StateHasWrongOrganizationHash_ThrowsNotFound( SutProvider sutProvider, OrganizationIntegration integration, OrganizationIntegration wrongOrgIntegration) @@ -156,7 +156,7 @@ public class SlackIntegrationControllerTests sutProvider.Sut.Url = Substitute.For(); sutProvider.Sut.Url - .RouteUrl(Arg.Is(c => c.RouteName == nameof(SlackIntegrationController.CreateAsync))) + .RouteUrl(Arg.Is(c => c.RouteName == "SlackIntegration_Create")) .Returns("https://localhost"); sutProvider.GetDependency() .ObtainTokenViaOAuth(_validSlackCode, Arg.Any()) @@ -179,7 +179,7 @@ public class SlackIntegrationControllerTests integration.Configuration = "{}"; sutProvider.Sut.Url = Substitute.For(); sutProvider.Sut.Url - .RouteUrl(Arg.Is(c => c.RouteName == nameof(SlackIntegrationController.CreateAsync))) + .RouteUrl(Arg.Is(c => c.RouteName == "SlackIntegration_Create")) .Returns("https://localhost"); sutProvider.GetDependency() .ObtainTokenViaOAuth(_validSlackCode, Arg.Any()) @@ -201,7 +201,7 @@ public class SlackIntegrationControllerTests integration.Configuration = null; sutProvider.Sut.Url = Substitute.For(); sutProvider.Sut.Url - .RouteUrl(Arg.Is(c => c.RouteName == nameof(SlackIntegrationController.CreateAsync))) + .RouteUrl(Arg.Is(c => c.RouteName == "SlackIntegration_Create")) .Returns("https://localhost"); sutProvider.GetDependency() .ObtainTokenViaOAuth(_validSlackCode, Arg.Any()) @@ -224,7 +224,7 @@ public class SlackIntegrationControllerTests sutProvider.Sut.Url = Substitute.For(); sutProvider.Sut.Url - .RouteUrl(Arg.Is(c => c.RouteName == nameof(SlackIntegrationController.CreateAsync))) + .RouteUrl(Arg.Is(c => c.RouteName == "SlackIntegration_Create")) .Returns(expectedUrl); sutProvider.GetDependency() .OrganizationOwner(integration.OrganizationId) @@ -260,7 +260,7 @@ public class SlackIntegrationControllerTests sutProvider.Sut.Url = Substitute.For(); sutProvider.Sut.Url - .RouteUrl(Arg.Is(c => c.RouteName == nameof(SlackIntegrationController.CreateAsync))) + .RouteUrl(Arg.Is(c => c.RouteName == "SlackIntegration_Create")) .Returns(expectedUrl); sutProvider.GetDependency() .OrganizationOwner(organizationId) @@ -291,7 +291,7 @@ public class SlackIntegrationControllerTests sutProvider.Sut.Url = Substitute.For(); sutProvider.Sut.Url - .RouteUrl(Arg.Is(c => c.RouteName == nameof(SlackIntegrationController.CreateAsync))) + .RouteUrl(Arg.Is(c => c.RouteName == "SlackIntegration_Create")) .Returns(expectedUrl); sutProvider.GetDependency() .OrganizationOwner(organizationId) @@ -316,7 +316,7 @@ public class SlackIntegrationControllerTests sutProvider.Sut.Url = Substitute.For(); sutProvider.Sut.Url - .RouteUrl(Arg.Is(c => c.RouteName == nameof(SlackIntegrationController.CreateAsync))) + .RouteUrl(Arg.Is(c => c.RouteName == "SlackIntegration_Create")) .Returns(expectedUrl); sutProvider.GetDependency() .OrganizationOwner(organizationId) diff --git a/test/Api.Test/AdminConsole/Controllers/TeamsIntegrationControllerTests.cs b/test/Api.Test/AdminConsole/Controllers/TeamsIntegrationControllerTests.cs new file mode 100644 index 0000000000..3af2affdd8 --- /dev/null +++ b/test/Api.Test/AdminConsole/Controllers/TeamsIntegrationControllerTests.cs @@ -0,0 +1,392 @@ +#nullable enable + +using Bit.Api.AdminConsole.Controllers; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Models.Data.EventIntegrations; +using Bit.Core.Context; +using Bit.Core.Enums; +using Bit.Core.Exceptions; +using Bit.Core.Models.Teams; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Routing; +using Microsoft.Bot.Builder; +using Microsoft.Bot.Builder.Integration.AspNet.Core; +using Microsoft.Extensions.Time.Testing; +using NSubstitute; +using Xunit; + +namespace Bit.Api.Test.AdminConsole.Controllers; + +[ControllerCustomize(typeof(TeamsIntegrationController))] +[SutProviderCustomize] +public class TeamsIntegrationControllerTests +{ + private const string _teamsToken = "test-token"; + private const string _validTeamsCode = "A_test_code"; + + [Theory, BitAutoData] + public async Task CreateAsync_AllParamsProvided_Succeeds( + SutProvider sutProvider, + OrganizationIntegration integration) + { + integration.Type = IntegrationType.Teams; + integration.Configuration = null; + sutProvider.Sut.Url = Substitute.For(); + sutProvider.Sut.Url + .RouteUrl(Arg.Is(c => c.RouteName == "TeamsIntegration_Create")) + .Returns("https://localhost"); + sutProvider.GetDependency() + .ObtainTokenViaOAuth(_validTeamsCode, Arg.Any()) + .Returns(_teamsToken); + sutProvider.GetDependency() + .GetJoinedTeamsAsync(_teamsToken) + .Returns([ + new TeamInfo() { DisplayName = "Test Team", Id = Guid.NewGuid().ToString(), TenantId = Guid.NewGuid().ToString() } + ]); + sutProvider.GetDependency() + .GetByIdAsync(integration.Id) + .Returns(integration); + + var state = IntegrationOAuthState.FromIntegration(integration, sutProvider.GetDependency()); + var requestAction = await sutProvider.Sut.CreateAsync(_validTeamsCode, state.ToString()); + + await sutProvider.GetDependency().Received(1) + .UpsertAsync(Arg.Any()); + Assert.IsType(requestAction); + } + + [Theory, BitAutoData] + public async Task CreateAsync_CodeIsEmpty_ThrowsBadRequest( + SutProvider sutProvider, + OrganizationIntegration integration) + { + integration.Type = IntegrationType.Teams; + integration.Configuration = null; + sutProvider.Sut.Url = Substitute.For(); + sutProvider.Sut.Url + .RouteUrl(Arg.Is(c => c.RouteName == "TeamsIntegration_Create")) + .Returns("https://localhost"); + sutProvider.GetDependency() + .GetByIdAsync(integration.Id) + .Returns(integration); + var state = IntegrationOAuthState.FromIntegration(integration, sutProvider.GetDependency()); + + await Assert.ThrowsAsync(async () => + await sutProvider.Sut.CreateAsync(string.Empty, state.ToString())); + } + + [Theory, BitAutoData] + public async Task CreateAsync_NoTeamsFound_ThrowsBadRequest( + SutProvider sutProvider, + OrganizationIntegration integration) + { + integration.Type = IntegrationType.Teams; + integration.Configuration = null; + sutProvider.Sut.Url = Substitute.For(); + sutProvider.Sut.Url + .RouteUrl(Arg.Is(c => c.RouteName == "TeamsIntegration_Create")) + .Returns("https://localhost"); + sutProvider.GetDependency() + .ObtainTokenViaOAuth(_validTeamsCode, Arg.Any()) + .Returns(_teamsToken); + sutProvider.GetDependency() + .GetJoinedTeamsAsync(_teamsToken) + .Returns([]); + sutProvider.GetDependency() + .GetByIdAsync(integration.Id) + .Returns(integration); + + var state = IntegrationOAuthState.FromIntegration(integration, sutProvider.GetDependency()); + + await Assert.ThrowsAsync(async () => await sutProvider.Sut.CreateAsync(_validTeamsCode, state.ToString())); + } + + [Theory, BitAutoData] + public async Task CreateAsync_TeamsServiceReturnsEmptyToken_ThrowsBadRequest( + SutProvider sutProvider, + OrganizationIntegration integration) + { + integration.Type = IntegrationType.Teams; + integration.Configuration = null; + sutProvider.Sut.Url = Substitute.For(); + sutProvider.Sut.Url + .RouteUrl(Arg.Is(c => c.RouteName == "TeamsIntegration_Create")) + .Returns("https://localhost"); + sutProvider.GetDependency() + .GetByIdAsync(integration.Id) + .Returns(integration); + sutProvider.GetDependency() + .ObtainTokenViaOAuth(_validTeamsCode, Arg.Any()) + .Returns(string.Empty); + var state = IntegrationOAuthState.FromIntegration(integration, sutProvider.GetDependency()); + + await Assert.ThrowsAsync(async () => await sutProvider.Sut.CreateAsync(_validTeamsCode, state.ToString())); + } + + [Theory, BitAutoData] + public async Task CreateAsync_StateEmpty_ThrowsNotFound( + SutProvider sutProvider) + { + sutProvider.Sut.Url = Substitute.For(); + sutProvider.Sut.Url + .RouteUrl(Arg.Is(c => c.RouteName == "TeamsIntegration_Create")) + .Returns("https://localhost"); + sutProvider.GetDependency() + .ObtainTokenViaOAuth(_validTeamsCode, Arg.Any()) + .Returns(_teamsToken); + + await Assert.ThrowsAsync(async () => await sutProvider.Sut.CreateAsync(_validTeamsCode, string.Empty)); + } + + [Theory, BitAutoData] + public async Task CreateAsync_StateExpired_ThrowsNotFound( + SutProvider sutProvider, + OrganizationIntegration integration) + { + var timeProvider = new FakeTimeProvider(new DateTime(2024, 4, 3, 2, 1, 0, DateTimeKind.Utc)); + sutProvider.Sut.Url = Substitute.For(); + sutProvider.Sut.Url + .RouteUrl(Arg.Is(c => c.RouteName == "TeamsIntegration_Create")) + .Returns("https://localhost"); + sutProvider.GetDependency() + .ObtainTokenViaOAuth(_validTeamsCode, Arg.Any()) + .Returns(_teamsToken); + var state = IntegrationOAuthState.FromIntegration(integration, timeProvider); + timeProvider.Advance(TimeSpan.FromMinutes(30)); + + sutProvider.SetDependency(timeProvider); + await Assert.ThrowsAsync(async () => await sutProvider.Sut.CreateAsync(_validTeamsCode, state.ToString())); + } + + [Theory, BitAutoData] + public async Task CreateAsync_StateHasNonexistentIntegration_ThrowsNotFound( + SutProvider sutProvider, + OrganizationIntegration integration) + { + sutProvider.Sut.Url = Substitute.For(); + sutProvider.Sut.Url + .RouteUrl(Arg.Is(c => c.RouteName == "TeamsIntegration_Create")) + .Returns("https://localhost"); + sutProvider.GetDependency() + .ObtainTokenViaOAuth(_validTeamsCode, Arg.Any()) + .Returns(_teamsToken); + + var state = IntegrationOAuthState.FromIntegration(integration, sutProvider.GetDependency()); + + await Assert.ThrowsAsync(async () => await sutProvider.Sut.CreateAsync(_validTeamsCode, state.ToString())); + } + + [Theory, BitAutoData] + public async Task CreateAsync_StateHasWrongOrganizationHash_ThrowsNotFound( + SutProvider sutProvider, + OrganizationIntegration integration, + OrganizationIntegration wrongOrgIntegration) + { + wrongOrgIntegration.Id = integration.Id; + wrongOrgIntegration.Type = IntegrationType.Teams; + wrongOrgIntegration.Configuration = null; + + sutProvider.Sut.Url = Substitute.For(); + sutProvider.Sut.Url + .RouteUrl(Arg.Is(c => c.RouteName == "TeamsIntegration_Create")) + .Returns("https://localhost"); + sutProvider.GetDependency() + .ObtainTokenViaOAuth(_validTeamsCode, Arg.Any()) + .Returns(_teamsToken); + sutProvider.GetDependency() + .GetByIdAsync(integration.Id) + .Returns(wrongOrgIntegration); + + var state = IntegrationOAuthState.FromIntegration(integration, sutProvider.GetDependency()); + + await Assert.ThrowsAsync(async () => await sutProvider.Sut.CreateAsync(_validTeamsCode, state.ToString())); + } + + [Theory, BitAutoData] + public async Task CreateAsync_StateHasNonEmptyIntegration_ThrowsNotFound( + SutProvider sutProvider, + OrganizationIntegration integration) + { + integration.Type = IntegrationType.Teams; + integration.Configuration = "{}"; + sutProvider.Sut.Url = Substitute.For(); + sutProvider.Sut.Url + .RouteUrl(Arg.Is(c => c.RouteName == "TeamsIntegration_Create")) + .Returns("https://localhost"); + sutProvider.GetDependency() + .ObtainTokenViaOAuth(_validTeamsCode, Arg.Any()) + .Returns(_teamsToken); + sutProvider.GetDependency() + .GetByIdAsync(integration.Id) + .Returns(integration); + + var state = IntegrationOAuthState.FromIntegration(integration, sutProvider.GetDependency()); + await Assert.ThrowsAsync(async () => await sutProvider.Sut.CreateAsync(_validTeamsCode, state.ToString())); + } + + [Theory, BitAutoData] + public async Task CreateAsync_StateHasNonTeamsIntegration_ThrowsNotFound( + SutProvider sutProvider, + OrganizationIntegration integration) + { + integration.Type = IntegrationType.Hec; + integration.Configuration = null; + sutProvider.Sut.Url = Substitute.For(); + sutProvider.Sut.Url + .RouteUrl(Arg.Is(c => c.RouteName == "TeamsIntegration_Create")) + .Returns("https://localhost"); + sutProvider.GetDependency() + .ObtainTokenViaOAuth(_validTeamsCode, Arg.Any()) + .Returns(_teamsToken); + sutProvider.GetDependency() + .GetByIdAsync(integration.Id) + .Returns(integration); + + var state = IntegrationOAuthState.FromIntegration(integration, sutProvider.GetDependency()); + await Assert.ThrowsAsync(async () => await sutProvider.Sut.CreateAsync(_validTeamsCode, state.ToString())); + } + + [Theory, BitAutoData] + public async Task RedirectAsync_Success( + SutProvider sutProvider, + OrganizationIntegration integration) + { + integration.Configuration = null; + var expectedUrl = "https://localhost/"; + + sutProvider.Sut.Url = Substitute.For(); + sutProvider.Sut.Url + .RouteUrl(Arg.Is(c => c.RouteName == "TeamsIntegration_Create")) + .Returns(expectedUrl); + sutProvider.GetDependency() + .OrganizationOwner(integration.OrganizationId) + .Returns(true); + sutProvider.GetDependency() + .GetManyByOrganizationAsync(integration.OrganizationId) + .Returns([]); + sutProvider.GetDependency() + .CreateAsync(Arg.Any()) + .Returns(integration); + sutProvider.GetDependency().GetRedirectUrl(Arg.Any(), Arg.Any()).Returns(expectedUrl); + + var expectedState = IntegrationOAuthState.FromIntegration(integration, sutProvider.GetDependency()); + + var requestAction = await sutProvider.Sut.RedirectAsync(integration.OrganizationId); + + Assert.IsType(requestAction); + await sutProvider.GetDependency().Received(1) + .CreateAsync(Arg.Any()); + sutProvider.GetDependency().Received(1).GetRedirectUrl(Arg.Any(), expectedState.ToString()); + } + + [Theory, BitAutoData] + public async Task RedirectAsync_IntegrationAlreadyExistsWithNullConfig_Success( + SutProvider sutProvider, + Guid organizationId, + OrganizationIntegration integration) + { + integration.OrganizationId = organizationId; + integration.Configuration = null; + integration.Type = IntegrationType.Teams; + var expectedUrl = "https://localhost/"; + + sutProvider.Sut.Url = Substitute.For(); + sutProvider.Sut.Url + .RouteUrl(Arg.Is(c => c.RouteName == "TeamsIntegration_Create")) + .Returns(expectedUrl); + sutProvider.GetDependency() + .OrganizationOwner(organizationId) + .Returns(true); + sutProvider.GetDependency() + .GetManyByOrganizationAsync(organizationId) + .Returns([integration]); + sutProvider.GetDependency().GetRedirectUrl(Arg.Any(), Arg.Any()).Returns(expectedUrl); + + var requestAction = await sutProvider.Sut.RedirectAsync(organizationId); + + var expectedState = IntegrationOAuthState.FromIntegration(integration, sutProvider.GetDependency()); + + Assert.IsType(requestAction); + sutProvider.GetDependency().Received(1).GetRedirectUrl(Arg.Any(), expectedState.ToString()); + } + + [Theory, BitAutoData] + public async Task RedirectAsync_IntegrationAlreadyExistsWithConfig_ThrowsBadRequest( + SutProvider sutProvider, + Guid organizationId, + OrganizationIntegration integration) + { + integration.OrganizationId = organizationId; + integration.Configuration = "{}"; + integration.Type = IntegrationType.Teams; + var expectedUrl = "https://localhost/"; + + sutProvider.Sut.Url = Substitute.For(); + sutProvider.Sut.Url + .RouteUrl(Arg.Is(c => c.RouteName == "TeamsIntegration_Create")) + .Returns(expectedUrl); + sutProvider.GetDependency() + .OrganizationOwner(organizationId) + .Returns(true); + sutProvider.GetDependency() + .GetManyByOrganizationAsync(organizationId) + .Returns([integration]); + sutProvider.GetDependency().GetRedirectUrl(Arg.Any(), Arg.Any()).Returns(expectedUrl); + + await Assert.ThrowsAsync(async () => await sutProvider.Sut.RedirectAsync(organizationId)); + } + + [Theory, BitAutoData] + public async Task RedirectAsync_TeamsServiceReturnsEmpty_ThrowsNotFound( + SutProvider sutProvider, + Guid organizationId, + OrganizationIntegration integration) + { + integration.OrganizationId = organizationId; + integration.Configuration = null; + var expectedUrl = "https://localhost/"; + + sutProvider.Sut.Url = Substitute.For(); + sutProvider.Sut.Url + .RouteUrl(Arg.Is(c => c.RouteName == "TeamsIntegration_Create")) + .Returns(expectedUrl); + sutProvider.GetDependency() + .OrganizationOwner(organizationId) + .Returns(true); + sutProvider.GetDependency() + .GetManyByOrganizationAsync(organizationId) + .Returns([]); + sutProvider.GetDependency() + .CreateAsync(Arg.Any()) + .Returns(integration); + sutProvider.GetDependency().GetRedirectUrl(Arg.Any(), Arg.Any()).Returns(string.Empty); + + await Assert.ThrowsAsync(async () => await sutProvider.Sut.RedirectAsync(organizationId)); + } + + [Theory, BitAutoData] + public async Task RedirectAsync_UserIsNotOrganizationAdmin_ThrowsNotFound(SutProvider sutProvider, + Guid organizationId) + { + sutProvider.GetDependency() + .OrganizationOwner(organizationId) + .Returns(false); + + await Assert.ThrowsAsync(async () => await sutProvider.Sut.RedirectAsync(organizationId)); + } + + [Theory, BitAutoData] + public async Task IncomingPostAsync_ForwardsToBot(SutProvider sutProvider) + { + var adapter = sutProvider.GetDependency(); + var bot = sutProvider.GetDependency(); + + await sutProvider.Sut.IncomingPostAsync(); + await adapter.Received(1).ProcessAsync(Arg.Any(), Arg.Any(), bot); + } +} diff --git a/test/Api.Test/AdminConsole/Models/Request/Organizations/OrganizationIntegrationConfigurationRequestModelTests.cs b/test/Api.Test/AdminConsole/Models/Request/Organizations/OrganizationIntegrationConfigurationRequestModelTests.cs index 74fe75a9d7..8a75db9da8 100644 --- a/test/Api.Test/AdminConsole/Models/Request/Organizations/OrganizationIntegrationConfigurationRequestModelTests.cs +++ b/test/Api.Test/AdminConsole/Models/Request/Organizations/OrganizationIntegrationConfigurationRequestModelTests.cs @@ -39,7 +39,7 @@ public class OrganizationIntegrationConfigurationRequestModelTests [Theory] [InlineData(data: "")] [InlineData(data: " ")] - public void IsValidForType_EmptyNonNullHecConfiguration_ReturnsFalse(string? config) + public void IsValidForType_EmptyNonNullConfiguration_ReturnsFalse(string? config) { var model = new OrganizationIntegrationConfigurationRequestModel { @@ -48,10 +48,12 @@ public class OrganizationIntegrationConfigurationRequestModelTests }; Assert.False(condition: model.IsValidForType(IntegrationType.Hec)); + Assert.False(condition: model.IsValidForType(IntegrationType.Datadog)); + Assert.False(condition: model.IsValidForType(IntegrationType.Teams)); } [Fact] - public void IsValidForType_NullHecConfiguration_ReturnsTrue() + public void IsValidForType_NullConfiguration_ReturnsTrue() { var model = new OrganizationIntegrationConfigurationRequestModel { @@ -60,32 +62,8 @@ public class OrganizationIntegrationConfigurationRequestModelTests }; Assert.True(condition: model.IsValidForType(IntegrationType.Hec)); - } - - [Theory] - [InlineData(data: "")] - [InlineData(data: " ")] - public void IsValidForType_EmptyNonNullDatadogConfiguration_ReturnsFalse(string? config) - { - var model = new OrganizationIntegrationConfigurationRequestModel - { - Configuration = config, - Template = "template" - }; - - Assert.False(condition: model.IsValidForType(IntegrationType.Datadog)); - } - - [Fact] - public void IsValidForType_NullDatadogConfiguration_ReturnsTrue() - { - var model = new OrganizationIntegrationConfigurationRequestModel - { - Configuration = null, - Template = "template" - }; - Assert.True(condition: model.IsValidForType(IntegrationType.Datadog)); + Assert.True(condition: model.IsValidForType(IntegrationType.Teams)); } [Theory] @@ -107,6 +85,8 @@ public class OrganizationIntegrationConfigurationRequestModelTests Assert.False(condition: model.IsValidForType(IntegrationType.Slack)); Assert.False(condition: model.IsValidForType(IntegrationType.Webhook)); Assert.False(condition: model.IsValidForType(IntegrationType.Hec)); + Assert.False(condition: model.IsValidForType(IntegrationType.Datadog)); + Assert.False(condition: model.IsValidForType(IntegrationType.Teams)); } [Fact] @@ -121,6 +101,8 @@ public class OrganizationIntegrationConfigurationRequestModelTests Assert.False(condition: model.IsValidForType(IntegrationType.Slack)); Assert.False(condition: model.IsValidForType(IntegrationType.Webhook)); Assert.False(condition: model.IsValidForType(IntegrationType.Hec)); + Assert.False(condition: model.IsValidForType(IntegrationType.Datadog)); + Assert.False(condition: model.IsValidForType(IntegrationType.Teams)); } diff --git a/test/Api.Test/AdminConsole/Models/Request/Organizations/OrganizationIntegrationRequestModelTests.cs b/test/Api.Test/AdminConsole/Models/Request/Organizations/OrganizationIntegrationRequestModelTests.cs index 81927a1bfe..1303e5fe89 100644 --- a/test/Api.Test/AdminConsole/Models/Request/Organizations/OrganizationIntegrationRequestModelTests.cs +++ b/test/Api.Test/AdminConsole/Models/Request/Organizations/OrganizationIntegrationRequestModelTests.cs @@ -57,6 +57,22 @@ public class OrganizationIntegrationRequestModelTests Assert.Contains("cannot be created directly", results[0].ErrorMessage); } + [Fact] + public void Validate_Teams_ReturnsCannotBeCreatedDirectlyError() + { + var model = new OrganizationIntegrationRequestModel + { + Type = IntegrationType.Teams, + 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() { diff --git a/test/Api.Test/AdminConsole/Models/Response/Organizations/OrganizationIntegrationResponseModelTests.cs b/test/Api.Test/AdminConsole/Models/Response/Organizations/OrganizationIntegrationResponseModelTests.cs index babdf3894d..28bc07de38 100644 --- a/test/Api.Test/AdminConsole/Models/Response/Organizations/OrganizationIntegrationResponseModelTests.cs +++ b/test/Api.Test/AdminConsole/Models/Response/Organizations/OrganizationIntegrationResponseModelTests.cs @@ -1,8 +1,11 @@ #nullable enable +using System.Text.Json; using Bit.Api.AdminConsole.Models.Response.Organizations; using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Models.Data.EventIntegrations; using Bit.Core.Enums; +using Bit.Core.Models.Teams; using Bit.Test.Common.AutoFixture.Attributes; using Xunit; @@ -58,6 +61,46 @@ public class OrganizationIntegrationResponseModelTests Assert.Equal(OrganizationIntegrationStatus.Completed, model.Status); } + [Theory, BitAutoData] + public void Status_Teams_NullConfig_ReturnsInitiated(OrganizationIntegration oi) + { + oi.Type = IntegrationType.Teams; + oi.Configuration = null; + + var model = new OrganizationIntegrationResponseModel(oi); + + Assert.Equal(OrganizationIntegrationStatus.Initiated, model.Status); + } + + [Theory, BitAutoData] + public void Status_Teams_WithTenantAndTeamsConfig_ReturnsInProgress(OrganizationIntegration oi) + { + oi.Type = IntegrationType.Teams; + oi.Configuration = JsonSerializer.Serialize(new TeamsIntegration( + TenantId: "tenant", Teams: [new TeamInfo() { DisplayName = "Team", Id = "TeamId", TenantId = "tenant" }] + )); + + var model = new OrganizationIntegrationResponseModel(oi); + + Assert.Equal(OrganizationIntegrationStatus.InProgress, model.Status); + } + + [Theory, BitAutoData] + public void Status_Teams_WithCompletedConfig_ReturnsCompleted(OrganizationIntegration oi) + { + oi.Type = IntegrationType.Teams; + oi.Configuration = JsonSerializer.Serialize(new TeamsIntegration( + TenantId: "tenant", + Teams: [new TeamInfo() { DisplayName = "Team", Id = "TeamId", TenantId = "tenant" }], + ServiceUrl: new Uri("https://example.com"), + ChannelId: "channellId" + )); + + var model = new OrganizationIntegrationResponseModel(oi); + + Assert.Equal(OrganizationIntegrationStatus.Completed, model.Status); + } + [Theory, BitAutoData] public void Status_Webhook_AlwaysCompleted(OrganizationIntegration oi) { diff --git a/test/Core.Test/AdminConsole/Models/Data/Teams/TeamsBotCredentialProviderTests.cs b/test/Core.Test/AdminConsole/Models/Data/Teams/TeamsBotCredentialProviderTests.cs new file mode 100644 index 0000000000..d3d433727f --- /dev/null +++ b/test/Core.Test/AdminConsole/Models/Data/Teams/TeamsBotCredentialProviderTests.cs @@ -0,0 +1,56 @@ +using Bit.Core.AdminConsole.Models.Teams; +using Microsoft.Bot.Connector.Authentication; +using Xunit; + +namespace Bit.Core.Test.Models.Data.Teams; + +public class TeamsBotCredentialProviderTests +{ + private string _clientId = "client id"; + private string _clientSecret = "client secret"; + + [Fact] + public async Task IsValidAppId_MustMatchClientId() + { + var sut = new TeamsBotCredentialProvider(_clientId, _clientSecret); + + Assert.True(await sut.IsValidAppIdAsync(_clientId)); + Assert.False(await sut.IsValidAppIdAsync("Different id")); + } + + [Fact] + public async Task GetAppPasswordAsync_MatchingClientId_ReturnsClientSecret() + { + var sut = new TeamsBotCredentialProvider(_clientId, _clientSecret); + var password = await sut.GetAppPasswordAsync(_clientId); + Assert.Equal(_clientSecret, password); + } + + [Fact] + public async Task GetAppPasswordAsync_NotMatchingClientId_ReturnsNull() + { + var sut = new TeamsBotCredentialProvider(_clientId, _clientSecret); + Assert.Null(await sut.GetAppPasswordAsync("Different id")); + } + + [Fact] + public async Task IsAuthenticationDisabledAsync_ReturnsFalse() + { + var sut = new TeamsBotCredentialProvider(_clientId, _clientSecret); + Assert.False(await sut.IsAuthenticationDisabledAsync()); + } + + [Fact] + public async Task ValidateIssuerAsync_ExpectedIssuer_ReturnsTrue() + { + var sut = new TeamsBotCredentialProvider(_clientId, _clientSecret); + Assert.True(await sut.ValidateIssuerAsync(AuthenticationConstants.ToBotFromChannelTokenIssuer)); + } + + [Fact] + public async Task ValidateIssuerAsync_UnexpectedIssuer_ReturnsFalse() + { + var sut = new TeamsBotCredentialProvider(_clientId, _clientSecret); + Assert.False(await sut.ValidateIssuerAsync("unexpected issuer")); + } +} diff --git a/test/Core.Test/AdminConsole/Services/IntegrationTypeTests.cs b/test/Core.Test/AdminConsole/Services/IntegrationTypeTests.cs index 98cf974df8..715bffaab1 100644 --- a/test/Core.Test/AdminConsole/Services/IntegrationTypeTests.cs +++ b/test/Core.Test/AdminConsole/Services/IntegrationTypeTests.cs @@ -5,17 +5,6 @@ namespace Bit.Core.Test.Services; public class IntegrationTypeTests { - [Fact] - public void ToRoutingKey_Slack_Succeeds() - { - Assert.Equal("slack", IntegrationType.Slack.ToRoutingKey()); - } - [Fact] - public void ToRoutingKey_Webhook_Succeeds() - { - Assert.Equal("webhook", IntegrationType.Webhook.ToRoutingKey()); - } - [Fact] public void ToRoutingKey_CloudBillingSync_ThrowsException() { @@ -27,4 +16,34 @@ public class IntegrationTypeTests { Assert.Throws(() => IntegrationType.Scim.ToRoutingKey()); } + + [Fact] + public void ToRoutingKey_Slack_Succeeds() + { + Assert.Equal("slack", IntegrationType.Slack.ToRoutingKey()); + } + + [Fact] + public void ToRoutingKey_Webhook_Succeeds() + { + Assert.Equal("webhook", IntegrationType.Webhook.ToRoutingKey()); + } + + [Fact] + public void ToRoutingKey_Hec_Succeeds() + { + Assert.Equal("hec", IntegrationType.Hec.ToRoutingKey()); + } + + [Fact] + public void ToRoutingKey_Datadog_Succeeds() + { + Assert.Equal("datadog", IntegrationType.Datadog.ToRoutingKey()); + } + + [Fact] + public void ToRoutingKey_Teams_Succeeds() + { + Assert.Equal("teams", IntegrationType.Teams.ToRoutingKey()); + } } diff --git a/test/Core.Test/AdminConsole/Services/SlackServiceTests.cs b/test/Core.Test/AdminConsole/Services/SlackServiceTests.cs index 2d0ca2433a..48dd9c490e 100644 --- a/test/Core.Test/AdminConsole/Services/SlackServiceTests.cs +++ b/test/Core.Test/AdminConsole/Services/SlackServiceTests.cs @@ -296,6 +296,18 @@ public class SlackServiceTests Assert.Equal("test-access-token", result); } + [Theory] + [InlineData("test-code", "")] + [InlineData("", "https://example.com/callback")] + [InlineData("", "")] + public async Task ObtainTokenViaOAuth_ReturnsEmptyString_WhenCodeOrRedirectUrlIsEmpty(string code, string redirectUrl) + { + var sutProvider = GetSutProvider(); + var result = await sutProvider.Sut.ObtainTokenViaOAuth(code, redirectUrl); + + Assert.Equal(string.Empty, result); + } + [Fact] public async Task ObtainTokenViaOAuth_ReturnsEmptyString_WhenErrorResponse() { diff --git a/test/Core.Test/AdminConsole/Services/TeamsIntegrationHandlerTests.cs b/test/Core.Test/AdminConsole/Services/TeamsIntegrationHandlerTests.cs new file mode 100644 index 0000000000..b744a6aa69 --- /dev/null +++ b/test/Core.Test/AdminConsole/Services/TeamsIntegrationHandlerTests.cs @@ -0,0 +1,126 @@ +using Bit.Core.AdminConsole.Models.Data.EventIntegrations; +using Bit.Core.Services; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Bit.Test.Common.Helpers; +using Microsoft.Rest; +using NSubstitute; +using NSubstitute.ExceptionExtensions; +using Xunit; + +namespace Bit.Core.Test.Services; + +[SutProviderCustomize] +public class TeamsIntegrationHandlerTests +{ + private readonly ITeamsService _teamsService = Substitute.For(); + private readonly string _channelId = "C12345"; + private readonly Uri _serviceUrl = new Uri("http://localhost"); + + private SutProvider GetSutProvider() + { + return new SutProvider() + .SetDependency(_teamsService) + .Create(); + } + + [Theory, BitAutoData] + public async Task HandleAsync_SuccessfulRequest_ReturnsSuccess(IntegrationMessage message) + { + var sutProvider = GetSutProvider(); + message.Configuration = new TeamsIntegrationConfigurationDetails(_channelId, _serviceUrl); + + var result = await sutProvider.Sut.HandleAsync(message); + + Assert.True(result.Success); + Assert.Equal(result.Message, message); + + await sutProvider.GetDependency().Received(1).SendMessageToChannelAsync( + Arg.Is(AssertHelper.AssertPropertyEqual(_serviceUrl)), + Arg.Is(AssertHelper.AssertPropertyEqual(_channelId)), + Arg.Is(AssertHelper.AssertPropertyEqual(message.RenderedTemplate)) + ); + } + + + [Theory, BitAutoData] + public async Task HandleAsync_HttpExceptionNonRetryable_ReturnsFalseAndNotRetryable(IntegrationMessage message) + { + var sutProvider = GetSutProvider(); + message.Configuration = new TeamsIntegrationConfigurationDetails(_channelId, _serviceUrl); + + sutProvider.GetDependency() + .SendMessageToChannelAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .ThrowsAsync(new HttpOperationException("Server error") + { + Response = new HttpResponseMessageWrapper( + new HttpResponseMessage(System.Net.HttpStatusCode.Forbidden), + "Forbidden" + ) + } + ); + var result = await sutProvider.Sut.HandleAsync(message); + + Assert.False(result.Success); + Assert.False(result.Retryable); + Assert.Equal(result.Message, message); + + await sutProvider.GetDependency().Received(1).SendMessageToChannelAsync( + Arg.Is(AssertHelper.AssertPropertyEqual(_serviceUrl)), + Arg.Is(AssertHelper.AssertPropertyEqual(_channelId)), + Arg.Is(AssertHelper.AssertPropertyEqual(message.RenderedTemplate)) + ); + } + + [Theory, BitAutoData] + public async Task HandleAsync_HttpExceptionRetryable_ReturnsFalseAndRetryable(IntegrationMessage message) + { + var sutProvider = GetSutProvider(); + message.Configuration = new TeamsIntegrationConfigurationDetails(_channelId, _serviceUrl); + + sutProvider.GetDependency() + .SendMessageToChannelAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .ThrowsAsync(new HttpOperationException("Server error") + { + Response = new HttpResponseMessageWrapper( + new HttpResponseMessage(System.Net.HttpStatusCode.TooManyRequests), + "Too Many Requests" + ) + } + ); + + var result = await sutProvider.Sut.HandleAsync(message); + + Assert.False(result.Success); + Assert.True(result.Retryable); + Assert.Equal(result.Message, message); + + await sutProvider.GetDependency().Received(1).SendMessageToChannelAsync( + Arg.Is(AssertHelper.AssertPropertyEqual(_serviceUrl)), + Arg.Is(AssertHelper.AssertPropertyEqual(_channelId)), + Arg.Is(AssertHelper.AssertPropertyEqual(message.RenderedTemplate)) + ); + } + + [Theory, BitAutoData] + public async Task HandleAsync_UnknownException_ReturnsFalseAndNotRetryable(IntegrationMessage message) + { + var sutProvider = GetSutProvider(); + message.Configuration = new TeamsIntegrationConfigurationDetails(_channelId, _serviceUrl); + + sutProvider.GetDependency() + .SendMessageToChannelAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .ThrowsAsync(new Exception("Unknown error")); + var result = await sutProvider.Sut.HandleAsync(message); + + Assert.False(result.Success); + Assert.False(result.Retryable); + Assert.Equal(result.Message, message); + + await sutProvider.GetDependency().Received(1).SendMessageToChannelAsync( + Arg.Is(AssertHelper.AssertPropertyEqual(_serviceUrl)), + Arg.Is(AssertHelper.AssertPropertyEqual(_channelId)), + Arg.Is(AssertHelper.AssertPropertyEqual(message.RenderedTemplate)) + ); + } +} diff --git a/test/Core.Test/AdminConsole/Services/TeamsServiceTests.cs b/test/Core.Test/AdminConsole/Services/TeamsServiceTests.cs new file mode 100644 index 0000000000..17d65f3237 --- /dev/null +++ b/test/Core.Test/AdminConsole/Services/TeamsServiceTests.cs @@ -0,0 +1,289 @@ +#nullable enable + +using System.Net; +using System.Text.Json; +using System.Web; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Models.Data.EventIntegrations; +using Bit.Core.Models.Teams; +using Bit.Core.Repositories; +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 TeamsServiceTests +{ + private readonly MockedHttpMessageHandler _handler; + private readonly HttpClient _httpClient; + + public TeamsServiceTests() + { + _handler = new MockedHttpMessageHandler(); + _httpClient = _handler.ToHttpClient(); + } + + private SutProvider GetSutProvider() + { + var clientFactory = Substitute.For(); + clientFactory.CreateClient(TeamsService.HttpClientName).Returns(_httpClient); + + var globalSettings = Substitute.For(); + globalSettings.Teams.LoginBaseUrl.Returns("https://login.example.com"); + globalSettings.Teams.GraphBaseUrl.Returns("https://graph.example.com"); + + return new SutProvider() + .SetDependency(clientFactory) + .SetDependency(globalSettings) + .Create(); + } + + [Fact] + public void GetRedirectUrl_ReturnsCorrectUrl() + { + var sutProvider = GetSutProvider(); + var clientId = sutProvider.GetDependency().Teams.ClientId; + var scopes = sutProvider.GetDependency().Teams.Scopes; + var callbackUrl = "https://example.com/callback"; + var state = Guid.NewGuid().ToString(); + var result = sutProvider.Sut.GetRedirectUrl(callbackUrl, state); + + var uri = new Uri(result); + var query = HttpUtility.ParseQueryString(uri.Query); + + Assert.Equal(clientId, query["client_id"]); + Assert.Equal(scopes, query["scope"]); + Assert.Equal(callbackUrl, query["redirect_uri"]); + Assert.Equal(state, query["state"]); + Assert.Equal("login.example.com", uri.Host); + Assert.Equal("/common/oauth2/v2.0/authorize", uri.AbsolutePath); + } + + [Fact] + public async Task ObtainTokenViaOAuth_Success_ReturnsAccessToken() + { + var sutProvider = GetSutProvider(); + var jsonResponse = JsonSerializer.Serialize(new + { + access_token = "test-access-token" + }); + + _handler.When("https://login.example.com/common/oauth2/v2.0/token") + .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); + } + + [Theory] + [InlineData("test-code", "")] + [InlineData("", "https://example.com/callback")] + [InlineData("", "")] + public async Task ObtainTokenViaOAuth_CodeOrRedirectUrlIsEmpty_ReturnsEmptyString(string code, string redirectUrl) + { + var sutProvider = GetSutProvider(); + var result = await sutProvider.Sut.ObtainTokenViaOAuth(code, redirectUrl); + + Assert.Equal(string.Empty, result); + } + + [Fact] + public async Task ObtainTokenViaOAuth_HttpFailure_ReturnsEmptyString() + { + var sutProvider = GetSutProvider(); + _handler.When("https://login.example.com/common/oauth2/v2.0/token") + .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 ObtainTokenViaOAuth_UnknownResponse_ReturnsEmptyString() + { + var sutProvider = GetSutProvider(); + + _handler.When("https://login.example.com/common/oauth2/v2.0/token") + .RespondWith(HttpStatusCode.OK) + .WithContent(new StringContent("Not an expected response")); + + var result = await sutProvider.Sut.ObtainTokenViaOAuth("test-code", "https://example.com/callback"); + + Assert.Equal(string.Empty, result); + } + + [Fact] + public async Task GetJoinedTeamsAsync_Success_ReturnsTeams() + { + var sutProvider = GetSutProvider(); + + var jsonResponse = JsonSerializer.Serialize(new + { + value = new[] + { + new { id = "team1", displayName = "Team One" }, + new { id = "team2", displayName = "Team Two" } + } + }); + + _handler.When("https://graph.example.com/me/joinedTeams") + .RespondWith(HttpStatusCode.OK) + .WithContent(new StringContent(jsonResponse)); + + var result = await sutProvider.Sut.GetJoinedTeamsAsync("fake-access-token"); + + Assert.Equal(2, result.Count); + Assert.Contains(result, t => t is { Id: "team1", DisplayName: "Team One" }); + Assert.Contains(result, t => t is { Id: "team2", DisplayName: "Team Two" }); + } + + [Fact] + public async Task GetJoinedTeamsAsync_ServerReturnsEmpty_ReturnsEmptyList() + { + var sutProvider = GetSutProvider(); + + var jsonResponse = JsonSerializer.Serialize(new { value = (object?)null }); + + _handler.When("https://graph.example.com/me/joinedTeams") + .RespondWith(HttpStatusCode.OK) + .WithContent(new StringContent(jsonResponse)); + + var result = await sutProvider.Sut.GetJoinedTeamsAsync("fake-access-token"); + + Assert.NotNull(result); + Assert.Empty(result); + } + + [Fact] + public async Task GetJoinedTeamsAsync_ServerErrorCode_ReturnsEmptyList() + { + var sutProvider = GetSutProvider(); + + _handler.When("https://graph.example.com/me/joinedTeams") + .RespondWith(HttpStatusCode.Unauthorized) + .WithContent(new StringContent("Unauthorized")); + + var result = await sutProvider.Sut.GetJoinedTeamsAsync("fake-access-token"); + + Assert.NotNull(result); + Assert.Empty(result); + } + + [Theory, BitAutoData] + public async Task HandleIncomingAppInstall_Success_UpdatesTeamsIntegration( + OrganizationIntegration integration) + { + var sutProvider = GetSutProvider(); + var tenantId = Guid.NewGuid().ToString(); + var teamId = Guid.NewGuid().ToString(); + var conversationId = Guid.NewGuid().ToString(); + var serviceUrl = new Uri("https://localhost"); + var initiatedConfiguration = new TeamsIntegration(TenantId: tenantId, Teams: + [ + new TeamInfo() { Id = teamId, DisplayName = "test team", TenantId = tenantId }, + new TeamInfo() { Id = Guid.NewGuid().ToString(), DisplayName = "other team", TenantId = tenantId }, + new TeamInfo() { Id = Guid.NewGuid().ToString(), DisplayName = "third team", TenantId = tenantId } + ]); + integration.Configuration = JsonSerializer.Serialize(initiatedConfiguration); + + sutProvider.GetDependency() + .GetByTeamsConfigurationTenantIdTeamId(tenantId, teamId) + .Returns(integration); + + OrganizationIntegration? capturedIntegration = null; + await sutProvider.GetDependency() + .UpsertAsync(Arg.Do(x => capturedIntegration = x)); + + await sutProvider.Sut.HandleIncomingAppInstallAsync( + conversationId: conversationId, + serviceUrl: serviceUrl, + teamId: teamId, + tenantId: tenantId + ); + + await sutProvider.GetDependency().Received(1).GetByTeamsConfigurationTenantIdTeamId(tenantId, teamId); + Assert.NotNull(capturedIntegration); + var configuration = JsonSerializer.Deserialize(capturedIntegration.Configuration ?? string.Empty); + Assert.NotNull(configuration); + Assert.NotNull(configuration.ServiceUrl); + Assert.Equal(serviceUrl, configuration.ServiceUrl); + Assert.Equal(conversationId, configuration.ChannelId); + } + + [Fact] + public async Task HandleIncomingAppInstall_NoIntegrationMatched_DoesNothing() + { + var sutProvider = GetSutProvider(); + await sutProvider.Sut.HandleIncomingAppInstallAsync( + conversationId: "conversationId", + serviceUrl: new Uri("https://localhost"), + teamId: "teamId", + tenantId: "tenantId" + ); + + await sutProvider.GetDependency().Received(1).GetByTeamsConfigurationTenantIdTeamId("tenantId", "teamId"); + await sutProvider.GetDependency().DidNotReceive().UpsertAsync(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task HandleIncomingAppInstall_MatchedIntegrationAlreadySetup_DoesNothing( + OrganizationIntegration integration) + { + var sutProvider = GetSutProvider(); + var tenantId = Guid.NewGuid().ToString(); + var teamId = Guid.NewGuid().ToString(); + var initiatedConfiguration = new TeamsIntegration( + TenantId: tenantId, + Teams: [new TeamInfo() { Id = teamId, DisplayName = "test team", TenantId = tenantId }], + ChannelId: "ChannelId", + ServiceUrl: new Uri("https://localhost") + ); + integration.Configuration = JsonSerializer.Serialize(initiatedConfiguration); + + sutProvider.GetDependency() + .GetByTeamsConfigurationTenantIdTeamId(tenantId, teamId) + .Returns(integration); + + await sutProvider.Sut.HandleIncomingAppInstallAsync( + conversationId: "conversationId", + serviceUrl: new Uri("https://localhost"), + teamId: teamId, + tenantId: tenantId + ); + + await sutProvider.GetDependency().Received(1).GetByTeamsConfigurationTenantIdTeamId(tenantId, teamId); + await sutProvider.GetDependency().DidNotReceive().UpsertAsync(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task HandleIncomingAppInstall_MatchedIntegrationWithMissingConfiguration_DoesNothing( + OrganizationIntegration integration) + { + var sutProvider = GetSutProvider(); + integration.Configuration = null; + + sutProvider.GetDependency() + .GetByTeamsConfigurationTenantIdTeamId("tenantId", "teamId") + .Returns(integration); + + await sutProvider.Sut.HandleIncomingAppInstallAsync( + conversationId: "conversationId", + serviceUrl: new Uri("https://localhost"), + teamId: "teamId", + tenantId: "tenantId" + ); + + await sutProvider.GetDependency().Received(1).GetByTeamsConfigurationTenantIdTeamId("tenantId", "teamId"); + await sutProvider.GetDependency().DidNotReceive().UpsertAsync(Arg.Any()); + } +} diff --git a/util/Migrator/DbScripts/2025-10-3_00_AddOrganizationIntegration_ReadByTeamsConfigurationTenantIdTeamIdStoredProcedure.sql b/util/Migrator/DbScripts/2025-10-3_00_AddOrganizationIntegration_ReadByTeamsConfigurationTenantIdTeamIdStoredProcedure.sql new file mode 100644 index 0000000000..16ac419cad --- /dev/null +++ b/util/Migrator/DbScripts/2025-10-3_00_AddOrganizationIntegration_ReadByTeamsConfigurationTenantIdTeamIdStoredProcedure.sql @@ -0,0 +1,18 @@ +CREATE OR ALTER PROCEDURE [dbo].[OrganizationIntegration_ReadByTeamsConfigurationTenantIdTeamId] + @TenantId NVARCHAR(200), + @TeamId NVARCHAR(200) +AS +BEGIN + SET NOCOUNT ON; + +SELECT TOP 1 * +FROM [dbo].[OrganizationIntegrationView] + CROSS APPLY OPENJSON([Configuration], '$.Teams') + WITH ( TeamId NVARCHAR(MAX) '$.id' ) t +WHERE [Type] = 7 + AND JSON_VALUE([Configuration], '$.TenantId') = @TenantId + AND t.TeamId = @TeamId + AND JSON_VALUE([Configuration], '$.ChannelId') IS NULL + AND JSON_VALUE([Configuration], '$.ServiceUrl') IS NULL; +END +GO