1
0
mirror of https://github.com/bitwarden/server synced 2025-12-06 00:03:34 +00:00

Add Microsoft Teams integration (#6410)

* Add Microsoft Teams integration

* Fix method naming error

* Expand and clean up unit test coverage

* Update with PR feedback

* Add documentation, add In Progress logic/tests for Teams

* Fixed lowercase Slack

* Added docs; Updated PR suggestions;

* Fix broken tests
This commit is contained in:
Brant DeBow
2025-10-10 10:39:31 -04:00
committed by GitHub
parent 3272586e31
commit a565fd9ee4
41 changed files with 1839 additions and 99 deletions

View File

@@ -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"
}
}
}
]
}
]
}

View File

@@ -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<IActionResult> 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()

View File

@@ -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<IActionResult> 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<IActionResult> 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);
}
}

View File

@@ -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;

View File

@@ -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:

View File

@@ -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<TeamsIntegration>(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)

View File

@@ -229,8 +229,9 @@ public class Startup
services.AddHostedService<Core.HostedServices.ApplicationCacheHostedService>();
}
// 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(

View File

@@ -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}");
}

View File

@@ -0,0 +1,12 @@
using Bit.Core.Models.Teams;
namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations;
public record TeamsIntegration(
string TenantId,
IReadOnlyList<TeamInfo> Teams,
string? ChannelId = null,
Uri? ServiceUrl = null)
{
public bool IsCompleted => !string.IsNullOrEmpty(ChannelId) && ServiceUrl is not null;
}

View File

@@ -0,0 +1,3 @@
namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations;
public record TeamsIntegrationConfigurationDetails(string ChannelId, Uri ServiceUrl);

View File

@@ -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;
}
}

View File

@@ -1,6 +1,4 @@
#nullable enable
using System.Text.Json.Serialization;
using System.Text.Json.Serialization;
namespace Bit.Core.Models.Slack;

View File

@@ -0,0 +1,41 @@
using System.Text.Json.Serialization;
namespace Bit.Core.Models.Teams;
/// <summary>Represents the response returned by the Microsoft OAuth 2.0 token endpoint.
/// See <see href="https://learn.microsoft.com/graph/auth-v2-user">Microsoft identity platform and OAuth 2.0
/// authorization code flow</see>.</summary>
public class TeamsOAuthResponse
{
/// <summary>The access token issued by Microsoft, used to call the Microsoft Graph API.</summary>
[JsonPropertyName("access_token")]
public string AccessToken { get; set; } = string.Empty;
}
/// <summary>Represents the response from the <c>/me/joinedTeams</c> Microsoft Graph API call.
/// See <see href="https://learn.microsoft.com/graph/api/user-list-joinedteams">List joined teams -
/// Microsoft Graph v1.0</see>.</summary>
public class JoinedTeamsResponse
{
/// <summary>The collection of teams that the user has joined.</summary>
[JsonPropertyName("value")]
public List<TeamInfo> Value { get; set; } = [];
}
/// <summary>Represents a Microsoft Teams team returned by the Graph API.
/// See <see href="https://learn.microsoft.com/graph/api/resources/team">Team resource type -
/// Microsoft Graph v1.0</see>.</summary>
public class TeamInfo
{
/// <summary>The unique identifier of the team.</summary>
[JsonPropertyName("id")]
public string Id { get; set; } = string.Empty;
/// <summary>The name of the team.</summary>
[JsonPropertyName("displayName")]
public string DisplayName { get; set; } = string.Empty;
/// <summary>The ID of the Microsoft Entra tenant for this team.</summary>
[JsonPropertyName("tenantId")]
public string TenantId { get; set; } = string.Empty;
}

View File

@@ -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<bool> IsValidAppIdAsync(string appId)
{
return Task.FromResult(appId == clientId);
}
public Task<string?> GetAppPasswordAsync(string appId)
{
return Task.FromResult(appId == clientId ? clientSecret : null);
}
public Task<bool> IsAuthenticationDisabledAsync()
{
return Task.FromResult(false);
}
public Task<bool> ValidateIssuerAsync(string issuer)
{
return Task.FromResult(issuer == _microsoftBotFrameworkIssuer);
}
}

View File

@@ -5,4 +5,6 @@ namespace Bit.Core.Repositories;
public interface IOrganizationIntegrationRepository : IRepository<OrganizationIntegration, Guid>
{
Task<List<OrganizationIntegration>> GetManyByOrganizationAsync(Guid organizationId);
Task<OrganizationIntegration?> GetByTeamsConfigurationTenantIdTeamId(string tenantId, string teamId);
}

View File

@@ -1,11 +1,59 @@
namespace Bit.Core.Services;
/// <summary>Defines operations for interacting with Slack, including OAuth authentication, channel discovery,
/// and sending messages.</summary>
public interface ISlackService
{
/// <remarks>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.</remarks>
/// <summary>Retrieves the ID of a Slack channel by name.
/// See <see href="https://api.slack.com/methods/conversations.list">conversations.list API</see>.</summary>
/// <param name="token">A valid Slack OAuth access token.</param>
/// <param name="channelName">The name of the channel to look up.</param>
/// <returns>The channel ID if found; otherwise, an empty string.</returns>
Task<string> GetChannelIdAsync(string token, string channelName);
/// <remarks>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.</remarks>
/// <summary>Retrieves the IDs of multiple Slack channels by name.
/// See <see href="https://api.slack.com/methods/conversations.list">conversations.list API</see>.</summary>
/// <param name="token">A valid Slack OAuth access token.</param>
/// <param name="channelNames">A list of channel names to look up.</param>
/// <returns>A list of matching channel IDs. Channels that cannot be found are omitted.</returns>
Task<List<string>> GetChannelIdsAsync(string token, List<string> channelNames);
/// <remarks>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.</remarks>
/// <summary>Retrieves the DM channel ID for a Slack user by email.
/// See <see href="https://api.slack.com/methods/users.lookupByEmail">users.lookupByEmail API</see> and
/// <see href="https://api.slack.com/methods/conversations.open">conversations.open API</see>.</summary>
/// <param name="token">A valid Slack OAuth access token.</param>
/// <param name="email">The email address of the user to open a DM with.</param>
/// <returns>The DM channel ID if successful; otherwise, an empty string.</returns>
Task<string> GetDmChannelByEmailAsync(string token, string email);
/// <summary>Builds the Slack OAuth 2.0 authorization URL for the app.
/// See <see href="https://api.slack.com/authentication/oauth-v2">Slack OAuth v2 documentation</see>.</summary>
/// <param name="callbackUrl">The absolute redirect URI that Slack will call after user authorization.
/// Must match the URI registered with the app configuration.</param>
/// <param name="state">A state token used to correlate the request and callback and prevent CSRF attacks.</param>
/// <returns>The full authorization URL to which the user should be redirected to begin the sign-in process.</returns>
string GetRedirectUrl(string callbackUrl, string state);
/// <summary>Exchanges a Slack OAuth code for an access token.
/// See <see href="https://api.slack.com/methods/oauth.v2.access">oauth.v2.access API</see>.</summary>
/// <param name="code">The authorization code returned by Slack via the callback URL after user authorization.</param>
/// <param name="redirectUrl">The redirect URI that was used in the authorization request.</param>
/// <returns>A valid Slack access token if successful; otherwise, an empty string.</returns>
Task<string> ObtainTokenViaOAuth(string code, string redirectUrl);
/// <summary>Sends a message to a Slack channel by ID.
/// See <see href="https://api.slack.com/methods/chat.postMessage">chat.postMessage API</see>.</summary>
/// <remarks>This is used primarily by the <see cref="SlackIntegrationHandler"/> to send events to the
/// Slack channel.</remarks>
/// <param name="token">A valid Slack OAuth access token.</param>
/// <param name="message">The message text to send.</param>
/// <param name="channelId">The channel ID to send the message to.</param>
/// <returns>A task that completes when the message has been sent.</returns>
Task SendSlackMessageByChannelIdAsync(string token, string message, string channelId);
}

View File

@@ -0,0 +1,49 @@
using Bit.Core.Models.Teams;
namespace Bit.Core.Services;
/// <summary>
/// Service that provides functionality relating to the Microsoft Teams integration including OAuth,
/// team discovery and sending a message to a channel in Teams.
/// </summary>
public interface ITeamsService
{
/// <summary>
/// Generate the Microsoft Teams OAuth 2.0 authorization URL used to begin the sign-in flow.
/// </summary>
/// <param name="callbackUrl">The absolute redirect URI that Microsoft will call after user authorization.
/// Must match the URI registered with the app configuration.</param>
/// <param name="state">A state token used to correlate the request and callback and prevent CSRF attacks.</param>
/// <returns>The full authorization URL to which the user should be redirected to begin the sign-in process.</returns>
string GetRedirectUrl(string callbackUrl, string state);
/// <summary>
/// Exchange the OAuth code for a Microsoft Graph API access token.
/// </summary>
/// <param name="code">The code returned from Microsoft via the OAuth callback Url.</param>
/// <param name="redirectUrl">The same redirect URI that was passed to the authorization request.</param>
/// <returns>A valid Microsoft Graph access token if the exchange succeeds; otherwise, an empty string.</returns>
Task<string> ObtainTokenViaOAuth(string code, string redirectUrl);
/// <summary>
/// Get the Teams to which the authenticated user belongs via Microsoft Graph API.
/// </summary>
/// <param name="accessToken">A valid Microsoft Graph access token for the user (obtained via OAuth).</param>
/// <returns>A read-only list of <see cref="TeamInfo"/> objects representing the users joined teams.
/// Returns an empty list if the request fails or if the token is invalid.</returns>
Task<IReadOnlyList<TeamInfo>> GetJoinedTeamsAsync(string accessToken);
/// <summary>
/// Send a message to a specific channel in Teams.
/// </summary>
/// <remarks>This is used primarily by the <see cref="TeamsIntegrationHandler"/> to send events to the
/// Teams channel.</remarks>
/// <param name="serviceUri">The service URI associated with the Microsoft Bot Framework connector for the target
/// team. Obtained via the bot framework callback.</param>
/// <param name="channelId"> The conversation or channel ID where the message should be delivered. Obtained via
/// the bot framework callback.</param>
/// <param name="message">The message text to post to the channel.</param>
/// <returns>A task that completes when the message has been sent. Errors during message delivery are surfaced
/// as exceptions from the underlying connector client.</returns>
Task SendMessageToChannelAsync(Uri serviceUri, string channelId, string message);
}

View File

@@ -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`<br/>**Completed**:<br/>`{ "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" }`<br/><br/>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`<br/>**In Progress**: <br/> `{ "TenantID": "tenant", "Teams": ["Id": "team", DisplayName: "MyTeam"]}`<br/>**Completed**: <br/>`{ "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

View File

@@ -90,6 +90,12 @@ public class SlackService(
public async Task<string> 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[]
{

View File

@@ -0,0 +1,41 @@
using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
using Microsoft.Rest;
namespace Bit.Core.Services;
public class TeamsIntegrationHandler(
ITeamsService teamsService)
: IntegrationHandlerBase<TeamsIntegrationConfigurationDetails>
{
public override async Task<IntegrationHandlerResult> HandleAsync(
IntegrationMessage<TeamsIntegrationConfigurationDetails> 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;
}
}
}

View File

@@ -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<TeamsService> 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<string> 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<string, string>
{
{ "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<TeamsOAuthResponse>();
}
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<IReadOnlyList<TeamInfo>> 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<TeamInfo>();
}
var result = await response.Content.ReadFromJsonAsync<JoinedTeamsResponse>();
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<IInstallationUpdateActivity> 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<TeamsIntegration>(integration.Configuration);
if (teamsConfig is null || teamsConfig.IsCompleted)
{
return;
}
integration.Configuration = JsonSerializer.Serialize(teamsConfig with
{
ChannelId = conversationId,
ServiceUrl = serviceUrl
});
await integrationRepository.UpsertAsync(integration);
}
}

View File

@@ -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<string> ObtainTokenViaOAuth(string code, string redirectUrl)
{
return Task.FromResult(string.Empty);
}
public Task<IReadOnlyList<TeamInfo>> GetJoinedTeamsAsync(string accessToken)
{
return Task.FromResult<IReadOnlyList<TeamInfo>>(Array.Empty<TeamInfo>());
}
public Task SendMessageToChannelAsync(Uri serviceUri, string channelId, string message)
{
return Task.CompletedTask;
}
}

View File

@@ -38,6 +38,8 @@
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.10" />
<PackageReference Include="Microsoft.Azure.Cosmos" Version="3.52.0" />
<PackageReference Include="Microsoft.Azure.NotificationHubs" Version="4.2.0" />
<PackageReference Include="Microsoft.Bot.Builder" Version="4.23.0" />
<PackageReference Include="Microsoft.Bot.Connector" Version="4.23.0" />
<PackageReference Include="Microsoft.Data.SqlClient" Version="5.2.2" />
<PackageReference Include="Microsoft.Extensions.Caching.Cosmos" Version="1.7.0" />
<PackageReference Include="Microsoft.Extensions.Caching.SqlServer" Version="8.0.10" />

View File

@@ -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
{

View File

@@ -29,4 +29,17 @@ public class OrganizationIntegrationRepository : Repository<OrganizationIntegrat
return results.ToList();
}
}
public async Task<OrganizationIntegration?> GetByTeamsConfigurationTenantIdTeamId(string tenantId, string teamId)
{
using (var connection = new SqlConnection(ConnectionString))
{
var result = await connection.QuerySingleOrDefaultAsync<OrganizationIntegration>(
"[dbo].[OrganizationIntegration_ReadByTeamsConfigurationTenantIdTeamId]",
new { TenantId = tenantId, TeamId = teamId },
commandType: CommandType.StoredProcedure);
return result;
}
}
}

View File

@@ -26,4 +26,16 @@ public class OrganizationIntegrationRepository :
return await query.Run(dbContext).ToListAsync();
}
}
public async Task<Core.AdminConsole.Entities.OrganizationIntegration?> 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();
}
}
}

View File

@@ -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<OrganizationIntegration>
{
private readonly string _tenantId;
private readonly string _teamId;
public OrganizationIntegrationReadByTeamsConfigurationTenantIdTeamIdQuery(string tenantId, string teamId)
{
_tenantId = tenantId;
_teamId = teamId;
}
public IQueryable<OrganizationIntegration> 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;
}
}

View File

@@ -7,6 +7,7 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Bot.Builder.Integration.AspNet.Core" Version="4.23.0" />
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerGen" Version="9.0.4" />
</ItemGroup>

View File

@@ -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<TeamsService>();
services.TryAddSingleton<IBot>(sp => sp.GetRequiredService<TeamsService>());
services.TryAddSingleton<ITeamsService>(sp => sp.GetRequiredService<TeamsService>());
services.TryAddSingleton<IBotFrameworkHttpAdapter>(sp =>
new BotFrameworkHttpAdapter(
new TeamsBotCredentialProvider(
clientId: globalSettings.Teams.ClientId,
clientSecret: globalSettings.Teams.ClientSecret
)
)
);
}
else
{
services.TryAddSingleton<ITeamsService, NoopTeamsService>();
}
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<IIntegrationHandler<SlackIntegrationConfigurationDetails>, SlackIntegrationHandler>();
services.TryAddSingleton<IIntegrationHandler<WebhookIntegrationConfigurationDetails>, WebhookIntegrationHandler>();
services.TryAddSingleton<IIntegrationHandler<DatadogIntegrationConfigurationDetails>, DatadogIntegrationHandler>();
services.TryAddSingleton<IIntegrationHandler<TeamsIntegrationConfigurationDetails>, 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<WebhookIntegrationConfigurationDetails, WebhookListenerConfiguration>(webhookConfiguration);
services.AddRabbitMqIntegration<WebhookIntegrationConfigurationDetails, HecListenerConfiguration>(hecConfiguration);
services.AddRabbitMqIntegration<DatadogIntegrationConfigurationDetails, DatadogListenerConfiguration>(datadogConfiguration);
services.AddRabbitMqIntegration<TeamsIntegrationConfigurationDetails, TeamsListenerConfiguration>(teamsConfiguration);
}
if (IsAzureServiceBusEnabled(globalSettings))
@@ -967,6 +1001,7 @@ public static class ServiceCollectionExtensions
services.AddAzureServiceBusIntegration<WebhookIntegrationConfigurationDetails, WebhookListenerConfiguration>(webhookConfiguration);
services.AddAzureServiceBusIntegration<WebhookIntegrationConfigurationDetails, HecListenerConfiguration>(hecConfiguration);
services.AddAzureServiceBusIntegration<DatadogIntegrationConfigurationDetails, DatadogListenerConfiguration>(datadogConfiguration);
services.AddAzureServiceBusIntegration<TeamsIntegrationConfigurationDetails, TeamsListenerConfiguration>(teamsConfiguration);
}
return services;

View File

@@ -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

View File

@@ -34,7 +34,7 @@ public class SlackIntegrationControllerTests
integration.Configuration = null;
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.Sut.Url
.RouteUrl(Arg.Is<UrlRouteContext>(c => c.RouteName == nameof(SlackIntegrationController.CreateAsync)))
.RouteUrl(Arg.Is<UrlRouteContext>(c => c.RouteName == "SlackIntegration_Create"))
.Returns("https://localhost");
sutProvider.GetDependency<ISlackService>()
.ObtainTokenViaOAuth(_validSlackCode, Arg.Any<string>())
@@ -60,7 +60,7 @@ public class SlackIntegrationControllerTests
integration.Configuration = null;
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.Sut.Url
.RouteUrl(Arg.Is<UrlRouteContext>(c => c.RouteName == nameof(SlackIntegrationController.CreateAsync)))
.RouteUrl(Arg.Is<UrlRouteContext>(c => c.RouteName == "SlackIntegration_Create"))
.Returns("https://localhost");
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.GetByIdAsync(integration.Id)
@@ -80,7 +80,7 @@ public class SlackIntegrationControllerTests
integration.Configuration = null;
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.Sut.Url
.RouteUrl(Arg.Is<UrlRouteContext>(c => c.RouteName == nameof(SlackIntegrationController.CreateAsync)))
.RouteUrl(Arg.Is<UrlRouteContext>(c => c.RouteName == "SlackIntegration_Create"))
.Returns("https://localhost");
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.GetByIdAsync(integration.Id)
@@ -99,13 +99,13 @@ public class SlackIntegrationControllerTests
{
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.Sut.Url
.RouteUrl(Arg.Is<UrlRouteContext>(c => c.RouteName == nameof(SlackIntegrationController.CreateAsync)))
.RouteUrl(Arg.Is<UrlRouteContext>(c => c.RouteName == "SlackIntegration_Create"))
.Returns("https://localhost");
sutProvider.GetDependency<ISlackService>()
.ObtainTokenViaOAuth(_validSlackCode, Arg.Any<string>())
.Returns(_slackToken);
await Assert.ThrowsAsync<NotFoundException>(async () => await sutProvider.Sut.CreateAsync(_validSlackCode, String.Empty));
await Assert.ThrowsAsync<NotFoundException>(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<IUrlHelper>();
sutProvider.Sut.Url
.RouteUrl(Arg.Is<UrlRouteContext>(c => c.RouteName == nameof(SlackIntegrationController.CreateAsync)))
.RouteUrl(Arg.Is<UrlRouteContext>(c => c.RouteName == "SlackIntegration_Create"))
.Returns("https://localhost");
sutProvider.GetDependency<ISlackService>()
.ObtainTokenViaOAuth(_validSlackCode, Arg.Any<string>())
@@ -135,7 +135,7 @@ public class SlackIntegrationControllerTests
{
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.Sut.Url
.RouteUrl(Arg.Is<UrlRouteContext>(c => c.RouteName == nameof(SlackIntegrationController.CreateAsync)))
.RouteUrl(Arg.Is<UrlRouteContext>(c => c.RouteName == "SlackIntegration_Create"))
.Returns("https://localhost");
sutProvider.GetDependency<ISlackService>()
.ObtainTokenViaOAuth(_validSlackCode, Arg.Any<string>())
@@ -147,7 +147,7 @@ public class SlackIntegrationControllerTests
}
[Theory, BitAutoData]
public async Task CreateAsync_StateHasWrongOgranizationHash_ThrowsNotFound(
public async Task CreateAsync_StateHasWrongOrganizationHash_ThrowsNotFound(
SutProvider<SlackIntegrationController> sutProvider,
OrganizationIntegration integration,
OrganizationIntegration wrongOrgIntegration)
@@ -156,7 +156,7 @@ public class SlackIntegrationControllerTests
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.Sut.Url
.RouteUrl(Arg.Is<UrlRouteContext>(c => c.RouteName == nameof(SlackIntegrationController.CreateAsync)))
.RouteUrl(Arg.Is<UrlRouteContext>(c => c.RouteName == "SlackIntegration_Create"))
.Returns("https://localhost");
sutProvider.GetDependency<ISlackService>()
.ObtainTokenViaOAuth(_validSlackCode, Arg.Any<string>())
@@ -179,7 +179,7 @@ public class SlackIntegrationControllerTests
integration.Configuration = "{}";
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.Sut.Url
.RouteUrl(Arg.Is<UrlRouteContext>(c => c.RouteName == nameof(SlackIntegrationController.CreateAsync)))
.RouteUrl(Arg.Is<UrlRouteContext>(c => c.RouteName == "SlackIntegration_Create"))
.Returns("https://localhost");
sutProvider.GetDependency<ISlackService>()
.ObtainTokenViaOAuth(_validSlackCode, Arg.Any<string>())
@@ -201,7 +201,7 @@ public class SlackIntegrationControllerTests
integration.Configuration = null;
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.Sut.Url
.RouteUrl(Arg.Is<UrlRouteContext>(c => c.RouteName == nameof(SlackIntegrationController.CreateAsync)))
.RouteUrl(Arg.Is<UrlRouteContext>(c => c.RouteName == "SlackIntegration_Create"))
.Returns("https://localhost");
sutProvider.GetDependency<ISlackService>()
.ObtainTokenViaOAuth(_validSlackCode, Arg.Any<string>())
@@ -224,7 +224,7 @@ public class SlackIntegrationControllerTests
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.Sut.Url
.RouteUrl(Arg.Is<UrlRouteContext>(c => c.RouteName == nameof(SlackIntegrationController.CreateAsync)))
.RouteUrl(Arg.Is<UrlRouteContext>(c => c.RouteName == "SlackIntegration_Create"))
.Returns(expectedUrl);
sutProvider.GetDependency<ICurrentContext>()
.OrganizationOwner(integration.OrganizationId)
@@ -260,7 +260,7 @@ public class SlackIntegrationControllerTests
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.Sut.Url
.RouteUrl(Arg.Is<UrlRouteContext>(c => c.RouteName == nameof(SlackIntegrationController.CreateAsync)))
.RouteUrl(Arg.Is<UrlRouteContext>(c => c.RouteName == "SlackIntegration_Create"))
.Returns(expectedUrl);
sutProvider.GetDependency<ICurrentContext>()
.OrganizationOwner(organizationId)
@@ -291,7 +291,7 @@ public class SlackIntegrationControllerTests
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.Sut.Url
.RouteUrl(Arg.Is<UrlRouteContext>(c => c.RouteName == nameof(SlackIntegrationController.CreateAsync)))
.RouteUrl(Arg.Is<UrlRouteContext>(c => c.RouteName == "SlackIntegration_Create"))
.Returns(expectedUrl);
sutProvider.GetDependency<ICurrentContext>()
.OrganizationOwner(organizationId)
@@ -316,7 +316,7 @@ public class SlackIntegrationControllerTests
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.Sut.Url
.RouteUrl(Arg.Is<UrlRouteContext>(c => c.RouteName == nameof(SlackIntegrationController.CreateAsync)))
.RouteUrl(Arg.Is<UrlRouteContext>(c => c.RouteName == "SlackIntegration_Create"))
.Returns(expectedUrl);
sutProvider.GetDependency<ICurrentContext>()
.OrganizationOwner(organizationId)

View File

@@ -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<TeamsIntegrationController> sutProvider,
OrganizationIntegration integration)
{
integration.Type = IntegrationType.Teams;
integration.Configuration = null;
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.Sut.Url
.RouteUrl(Arg.Is<UrlRouteContext>(c => c.RouteName == "TeamsIntegration_Create"))
.Returns("https://localhost");
sutProvider.GetDependency<ITeamsService>()
.ObtainTokenViaOAuth(_validTeamsCode, Arg.Any<string>())
.Returns(_teamsToken);
sutProvider.GetDependency<ITeamsService>()
.GetJoinedTeamsAsync(_teamsToken)
.Returns([
new TeamInfo() { DisplayName = "Test Team", Id = Guid.NewGuid().ToString(), TenantId = Guid.NewGuid().ToString() }
]);
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.GetByIdAsync(integration.Id)
.Returns(integration);
var state = IntegrationOAuthState.FromIntegration(integration, sutProvider.GetDependency<TimeProvider>());
var requestAction = await sutProvider.Sut.CreateAsync(_validTeamsCode, state.ToString());
await sutProvider.GetDependency<IOrganizationIntegrationRepository>().Received(1)
.UpsertAsync(Arg.Any<OrganizationIntegration>());
Assert.IsType<CreatedResult>(requestAction);
}
[Theory, BitAutoData]
public async Task CreateAsync_CodeIsEmpty_ThrowsBadRequest(
SutProvider<TeamsIntegrationController> sutProvider,
OrganizationIntegration integration)
{
integration.Type = IntegrationType.Teams;
integration.Configuration = null;
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.Sut.Url
.RouteUrl(Arg.Is<UrlRouteContext>(c => c.RouteName == "TeamsIntegration_Create"))
.Returns("https://localhost");
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.GetByIdAsync(integration.Id)
.Returns(integration);
var state = IntegrationOAuthState.FromIntegration(integration, sutProvider.GetDependency<TimeProvider>());
await Assert.ThrowsAsync<BadRequestException>(async () =>
await sutProvider.Sut.CreateAsync(string.Empty, state.ToString()));
}
[Theory, BitAutoData]
public async Task CreateAsync_NoTeamsFound_ThrowsBadRequest(
SutProvider<TeamsIntegrationController> sutProvider,
OrganizationIntegration integration)
{
integration.Type = IntegrationType.Teams;
integration.Configuration = null;
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.Sut.Url
.RouteUrl(Arg.Is<UrlRouteContext>(c => c.RouteName == "TeamsIntegration_Create"))
.Returns("https://localhost");
sutProvider.GetDependency<ITeamsService>()
.ObtainTokenViaOAuth(_validTeamsCode, Arg.Any<string>())
.Returns(_teamsToken);
sutProvider.GetDependency<ITeamsService>()
.GetJoinedTeamsAsync(_teamsToken)
.Returns([]);
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.GetByIdAsync(integration.Id)
.Returns(integration);
var state = IntegrationOAuthState.FromIntegration(integration, sutProvider.GetDependency<TimeProvider>());
await Assert.ThrowsAsync<BadRequestException>(async () => await sutProvider.Sut.CreateAsync(_validTeamsCode, state.ToString()));
}
[Theory, BitAutoData]
public async Task CreateAsync_TeamsServiceReturnsEmptyToken_ThrowsBadRequest(
SutProvider<TeamsIntegrationController> sutProvider,
OrganizationIntegration integration)
{
integration.Type = IntegrationType.Teams;
integration.Configuration = null;
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.Sut.Url
.RouteUrl(Arg.Is<UrlRouteContext>(c => c.RouteName == "TeamsIntegration_Create"))
.Returns("https://localhost");
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.GetByIdAsync(integration.Id)
.Returns(integration);
sutProvider.GetDependency<ITeamsService>()
.ObtainTokenViaOAuth(_validTeamsCode, Arg.Any<string>())
.Returns(string.Empty);
var state = IntegrationOAuthState.FromIntegration(integration, sutProvider.GetDependency<TimeProvider>());
await Assert.ThrowsAsync<BadRequestException>(async () => await sutProvider.Sut.CreateAsync(_validTeamsCode, state.ToString()));
}
[Theory, BitAutoData]
public async Task CreateAsync_StateEmpty_ThrowsNotFound(
SutProvider<TeamsIntegrationController> sutProvider)
{
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.Sut.Url
.RouteUrl(Arg.Is<UrlRouteContext>(c => c.RouteName == "TeamsIntegration_Create"))
.Returns("https://localhost");
sutProvider.GetDependency<ITeamsService>()
.ObtainTokenViaOAuth(_validTeamsCode, Arg.Any<string>())
.Returns(_teamsToken);
await Assert.ThrowsAsync<NotFoundException>(async () => await sutProvider.Sut.CreateAsync(_validTeamsCode, string.Empty));
}
[Theory, BitAutoData]
public async Task CreateAsync_StateExpired_ThrowsNotFound(
SutProvider<TeamsIntegrationController> sutProvider,
OrganizationIntegration integration)
{
var timeProvider = new FakeTimeProvider(new DateTime(2024, 4, 3, 2, 1, 0, DateTimeKind.Utc));
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.Sut.Url
.RouteUrl(Arg.Is<UrlRouteContext>(c => c.RouteName == "TeamsIntegration_Create"))
.Returns("https://localhost");
sutProvider.GetDependency<ITeamsService>()
.ObtainTokenViaOAuth(_validTeamsCode, Arg.Any<string>())
.Returns(_teamsToken);
var state = IntegrationOAuthState.FromIntegration(integration, timeProvider);
timeProvider.Advance(TimeSpan.FromMinutes(30));
sutProvider.SetDependency<TimeProvider>(timeProvider);
await Assert.ThrowsAsync<NotFoundException>(async () => await sutProvider.Sut.CreateAsync(_validTeamsCode, state.ToString()));
}
[Theory, BitAutoData]
public async Task CreateAsync_StateHasNonexistentIntegration_ThrowsNotFound(
SutProvider<TeamsIntegrationController> sutProvider,
OrganizationIntegration integration)
{
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.Sut.Url
.RouteUrl(Arg.Is<UrlRouteContext>(c => c.RouteName == "TeamsIntegration_Create"))
.Returns("https://localhost");
sutProvider.GetDependency<ITeamsService>()
.ObtainTokenViaOAuth(_validTeamsCode, Arg.Any<string>())
.Returns(_teamsToken);
var state = IntegrationOAuthState.FromIntegration(integration, sutProvider.GetDependency<TimeProvider>());
await Assert.ThrowsAsync<NotFoundException>(async () => await sutProvider.Sut.CreateAsync(_validTeamsCode, state.ToString()));
}
[Theory, BitAutoData]
public async Task CreateAsync_StateHasWrongOrganizationHash_ThrowsNotFound(
SutProvider<TeamsIntegrationController> sutProvider,
OrganizationIntegration integration,
OrganizationIntegration wrongOrgIntegration)
{
wrongOrgIntegration.Id = integration.Id;
wrongOrgIntegration.Type = IntegrationType.Teams;
wrongOrgIntegration.Configuration = null;
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.Sut.Url
.RouteUrl(Arg.Is<UrlRouteContext>(c => c.RouteName == "TeamsIntegration_Create"))
.Returns("https://localhost");
sutProvider.GetDependency<ITeamsService>()
.ObtainTokenViaOAuth(_validTeamsCode, Arg.Any<string>())
.Returns(_teamsToken);
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.GetByIdAsync(integration.Id)
.Returns(wrongOrgIntegration);
var state = IntegrationOAuthState.FromIntegration(integration, sutProvider.GetDependency<TimeProvider>());
await Assert.ThrowsAsync<NotFoundException>(async () => await sutProvider.Sut.CreateAsync(_validTeamsCode, state.ToString()));
}
[Theory, BitAutoData]
public async Task CreateAsync_StateHasNonEmptyIntegration_ThrowsNotFound(
SutProvider<TeamsIntegrationController> sutProvider,
OrganizationIntegration integration)
{
integration.Type = IntegrationType.Teams;
integration.Configuration = "{}";
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.Sut.Url
.RouteUrl(Arg.Is<UrlRouteContext>(c => c.RouteName == "TeamsIntegration_Create"))
.Returns("https://localhost");
sutProvider.GetDependency<ITeamsService>()
.ObtainTokenViaOAuth(_validTeamsCode, Arg.Any<string>())
.Returns(_teamsToken);
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.GetByIdAsync(integration.Id)
.Returns(integration);
var state = IntegrationOAuthState.FromIntegration(integration, sutProvider.GetDependency<TimeProvider>());
await Assert.ThrowsAsync<NotFoundException>(async () => await sutProvider.Sut.CreateAsync(_validTeamsCode, state.ToString()));
}
[Theory, BitAutoData]
public async Task CreateAsync_StateHasNonTeamsIntegration_ThrowsNotFound(
SutProvider<TeamsIntegrationController> sutProvider,
OrganizationIntegration integration)
{
integration.Type = IntegrationType.Hec;
integration.Configuration = null;
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.Sut.Url
.RouteUrl(Arg.Is<UrlRouteContext>(c => c.RouteName == "TeamsIntegration_Create"))
.Returns("https://localhost");
sutProvider.GetDependency<ITeamsService>()
.ObtainTokenViaOAuth(_validTeamsCode, Arg.Any<string>())
.Returns(_teamsToken);
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.GetByIdAsync(integration.Id)
.Returns(integration);
var state = IntegrationOAuthState.FromIntegration(integration, sutProvider.GetDependency<TimeProvider>());
await Assert.ThrowsAsync<NotFoundException>(async () => await sutProvider.Sut.CreateAsync(_validTeamsCode, state.ToString()));
}
[Theory, BitAutoData]
public async Task RedirectAsync_Success(
SutProvider<TeamsIntegrationController> sutProvider,
OrganizationIntegration integration)
{
integration.Configuration = null;
var expectedUrl = "https://localhost/";
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.Sut.Url
.RouteUrl(Arg.Is<UrlRouteContext>(c => c.RouteName == "TeamsIntegration_Create"))
.Returns(expectedUrl);
sutProvider.GetDependency<ICurrentContext>()
.OrganizationOwner(integration.OrganizationId)
.Returns(true);
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.GetManyByOrganizationAsync(integration.OrganizationId)
.Returns([]);
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.CreateAsync(Arg.Any<OrganizationIntegration>())
.Returns(integration);
sutProvider.GetDependency<ITeamsService>().GetRedirectUrl(Arg.Any<string>(), Arg.Any<string>()).Returns(expectedUrl);
var expectedState = IntegrationOAuthState.FromIntegration(integration, sutProvider.GetDependency<TimeProvider>());
var requestAction = await sutProvider.Sut.RedirectAsync(integration.OrganizationId);
Assert.IsType<RedirectResult>(requestAction);
await sutProvider.GetDependency<IOrganizationIntegrationRepository>().Received(1)
.CreateAsync(Arg.Any<OrganizationIntegration>());
sutProvider.GetDependency<ITeamsService>().Received(1).GetRedirectUrl(Arg.Any<string>(), expectedState.ToString());
}
[Theory, BitAutoData]
public async Task RedirectAsync_IntegrationAlreadyExistsWithNullConfig_Success(
SutProvider<TeamsIntegrationController> sutProvider,
Guid organizationId,
OrganizationIntegration integration)
{
integration.OrganizationId = organizationId;
integration.Configuration = null;
integration.Type = IntegrationType.Teams;
var expectedUrl = "https://localhost/";
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.Sut.Url
.RouteUrl(Arg.Is<UrlRouteContext>(c => c.RouteName == "TeamsIntegration_Create"))
.Returns(expectedUrl);
sutProvider.GetDependency<ICurrentContext>()
.OrganizationOwner(organizationId)
.Returns(true);
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.GetManyByOrganizationAsync(organizationId)
.Returns([integration]);
sutProvider.GetDependency<ITeamsService>().GetRedirectUrl(Arg.Any<string>(), Arg.Any<string>()).Returns(expectedUrl);
var requestAction = await sutProvider.Sut.RedirectAsync(organizationId);
var expectedState = IntegrationOAuthState.FromIntegration(integration, sutProvider.GetDependency<TimeProvider>());
Assert.IsType<RedirectResult>(requestAction);
sutProvider.GetDependency<ITeamsService>().Received(1).GetRedirectUrl(Arg.Any<string>(), expectedState.ToString());
}
[Theory, BitAutoData]
public async Task RedirectAsync_IntegrationAlreadyExistsWithConfig_ThrowsBadRequest(
SutProvider<TeamsIntegrationController> sutProvider,
Guid organizationId,
OrganizationIntegration integration)
{
integration.OrganizationId = organizationId;
integration.Configuration = "{}";
integration.Type = IntegrationType.Teams;
var expectedUrl = "https://localhost/";
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.Sut.Url
.RouteUrl(Arg.Is<UrlRouteContext>(c => c.RouteName == "TeamsIntegration_Create"))
.Returns(expectedUrl);
sutProvider.GetDependency<ICurrentContext>()
.OrganizationOwner(organizationId)
.Returns(true);
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.GetManyByOrganizationAsync(organizationId)
.Returns([integration]);
sutProvider.GetDependency<ITeamsService>().GetRedirectUrl(Arg.Any<string>(), Arg.Any<string>()).Returns(expectedUrl);
await Assert.ThrowsAsync<BadRequestException>(async () => await sutProvider.Sut.RedirectAsync(organizationId));
}
[Theory, BitAutoData]
public async Task RedirectAsync_TeamsServiceReturnsEmpty_ThrowsNotFound(
SutProvider<TeamsIntegrationController> sutProvider,
Guid organizationId,
OrganizationIntegration integration)
{
integration.OrganizationId = organizationId;
integration.Configuration = null;
var expectedUrl = "https://localhost/";
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.Sut.Url
.RouteUrl(Arg.Is<UrlRouteContext>(c => c.RouteName == "TeamsIntegration_Create"))
.Returns(expectedUrl);
sutProvider.GetDependency<ICurrentContext>()
.OrganizationOwner(organizationId)
.Returns(true);
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.GetManyByOrganizationAsync(organizationId)
.Returns([]);
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.CreateAsync(Arg.Any<OrganizationIntegration>())
.Returns(integration);
sutProvider.GetDependency<ITeamsService>().GetRedirectUrl(Arg.Any<string>(), Arg.Any<string>()).Returns(string.Empty);
await Assert.ThrowsAsync<NotFoundException>(async () => await sutProvider.Sut.RedirectAsync(organizationId));
}
[Theory, BitAutoData]
public async Task RedirectAsync_UserIsNotOrganizationAdmin_ThrowsNotFound(SutProvider<TeamsIntegrationController> sutProvider,
Guid organizationId)
{
sutProvider.GetDependency<ICurrentContext>()
.OrganizationOwner(organizationId)
.Returns(false);
await Assert.ThrowsAsync<NotFoundException>(async () => await sutProvider.Sut.RedirectAsync(organizationId));
}
[Theory, BitAutoData]
public async Task IncomingPostAsync_ForwardsToBot(SutProvider<TeamsIntegrationController> sutProvider)
{
var adapter = sutProvider.GetDependency<IBotFrameworkHttpAdapter>();
var bot = sutProvider.GetDependency<IBot>();
await sutProvider.Sut.IncomingPostAsync();
await adapter.Received(1).ProcessAsync(Arg.Any<HttpRequest>(), Arg.Any<HttpResponse>(), bot);
}
}

View File

@@ -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));
}

View File

@@ -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()
{

View File

@@ -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)
{

View File

@@ -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"));
}
}

View File

@@ -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<ArgumentOutOfRangeException>(() => 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());
}
}

View File

@@ -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()
{

View File

@@ -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<ITeamsService>();
private readonly string _channelId = "C12345";
private readonly Uri _serviceUrl = new Uri("http://localhost");
private SutProvider<TeamsIntegrationHandler> GetSutProvider()
{
return new SutProvider<TeamsIntegrationHandler>()
.SetDependency(_teamsService)
.Create();
}
[Theory, BitAutoData]
public async Task HandleAsync_SuccessfulRequest_ReturnsSuccess(IntegrationMessage<TeamsIntegrationConfigurationDetails> 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<ITeamsService>().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<TeamsIntegrationConfigurationDetails> message)
{
var sutProvider = GetSutProvider();
message.Configuration = new TeamsIntegrationConfigurationDetails(_channelId, _serviceUrl);
sutProvider.GetDependency<ITeamsService>()
.SendMessageToChannelAsync(Arg.Any<Uri>(), Arg.Any<string>(), Arg.Any<string>())
.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<ITeamsService>().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<TeamsIntegrationConfigurationDetails> message)
{
var sutProvider = GetSutProvider();
message.Configuration = new TeamsIntegrationConfigurationDetails(_channelId, _serviceUrl);
sutProvider.GetDependency<ITeamsService>()
.SendMessageToChannelAsync(Arg.Any<Uri>(), Arg.Any<string>(), Arg.Any<string>())
.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<ITeamsService>().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<TeamsIntegrationConfigurationDetails> message)
{
var sutProvider = GetSutProvider();
message.Configuration = new TeamsIntegrationConfigurationDetails(_channelId, _serviceUrl);
sutProvider.GetDependency<ITeamsService>()
.SendMessageToChannelAsync(Arg.Any<Uri>(), Arg.Any<string>(), Arg.Any<string>())
.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<ITeamsService>().Received(1).SendMessageToChannelAsync(
Arg.Is(AssertHelper.AssertPropertyEqual(_serviceUrl)),
Arg.Is(AssertHelper.AssertPropertyEqual(_channelId)),
Arg.Is(AssertHelper.AssertPropertyEqual(message.RenderedTemplate))
);
}
}

View File

@@ -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<TeamsService> GetSutProvider()
{
var clientFactory = Substitute.For<IHttpClientFactory>();
clientFactory.CreateClient(TeamsService.HttpClientName).Returns(_httpClient);
var globalSettings = Substitute.For<GlobalSettings>();
globalSettings.Teams.LoginBaseUrl.Returns("https://login.example.com");
globalSettings.Teams.GraphBaseUrl.Returns("https://graph.example.com");
return new SutProvider<TeamsService>()
.SetDependency(clientFactory)
.SetDependency(globalSettings)
.Create();
}
[Fact]
public void GetRedirectUrl_ReturnsCorrectUrl()
{
var sutProvider = GetSutProvider();
var clientId = sutProvider.GetDependency<GlobalSettings>().Teams.ClientId;
var scopes = sutProvider.GetDependency<GlobalSettings>().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<IOrganizationIntegrationRepository>()
.GetByTeamsConfigurationTenantIdTeamId(tenantId, teamId)
.Returns(integration);
OrganizationIntegration? capturedIntegration = null;
await sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.UpsertAsync(Arg.Do<OrganizationIntegration>(x => capturedIntegration = x));
await sutProvider.Sut.HandleIncomingAppInstallAsync(
conversationId: conversationId,
serviceUrl: serviceUrl,
teamId: teamId,
tenantId: tenantId
);
await sutProvider.GetDependency<IOrganizationIntegrationRepository>().Received(1).GetByTeamsConfigurationTenantIdTeamId(tenantId, teamId);
Assert.NotNull(capturedIntegration);
var configuration = JsonSerializer.Deserialize<TeamsIntegration>(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<IOrganizationIntegrationRepository>().Received(1).GetByTeamsConfigurationTenantIdTeamId("tenantId", "teamId");
await sutProvider.GetDependency<IOrganizationIntegrationRepository>().DidNotReceive().UpsertAsync(Arg.Any<OrganizationIntegration>());
}
[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<IOrganizationIntegrationRepository>()
.GetByTeamsConfigurationTenantIdTeamId(tenantId, teamId)
.Returns(integration);
await sutProvider.Sut.HandleIncomingAppInstallAsync(
conversationId: "conversationId",
serviceUrl: new Uri("https://localhost"),
teamId: teamId,
tenantId: tenantId
);
await sutProvider.GetDependency<IOrganizationIntegrationRepository>().Received(1).GetByTeamsConfigurationTenantIdTeamId(tenantId, teamId);
await sutProvider.GetDependency<IOrganizationIntegrationRepository>().DidNotReceive().UpsertAsync(Arg.Any<OrganizationIntegration>());
}
[Theory, BitAutoData]
public async Task HandleIncomingAppInstall_MatchedIntegrationWithMissingConfiguration_DoesNothing(
OrganizationIntegration integration)
{
var sutProvider = GetSutProvider();
integration.Configuration = null;
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.GetByTeamsConfigurationTenantIdTeamId("tenantId", "teamId")
.Returns(integration);
await sutProvider.Sut.HandleIncomingAppInstallAsync(
conversationId: "conversationId",
serviceUrl: new Uri("https://localhost"),
teamId: "teamId",
tenantId: "tenantId"
);
await sutProvider.GetDependency<IOrganizationIntegrationRepository>().Received(1).GetByTeamsConfigurationTenantIdTeamId("tenantId", "teamId");
await sutProvider.GetDependency<IOrganizationIntegrationRepository>().DidNotReceive().UpsertAsync(Arg.Any<OrganizationIntegration>());
}
}

View File

@@ -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