1
0
mirror of https://github.com/bitwarden/server synced 2025-12-15 15:53:59 +00:00

Merge branch 'arch/seeder-sdk' of github.com:bitwarden/server into arch/seeder-api

# Conflicts:
#	util/Seeder/Factories/UserSeeder.cs
This commit is contained in:
Hinton
2025-10-16 15:06:33 -07:00
78 changed files with 3756 additions and 241 deletions

View File

@@ -84,16 +84,18 @@ jobs:
- name: Review with Claude Code
if: steps.check_changes.outputs.vault_team_changes == 'true'
uses: anthropics/claude-code-action@a5528eec7426a4f0c9c1ac96018daa53ebd05bc4 # v1.0.7
uses: anthropics/claude-code-action@ac1a3207f3f00b4a37e2f3a6f0935733c7c64651 # v1.0.11
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
track_progress: true
use_sticky_comment: true
prompt: |
REPO: ${{ github.repository }}
PR NUMBER: ${{ github.event.pull_request.number }}
TITLE: ${{ github.event.pull_request.title }}
BODY: ${{ github.event.pull_request.body }}
AUTHOR: ${{ github.event.pull_request.user.login }}
COMMIT: ${{ github.event.pull_request.head.sha }}
Please review this pull request with a focus on:
- Code quality and best practices
@@ -103,7 +105,20 @@ jobs:
Note: The PR branch is already checked out in the current working directory.
Provide detailed feedback using inline comments for specific issues.
Provide a comprehensive review including:
- Summary of changes since last review
- Critical issues found (be thorough)
- Suggested improvements (be thorough)
- Good practices observed (be concise - list only the most notable items without elaboration)
- Action items for the author
- Leverage collapsible <details> sections where appropriate for lengthy explanations or code snippets to enhance human readability
When reviewing subsequent commits:
- Track status of previously identified issues (fixed/unfixed/reopened)
- Identify NEW problems introduced since last review
- Note if fixes introduced new issues
IMPORTANT: Be comprehensive about issues and improvements. For good practices, be brief - just note what was done well without explaining why or praising excessively.
claude_args: |
--allowedTools "mcp__github_inline_comment__create_inline_comment,Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*)"
--allowedTools "mcp__github_comment__update_claude_comment,mcp__github_inline_comment__create_inline_comment,Bash(gh pr diff:*),Bash(gh pr view:*)"

1
.gitignore vendored
View File

@@ -215,6 +215,7 @@ bitwarden_license/src/Sso/wwwroot/assets
**/**.swp
.mono
src/Core/MailTemplates/Mjml/out
src/Core/MailTemplates/Mjml/out-hbs
NativeMethods.g.cs
util/RustSdk/rust/target

View File

@@ -1,24 +1,29 @@
# Bitwarden Server - Claude Code Configuration
## Project Context Files
**Read these files before reviewing to ensure that you fully understand the project and contributing guidelines**
1. @README.md
2. @CONTRIBUTING.md
3. @.github/PULL_REQUEST_TEMPLATE.md
## Critical Rules
- **NEVER** edit: `/bin/`, `/obj/`, `/.git/`, `/.vs/`, `/packages/` which are generated files
- **NEVER** use code regions: If complexity suggests regions, refactor for better readability
- **NEVER** compromise zero-knowledge principles: User vault data must remain encrypted and inaccessible to Bitwarden
- **NEVER** log or expose sensitive data: No PII, passwords, keys, or vault data in logs or error messages
- **ALWAYS** use secure communication channels: Enforce confidentiality, integrity, and authenticity
- **ALWAYS** encrypt sensitive data: All vault data must be encrypted at rest, in transit, and in use
- **ALWAYS** prioritize cryptographic integrity and data protection
- **ALWAYS** add unit tests (with mocking) for any new feature development
## Project Context
- **Architecture**: Feature and team-based organization
- **Framework**: .NET 8.0, ASP.NET Core
- **Database**: SQL Server primary, EF Core supports PostgreSQL, MySQL/MariaDB, SQLite
- **Testing**: xUnit, NSubstitute
- **Container**: Docker, Docker Compose, Kubernetes/Helm deployable
## Project Structure
- **Source Code**: `/src/` - Services and core infrastructure
@@ -42,7 +47,7 @@
- **Database update**: `pwsh dev/migrate.ps1`
- **Generate OpenAPI**: `pwsh dev/generate_openapi_files.ps1`
## Code Review Checklist
## Development Workflow
- Security impact assessed
- xUnit tests added / updated

View File

@@ -7,8 +7,6 @@
<RootNamespace>Bit.$(MSBuildProjectName)</RootNamespace>
<ImplicitUsings>enable</ImplicitUsings>
<IncludeSourceRevisionInInformationalVersion>false</IncludeSourceRevisionInInformationalVersion>
<IsTestProject Condition="'$(IsTestProject)' == '' and ($(MSBuildProjectName.EndsWith('.Test')) or $(MSBuildProjectName.EndsWith('.IntegrationTest')))">true</IsTestProject>
<Nullable Condition="'$(Nullable)' == '' and '$(IsTestProject)' == 'true'">annotations</Nullable>
<Nullable Condition="'$(Nullable)' == '' and '$(IsTestProject)' != 'true'">enable</Nullable>
@@ -32,19 +30,4 @@
<AutoFixtureAutoNSubstituteVersion>4.18.1</AutoFixtureAutoNSubstituteVersion>
</PropertyGroup>
<Target Name="SetSourceRevisionId" BeforeTargets="CoreGenerateAssemblyInfo">
<Exec Command="git describe --long --always --dirty --exclude=* --abbrev=8" ConsoleToMSBuild="True" IgnoreExitCode="False">
<Output PropertyName="SourceRevisionId" TaskParameter="ConsoleOutput" />
</Exec>
</Target>
<Target Name="WriteRevision" AfterTargets="SetSourceRevisionId">
<ItemGroup>
<AssemblyAttribute Include="System.Reflection.AssemblyMetadataAttribute">
<_Parameter1>GitHash</_Parameter1>
<_Parameter2>$(SourceRevisionId)</_Parameter2>
</AssemblyAttribute>
</ItemGroup>
</Target>
</Project>

View File

@@ -3,22 +3,6 @@
"Namespaces": [
{
"Name": "sbemulatorns",
"Queues": [
{
"Name": "queue.1",
"Properties": {
"DeadLetteringOnMessageExpiration": false,
"DefaultMessageTimeToLive": "PT1H",
"DuplicateDetectionHistoryTimeWindow": "PT20S",
"ForwardDeadLetteredMessagesTo": "",
"ForwardTo": "",
"LockDuration": "PT1M",
"MaxDeliveryCount": 3,
"RequiresDuplicateDetection": false,
"RequiresSession": false
}
}
],
"Topics": [
{
"Name": "event-logging",
@@ -37,6 +21,9 @@
},
{
"Name": "events-datadog-subscription"
},
{
"Name": "events-teams-subscription"
}
]
},
@@ -98,6 +85,20 @@
}
}
]
},
{
"Name": "integration-teams-subscription",
"Rules": [
{
"Name": "teams-integration-filter",
"Properties": {
"FilterType": "Correlation",
"CorrelationFilter": {
"Label": "teams"
}
}
}
]
}
]
}

View File

@@ -32,7 +32,7 @@ public class SlackIntegrationController(
}
string? callbackUrl = Url.RouteUrl(
routeName: nameof(CreateAsync),
routeName: "SlackIntegration_Create",
values: null,
protocol: currentContext.HttpContext.Request.Scheme,
host: currentContext.HttpContext.Request.Host.ToUriComponent()
@@ -76,7 +76,7 @@ public class SlackIntegrationController(
return Redirect(redirectUrl);
}
[HttpGet("integrations/slack/create", Name = nameof(CreateAsync))]
[HttpGet("integrations/slack/create", Name = "SlackIntegration_Create")]
[AllowAnonymous]
public async Task<IActionResult> CreateAsync([FromQuery] string code, [FromQuery] string state)
{
@@ -103,7 +103,7 @@ public class SlackIntegrationController(
// Fetch token from Slack and store to DB
string? callbackUrl = Url.RouteUrl(
routeName: nameof(CreateAsync),
routeName: "SlackIntegration_Create",
values: null,
protocol: currentContext.HttpContext.Request.Scheme,
host: currentContext.HttpContext.Request.Host.ToUriComponent()

View File

@@ -0,0 +1,147 @@
using System.Text.Json;
using Bit.Api.AdminConsole.Models.Response.Organizations;
using Bit.Core;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
using Bit.Core.Context;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Utilities;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Bot.Builder;
using Microsoft.Bot.Builder.Integration.AspNet.Core;
namespace Bit.Api.AdminConsole.Controllers;
[RequireFeature(FeatureFlagKeys.EventBasedOrganizationIntegrations)]
[Route("organizations")]
[Authorize("Application")]
public class TeamsIntegrationController(
ICurrentContext currentContext,
IOrganizationIntegrationRepository integrationRepository,
IBot bot,
IBotFrameworkHttpAdapter adapter,
ITeamsService teamsService,
TimeProvider timeProvider) : Controller
{
[HttpGet("{organizationId:guid}/integrations/teams/redirect")]
public async Task<IActionResult> RedirectAsync(Guid organizationId)
{
if (!await currentContext.OrganizationOwner(organizationId))
{
throw new NotFoundException();
}
var callbackUrl = Url.RouteUrl(
routeName: "TeamsIntegration_Create",
values: null,
protocol: currentContext.HttpContext.Request.Scheme,
host: currentContext.HttpContext.Request.Host.ToUriComponent()
);
if (string.IsNullOrEmpty(callbackUrl))
{
throw new BadRequestException("Unable to build callback Url");
}
var integrations = await integrationRepository.GetManyByOrganizationAsync(organizationId);
var integration = integrations.FirstOrDefault(i => i.Type == IntegrationType.Teams);
if (integration is null)
{
// No teams integration exists, create Initiated version
integration = await integrationRepository.CreateAsync(new OrganizationIntegration
{
OrganizationId = organizationId,
Type = IntegrationType.Teams,
Configuration = null,
});
}
else if (integration.Configuration is not null)
{
// A Completed (fully configured) Teams integration already exists, throw to prevent overriding
throw new BadRequestException("There already exists a Teams integration for this organization");
} // An Initiated teams integration exits, re-use it and kick off a new OAuth flow
var state = IntegrationOAuthState.FromIntegration(integration, timeProvider);
var redirectUrl = teamsService.GetRedirectUrl(
callbackUrl: callbackUrl,
state: state.ToString()
);
if (string.IsNullOrEmpty(redirectUrl))
{
throw new NotFoundException();
}
return Redirect(redirectUrl);
}
[HttpGet("integrations/teams/create", Name = "TeamsIntegration_Create")]
[AllowAnonymous]
public async Task<IActionResult> CreateAsync([FromQuery] string code, [FromQuery] string state)
{
var oAuthState = IntegrationOAuthState.FromString(state: state, timeProvider: timeProvider);
if (oAuthState is null)
{
throw new NotFoundException();
}
// Fetch existing Initiated record
var integration = await integrationRepository.GetByIdAsync(oAuthState.IntegrationId);
if (integration is null ||
integration.Type != IntegrationType.Teams ||
integration.Configuration is not null)
{
throw new NotFoundException();
}
// Verify Organization matches hash
if (!oAuthState.ValidateOrg(integration.OrganizationId))
{
throw new NotFoundException();
}
var callbackUrl = Url.RouteUrl(
routeName: "TeamsIntegration_Create",
values: null,
protocol: currentContext.HttpContext.Request.Scheme,
host: currentContext.HttpContext.Request.Host.ToUriComponent()
);
if (string.IsNullOrEmpty(callbackUrl))
{
throw new BadRequestException("Unable to build callback Url");
}
var token = await teamsService.ObtainTokenViaOAuth(code, callbackUrl);
if (string.IsNullOrEmpty(token))
{
throw new BadRequestException("Invalid response from Teams.");
}
var teams = await teamsService.GetJoinedTeamsAsync(token);
if (!teams.Any())
{
throw new BadRequestException("No teams were found.");
}
var teamsIntegration = new TeamsIntegration(TenantId: teams[0].TenantId, Teams: teams);
integration.Configuration = JsonSerializer.Serialize(teamsIntegration);
await integrationRepository.UpsertAsync(integration);
var location = $"/organizations/{integration.OrganizationId}/integrations/{integration.Id}";
return Created(location, new OrganizationIntegrationResponseModel(integration));
}
[Route("integrations/teams/incoming")]
[AllowAnonymous]
[HttpPost]
public async Task IncomingPostAsync()
{
await adapter.ProcessAsync(Request, Response, bot);
}
}

View File

@@ -38,6 +38,10 @@ public class OrganizationIntegrationConfigurationRequestModel
return !string.IsNullOrWhiteSpace(Template) &&
Configuration is null &&
IsFiltersValid();
case IntegrationType.Teams:
return !string.IsNullOrWhiteSpace(Template) &&
Configuration is null &&
IsFiltersValid();
default:
return false;

View File

@@ -35,7 +35,7 @@ public class OrganizationIntegrationRequestModel : IValidatableObject
case IntegrationType.CloudBillingSync or IntegrationType.Scim:
yield return new ValidationResult($"{nameof(Type)} integrations are not yet supported.", [nameof(Type)]);
break;
case IntegrationType.Slack:
case IntegrationType.Slack or IntegrationType.Teams:
yield return new ValidationResult($"{nameof(Type)} integrations cannot be created directly.", [nameof(Type)]);
break;
case IntegrationType.Webhook:

View File

@@ -1,4 +1,6 @@
using Bit.Core.AdminConsole.Entities;
using System.Text.Json;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
using Bit.Core.Enums;
using Bit.Core.Models.Api;
@@ -35,6 +37,16 @@ public class OrganizationIntegrationResponseModel : ResponseModel
? OrganizationIntegrationStatus.Initiated
: OrganizationIntegrationStatus.Completed,
// If present and the configuration is null, OAuth has been initiated, and we are
// waiting on the return OAuth call. If Configuration is not null and IsCompleted is true,
// then we've received the app install bot callback, and it's Completed. Otherwise,
// it is In Progress while we await the app install bot callback.
IntegrationType.Teams => string.IsNullOrWhiteSpace(Configuration)
? OrganizationIntegrationStatus.Initiated
: (JsonSerializer.Deserialize<TeamsIntegration>(Configuration)?.IsCompleted ?? false)
? OrganizationIntegrationStatus.Completed
: OrganizationIntegrationStatus.InProgress,
// HEC and Datadog should only be allowed to be created non-null.
// If they are null, they are Invalid
IntegrationType.Hec => string.IsNullOrWhiteSpace(Configuration)

View File

@@ -229,8 +229,9 @@ public class Startup
services.AddHostedService<Core.HostedServices.ApplicationCacheHostedService>();
}
// Add SlackService for OAuth API requests - if configured
// Add Slack / Teams Services for OAuth API requests - if configured
services.AddSlackService(globalSettings);
services.AddTeamsService(globalSettings);
}
public void Configure(

View File

@@ -7,7 +7,8 @@ public enum IntegrationType : int
Slack = 3,
Webhook = 4,
Hec = 5,
Datadog = 6
Datadog = 6,
Teams = 7
}
public static class IntegrationTypeExtensions
@@ -24,6 +25,8 @@ public static class IntegrationTypeExtensions
return "hec";
case IntegrationType.Datadog:
return "datadog";
case IntegrationType.Teams:
return "teams";
default:
throw new ArgumentOutOfRangeException(nameof(type), $"Unsupported integration type: {type}");
}

View File

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

View File

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

View File

@@ -0,0 +1,38 @@
using Bit.Core.Enums;
using Bit.Core.Settings;
namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations;
public class TeamsListenerConfiguration(GlobalSettings globalSettings) :
ListenerConfiguration(globalSettings), IIntegrationListenerConfiguration
{
public IntegrationType IntegrationType
{
get => IntegrationType.Teams;
}
public string EventQueueName
{
get => _globalSettings.EventLogging.RabbitMq.TeamsEventsQueueName;
}
public string IntegrationQueueName
{
get => _globalSettings.EventLogging.RabbitMq.TeamsIntegrationQueueName;
}
public string IntegrationRetryQueueName
{
get => _globalSettings.EventLogging.RabbitMq.TeamsIntegrationRetryQueueName;
}
public string EventSubscriptionName
{
get => _globalSettings.EventLogging.AzureServiceBus.TeamsEventSubscriptionName;
}
public string IntegrationSubscriptionName
{
get => _globalSettings.EventLogging.AzureServiceBus.TeamsIntegrationSubscriptionName;
}
}

View File

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

View File

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

View File

@@ -0,0 +1,28 @@
using Microsoft.Bot.Connector.Authentication;
namespace Bit.Core.AdminConsole.Models.Teams;
public class TeamsBotCredentialProvider(string clientId, string clientSecret) : ICredentialProvider
{
private const string _microsoftBotFrameworkIssuer = AuthenticationConstants.ToBotFromChannelTokenIssuer;
public Task<bool> IsValidAppIdAsync(string appId)
{
return Task.FromResult(appId == clientId);
}
public Task<string?> GetAppPasswordAsync(string appId)
{
return Task.FromResult(appId == clientId ? clientSecret : null);
}
public Task<bool> IsAuthenticationDisabledAsync()
{
return Task.FromResult(false);
}
public Task<bool> ValidateIssuerAsync(string issuer)
{
return Task.FromResult(issuer == _microsoftBotFrameworkIssuer);
}
}

View File

@@ -0,0 +1,208 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Services;
namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.Implementations;
public class VNextSavePolicyCommand(
IApplicationCacheService applicationCacheService,
IEventService eventService,
IPolicyRepository policyRepository,
IEnumerable<IEnforceDependentPoliciesEvent> policyValidationEventHandlers,
TimeProvider timeProvider,
IPolicyEventHandlerFactory policyEventHandlerFactory)
: IVNextSavePolicyCommand
{
private readonly IReadOnlyDictionary<PolicyType, IEnforceDependentPoliciesEvent> _policyValidationEvents = MapToDictionary(policyValidationEventHandlers);
private static Dictionary<PolicyType, IEnforceDependentPoliciesEvent> MapToDictionary(IEnumerable<IEnforceDependentPoliciesEvent> policyValidationEventHandlers)
{
var policyValidationEventsDict = new Dictionary<PolicyType, IEnforceDependentPoliciesEvent>();
foreach (var policyValidationEvent in policyValidationEventHandlers)
{
if (!policyValidationEventsDict.TryAdd(policyValidationEvent.Type, policyValidationEvent))
{
throw new Exception($"Duplicate PolicyValidationEvent for {policyValidationEvent.Type} policy.");
}
}
return policyValidationEventsDict;
}
public async Task<Policy> SaveAsync(SavePolicyModel policyRequest)
{
var policyUpdateRequest = policyRequest.PolicyUpdate;
var organizationId = policyUpdateRequest.OrganizationId;
await EnsureOrganizationCanUsePolicyAsync(organizationId);
var savedPoliciesDict = await GetCurrentPolicyStateAsync(organizationId);
var currentPolicy = savedPoliciesDict.GetValueOrDefault(policyUpdateRequest.Type);
ValidatePolicyDependencies(policyUpdateRequest, currentPolicy, savedPoliciesDict);
await ValidateTargetedPolicyAsync(policyRequest, currentPolicy);
await ExecutePreUpsertSideEffectAsync(policyRequest, currentPolicy);
var upsertedPolicy = await UpsertPolicyAsync(policyUpdateRequest);
await eventService.LogPolicyEventAsync(upsertedPolicy, EventType.Policy_Updated);
await ExecutePostUpsertSideEffectAsync(policyRequest, upsertedPolicy, currentPolicy);
return upsertedPolicy;
}
private async Task EnsureOrganizationCanUsePolicyAsync(Guid organizationId)
{
var org = await applicationCacheService.GetOrganizationAbilityAsync(organizationId);
if (org == null)
{
throw new BadRequestException("Organization not found");
}
if (!org.UsePolicies)
{
throw new BadRequestException("This organization cannot use policies.");
}
}
private async Task<Policy> UpsertPolicyAsync(PolicyUpdate policyUpdateRequest)
{
var policy = await policyRepository.GetByOrganizationIdTypeAsync(policyUpdateRequest.OrganizationId, policyUpdateRequest.Type)
?? new Policy
{
OrganizationId = policyUpdateRequest.OrganizationId,
Type = policyUpdateRequest.Type,
CreationDate = timeProvider.GetUtcNow().UtcDateTime
};
policy.Enabled = policyUpdateRequest.Enabled;
policy.Data = policyUpdateRequest.Data;
policy.RevisionDate = timeProvider.GetUtcNow().UtcDateTime;
await policyRepository.UpsertAsync(policy);
return policy;
}
private async Task ValidateTargetedPolicyAsync(SavePolicyModel policyRequest,
Policy? currentPolicy)
{
await ExecutePolicyEventAsync<IPolicyValidationEvent>(
policyRequest.PolicyUpdate.Type,
async validator =>
{
var validationError = await validator.ValidateAsync(policyRequest, currentPolicy);
if (!string.IsNullOrEmpty(validationError))
{
throw new BadRequestException(validationError);
}
});
}
private void ValidatePolicyDependencies(
PolicyUpdate policyUpdateRequest,
Policy? currentPolicy,
Dictionary<PolicyType, Policy> savedPoliciesDict)
{
var result = policyEventHandlerFactory.GetHandler<IEnforceDependentPoliciesEvent>(policyUpdateRequest.Type);
result.Switch(
validator =>
{
var isCurrentlyEnabled = currentPolicy?.Enabled == true;
switch (policyUpdateRequest.Enabled)
{
case true when !isCurrentlyEnabled:
ValidateEnablingRequirements(validator, savedPoliciesDict);
return;
case false when isCurrentlyEnabled:
ValidateDisablingRequirements(validator, policyUpdateRequest.Type, savedPoliciesDict);
break;
}
},
_ => { });
}
private void ValidateDisablingRequirements(
IEnforceDependentPoliciesEvent validator,
PolicyType policyType,
Dictionary<PolicyType, Policy> savedPoliciesDict)
{
var dependentPolicyTypes = _policyValidationEvents.Values
.Where(otherValidator => otherValidator.RequiredPolicies.Contains(policyType))
.Select(otherValidator => otherValidator.Type)
.Where(otherPolicyType => savedPoliciesDict.TryGetValue(otherPolicyType, out var savedPolicy) &&
savedPolicy.Enabled)
.ToList();
switch (dependentPolicyTypes)
{
case { Count: 1 }:
throw new BadRequestException($"Turn off the {dependentPolicyTypes.First().GetName()} policy because it requires the {validator.Type.GetName()} policy.");
case { Count: > 1 }:
throw new BadRequestException($"Turn off all of the policies that require the {validator.Type.GetName()} policy.");
}
}
private static void ValidateEnablingRequirements(
IEnforceDependentPoliciesEvent validator,
Dictionary<PolicyType, Policy> savedPoliciesDict)
{
var missingRequiredPolicyTypes = validator.RequiredPolicies
.Where(requiredPolicyType => savedPoliciesDict.GetValueOrDefault(requiredPolicyType) is not { Enabled: true })
.ToList();
if (missingRequiredPolicyTypes.Count != 0)
{
throw new BadRequestException($"Turn on the {missingRequiredPolicyTypes.First().GetName()} policy because it is required for the {validator.Type.GetName()} policy.");
}
}
private async Task ExecutePreUpsertSideEffectAsync(
SavePolicyModel policyRequest,
Policy? currentPolicy)
{
await ExecutePolicyEventAsync<IOnPolicyPreUpdateEvent>(
policyRequest.PolicyUpdate.Type,
handler => handler.ExecutePreUpsertSideEffectAsync(policyRequest, currentPolicy));
}
private async Task ExecutePostUpsertSideEffectAsync(
SavePolicyModel policyRequest,
Policy postUpsertedPolicyState,
Policy? previousPolicyState)
{
await ExecutePolicyEventAsync<IOnPolicyPostUpdateEvent>(
policyRequest.PolicyUpdate.Type,
handler => handler.ExecutePostUpsertSideEffectAsync(
policyRequest,
postUpsertedPolicyState,
previousPolicyState));
}
private async Task ExecutePolicyEventAsync<T>(PolicyType type, Func<T, Task> func) where T : IPolicyUpdateEvent
{
var handler = policyEventHandlerFactory.GetHandler<T>(type);
await handler.Match(
async h => await func(h),
_ => Task.CompletedTask
);
}
private async Task<Dictionary<PolicyType, Policy>> GetCurrentPolicyStateAsync(Guid organizationId)
{
var savedPolicies = await policyRepository.GetManyByOrganizationIdAsync(organizationId);
// Note: policies may be missing from this dict if they have never been enabled
var savedPoliciesDict = savedPolicies.ToDictionary(p => p.Type);
return savedPoliciesDict;
}
}

View File

@@ -16,6 +16,8 @@ public record PolicyUpdate
public PolicyType Type { get; set; }
public string? Data { get; set; }
public bool Enabled { get; set; }
[Obsolete("Please use SavePolicyModel.PerformedBy instead.")]
public IActingUser? PerformedBy { get; set; }
public T GetDataModel<T>() where T : IPolicyDataModel, new()

View File

@@ -1,5 +1,7 @@
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Implementations;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyValidators;
using Bit.Core.AdminConsole.Services;
using Bit.Core.AdminConsole.Services.Implementations;
@@ -13,7 +15,9 @@ public static class PolicyServiceCollectionExtensions
{
services.AddScoped<IPolicyService, PolicyService>();
services.AddScoped<ISavePolicyCommand, SavePolicyCommand>();
services.AddScoped<IVNextSavePolicyCommand, VNextSavePolicyCommand>();
services.AddScoped<IPolicyRequirementQuery, PolicyRequirementQuery>();
services.AddScoped<IPolicyEventHandlerFactory, PolicyEventHandlerHandlerFactory>();
services.AddPolicyValidators();
services.AddPolicyRequirements();

View File

@@ -0,0 +1,12 @@
using Bit.Core.AdminConsole.Enums;
namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces;
public interface IEnforceDependentPoliciesEvent : IPolicyUpdateEvent
{
/// <summary>
/// PolicyTypes that must be enabled before this policy can be enabled, if any.
/// These dependencies will be checked when this policy is enabled and when any required policy is disabled.
/// </summary>
public IEnumerable<PolicyType> RequiredPolicies { get; }
}

View File

@@ -0,0 +1,18 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models;
namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces;
public interface IOnPolicyPostUpdateEvent : IPolicyUpdateEvent
{
/// <summary>
/// Performs side effects after a policy has been upserted.
/// For example, this can be used for cleanup tasks or notifications.
/// </summary>
/// <param name="policyRequest">The policy save request</param>
/// <param name="postUpsertedPolicyState">The policy after it was upserted</param>
/// <param name="previousPolicyState">The policy state before it was updated, if any</param>
public Task ExecutePostUpsertSideEffectAsync(
SavePolicyModel policyRequest,
Policy postUpsertedPolicyState,
Policy? previousPolicyState);
}

View File

@@ -0,0 +1,17 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models;
namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces;
public interface IOnPolicyPreUpdateEvent : IPolicyUpdateEvent
{
/// <summary>
/// Performs side effects before a policy is upserted.
/// For example, this can be used to remove non-compliant users from the organization.
/// </summary>
/// <param name="policyRequest">The policy save request containing the policy update and metadata</param>
/// <param name="currentPolicy">The current policy, if any</param>
public Task ExecutePreUpsertSideEffectAsync(
SavePolicyModel policyRequest,
Policy? currentPolicy);
}

View File

@@ -0,0 +1,30 @@
#nullable enable
using Bit.Core.AdminConsole.Enums;
using OneOf;
using OneOf.Types;
namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces;
/// <summary>
/// Provides policy-specific event handlers used during the save workflow in <see cref="IVNextSavePolicyCommand"/>.
/// </summary>
/// <remarks>
/// Supported handlers:
/// - <see cref="IEnforceDependentPoliciesEvent"/> for dependency checks
/// - <see cref="IPolicyValidationEvent"/> for custom validation
/// - <see cref="IOnPolicyPreUpdateEvent"/> for pre-save logic
/// - <see cref="IOnPolicyPostUpdateEvent"/> for post-save logic
/// </remarks>
public interface IPolicyEventHandlerFactory
{
/// <summary>
/// Gets the event handler for the given policy type and handler interface.
/// </summary>
/// <typeparam name="T">Handler type implementing <see cref="IPolicyUpdateEvent"/>.</typeparam>
/// <param name="policyType">The policy type to resolve.</param>
/// <returns>
/// <see cref="OneOf{T, None}"/> — the handler if available, or None if not implemented.
/// </returns>
OneOf<T, None> GetHandler<T>(PolicyType policyType) where T : IPolicyUpdateEvent;
}

View File

@@ -0,0 +1,11 @@
using Bit.Core.AdminConsole.Enums;
namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces;
public interface IPolicyUpdateEvent
{
/// <summary>
/// The policy type that the associated handler will handle.
/// </summary>
public PolicyType Type { get; }
}

View File

@@ -0,0 +1,19 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models;
namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces;
public interface IPolicyValidationEvent : IPolicyUpdateEvent
{
/// <summary>
/// Performs side effects after a policy is validated but before it is saved.
/// For example, this can be used to remove non-compliant users from the organization.
/// Implementation is optional; by default, it will not perform any side effects.
/// </summary>
/// <param name="policyRequest">The policy save request containing the policy update and metadata</param>
/// <param name="currentPolicy">The current policy, if any</param>
public Task<string> ValidateAsync(
SavePolicyModel policyRequest,
Policy? currentPolicy);
}

View File

@@ -0,0 +1,34 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models;
using Microsoft.Azure.NotificationHubs.Messaging;
namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces;
/// <summary>
/// Handles creating or updating organization policies with validation and side effect execution.
/// </summary>
/// <remarks>
/// Workflow:
/// 1. Validates organization can use policies
/// 2. Validates required and dependent policies
/// 3. Runs policy-specific validation (<see cref="IPolicyValidationEvent"/>)
/// 4. Executes pre-save logic (<see cref="IOnPolicyPreUpdateEvent"/>)
/// 5. Saves the policy
/// 6. Logs the event
/// 7. Executes post-save logic (<see cref="IOnPolicyPostUpdateEvent"/>)
/// </remarks>
public interface IVNextSavePolicyCommand
{
/// <summary>
/// Performs the necessary validations, saves the policy and any side effects
/// </summary>
/// <param name="policyRequest">Policy data, acting user, and metadata.</param>
/// <returns>The saved policy with updated revision and applied changes.</returns>
/// <exception cref="BadRequestException">
/// Thrown if:
/// - The organization cant use policies
/// - Dependent policies are missing or block changes
/// - Custom validation fails
/// </exception>
Task<Policy> SaveAsync(SavePolicyModel policyRequest);
}

View File

@@ -0,0 +1,33 @@

using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces;
using OneOf;
using OneOf.Types;
namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents;
public class PolicyEventHandlerHandlerFactory(
IEnumerable<IPolicyUpdateEvent> allEventHandlers) : IPolicyEventHandlerFactory
{
public OneOf<T, None> GetHandler<T>(PolicyType policyType) where T : IPolicyUpdateEvent
{
var tEventHandlers = allEventHandlers.OfType<T>().ToList();
var matchingHandlers = tEventHandlers.Where(h => h.Type == policyType).ToList();
if (matchingHandlers.Count > 1)
{
throw new InvalidOperationException(
$"Multiple {nameof(IPolicyUpdateEvent)} handlers of type {typeof(T).Name} found for {nameof(PolicyType)} {policyType}. " +
$"Expected one {typeof(T).Name} handler per {nameof(PolicyType)}.");
}
var policyTEventHandler = matchingHandlers.SingleOrDefault();
if (policyTEventHandler is null)
{
return new None();
}
return policyTEventHandler;
}
}

View File

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

View File

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

View File

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

View File

@@ -203,31 +203,17 @@ Currently, there are integrations / handlers for Slack, webhooks, and HTTP Event
- The top-level object that enables a specific integration for the organization.
- Includes any properties that apply to the entire integration across all events.
- For Slack, it consists of the token: `{ "Token": "xoxb-token-from-slack" }`.
- For webhooks, it is optional. Webhooks can either be configured at this level or the configuration level,
but the configuration level takes precedence. However, even though it is optional, an organization must
have a webhook `OrganizationIntegration` (even will a `null` `Configuration`) to enable configuration
via `OrganizationIntegrationConfiguration`.
- For HEC, it consists of the scheme, token, and URI:
```json
{
"Scheme": "Bearer",
"Token": "Auth-token-from-HEC-service",
"Uri": "https://example.com/api"
}
```
- For example, Slack stores the token in the `Configuration` which applies to every event, but stores the
channel id in the `Configuration` of the `OrganizationIntegrationConfiguration`. The token applies to the entire Slack
integration, but the channel could be configured differently depending on event type.
- See the table below for more examples / details on what is stored at which level.
### `OrganizationIntegrationConfiguration`
- This contains the configurations specific to each `EventType` for the integration.
- `Configuration` contains the event-specific configuration.
- For Slack, this would contain what channel to send the message to: `{ "channelId": "C123456" }`
- For webhooks, this is the URL the request should be sent to: `{ "url": "https://api.example.com" }`
- Optionally this also can include a `Scheme` and `Token` if this webhook needs Authentication.
- As stated above, all of this information can be specified here or at the `OrganizationIntegration`
level, but any properties declared here will take precedence over the ones above.
- For HEC, this must be null. HEC is configured only at the `OrganizationIntegration` level.
- Any properties at this level override the `Configuration` form the `OrganizationIntegration`.
- See the table below for examples of specific integrations.
- `Template` contains a template string that is expected to be filled in with the contents of the actual event.
- The tokens in the string are wrapped in `#` characters. For instance, the UserId would be `#UserId#`.
- The `IntegrationTemplateProcessor` does the actual work of replacing these tokens with introspected values from
@@ -245,6 +231,23 @@ Currently, there are integrations / handlers for Slack, webhooks, and HTTP Event
- An array of `OrganizationIntegrationConfigurationDetails` is what the `EventIntegrationHandler` fetches from
the database to determine what to publish at the integration level.
### Existing integrations and the configurations at each level
The following table illustrates how each integration is configured and what exactly is stored in the `Configuration`
property at each level (`OrganizationIntegration` or `OrganizationIntegrationConfiguration`). Under
`OrganizationIntegration` the valid `OrganizationIntegrationStatus` are in bold, with an example of what would be
stored at each status.
| **Integration** | **OrganizationIntegration** | **OrganizationIntegrationConfiguration** |
|------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------|
| CloudBillingSync | **Not Applicable** (not yet used) | **Not Applicable** (not yet used) |
| Scim | **Not Applicable** (not yet used) | **Not Applicable** (not yet used) |
| Slack | **Initiated**: `null`<br/>**Completed**:<br/>`{ "Token": "xoxb-token-from-slack" }` | `{ "channelId": "C123456" }` |
| Webhook | `null` or `{ "Scheme": "Bearer", "Token": "AUTH-TOKEN", "Uri": "https://example.com" }` | `null` or `{ "Scheme": "Bearer", "Token":"AUTH-TOKEN", "Uri": "https://example.com" }`<br/><br/>Whatever is defined at this level takes precedence |
| Hec | `{ "Scheme": "Bearer", "Token": "AUTH-TOKEN", "Uri": "https://example.com" }` | Always `null` |
| Datadog | `{ "ApiKey": "TheKey12345", "Uri": "https://api.us5.datadoghq.com/api/v1/events"}` | Always `null` |
| Teams | **Initiated**: `null`<br/>**In Progress**: <br/> `{ "TenantID": "tenant", "Teams": ["Id": "team", DisplayName: "MyTeam"]}`<br/>**Completed**: <br/>`{ "TenantID": "tenant", "Teams": ["Id": "team", DisplayName: "MyTeam"], "ServiceUrl":"https://example.com", ChannelId: "channel-1234"}` | Always `null` |
## Filtering
In addition to the ability to configure integrations mentioned above, organization admins can
@@ -349,10 +352,20 @@ and event type.
- This will be the deserialized version of the `MergedConfiguration` in
`OrganizationIntegrationConfigurationDetails`.
A new row with the new integration should be added to this doc in the table above [Existing integrations
and the configurations at each level](#existing-integrations-and-the-configurations-at-each-level).
## Request Models
1. Add a new case to the switch method in `OrganizationIntegrationRequestModel.Validate`.
- Additionally, add tests in `OrganizationIntegrationRequestModelTests`
2. Add a new case to the switch method in `OrganizationIntegrationConfigurationRequestModel.IsValidForType`.
- Additionally, add / update tests in `OrganizationIntegrationConfigurationRequestModelTests`
## Response Model
1. Add a new case to the switch method in `OrganizationIntegrationResponseModel.Status`.
- Additionally, add / update tests in `OrganizationIntegrationResponseModelTests`
## Integration Handler

View File

@@ -90,6 +90,12 @@ public class SlackService(
public async Task<string> ObtainTokenViaOAuth(string code, string redirectUrl)
{
if (string.IsNullOrEmpty(code) || string.IsNullOrWhiteSpace(redirectUrl))
{
logger.LogError("Error obtaining token via OAuth: Code and/or RedirectUrl were empty");
return string.Empty;
}
var tokenResponse = await _httpClient.PostAsync($"{_slackApiBaseUrl}/oauth.v2.access",
new FormUrlEncodedContent(new[]
{

View File

@@ -0,0 +1,41 @@
using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
using Microsoft.Rest;
namespace Bit.Core.Services;
public class TeamsIntegrationHandler(
ITeamsService teamsService)
: IntegrationHandlerBase<TeamsIntegrationConfigurationDetails>
{
public override async Task<IntegrationHandlerResult> HandleAsync(
IntegrationMessage<TeamsIntegrationConfigurationDetails> message)
{
try
{
await teamsService.SendMessageToChannelAsync(
serviceUri: message.Configuration.ServiceUrl,
message: message.RenderedTemplate,
channelId: message.Configuration.ChannelId
);
return new IntegrationHandlerResult(success: true, message: message);
}
catch (HttpOperationException ex)
{
var result = new IntegrationHandlerResult(success: false, message: message);
var statusCode = (int)ex.Response.StatusCode;
result.Retryable = statusCode is 429 or >= 500 and < 600;
result.FailureReason = ex.Message;
return result;
}
catch (Exception ex)
{
var result = new IntegrationHandlerResult(success: false, message: message);
result.Retryable = false;
result.FailureReason = ex.Message;
return result;
}
}
}

View File

@@ -0,0 +1,182 @@
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using System.Web;
using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
using Bit.Core.Models.Teams;
using Bit.Core.Repositories;
using Bit.Core.Settings;
using Microsoft.Bot.Builder;
using Microsoft.Bot.Builder.Teams;
using Microsoft.Bot.Connector;
using Microsoft.Bot.Connector.Authentication;
using Microsoft.Bot.Schema;
using Microsoft.Extensions.Logging;
using TeamInfo = Bit.Core.Models.Teams.TeamInfo;
namespace Bit.Core.Services;
public class TeamsService(
IHttpClientFactory httpClientFactory,
IOrganizationIntegrationRepository integrationRepository,
GlobalSettings globalSettings,
ILogger<TeamsService> logger) : ActivityHandler, ITeamsService
{
private readonly HttpClient _httpClient = httpClientFactory.CreateClient(HttpClientName);
private readonly string _clientId = globalSettings.Teams.ClientId;
private readonly string _clientSecret = globalSettings.Teams.ClientSecret;
private readonly string _scopes = globalSettings.Teams.Scopes;
private readonly string _graphBaseUrl = globalSettings.Teams.GraphBaseUrl;
private readonly string _loginBaseUrl = globalSettings.Teams.LoginBaseUrl;
public const string HttpClientName = "TeamsServiceHttpClient";
public string GetRedirectUrl(string redirectUrl, string state)
{
var query = HttpUtility.ParseQueryString(string.Empty);
query["client_id"] = _clientId;
query["response_type"] = "code";
query["redirect_uri"] = redirectUrl;
query["response_mode"] = "query";
query["scope"] = string.Join(" ", _scopes);
query["state"] = state;
return $"{_loginBaseUrl}/common/oauth2/v2.0/authorize?{query}";
}
public async Task<string> ObtainTokenViaOAuth(string code, string redirectUrl)
{
if (string.IsNullOrEmpty(code) || string.IsNullOrWhiteSpace(redirectUrl))
{
logger.LogError("Error obtaining token via OAuth: Code and/or RedirectUrl were empty");
return string.Empty;
}
var request = new HttpRequestMessage(HttpMethod.Post,
$"{_loginBaseUrl}/common/oauth2/v2.0/token");
request.Content = new FormUrlEncodedContent(new Dictionary<string, string>
{
{ "client_id", _clientId },
{ "client_secret", _clientSecret },
{ "code", code },
{ "redirect_uri", redirectUrl },
{ "grant_type", "authorization_code" }
});
using var response = await _httpClient.SendAsync(request);
if (!response.IsSuccessStatusCode)
{
var errorText = await response.Content.ReadAsStringAsync();
logger.LogError("Teams OAuth token exchange failed: {errorText}", errorText);
return string.Empty;
}
TeamsOAuthResponse? result;
try
{
result = await response.Content.ReadFromJsonAsync<TeamsOAuthResponse>();
}
catch
{
result = null;
}
if (result is null)
{
logger.LogError("Error obtaining token via OAuth: Unknown error");
return string.Empty;
}
return result.AccessToken;
}
public async Task<IReadOnlyList<TeamInfo>> GetJoinedTeamsAsync(string accessToken)
{
using var request = new HttpRequestMessage(
HttpMethod.Get,
$"{_graphBaseUrl}/me/joinedTeams");
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
using var response = await _httpClient.SendAsync(request);
if (!response.IsSuccessStatusCode)
{
var errorText = await response.Content.ReadAsStringAsync();
logger.LogError("Get Teams request failed: {errorText}", errorText);
return new List<TeamInfo>();
}
var result = await response.Content.ReadFromJsonAsync<JoinedTeamsResponse>();
return result?.Value ?? [];
}
public async Task SendMessageToChannelAsync(Uri serviceUri, string channelId, string message)
{
var credentials = new MicrosoftAppCredentials(_clientId, _clientSecret);
using var connectorClient = new ConnectorClient(serviceUri, credentials);
var activity = new Activity
{
Type = ActivityTypes.Message,
Text = message
};
await connectorClient.Conversations.SendToConversationAsync(channelId, activity);
}
protected override async Task OnInstallationUpdateAddAsync(ITurnContext<IInstallationUpdateActivity> turnContext,
CancellationToken cancellationToken)
{
var conversationId = turnContext.Activity.Conversation.Id;
var serviceUrl = turnContext.Activity.ServiceUrl;
var teamId = turnContext.Activity.TeamsGetTeamInfo().AadGroupId;
var tenantId = turnContext.Activity.Conversation.TenantId;
if (!string.IsNullOrWhiteSpace(conversationId) &&
!string.IsNullOrWhiteSpace(serviceUrl) &&
Uri.TryCreate(serviceUrl, UriKind.Absolute, out var parsedUri) &&
!string.IsNullOrWhiteSpace(teamId) &&
!string.IsNullOrWhiteSpace(tenantId))
{
await HandleIncomingAppInstallAsync(
conversationId: conversationId,
serviceUrl: parsedUri,
teamId: teamId,
tenantId: tenantId
);
}
await base.OnInstallationUpdateAddAsync(turnContext, cancellationToken);
}
internal async Task HandleIncomingAppInstallAsync(
string conversationId,
Uri serviceUrl,
string teamId,
string tenantId)
{
var integration = await integrationRepository.GetByTeamsConfigurationTenantIdTeamId(
tenantId: tenantId,
teamId: teamId);
if (integration?.Configuration is null)
{
return;
}
var teamsConfig = JsonSerializer.Deserialize<TeamsIntegration>(integration.Configuration);
if (teamsConfig is null || teamsConfig.IsCompleted)
{
return;
}
integration.Configuration = JsonSerializer.Serialize(teamsConfig with
{
ChannelId = conversationId,
ServiceUrl = serviceUrl
});
await integrationRepository.UpsertAsync(integration);
}
}

View File

@@ -0,0 +1,27 @@
using Bit.Core.Models.Teams;
using Bit.Core.Services;
namespace Bit.Core.AdminConsole.Services.NoopImplementations;
public class NoopTeamsService : ITeamsService
{
public string GetRedirectUrl(string callbackUrl, string state)
{
return string.Empty;
}
public Task<string> ObtainTokenViaOAuth(string code, string redirectUrl)
{
return Task.FromResult(string.Empty);
}
public Task<IReadOnlyList<TeamInfo>> GetJoinedTeamsAsync(string accessToken)
{
return Task.FromResult<IReadOnlyList<TeamInfo>>(Array.Empty<TeamInfo>());
}
public Task SendMessageToChannelAsync(Uri serviceUri, string channelId, string message)
{
return Task.CompletedTask;
}
}

View File

@@ -135,6 +135,8 @@ public class PreviewOrganizationTaxCommand(
var newPlan = await pricingClient.GetPlanOrThrow(planChange.PlanType);
var quantity = newPlan.HasNonSeatBasedPasswordManagerPlan() ? 1 : 2;
var items = new List<InvoiceSubscriptionDetailsItemOptions>
{
new ()
@@ -142,7 +144,7 @@ public class PreviewOrganizationTaxCommand(
Price = newPlan.HasNonSeatBasedPasswordManagerPlan()
? newPlan.PasswordManager.StripePlanId
: newPlan.PasswordManager.StripeSeatPlanId,
Quantity = 2
Quantity = quantity
}
};
@@ -194,12 +196,17 @@ public class PreviewOrganizationTaxCommand(
? currentPlan.PasswordManager.StripePlanId
: currentPlan.PasswordManager.StripeSeatPlanId];
var quantity = currentPlan.HasNonSeatBasedPasswordManagerPlan() &&
!newPlan.HasNonSeatBasedPasswordManagerPlan()
? (long)organization.Seats!
: passwordManagerSeats.Quantity;
items.Add(new InvoiceSubscriptionDetailsItemOptions
{
Price = newPlan.HasNonSeatBasedPasswordManagerPlan()
? newPlan.PasswordManager.StripePlanId
: newPlan.PasswordManager.StripeSeatPlanId,
Quantity = passwordManagerSeats.Quantity
Quantity = quantity
});
var hasStorage =

View File

@@ -2,11 +2,11 @@
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Caches;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Extensions;
using Bit.Core.Billing.Organizations.Models;
using Bit.Core.Billing.Payment.Queries;
using Bit.Core.Billing.Services;
using Bit.Core.Context;
using Bit.Core.Services;
@@ -30,8 +30,8 @@ public interface IGetOrganizationWarningsQuery
public class GetOrganizationWarningsQuery(
ICurrentContext currentContext,
IHasPaymentMethodQuery hasPaymentMethodQuery,
IProviderRepository providerRepository,
ISetupIntentCache setupIntentCache,
IStripeAdapter stripeAdapter,
ISubscriberService subscriberService) : IGetOrganizationWarningsQuery
{
@@ -81,15 +81,7 @@ public class GetOrganizationWarningsQuery(
return null;
}
var customer = subscription.Customer;
var hasUnverifiedBankAccount = await HasUnverifiedBankAccountAsync(organization);
var hasPaymentMethod =
!string.IsNullOrEmpty(customer.InvoiceSettings.DefaultPaymentMethodId) ||
!string.IsNullOrEmpty(customer.DefaultSourceId) ||
hasUnverifiedBankAccount ||
customer.Metadata.ContainsKey(MetadataKeys.BraintreeCustomerId);
var hasPaymentMethod = await hasPaymentMethodQuery.Run(organization);
if (hasPaymentMethod)
{
@@ -287,22 +279,4 @@ public class GetOrganizationWarningsQuery(
_ => null
};
}
private async Task<bool> HasUnverifiedBankAccountAsync(
Organization organization)
{
var setupIntentId = await setupIntentCache.GetSetupIntentIdForSubscriber(organization.Id);
if (string.IsNullOrEmpty(setupIntentId))
{
return false;
}
var setupIntent = await stripeAdapter.SetupIntentGet(setupIntentId, new SetupIntentGetOptions
{
Expand = ["payment_method"]
});
return setupIntent.IsUnverifiedBankAccount();
}
}

View File

@@ -6,6 +6,7 @@ using Bit.Core.Billing.Extensions;
using Bit.Core.Billing.Models;
using Bit.Core.Billing.Models.Sales;
using Bit.Core.Billing.Organizations.Models;
using Bit.Core.Billing.Payment.Queries;
using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Services;
using Bit.Core.Billing.Tax.Models;
@@ -27,6 +28,7 @@ namespace Bit.Core.Billing.Organizations.Services;
public class OrganizationBillingService(
IBraintreeGateway braintreeGateway,
IGlobalSettings globalSettings,
IHasPaymentMethodQuery hasPaymentMethodQuery,
ILogger<OrganizationBillingService> logger,
IOrganizationRepository organizationRepository,
IPricingClient pricingClient,
@@ -43,7 +45,7 @@ public class OrganizationBillingService(
? await CreateCustomerAsync(organization, customerSetup, subscriptionSetup.PlanType)
: await GetCustomerWhileEnsuringCorrectTaxExemptionAsync(organization, subscriptionSetup);
var subscription = await CreateSubscriptionAsync(organization.Id, customer, subscriptionSetup);
var subscription = await CreateSubscriptionAsync(organization, customer, subscriptionSetup);
if (subscription.Status is StripeConstants.SubscriptionStatus.Trialing or StripeConstants.SubscriptionStatus.Active)
{
@@ -120,8 +122,7 @@ public class OrganizationBillingService(
orgOccupiedSeats.Total);
}
public async Task
UpdatePaymentMethod(
public async Task UpdatePaymentMethod(
Organization organization,
TokenizedPaymentSource tokenizedPaymentSource,
TaxInformation taxInformation)
@@ -397,7 +398,7 @@ public class OrganizationBillingService(
}
private async Task<Subscription> CreateSubscriptionAsync(
Guid organizationId,
Organization organization,
Customer customer,
SubscriptionSetup subscriptionSetup)
{
@@ -465,7 +466,7 @@ public class OrganizationBillingService(
Items = subscriptionItemOptionsList,
Metadata = new Dictionary<string, string>
{
["organizationId"] = organizationId.ToString(),
["organizationId"] = organization.Id.ToString(),
["trialInitiationPath"] = !string.IsNullOrEmpty(subscriptionSetup.InitiationPath) &&
subscriptionSetup.InitiationPath.Contains("trial from marketing website")
? "marketing-initiated"
@@ -475,9 +476,10 @@ public class OrganizationBillingService(
TrialPeriodDays = subscriptionSetup.SkipTrial ? 0 : plan.TrialPeriodDays
};
var hasPaymentMethod = await hasPaymentMethodQuery.Run(organization);
// Only set trial_settings.end_behavior.missing_payment_method to "cancel" if there is no payment method
if (string.IsNullOrEmpty(customer.InvoiceSettings?.DefaultPaymentMethodId) &&
!customer.Metadata.ContainsKey(BraintreeCustomerIdKey))
if (!hasPaymentMethod)
{
subscriptionCreateOptions.TrialSettings = new SubscriptionTrialSettingsOptions
{

View File

@@ -0,0 +1,58 @@
using Bit.Core.Billing.Caches;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Extensions;
using Bit.Core.Billing.Services;
using Bit.Core.Entities;
using Bit.Core.Services;
using Stripe;
namespace Bit.Core.Billing.Payment.Queries;
using static StripeConstants;
public interface IHasPaymentMethodQuery
{
Task<bool> Run(ISubscriber subscriber);
}
public class HasPaymentMethodQuery(
ISetupIntentCache setupIntentCache,
IStripeAdapter stripeAdapter,
ISubscriberService subscriberService) : IHasPaymentMethodQuery
{
public async Task<bool> Run(ISubscriber subscriber)
{
var hasUnverifiedBankAccount = await HasUnverifiedBankAccountAsync(subscriber);
var customer = await subscriberService.GetCustomer(subscriber);
if (customer == null)
{
return hasUnverifiedBankAccount;
}
return
!string.IsNullOrEmpty(customer.InvoiceSettings.DefaultPaymentMethodId) ||
!string.IsNullOrEmpty(customer.DefaultSourceId) ||
hasUnverifiedBankAccount ||
customer.Metadata.ContainsKey(MetadataKeys.BraintreeCustomerId);
}
private async Task<bool> HasUnverifiedBankAccountAsync(
ISubscriber subscriber)
{
var setupIntentId = await setupIntentCache.GetSetupIntentIdForSubscriber(subscriber.Id);
if (string.IsNullOrEmpty(setupIntentId))
{
return false;
}
var setupIntent = await stripeAdapter.SetupIntentGet(setupIntentId, new SetupIntentGetOptions
{
Expand = ["payment_method"]
});
return setupIntent.IsUnverifiedBankAccount();
}
}

View File

@@ -19,5 +19,6 @@ public static class Registrations
services.AddTransient<IGetBillingAddressQuery, GetBillingAddressQuery>();
services.AddTransient<IGetCreditQuery, GetCreditQuery>();
services.AddTransient<IGetPaymentMethodQuery, GetPaymentMethodQuery>();
services.AddTransient<IHasPaymentMethodQuery, HasPaymentMethodQuery>();
}
}

View File

@@ -226,7 +226,6 @@ public static class FeatureFlagKeys
/* Tools Team */
public const string DesktopSendUIRefresh = "desktop-send-ui-refresh";
public const string UseSdkPasswordGenerators = "pm-19976-use-sdk-password-generators";
public const string UseChromiumImporter = "pm-23982-chromium-importer";
/* Vault Team */
public const string PM8851_BrowserOnboardingNudge = "pm-8851-browser-onboarding-nudge";

View File

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

View File

@@ -1,19 +1,112 @@
# Email templates
# MJML email templating
This directory contains MJML templates for emails sent by the application. MJML is a markup language designed to reduce the pain of coding responsive email templates.
This directory contains MJML templates for emails. MJML is a markup language designed to reduce the pain of coding responsive email templates. Component based development features in MJML improve code quality and reusability.
## Usage
MJML stands for MailJet Markup Language.
```bash
## Implementation considerations
These `MJML` templates are compiled into HTML which will then be further consumed by our Handlebars mail service. We can continue to use this service to assign values from our View Models. This leverages the existing infrastructure. It also means we can continue to use the double brace (`{{}}`) syntax within MJML since Handlebars can be used to assign values to those `{{variables}}`.
There is no change on how we interact with our view models.
There is an added step where we compile `*.mjml` to `*.html.hbs`. `*.html.hbs` is the format we use so the handlebars service can apply the variables. This build pipeline process is in progress and may need to be manually done at times.
### `*.txt.hbs`
There is no change to how we create the `txt.hbs`. MJML does not impact how we create these artifacts.
## Building `MJML` files
```shell
npm ci
# Build once
# Build *.html to ./out directory
npm run build
# To build on changes
npm run watch
# To build on changes to *.mjml and *.js files, new files will not be tracked, you will need to run again
npm run build:watch
# Build *.html.hbs to ./out directory
npm run build:hbs
# Build minified *.html.hbs to ./out directory
npm run build:minify
# apply prettier formatting
npm run prettier
```
## Development
MJML supports components and you can create your own components by adding them to `.mjmlconfig`.
MJML supports components and you can create your own components by adding them to `.mjmlconfig`. Components are simple JavaScript that return MJML markup based on the attributes assigned, see components/mj-bw-hero.js. The markup is not a proper object, but contained in a string.
When using MJML templating you can use the above [commands](#building-mjml-files) to compile the template and view it in a web browser.
Not all MJML tags have the same attributes, it is highly recommended to review the documentation on the official MJML website to understand the usages of each of the tags.
### Recommended development
#### Mjml email template development
1. create `cool-email.mjml` in appropriate team directory
2. run `npm run build:watch`
3. view compiled `HTML` output in a web browser
4. iterate -> while `build:watch`'ing you should be able to refresh the browser page after the mjml/js re-compile to see the changes
#### Testing with `IMailService`
After the email is developed from the [initial step](#mjml-email-template-development) make sure the email `{{variables}}` are populated properly by running it through an `IMailService` implementation.
1. run `npm run build:minify`
2. copy built `*.html.hbs` files from the build directory to a location the mail service can consume them
3. run code that will send the email
The minified `html.hbs` artifacts are deliverables and must be placed into the correct `src/Core/MailTemplates/Handlebars/` directories in order to be used by `IMailService` implementations.
### Custom tags
There is currently a `mj-bw-hero` tag you can use within your `*.mjml` templates. This is a good example of how to create a component that takes in attribute values allowing us to be more DRY in our development of emails. Since the attribute's input is a string we are able to define whatever we need into the component, in this case `mj-bw-hero`.
In order to view the custom component you have written you will need to include it in the `.mjmlconfig` and reference it in an `mjml` template file.
```html
<!-- Custom component implementation-->
<mj-bw-hero
img-src="https://assets.bitwarden.com/email/v1/business.png"
title="Verify your email to access this Bitwarden Send"
/>
```
Attributes in Custom Components are defined by the developer. They can be required or optional depending on implementation. See the official MJML documentation for more information.
```js
static allowedAttributes = {
"img-src": "string", // REQUIRED: Source for the image displayed in the right-hand side of the blue header area
title: "string", // REQUIRED: large text stating primary purpose of the email
"button-text": "string", // OPTIONAL: text to display in the button
"button-url": "string", // OPTIONAL: URL to navigate to when the button is clicked
"sub-title": "string", // OPTIONAL: smaller text providing additional context for the title
};
static defaultAttributes = {};
```
Custom components, such as `mj-bw-hero`, must be defined in the `.mjmlconfig` in order for them to be compiled and rendered properly in the templates.
```json
{
"packages": ["components/mj-bw-hero"]
}
```
### `mj-include`
You are also able to reference other more static MJML templates in your MJML file simply by referencing the file within the MJML template.
```html
<!-- Example of reference to mjml template -->
<mj-wrapper padding="5px 20px 10px 20px">
<mj-include path="../../components/learn-more-footer.mjml" />
</mj-wrapper>
```

View File

@@ -0,0 +1,128 @@
const mjml2html = require("mjml");
const { registerComponent } = require("mjml-core");
const fs = require("fs");
const path = require("path");
const glob = require("glob");
// Parse command line arguments
const args = process.argv.slice(2); // Remove 'node' and script path
// Parse flags
const flags = {
minify: args.includes("--minify") || args.includes("-m"),
watch: args.includes("--watch") || args.includes("-w"),
hbs: args.includes("--hbs") || args.includes("-h"),
trace: args.includes("--trace") || args.includes("-t"),
clean: args.includes("--clean") || args.includes("-c"),
help: args.includes("--help"),
};
// Use __dirname to get absolute paths relative to the script location
const config = {
inputDir: path.join(__dirname, "emails"),
outputDir: path.join(__dirname, "out"),
minify: flags.minify,
validationLevel: "strict",
hbsOutput: flags.hbs,
};
// Debug output
if (flags.trace) {
console.log("[DEBUG] Script location:", __dirname);
console.log("[DEBUG] Input directory:", config.inputDir);
console.log("[DEBUG] Output directory:", config.outputDir);
}
// Ensure output directory exists
if (!fs.existsSync(config.outputDir)) {
fs.mkdirSync(config.outputDir, { recursive: true });
if (flags.trace) {
console.log("[INFO] Created output directory:", config.outputDir);
}
}
// Find all MJML files with absolute path
const mjmlFiles = glob.sync(`${config.inputDir}/**/*.mjml`);
console.log(`\n[INFO] Found ${mjmlFiles.length} MJML file(s) to compile...`);
if (mjmlFiles.length === 0) {
console.error("[ERROR] No MJML files found!");
console.error("[ERROR] Looked in:", config.inputDir);
console.error(
"[ERROR] Does this directory exist?",
fs.existsSync(config.inputDir),
);
process.exit(1);
}
// Compile each MJML file
let successCount = 0;
let errorCount = 0;
mjmlFiles.forEach((filePath) => {
try {
const mjmlContent = fs.readFileSync(filePath, "utf8");
const fileName = path.basename(filePath, ".mjml");
const relativePath = path.relative(config.inputDir, filePath);
console.log(`\n[BUILD] Compiling: ${relativePath}`);
// Compile MJML to HTML
const result = mjml2html(mjmlContent, {
minify: config.minify,
validationLevel: config.validationLevel,
filePath: filePath, // Important: tells MJML where the file is for resolving includes
mjmlConfigPath: __dirname, // Point to the directory with .mjmlconfig
});
// Check for errors
if (result.errors.length > 0) {
console.error(`[ERROR] Failed to compile ${fileName}.mjml:`);
result.errors.forEach((err) =>
console.error(` ${err.formattedMessage}`),
);
errorCount++;
return;
}
// Calculate output path preserving directory structure
const relativeDir = path.dirname(relativePath);
const outputDir = path.join(config.outputDir, relativeDir);
// Ensure subdirectory exists
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}
const outputExtension = config.hbsOutput ? ".html.hbs" : ".html";
const outputPath = path.join(outputDir, `${fileName}${outputExtension}`);
fs.writeFileSync(outputPath, result.html);
console.log(
`[OK] Built: ${fileName}.mjml → ${path.relative(__dirname, outputPath)}`,
);
successCount++;
// Log warnings if any
if (result.warnings && result.warnings.length > 0) {
console.warn(`[WARN] Warnings for ${fileName}.mjml:`);
result.warnings.forEach((warn) =>
console.warn(` ${warn.formattedMessage}`),
);
}
} catch (error) {
console.error(`[ERROR] Exception processing ${path.basename(filePath)}:`);
console.error(` ${error.message}`);
errorCount++;
}
});
console.log(`\n[SUMMARY] Compilation complete!`);
console.log(` Success: ${successCount}`);
console.log(` Failed: ${errorCount}`);
console.log(` Output: ${config.outputDir}`);
if (errorCount > 0) {
process.exit(1);
}

View File

@@ -1,4 +0,0 @@
# TODO: This should probably be replaced with a node script building every file in `emails/`
npx mjml emails/invite.mjml -o out/invite.html
npx mjml emails/two-factor.mjml -o out/two-factor.html

View File

@@ -8,9 +8,17 @@
<mj-body background-color="#e6e9ef" width="660px" />
</mj-attributes>
<mj-style inline="inline">
.link { text-decoration: none; color: #175ddc; font-weight: 600 }
.link {
text-decoration: none;
color: #175ddc;
font-weight: 600;
}
</mj-style>
<mj-style>
.border-fix > table { border-collapse:separate !important; } .border-fix >
table > tbody > tr > td { border-radius: 3px; }
.border-fix > table {
border-collapse: separate !important;
}
.border-fix > table > tbody > tr > td {
border-radius: 3px;
}
</mj-style>

View File

@@ -15,7 +15,9 @@
<mj-section>
<mj-column>
<mj-text>
<p>Your two-step verification code is: <b>{{Token}}</b></p>
<p>
Your two-step verification code is: <b>{{ Token }}</b>
</p>
<p>Use this code to complete logging in with Bitwarden.</p>
</mj-text>
</mj-column>

View File

@@ -15,8 +15,10 @@
},
"homepage": "https://bitwarden.com",
"scripts": {
"build": "./build.sh",
"watch": "nodemon --exec ./build.sh --watch ./components --watch ./emails --ext js,mjml",
"build": "node ./build.js",
"build:hbs": "node ./build.js --hbs",
"build:minify": "node ./build.js --hbs --minify",
"build:watch": "nodemon ./build.js --watch emails --watch components --ext mjml,js",
"prettier": "prettier --cache --write ."
},
"dependencies": {

View File

@@ -0,0 +1,78 @@
Email templating
================
We use MJML to generate the HTML that our mail services use to send emails to users. To accomplish this, we use different file types depending on which part of the email generation process we're working with.
# File Types
## `*.html.hbs`
These are the compiled HTML email templates that serve as the foundation for all HTML emails sent by the Bitwarden platform. They are generated from MJML source files and enhanced with Handlebars templating capabilities.
### Generation Process
- **Source**: Built from `*.mjml` files in the `./mjml` directory.
- The MJML source acts as a toolkit for developers to generate HTML. It is the developers responsibility to generate the HTML and then ensure it is accessible to `IMailService` implementations.
- **Build Tool**: Generated via node build scripts: `npm run build`.
- The build script definitions can be viewed in the `Mjml/package.json` as well as in `Mjml/build.js`.
- **Output**: Cross-client compatible HTML with embedded CSS for maximum email client support
- **Template Engine**: Enhanced with Handlebars syntax for dynamic content injection
### Handlebars Integration
The templates use Handlebars templating syntax for dynamic content replacement:
```html
<!-- Example Handlebars usage -->
<h1>Welcome {{userName}}!</h1>
<p>Your organization {{organizationName}} has invited you to join.</p>
<a href="{{actionUrl}}">Accept Invitation</a>
```
**Variable Types:**
- **Simple Variables**: `{{userName}}`, `{{email}}`, `{{organizationName}}`
### Email Service Integration
The `IMailService` consumes these templates through the following process:
1. **Template Selection**: Service selects appropriate `.html.hbs` template based on email type
2. **Model Binding**: View model properties are mapped to Handlebars variables
3. **Compilation**: Handlebars engine processes variables and generates final HTML
### Development Guidelines
**Variable Naming:**
- Use camelCase for consistency: `{{userName}}`, `{{organizationName}}`
- Prefix URLs with descriptive names: `{{actionUrl}}`, `{{logoUrl}}`
**Testing Considerations:**
- Verify Handlebars variable replacement with actual view model data
- Ensure graceful degradation when variables are missing or null, if necessary
- Validate HTML structure and accessibility compliance
## `*.txt.hbs`
These files provide plain text versions of emails and are essential for email accessibility and deliverability. They serve several important purposes:
### Purpose and Usage
- **Accessibility**: Screen readers and assistive technologies often work better with plain text versions
- **Email Client Compatibility**: Some email clients prefer or only display plain text versions
- **Fallback Content**: When HTML rendering fails, the plain text version ensures the message is still readable
### Structure
Plain text email templates use the same Handlebars syntax (`{{variable}}`) as HTML templates for dynamic content replacement. They should:
- Contain the core message content without HTML formatting
- Use line breaks and spacing for readability
- Include all important links as full URLs
- Maintain logical content hierarchy using spacing and simple text formatting
### Email Service Integration
The `IMailService` automatically uses both versions when sending emails:
- The HTML version (from `*.html.hbs`) provides rich formatting and styling
- The plain text version (from `*.txt.hbs`) serves as the text alternative
- Email clients can choose which version to display based on user preferences and capabilities
### Development Guidelines
- Always create a corresponding `*.txt.hbs` file for each `*.html.hbs` template
- Keep the content concise but complete - include all essential information from the HTML version
- Test plain text templates to ensure they're readable and convey the same message
## `*.mjml`
This is a templating language we use to increase efficiency when creating email content. See the readme within the `./mjml` directory for more comprehensive information.

View File

@@ -62,6 +62,7 @@ public class GlobalSettings : IGlobalSettings
public virtual SqlSettings MySql { get; set; } = new SqlSettings();
public virtual SqlSettings Sqlite { get; set; } = new SqlSettings() { ConnectionString = "Data Source=:memory:" };
public virtual SlackSettings Slack { get; set; } = new SlackSettings();
public virtual TeamsSettings Teams { get; set; } = new TeamsSettings();
public virtual EventLoggingSettings EventLogging { get; set; } = new EventLoggingSettings();
public virtual MailSettings Mail { get; set; } = new MailSettings();
public virtual IConnectionStringSettings Storage { get; set; } = new ConnectionStringSettings();
@@ -295,6 +296,15 @@ public class GlobalSettings : IGlobalSettings
public virtual string Scopes { get; set; }
}
public class TeamsSettings
{
public virtual string LoginBaseUrl { get; set; } = "https://login.microsoftonline.com";
public virtual string GraphBaseUrl { get; set; } = "https://graph.microsoft.com/v1.0";
public virtual string ClientId { get; set; }
public virtual string ClientSecret { get; set; }
public virtual string Scopes { get; set; }
}
public class EventLoggingSettings
{
public AzureServiceBusSettings AzureServiceBus { get; set; } = new AzureServiceBusSettings();
@@ -320,6 +330,8 @@ public class GlobalSettings : IGlobalSettings
public virtual string HecIntegrationSubscriptionName { get; set; } = "integration-hec-subscription";
public virtual string DatadogEventSubscriptionName { get; set; } = "events-datadog-subscription";
public virtual string DatadogIntegrationSubscriptionName { get; set; } = "integration-datadog-subscription";
public virtual string TeamsEventSubscriptionName { get; set; } = "events-teams-subscription";
public virtual string TeamsIntegrationSubscriptionName { get; set; } = "integration-teams-subscription";
public string ConnectionString
{
@@ -364,6 +376,9 @@ public class GlobalSettings : IGlobalSettings
public virtual string DatadogEventsQueueName { get; set; } = "events-datadog-queue";
public virtual string DatadogIntegrationQueueName { get; set; } = "integration-datadog-queue";
public virtual string DatadogIntegrationRetryQueueName { get; set; } = "integration-datadog-retry-queue";
public virtual string TeamsEventsQueueName { get; set; } = "events-teams-queue";
public virtual string TeamsIntegrationQueueName { get; set; } = "integration-teams-queue";
public virtual string TeamsIntegrationRetryQueueName { get; set; } = "integration-teams-retry-queue";
public string HostName
{

View File

@@ -1,46 +1,46 @@
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using System.Diagnostics;
using System.Reflection;
namespace Bit.Core.Utilities;
public static class AssemblyHelpers
{
private static readonly IEnumerable<AssemblyMetadataAttribute> _assemblyMetadataAttributes;
private static readonly AssemblyInformationalVersionAttribute _assemblyInformationalVersionAttributes;
private const string GIT_HASH_ASSEMBLY_KEY = "GitHash";
private static string _version;
private static string _gitHash;
private static string? _version;
private static string? _gitHash;
static AssemblyHelpers()
{
_assemblyMetadataAttributes = Assembly.GetEntryAssembly().GetCustomAttributes<AssemblyMetadataAttribute>();
_assemblyInformationalVersionAttributes = Assembly.GetEntryAssembly().GetCustomAttribute<AssemblyInformationalVersionAttribute>();
}
public static string GetVersion()
{
if (string.IsNullOrWhiteSpace(_version))
var assemblyInformationalVersionAttribute = typeof(AssemblyHelpers).Assembly.GetCustomAttribute<AssemblyInformationalVersionAttribute>();
if (assemblyInformationalVersionAttribute == null)
{
_version = _assemblyInformationalVersionAttributes.InformationalVersion;
Debug.Fail("The AssemblyInformationalVersionAttribute is expected to exist in this assembly, possibly its generation was turned off.");
return;
}
var informationalVersion = assemblyInformationalVersionAttribute.InformationalVersion.AsSpan();
if (!informationalVersion.TrySplitBy('+', out var version, out var gitHash))
{
// Treat the whole thing as the version
_version = informationalVersion.ToString();
return;
}
_version = version.ToString();
if (gitHash.Length < 8)
{
return;
}
_gitHash = gitHash[..8].ToString();
}
public static string? GetVersion()
{
return _version;
}
public static string GetGitHash()
public static string? GetGitHash()
{
if (string.IsNullOrWhiteSpace(_gitHash))
{
try
{
_gitHash = _assemblyMetadataAttributes.Where(i => i.Key == GIT_HASH_ASSEMBLY_KEY).First().Value;
}
catch (Exception)
{ }
}
return _gitHash;
}
}

View File

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

View File

@@ -26,4 +26,16 @@ public class OrganizationIntegrationRepository :
return await query.Run(dbContext).ToListAsync();
}
}
public async Task<Core.AdminConsole.Entities.OrganizationIntegration?> GetByTeamsConfigurationTenantIdTeamId(
string tenantId,
string teamId)
{
using (var scope = ServiceScopeFactory.CreateScope())
{
var dbContext = GetDatabaseContext(scope);
var query = new OrganizationIntegrationReadByTeamsConfigurationTenantIdTeamIdQuery(tenantId: tenantId, teamId: teamId);
return await query.Run(dbContext).SingleOrDefaultAsync();
}
}
}

View File

@@ -0,0 +1,36 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Enums;
using Bit.Infrastructure.EntityFramework.Repositories;
using Bit.Infrastructure.EntityFramework.Repositories.Queries;
namespace Bit.Infrastructure.EntityFramework.AdminConsole.Repositories.Queries;
public class OrganizationIntegrationReadByTeamsConfigurationTenantIdTeamIdQuery : IQuery<OrganizationIntegration>
{
private readonly string _tenantId;
private readonly string _teamId;
public OrganizationIntegrationReadByTeamsConfigurationTenantIdTeamIdQuery(string tenantId, string teamId)
{
_tenantId = tenantId;
_teamId = teamId;
}
public IQueryable<OrganizationIntegration> Run(DatabaseContext dbContext)
{
var query =
from oi in dbContext.OrganizationIntegrations
where oi.Type == IntegrationType.Teams &&
oi.Configuration != null &&
oi.Configuration.Contains($"\"TenantId\":\"{_tenantId}\"") &&
oi.Configuration.Contains($"\"id\":\"{_teamId}\"")
select new OrganizationIntegration()
{
Id = oi.Id,
OrganizationId = oi.OrganizationId,
Type = oi.Type,
Configuration = oi.Configuration,
};
return query;
}
}

View File

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

View File

@@ -11,6 +11,7 @@ using Bit.Core;
using Bit.Core.AdminConsole.AbilitiesCache;
using Bit.Core.AdminConsole.Models.Business.Tokenables;
using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
using Bit.Core.AdminConsole.Models.Teams;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
using Bit.Core.AdminConsole.Services;
using Bit.Core.AdminConsole.Services.Implementations;
@@ -69,6 +70,8 @@ using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc.Localization;
using Microsoft.Azure.Cosmos.Fluent;
using Microsoft.Bot.Builder;
using Microsoft.Bot.Builder.Integration.AspNet.Core;
using Microsoft.Extensions.Caching.Cosmos;
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.Configuration;
@@ -604,6 +607,33 @@ public static class ServiceCollectionExtensions
return services;
}
public static IServiceCollection AddTeamsService(this IServiceCollection services, GlobalSettings globalSettings)
{
if (CoreHelpers.SettingHasValue(globalSettings.Teams.ClientId) &&
CoreHelpers.SettingHasValue(globalSettings.Teams.ClientSecret) &&
CoreHelpers.SettingHasValue(globalSettings.Teams.Scopes))
{
services.AddHttpClient(TeamsService.HttpClientName);
services.TryAddSingleton<TeamsService>();
services.TryAddSingleton<IBot>(sp => sp.GetRequiredService<TeamsService>());
services.TryAddSingleton<ITeamsService>(sp => sp.GetRequiredService<TeamsService>());
services.TryAddSingleton<IBotFrameworkHttpAdapter>(sp =>
new BotFrameworkHttpAdapter(
new TeamsBotCredentialProvider(
clientId: globalSettings.Teams.ClientId,
clientSecret: globalSettings.Teams.ClientSecret
)
)
);
}
else
{
services.TryAddSingleton<ITeamsService, NoopTeamsService>();
}
return services;
}
public static void UseDefaultMiddleware(this IApplicationBuilder app,
IWebHostEnvironment env, GlobalSettings globalSettings)
{
@@ -913,6 +943,7 @@ public static class ServiceCollectionExtensions
// Add services in support of handlers
services.AddSlackService(globalSettings);
services.AddTeamsService(globalSettings);
services.TryAddSingleton(TimeProvider.System);
services.AddHttpClient(WebhookIntegrationHandler.HttpClientName);
services.AddHttpClient(DatadogIntegrationHandler.HttpClientName);
@@ -921,12 +952,14 @@ public static class ServiceCollectionExtensions
services.TryAddSingleton<IIntegrationHandler<SlackIntegrationConfigurationDetails>, SlackIntegrationHandler>();
services.TryAddSingleton<IIntegrationHandler<WebhookIntegrationConfigurationDetails>, WebhookIntegrationHandler>();
services.TryAddSingleton<IIntegrationHandler<DatadogIntegrationConfigurationDetails>, DatadogIntegrationHandler>();
services.TryAddSingleton<IIntegrationHandler<TeamsIntegrationConfigurationDetails>, TeamsIntegrationHandler>();
var repositoryConfiguration = new RepositoryListenerConfiguration(globalSettings);
var slackConfiguration = new SlackListenerConfiguration(globalSettings);
var webhookConfiguration = new WebhookListenerConfiguration(globalSettings);
var hecConfiguration = new HecListenerConfiguration(globalSettings);
var datadogConfiguration = new DatadogListenerConfiguration(globalSettings);
var teamsConfiguration = new TeamsListenerConfiguration(globalSettings);
if (IsRabbitMqEnabled(globalSettings))
{
@@ -944,6 +977,7 @@ public static class ServiceCollectionExtensions
services.AddRabbitMqIntegration<WebhookIntegrationConfigurationDetails, WebhookListenerConfiguration>(webhookConfiguration);
services.AddRabbitMqIntegration<WebhookIntegrationConfigurationDetails, HecListenerConfiguration>(hecConfiguration);
services.AddRabbitMqIntegration<DatadogIntegrationConfigurationDetails, DatadogListenerConfiguration>(datadogConfiguration);
services.AddRabbitMqIntegration<TeamsIntegrationConfigurationDetails, TeamsListenerConfiguration>(teamsConfiguration);
}
if (IsAzureServiceBusEnabled(globalSettings))
@@ -967,6 +1001,7 @@ public static class ServiceCollectionExtensions
services.AddAzureServiceBusIntegration<WebhookIntegrationConfigurationDetails, WebhookListenerConfiguration>(webhookConfiguration);
services.AddAzureServiceBusIntegration<WebhookIntegrationConfigurationDetails, HecListenerConfiguration>(hecConfiguration);
services.AddAzureServiceBusIntegration<DatadogIntegrationConfigurationDetails, DatadogListenerConfiguration>(datadogConfiguration);
services.AddAzureServiceBusIntegration<TeamsIntegrationConfigurationDetails, TeamsListenerConfiguration>(teamsConfiguration);
}
return services;

View File

@@ -0,0 +1,17 @@
CREATE PROCEDURE [dbo].[OrganizationIntegration_ReadByTeamsConfigurationTenantIdTeamId]
@TenantId NVARCHAR(200),
@TeamId NVARCHAR(200)
AS
BEGIN
SET NOCOUNT ON;
SELECT TOP 1 *
FROM [dbo].[OrganizationIntegrationView]
CROSS APPLY OPENJSON([Configuration], '$.Teams')
WITH ( TeamId NVARCHAR(MAX) '$.id' ) t
WHERE [Type] = 7
AND JSON_VALUE([Configuration], '$.TenantId') = @TenantId
AND t.TeamId = @TeamId
AND JSON_VALUE([Configuration], '$.ChannelId') IS NULL
AND JSON_VALUE([Configuration], '$.ServiceUrl') IS NULL;
END

View File

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

View File

@@ -0,0 +1,392 @@
#nullable enable
using Bit.Api.AdminConsole.Controllers;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
using Bit.Core.Context;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Models.Teams;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Routing;
using Microsoft.Bot.Builder;
using Microsoft.Bot.Builder.Integration.AspNet.Core;
using Microsoft.Extensions.Time.Testing;
using NSubstitute;
using Xunit;
namespace Bit.Api.Test.AdminConsole.Controllers;
[ControllerCustomize(typeof(TeamsIntegrationController))]
[SutProviderCustomize]
public class TeamsIntegrationControllerTests
{
private const string _teamsToken = "test-token";
private const string _validTeamsCode = "A_test_code";
[Theory, BitAutoData]
public async Task CreateAsync_AllParamsProvided_Succeeds(
SutProvider<TeamsIntegrationController> sutProvider,
OrganizationIntegration integration)
{
integration.Type = IntegrationType.Teams;
integration.Configuration = null;
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.Sut.Url
.RouteUrl(Arg.Is<UrlRouteContext>(c => c.RouteName == "TeamsIntegration_Create"))
.Returns("https://localhost");
sutProvider.GetDependency<ITeamsService>()
.ObtainTokenViaOAuth(_validTeamsCode, Arg.Any<string>())
.Returns(_teamsToken);
sutProvider.GetDependency<ITeamsService>()
.GetJoinedTeamsAsync(_teamsToken)
.Returns([
new TeamInfo() { DisplayName = "Test Team", Id = Guid.NewGuid().ToString(), TenantId = Guid.NewGuid().ToString() }
]);
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.GetByIdAsync(integration.Id)
.Returns(integration);
var state = IntegrationOAuthState.FromIntegration(integration, sutProvider.GetDependency<TimeProvider>());
var requestAction = await sutProvider.Sut.CreateAsync(_validTeamsCode, state.ToString());
await sutProvider.GetDependency<IOrganizationIntegrationRepository>().Received(1)
.UpsertAsync(Arg.Any<OrganizationIntegration>());
Assert.IsType<CreatedResult>(requestAction);
}
[Theory, BitAutoData]
public async Task CreateAsync_CodeIsEmpty_ThrowsBadRequest(
SutProvider<TeamsIntegrationController> sutProvider,
OrganizationIntegration integration)
{
integration.Type = IntegrationType.Teams;
integration.Configuration = null;
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.Sut.Url
.RouteUrl(Arg.Is<UrlRouteContext>(c => c.RouteName == "TeamsIntegration_Create"))
.Returns("https://localhost");
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.GetByIdAsync(integration.Id)
.Returns(integration);
var state = IntegrationOAuthState.FromIntegration(integration, sutProvider.GetDependency<TimeProvider>());
await Assert.ThrowsAsync<BadRequestException>(async () =>
await sutProvider.Sut.CreateAsync(string.Empty, state.ToString()));
}
[Theory, BitAutoData]
public async Task CreateAsync_NoTeamsFound_ThrowsBadRequest(
SutProvider<TeamsIntegrationController> sutProvider,
OrganizationIntegration integration)
{
integration.Type = IntegrationType.Teams;
integration.Configuration = null;
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.Sut.Url
.RouteUrl(Arg.Is<UrlRouteContext>(c => c.RouteName == "TeamsIntegration_Create"))
.Returns("https://localhost");
sutProvider.GetDependency<ITeamsService>()
.ObtainTokenViaOAuth(_validTeamsCode, Arg.Any<string>())
.Returns(_teamsToken);
sutProvider.GetDependency<ITeamsService>()
.GetJoinedTeamsAsync(_teamsToken)
.Returns([]);
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.GetByIdAsync(integration.Id)
.Returns(integration);
var state = IntegrationOAuthState.FromIntegration(integration, sutProvider.GetDependency<TimeProvider>());
await Assert.ThrowsAsync<BadRequestException>(async () => await sutProvider.Sut.CreateAsync(_validTeamsCode, state.ToString()));
}
[Theory, BitAutoData]
public async Task CreateAsync_TeamsServiceReturnsEmptyToken_ThrowsBadRequest(
SutProvider<TeamsIntegrationController> sutProvider,
OrganizationIntegration integration)
{
integration.Type = IntegrationType.Teams;
integration.Configuration = null;
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.Sut.Url
.RouteUrl(Arg.Is<UrlRouteContext>(c => c.RouteName == "TeamsIntegration_Create"))
.Returns("https://localhost");
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.GetByIdAsync(integration.Id)
.Returns(integration);
sutProvider.GetDependency<ITeamsService>()
.ObtainTokenViaOAuth(_validTeamsCode, Arg.Any<string>())
.Returns(string.Empty);
var state = IntegrationOAuthState.FromIntegration(integration, sutProvider.GetDependency<TimeProvider>());
await Assert.ThrowsAsync<BadRequestException>(async () => await sutProvider.Sut.CreateAsync(_validTeamsCode, state.ToString()));
}
[Theory, BitAutoData]
public async Task CreateAsync_StateEmpty_ThrowsNotFound(
SutProvider<TeamsIntegrationController> sutProvider)
{
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.Sut.Url
.RouteUrl(Arg.Is<UrlRouteContext>(c => c.RouteName == "TeamsIntegration_Create"))
.Returns("https://localhost");
sutProvider.GetDependency<ITeamsService>()
.ObtainTokenViaOAuth(_validTeamsCode, Arg.Any<string>())
.Returns(_teamsToken);
await Assert.ThrowsAsync<NotFoundException>(async () => await sutProvider.Sut.CreateAsync(_validTeamsCode, string.Empty));
}
[Theory, BitAutoData]
public async Task CreateAsync_StateExpired_ThrowsNotFound(
SutProvider<TeamsIntegrationController> sutProvider,
OrganizationIntegration integration)
{
var timeProvider = new FakeTimeProvider(new DateTime(2024, 4, 3, 2, 1, 0, DateTimeKind.Utc));
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.Sut.Url
.RouteUrl(Arg.Is<UrlRouteContext>(c => c.RouteName == "TeamsIntegration_Create"))
.Returns("https://localhost");
sutProvider.GetDependency<ITeamsService>()
.ObtainTokenViaOAuth(_validTeamsCode, Arg.Any<string>())
.Returns(_teamsToken);
var state = IntegrationOAuthState.FromIntegration(integration, timeProvider);
timeProvider.Advance(TimeSpan.FromMinutes(30));
sutProvider.SetDependency<TimeProvider>(timeProvider);
await Assert.ThrowsAsync<NotFoundException>(async () => await sutProvider.Sut.CreateAsync(_validTeamsCode, state.ToString()));
}
[Theory, BitAutoData]
public async Task CreateAsync_StateHasNonexistentIntegration_ThrowsNotFound(
SutProvider<TeamsIntegrationController> sutProvider,
OrganizationIntegration integration)
{
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.Sut.Url
.RouteUrl(Arg.Is<UrlRouteContext>(c => c.RouteName == "TeamsIntegration_Create"))
.Returns("https://localhost");
sutProvider.GetDependency<ITeamsService>()
.ObtainTokenViaOAuth(_validTeamsCode, Arg.Any<string>())
.Returns(_teamsToken);
var state = IntegrationOAuthState.FromIntegration(integration, sutProvider.GetDependency<TimeProvider>());
await Assert.ThrowsAsync<NotFoundException>(async () => await sutProvider.Sut.CreateAsync(_validTeamsCode, state.ToString()));
}
[Theory, BitAutoData]
public async Task CreateAsync_StateHasWrongOrganizationHash_ThrowsNotFound(
SutProvider<TeamsIntegrationController> sutProvider,
OrganizationIntegration integration,
OrganizationIntegration wrongOrgIntegration)
{
wrongOrgIntegration.Id = integration.Id;
wrongOrgIntegration.Type = IntegrationType.Teams;
wrongOrgIntegration.Configuration = null;
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.Sut.Url
.RouteUrl(Arg.Is<UrlRouteContext>(c => c.RouteName == "TeamsIntegration_Create"))
.Returns("https://localhost");
sutProvider.GetDependency<ITeamsService>()
.ObtainTokenViaOAuth(_validTeamsCode, Arg.Any<string>())
.Returns(_teamsToken);
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.GetByIdAsync(integration.Id)
.Returns(wrongOrgIntegration);
var state = IntegrationOAuthState.FromIntegration(integration, sutProvider.GetDependency<TimeProvider>());
await Assert.ThrowsAsync<NotFoundException>(async () => await sutProvider.Sut.CreateAsync(_validTeamsCode, state.ToString()));
}
[Theory, BitAutoData]
public async Task CreateAsync_StateHasNonEmptyIntegration_ThrowsNotFound(
SutProvider<TeamsIntegrationController> sutProvider,
OrganizationIntegration integration)
{
integration.Type = IntegrationType.Teams;
integration.Configuration = "{}";
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.Sut.Url
.RouteUrl(Arg.Is<UrlRouteContext>(c => c.RouteName == "TeamsIntegration_Create"))
.Returns("https://localhost");
sutProvider.GetDependency<ITeamsService>()
.ObtainTokenViaOAuth(_validTeamsCode, Arg.Any<string>())
.Returns(_teamsToken);
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.GetByIdAsync(integration.Id)
.Returns(integration);
var state = IntegrationOAuthState.FromIntegration(integration, sutProvider.GetDependency<TimeProvider>());
await Assert.ThrowsAsync<NotFoundException>(async () => await sutProvider.Sut.CreateAsync(_validTeamsCode, state.ToString()));
}
[Theory, BitAutoData]
public async Task CreateAsync_StateHasNonTeamsIntegration_ThrowsNotFound(
SutProvider<TeamsIntegrationController> sutProvider,
OrganizationIntegration integration)
{
integration.Type = IntegrationType.Hec;
integration.Configuration = null;
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.Sut.Url
.RouteUrl(Arg.Is<UrlRouteContext>(c => c.RouteName == "TeamsIntegration_Create"))
.Returns("https://localhost");
sutProvider.GetDependency<ITeamsService>()
.ObtainTokenViaOAuth(_validTeamsCode, Arg.Any<string>())
.Returns(_teamsToken);
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.GetByIdAsync(integration.Id)
.Returns(integration);
var state = IntegrationOAuthState.FromIntegration(integration, sutProvider.GetDependency<TimeProvider>());
await Assert.ThrowsAsync<NotFoundException>(async () => await sutProvider.Sut.CreateAsync(_validTeamsCode, state.ToString()));
}
[Theory, BitAutoData]
public async Task RedirectAsync_Success(
SutProvider<TeamsIntegrationController> sutProvider,
OrganizationIntegration integration)
{
integration.Configuration = null;
var expectedUrl = "https://localhost/";
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.Sut.Url
.RouteUrl(Arg.Is<UrlRouteContext>(c => c.RouteName == "TeamsIntegration_Create"))
.Returns(expectedUrl);
sutProvider.GetDependency<ICurrentContext>()
.OrganizationOwner(integration.OrganizationId)
.Returns(true);
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.GetManyByOrganizationAsync(integration.OrganizationId)
.Returns([]);
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.CreateAsync(Arg.Any<OrganizationIntegration>())
.Returns(integration);
sutProvider.GetDependency<ITeamsService>().GetRedirectUrl(Arg.Any<string>(), Arg.Any<string>()).Returns(expectedUrl);
var expectedState = IntegrationOAuthState.FromIntegration(integration, sutProvider.GetDependency<TimeProvider>());
var requestAction = await sutProvider.Sut.RedirectAsync(integration.OrganizationId);
Assert.IsType<RedirectResult>(requestAction);
await sutProvider.GetDependency<IOrganizationIntegrationRepository>().Received(1)
.CreateAsync(Arg.Any<OrganizationIntegration>());
sutProvider.GetDependency<ITeamsService>().Received(1).GetRedirectUrl(Arg.Any<string>(), expectedState.ToString());
}
[Theory, BitAutoData]
public async Task RedirectAsync_IntegrationAlreadyExistsWithNullConfig_Success(
SutProvider<TeamsIntegrationController> sutProvider,
Guid organizationId,
OrganizationIntegration integration)
{
integration.OrganizationId = organizationId;
integration.Configuration = null;
integration.Type = IntegrationType.Teams;
var expectedUrl = "https://localhost/";
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.Sut.Url
.RouteUrl(Arg.Is<UrlRouteContext>(c => c.RouteName == "TeamsIntegration_Create"))
.Returns(expectedUrl);
sutProvider.GetDependency<ICurrentContext>()
.OrganizationOwner(organizationId)
.Returns(true);
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.GetManyByOrganizationAsync(organizationId)
.Returns([integration]);
sutProvider.GetDependency<ITeamsService>().GetRedirectUrl(Arg.Any<string>(), Arg.Any<string>()).Returns(expectedUrl);
var requestAction = await sutProvider.Sut.RedirectAsync(organizationId);
var expectedState = IntegrationOAuthState.FromIntegration(integration, sutProvider.GetDependency<TimeProvider>());
Assert.IsType<RedirectResult>(requestAction);
sutProvider.GetDependency<ITeamsService>().Received(1).GetRedirectUrl(Arg.Any<string>(), expectedState.ToString());
}
[Theory, BitAutoData]
public async Task RedirectAsync_IntegrationAlreadyExistsWithConfig_ThrowsBadRequest(
SutProvider<TeamsIntegrationController> sutProvider,
Guid organizationId,
OrganizationIntegration integration)
{
integration.OrganizationId = organizationId;
integration.Configuration = "{}";
integration.Type = IntegrationType.Teams;
var expectedUrl = "https://localhost/";
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.Sut.Url
.RouteUrl(Arg.Is<UrlRouteContext>(c => c.RouteName == "TeamsIntegration_Create"))
.Returns(expectedUrl);
sutProvider.GetDependency<ICurrentContext>()
.OrganizationOwner(organizationId)
.Returns(true);
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.GetManyByOrganizationAsync(organizationId)
.Returns([integration]);
sutProvider.GetDependency<ITeamsService>().GetRedirectUrl(Arg.Any<string>(), Arg.Any<string>()).Returns(expectedUrl);
await Assert.ThrowsAsync<BadRequestException>(async () => await sutProvider.Sut.RedirectAsync(organizationId));
}
[Theory, BitAutoData]
public async Task RedirectAsync_TeamsServiceReturnsEmpty_ThrowsNotFound(
SutProvider<TeamsIntegrationController> sutProvider,
Guid organizationId,
OrganizationIntegration integration)
{
integration.OrganizationId = organizationId;
integration.Configuration = null;
var expectedUrl = "https://localhost/";
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.Sut.Url
.RouteUrl(Arg.Is<UrlRouteContext>(c => c.RouteName == "TeamsIntegration_Create"))
.Returns(expectedUrl);
sutProvider.GetDependency<ICurrentContext>()
.OrganizationOwner(organizationId)
.Returns(true);
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.GetManyByOrganizationAsync(organizationId)
.Returns([]);
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.CreateAsync(Arg.Any<OrganizationIntegration>())
.Returns(integration);
sutProvider.GetDependency<ITeamsService>().GetRedirectUrl(Arg.Any<string>(), Arg.Any<string>()).Returns(string.Empty);
await Assert.ThrowsAsync<NotFoundException>(async () => await sutProvider.Sut.RedirectAsync(organizationId));
}
[Theory, BitAutoData]
public async Task RedirectAsync_UserIsNotOrganizationAdmin_ThrowsNotFound(SutProvider<TeamsIntegrationController> sutProvider,
Guid organizationId)
{
sutProvider.GetDependency<ICurrentContext>()
.OrganizationOwner(organizationId)
.Returns(false);
await Assert.ThrowsAsync<NotFoundException>(async () => await sutProvider.Sut.RedirectAsync(organizationId));
}
[Theory, BitAutoData]
public async Task IncomingPostAsync_ForwardsToBot(SutProvider<TeamsIntegrationController> sutProvider)
{
var adapter = sutProvider.GetDependency<IBotFrameworkHttpAdapter>();
var bot = sutProvider.GetDependency<IBot>();
await sutProvider.Sut.IncomingPostAsync();
await adapter.Received(1).ProcessAsync(Arg.Any<HttpRequest>(), Arg.Any<HttpResponse>(), bot);
}
}

View File

@@ -39,7 +39,7 @@ public class OrganizationIntegrationConfigurationRequestModelTests
[Theory]
[InlineData(data: "")]
[InlineData(data: " ")]
public void IsValidForType_EmptyNonNullHecConfiguration_ReturnsFalse(string? config)
public void IsValidForType_EmptyNonNullConfiguration_ReturnsFalse(string? config)
{
var model = new OrganizationIntegrationConfigurationRequestModel
{
@@ -48,10 +48,12 @@ public class OrganizationIntegrationConfigurationRequestModelTests
};
Assert.False(condition: model.IsValidForType(IntegrationType.Hec));
Assert.False(condition: model.IsValidForType(IntegrationType.Datadog));
Assert.False(condition: model.IsValidForType(IntegrationType.Teams));
}
[Fact]
public void IsValidForType_NullHecConfiguration_ReturnsTrue()
public void IsValidForType_NullConfiguration_ReturnsTrue()
{
var model = new OrganizationIntegrationConfigurationRequestModel
{
@@ -60,32 +62,8 @@ public class OrganizationIntegrationConfigurationRequestModelTests
};
Assert.True(condition: model.IsValidForType(IntegrationType.Hec));
}
[Theory]
[InlineData(data: "")]
[InlineData(data: " ")]
public void IsValidForType_EmptyNonNullDatadogConfiguration_ReturnsFalse(string? config)
{
var model = new OrganizationIntegrationConfigurationRequestModel
{
Configuration = config,
Template = "template"
};
Assert.False(condition: model.IsValidForType(IntegrationType.Datadog));
}
[Fact]
public void IsValidForType_NullDatadogConfiguration_ReturnsTrue()
{
var model = new OrganizationIntegrationConfigurationRequestModel
{
Configuration = null,
Template = "template"
};
Assert.True(condition: model.IsValidForType(IntegrationType.Datadog));
Assert.True(condition: model.IsValidForType(IntegrationType.Teams));
}
[Theory]
@@ -107,6 +85,8 @@ public class OrganizationIntegrationConfigurationRequestModelTests
Assert.False(condition: model.IsValidForType(IntegrationType.Slack));
Assert.False(condition: model.IsValidForType(IntegrationType.Webhook));
Assert.False(condition: model.IsValidForType(IntegrationType.Hec));
Assert.False(condition: model.IsValidForType(IntegrationType.Datadog));
Assert.False(condition: model.IsValidForType(IntegrationType.Teams));
}
[Fact]
@@ -121,6 +101,8 @@ public class OrganizationIntegrationConfigurationRequestModelTests
Assert.False(condition: model.IsValidForType(IntegrationType.Slack));
Assert.False(condition: model.IsValidForType(IntegrationType.Webhook));
Assert.False(condition: model.IsValidForType(IntegrationType.Hec));
Assert.False(condition: model.IsValidForType(IntegrationType.Datadog));
Assert.False(condition: model.IsValidForType(IntegrationType.Teams));
}

View File

@@ -57,6 +57,22 @@ public class OrganizationIntegrationRequestModelTests
Assert.Contains("cannot be created directly", results[0].ErrorMessage);
}
[Fact]
public void Validate_Teams_ReturnsCannotBeCreatedDirectlyError()
{
var model = new OrganizationIntegrationRequestModel
{
Type = IntegrationType.Teams,
Configuration = null
};
var results = model.Validate(new ValidationContext(model)).ToList();
Assert.Single(results);
Assert.Contains(nameof(model.Type), results[0].MemberNames);
Assert.Contains("cannot be created directly", results[0].ErrorMessage);
}
[Fact]
public void Validate_Webhook_WithNullConfiguration_ReturnsNoErrors()
{

View File

@@ -1,8 +1,11 @@
#nullable enable
using System.Text.Json;
using Bit.Api.AdminConsole.Models.Response.Organizations;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
using Bit.Core.Enums;
using Bit.Core.Models.Teams;
using Bit.Test.Common.AutoFixture.Attributes;
using Xunit;
@@ -58,6 +61,46 @@ public class OrganizationIntegrationResponseModelTests
Assert.Equal(OrganizationIntegrationStatus.Completed, model.Status);
}
[Theory, BitAutoData]
public void Status_Teams_NullConfig_ReturnsInitiated(OrganizationIntegration oi)
{
oi.Type = IntegrationType.Teams;
oi.Configuration = null;
var model = new OrganizationIntegrationResponseModel(oi);
Assert.Equal(OrganizationIntegrationStatus.Initiated, model.Status);
}
[Theory, BitAutoData]
public void Status_Teams_WithTenantAndTeamsConfig_ReturnsInProgress(OrganizationIntegration oi)
{
oi.Type = IntegrationType.Teams;
oi.Configuration = JsonSerializer.Serialize(new TeamsIntegration(
TenantId: "tenant", Teams: [new TeamInfo() { DisplayName = "Team", Id = "TeamId", TenantId = "tenant" }]
));
var model = new OrganizationIntegrationResponseModel(oi);
Assert.Equal(OrganizationIntegrationStatus.InProgress, model.Status);
}
[Theory, BitAutoData]
public void Status_Teams_WithCompletedConfig_ReturnsCompleted(OrganizationIntegration oi)
{
oi.Type = IntegrationType.Teams;
oi.Configuration = JsonSerializer.Serialize(new TeamsIntegration(
TenantId: "tenant",
Teams: [new TeamInfo() { DisplayName = "Team", Id = "TeamId", TenantId = "tenant" }],
ServiceUrl: new Uri("https://example.com"),
ChannelId: "channellId"
));
var model = new OrganizationIntegrationResponseModel(oi);
Assert.Equal(OrganizationIntegrationStatus.Completed, model.Status);
}
[Theory, BitAutoData]
public void Status_Webhook_AlwaysCompleted(OrganizationIntegration oi)
{

View File

@@ -0,0 +1,56 @@
using Bit.Core.AdminConsole.Models.Teams;
using Microsoft.Bot.Connector.Authentication;
using Xunit;
namespace Bit.Core.Test.Models.Data.Teams;
public class TeamsBotCredentialProviderTests
{
private string _clientId = "client id";
private string _clientSecret = "client secret";
[Fact]
public async Task IsValidAppId_MustMatchClientId()
{
var sut = new TeamsBotCredentialProvider(_clientId, _clientSecret);
Assert.True(await sut.IsValidAppIdAsync(_clientId));
Assert.False(await sut.IsValidAppIdAsync("Different id"));
}
[Fact]
public async Task GetAppPasswordAsync_MatchingClientId_ReturnsClientSecret()
{
var sut = new TeamsBotCredentialProvider(_clientId, _clientSecret);
var password = await sut.GetAppPasswordAsync(_clientId);
Assert.Equal(_clientSecret, password);
}
[Fact]
public async Task GetAppPasswordAsync_NotMatchingClientId_ReturnsNull()
{
var sut = new TeamsBotCredentialProvider(_clientId, _clientSecret);
Assert.Null(await sut.GetAppPasswordAsync("Different id"));
}
[Fact]
public async Task IsAuthenticationDisabledAsync_ReturnsFalse()
{
var sut = new TeamsBotCredentialProvider(_clientId, _clientSecret);
Assert.False(await sut.IsAuthenticationDisabledAsync());
}
[Fact]
public async Task ValidateIssuerAsync_ExpectedIssuer_ReturnsTrue()
{
var sut = new TeamsBotCredentialProvider(_clientId, _clientSecret);
Assert.True(await sut.ValidateIssuerAsync(AuthenticationConstants.ToBotFromChannelTokenIssuer));
}
[Fact]
public async Task ValidateIssuerAsync_UnexpectedIssuer_ReturnsFalse()
{
var sut = new TeamsBotCredentialProvider(_clientId, _clientSecret);
Assert.False(await sut.ValidateIssuerAsync("unexpected issuer"));
}
}

View File

@@ -0,0 +1,124 @@
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces;
using OneOf.Types;
using Xunit;
namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.Policies;
public class PolicyEventHandlerHandlerFactoryTests
{
[Fact]
public void GetHandler_ReturnsHandler_WhenHandlerExists()
{
// Arrange
var expectedHandler = new FakeSingleOrgDependencyEvent();
var factory = new PolicyEventHandlerHandlerFactory([expectedHandler]);
// Act
var result = factory.GetHandler<IEnforceDependentPoliciesEvent>(PolicyType.SingleOrg);
// Assert
Assert.True(result.IsT0);
Assert.Equal(expectedHandler, result.AsT0);
}
[Fact]
public void GetHandler_ReturnsNone_WhenHandlerDoesNotExist()
{
// Arrange
var factory = new PolicyEventHandlerHandlerFactory([new FakeSingleOrgDependencyEvent()]);
// Act
var result = factory.GetHandler<IEnforceDependentPoliciesEvent>(PolicyType.RequireSso);
// Assert
Assert.True(result.IsT1);
Assert.IsType<None>(result.AsT1);
}
[Fact]
public void GetHandler_ReturnsNone_WhenHandlerTypeDoesNotMatch()
{
// Arrange
var factory = new PolicyEventHandlerHandlerFactory([new FakeSingleOrgDependencyEvent()]);
// Act
var result = factory.GetHandler<IPolicyValidationEvent>(PolicyType.SingleOrg);
// Assert
Assert.True(result.IsT1);
Assert.IsType<None>(result.AsT1);
}
[Fact]
public void GetHandler_ReturnsCorrectHandler_WhenMultipleHandlerTypesExist()
{
// Arrange
var dependencyEvent = new FakeSingleOrgDependencyEvent();
var validationEvent = new FakeSingleOrgValidationEvent();
var factory = new PolicyEventHandlerHandlerFactory([dependencyEvent, validationEvent]);
// Act
var dependencyResult = factory.GetHandler<IEnforceDependentPoliciesEvent>(PolicyType.SingleOrg);
var validationResult = factory.GetHandler<IPolicyValidationEvent>(PolicyType.SingleOrg);
// Assert
Assert.True(dependencyResult.IsT0);
Assert.Equal(dependencyEvent, dependencyResult.AsT0);
Assert.True(validationResult.IsT0);
Assert.Equal(validationEvent, validationResult.AsT0);
}
[Fact]
public void GetHandler_ReturnsCorrectHandler_WhenMultiplePolicyTypesExist()
{
// Arrange
var singleOrgEvent = new FakeSingleOrgDependencyEvent();
var requireSsoEvent = new FakeRequireSsoDependencyEvent();
var factory = new PolicyEventHandlerHandlerFactory([singleOrgEvent, requireSsoEvent]);
// Act
var singleOrgResult = factory.GetHandler<IEnforceDependentPoliciesEvent>(PolicyType.SingleOrg);
var requireSsoResult = factory.GetHandler<IEnforceDependentPoliciesEvent>(PolicyType.RequireSso);
// Assert
Assert.True(singleOrgResult.IsT0);
Assert.Equal(singleOrgEvent, singleOrgResult.AsT0);
Assert.True(requireSsoResult.IsT0);
Assert.Equal(requireSsoEvent, requireSsoResult.AsT0);
}
[Fact]
public void GetHandler_Throws_WhenDuplicateHandlersExist()
{
// Arrange
var factory = new PolicyEventHandlerHandlerFactory([
new FakeSingleOrgDependencyEvent(),
new FakeSingleOrgDependencyEvent()
]);
// Act & Assert
var exception = Assert.Throws<InvalidOperationException>(() =>
factory.GetHandler<IEnforceDependentPoliciesEvent>(PolicyType.SingleOrg));
Assert.Contains("Multiple IPolicyUpdateEvent handlers of type IEnforceDependentPoliciesEvent found for PolicyType SingleOrg", exception.Message);
Assert.Contains("Expected one IEnforceDependentPoliciesEvent handler per PolicyType", exception.Message);
}
[Fact]
public void GetHandler_ReturnsNone_WhenNoHandlersProvided()
{
// Arrange
var factory = new PolicyEventHandlerHandlerFactory([]);
// Act
var result = factory.GetHandler<IEnforceDependentPoliciesEvent>(PolicyType.SingleOrg);
// Assert
Assert.True(result.IsT1);
Assert.IsType<None>(result.AsT1);
}
}

View File

@@ -0,0 +1,37 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces;
using NSubstitute;
namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.Policies;
public class FakeSingleOrgDependencyEvent : IEnforceDependentPoliciesEvent
{
public PolicyType Type => PolicyType.SingleOrg;
public IEnumerable<PolicyType> RequiredPolicies => [];
}
public class FakeRequireSsoDependencyEvent : IEnforceDependentPoliciesEvent
{
public PolicyType Type => PolicyType.RequireSso;
public IEnumerable<PolicyType> RequiredPolicies => [PolicyType.SingleOrg];
}
public class FakeVaultTimeoutDependencyEvent : IEnforceDependentPoliciesEvent
{
public PolicyType Type => PolicyType.MaximumVaultTimeout;
public IEnumerable<PolicyType> RequiredPolicies => [PolicyType.SingleOrg];
}
public class FakeSingleOrgValidationEvent : IPolicyValidationEvent
{
public PolicyType Type => PolicyType.SingleOrg;
public readonly Func<SavePolicyModel, Policy?, Task<string>> ValidateAsyncMock = Substitute.For<Func<SavePolicyModel, Policy?, Task<string>>>();
public Task<string> ValidateAsync(SavePolicyModel policyRequest, Policy? currentPolicy)
{
return ValidateAsyncMock(policyRequest, currentPolicy);
}
}

View File

@@ -0,0 +1,471 @@
#nullable enable
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Implementations;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Exceptions;
using Bit.Core.Models.Data.Organizations;
using Bit.Core.Services;
using Bit.Core.Test.AdminConsole.AutoFixture;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using Microsoft.Extensions.Time.Testing;
using NSubstitute;
using OneOf.Types;
using Xunit;
using EventType = Bit.Core.Enums.EventType;
namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.Policies;
public class VNextSavePolicyCommandTests
{
[Theory, BitAutoData]
public async Task SaveAsync_NewPolicy_Success([PolicyUpdate(PolicyType.SingleOrg)] PolicyUpdate policyUpdate)
{
// Arrange
var fakePolicyValidationEvent = new FakeSingleOrgValidationEvent();
fakePolicyValidationEvent.ValidateAsyncMock(Arg.Any<SavePolicyModel>(), Arg.Any<Policy>()).Returns("");
var sutProvider = SutProviderFactory(
[new FakeSingleOrgDependencyEvent()],
[fakePolicyValidationEvent]);
var savePolicyModel = new SavePolicyModel(policyUpdate, null, new EmptyMetadataModel());
var newPolicy = new Policy
{
Type = policyUpdate.Type,
OrganizationId = policyUpdate.OrganizationId,
Enabled = false
};
ArrangeOrganization(sutProvider, policyUpdate);
sutProvider.GetDependency<IPolicyRepository>().GetManyByOrganizationIdAsync(policyUpdate.OrganizationId).Returns([newPolicy]);
var creationDate = sutProvider.GetDependency<FakeTimeProvider>().Start;
// Act
await sutProvider.Sut.SaveAsync(savePolicyModel);
// Assert
await fakePolicyValidationEvent.ValidateAsyncMock
.Received(1)
.Invoke(Arg.Any<SavePolicyModel>(), Arg.Any<Policy>());
await AssertPolicySavedAsync(sutProvider, policyUpdate);
await sutProvider.GetDependency<IPolicyRepository>()
.Received(1)
.UpsertAsync(Arg.Is<Policy>(p =>
p.CreationDate == creationDate &&
p.RevisionDate == creationDate));
}
[Theory, BitAutoData]
public async Task SaveAsync_ExistingPolicy_Success(
[PolicyUpdate(PolicyType.SingleOrg)] PolicyUpdate policyUpdate,
[Policy(PolicyType.SingleOrg, false)] Policy currentPolicy)
{
// Arrange
var fakePolicyValidationEvent = new FakeSingleOrgValidationEvent();
fakePolicyValidationEvent.ValidateAsyncMock(Arg.Any<SavePolicyModel>(), Arg.Any<Policy>()).Returns("");
var sutProvider = SutProviderFactory(
[new FakeSingleOrgDependencyEvent()],
[fakePolicyValidationEvent]);
var savePolicyModel = new SavePolicyModel(policyUpdate, null, new EmptyMetadataModel());
currentPolicy.OrganizationId = policyUpdate.OrganizationId;
sutProvider.GetDependency<IPolicyRepository>()
.GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, policyUpdate.Type)
.Returns(currentPolicy);
ArrangeOrganization(sutProvider, policyUpdate);
sutProvider.GetDependency<IPolicyRepository>()
.GetManyByOrganizationIdAsync(policyUpdate.OrganizationId)
.Returns([currentPolicy]);
// Act
await sutProvider.Sut.SaveAsync(savePolicyModel);
// Assert
await fakePolicyValidationEvent.ValidateAsyncMock
.Received(1)
.Invoke(Arg.Any<SavePolicyModel>(), currentPolicy);
await AssertPolicySavedAsync(sutProvider, policyUpdate);
var revisionDate = sutProvider.GetDependency<FakeTimeProvider>().Start;
await sutProvider.GetDependency<IPolicyRepository>()
.Received(1)
.UpsertAsync(Arg.Is<Policy>(p =>
p.Id == currentPolicy.Id &&
p.OrganizationId == currentPolicy.OrganizationId &&
p.Type == currentPolicy.Type &&
p.CreationDate == currentPolicy.CreationDate &&
p.RevisionDate == revisionDate));
}
[Fact]
public void Constructor_DuplicatePolicyDependencyEvents_Throws()
{
// Arrange & Act
var exception = Assert.Throws<Exception>(() =>
new VNextSavePolicyCommand(
Substitute.For<IApplicationCacheService>(),
Substitute.For<IEventService>(),
Substitute.For<IPolicyRepository>(),
[new FakeSingleOrgDependencyEvent(), new FakeSingleOrgDependencyEvent()],
Substitute.For<TimeProvider>(),
Substitute.For<IPolicyEventHandlerFactory>()));
// Assert
Assert.Contains("Duplicate PolicyValidationEvent for SingleOrg policy", exception.Message);
}
[Theory, BitAutoData]
public async Task SaveAsync_OrganizationDoesNotExist_ThrowsBadRequest([PolicyUpdate(PolicyType.ActivateAutofill)] PolicyUpdate policyUpdate)
{
// Arrange
var sutProvider = SutProviderFactory();
var savePolicyModel = new SavePolicyModel(policyUpdate, null, new EmptyMetadataModel());
sutProvider.GetDependency<IApplicationCacheService>()
.GetOrganizationAbilityAsync(policyUpdate.OrganizationId)
.Returns(Task.FromResult<OrganizationAbility?>(null));
// Act
var badRequestException = await Assert.ThrowsAsync<BadRequestException>(
() => sutProvider.Sut.SaveAsync(savePolicyModel));
// Assert
Assert.Contains("Organization not found", badRequestException.Message, StringComparison.OrdinalIgnoreCase);
await AssertPolicyNotSavedAsync(sutProvider);
}
[Theory, BitAutoData]
public async Task SaveAsync_OrganizationCannotUsePolicies_ThrowsBadRequest([PolicyUpdate(PolicyType.ActivateAutofill)] PolicyUpdate policyUpdate)
{
// Arrange
var sutProvider = SutProviderFactory();
var savePolicyModel = new SavePolicyModel(policyUpdate, null, new EmptyMetadataModel());
sutProvider.GetDependency<IApplicationCacheService>()
.GetOrganizationAbilityAsync(policyUpdate.OrganizationId)
.Returns(new OrganizationAbility
{
Id = policyUpdate.OrganizationId,
UsePolicies = false
});
// Act
var badRequestException = await Assert.ThrowsAsync<BadRequestException>(
() => sutProvider.Sut.SaveAsync(savePolicyModel));
// Assert
Assert.Contains("cannot use policies", badRequestException.Message, StringComparison.OrdinalIgnoreCase);
await AssertPolicyNotSavedAsync(sutProvider);
}
[Theory, BitAutoData]
public async Task SaveAsync_RequiredPolicyIsNull_Throws(
[PolicyUpdate(PolicyType.RequireSso)] PolicyUpdate policyUpdate)
{
// Arrange
var sutProvider = SutProviderFactory(
[
new FakeRequireSsoDependencyEvent(),
new FakeSingleOrgDependencyEvent()
]);
var savePolicyModel = new SavePolicyModel(policyUpdate, null, new EmptyMetadataModel());
var requireSsoPolicy = new Policy
{
Type = PolicyType.RequireSso,
OrganizationId = policyUpdate.OrganizationId,
Enabled = false
};
ArrangeOrganization(sutProvider, policyUpdate);
sutProvider.GetDependency<IPolicyRepository>()
.GetManyByOrganizationIdAsync(policyUpdate.OrganizationId)
.Returns([requireSsoPolicy]);
// Act
var badRequestException = await Assert.ThrowsAsync<BadRequestException>(
() => sutProvider.Sut.SaveAsync(savePolicyModel));
// Assert
Assert.Contains("Turn on the Single organization policy because it is required for the Require single sign-on authentication policy", badRequestException.Message, StringComparison.OrdinalIgnoreCase);
await AssertPolicyNotSavedAsync(sutProvider);
}
[Theory, BitAutoData]
public async Task SaveAsync_RequiredPolicyNotEnabled_Throws(
[PolicyUpdate(PolicyType.RequireSso)] PolicyUpdate policyUpdate,
[Policy(PolicyType.SingleOrg, false)] Policy singleOrgPolicy)
{
// Arrange
var sutProvider = SutProviderFactory(
[
new FakeRequireSsoDependencyEvent(),
new FakeSingleOrgDependencyEvent()
]);
var savePolicyModel = new SavePolicyModel(policyUpdate, null, new EmptyMetadataModel());
var requireSsoPolicy = new Policy
{
Type = PolicyType.RequireSso,
OrganizationId = policyUpdate.OrganizationId,
Enabled = false
};
ArrangeOrganization(sutProvider, policyUpdate);
sutProvider.GetDependency<IPolicyRepository>()
.GetManyByOrganizationIdAsync(policyUpdate.OrganizationId)
.Returns([singleOrgPolicy, requireSsoPolicy]);
// Act
var badRequestException = await Assert.ThrowsAsync<BadRequestException>(
() => sutProvider.Sut.SaveAsync(savePolicyModel));
// Assert
Assert.Contains("Turn on the Single organization policy because it is required for the Require single sign-on authentication policy", badRequestException.Message, StringComparison.OrdinalIgnoreCase);
await AssertPolicyNotSavedAsync(sutProvider);
}
[Theory, BitAutoData]
public async Task SaveAsync_RequiredPolicyEnabled_Success(
[PolicyUpdate(PolicyType.RequireSso)] PolicyUpdate policyUpdate,
[Policy(PolicyType.SingleOrg)] Policy singleOrgPolicy)
{
// Arrange
var sutProvider = SutProviderFactory(
[
new FakeRequireSsoDependencyEvent(),
new FakeSingleOrgDependencyEvent()
]);
var savePolicyModel = new SavePolicyModel(policyUpdate, null, new EmptyMetadataModel());
var requireSsoPolicy = new Policy
{
Type = PolicyType.RequireSso,
OrganizationId = policyUpdate.OrganizationId,
Enabled = false
};
ArrangeOrganization(sutProvider, policyUpdate);
sutProvider.GetDependency<IPolicyRepository>()
.GetManyByOrganizationIdAsync(policyUpdate.OrganizationId)
.Returns([singleOrgPolicy, requireSsoPolicy]);
// Act
await sutProvider.Sut.SaveAsync(savePolicyModel);
// Assert
await AssertPolicySavedAsync(sutProvider, policyUpdate);
}
[Theory, BitAutoData]
public async Task SaveAsync_DependentPolicyIsEnabled_Throws(
[PolicyUpdate(PolicyType.SingleOrg, false)] PolicyUpdate policyUpdate,
[Policy(PolicyType.SingleOrg)] Policy currentPolicy,
[Policy(PolicyType.RequireSso)] Policy requireSsoPolicy)
{
// Arrange
var sutProvider = SutProviderFactory(
[
new FakeRequireSsoDependencyEvent(),
new FakeSingleOrgDependencyEvent()
]);
var savePolicyModel = new SavePolicyModel(policyUpdate, null, new EmptyMetadataModel());
ArrangeOrganization(sutProvider, policyUpdate);
sutProvider.GetDependency<IPolicyRepository>()
.GetManyByOrganizationIdAsync(policyUpdate.OrganizationId)
.Returns([currentPolicy, requireSsoPolicy]);
// Act
var badRequestException = await Assert.ThrowsAsync<BadRequestException>(
() => sutProvider.Sut.SaveAsync(savePolicyModel));
// Assert
Assert.Contains("Turn off the Require single sign-on authentication policy because it requires the Single organization policy", badRequestException.Message, StringComparison.OrdinalIgnoreCase);
await AssertPolicyNotSavedAsync(sutProvider);
}
[Theory, BitAutoData]
public async Task SaveAsync_MultipleDependentPoliciesAreEnabled_Throws(
[PolicyUpdate(PolicyType.SingleOrg, false)] PolicyUpdate policyUpdate,
[Policy(PolicyType.SingleOrg)] Policy currentPolicy,
[Policy(PolicyType.RequireSso)] Policy requireSsoPolicy,
[Policy(PolicyType.MaximumVaultTimeout)] Policy vaultTimeoutPolicy)
{
// Arrange
var sutProvider = SutProviderFactory(
[
new FakeRequireSsoDependencyEvent(),
new FakeSingleOrgDependencyEvent(),
new FakeVaultTimeoutDependencyEvent()
]);
var savePolicyModel = new SavePolicyModel(policyUpdate, null, new EmptyMetadataModel());
ArrangeOrganization(sutProvider, policyUpdate);
sutProvider.GetDependency<IPolicyRepository>()
.GetManyByOrganizationIdAsync(policyUpdate.OrganizationId)
.Returns([currentPolicy, requireSsoPolicy, vaultTimeoutPolicy]);
// Act
var badRequestException = await Assert.ThrowsAsync<BadRequestException>(
() => sutProvider.Sut.SaveAsync(savePolicyModel));
// Assert
Assert.Contains("Turn off all of the policies that require the Single organization policy", badRequestException.Message, StringComparison.OrdinalIgnoreCase);
await AssertPolicyNotSavedAsync(sutProvider);
}
[Theory, BitAutoData]
public async Task SaveAsync_DependentPolicyNotEnabled_Success(
[PolicyUpdate(PolicyType.SingleOrg, false)] PolicyUpdate policyUpdate,
[Policy(PolicyType.SingleOrg)] Policy currentPolicy,
[Policy(PolicyType.RequireSso, false)] Policy requireSsoPolicy)
{
// Arrange
var sutProvider = SutProviderFactory(
[
new FakeRequireSsoDependencyEvent(),
new FakeSingleOrgDependencyEvent()
]);
var savePolicyModel = new SavePolicyModel(policyUpdate, null, new EmptyMetadataModel());
ArrangeOrganization(sutProvider, policyUpdate);
sutProvider.GetDependency<IPolicyRepository>()
.GetManyByOrganizationIdAsync(policyUpdate.OrganizationId)
.Returns([currentPolicy, requireSsoPolicy]);
// Act
await sutProvider.Sut.SaveAsync(savePolicyModel);
// Assert
await AssertPolicySavedAsync(sutProvider, policyUpdate);
}
[Theory, BitAutoData]
public async Task SaveAsync_ThrowsOnValidationError([PolicyUpdate(PolicyType.SingleOrg)] PolicyUpdate policyUpdate)
{
// Arrange
var fakePolicyValidationEvent = new FakeSingleOrgValidationEvent();
fakePolicyValidationEvent.ValidateAsyncMock(Arg.Any<SavePolicyModel>(), Arg.Any<Policy>()).Returns("Validation error!");
var sutProvider = SutProviderFactory(
[new FakeSingleOrgDependencyEvent()],
[fakePolicyValidationEvent]);
var savePolicyModel = new SavePolicyModel(policyUpdate, null, new EmptyMetadataModel());
var singleOrgPolicy = new Policy
{
Type = PolicyType.SingleOrg,
OrganizationId = policyUpdate.OrganizationId,
Enabled = false
};
ArrangeOrganization(sutProvider, policyUpdate);
sutProvider.GetDependency<IPolicyRepository>().GetManyByOrganizationIdAsync(policyUpdate.OrganizationId).Returns([singleOrgPolicy]);
// Act
var badRequestException = await Assert.ThrowsAsync<BadRequestException>(
() => sutProvider.Sut.SaveAsync(savePolicyModel));
// Assert
Assert.Contains("Validation error!", badRequestException.Message, StringComparison.OrdinalIgnoreCase);
await AssertPolicyNotSavedAsync(sutProvider);
}
/// <summary>
/// Returns a new SutProvider with the PolicyDependencyEvents registered in the Sut.
/// </summary>
private static SutProvider<VNextSavePolicyCommand> SutProviderFactory(
IEnumerable<IEnforceDependentPoliciesEvent>? policyDependencyEvents = null,
IEnumerable<IPolicyValidationEvent>? policyValidationEvents = null)
{
var policyEventHandlerFactory = Substitute.For<IPolicyEventHandlerFactory>();
// Setup factory to return handlers based on type
policyEventHandlerFactory.GetHandler<IEnforceDependentPoliciesEvent>(Arg.Any<PolicyType>())
.Returns(callInfo =>
{
var policyType = callInfo.Arg<PolicyType>();
var handler = policyDependencyEvents?.FirstOrDefault(e => e.Type == policyType);
return handler != null ? OneOf.OneOf<IEnforceDependentPoliciesEvent, None>.FromT0(handler) : OneOf.OneOf<IEnforceDependentPoliciesEvent, None>.FromT1(new None());
});
policyEventHandlerFactory.GetHandler<IPolicyValidationEvent>(Arg.Any<PolicyType>())
.Returns(callInfo =>
{
var policyType = callInfo.Arg<PolicyType>();
var handler = policyValidationEvents?.FirstOrDefault(e => e.Type == policyType);
return handler != null ? OneOf.OneOf<IPolicyValidationEvent, None>.FromT0(handler) : OneOf.OneOf<IPolicyValidationEvent, None>.FromT1(new None());
});
policyEventHandlerFactory.GetHandler<IOnPolicyPreUpdateEvent>(Arg.Any<PolicyType>())
.Returns(new None());
policyEventHandlerFactory.GetHandler<IOnPolicyPostUpdateEvent>(Arg.Any<PolicyType>())
.Returns(new None());
return new SutProvider<VNextSavePolicyCommand>()
.WithFakeTimeProvider()
.SetDependency(policyDependencyEvents ?? [])
.SetDependency(policyEventHandlerFactory)
.Create();
}
private static void ArrangeOrganization(SutProvider<VNextSavePolicyCommand> sutProvider, PolicyUpdate policyUpdate)
{
sutProvider.GetDependency<IApplicationCacheService>()
.GetOrganizationAbilityAsync(policyUpdate.OrganizationId)
.Returns(new OrganizationAbility
{
Id = policyUpdate.OrganizationId,
UsePolicies = true
});
}
private static async Task AssertPolicyNotSavedAsync(SutProvider<VNextSavePolicyCommand> sutProvider)
{
await sutProvider.GetDependency<IPolicyRepository>()
.DidNotReceiveWithAnyArgs()
.UpsertAsync(default!);
await sutProvider.GetDependency<IEventService>()
.DidNotReceiveWithAnyArgs()
.LogPolicyEventAsync(default, default);
}
private static async Task AssertPolicySavedAsync(SutProvider<VNextSavePolicyCommand> sutProvider, PolicyUpdate policyUpdate)
{
await sutProvider.GetDependency<IPolicyRepository>().Received(1).UpsertAsync(ExpectedPolicy());
await sutProvider.GetDependency<IEventService>().Received(1)
.LogPolicyEventAsync(ExpectedPolicy(), EventType.Policy_Updated);
return;
Policy ExpectedPolicy() => Arg.Is<Policy>(
p =>
p.Type == policyUpdate.Type
&& p.OrganizationId == policyUpdate.OrganizationId
&& p.Enabled == policyUpdate.Enabled
&& p.Data == policyUpdate.Data);
}
}

View File

@@ -5,17 +5,6 @@ namespace Bit.Core.Test.Services;
public class IntegrationTypeTests
{
[Fact]
public void ToRoutingKey_Slack_Succeeds()
{
Assert.Equal("slack", IntegrationType.Slack.ToRoutingKey());
}
[Fact]
public void ToRoutingKey_Webhook_Succeeds()
{
Assert.Equal("webhook", IntegrationType.Webhook.ToRoutingKey());
}
[Fact]
public void ToRoutingKey_CloudBillingSync_ThrowsException()
{
@@ -27,4 +16,34 @@ public class IntegrationTypeTests
{
Assert.Throws<ArgumentOutOfRangeException>(() => IntegrationType.Scim.ToRoutingKey());
}
[Fact]
public void ToRoutingKey_Slack_Succeeds()
{
Assert.Equal("slack", IntegrationType.Slack.ToRoutingKey());
}
[Fact]
public void ToRoutingKey_Webhook_Succeeds()
{
Assert.Equal("webhook", IntegrationType.Webhook.ToRoutingKey());
}
[Fact]
public void ToRoutingKey_Hec_Succeeds()
{
Assert.Equal("hec", IntegrationType.Hec.ToRoutingKey());
}
[Fact]
public void ToRoutingKey_Datadog_Succeeds()
{
Assert.Equal("datadog", IntegrationType.Datadog.ToRoutingKey());
}
[Fact]
public void ToRoutingKey_Teams_Succeeds()
{
Assert.Equal("teams", IntegrationType.Teams.ToRoutingKey());
}
}

View File

@@ -296,6 +296,18 @@ public class SlackServiceTests
Assert.Equal("test-access-token", result);
}
[Theory]
[InlineData("test-code", "")]
[InlineData("", "https://example.com/callback")]
[InlineData("", "")]
public async Task ObtainTokenViaOAuth_ReturnsEmptyString_WhenCodeOrRedirectUrlIsEmpty(string code, string redirectUrl)
{
var sutProvider = GetSutProvider();
var result = await sutProvider.Sut.ObtainTokenViaOAuth(code, redirectUrl);
Assert.Equal(string.Empty, result);
}
[Fact]
public async Task ObtainTokenViaOAuth_ReturnsEmptyString_WhenErrorResponse()
{

View File

@@ -0,0 +1,126 @@
using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
using Bit.Core.Services;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using Bit.Test.Common.Helpers;
using Microsoft.Rest;
using NSubstitute;
using NSubstitute.ExceptionExtensions;
using Xunit;
namespace Bit.Core.Test.Services;
[SutProviderCustomize]
public class TeamsIntegrationHandlerTests
{
private readonly ITeamsService _teamsService = Substitute.For<ITeamsService>();
private readonly string _channelId = "C12345";
private readonly Uri _serviceUrl = new Uri("http://localhost");
private SutProvider<TeamsIntegrationHandler> GetSutProvider()
{
return new SutProvider<TeamsIntegrationHandler>()
.SetDependency(_teamsService)
.Create();
}
[Theory, BitAutoData]
public async Task HandleAsync_SuccessfulRequest_ReturnsSuccess(IntegrationMessage<TeamsIntegrationConfigurationDetails> message)
{
var sutProvider = GetSutProvider();
message.Configuration = new TeamsIntegrationConfigurationDetails(_channelId, _serviceUrl);
var result = await sutProvider.Sut.HandleAsync(message);
Assert.True(result.Success);
Assert.Equal(result.Message, message);
await sutProvider.GetDependency<ITeamsService>().Received(1).SendMessageToChannelAsync(
Arg.Is(AssertHelper.AssertPropertyEqual(_serviceUrl)),
Arg.Is(AssertHelper.AssertPropertyEqual(_channelId)),
Arg.Is(AssertHelper.AssertPropertyEqual(message.RenderedTemplate))
);
}
[Theory, BitAutoData]
public async Task HandleAsync_HttpExceptionNonRetryable_ReturnsFalseAndNotRetryable(IntegrationMessage<TeamsIntegrationConfigurationDetails> message)
{
var sutProvider = GetSutProvider();
message.Configuration = new TeamsIntegrationConfigurationDetails(_channelId, _serviceUrl);
sutProvider.GetDependency<ITeamsService>()
.SendMessageToChannelAsync(Arg.Any<Uri>(), Arg.Any<string>(), Arg.Any<string>())
.ThrowsAsync(new HttpOperationException("Server error")
{
Response = new HttpResponseMessageWrapper(
new HttpResponseMessage(System.Net.HttpStatusCode.Forbidden),
"Forbidden"
)
}
);
var result = await sutProvider.Sut.HandleAsync(message);
Assert.False(result.Success);
Assert.False(result.Retryable);
Assert.Equal(result.Message, message);
await sutProvider.GetDependency<ITeamsService>().Received(1).SendMessageToChannelAsync(
Arg.Is(AssertHelper.AssertPropertyEqual(_serviceUrl)),
Arg.Is(AssertHelper.AssertPropertyEqual(_channelId)),
Arg.Is(AssertHelper.AssertPropertyEqual(message.RenderedTemplate))
);
}
[Theory, BitAutoData]
public async Task HandleAsync_HttpExceptionRetryable_ReturnsFalseAndRetryable(IntegrationMessage<TeamsIntegrationConfigurationDetails> message)
{
var sutProvider = GetSutProvider();
message.Configuration = new TeamsIntegrationConfigurationDetails(_channelId, _serviceUrl);
sutProvider.GetDependency<ITeamsService>()
.SendMessageToChannelAsync(Arg.Any<Uri>(), Arg.Any<string>(), Arg.Any<string>())
.ThrowsAsync(new HttpOperationException("Server error")
{
Response = new HttpResponseMessageWrapper(
new HttpResponseMessage(System.Net.HttpStatusCode.TooManyRequests),
"Too Many Requests"
)
}
);
var result = await sutProvider.Sut.HandleAsync(message);
Assert.False(result.Success);
Assert.True(result.Retryable);
Assert.Equal(result.Message, message);
await sutProvider.GetDependency<ITeamsService>().Received(1).SendMessageToChannelAsync(
Arg.Is(AssertHelper.AssertPropertyEqual(_serviceUrl)),
Arg.Is(AssertHelper.AssertPropertyEqual(_channelId)),
Arg.Is(AssertHelper.AssertPropertyEqual(message.RenderedTemplate))
);
}
[Theory, BitAutoData]
public async Task HandleAsync_UnknownException_ReturnsFalseAndNotRetryable(IntegrationMessage<TeamsIntegrationConfigurationDetails> message)
{
var sutProvider = GetSutProvider();
message.Configuration = new TeamsIntegrationConfigurationDetails(_channelId, _serviceUrl);
sutProvider.GetDependency<ITeamsService>()
.SendMessageToChannelAsync(Arg.Any<Uri>(), Arg.Any<string>(), Arg.Any<string>())
.ThrowsAsync(new Exception("Unknown error"));
var result = await sutProvider.Sut.HandleAsync(message);
Assert.False(result.Success);
Assert.False(result.Retryable);
Assert.Equal(result.Message, message);
await sutProvider.GetDependency<ITeamsService>().Received(1).SendMessageToChannelAsync(
Arg.Is(AssertHelper.AssertPropertyEqual(_serviceUrl)),
Arg.Is(AssertHelper.AssertPropertyEqual(_channelId)),
Arg.Is(AssertHelper.AssertPropertyEqual(message.RenderedTemplate))
);
}
}

View File

@@ -0,0 +1,289 @@
#nullable enable
using System.Net;
using System.Text.Json;
using System.Web;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
using Bit.Core.Models.Teams;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using Bit.Test.Common.MockedHttpClient;
using NSubstitute;
using Xunit;
using GlobalSettings = Bit.Core.Settings.GlobalSettings;
namespace Bit.Core.Test.Services;
[SutProviderCustomize]
public class TeamsServiceTests
{
private readonly MockedHttpMessageHandler _handler;
private readonly HttpClient _httpClient;
public TeamsServiceTests()
{
_handler = new MockedHttpMessageHandler();
_httpClient = _handler.ToHttpClient();
}
private SutProvider<TeamsService> GetSutProvider()
{
var clientFactory = Substitute.For<IHttpClientFactory>();
clientFactory.CreateClient(TeamsService.HttpClientName).Returns(_httpClient);
var globalSettings = Substitute.For<GlobalSettings>();
globalSettings.Teams.LoginBaseUrl.Returns("https://login.example.com");
globalSettings.Teams.GraphBaseUrl.Returns("https://graph.example.com");
return new SutProvider<TeamsService>()
.SetDependency(clientFactory)
.SetDependency(globalSettings)
.Create();
}
[Fact]
public void GetRedirectUrl_ReturnsCorrectUrl()
{
var sutProvider = GetSutProvider();
var clientId = sutProvider.GetDependency<GlobalSettings>().Teams.ClientId;
var scopes = sutProvider.GetDependency<GlobalSettings>().Teams.Scopes;
var callbackUrl = "https://example.com/callback";
var state = Guid.NewGuid().ToString();
var result = sutProvider.Sut.GetRedirectUrl(callbackUrl, state);
var uri = new Uri(result);
var query = HttpUtility.ParseQueryString(uri.Query);
Assert.Equal(clientId, query["client_id"]);
Assert.Equal(scopes, query["scope"]);
Assert.Equal(callbackUrl, query["redirect_uri"]);
Assert.Equal(state, query["state"]);
Assert.Equal("login.example.com", uri.Host);
Assert.Equal("/common/oauth2/v2.0/authorize", uri.AbsolutePath);
}
[Fact]
public async Task ObtainTokenViaOAuth_Success_ReturnsAccessToken()
{
var sutProvider = GetSutProvider();
var jsonResponse = JsonSerializer.Serialize(new
{
access_token = "test-access-token"
});
_handler.When("https://login.example.com/common/oauth2/v2.0/token")
.RespondWith(HttpStatusCode.OK)
.WithContent(new StringContent(jsonResponse));
var result = await sutProvider.Sut.ObtainTokenViaOAuth("test-code", "https://example.com/callback");
Assert.Equal("test-access-token", result);
}
[Theory]
[InlineData("test-code", "")]
[InlineData("", "https://example.com/callback")]
[InlineData("", "")]
public async Task ObtainTokenViaOAuth_CodeOrRedirectUrlIsEmpty_ReturnsEmptyString(string code, string redirectUrl)
{
var sutProvider = GetSutProvider();
var result = await sutProvider.Sut.ObtainTokenViaOAuth(code, redirectUrl);
Assert.Equal(string.Empty, result);
}
[Fact]
public async Task ObtainTokenViaOAuth_HttpFailure_ReturnsEmptyString()
{
var sutProvider = GetSutProvider();
_handler.When("https://login.example.com/common/oauth2/v2.0/token")
.RespondWith(HttpStatusCode.InternalServerError)
.WithContent(new StringContent(string.Empty));
var result = await sutProvider.Sut.ObtainTokenViaOAuth("test-code", "https://example.com/callback");
Assert.Equal(string.Empty, result);
}
[Fact]
public async Task ObtainTokenViaOAuth_UnknownResponse_ReturnsEmptyString()
{
var sutProvider = GetSutProvider();
_handler.When("https://login.example.com/common/oauth2/v2.0/token")
.RespondWith(HttpStatusCode.OK)
.WithContent(new StringContent("Not an expected response"));
var result = await sutProvider.Sut.ObtainTokenViaOAuth("test-code", "https://example.com/callback");
Assert.Equal(string.Empty, result);
}
[Fact]
public async Task GetJoinedTeamsAsync_Success_ReturnsTeams()
{
var sutProvider = GetSutProvider();
var jsonResponse = JsonSerializer.Serialize(new
{
value = new[]
{
new { id = "team1", displayName = "Team One" },
new { id = "team2", displayName = "Team Two" }
}
});
_handler.When("https://graph.example.com/me/joinedTeams")
.RespondWith(HttpStatusCode.OK)
.WithContent(new StringContent(jsonResponse));
var result = await sutProvider.Sut.GetJoinedTeamsAsync("fake-access-token");
Assert.Equal(2, result.Count);
Assert.Contains(result, t => t is { Id: "team1", DisplayName: "Team One" });
Assert.Contains(result, t => t is { Id: "team2", DisplayName: "Team Two" });
}
[Fact]
public async Task GetJoinedTeamsAsync_ServerReturnsEmpty_ReturnsEmptyList()
{
var sutProvider = GetSutProvider();
var jsonResponse = JsonSerializer.Serialize(new { value = (object?)null });
_handler.When("https://graph.example.com/me/joinedTeams")
.RespondWith(HttpStatusCode.OK)
.WithContent(new StringContent(jsonResponse));
var result = await sutProvider.Sut.GetJoinedTeamsAsync("fake-access-token");
Assert.NotNull(result);
Assert.Empty(result);
}
[Fact]
public async Task GetJoinedTeamsAsync_ServerErrorCode_ReturnsEmptyList()
{
var sutProvider = GetSutProvider();
_handler.When("https://graph.example.com/me/joinedTeams")
.RespondWith(HttpStatusCode.Unauthorized)
.WithContent(new StringContent("Unauthorized"));
var result = await sutProvider.Sut.GetJoinedTeamsAsync("fake-access-token");
Assert.NotNull(result);
Assert.Empty(result);
}
[Theory, BitAutoData]
public async Task HandleIncomingAppInstall_Success_UpdatesTeamsIntegration(
OrganizationIntegration integration)
{
var sutProvider = GetSutProvider();
var tenantId = Guid.NewGuid().ToString();
var teamId = Guid.NewGuid().ToString();
var conversationId = Guid.NewGuid().ToString();
var serviceUrl = new Uri("https://localhost");
var initiatedConfiguration = new TeamsIntegration(TenantId: tenantId, Teams:
[
new TeamInfo() { Id = teamId, DisplayName = "test team", TenantId = tenantId },
new TeamInfo() { Id = Guid.NewGuid().ToString(), DisplayName = "other team", TenantId = tenantId },
new TeamInfo() { Id = Guid.NewGuid().ToString(), DisplayName = "third team", TenantId = tenantId }
]);
integration.Configuration = JsonSerializer.Serialize(initiatedConfiguration);
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.GetByTeamsConfigurationTenantIdTeamId(tenantId, teamId)
.Returns(integration);
OrganizationIntegration? capturedIntegration = null;
await sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.UpsertAsync(Arg.Do<OrganizationIntegration>(x => capturedIntegration = x));
await sutProvider.Sut.HandleIncomingAppInstallAsync(
conversationId: conversationId,
serviceUrl: serviceUrl,
teamId: teamId,
tenantId: tenantId
);
await sutProvider.GetDependency<IOrganizationIntegrationRepository>().Received(1).GetByTeamsConfigurationTenantIdTeamId(tenantId, teamId);
Assert.NotNull(capturedIntegration);
var configuration = JsonSerializer.Deserialize<TeamsIntegration>(capturedIntegration.Configuration ?? string.Empty);
Assert.NotNull(configuration);
Assert.NotNull(configuration.ServiceUrl);
Assert.Equal(serviceUrl, configuration.ServiceUrl);
Assert.Equal(conversationId, configuration.ChannelId);
}
[Fact]
public async Task HandleIncomingAppInstall_NoIntegrationMatched_DoesNothing()
{
var sutProvider = GetSutProvider();
await sutProvider.Sut.HandleIncomingAppInstallAsync(
conversationId: "conversationId",
serviceUrl: new Uri("https://localhost"),
teamId: "teamId",
tenantId: "tenantId"
);
await sutProvider.GetDependency<IOrganizationIntegrationRepository>().Received(1).GetByTeamsConfigurationTenantIdTeamId("tenantId", "teamId");
await sutProvider.GetDependency<IOrganizationIntegrationRepository>().DidNotReceive().UpsertAsync(Arg.Any<OrganizationIntegration>());
}
[Theory, BitAutoData]
public async Task HandleIncomingAppInstall_MatchedIntegrationAlreadySetup_DoesNothing(
OrganizationIntegration integration)
{
var sutProvider = GetSutProvider();
var tenantId = Guid.NewGuid().ToString();
var teamId = Guid.NewGuid().ToString();
var initiatedConfiguration = new TeamsIntegration(
TenantId: tenantId,
Teams: [new TeamInfo() { Id = teamId, DisplayName = "test team", TenantId = tenantId }],
ChannelId: "ChannelId",
ServiceUrl: new Uri("https://localhost")
);
integration.Configuration = JsonSerializer.Serialize(initiatedConfiguration);
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.GetByTeamsConfigurationTenantIdTeamId(tenantId, teamId)
.Returns(integration);
await sutProvider.Sut.HandleIncomingAppInstallAsync(
conversationId: "conversationId",
serviceUrl: new Uri("https://localhost"),
teamId: teamId,
tenantId: tenantId
);
await sutProvider.GetDependency<IOrganizationIntegrationRepository>().Received(1).GetByTeamsConfigurationTenantIdTeamId(tenantId, teamId);
await sutProvider.GetDependency<IOrganizationIntegrationRepository>().DidNotReceive().UpsertAsync(Arg.Any<OrganizationIntegration>());
}
[Theory, BitAutoData]
public async Task HandleIncomingAppInstall_MatchedIntegrationWithMissingConfiguration_DoesNothing(
OrganizationIntegration integration)
{
var sutProvider = GetSutProvider();
integration.Configuration = null;
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.GetByTeamsConfigurationTenantIdTeamId("tenantId", "teamId")
.Returns(integration);
await sutProvider.Sut.HandleIncomingAppInstallAsync(
conversationId: "conversationId",
serviceUrl: new Uri("https://localhost"),
teamId: "teamId",
tenantId: "tenantId"
);
await sutProvider.GetDependency<IOrganizationIntegrationRepository>().Received(1).GetByTeamsConfigurationTenantIdTeamId("tenantId", "teamId");
await sutProvider.GetDependency<IOrganizationIntegrationRepository>().DidNotReceive().UpsertAsync(Arg.Any<OrganizationIntegration>());
}
}

View File

@@ -474,7 +474,149 @@ public class PreviewOrganizationTaxCommandTests
options.CustomerDetails.TaxExempt == TaxExempt.None &&
options.SubscriptionDetails.Items.Count == 1 &&
options.SubscriptionDetails.Items[0].Price == "2020-families-org-annually" &&
options.SubscriptionDetails.Items[0].Quantity == 2 &&
options.SubscriptionDetails.Items[0].Quantity == 1 &&
options.Coupon == null));
}
[Fact]
public async Task Run_OrganizationPlanChange_FamiliesOrganizationToTeams_UsesOrganizationSeats()
{
var organization = new Organization
{
Id = Guid.NewGuid(),
PlanType = PlanType.FamiliesAnnually,
GatewayCustomerId = "cus_test123",
GatewaySubscriptionId = "sub_test123",
UseSecretsManager = false,
Seats = 6
};
var planChange = new OrganizationSubscriptionPlanChange
{
Tier = ProductTierType.Teams,
Cadence = PlanCadenceType.Annually
};
var billingAddress = new BillingAddress
{
Country = "US",
PostalCode = "10012"
};
var currentPlan = new FamiliesPlan();
var newPlan = new TeamsPlan(true);
_pricingClient.GetPlanOrThrow(organization.PlanType).Returns(currentPlan);
_pricingClient.GetPlanOrThrow(planChange.PlanType).Returns(newPlan);
var subscriptionItems = new List<SubscriptionItem>
{
new() { Price = new Price { Id = "2020-families-org-annually" }, Quantity = 1 }
};
var subscription = new Subscription
{
Id = "sub_test123",
Items = new StripeList<SubscriptionItem> { Data = subscriptionItems },
Customer = new Customer { Discount = null }
};
_stripeAdapter.SubscriptionGetAsync("sub_test123", Arg.Any<SubscriptionGetOptions>()).Returns(subscription);
var invoice = new Invoice
{
Tax = 900,
Total = 9900
};
_stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any<InvoiceCreatePreviewOptions>()).Returns(invoice);
var result = await _command.Run(organization, planChange, billingAddress);
Assert.True(result.IsT0);
var (tax, total) = result.AsT0;
Assert.Equal(9.00m, tax);
Assert.Equal(99.00m, total);
await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is<InvoiceCreatePreviewOptions>(options =>
options.AutomaticTax.Enabled == true &&
options.Currency == "usd" &&
options.CustomerDetails.Address.Country == "US" &&
options.CustomerDetails.Address.PostalCode == "10012" &&
options.CustomerDetails.TaxExempt == TaxExempt.None &&
options.SubscriptionDetails.Items.Count == 1 &&
options.SubscriptionDetails.Items[0].Price == "2023-teams-org-seat-annually" &&
options.SubscriptionDetails.Items[0].Quantity == 6 &&
options.Coupon == null));
}
[Fact]
public async Task Run_OrganizationPlanChange_FamiliesOrganizationToEnterprise_UsesOrganizationSeats()
{
var organization = new Organization
{
Id = Guid.NewGuid(),
PlanType = PlanType.FamiliesAnnually,
GatewayCustomerId = "cus_test123",
GatewaySubscriptionId = "sub_test123",
UseSecretsManager = false,
Seats = 6
};
var planChange = new OrganizationSubscriptionPlanChange
{
Tier = ProductTierType.Enterprise,
Cadence = PlanCadenceType.Annually
};
var billingAddress = new BillingAddress
{
Country = "US",
PostalCode = "10012"
};
var currentPlan = new FamiliesPlan();
var newPlan = new EnterprisePlan(true);
_pricingClient.GetPlanOrThrow(organization.PlanType).Returns(currentPlan);
_pricingClient.GetPlanOrThrow(planChange.PlanType).Returns(newPlan);
var subscriptionItems = new List<SubscriptionItem>
{
new() { Price = new Price { Id = "2020-families-org-annually" }, Quantity = 1 }
};
var subscription = new Subscription
{
Id = "sub_test123",
Items = new StripeList<SubscriptionItem> { Data = subscriptionItems },
Customer = new Customer { Discount = null }
};
_stripeAdapter.SubscriptionGetAsync("sub_test123", Arg.Any<SubscriptionGetOptions>()).Returns(subscription);
var invoice = new Invoice
{
Tax = 1200,
Total = 13200
};
_stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any<InvoiceCreatePreviewOptions>()).Returns(invoice);
var result = await _command.Run(organization, planChange, billingAddress);
Assert.True(result.IsT0);
var (tax, total) = result.AsT0;
Assert.Equal(12.00m, tax);
Assert.Equal(132.00m, total);
await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is<InvoiceCreatePreviewOptions>(options =>
options.AutomaticTax.Enabled == true &&
options.Currency == "usd" &&
options.CustomerDetails.Address.Country == "US" &&
options.CustomerDetails.Address.PostalCode == "10012" &&
options.CustomerDetails.TaxExempt == TaxExempt.None &&
options.SubscriptionDetails.Items.Count == 1 &&
options.SubscriptionDetails.Items[0].Price == "2023-enterprise-org-seat-annually" &&
options.SubscriptionDetails.Items[0].Quantity == 6 &&
options.Coupon == null));
}
@@ -956,10 +1098,7 @@ public class PreviewOrganizationTaxCommandTests
Discount = null,
TaxIds = new StripeList<TaxId>
{
Data = new List<TaxId>
{
new() { Type = "gb_vat", Value = "GB123456789" }
}
Data = [new TaxId { Type = "gb_vat", Value = "GB123456789" }]
}
};
@@ -1040,10 +1179,7 @@ public class PreviewOrganizationTaxCommandTests
},
TaxIds = new StripeList<TaxId>
{
Data = new List<TaxId>
{
new() { Type = TaxIdType.SpanishNIF, Value = "12345678Z" }
}
Data = [new TaxId { Type = TaxIdType.SpanishNIF, Value = "12345678Z" }]
}
};

View File

@@ -2,10 +2,10 @@
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Caches;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Organizations.Queries;
using Bit.Core.Billing.Payment.Queries;
using Bit.Core.Billing.Services;
using Bit.Core.Context;
using Bit.Core.Services;
@@ -75,7 +75,7 @@ public class GetOrganizationWarningsQueryTests
});
sutProvider.GetDependency<ICurrentContext>().EditSubscription(organization.Id).Returns(true);
sutProvider.GetDependency<ISetupIntentCache>().GetSetupIntentIdForSubscriber(organization.Id).Returns((string?)null);
sutProvider.GetDependency<IHasPaymentMethodQuery>().Run(organization).Returns(false);
var response = await sutProvider.Sut.Run(organization);
@@ -86,12 +86,11 @@ public class GetOrganizationWarningsQueryTests
}
[Theory, BitAutoData]
public async Task Run_Has_FreeTrialWarning_WithUnverifiedBankAccount_NoWarning(
public async Task Run_Has_FreeTrialWarning_WithPaymentMethod_NoWarning(
Organization organization,
SutProvider<GetOrganizationWarningsQuery> sutProvider)
{
var now = DateTime.UtcNow;
const string setupIntentId = "setup_intent_id";
sutProvider.GetDependency<ISubscriberService>()
.GetSubscription(organization, Arg.Is<SubscriptionGetOptions>(options =>
@@ -113,20 +112,7 @@ public class GetOrganizationWarningsQueryTests
});
sutProvider.GetDependency<ICurrentContext>().EditSubscription(organization.Id).Returns(true);
sutProvider.GetDependency<ISetupIntentCache>().GetSetupIntentIdForSubscriber(organization.Id).Returns(setupIntentId);
sutProvider.GetDependency<IStripeAdapter>().SetupIntentGet(setupIntentId, Arg.Is<SetupIntentGetOptions>(
options => options.Expand.Contains("payment_method"))).Returns(new SetupIntent
{
Status = "requires_action",
NextAction = new SetupIntentNextAction
{
VerifyWithMicrodeposits = new SetupIntentNextActionVerifyWithMicrodeposits()
},
PaymentMethod = new PaymentMethod
{
UsBankAccount = new PaymentMethodUsBankAccount()
}
});
sutProvider.GetDependency<IHasPaymentMethodQuery>().Run(organization).Returns(true);
var response = await sutProvider.Sut.Run(organization);

View File

@@ -0,0 +1,264 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Billing.Caches;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Payment.Queries;
using Bit.Core.Billing.Services;
using Bit.Core.Services;
using Bit.Core.Test.Billing.Extensions;
using NSubstitute;
using NSubstitute.ReturnsExtensions;
using Stripe;
using Xunit;
namespace Bit.Core.Test.Billing.Payment.Queries;
using static StripeConstants;
public class HasPaymentMethodQueryTests
{
private readonly ISetupIntentCache _setupIntentCache = Substitute.For<ISetupIntentCache>();
private readonly IStripeAdapter _stripeAdapter = Substitute.For<IStripeAdapter>();
private readonly ISubscriberService _subscriberService = Substitute.For<ISubscriberService>();
private readonly HasPaymentMethodQuery _query;
public HasPaymentMethodQueryTests()
{
_query = new HasPaymentMethodQuery(
_setupIntentCache,
_stripeAdapter,
_subscriberService);
}
[Fact]
public async Task Run_NoCustomer_ReturnsFalse()
{
var organization = new Organization
{
Id = Guid.NewGuid()
};
_subscriberService.GetCustomer(organization).ReturnsNull();
_setupIntentCache.GetSetupIntentIdForSubscriber(organization.Id).Returns((string)null);
var hasPaymentMethod = await _query.Run(organization);
Assert.False(hasPaymentMethod);
}
[Fact]
public async Task Run_NoCustomer_WithUnverifiedBankAccount_ReturnsTrue()
{
var organization = new Organization
{
Id = Guid.NewGuid()
};
_subscriberService.GetCustomer(organization).ReturnsNull();
_setupIntentCache.GetSetupIntentIdForSubscriber(organization.Id).Returns("seti_123");
_stripeAdapter
.SetupIntentGet("seti_123",
Arg.Is<SetupIntentGetOptions>(options => options.HasExpansions("payment_method")))
.Returns(new SetupIntent
{
Status = "requires_action",
NextAction = new SetupIntentNextAction
{
VerifyWithMicrodeposits = new SetupIntentNextActionVerifyWithMicrodeposits()
},
PaymentMethod = new PaymentMethod
{
UsBankAccount = new PaymentMethodUsBankAccount()
}
});
var hasPaymentMethod = await _query.Run(organization);
Assert.True(hasPaymentMethod);
}
[Fact]
public async Task Run_NoPaymentMethod_ReturnsFalse()
{
var organization = new Organization
{
Id = Guid.NewGuid()
};
var customer = new Customer
{
InvoiceSettings = new CustomerInvoiceSettings(),
Metadata = new Dictionary<string, string>()
};
_subscriberService.GetCustomer(organization).Returns(customer);
var hasPaymentMethod = await _query.Run(organization);
Assert.False(hasPaymentMethod);
}
[Fact]
public async Task Run_HasDefaultPaymentMethodId_ReturnsTrue()
{
var organization = new Organization
{
Id = Guid.NewGuid()
};
var customer = new Customer
{
InvoiceSettings = new CustomerInvoiceSettings
{
DefaultPaymentMethodId = "pm_123"
},
Metadata = new Dictionary<string, string>()
};
_subscriberService.GetCustomer(organization).Returns(customer);
var hasPaymentMethod = await _query.Run(organization);
Assert.True(hasPaymentMethod);
}
[Fact]
public async Task Run_HasDefaultSourceId_ReturnsTrue()
{
var organization = new Organization
{
Id = Guid.NewGuid()
};
var customer = new Customer
{
DefaultSourceId = "card_123",
InvoiceSettings = new CustomerInvoiceSettings(),
Metadata = new Dictionary<string, string>()
};
_subscriberService.GetCustomer(organization).Returns(customer);
var hasPaymentMethod = await _query.Run(organization);
Assert.True(hasPaymentMethod);
}
[Fact]
public async Task Run_HasUnverifiedBankAccount_ReturnsTrue()
{
var organization = new Organization
{
Id = Guid.NewGuid()
};
var customer = new Customer
{
InvoiceSettings = new CustomerInvoiceSettings(),
Metadata = new Dictionary<string, string>()
};
_subscriberService.GetCustomer(organization).Returns(customer);
_setupIntentCache.GetSetupIntentIdForSubscriber(organization.Id).Returns("seti_123");
_stripeAdapter
.SetupIntentGet("seti_123",
Arg.Is<SetupIntentGetOptions>(options => options.HasExpansions("payment_method")))
.Returns(new SetupIntent
{
Status = "requires_action",
NextAction = new SetupIntentNextAction
{
VerifyWithMicrodeposits = new SetupIntentNextActionVerifyWithMicrodeposits()
},
PaymentMethod = new PaymentMethod
{
UsBankAccount = new PaymentMethodUsBankAccount()
}
});
var hasPaymentMethod = await _query.Run(organization);
Assert.True(hasPaymentMethod);
}
[Fact]
public async Task Run_HasBraintreeCustomerId_ReturnsTrue()
{
var organization = new Organization
{
Id = Guid.NewGuid()
};
var customer = new Customer
{
InvoiceSettings = new CustomerInvoiceSettings(),
Metadata = new Dictionary<string, string>
{
[MetadataKeys.BraintreeCustomerId] = "braintree_customer_id"
}
};
_subscriberService.GetCustomer(organization).Returns(customer);
var hasPaymentMethod = await _query.Run(organization);
Assert.True(hasPaymentMethod);
}
[Fact]
public async Task Run_NoSetupIntentId_ReturnsFalse()
{
var organization = new Organization
{
Id = Guid.NewGuid()
};
var customer = new Customer
{
InvoiceSettings = new CustomerInvoiceSettings(),
Metadata = new Dictionary<string, string>()
};
_subscriberService.GetCustomer(organization).Returns(customer);
_setupIntentCache.GetSetupIntentIdForSubscriber(organization.Id).Returns((string)null);
var hasPaymentMethod = await _query.Run(organization);
Assert.False(hasPaymentMethod);
}
[Fact]
public async Task Run_SetupIntentNotBankAccount_ReturnsFalse()
{
var organization = new Organization
{
Id = Guid.NewGuid()
};
var customer = new Customer
{
InvoiceSettings = new CustomerInvoiceSettings(),
Metadata = new Dictionary<string, string>()
};
_subscriberService.GetCustomer(organization).Returns(customer);
_setupIntentCache.GetSetupIntentIdForSubscriber(organization.Id).Returns("seti_123");
_stripeAdapter
.SetupIntentGet("seti_123",
Arg.Is<SetupIntentGetOptions>(options => options.HasExpansions("payment_method")))
.Returns(new SetupIntent
{
PaymentMethod = new PaymentMethod
{
Type = "card"
},
Status = "succeeded"
});
var hasPaymentMethod = await _query.Run(organization);
Assert.False(hasPaymentMethod);
}
}

View File

@@ -0,0 +1,18 @@
using Bit.Core.Utilities;
using Xunit;
namespace Bit.Core.Test.Utilities;
public class AssemblyHelpersTests
{
[Fact]
public void ReturnsValidVersionAndGitHash()
{
var version = AssemblyHelpers.GetVersion();
_ = Version.Parse(version);
var gitHash = AssemblyHelpers.GetGitHash();
Assert.NotNull(gitHash);
Assert.Equal(8, gitHash.Length);
}
}

View File

@@ -26,7 +26,6 @@ public class Program
// Create service provider with necessary services
var services = new ServiceCollection();
ServiceCollectionExtension.ConfigureServices(services);
var serviceProvider = services.BuildServiceProvider();
// Get a scoped DB context

View File

@@ -0,0 +1,18 @@
CREATE OR ALTER PROCEDURE [dbo].[OrganizationIntegration_ReadByTeamsConfigurationTenantIdTeamId]
@TenantId NVARCHAR(200),
@TeamId NVARCHAR(200)
AS
BEGIN
SET NOCOUNT ON;
SELECT TOP 1 *
FROM [dbo].[OrganizationIntegrationView]
CROSS APPLY OPENJSON([Configuration], '$.Teams')
WITH ( TeamId NVARCHAR(MAX) '$.id' ) t
WHERE [Type] = 7
AND JSON_VALUE([Configuration], '$.TenantId') = @TenantId
AND t.TeamId = @TeamId
AND JSON_VALUE([Configuration], '$.ChannelId') IS NULL
AND JSON_VALUE([Configuration], '$.ServiceUrl') IS NULL;
END
GO