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:
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
147
src/Api/AdminConsole/Controllers/TeamsIntegrationController.cs
Normal file
147
src/Api/AdminConsole/Controllers/TeamsIntegrationController.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
||||
|
||||
public record TeamsIntegrationConfigurationDetails(string ChannelId, Uri ServiceUrl);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,4 @@
|
||||
#nullable enable
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Bit.Core.Models.Slack;
|
||||
|
||||
|
||||
41
src/Core/AdminConsole/Models/Teams/TeamsApiResponse.cs
Normal file
41
src/Core/AdminConsole/Models/Teams/TeamsApiResponse.cs
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
49
src/Core/AdminConsole/Services/ITeamsService.cs
Normal file
49
src/Core/AdminConsole/Services/ITeamsService.cs
Normal 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 user’s 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);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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[]
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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" />
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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))
|
||||
);
|
||||
}
|
||||
}
|
||||
289
test/Core.Test/AdminConsole/Services/TeamsServiceTests.cs
Normal file
289
test/Core.Test/AdminConsole/Services/TeamsServiceTests.cs
Normal 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>());
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user