mirror of
https://github.com/bitwarden/server
synced 2025-12-26 21:23:39 +00:00
Merge remote-tracking branch 'origin/main' into dbops/dbops-31/csv-import
This commit is contained in:
@@ -35,8 +35,9 @@ public class ProviderService : IProviderService
|
||||
{
|
||||
private static readonly PlanType[] _resellerDisallowedOrganizationTypes = [
|
||||
PlanType.Free,
|
||||
PlanType.FamiliesAnnually,
|
||||
PlanType.FamiliesAnnually2019
|
||||
PlanType.FamiliesAnnually2025,
|
||||
PlanType.FamiliesAnnually2019,
|
||||
PlanType.FamiliesAnnually
|
||||
];
|
||||
|
||||
private readonly IDataProtector _dataProtector;
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
using Bit.Api.AdminConsole.Models.Request.Organizations;
|
||||
using Bit.Api.AdminConsole.Models.Response.Organizations;
|
||||
using Bit.Core;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Utilities;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Bit.Api.AdminConsole.Controllers;
|
||||
|
||||
[RequireFeature(FeatureFlagKeys.EventBasedOrganizationIntegrations)]
|
||||
[Route("organizations/{organizationId:guid}/integrations/{integrationId:guid}/configurations")]
|
||||
[Authorize("Application")]
|
||||
public class OrganizationIntegrationConfigurationController(
|
||||
|
||||
@@ -1,18 +1,13 @@
|
||||
using Bit.Api.AdminConsole.Models.Request.Organizations;
|
||||
using Bit.Api.AdminConsole.Models.Response.Organizations;
|
||||
using Bit.Core;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Utilities;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
#nullable enable
|
||||
|
||||
namespace Bit.Api.AdminConsole.Controllers;
|
||||
|
||||
[RequireFeature(FeatureFlagKeys.EventBasedOrganizationIntegrations)]
|
||||
[Route("organizations/{organizationId:guid}/integrations")]
|
||||
[Authorize("Application")]
|
||||
public class OrganizationIntegrationController(
|
||||
|
||||
@@ -209,23 +209,17 @@ public class PoliciesController : Controller
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
if (type != model.Type)
|
||||
{
|
||||
throw new BadRequestException("Mismatched policy type");
|
||||
}
|
||||
|
||||
var policyUpdate = await model.ToPolicyUpdateAsync(orgId, _currentContext);
|
||||
var policyUpdate = await model.ToPolicyUpdateAsync(orgId, type, _currentContext);
|
||||
var policy = await _savePolicyCommand.SaveAsync(policyUpdate);
|
||||
return new PolicyResponseModel(policy);
|
||||
}
|
||||
|
||||
|
||||
[HttpPut("{type}/vnext")]
|
||||
[RequireFeatureAttribute(FeatureFlagKeys.CreateDefaultLocation)]
|
||||
[Authorize<ManagePoliciesRequirement>]
|
||||
public async Task<PolicyResponseModel> PutVNext(Guid orgId, [FromBody] SavePolicyRequest model)
|
||||
public async Task<PolicyResponseModel> PutVNext(Guid orgId, PolicyType type, [FromBody] SavePolicyRequest model)
|
||||
{
|
||||
var savePolicyRequest = await model.ToSavePolicyModelAsync(orgId, _currentContext);
|
||||
var savePolicyRequest = await model.ToSavePolicyModelAsync(orgId, type, _currentContext);
|
||||
|
||||
var policy = _featureService.IsEnabled(FeatureFlagKeys.PolicyValidatorsRefactor) ?
|
||||
await _vNextSavePolicyCommand.SaveAsync(savePolicyRequest) :
|
||||
@@ -233,5 +227,4 @@ public class PoliciesController : Controller
|
||||
|
||||
return new PolicyResponseModel(policy);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
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;
|
||||
@@ -8,13 +7,11 @@ 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;
|
||||
|
||||
namespace Bit.Api.AdminConsole.Controllers;
|
||||
|
||||
[RequireFeature(FeatureFlagKeys.EventBasedOrganizationIntegrations)]
|
||||
[Route("organizations")]
|
||||
[Authorize("Application")]
|
||||
public class SlackIntegrationController(
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
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;
|
||||
@@ -8,7 +7,6 @@ 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;
|
||||
@@ -16,7 +14,6 @@ using Microsoft.Bot.Builder.Integration.AspNet.Core;
|
||||
|
||||
namespace Bit.Api.AdminConsole.Controllers;
|
||||
|
||||
[RequireFeature(FeatureFlagKeys.EventBasedOrganizationIntegrations)]
|
||||
[Route("organizations")]
|
||||
[Authorize("Application")]
|
||||
public class TeamsIntegrationController(
|
||||
|
||||
@@ -9,20 +9,18 @@ namespace Bit.Api.AdminConsole.Models.Request;
|
||||
|
||||
public class PolicyRequestModel
|
||||
{
|
||||
[Required]
|
||||
public PolicyType? Type { get; set; }
|
||||
[Required]
|
||||
public bool? Enabled { get; set; }
|
||||
public Dictionary<string, object>? Data { get; set; }
|
||||
|
||||
public async Task<PolicyUpdate> ToPolicyUpdateAsync(Guid organizationId, ICurrentContext currentContext)
|
||||
public async Task<PolicyUpdate> ToPolicyUpdateAsync(Guid organizationId, PolicyType type, ICurrentContext currentContext)
|
||||
{
|
||||
var serializedData = PolicyDataValidator.ValidateAndSerialize(Data, Type!.Value);
|
||||
var serializedData = PolicyDataValidator.ValidateAndSerialize(Data, type);
|
||||
var performedBy = new StandardUser(currentContext.UserId!.Value, await currentContext.OrganizationOwner(organizationId));
|
||||
|
||||
return new()
|
||||
{
|
||||
Type = Type!.Value,
|
||||
Type = type,
|
||||
OrganizationId = organizationId,
|
||||
Data = serializedData,
|
||||
Enabled = Enabled.GetValueOrDefault(),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Bit.Core.AdminConsole.Enums;
|
||||
using Bit.Core.AdminConsole.Models.Data;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models;
|
||||
using Bit.Core.AdminConsole.Utilities;
|
||||
@@ -13,10 +14,10 @@ public class SavePolicyRequest
|
||||
|
||||
public Dictionary<string, object>? Metadata { get; set; }
|
||||
|
||||
public async Task<SavePolicyModel> ToSavePolicyModelAsync(Guid organizationId, ICurrentContext currentContext)
|
||||
public async Task<SavePolicyModel> ToSavePolicyModelAsync(Guid organizationId, PolicyType type, ICurrentContext currentContext)
|
||||
{
|
||||
var policyUpdate = await Policy.ToPolicyUpdateAsync(organizationId, currentContext);
|
||||
var metadata = PolicyDataValidator.ValidateAndDeserializeMetadata(Metadata, Policy.Type!.Value);
|
||||
var policyUpdate = await Policy.ToPolicyUpdateAsync(organizationId, type, currentContext);
|
||||
var metadata = PolicyDataValidator.ValidateAndDeserializeMetadata(Metadata, type);
|
||||
var performedBy = new StandardUser(currentContext.UserId!.Value, await currentContext.OrganizationOwner(organizationId));
|
||||
|
||||
return new SavePolicyModel(policyUpdate, performedBy, metadata);
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Models.Api;
|
||||
|
||||
#nullable enable
|
||||
|
||||
namespace Bit.Api.AdminConsole.Models.Response.Organizations;
|
||||
|
||||
public class OrganizationIntegrationConfigurationResponseModel : ResponseModel
|
||||
@@ -11,8 +9,6 @@ public class OrganizationIntegrationConfigurationResponseModel : ResponseModel
|
||||
public OrganizationIntegrationConfigurationResponseModel(OrganizationIntegrationConfiguration organizationIntegrationConfiguration, string obj = "organizationIntegrationConfiguration")
|
||||
: base(obj)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(organizationIntegrationConfiguration);
|
||||
|
||||
Id = organizationIntegrationConfiguration.Id;
|
||||
Configuration = organizationIntegrationConfiguration.Configuration;
|
||||
CreationDate = organizationIntegrationConfiguration.CreationDate;
|
||||
|
||||
@@ -158,6 +158,7 @@ public class FreshsalesController : Controller
|
||||
planName = "Free";
|
||||
return true;
|
||||
case PlanType.FamiliesAnnually:
|
||||
case PlanType.FamiliesAnnually2025:
|
||||
case PlanType.FamiliesAnnually2019:
|
||||
planName = "Families";
|
||||
return true;
|
||||
|
||||
@@ -177,7 +177,8 @@ public class UpcomingInvoiceHandler(
|
||||
Discounts =
|
||||
[
|
||||
new SubscriptionDiscountOptions { Coupon = CouponIDs.Milestone2SubscriptionDiscount }
|
||||
]
|
||||
],
|
||||
ProrationBehavior = "none"
|
||||
});
|
||||
}
|
||||
catch (Exception exception)
|
||||
|
||||
@@ -33,6 +33,12 @@ public class SlackOAuthResponse : SlackApiResponse
|
||||
public SlackTeam Team { get; set; } = new();
|
||||
}
|
||||
|
||||
public class SlackSendMessageResponse : SlackApiResponse
|
||||
{
|
||||
[JsonPropertyName("channel")]
|
||||
public string Channel { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public class SlackTeam
|
||||
{
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
namespace Bit.Core.Services;
|
||||
using Bit.Core.Models.Slack;
|
||||
|
||||
namespace Bit.Core.Services;
|
||||
|
||||
/// <summary>Defines operations for interacting with Slack, including OAuth authentication, channel discovery,
|
||||
/// and sending messages.</summary>
|
||||
@@ -54,6 +56,6 @@ public interface ISlackService
|
||||
/// <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);
|
||||
/// <returns>The response from Slack after sending the message.</returns>
|
||||
Task<SlackSendMessageResponse?> SendSlackMessageByChannelIdAsync(string token, string message, string channelId);
|
||||
}
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
using Bit.Core.Models.Data;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Bit.Core.Services;
|
||||
|
||||
public class EventRouteService(
|
||||
[FromKeyedServices("broadcast")] IEventWriteService broadcastEventWriteService,
|
||||
[FromKeyedServices("storage")] IEventWriteService storageEventWriteService,
|
||||
IFeatureService _featureService) : IEventWriteService
|
||||
{
|
||||
public async Task CreateAsync(IEvent e)
|
||||
{
|
||||
if (_featureService.IsEnabled(FeatureFlagKeys.EventBasedOrganizationIntegrations))
|
||||
{
|
||||
await broadcastEventWriteService.CreateAsync(e);
|
||||
}
|
||||
else
|
||||
{
|
||||
await storageEventWriteService.CreateAsync(e);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task CreateManyAsync(IEnumerable<IEvent> e)
|
||||
{
|
||||
if (_featureService.IsEnabled(FeatureFlagKeys.EventBasedOrganizationIntegrations))
|
||||
{
|
||||
await broadcastEventWriteService.CreateManyAsync(e);
|
||||
}
|
||||
else
|
||||
{
|
||||
await storageEventWriteService.CreateManyAsync(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,14 +6,43 @@ public class SlackIntegrationHandler(
|
||||
ISlackService slackService)
|
||||
: IntegrationHandlerBase<SlackIntegrationConfigurationDetails>
|
||||
{
|
||||
private static readonly HashSet<string> _retryableErrors = new(StringComparer.Ordinal)
|
||||
{
|
||||
"internal_error",
|
||||
"message_limit_exceeded",
|
||||
"rate_limited",
|
||||
"ratelimited",
|
||||
"service_unavailable"
|
||||
};
|
||||
|
||||
public override async Task<IntegrationHandlerResult> HandleAsync(IntegrationMessage<SlackIntegrationConfigurationDetails> message)
|
||||
{
|
||||
await slackService.SendSlackMessageByChannelIdAsync(
|
||||
var slackResponse = await slackService.SendSlackMessageByChannelIdAsync(
|
||||
message.Configuration.Token,
|
||||
message.RenderedTemplate,
|
||||
message.Configuration.ChannelId
|
||||
);
|
||||
|
||||
return new IntegrationHandlerResult(success: true, message: message);
|
||||
if (slackResponse is null)
|
||||
{
|
||||
return new IntegrationHandlerResult(success: false, message: message)
|
||||
{
|
||||
FailureReason = "Slack response was null"
|
||||
};
|
||||
}
|
||||
|
||||
if (slackResponse.Ok)
|
||||
{
|
||||
return new IntegrationHandlerResult(success: true, message: message);
|
||||
}
|
||||
|
||||
var result = new IntegrationHandlerResult(success: false, message: message) { FailureReason = slackResponse.Error };
|
||||
|
||||
if (_retryableErrors.Contains(slackResponse.Error))
|
||||
{
|
||||
result.Retryable = true;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using System.Web;
|
||||
using Bit.Core.Models.Slack;
|
||||
using Bit.Core.Settings;
|
||||
@@ -71,7 +72,7 @@ public class SlackService(
|
||||
public async Task<string> GetDmChannelByEmailAsync(string token, string email)
|
||||
{
|
||||
var userId = await GetUserIdByEmailAsync(token, email);
|
||||
return await OpenDmChannel(token, userId);
|
||||
return await OpenDmChannelAsync(token, userId);
|
||||
}
|
||||
|
||||
public string GetRedirectUrl(string callbackUrl, string state)
|
||||
@@ -97,21 +98,21 @@ public class SlackService(
|
||||
}
|
||||
|
||||
var tokenResponse = await _httpClient.PostAsync($"{_slackApiBaseUrl}/oauth.v2.access",
|
||||
new FormUrlEncodedContent(new[]
|
||||
{
|
||||
new FormUrlEncodedContent([
|
||||
new KeyValuePair<string, string>("client_id", _clientId),
|
||||
new KeyValuePair<string, string>("client_secret", _clientSecret),
|
||||
new KeyValuePair<string, string>("code", code),
|
||||
new KeyValuePair<string, string>("redirect_uri", redirectUrl)
|
||||
}));
|
||||
]));
|
||||
|
||||
SlackOAuthResponse? result;
|
||||
try
|
||||
{
|
||||
result = await tokenResponse.Content.ReadFromJsonAsync<SlackOAuthResponse>();
|
||||
}
|
||||
catch
|
||||
catch (JsonException ex)
|
||||
{
|
||||
logger.LogError(ex, "Error parsing SlackOAuthResponse: invalid JSON");
|
||||
result = null;
|
||||
}
|
||||
|
||||
@@ -129,14 +130,25 @@ public class SlackService(
|
||||
return result.AccessToken;
|
||||
}
|
||||
|
||||
public async Task SendSlackMessageByChannelIdAsync(string token, string message, string channelId)
|
||||
public async Task<SlackSendMessageResponse?> SendSlackMessageByChannelIdAsync(string token, string message,
|
||||
string channelId)
|
||||
{
|
||||
var payload = JsonContent.Create(new { channel = channelId, text = message });
|
||||
var request = new HttpRequestMessage(HttpMethod.Post, $"{_slackApiBaseUrl}/chat.postMessage");
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
request.Content = payload;
|
||||
|
||||
await _httpClient.SendAsync(request);
|
||||
var response = await _httpClient.SendAsync(request);
|
||||
|
||||
try
|
||||
{
|
||||
return await response.Content.ReadFromJsonAsync<SlackSendMessageResponse>();
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
logger.LogError(ex, "Error parsing Slack message response: invalid JSON");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<string> GetUserIdByEmailAsync(string token, string email)
|
||||
@@ -144,7 +156,16 @@ public class SlackService(
|
||||
var request = new HttpRequestMessage(HttpMethod.Get, $"{_slackApiBaseUrl}/users.lookupByEmail?email={email}");
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
var response = await _httpClient.SendAsync(request);
|
||||
var result = await response.Content.ReadFromJsonAsync<SlackUserResponse>();
|
||||
SlackUserResponse? result;
|
||||
try
|
||||
{
|
||||
result = await response.Content.ReadFromJsonAsync<SlackUserResponse>();
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
logger.LogError(ex, "Error parsing SlackUserResponse: invalid JSON");
|
||||
result = null;
|
||||
}
|
||||
|
||||
if (result is null)
|
||||
{
|
||||
@@ -160,7 +181,7 @@ public class SlackService(
|
||||
return result.User.Id;
|
||||
}
|
||||
|
||||
private async Task<string> OpenDmChannel(string token, string userId)
|
||||
private async Task<string> OpenDmChannelAsync(string token, string userId)
|
||||
{
|
||||
if (string.IsNullOrEmpty(userId))
|
||||
return string.Empty;
|
||||
@@ -170,7 +191,16 @@ public class SlackService(
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
request.Content = payload;
|
||||
var response = await _httpClient.SendAsync(request);
|
||||
var result = await response.Content.ReadFromJsonAsync<SlackDmResponse>();
|
||||
SlackDmResponse? result;
|
||||
try
|
||||
{
|
||||
result = await response.Content.ReadFromJsonAsync<SlackDmResponse>();
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
logger.LogError(ex, "Error parsing SlackDmResponse: invalid JSON");
|
||||
result = null;
|
||||
}
|
||||
|
||||
if (result is null)
|
||||
{
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Models.Slack;
|
||||
using Bit.Core.Services;
|
||||
|
||||
namespace Bit.Core.AdminConsole.Services.NoopImplementations;
|
||||
|
||||
@@ -24,9 +25,10 @@ public class NoopSlackService : ISlackService
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
public Task SendSlackMessageByChannelIdAsync(string token, string message, string channelId)
|
||||
public Task<SlackSendMessageResponse?> SendSlackMessageByChannelIdAsync(string token, string message,
|
||||
string channelId)
|
||||
{
|
||||
return Task.FromResult(0);
|
||||
return Task.FromResult<SlackSendMessageResponse?>(null);
|
||||
}
|
||||
|
||||
public Task<string> ObtainTokenViaOAuth(string code, string redirectUrl)
|
||||
|
||||
@@ -18,8 +18,8 @@ public enum PlanType : byte
|
||||
EnterpriseAnnually2019 = 5,
|
||||
[Display(Name = "Custom")]
|
||||
Custom = 6,
|
||||
[Display(Name = "Families")]
|
||||
FamiliesAnnually = 7,
|
||||
[Display(Name = "Families 2025")]
|
||||
FamiliesAnnually2025 = 7,
|
||||
[Display(Name = "Teams (Monthly) 2020")]
|
||||
TeamsMonthly2020 = 8,
|
||||
[Display(Name = "Teams (Annually) 2020")]
|
||||
@@ -48,4 +48,6 @@ public enum PlanType : byte
|
||||
EnterpriseAnnually = 20,
|
||||
[Display(Name = "Teams Starter")]
|
||||
TeamsStarter = 21,
|
||||
[Display(Name = "Families")]
|
||||
FamiliesAnnually = 22,
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ public static class BillingExtensions
|
||||
=> planType switch
|
||||
{
|
||||
PlanType.Custom or PlanType.Free => ProductTierType.Free,
|
||||
PlanType.FamiliesAnnually or PlanType.FamiliesAnnually2019 => ProductTierType.Families,
|
||||
PlanType.FamiliesAnnually or PlanType.FamiliesAnnually2025 or PlanType.FamiliesAnnually2019 => ProductTierType.Families,
|
||||
PlanType.TeamsStarter or PlanType.TeamsStarter2023 => ProductTierType.TeamsStarter,
|
||||
_ when planType.ToString().Contains("Teams") => ProductTierType.Teams,
|
||||
_ when planType.ToString().Contains("Enterprise") => ProductTierType.Enterprise,
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Models.StaticStore;
|
||||
|
||||
namespace Bit.Core.Billing.Models.StaticStore.Plans;
|
||||
|
||||
public record Families2025Plan : Plan
|
||||
{
|
||||
public Families2025Plan()
|
||||
{
|
||||
Type = PlanType.FamiliesAnnually2025;
|
||||
ProductTier = ProductTierType.Families;
|
||||
Name = "Families 2025";
|
||||
IsAnnual = true;
|
||||
NameLocalizationKey = "planNameFamilies";
|
||||
DescriptionLocalizationKey = "planDescFamilies";
|
||||
|
||||
TrialPeriodDays = 7;
|
||||
|
||||
HasSelfHost = true;
|
||||
HasTotp = true;
|
||||
UsersGetPremium = true;
|
||||
|
||||
UpgradeSortOrder = 1;
|
||||
DisplaySortOrder = 1;
|
||||
|
||||
PasswordManager = new Families2025PasswordManagerFeatures();
|
||||
}
|
||||
|
||||
private record Families2025PasswordManagerFeatures : PasswordManagerPlanFeatures
|
||||
{
|
||||
public Families2025PasswordManagerFeatures()
|
||||
{
|
||||
BaseSeats = 6;
|
||||
BaseStorageGb = 1;
|
||||
MaxSeats = 6;
|
||||
|
||||
HasAdditionalStorageOption = true;
|
||||
|
||||
StripePlanId = "2020-families-org-annually";
|
||||
StripeStoragePlanId = "personal-storage-gb-annually";
|
||||
BasePrice = 40;
|
||||
AdditionalStoragePricePerGb = 4;
|
||||
|
||||
AllowSeatAutoscale = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -23,12 +23,12 @@ public record FamiliesPlan : Plan
|
||||
UpgradeSortOrder = 1;
|
||||
DisplaySortOrder = 1;
|
||||
|
||||
PasswordManager = new TeamsPasswordManagerFeatures();
|
||||
PasswordManager = new FamiliesPasswordManagerFeatures();
|
||||
}
|
||||
|
||||
private record TeamsPasswordManagerFeatures : PasswordManagerPlanFeatures
|
||||
private record FamiliesPasswordManagerFeatures : PasswordManagerPlanFeatures
|
||||
{
|
||||
public TeamsPasswordManagerFeatures()
|
||||
public FamiliesPasswordManagerFeatures()
|
||||
{
|
||||
BaseSeats = 6;
|
||||
BaseStorageGb = 1;
|
||||
|
||||
@@ -58,6 +58,7 @@ public record PlanAdapter : Core.Models.StaticStore.Plan
|
||||
"enterprise-monthly-2020" => PlanType.EnterpriseMonthly2020,
|
||||
"enterprise-monthly-2023" => PlanType.EnterpriseMonthly2023,
|
||||
"families" => PlanType.FamiliesAnnually,
|
||||
"families-2025" => PlanType.FamiliesAnnually2025,
|
||||
"families-2019" => PlanType.FamiliesAnnually2019,
|
||||
"free" => PlanType.Free,
|
||||
"teams-annually" => PlanType.TeamsAnnually,
|
||||
@@ -77,7 +78,7 @@ public record PlanAdapter : Core.Models.StaticStore.Plan
|
||||
=> planType switch
|
||||
{
|
||||
PlanType.Free => ProductTierType.Free,
|
||||
PlanType.FamiliesAnnually or PlanType.FamiliesAnnually2019 => ProductTierType.Families,
|
||||
PlanType.FamiliesAnnually or PlanType.FamiliesAnnually2025 or PlanType.FamiliesAnnually2019 => ProductTierType.Families,
|
||||
PlanType.TeamsStarter or PlanType.TeamsStarter2023 => ProductTierType.TeamsStarter,
|
||||
_ when planType.ToString().Contains("Teams") => ProductTierType.Teams,
|
||||
_ when planType.ToString().Contains("Enterprise") => ProductTierType.Enterprise,
|
||||
|
||||
@@ -50,7 +50,7 @@ public class PricingClient(
|
||||
var plan = await response.Content.ReadFromJsonAsync<Plan>();
|
||||
return plan == null
|
||||
? throw new BillingException(message: "Deserialization of Pricing Service response resulted in null")
|
||||
: new PlanAdapter(plan);
|
||||
: new PlanAdapter(PreProcessFamiliesPreMigrationPlan(plan));
|
||||
}
|
||||
|
||||
if (response.StatusCode == HttpStatusCode.NotFound)
|
||||
@@ -91,7 +91,7 @@ public class PricingClient(
|
||||
var plans = await response.Content.ReadFromJsonAsync<List<Plan>>();
|
||||
return plans == null
|
||||
? throw new BillingException(message: "Deserialization of Pricing Service response resulted in null")
|
||||
: plans.Select(OrganizationPlan (plan) => new PlanAdapter(plan)).ToList();
|
||||
: plans.Select(OrganizationPlan (plan) => new PlanAdapter(PreProcessFamiliesPreMigrationPlan(plan))).ToList();
|
||||
}
|
||||
|
||||
throw new BillingException(
|
||||
@@ -123,9 +123,7 @@ public class PricingClient(
|
||||
return [CurrentPremiumPlan];
|
||||
}
|
||||
|
||||
var milestone2Feature = featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2);
|
||||
|
||||
var response = await httpClient.GetAsync($"plans/premium?milestone2={milestone2Feature}");
|
||||
var response = await httpClient.GetAsync("plans/premium");
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
@@ -137,7 +135,7 @@ public class PricingClient(
|
||||
message: $"Request to the Pricing Service failed with status {response.StatusCode}");
|
||||
}
|
||||
|
||||
private static string? GetLookupKey(PlanType planType)
|
||||
private string? GetLookupKey(PlanType planType)
|
||||
=> planType switch
|
||||
{
|
||||
PlanType.EnterpriseAnnually => "enterprise-annually",
|
||||
@@ -149,6 +147,10 @@ public class PricingClient(
|
||||
PlanType.EnterpriseMonthly2020 => "enterprise-monthly-2020",
|
||||
PlanType.EnterpriseMonthly2023 => "enterprise-monthly-2023",
|
||||
PlanType.FamiliesAnnually => "families",
|
||||
PlanType.FamiliesAnnually2025 =>
|
||||
featureService.IsEnabled(FeatureFlagKeys.PM26462_Milestone_3)
|
||||
? "families-2025"
|
||||
: "families",
|
||||
PlanType.FamiliesAnnually2019 => "families-2019",
|
||||
PlanType.Free => "free",
|
||||
PlanType.TeamsAnnually => "teams-annually",
|
||||
@@ -164,6 +166,20 @@ public class PricingClient(
|
||||
_ => null
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Safeguard used until the feature flag is enabled. Pricing service will return the
|
||||
/// 2025PreMigration plan with "families" lookup key. When that is detected and the FF
|
||||
/// is still disabled, set the lookup key to families-2025 so PlanAdapter will assign
|
||||
/// the correct plan.
|
||||
/// </summary>
|
||||
/// <param name="plan">The plan to preprocess</param>
|
||||
private Plan PreProcessFamiliesPreMigrationPlan(Plan plan)
|
||||
{
|
||||
if (plan.LookupKey == "families" && !featureService.IsEnabled(FeatureFlagKeys.PM26462_Milestone_3))
|
||||
plan.LookupKey = "families-2025";
|
||||
return plan;
|
||||
}
|
||||
|
||||
private static PremiumPlan CurrentPremiumPlan => new()
|
||||
{
|
||||
Name = "Premium",
|
||||
|
||||
@@ -137,7 +137,6 @@ public static class FeatureFlagKeys
|
||||
/* Admin Console Team */
|
||||
public const string PolicyRequirements = "pm-14439-policy-requirements";
|
||||
public const string ScimInviteUserOptimization = "pm-16811-optimize-invite-user-flow-to-fail-fast";
|
||||
public const string EventBasedOrganizationIntegrations = "event-based-organization-integrations";
|
||||
public const string CreateDefaultLocation = "pm-19467-create-default-location";
|
||||
public const string AutomaticConfirmUsers = "pm-19934-auto-confirm-organization-users";
|
||||
public const string PM23845_VNextApplicationCache = "pm-24957-refactor-memory-application-cache";
|
||||
@@ -195,6 +194,7 @@ public static class FeatureFlagKeys
|
||||
public const string PremiumUpgradeNewDesign = "pm-24033-updat-premium-subscription-page";
|
||||
public const string PM26793_FetchPremiumPriceFromPricingService = "pm-26793-fetch-premium-price-from-pricing-service";
|
||||
public const string PM23341_Milestone_2 = "pm-23341-milestone-2";
|
||||
public const string PM26462_Milestone_3 = "pm-26462-milestone-3";
|
||||
|
||||
/* Key Management Team */
|
||||
public const string ReturnErrorOnExistingKeypair = "return-error-on-existing-keypair";
|
||||
|
||||
@@ -29,8 +29,8 @@
|
||||
.mj-outlook-group-fix { width:100% !important; }
|
||||
</style>
|
||||
<![endif]-->
|
||||
|
||||
|
||||
|
||||
|
||||
<style type="text/css">
|
||||
@media only screen and (min-width:480px) {
|
||||
.mj-column-per-70 { width:70% !important; max-width: 70%; }
|
||||
@@ -45,19 +45,19 @@
|
||||
.moz-text-html .mj-column-per-100 { width:100% !important; max-width: 100%; }
|
||||
.moz-text-html .mj-column-per-90 { width:90% !important; max-width: 90%; }
|
||||
</style>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<style type="text/css">
|
||||
|
||||
@media only screen and (max-width:479px) {
|
||||
table.mj-full-width-mobile { width: 100% !important; }
|
||||
td.mj-full-width-mobile { width: auto !important; }
|
||||
}
|
||||
|
||||
|
||||
</style>
|
||||
|
||||
|
||||
<style type="text/css">
|
||||
.border-fix > table {
|
||||
border-collapse: separate !important;
|
||||
@@ -65,250 +65,258 @@
|
||||
.border-fix > table > tbody > tr > td {
|
||||
border-radius: 3px;
|
||||
}
|
||||
@media only screen and
|
||||
(max-width: 480px) { .hide-small-img { display: none !important; } .send-bubble { padding-left: 20px; padding-right: 20px; width: 90% !important; } }
|
||||
@media only screen and (max-width: 480px) {
|
||||
.hide-small-img {
|
||||
display: none !important;
|
||||
}
|
||||
.send-bubble {
|
||||
padding-left: 20px;
|
||||
padding-right: 20px;
|
||||
width: 90% !important;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
<!-- Responsive icon visibility -->
|
||||
</head>
|
||||
<body style="word-spacing:normal;background-color:#e6e9ef;">
|
||||
|
||||
|
||||
|
||||
|
||||
<div class="border-fix" style="background-color:#e6e9ef;" lang="und" dir="auto">
|
||||
<!-- Blue Header Section -->
|
||||
|
||||
|
||||
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="border-fix-outlook" role="presentation" style="width:660px;" width="660" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
|
||||
|
||||
|
||||
|
||||
<div class="border-fix" style="margin:0px auto;max-width:660px;">
|
||||
|
||||
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="direction:ltr;font-size:0px;padding:20px 20px 10px 20px;text-align:center;">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" width="660px" ><![endif]-->
|
||||
|
||||
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#175ddc;background-color:#175ddc;width:100%;border-radius:4px 4px 0px 0px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
|
||||
|
||||
|
||||
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:620px;" width="620" bgcolor="#175ddc" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
|
||||
|
||||
|
||||
|
||||
<div style="margin:0px auto;border-radius:4px 4px 0px 0px;max-width:620px;">
|
||||
|
||||
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;border-radius:4px 4px 0px 0px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:434px;" ><![endif]-->
|
||||
|
||||
|
||||
<div class="mj-column-per-70 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
||||
|
||||
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
|
||||
<tbody>
|
||||
|
||||
|
||||
<tr>
|
||||
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||
|
||||
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:collapse;border-spacing:0px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="width:150px;">
|
||||
|
||||
|
||||
<img alt src="https://bitwarden.com/images/logo-horizontal-white.png" style="border:0;display:block;outline:none;text-decoration:none;height:30px;width:100%;font-size:16px;" width="150" height="30">
|
||||
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
|
||||
<tr>
|
||||
<td align="left" style="font-size:0px;padding:10px 25px;padding-top:0;padding-bottom:0;word-break:break-word;">
|
||||
|
||||
|
||||
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;line-height:1;text-align:left;color:#ffffff;"><h1 style="font-weight: normal; font-size: 24px; line-height: 32px">
|
||||
Verify your email to access this Bitwarden Send
|
||||
</h1></div>
|
||||
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<!--[if mso | IE]></td><td class="" style="vertical-align:bottom;width:186px;" ><![endif]-->
|
||||
|
||||
|
||||
<div class="mj-column-per-30 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:bottom;width:100%;">
|
||||
|
||||
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:bottom;" width="100%">
|
||||
<tbody>
|
||||
|
||||
|
||||
<tr>
|
||||
<td align="center" class="hide-small-img" style="font-size:0px;padding:0px;word-break:break-word;">
|
||||
|
||||
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:collapse;border-spacing:0px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="width:140px;">
|
||||
|
||||
<img alt src="https://assets.bitwarden.com/email/v1/spot-secure-send-round.png" style="border:0;display:block;outline:none;text-decoration:none;height:140px;width:100%;font-size:16px;" width="140" height="140">
|
||||
|
||||
<td style="width:155px;">
|
||||
|
||||
<img alt src="https://assets.bitwarden.com/email/v1/spot-secure-send-round.png" style="border:0;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:16px;" width="155" height="auto">
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
|
||||
|
||||
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
|
||||
|
||||
<!-- Main Content -->
|
||||
|
||||
|
||||
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:660px;" width="660" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
|
||||
|
||||
|
||||
|
||||
<div style="margin:0px auto;max-width:660px;">
|
||||
|
||||
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="direction:ltr;font-size:0px;padding:5px 20px 10px 20px;text-align:center;">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" width="660px" ><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:620px;" width="620" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
|
||||
|
||||
|
||||
|
||||
<div style="background:#ffffff;background-color:#ffffff;margin:0px auto;max-width:620px;">
|
||||
|
||||
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#ffffff;background-color:#ffffff;width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:620px;" ><![endif]-->
|
||||
|
||||
|
||||
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
||||
|
||||
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="vertical-align:top;padding:0px;">
|
||||
|
||||
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style width="100%">
|
||||
<tbody>
|
||||
|
||||
|
||||
<tr>
|
||||
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||
|
||||
|
||||
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;line-height:1;text-align:left;color:#1B2029;">Your verification code is:</div>
|
||||
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
|
||||
<tr>
|
||||
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||
|
||||
|
||||
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:32px;line-height:1;text-align:left;color:#1B2029;"><b>{{Token}}</b></div>
|
||||
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
|
||||
<tr>
|
||||
<td style="font-size:0px;word-break:break-word;">
|
||||
|
||||
|
||||
<div style="height:20px;line-height:20px;"> </div>
|
||||
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
|
||||
<tr>
|
||||
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||
|
||||
|
||||
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;line-height:1;text-align:left;color:#1B2029;">This code expires in {{Expiry}} minutes. After that, you'll need to
|
||||
verify your email again.</div>
|
||||
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
<!--[if mso | IE]></td></tr></table></td></tr><tr><td class="" width="660px" ><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:620px;" width="620" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
|
||||
|
||||
|
||||
|
||||
<div style="background:#ffffff;background-color:#ffffff;margin:0px auto;max-width:620px;">
|
||||
|
||||
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#ffffff;background-color:#ffffff;width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="direction:ltr;font-size:0px;padding:0px 0px 20px 0px;text-align:center;">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="send-bubble-outlook" style="vertical-align:top;width:558px;" ><![endif]-->
|
||||
|
||||
|
||||
<div class="mj-column-per-90 mj-outlook-group-fix send-bubble" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
||||
|
||||
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="vertical-align:top;padding:0px;">
|
||||
|
||||
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="background-color:#DBE5F6;border-radius:16px;" width="100%">
|
||||
<tbody>
|
||||
|
||||
|
||||
<tr>
|
||||
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||
|
||||
|
||||
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;line-height:1;text-align:left;color:#1B2029;"><p>
|
||||
Bitwarden Send transmits sensitive, temporary information to
|
||||
others easily and securely. Learn more about
|
||||
@@ -317,160 +325,160 @@
|
||||
<a href="https://bitwarden.com/signup" class="link" style="text-decoration: none; color: #175ddc; font-weight: 600;">sign up</a>
|
||||
to try it today.
|
||||
</p></div>
|
||||
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
<!--[if mso | IE]></td></tr></table></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
|
||||
|
||||
<!-- Learn More Section -->
|
||||
|
||||
|
||||
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:660px;" width="660" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
|
||||
|
||||
|
||||
|
||||
<div style="margin:0px auto;max-width:660px;">
|
||||
|
||||
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="direction:ltr;font-size:0px;padding:5px 20px 10px 20px;text-align:center;">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" width="660px" ><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:620px;" width="620" bgcolor="#f6f6f6" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
|
||||
|
||||
|
||||
|
||||
<div style="background:#f6f6f6;background-color:#f6f6f6;margin:0px auto;border-radius:0px 0px 4px 4px;max-width:620px;">
|
||||
|
||||
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#f6f6f6;background-color:#f6f6f6;width:100%;border-radius:0px 0px 4px 4px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="direction:ltr;font-size:0px;padding:5px 20px 10px 20px;text-align:center;">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:406px;" ><![endif]-->
|
||||
|
||||
<td style="direction:ltr;font-size:0px;padding:5px 10px 10px 10px;text-align:center;">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:420px;" ><![endif]-->
|
||||
|
||||
<div class="mj-column-per-70 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
||||
|
||||
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
|
||||
<tbody>
|
||||
|
||||
|
||||
<tr>
|
||||
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||
|
||||
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;line-height:24px;text-align:left;color:#1B2029;"><h3 style="font-size: 20px; margin: 0; line-height: 28px">
|
||||
|
||||
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;line-height:24px;text-align:left;color:#1B2029;"><p style="font-size: 18px; line-height: 28px; font-weight: bold;">
|
||||
Learn more about Bitwarden
|
||||
</h3>
|
||||
</p>
|
||||
Find user guides, product documentation, and videos on the
|
||||
<a href="https://bitwarden.com/help/" class="link" style="text-decoration: none; color: #175ddc; font-weight: 600;"> Bitwarden Help Center</a>.</div>
|
||||
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
<!--[if mso | IE]></td><td class="" style="vertical-align:top;width:174px;" ><![endif]-->
|
||||
|
||||
|
||||
<!--[if mso | IE]></td><td class="" style="vertical-align:top;width:180px;" ><![endif]-->
|
||||
|
||||
<div class="mj-column-per-30 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
||||
|
||||
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
|
||||
<tbody>
|
||||
|
||||
|
||||
<tr>
|
||||
<td align="center" class="hide-small-img" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||
|
||||
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:collapse;border-spacing:0px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="width:94px;">
|
||||
|
||||
|
||||
<img alt src="https://assets.bitwarden.com/email/v1/spot-community.png" style="border:0;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:16px;" width="94" height="auto">
|
||||
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
<!--[if mso | IE]></td></tr></table></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
|
||||
|
||||
<!-- Footer -->
|
||||
|
||||
|
||||
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:660px;" width="660" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
|
||||
|
||||
|
||||
|
||||
<div style="margin:0px auto;max-width:660px;">
|
||||
|
||||
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:660px;" ><![endif]-->
|
||||
|
||||
|
||||
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
||||
|
||||
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
|
||||
<tbody>
|
||||
|
||||
|
||||
<tr>
|
||||
<td align="center" style="font-size:0px;padding:0;word-break:break-word;">
|
||||
|
||||
|
||||
|
||||
|
||||
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" ><tr><td><![endif]-->
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
|
||||
<tbody>
|
||||
|
||||
|
||||
<tr>
|
||||
<td style="padding:10px;vertical-align:middle;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:30px;">
|
||||
@@ -485,15 +493,15 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
|
||||
|
||||
</tr>
|
||||
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
<!--[if mso | IE]></td><td><![endif]-->
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
|
||||
<tbody>
|
||||
|
||||
|
||||
<tr>
|
||||
<td style="padding:10px;vertical-align:middle;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:30px;">
|
||||
@@ -508,15 +516,15 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
|
||||
|
||||
</tr>
|
||||
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
<!--[if mso | IE]></td><td><![endif]-->
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
|
||||
<tbody>
|
||||
|
||||
|
||||
<tr>
|
||||
<td style="padding:10px;vertical-align:middle;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:30px;">
|
||||
@@ -531,15 +539,15 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
|
||||
|
||||
</tr>
|
||||
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
<!--[if mso | IE]></td><td><![endif]-->
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
|
||||
<tbody>
|
||||
|
||||
|
||||
<tr>
|
||||
<td style="padding:10px;vertical-align:middle;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:30px;">
|
||||
@@ -554,15 +562,15 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
|
||||
|
||||
</tr>
|
||||
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
<!--[if mso | IE]></td><td><![endif]-->
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
|
||||
<tbody>
|
||||
|
||||
|
||||
<tr>
|
||||
<td style="padding:10px;vertical-align:middle;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:30px;">
|
||||
@@ -577,15 +585,15 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
|
||||
|
||||
</tr>
|
||||
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
<!--[if mso | IE]></td><td><![endif]-->
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
|
||||
<tbody>
|
||||
|
||||
|
||||
<tr>
|
||||
<td style="padding:10px;vertical-align:middle;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:30px;">
|
||||
@@ -600,15 +608,15 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
|
||||
|
||||
</tr>
|
||||
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
<!--[if mso | IE]></td><td><![endif]-->
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
|
||||
<tbody>
|
||||
|
||||
|
||||
<tr>
|
||||
<td style="padding:10px;vertical-align:middle;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:30px;">
|
||||
@@ -623,20 +631,20 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
|
||||
|
||||
</tr>
|
||||
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
|
||||
|
||||
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
|
||||
<tr>
|
||||
<td align="center" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||
|
||||
|
||||
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:12px;line-height:16px;text-align:center;color:#5A6D91;"><p style="margin-bottom: 5px">
|
||||
© 2025 Bitwarden Inc. 1 N. Calle Cesar Chavez, Suite 102, Santa
|
||||
Barbara, CA, USA
|
||||
@@ -647,29 +655,28 @@
|
||||
<a href="https://bitwarden.com/">bitwarden.com</a> |
|
||||
<a href="https://bitwarden.com/help/emails-from-bitwarden/">Learn why we include this</a>
|
||||
</p></div>
|
||||
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
{
|
||||
"packages": [
|
||||
"components/mj-bw-hero"
|
||||
"components/mj-bw-hero",
|
||||
"components/mj-bw-icon-row",
|
||||
"components/mj-bw-learn-more-footer"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ When using MJML templating you can use the above [commands](#building-mjml-files
|
||||
|
||||
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
|
||||
### Recommended development - IMailService
|
||||
|
||||
#### Mjml email template development
|
||||
|
||||
@@ -58,11 +58,17 @@ Not all MJML tags have the same attributes, it is highly recommended to review t
|
||||
|
||||
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`
|
||||
1. run `npm run build:hbs`
|
||||
2. copy built `*.html.hbs` files from the build directory to a location the mail service can consume them
|
||||
1. all files in the `Core/MailTemplates/Mjml/out` directory can be copied to the `src/Core/MailTemplates/Handlebars/MJML` directory. If a shared component is modified it is important to copy and overwrite all files in that directory to capture
|
||||
changes in the `*.html.hbs`.
|
||||
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.
|
||||
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, see 2.1 above.
|
||||
|
||||
### Recommended development - IMailer
|
||||
|
||||
TBD - PM-26475
|
||||
|
||||
### Custom tags
|
||||
|
||||
@@ -110,3 +116,8 @@ You are also able to reference other more static MJML templates in your MJML fil
|
||||
<mj-include path="../../components/learn-more-footer.mjml" />
|
||||
</mj-wrapper>
|
||||
```
|
||||
|
||||
#### `head.mjml`
|
||||
Currently we include the `head.mjml` file in all MJML templates as it contains shared styling and formatting that ensures consistency across all email implementations.
|
||||
|
||||
In the future we may deviate from this practice to support different layouts. At that time we will modify the docs with direction.
|
||||
|
||||
@@ -22,9 +22,3 @@
|
||||
border-radius: 3px;
|
||||
}
|
||||
</mj-style>
|
||||
|
||||
<!-- Responsive icon visibility -->
|
||||
<mj-style>
|
||||
@media only screen and
|
||||
(max-width: 480px) { .hide-small-img { display: none !important; } .send-bubble { padding-left: 20px; padding-right: 20px; width: 90% !important; } }
|
||||
</mj-style>
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
<mj-section border-radius="0px 0px 4px 4px" background-color="#f6f6f6" padding="5px 20px 10px 20px">
|
||||
<mj-column width="70%">
|
||||
<mj-text line-height="24px">
|
||||
<h3 style="font-size: 20px; margin: 0; line-height: 28px">
|
||||
Learn more about Bitwarden
|
||||
</h3>
|
||||
Find user guides, product documentation, and videos on the
|
||||
<a href="https://bitwarden.com/help/" class="link"> Bitwarden Help Center</a>.
|
||||
</mj-text>
|
||||
</mj-column>
|
||||
<mj-column width="30%">
|
||||
<mj-image
|
||||
src="https://assets.bitwarden.com/email/v1/spot-community.png"
|
||||
css-class="hide-small-img"
|
||||
width="94px"
|
||||
/>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
@@ -18,27 +18,19 @@ class MjBwHero extends BodyComponent {
|
||||
|
||||
static defaultAttributes = {};
|
||||
|
||||
componentHeadStyle = breakpoint => {
|
||||
return `
|
||||
@media only screen and (max-width:${breakpoint}) {
|
||||
.mj-bw-hero-responsive-img {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
`
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.getAttribute("button-text") && this.getAttribute("button-url")) {
|
||||
return this.renderMJML(`
|
||||
<mj-section
|
||||
full-width="full-width"
|
||||
background-color="#175ddc"
|
||||
border-radius="4px 4px 0px 0px"
|
||||
>
|
||||
<mj-column width="70%">
|
||||
<mj-image
|
||||
align="left"
|
||||
src="https://bitwarden.com/images/logo-horizontal-white.png"
|
||||
width="150px"
|
||||
height="30px"
|
||||
></mj-image>
|
||||
<mj-text color="#fff" padding-top="0" padding-bottom="0">
|
||||
<h1 style="font-weight: normal; font-size: 24px; line-height: 32px">
|
||||
${this.getAttribute("title")}
|
||||
</h1>
|
||||
</mj-text>
|
||||
<mj-button
|
||||
const buttonElement = this.getAttribute("button-text") && this.getAttribute("button-url") ?
|
||||
`<mj-button
|
||||
href="${this.getAttribute("button-url")}"
|
||||
background-color="#fff"
|
||||
color="#1A41AC"
|
||||
@@ -47,22 +39,16 @@ class MjBwHero extends BodyComponent {
|
||||
>
|
||||
${this.getAttribute("button-text")}
|
||||
</mj-button
|
||||
>
|
||||
</mj-column>
|
||||
<mj-column width="30%" vertical-align="bottom">
|
||||
<mj-image
|
||||
src="${this.getAttribute("img-src")}"
|
||||
alt=""
|
||||
width="140px"
|
||||
height="140px"
|
||||
padding="0px"
|
||||
css-class="hide-small-img"
|
||||
/>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
`);
|
||||
} else {
|
||||
return this.renderMJML(`
|
||||
>` : "";
|
||||
const subTitleElement = this.getAttribute("sub-title") ?
|
||||
`<mj-text color="#fff" padding-top="0" padding-bottom="0">
|
||||
<h2 style="font-weight: normal; font-size: 16px; line-height: 0px">
|
||||
${this.getAttribute("sub-title")}
|
||||
</h2>
|
||||
</mj-text>` : "";
|
||||
|
||||
return this.renderMJML(
|
||||
`
|
||||
<mj-section
|
||||
full-width="full-width"
|
||||
background-color="#175ddc"
|
||||
@@ -79,21 +65,25 @@ class MjBwHero extends BodyComponent {
|
||||
<h1 style="font-weight: normal; font-size: 24px; line-height: 32px">
|
||||
${this.getAttribute("title")}
|
||||
</h1>
|
||||
</mj-text>
|
||||
` +
|
||||
subTitleElement +
|
||||
`
|
||||
</mj-text>` +
|
||||
buttonElement +
|
||||
`
|
||||
</mj-column>
|
||||
<mj-column width="30%" vertical-align="bottom">
|
||||
<mj-image
|
||||
src="${this.getAttribute("img-src")}"
|
||||
alt=""
|
||||
width="140px"
|
||||
height="140px"
|
||||
width="155px"
|
||||
padding="0px"
|
||||
css-class="hide-small-img"
|
||||
/>
|
||||
css-class="mj-bw-hero-responsive-img"
|
||||
/>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
`);
|
||||
}
|
||||
`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
100
src/Core/MailTemplates/Mjml/components/mj-bw-icon-row.js
Normal file
100
src/Core/MailTemplates/Mjml/components/mj-bw-icon-row.js
Normal file
@@ -0,0 +1,100 @@
|
||||
const { BodyComponent } = require("mjml-core");
|
||||
class MjBwIconRow extends BodyComponent {
|
||||
static dependencies = {
|
||||
"mj-column": ["mj-bw-icon-row"],
|
||||
"mj-wrapper": ["mj-bw-icon-row"],
|
||||
"mj-bw-icon-row": [],
|
||||
};
|
||||
|
||||
static allowedAttributes = {
|
||||
"icon-src": "string",
|
||||
"icon-alt": "string",
|
||||
"head-url-text": "string",
|
||||
"head-url": "string",
|
||||
text: "string",
|
||||
"foot-url-text": "string",
|
||||
"foot-url": "string",
|
||||
};
|
||||
|
||||
static defaultAttributes = {};
|
||||
|
||||
componentHeadStyle = (breakpoint) => {
|
||||
return `
|
||||
@media only screen and (max-width:${breakpoint}): {
|
||||
".mj-bw-icon-row-text": {
|
||||
padding-left: "5px !important",
|
||||
line-height: "20px",
|
||||
},
|
||||
".mj-bw-icon-row": {
|
||||
padding: "10px 15px",
|
||||
width: "fit-content !important",
|
||||
}
|
||||
}
|
||||
`;
|
||||
};
|
||||
|
||||
render() {
|
||||
const headAnchorElement =
|
||||
this.getAttribute("head-url-text") && this.getAttribute("head-url")
|
||||
? `<a href="${this.getAttribute("head-url")}" class="link">
|
||||
${this.getAttribute("head-url-text")}
|
||||
<span style="text-decoration: none">
|
||||
<img src="https://assets.bitwarden.com/email/v1/bwi-external-link-16px.png"
|
||||
alt="External Link Icon"
|
||||
width="16px"
|
||||
style="vertical-align: middle;"
|
||||
/>
|
||||
</span>
|
||||
</a>`
|
||||
: "";
|
||||
|
||||
const footAnchorElement =
|
||||
this.getAttribute("foot-url-text") && this.getAttribute("foot-url")
|
||||
? `<a href="${this.getAttribute("foot-url")}" class="link">
|
||||
${this.getAttribute("foot-url-text")}
|
||||
<span style="text-decoration: none">
|
||||
<img src="https://assets.bitwarden.com/email/v1/bwi-external-link-16px.png"
|
||||
alt="External Link Icon"
|
||||
width="16px"
|
||||
style="vertical-align: middle;"
|
||||
/>
|
||||
</span>
|
||||
</a>`
|
||||
: "";
|
||||
|
||||
return this.renderMJML(
|
||||
`
|
||||
<mj-section background-color="#fff" padding="10px 10px 10px 10px">
|
||||
<mj-group css-class="mj-bw-icon-row">
|
||||
<mj-column width="15%" vertical-align="top">
|
||||
<mj-image
|
||||
src="${this.getAttribute("icon-src")}"
|
||||
alt="${this.getAttribute("icon-alt")}"
|
||||
width="48px"
|
||||
padding="0px"
|
||||
border-radius="8px"
|
||||
/>
|
||||
</mj-column>
|
||||
<mj-column width="85%" vertical-align="top">
|
||||
<mj-text css-class="mj-bw-icon-row-text" padding="5px 10px 0px 10px">
|
||||
` +
|
||||
headAnchorElement +
|
||||
`
|
||||
</mj-text>
|
||||
<mj-text css-class="mj-bw-icon-row-text" padding="5px 10px 0px 10px">
|
||||
${this.getAttribute("text")}
|
||||
</mj-text>
|
||||
<mj-text css-class="mj-bw-icon-row-text" padding="5px 10px 0px 10px">
|
||||
` +
|
||||
footAnchorElement +
|
||||
`
|
||||
</mj-text>
|
||||
</mj-column>
|
||||
</mj-group>
|
||||
</mj-section>
|
||||
`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = MjBwIconRow;
|
||||
@@ -0,0 +1,51 @@
|
||||
const { BodyComponent } = require("mjml-core");
|
||||
class MjBwLearnMoreFooter extends BodyComponent {
|
||||
static dependencies = {
|
||||
// Tell the validator which tags are allowed as our component's parent
|
||||
"mj-column": ["mj-bw-learn-more-footer"],
|
||||
"mj-wrapper": ["mj-bw-learn-more-footer"],
|
||||
// Tell the validator which tags are allowed as our component's children
|
||||
"mj-bw-learn-more-footer": [],
|
||||
};
|
||||
|
||||
static allowedAttributes = {};
|
||||
|
||||
static defaultAttributes = {};
|
||||
|
||||
componentHeadStyle = (breakpoint) => {
|
||||
return `
|
||||
@media only screen and (max-width:${breakpoint}) {
|
||||
.mj-bw-learn-more-footer-responsive-img {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
`;
|
||||
};
|
||||
|
||||
render() {
|
||||
return this.renderMJML(
|
||||
`
|
||||
<mj-section border-radius="0px 0px 4px 4px" background-color="#f6f6f6" padding="5px 10px 10px 10px">
|
||||
<mj-column width="70%">
|
||||
<mj-text line-height="24px">
|
||||
<p style="font-size: 18px; line-height: 28px; font-weight: bold;">
|
||||
Learn more about Bitwarden
|
||||
</p>
|
||||
Find user guides, product documentation, and videos on the
|
||||
<a href="https://bitwarden.com/help/" class="link"> Bitwarden Help Center</a>.
|
||||
</mj-text>
|
||||
</mj-column>
|
||||
<mj-column width="30%">
|
||||
<mj-image
|
||||
src="https://assets.bitwarden.com/email/v1/spot-community.png"
|
||||
css-class="mj-bw-learn-more-footer-responsive-img"
|
||||
width="94px"
|
||||
/>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = MjBwLearnMoreFooter;
|
||||
@@ -0,0 +1,60 @@
|
||||
<mjml>
|
||||
<mj-head>
|
||||
<mj-include path="../../../components/head.mjml" />
|
||||
</mj-head>
|
||||
|
||||
<mj-body css-class="border-fix">
|
||||
<!-- Blue Header Section -->
|
||||
<mj-wrapper css-class="border-fix" padding="20px 20px 10px 20px">
|
||||
<mj-bw-hero
|
||||
img-src="https://assets.bitwarden.com/email/v1/account-fill.png"
|
||||
title="Welcome to Bitwarden!"
|
||||
sub-title="Let's get set up to autofill."
|
||||
/>
|
||||
</mj-wrapper>
|
||||
|
||||
<!-- Main Content -->
|
||||
<mj-wrapper padding="5px 20px 10px 20px">
|
||||
<mj-section background-color="#fff" padding="15px 10px 10px 10px">
|
||||
<mj-column>
|
||||
<mj-text font-size="16px" line-height="24px" padding="10px 15px">
|
||||
An administrator from <b>{{OrganizationName}}</b> will approve you
|
||||
before you can share passwords. While you wait for approval, get
|
||||
started with Bitwarden Password Manager:
|
||||
</mj-text>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
<mj-bw-icon-row
|
||||
icon-src="https://assets.bitwarden.com/email/v1/icon-browser-extension.png"
|
||||
icon-alt="Browser Extension Icon"
|
||||
head-url-text="Get the browser extension"
|
||||
head-url="https://bitwarden.com/download/"
|
||||
text="With the Bitwarden extension, you can fill passwords with one click."
|
||||
/>
|
||||
<mj-bw-icon-row
|
||||
icon-src="https://assets.bitwarden.com/email/v1/icon-install.png"
|
||||
icon-alt="Install Icon"
|
||||
head-url-text="Add passwords to your vault"
|
||||
head-url="https://bitwarden.com/help/import-data/"
|
||||
text="Quickly transfer existing passwords to Bitwarden using the importer."
|
||||
/>
|
||||
<mj-bw-icon-row
|
||||
icon-src="https://assets.bitwarden.com/email/v1/icon-devices.png"
|
||||
icon-alt="Devices Icon"
|
||||
head-url-text="Download Bitwarden on all devices"
|
||||
head-url="https://bitwarden.com/download/"
|
||||
text="Take your passwords with you anywhere."
|
||||
/>
|
||||
<mj-section background-color="#fff" padding="0 20px 20px 20px">
|
||||
</mj-section>
|
||||
</mj-wrapper>
|
||||
|
||||
<!-- Learn More Section -->
|
||||
<mj-wrapper padding="5px 20px 10px 20px">
|
||||
<mj-bw-learn-more-footer />
|
||||
</mj-wrapper>
|
||||
|
||||
<!-- Footer -->
|
||||
<mj-include path="../../../components/footer.mjml" />
|
||||
</mj-body>
|
||||
</mjml>
|
||||
@@ -0,0 +1,59 @@
|
||||
<mjml>
|
||||
<mj-head>
|
||||
<mj-include path="../../../components/head.mjml" />
|
||||
</mj-head>
|
||||
|
||||
<mj-body css-class="border-fix">
|
||||
<!-- Blue Header Section -->
|
||||
<mj-wrapper css-class="border-fix" padding="20px 20px 10px 20px">
|
||||
<mj-bw-hero
|
||||
img-src="https://assets.bitwarden.com/email/v1/account-fill.png"
|
||||
title="Welcome to Bitwarden!"
|
||||
sub-title="Let's get set up to autofill."
|
||||
/>
|
||||
</mj-wrapper>
|
||||
|
||||
<!-- Main Content -->
|
||||
<mj-wrapper padding="5px 20px 10px 20px">
|
||||
<mj-section background-color="#fff" padding="15px 10px 10px 10px">
|
||||
<mj-column>
|
||||
<mj-text font-size="16px" line-height="24px" padding="10px 15px">
|
||||
Follow these simple steps to get up and running with Bitwarden
|
||||
Password Manager:
|
||||
</mj-text>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
<mj-bw-icon-row
|
||||
icon-src="https://assets.bitwarden.com/email/v1/icon-browser-extension.png"
|
||||
icon-alt="Browser Extension Icon"
|
||||
head-url-text="Get the browser extension"
|
||||
head-url="https://bitwarden.com/download/"
|
||||
text="With the Bitwarden extension, you can fill passwords with one click."
|
||||
/>
|
||||
<mj-bw-icon-row
|
||||
icon-src="https://assets.bitwarden.com/email/v1/icon-install.png"
|
||||
icon-alt="Install Icon"
|
||||
head-url-text="Add passwords to your vault"
|
||||
head-url="https://bitwarden.com/help/import-data/"
|
||||
text="Quickly transfer existing passwords to Bitwarden using the importer."
|
||||
/>
|
||||
<mj-bw-icon-row
|
||||
icon-src="https://assets.bitwarden.com/email/v1/icon-devices.png"
|
||||
icon-alt="Devices Icon"
|
||||
head-url-text="Download Bitwarden on all devices"
|
||||
head-url="https://bitwarden.com/download/"
|
||||
text="Take your passwords with you anywhere."
|
||||
/>
|
||||
<mj-section background-color="#fff" padding="0 20px 20px 20px">
|
||||
</mj-section>
|
||||
</mj-wrapper>
|
||||
|
||||
<!-- Learn More Section -->
|
||||
<mj-wrapper padding="5px 20px 10px 20px">
|
||||
<mj-bw-learn-more-footer />
|
||||
</mj-wrapper>
|
||||
|
||||
<!-- Footer -->
|
||||
<mj-include path="../../../components/footer.mjml" />
|
||||
</mj-body>
|
||||
</mjml>
|
||||
@@ -0,0 +1,60 @@
|
||||
<mjml>
|
||||
<mj-head>
|
||||
<mj-include path="../../../components/head.mjml" />
|
||||
</mj-head>
|
||||
|
||||
<mj-body css-class="border-fix">
|
||||
<!-- Blue Header Section -->
|
||||
<mj-wrapper css-class="border-fix" padding="20px 20px 10px 20px">
|
||||
<mj-bw-hero
|
||||
img-src="https://assets.bitwarden.com/email/v1/account-fill.png"
|
||||
title="Welcome to Bitwarden!"
|
||||
sub-title="Let's get set up to autofill."
|
||||
/>
|
||||
</mj-wrapper>
|
||||
|
||||
<!-- Main Content -->
|
||||
<mj-wrapper padding="5px 20px 10px 20px">
|
||||
<mj-section background-color="#fff" padding="15px 10px 10px 10px">
|
||||
<mj-column>
|
||||
<mj-text font-size="16px" line-height="24px" padding="10px 15px">
|
||||
An administrator from <b>{{OrganizationName}}</b> will need to confirm
|
||||
you before you can share passwords. Get started with Bitwarden
|
||||
Password Manager:
|
||||
</mj-text>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
<mj-bw-icon-row
|
||||
icon-src="https://assets.bitwarden.com/email/v1/icon-browser-extension.png"
|
||||
icon-alt="Browser Extension Icon"
|
||||
head-url-text="Get the browser extension"
|
||||
head-url="https://bitwarden.com/download/"
|
||||
text="With the Bitwarden extension, you can fill passwords with one click."
|
||||
/>
|
||||
<mj-bw-icon-row
|
||||
icon-src="https://assets.bitwarden.com/email/v1/icon-install.png"
|
||||
icon-alt="Install Icon"
|
||||
head-url-text="Add passwords to your vault"
|
||||
head-url="https://bitwarden.com/help/import-data/"
|
||||
text="Quickly transfer existing passwords to Bitwarden using the importer."
|
||||
/>
|
||||
<mj-bw-icon-row
|
||||
icon-src="https://assets.bitwarden.com/email/v1/icon-autofill.png"
|
||||
icon-alt="Autofill Icon"
|
||||
head-url-text="Try Bitwarden autofill"
|
||||
head-url="https://bitwarden.com/help/auto-fill-browser/"
|
||||
text="Fill your passwords securely with one click."
|
||||
/>
|
||||
<mj-section background-color="#fff" padding="0 20px 20px 20px">
|
||||
</mj-section>
|
||||
</mj-wrapper>
|
||||
|
||||
<!-- Learn More Section -->
|
||||
<mj-wrapper padding="5px 20px 10px 20px">
|
||||
<mj-bw-learn-more-footer />
|
||||
</mj-wrapper>
|
||||
|
||||
<!-- Footer -->
|
||||
<mj-include path="../../../components/footer.mjml" />
|
||||
</mj-body>
|
||||
</mjml>
|
||||
@@ -55,7 +55,7 @@
|
||||
|
||||
<!-- Learn More Section -->
|
||||
<mj-wrapper padding="5px 20px 10px 20px">
|
||||
<mj-include path="../../components/learn-more-footer.mjml" />
|
||||
<mj-bw-learn-more-footer />
|
||||
</mj-wrapper>
|
||||
|
||||
<!-- Footer -->
|
||||
|
||||
@@ -24,6 +24,8 @@
|
||||
</mj-section>
|
||||
</mj-wrapper>
|
||||
|
||||
<mj-include path="../../components/footer.mjml" />
|
||||
<mj-wrapper>
|
||||
<mj-bw-learn-more-footer />
|
||||
</mj-wrapper>
|
||||
</mj-body>
|
||||
</mjml>
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
</mj-text>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
<mj-include path="../components/learn-more-footer.mjml" />
|
||||
<mj-bw-learn-more-footer />
|
||||
</mj-wrapper>
|
||||
|
||||
<mj-include path="../components/footer.mjml" />
|
||||
|
||||
@@ -50,6 +50,7 @@ public abstract class SubscriptionUpdate
|
||||
protected static bool IsNonSeatBasedPlan(StaticStore.Plan plan)
|
||||
=> plan.Type is
|
||||
>= PlanType.FamiliesAnnually2019 and <= PlanType.EnterpriseAnnually2019
|
||||
or PlanType.FamiliesAnnually2025
|
||||
or PlanType.FamiliesAnnually
|
||||
or PlanType.TeamsStarter2023
|
||||
or PlanType.TeamsStarter;
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
#nullable enable
|
||||
using System.Collections.Concurrent;
|
||||
using System.Reflection;
|
||||
using Bit.Core.Settings;
|
||||
using HandlebarsDotNet;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Bit.Core.Platform.Mail.Mailer;
|
||||
public class HandlebarMailRenderer : IMailRenderer
|
||||
@@ -9,7 +11,7 @@ public class HandlebarMailRenderer : IMailRenderer
|
||||
/// <summary>
|
||||
/// Lazy-initialized Handlebars instance. Thread-safe and ensures initialization occurs only once.
|
||||
/// </summary>
|
||||
private readonly Lazy<Task<IHandlebars>> _handlebarsTask = new(InitializeHandlebarsAsync, LazyThreadSafetyMode.ExecutionAndPublication);
|
||||
private readonly Lazy<Task<IHandlebars>> _handlebarsTask;
|
||||
|
||||
/// <summary>
|
||||
/// Helper function that returns the handlebar instance.
|
||||
@@ -21,6 +23,17 @@ public class HandlebarMailRenderer : IMailRenderer
|
||||
/// </summary>
|
||||
private readonly ConcurrentDictionary<string, Lazy<Task<HandlebarsTemplate<object, object>>>> _templateCache = new();
|
||||
|
||||
private readonly ILogger<HandlebarMailRenderer> _logger;
|
||||
private readonly GlobalSettings _globalSettings;
|
||||
|
||||
public HandlebarMailRenderer(ILogger<HandlebarMailRenderer> logger, GlobalSettings globalSettings)
|
||||
{
|
||||
_logger = logger;
|
||||
_globalSettings = globalSettings;
|
||||
|
||||
_handlebarsTask = new Lazy<Task<IHandlebars>>(InitializeHandlebarsAsync, LazyThreadSafetyMode.ExecutionAndPublication);
|
||||
}
|
||||
|
||||
public async Task<(string html, string txt)> RenderAsync(BaseMailView model)
|
||||
{
|
||||
var html = await CompileTemplateAsync(model, "html");
|
||||
@@ -53,19 +66,59 @@ public class HandlebarMailRenderer : IMailRenderer
|
||||
return handlebars.Compile(source);
|
||||
}
|
||||
|
||||
private static async Task<string> ReadSourceAsync(Assembly assembly, string template)
|
||||
private async Task<string> ReadSourceAsync(Assembly assembly, string template)
|
||||
{
|
||||
if (assembly.GetManifestResourceNames().All(f => f != template))
|
||||
{
|
||||
throw new FileNotFoundException("Template not found: " + template);
|
||||
}
|
||||
|
||||
var diskSource = await ReadSourceFromDiskAsync(template);
|
||||
if (!string.IsNullOrWhiteSpace(diskSource))
|
||||
{
|
||||
return diskSource;
|
||||
}
|
||||
|
||||
await using var s = assembly.GetManifestResourceStream(template)!;
|
||||
using var sr = new StreamReader(s);
|
||||
return await sr.ReadToEndAsync();
|
||||
}
|
||||
|
||||
private static async Task<IHandlebars> InitializeHandlebarsAsync()
|
||||
private async Task<string?> ReadSourceFromDiskAsync(string template)
|
||||
{
|
||||
if (!_globalSettings.SelfHosted)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var diskPath = Path.GetFullPath(Path.Combine(_globalSettings.MailTemplateDirectory, template));
|
||||
var baseDirectory = Path.GetFullPath(_globalSettings.MailTemplateDirectory);
|
||||
|
||||
// Ensure the resolved path is within the configured directory
|
||||
if (!diskPath.StartsWith(baseDirectory + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) &&
|
||||
!diskPath.Equals(baseDirectory, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_logger.LogWarning("Template path traversal attempt detected: {Template}", template);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (File.Exists(diskPath))
|
||||
{
|
||||
var fileContents = await File.ReadAllTextAsync(diskPath);
|
||||
return fileContents;
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
_logger.LogError(e, "Failed to read mail template from disk: {TemplateName}", template);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private async Task<IHandlebars> InitializeHandlebarsAsync()
|
||||
{
|
||||
var handlebars = Handlebars.Create();
|
||||
|
||||
|
||||
@@ -137,6 +137,7 @@ public static class StaticStore
|
||||
new Teams2019Plan(true),
|
||||
new Teams2019Plan(false),
|
||||
new Families2019Plan(),
|
||||
new Families2025Plan()
|
||||
}.ToImmutableList();
|
||||
}
|
||||
|
||||
|
||||
@@ -681,7 +681,8 @@ public class OrganizationUserRepository : Repository<OrganizationUser, Guid>, IO
|
||||
{
|
||||
organizationUser.Id,
|
||||
organizationUser.UserId,
|
||||
RevisionDate = DateTime.UtcNow.Date
|
||||
RevisionDate = DateTime.UtcNow.Date,
|
||||
Key = organizationUser.Key
|
||||
});
|
||||
|
||||
return rowCount > 0;
|
||||
|
||||
@@ -129,6 +129,7 @@ public class OrganizationRepository : Repository<Core.AdminConsole.Entities.Orga
|
||||
PlanType.Free,
|
||||
PlanType.Custom,
|
||||
PlanType.FamiliesAnnually2019,
|
||||
PlanType.FamiliesAnnually2025,
|
||||
PlanType.FamiliesAnnually
|
||||
};
|
||||
|
||||
|
||||
@@ -950,8 +950,9 @@ public class OrganizationUserRepository : Repository<Core.Entities.OrganizationU
|
||||
|
||||
var result = await dbContext.OrganizationUsers
|
||||
.Where(ou => ou.Id == organizationUser.Id && ou.Status == OrganizationUserStatusType.Accepted)
|
||||
.ExecuteUpdateAsync(x =>
|
||||
x.SetProperty(y => y.Status, OrganizationUserStatusType.Confirmed));
|
||||
.ExecuteUpdateAsync(x => x
|
||||
.SetProperty(y => y.Status, OrganizationUserStatusType.Confirmed)
|
||||
.SetProperty(y => y.Key, organizationUser.Key));
|
||||
|
||||
if (result <= 0)
|
||||
{
|
||||
|
||||
@@ -524,42 +524,33 @@ public static class ServiceCollectionExtensions
|
||||
|
||||
public static IServiceCollection AddEventWriteServices(this IServiceCollection services, GlobalSettings globalSettings)
|
||||
{
|
||||
if (!globalSettings.SelfHosted && CoreHelpers.SettingHasValue(globalSettings.Events.ConnectionString))
|
||||
if (IsAzureServiceBusEnabled(globalSettings))
|
||||
{
|
||||
services.TryAddKeyedSingleton<IEventWriteService, AzureQueueEventWriteService>("storage");
|
||||
|
||||
if (CoreHelpers.SettingHasValue(globalSettings.EventLogging.AzureServiceBus.ConnectionString) &&
|
||||
CoreHelpers.SettingHasValue(globalSettings.EventLogging.AzureServiceBus.EventTopicName))
|
||||
{
|
||||
services.TryAddSingleton<IEventIntegrationPublisher, AzureServiceBusService>();
|
||||
services.TryAddKeyedSingleton<IEventWriteService, EventIntegrationEventWriteService>("broadcast");
|
||||
}
|
||||
else
|
||||
{
|
||||
services.TryAddKeyedSingleton<IEventWriteService, NoopEventWriteService>("broadcast");
|
||||
}
|
||||
}
|
||||
else if (globalSettings.SelfHosted)
|
||||
{
|
||||
services.TryAddKeyedSingleton<IEventWriteService, RepositoryEventWriteService>("storage");
|
||||
|
||||
if (IsRabbitMqEnabled(globalSettings))
|
||||
{
|
||||
services.TryAddSingleton<IEventIntegrationPublisher, RabbitMqService>();
|
||||
services.TryAddKeyedSingleton<IEventWriteService, EventIntegrationEventWriteService>("broadcast");
|
||||
}
|
||||
else
|
||||
{
|
||||
services.TryAddKeyedSingleton<IEventWriteService, NoopEventWriteService>("broadcast");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
services.TryAddKeyedSingleton<IEventWriteService, NoopEventWriteService>("storage");
|
||||
services.TryAddKeyedSingleton<IEventWriteService, NoopEventWriteService>("broadcast");
|
||||
services.TryAddSingleton<IEventIntegrationPublisher, AzureServiceBusService>();
|
||||
services.TryAddSingleton<IEventWriteService, EventIntegrationEventWriteService>();
|
||||
return services;
|
||||
}
|
||||
|
||||
services.TryAddScoped<IEventWriteService, EventRouteService>();
|
||||
if (IsRabbitMqEnabled(globalSettings))
|
||||
{
|
||||
services.TryAddSingleton<IEventIntegrationPublisher, RabbitMqService>();
|
||||
services.TryAddSingleton<IEventWriteService, EventIntegrationEventWriteService>();
|
||||
return services;
|
||||
}
|
||||
|
||||
if (CoreHelpers.SettingHasValue(globalSettings.Events.ConnectionString))
|
||||
{
|
||||
services.TryAddSingleton<IEventWriteService, AzureQueueEventWriteService>();
|
||||
return services;
|
||||
}
|
||||
|
||||
if (globalSettings.SelfHosted)
|
||||
{
|
||||
services.TryAddSingleton<IEventWriteService, RepositoryEventWriteService>();
|
||||
return services;
|
||||
}
|
||||
|
||||
services.TryAddSingleton<IEventWriteService, NoopEventWriteService>();
|
||||
return services;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
CREATE PROCEDURE [dbo].[OrganizationUser_ConfirmById]
|
||||
@Id UNIQUEIDENTIFIER,
|
||||
@UserId UNIQUEIDENTIFIER,
|
||||
@RevisionDate DATETIME2(7)
|
||||
@RevisionDate DATETIME2(7),
|
||||
@Key NVARCHAR(MAX) = NULL
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON
|
||||
@@ -12,7 +13,8 @@ BEGIN
|
||||
[dbo].[OrganizationUser]
|
||||
SET
|
||||
[Status] = 2, -- Set to Confirmed
|
||||
[RevisionDate] = @RevisionDate
|
||||
[RevisionDate] = @RevisionDate,
|
||||
[Key] = @Key
|
||||
WHERE
|
||||
[Id] = @Id
|
||||
AND [Status] = 1 -- Only update if status is Accepted
|
||||
|
||||
@@ -67,7 +67,6 @@ public class PoliciesControllerTests : IClassFixture<ApiApplicationFactory>, IAs
|
||||
{
|
||||
Policy = new PolicyRequestModel
|
||||
{
|
||||
Type = policyType,
|
||||
Enabled = true,
|
||||
},
|
||||
Metadata = new Dictionary<string, object>
|
||||
@@ -148,7 +147,6 @@ public class PoliciesControllerTests : IClassFixture<ApiApplicationFactory>, IAs
|
||||
{
|
||||
Policy = new PolicyRequestModel
|
||||
{
|
||||
Type = policyType,
|
||||
Enabled = true,
|
||||
Data = new Dictionary<string, object>
|
||||
{
|
||||
@@ -218,7 +216,6 @@ public class PoliciesControllerTests : IClassFixture<ApiApplicationFactory>, IAs
|
||||
var policyType = PolicyType.MasterPassword;
|
||||
var request = new PolicyRequestModel
|
||||
{
|
||||
Type = policyType,
|
||||
Enabled = true,
|
||||
Data = new Dictionary<string, object>
|
||||
{
|
||||
@@ -244,7 +241,6 @@ public class PoliciesControllerTests : IClassFixture<ApiApplicationFactory>, IAs
|
||||
var policyType = PolicyType.SendOptions;
|
||||
var request = new PolicyRequestModel
|
||||
{
|
||||
Type = policyType,
|
||||
Enabled = true,
|
||||
Data = new Dictionary<string, object>
|
||||
{
|
||||
@@ -267,7 +263,6 @@ public class PoliciesControllerTests : IClassFixture<ApiApplicationFactory>, IAs
|
||||
var policyType = PolicyType.ResetPassword;
|
||||
var request = new PolicyRequestModel
|
||||
{
|
||||
Type = policyType,
|
||||
Enabled = true,
|
||||
Data = new Dictionary<string, object>
|
||||
{
|
||||
@@ -292,7 +287,6 @@ public class PoliciesControllerTests : IClassFixture<ApiApplicationFactory>, IAs
|
||||
{
|
||||
Policy = new PolicyRequestModel
|
||||
{
|
||||
Type = policyType,
|
||||
Enabled = true,
|
||||
Data = new Dictionary<string, object>
|
||||
{
|
||||
@@ -321,7 +315,6 @@ public class PoliciesControllerTests : IClassFixture<ApiApplicationFactory>, IAs
|
||||
{
|
||||
Policy = new PolicyRequestModel
|
||||
{
|
||||
Type = policyType,
|
||||
Enabled = true,
|
||||
Data = new Dictionary<string, object>
|
||||
{
|
||||
@@ -347,7 +340,6 @@ public class PoliciesControllerTests : IClassFixture<ApiApplicationFactory>, IAs
|
||||
{
|
||||
Policy = new PolicyRequestModel
|
||||
{
|
||||
Type = policyType,
|
||||
Enabled = true,
|
||||
Data = new Dictionary<string, object>
|
||||
{
|
||||
@@ -371,7 +363,6 @@ public class PoliciesControllerTests : IClassFixture<ApiApplicationFactory>, IAs
|
||||
var policyType = PolicyType.SingleOrg;
|
||||
var request = new PolicyRequestModel
|
||||
{
|
||||
Type = policyType,
|
||||
Enabled = true,
|
||||
Data = null
|
||||
};
|
||||
@@ -393,7 +384,6 @@ public class PoliciesControllerTests : IClassFixture<ApiApplicationFactory>, IAs
|
||||
{
|
||||
Policy = new PolicyRequestModel
|
||||
{
|
||||
Type = policyType,
|
||||
Enabled = true,
|
||||
Data = null
|
||||
},
|
||||
|
||||
@@ -133,6 +133,29 @@ public class OrganizationIntegrationControllerTests
|
||||
.DeleteAsync(organizationIntegration);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task PostDeleteAsync_AllParamsProvided_Succeeds(
|
||||
SutProvider<OrganizationIntegrationController> sutProvider,
|
||||
Guid organizationId,
|
||||
OrganizationIntegration organizationIntegration)
|
||||
{
|
||||
organizationIntegration.OrganizationId = organizationId;
|
||||
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
|
||||
sutProvider.GetDependency<ICurrentContext>()
|
||||
.OrganizationOwner(organizationId)
|
||||
.Returns(true);
|
||||
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
|
||||
.GetByIdAsync(Arg.Any<Guid>())
|
||||
.Returns(organizationIntegration);
|
||||
|
||||
await sutProvider.Sut.PostDeleteAsync(organizationId, organizationIntegration.Id);
|
||||
|
||||
await sutProvider.GetDependency<IOrganizationIntegrationRepository>().Received(1)
|
||||
.GetByIdAsync(organizationIntegration.Id);
|
||||
await sutProvider.GetDependency<IOrganizationIntegrationRepository>().Received(1)
|
||||
.DeleteAsync(organizationIntegration);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task DeleteAsync_IntegrationDoesNotBelongToOrganization_ThrowsNotFound(
|
||||
SutProvider<OrganizationIntegrationController> sutProvider,
|
||||
|
||||
@@ -51,6 +51,36 @@ public class OrganizationIntegrationsConfigurationControllerTests
|
||||
.DeleteAsync(organizationIntegrationConfiguration);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task PostDeleteAsync_AllParamsProvided_Succeeds(
|
||||
SutProvider<OrganizationIntegrationConfigurationController> sutProvider,
|
||||
Guid organizationId,
|
||||
OrganizationIntegration organizationIntegration,
|
||||
OrganizationIntegrationConfiguration organizationIntegrationConfiguration)
|
||||
{
|
||||
organizationIntegration.OrganizationId = organizationId;
|
||||
organizationIntegrationConfiguration.OrganizationIntegrationId = organizationIntegration.Id;
|
||||
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
|
||||
sutProvider.GetDependency<ICurrentContext>()
|
||||
.OrganizationOwner(organizationId)
|
||||
.Returns(true);
|
||||
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
|
||||
.GetByIdAsync(Arg.Any<Guid>())
|
||||
.Returns(organizationIntegration);
|
||||
sutProvider.GetDependency<IOrganizationIntegrationConfigurationRepository>()
|
||||
.GetByIdAsync(Arg.Any<Guid>())
|
||||
.Returns(organizationIntegrationConfiguration);
|
||||
|
||||
await sutProvider.Sut.PostDeleteAsync(organizationId, organizationIntegration.Id, organizationIntegrationConfiguration.Id);
|
||||
|
||||
await sutProvider.GetDependency<IOrganizationIntegrationRepository>().Received(1)
|
||||
.GetByIdAsync(organizationIntegration.Id);
|
||||
await sutProvider.GetDependency<IOrganizationIntegrationConfigurationRepository>().Received(1)
|
||||
.GetByIdAsync(organizationIntegrationConfiguration.Id);
|
||||
await sutProvider.GetDependency<IOrganizationIntegrationConfigurationRepository>().Received(1)
|
||||
.DeleteAsync(organizationIntegrationConfiguration);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task DeleteAsync_IntegrationConfigurationDoesNotExist_ThrowsNotFound(
|
||||
SutProvider<OrganizationIntegrationConfigurationController> sutProvider,
|
||||
@@ -199,27 +229,6 @@ public class OrganizationIntegrationsConfigurationControllerTests
|
||||
.GetManyByIntegrationAsync(organizationIntegration.Id);
|
||||
}
|
||||
|
||||
// [Theory, BitAutoData]
|
||||
// public async Task GetAsync_IntegrationConfigurationDoesNotExist_ThrowsNotFound(
|
||||
// SutProvider<OrganizationIntegrationConfigurationController> sutProvider,
|
||||
// Guid organizationId,
|
||||
// OrganizationIntegration organizationIntegration)
|
||||
// {
|
||||
// organizationIntegration.OrganizationId = organizationId;
|
||||
// sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
|
||||
// sutProvider.GetDependency<ICurrentContext>()
|
||||
// .OrganizationOwner(organizationId)
|
||||
// .Returns(true);
|
||||
// sutProvider.GetDependency<IOrganizationIntegrationRepository>()
|
||||
// .GetByIdAsync(Arg.Any<Guid>())
|
||||
// .Returns(organizationIntegration);
|
||||
// sutProvider.GetDependency<IOrganizationIntegrationConfigurationRepository>()
|
||||
// .GetByIdAsync(Arg.Any<Guid>())
|
||||
// .ReturnsNull();
|
||||
//
|
||||
// await Assert.ThrowsAsync<NotFoundException>(async () => await sutProvider.Sut.GetAsync(organizationId, Guid.Empty, Guid.Empty));
|
||||
// }
|
||||
//
|
||||
[Theory, BitAutoData]
|
||||
public async Task GetAsync_IntegrationDoesNotExist_ThrowsNotFound(
|
||||
SutProvider<OrganizationIntegrationConfigurationController> sutProvider,
|
||||
@@ -293,15 +302,16 @@ public class OrganizationIntegrationsConfigurationControllerTests
|
||||
sutProvider.GetDependency<IOrganizationIntegrationConfigurationRepository>()
|
||||
.CreateAsync(Arg.Any<OrganizationIntegrationConfiguration>())
|
||||
.Returns(organizationIntegrationConfiguration);
|
||||
var requestAction = await sutProvider.Sut.CreateAsync(organizationId, organizationIntegration.Id, model);
|
||||
var createResponse = await sutProvider.Sut.CreateAsync(organizationId, organizationIntegration.Id, model);
|
||||
|
||||
await sutProvider.GetDependency<IOrganizationIntegrationConfigurationRepository>().Received(1)
|
||||
.CreateAsync(Arg.Any<OrganizationIntegrationConfiguration>());
|
||||
Assert.IsType<OrganizationIntegrationConfigurationResponseModel>(requestAction);
|
||||
Assert.Equal(expected.Id, requestAction.Id);
|
||||
Assert.Equal(expected.Configuration, requestAction.Configuration);
|
||||
Assert.Equal(expected.EventType, requestAction.EventType);
|
||||
Assert.Equal(expected.Template, requestAction.Template);
|
||||
Assert.IsType<OrganizationIntegrationConfigurationResponseModel>(createResponse);
|
||||
Assert.Equal(expected.Id, createResponse.Id);
|
||||
Assert.Equal(expected.Configuration, createResponse.Configuration);
|
||||
Assert.Equal(expected.EventType, createResponse.EventType);
|
||||
Assert.Equal(expected.Filters, createResponse.Filters);
|
||||
Assert.Equal(expected.Template, createResponse.Template);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
@@ -331,15 +341,16 @@ public class OrganizationIntegrationsConfigurationControllerTests
|
||||
sutProvider.GetDependency<IOrganizationIntegrationConfigurationRepository>()
|
||||
.CreateAsync(Arg.Any<OrganizationIntegrationConfiguration>())
|
||||
.Returns(organizationIntegrationConfiguration);
|
||||
var requestAction = await sutProvider.Sut.CreateAsync(organizationId, organizationIntegration.Id, model);
|
||||
var createResponse = await sutProvider.Sut.CreateAsync(organizationId, organizationIntegration.Id, model);
|
||||
|
||||
await sutProvider.GetDependency<IOrganizationIntegrationConfigurationRepository>().Received(1)
|
||||
.CreateAsync(Arg.Any<OrganizationIntegrationConfiguration>());
|
||||
Assert.IsType<OrganizationIntegrationConfigurationResponseModel>(requestAction);
|
||||
Assert.Equal(expected.Id, requestAction.Id);
|
||||
Assert.Equal(expected.Configuration, requestAction.Configuration);
|
||||
Assert.Equal(expected.EventType, requestAction.EventType);
|
||||
Assert.Equal(expected.Template, requestAction.Template);
|
||||
Assert.IsType<OrganizationIntegrationConfigurationResponseModel>(createResponse);
|
||||
Assert.Equal(expected.Id, createResponse.Id);
|
||||
Assert.Equal(expected.Configuration, createResponse.Configuration);
|
||||
Assert.Equal(expected.EventType, createResponse.EventType);
|
||||
Assert.Equal(expected.Filters, createResponse.Filters);
|
||||
Assert.Equal(expected.Template, createResponse.Template);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
@@ -369,15 +380,16 @@ public class OrganizationIntegrationsConfigurationControllerTests
|
||||
sutProvider.GetDependency<IOrganizationIntegrationConfigurationRepository>()
|
||||
.CreateAsync(Arg.Any<OrganizationIntegrationConfiguration>())
|
||||
.Returns(organizationIntegrationConfiguration);
|
||||
var requestAction = await sutProvider.Sut.CreateAsync(organizationId, organizationIntegration.Id, model);
|
||||
var createResponse = await sutProvider.Sut.CreateAsync(organizationId, organizationIntegration.Id, model);
|
||||
|
||||
await sutProvider.GetDependency<IOrganizationIntegrationConfigurationRepository>().Received(1)
|
||||
.CreateAsync(Arg.Any<OrganizationIntegrationConfiguration>());
|
||||
Assert.IsType<OrganizationIntegrationConfigurationResponseModel>(requestAction);
|
||||
Assert.Equal(expected.Id, requestAction.Id);
|
||||
Assert.Equal(expected.Configuration, requestAction.Configuration);
|
||||
Assert.Equal(expected.EventType, requestAction.EventType);
|
||||
Assert.Equal(expected.Template, requestAction.Template);
|
||||
Assert.IsType<OrganizationIntegrationConfigurationResponseModel>(createResponse);
|
||||
Assert.Equal(expected.Id, createResponse.Id);
|
||||
Assert.Equal(expected.Configuration, createResponse.Configuration);
|
||||
Assert.Equal(expected.EventType, createResponse.EventType);
|
||||
Assert.Equal(expected.Filters, createResponse.Filters);
|
||||
Assert.Equal(expected.Template, createResponse.Template);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
@@ -575,7 +587,7 @@ public class OrganizationIntegrationsConfigurationControllerTests
|
||||
sutProvider.GetDependency<IOrganizationIntegrationConfigurationRepository>()
|
||||
.GetByIdAsync(Arg.Any<Guid>())
|
||||
.Returns(organizationIntegrationConfiguration);
|
||||
var requestAction = await sutProvider.Sut.UpdateAsync(
|
||||
var updateResponse = await sutProvider.Sut.UpdateAsync(
|
||||
organizationId,
|
||||
organizationIntegration.Id,
|
||||
organizationIntegrationConfiguration.Id,
|
||||
@@ -583,11 +595,12 @@ public class OrganizationIntegrationsConfigurationControllerTests
|
||||
|
||||
await sutProvider.GetDependency<IOrganizationIntegrationConfigurationRepository>().Received(1)
|
||||
.ReplaceAsync(Arg.Any<OrganizationIntegrationConfiguration>());
|
||||
Assert.IsType<OrganizationIntegrationConfigurationResponseModel>(requestAction);
|
||||
Assert.Equal(expected.Id, requestAction.Id);
|
||||
Assert.Equal(expected.Configuration, requestAction.Configuration);
|
||||
Assert.Equal(expected.EventType, requestAction.EventType);
|
||||
Assert.Equal(expected.Template, requestAction.Template);
|
||||
Assert.IsType<OrganizationIntegrationConfigurationResponseModel>(updateResponse);
|
||||
Assert.Equal(expected.Id, updateResponse.Id);
|
||||
Assert.Equal(expected.Configuration, updateResponse.Configuration);
|
||||
Assert.Equal(expected.EventType, updateResponse.EventType);
|
||||
Assert.Equal(expected.Filters, updateResponse.Filters);
|
||||
Assert.Equal(expected.Template, updateResponse.Template);
|
||||
}
|
||||
|
||||
|
||||
@@ -619,7 +632,7 @@ public class OrganizationIntegrationsConfigurationControllerTests
|
||||
sutProvider.GetDependency<IOrganizationIntegrationConfigurationRepository>()
|
||||
.GetByIdAsync(Arg.Any<Guid>())
|
||||
.Returns(organizationIntegrationConfiguration);
|
||||
var requestAction = await sutProvider.Sut.UpdateAsync(
|
||||
var updateResponse = await sutProvider.Sut.UpdateAsync(
|
||||
organizationId,
|
||||
organizationIntegration.Id,
|
||||
organizationIntegrationConfiguration.Id,
|
||||
@@ -627,11 +640,12 @@ public class OrganizationIntegrationsConfigurationControllerTests
|
||||
|
||||
await sutProvider.GetDependency<IOrganizationIntegrationConfigurationRepository>().Received(1)
|
||||
.ReplaceAsync(Arg.Any<OrganizationIntegrationConfiguration>());
|
||||
Assert.IsType<OrganizationIntegrationConfigurationResponseModel>(requestAction);
|
||||
Assert.Equal(expected.Id, requestAction.Id);
|
||||
Assert.Equal(expected.Configuration, requestAction.Configuration);
|
||||
Assert.Equal(expected.EventType, requestAction.EventType);
|
||||
Assert.Equal(expected.Template, requestAction.Template);
|
||||
Assert.IsType<OrganizationIntegrationConfigurationResponseModel>(updateResponse);
|
||||
Assert.Equal(expected.Id, updateResponse.Id);
|
||||
Assert.Equal(expected.Configuration, updateResponse.Configuration);
|
||||
Assert.Equal(expected.EventType, updateResponse.EventType);
|
||||
Assert.Equal(expected.Filters, updateResponse.Filters);
|
||||
Assert.Equal(expected.Template, updateResponse.Template);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
@@ -662,7 +676,7 @@ public class OrganizationIntegrationsConfigurationControllerTests
|
||||
sutProvider.GetDependency<IOrganizationIntegrationConfigurationRepository>()
|
||||
.GetByIdAsync(Arg.Any<Guid>())
|
||||
.Returns(organizationIntegrationConfiguration);
|
||||
var requestAction = await sutProvider.Sut.UpdateAsync(
|
||||
var updateResponse = await sutProvider.Sut.UpdateAsync(
|
||||
organizationId,
|
||||
organizationIntegration.Id,
|
||||
organizationIntegrationConfiguration.Id,
|
||||
@@ -670,11 +684,12 @@ public class OrganizationIntegrationsConfigurationControllerTests
|
||||
|
||||
await sutProvider.GetDependency<IOrganizationIntegrationConfigurationRepository>().Received(1)
|
||||
.ReplaceAsync(Arg.Any<OrganizationIntegrationConfiguration>());
|
||||
Assert.IsType<OrganizationIntegrationConfigurationResponseModel>(requestAction);
|
||||
Assert.Equal(expected.Id, requestAction.Id);
|
||||
Assert.Equal(expected.Configuration, requestAction.Configuration);
|
||||
Assert.Equal(expected.EventType, requestAction.EventType);
|
||||
Assert.Equal(expected.Template, requestAction.Template);
|
||||
Assert.IsType<OrganizationIntegrationConfigurationResponseModel>(updateResponse);
|
||||
Assert.Equal(expected.Id, updateResponse.Id);
|
||||
Assert.Equal(expected.Configuration, updateResponse.Configuration);
|
||||
Assert.Equal(expected.EventType, updateResponse.EventType);
|
||||
Assert.Equal(expected.Filters, updateResponse.Filters);
|
||||
Assert.Equal(expected.Template, updateResponse.Template);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
|
||||
@@ -71,6 +71,26 @@ public class SlackIntegrationControllerTests
|
||||
await sutProvider.Sut.CreateAsync(string.Empty, state.ToString()));
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task CreateAsync_CallbackUrlIsEmpty_ThrowsBadRequest(
|
||||
SutProvider<SlackIntegrationController> sutProvider,
|
||||
OrganizationIntegration integration)
|
||||
{
|
||||
integration.Type = IntegrationType.Slack;
|
||||
integration.Configuration = null;
|
||||
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
|
||||
sutProvider.Sut.Url
|
||||
.RouteUrl(Arg.Is<UrlRouteContext>(c => c.RouteName == "SlackIntegration_Create"))
|
||||
.Returns((string?)null);
|
||||
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(_validSlackCode, state.ToString()));
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task CreateAsync_SlackServiceReturnsEmpty_ThrowsBadRequest(
|
||||
SutProvider<SlackIntegrationController> sutProvider,
|
||||
@@ -153,6 +173,8 @@ public class SlackIntegrationControllerTests
|
||||
OrganizationIntegration wrongOrgIntegration)
|
||||
{
|
||||
wrongOrgIntegration.Id = integration.Id;
|
||||
wrongOrgIntegration.Type = IntegrationType.Slack;
|
||||
wrongOrgIntegration.Configuration = null;
|
||||
|
||||
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
|
||||
sutProvider.Sut.Url
|
||||
@@ -304,6 +326,22 @@ public class SlackIntegrationControllerTests
|
||||
await Assert.ThrowsAsync<BadRequestException>(async () => await sutProvider.Sut.RedirectAsync(organizationId));
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task RedirectAsync_CallbackUrlReturnsEmpty_ThrowsBadRequest(
|
||||
SutProvider<SlackIntegrationController> sutProvider,
|
||||
Guid organizationId)
|
||||
{
|
||||
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
|
||||
sutProvider.Sut.Url
|
||||
.RouteUrl(Arg.Is<UrlRouteContext>(c => c.RouteName == "SlackIntegration_Create"))
|
||||
.Returns((string?)null);
|
||||
sutProvider.GetDependency<ICurrentContext>()
|
||||
.OrganizationOwner(organizationId)
|
||||
.Returns(true);
|
||||
|
||||
await Assert.ThrowsAsync<BadRequestException>(async () => await sutProvider.Sut.RedirectAsync(organizationId));
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task RedirectAsync_SlackServiceReturnsEmpty_ThrowsNotFound(
|
||||
SutProvider<SlackIntegrationController> sutProvider,
|
||||
|
||||
@@ -60,6 +60,26 @@ public class TeamsIntegrationControllerTests
|
||||
Assert.IsType<CreatedResult>(requestAction);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task CreateAsync_CallbackUrlIsEmpty_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((string?)null);
|
||||
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_CodeIsEmpty_ThrowsBadRequest(
|
||||
SutProvider<TeamsIntegrationController> sutProvider,
|
||||
@@ -315,6 +335,30 @@ public class TeamsIntegrationControllerTests
|
||||
sutProvider.GetDependency<ITeamsService>().Received(1).GetRedirectUrl(Arg.Any<string>(), expectedState.ToString());
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task RedirectAsync_CallbackUrlIsEmpty_ThrowsBadRequest(
|
||||
SutProvider<TeamsIntegrationController> sutProvider,
|
||||
Guid organizationId,
|
||||
OrganizationIntegration integration)
|
||||
{
|
||||
integration.OrganizationId = organizationId;
|
||||
integration.Configuration = null;
|
||||
integration.Type = IntegrationType.Teams;
|
||||
|
||||
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
|
||||
sutProvider.Sut.Url
|
||||
.RouteUrl(Arg.Is<UrlRouteContext>(c => c.RouteName == "TeamsIntegration_Create"))
|
||||
.Returns((string?)null);
|
||||
sutProvider.GetDependency<ICurrentContext>()
|
||||
.OrganizationOwner(organizationId)
|
||||
.Returns(true);
|
||||
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
|
||||
.GetManyByOrganizationAsync(organizationId)
|
||||
.Returns([integration]);
|
||||
|
||||
await Assert.ThrowsAsync<BadRequestException>(async () => await sutProvider.Sut.RedirectAsync(organizationId));
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task RedirectAsync_IntegrationAlreadyExistsWithConfig_ThrowsBadRequest(
|
||||
SutProvider<TeamsIntegrationController> sutProvider,
|
||||
|
||||
@@ -1,14 +1,47 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Text.Json;
|
||||
using Bit.Api.AdminConsole.Models.Request.Organizations;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Test.Common.AutoFixture.Attributes;
|
||||
using Xunit;
|
||||
|
||||
namespace Bit.Api.Test.AdminConsole.Models.Request.Organizations;
|
||||
|
||||
public class OrganizationIntegrationRequestModelTests
|
||||
{
|
||||
[Fact]
|
||||
public void ToOrganizationIntegration_CreatesNewOrganizationIntegration()
|
||||
{
|
||||
var model = new OrganizationIntegrationRequestModel
|
||||
{
|
||||
Type = IntegrationType.Hec,
|
||||
Configuration = JsonSerializer.Serialize(new HecIntegration(Uri: new Uri("http://localhost"), Scheme: "Bearer", Token: "Token"))
|
||||
};
|
||||
|
||||
var organizationId = Guid.NewGuid();
|
||||
var organizationIntegration = model.ToOrganizationIntegration(organizationId);
|
||||
|
||||
Assert.Equal(organizationIntegration.Type, model.Type);
|
||||
Assert.Equal(organizationIntegration.Configuration, model.Configuration);
|
||||
Assert.Equal(organizationIntegration.OrganizationId, organizationId);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public void ToOrganizationIntegration_UpdatesExistingOrganizationIntegration(OrganizationIntegration integration)
|
||||
{
|
||||
var model = new OrganizationIntegrationRequestModel
|
||||
{
|
||||
Type = IntegrationType.Hec,
|
||||
Configuration = JsonSerializer.Serialize(new HecIntegration(Uri: new Uri("http://localhost"), Scheme: "Bearer", Token: "Token"))
|
||||
};
|
||||
|
||||
var organizationIntegration = model.ToOrganizationIntegration(integration);
|
||||
|
||||
Assert.Equal(organizationIntegration.Configuration, model.Configuration);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_CloudBillingSync_ReturnsNotYetSupportedError()
|
||||
{
|
||||
|
||||
@@ -24,11 +24,11 @@ public class SavePolicyRequestTests
|
||||
currentContext.OrganizationOwner(organizationId).Returns(true);
|
||||
|
||||
var testData = new Dictionary<string, object> { { "test", "value" } };
|
||||
var policyType = PolicyType.TwoFactorAuthentication;
|
||||
var model = new SavePolicyRequest
|
||||
{
|
||||
Policy = new PolicyRequestModel
|
||||
{
|
||||
Type = PolicyType.TwoFactorAuthentication,
|
||||
Enabled = true,
|
||||
Data = testData
|
||||
},
|
||||
@@ -36,7 +36,7 @@ public class SavePolicyRequestTests
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await model.ToSavePolicyModelAsync(organizationId, currentContext);
|
||||
var result = await model.ToSavePolicyModelAsync(organizationId, policyType, currentContext);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(PolicyType.TwoFactorAuthentication, result.PolicyUpdate.Type);
|
||||
@@ -63,17 +63,17 @@ public class SavePolicyRequestTests
|
||||
currentContext.UserId.Returns(userId);
|
||||
currentContext.OrganizationOwner(organizationId).Returns(false);
|
||||
|
||||
var policyType = PolicyType.SingleOrg;
|
||||
var model = new SavePolicyRequest
|
||||
{
|
||||
Policy = new PolicyRequestModel
|
||||
{
|
||||
Type = PolicyType.SingleOrg,
|
||||
Enabled = false
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await model.ToSavePolicyModelAsync(organizationId, currentContext);
|
||||
var result = await model.ToSavePolicyModelAsync(organizationId, policyType, currentContext);
|
||||
|
||||
// Assert
|
||||
Assert.Null(result.PolicyUpdate.Data);
|
||||
@@ -93,17 +93,17 @@ public class SavePolicyRequestTests
|
||||
currentContext.UserId.Returns(userId);
|
||||
currentContext.OrganizationOwner(organizationId).Returns(true);
|
||||
|
||||
var policyType = PolicyType.SingleOrg;
|
||||
var model = new SavePolicyRequest
|
||||
{
|
||||
Policy = new PolicyRequestModel
|
||||
{
|
||||
Type = PolicyType.SingleOrg,
|
||||
Enabled = false
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await model.ToSavePolicyModelAsync(organizationId, currentContext);
|
||||
var result = await model.ToSavePolicyModelAsync(organizationId, policyType, currentContext);
|
||||
|
||||
// Assert
|
||||
Assert.Null(result.PolicyUpdate.Data);
|
||||
@@ -124,11 +124,11 @@ public class SavePolicyRequestTests
|
||||
currentContext.UserId.Returns(userId);
|
||||
currentContext.OrganizationOwner(organizationId).Returns(true);
|
||||
|
||||
var policyType = PolicyType.OrganizationDataOwnership;
|
||||
var model = new SavePolicyRequest
|
||||
{
|
||||
Policy = new PolicyRequestModel
|
||||
{
|
||||
Type = PolicyType.OrganizationDataOwnership,
|
||||
Enabled = true
|
||||
},
|
||||
Metadata = new Dictionary<string, object>
|
||||
@@ -138,7 +138,7 @@ public class SavePolicyRequestTests
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await model.ToSavePolicyModelAsync(organizationId, currentContext);
|
||||
var result = await model.ToSavePolicyModelAsync(organizationId, policyType, currentContext);
|
||||
|
||||
// Assert
|
||||
Assert.IsType<OrganizationModelOwnershipPolicyModel>(result.Metadata);
|
||||
@@ -156,17 +156,17 @@ public class SavePolicyRequestTests
|
||||
currentContext.UserId.Returns(userId);
|
||||
currentContext.OrganizationOwner(organizationId).Returns(true);
|
||||
|
||||
var policyType = PolicyType.OrganizationDataOwnership;
|
||||
var model = new SavePolicyRequest
|
||||
{
|
||||
Policy = new PolicyRequestModel
|
||||
{
|
||||
Type = PolicyType.OrganizationDataOwnership,
|
||||
Enabled = true
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await model.ToSavePolicyModelAsync(organizationId, currentContext);
|
||||
var result = await model.ToSavePolicyModelAsync(organizationId, policyType, currentContext);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
@@ -193,12 +193,11 @@ public class SavePolicyRequestTests
|
||||
currentContext.UserId.Returns(userId);
|
||||
currentContext.OrganizationOwner(organizationId).Returns(true);
|
||||
|
||||
|
||||
var policyType = PolicyType.ResetPassword;
|
||||
var model = new SavePolicyRequest
|
||||
{
|
||||
Policy = new PolicyRequestModel
|
||||
{
|
||||
Type = PolicyType.ResetPassword,
|
||||
Enabled = true,
|
||||
Data = _complexData
|
||||
},
|
||||
@@ -206,7 +205,7 @@ public class SavePolicyRequestTests
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await model.ToSavePolicyModelAsync(organizationId, currentContext);
|
||||
var result = await model.ToSavePolicyModelAsync(organizationId, policyType, currentContext);
|
||||
|
||||
// Assert
|
||||
var deserializedData = JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(result.PolicyUpdate.Data);
|
||||
@@ -234,11 +233,11 @@ public class SavePolicyRequestTests
|
||||
currentContext.UserId.Returns(userId);
|
||||
currentContext.OrganizationOwner(organizationId).Returns(true);
|
||||
|
||||
var policyType = PolicyType.MaximumVaultTimeout;
|
||||
var model = new SavePolicyRequest
|
||||
{
|
||||
Policy = new PolicyRequestModel
|
||||
{
|
||||
Type = PolicyType.MaximumVaultTimeout,
|
||||
Enabled = true
|
||||
},
|
||||
Metadata = new Dictionary<string, object>
|
||||
@@ -248,7 +247,7 @@ public class SavePolicyRequestTests
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await model.ToSavePolicyModelAsync(organizationId, currentContext);
|
||||
var result = await model.ToSavePolicyModelAsync(organizationId, policyType, currentContext);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
@@ -266,19 +265,18 @@ public class SavePolicyRequestTests
|
||||
currentContext.OrganizationOwner(organizationId).Returns(true);
|
||||
|
||||
var errorDictionary = BuildErrorDictionary();
|
||||
|
||||
var policyType = PolicyType.OrganizationDataOwnership;
|
||||
var model = new SavePolicyRequest
|
||||
{
|
||||
Policy = new PolicyRequestModel
|
||||
{
|
||||
Type = PolicyType.OrganizationDataOwnership,
|
||||
Enabled = true
|
||||
},
|
||||
Metadata = errorDictionary
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await model.ToSavePolicyModelAsync(organizationId, currentContext);
|
||||
var result = await model.ToSavePolicyModelAsync(organizationId, policyType, currentContext);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
|
||||
@@ -487,14 +487,14 @@ public class PoliciesControllerTests
|
||||
.Returns(policy);
|
||||
|
||||
// Act
|
||||
var result = await sutProvider.Sut.PutVNext(orgId, model);
|
||||
var result = await sutProvider.Sut.PutVNext(orgId, policy.Type, model);
|
||||
|
||||
// Assert
|
||||
await sutProvider.GetDependency<IVNextSavePolicyCommand>()
|
||||
.Received(1)
|
||||
.SaveAsync(Arg.Is<SavePolicyModel>(
|
||||
m => m.PolicyUpdate.OrganizationId == orgId &&
|
||||
m.PolicyUpdate.Type == model.Policy.Type &&
|
||||
m.PolicyUpdate.Type == policy.Type &&
|
||||
m.PolicyUpdate.Enabled == model.Policy.Enabled &&
|
||||
m.PerformedBy.UserId == userId &&
|
||||
m.PerformedBy.IsOrganizationOwnerOrProvider == true));
|
||||
@@ -534,14 +534,14 @@ public class PoliciesControllerTests
|
||||
.Returns(policy);
|
||||
|
||||
// Act
|
||||
var result = await sutProvider.Sut.PutVNext(orgId, model);
|
||||
var result = await sutProvider.Sut.PutVNext(orgId, policy.Type, model);
|
||||
|
||||
// Assert
|
||||
await sutProvider.GetDependency<ISavePolicyCommand>()
|
||||
.Received(1)
|
||||
.VNextSaveAsync(Arg.Is<SavePolicyModel>(
|
||||
m => m.PolicyUpdate.OrganizationId == orgId &&
|
||||
m.PolicyUpdate.Type == model.Policy.Type &&
|
||||
m.PolicyUpdate.Type == policy.Type &&
|
||||
m.PolicyUpdate.Enabled == model.Policy.Enabled &&
|
||||
m.PerformedBy.UserId == userId &&
|
||||
m.PerformedBy.IsOrganizationOwnerOrProvider == true));
|
||||
|
||||
@@ -268,7 +268,8 @@ public class UpcomingInvoiceHandlerTests
|
||||
Arg.Is("sub_123"),
|
||||
Arg.Is<SubscriptionUpdateOptions>(o =>
|
||||
o.Items[0].Id == priceSubscriptionId &&
|
||||
o.Items[0].Price == priceId));
|
||||
o.Items[0].Price == priceId &&
|
||||
o.ProrationBehavior == "none"));
|
||||
|
||||
// Verify the updated invoice email was sent
|
||||
await _mailer.Received(1).SendEmail(
|
||||
|
||||
@@ -20,6 +20,20 @@ public class IntegrationTemplateContextTests
|
||||
Assert.Equal(expected, sut.EventMessage);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public void DateIso8601_ReturnsIso8601FormattedDate(EventMessage eventMessage)
|
||||
{
|
||||
var testDate = new DateTime(2025, 10, 27, 13, 30, 0, DateTimeKind.Utc);
|
||||
eventMessage.Date = testDate;
|
||||
var sut = new IntegrationTemplateContext(eventMessage);
|
||||
|
||||
var result = sut.DateIso8601;
|
||||
|
||||
Assert.Equal("2025-10-27T13:30:00.0000000Z", result);
|
||||
// Verify it's valid ISO 8601
|
||||
Assert.True(DateTime.TryParse(result, out _));
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public void UserName_WhenUserIsSet_ReturnsName(EventMessage eventMessage, User user)
|
||||
{
|
||||
|
||||
@@ -97,6 +97,8 @@ public class ConfirmOrganizationUserCommandTests
|
||||
[BitAutoData(PlanType.EnterpriseMonthly2019, OrganizationUserType.Owner)]
|
||||
[BitAutoData(PlanType.FamiliesAnnually, OrganizationUserType.Admin)]
|
||||
[BitAutoData(PlanType.FamiliesAnnually, OrganizationUserType.Owner)]
|
||||
[BitAutoData(PlanType.FamiliesAnnually2025, OrganizationUserType.Admin)]
|
||||
[BitAutoData(PlanType.FamiliesAnnually2025, OrganizationUserType.Owner)]
|
||||
[BitAutoData(PlanType.FamiliesAnnually2019, OrganizationUserType.Admin)]
|
||||
[BitAutoData(PlanType.FamiliesAnnually2019, OrganizationUserType.Owner)]
|
||||
[BitAutoData(PlanType.TeamsAnnually, OrganizationUserType.Admin)]
|
||||
|
||||
@@ -23,6 +23,7 @@ public class CloudICloudOrganizationSignUpCommandTests
|
||||
{
|
||||
[Theory]
|
||||
[BitAutoData(PlanType.FamiliesAnnually)]
|
||||
[BitAutoData(PlanType.FamiliesAnnually2025)]
|
||||
public async Task SignUp_PM_Family_Passes(PlanType planType, OrganizationSignup signup, SutProvider<CloudOrganizationSignUpCommand> sutProvider)
|
||||
{
|
||||
signup.Plan = planType;
|
||||
@@ -65,6 +66,7 @@ public class CloudICloudOrganizationSignUpCommandTests
|
||||
|
||||
[Theory]
|
||||
[BitAutoData(PlanType.FamiliesAnnually)]
|
||||
[BitAutoData(PlanType.FamiliesAnnually2025)]
|
||||
public async Task SignUp_AssignsOwnerToDefaultCollection
|
||||
(PlanType planType, OrganizationSignup signup, SutProvider<CloudOrganizationSignUpCommand> sutProvider)
|
||||
{
|
||||
|
||||
@@ -38,6 +38,20 @@ public class EventIntegrationEventWriteServiceTests
|
||||
organizationId: Arg.Is<string>(orgId => eventMessage.OrganizationId.ToString().Equals(orgId)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateManyAsync_EmptyList_DoesNothing()
|
||||
{
|
||||
await Subject.CreateManyAsync([]);
|
||||
await _eventIntegrationPublisher.DidNotReceiveWithAnyArgs().PublishEventAsync(Arg.Any<string>(), Arg.Any<string>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DisposeAsync_DisposesEventIntegrationPublisher()
|
||||
{
|
||||
await Subject.DisposeAsync();
|
||||
await _eventIntegrationPublisher.Received(1).DisposeAsync();
|
||||
}
|
||||
|
||||
private static bool AssertJsonStringsMatch(EventMessage expected, string body)
|
||||
{
|
||||
var actual = JsonSerializer.Deserialize<EventMessage>(body);
|
||||
|
||||
@@ -120,6 +120,16 @@ public class EventIntegrationHandlerTests
|
||||
Assert.Empty(_eventIntegrationPublisher.ReceivedCalls());
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task HandleEventAsync_NoOrganizationId_DoesNothing(EventMessage eventMessage)
|
||||
{
|
||||
var sutProvider = GetSutProvider(OneConfiguration(_templateBase));
|
||||
eventMessage.OrganizationId = null;
|
||||
|
||||
await sutProvider.Sut.HandleEventAsync(eventMessage);
|
||||
Assert.Empty(_eventIntegrationPublisher.ReceivedCalls());
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task HandleEventAsync_BaseTemplateOneConfiguration_PublishesIntegrationMessage(EventMessage eventMessage)
|
||||
{
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
using Bit.Core.Models.Data;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Test.Common.AutoFixture.Attributes;
|
||||
using NSubstitute;
|
||||
using Xunit;
|
||||
|
||||
namespace Bit.Core.Test.Services;
|
||||
|
||||
[SutProviderCustomize]
|
||||
public class EventRouteServiceTests
|
||||
{
|
||||
private readonly IEventWriteService _broadcastEventWriteService = Substitute.For<IEventWriteService>();
|
||||
private readonly IEventWriteService _storageEventWriteService = Substitute.For<IEventWriteService>();
|
||||
private readonly IFeatureService _featureService = Substitute.For<IFeatureService>();
|
||||
private readonly EventRouteService Subject;
|
||||
|
||||
public EventRouteServiceTests()
|
||||
{
|
||||
Subject = new EventRouteService(_broadcastEventWriteService, _storageEventWriteService, _featureService);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task CreateAsync_FlagDisabled_EventSentToStorageService(EventMessage eventMessage)
|
||||
{
|
||||
_featureService.IsEnabled(FeatureFlagKeys.EventBasedOrganizationIntegrations).Returns(false);
|
||||
|
||||
await Subject.CreateAsync(eventMessage);
|
||||
|
||||
await _broadcastEventWriteService.DidNotReceiveWithAnyArgs().CreateAsync(Arg.Any<EventMessage>());
|
||||
await _storageEventWriteService.Received(1).CreateAsync(eventMessage);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task CreateAsync_FlagEnabled_EventSentToBroadcastService(EventMessage eventMessage)
|
||||
{
|
||||
_featureService.IsEnabled(FeatureFlagKeys.EventBasedOrganizationIntegrations).Returns(true);
|
||||
|
||||
await Subject.CreateAsync(eventMessage);
|
||||
|
||||
await _broadcastEventWriteService.Received(1).CreateAsync(eventMessage);
|
||||
await _storageEventWriteService.DidNotReceiveWithAnyArgs().CreateAsync(Arg.Any<EventMessage>());
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task CreateManyAsync_FlagDisabled_EventsSentToStorageService(IEnumerable<EventMessage> eventMessages)
|
||||
{
|
||||
_featureService.IsEnabled(FeatureFlagKeys.EventBasedOrganizationIntegrations).Returns(false);
|
||||
|
||||
await Subject.CreateManyAsync(eventMessages);
|
||||
|
||||
await _broadcastEventWriteService.DidNotReceiveWithAnyArgs().CreateManyAsync(Arg.Any<IEnumerable<EventMessage>>());
|
||||
await _storageEventWriteService.Received(1).CreateManyAsync(eventMessages);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task CreateManyAsync_FlagEnabled_EventsSentToBroadcastService(IEnumerable<EventMessage> eventMessages)
|
||||
{
|
||||
_featureService.IsEnabled(FeatureFlagKeys.EventBasedOrganizationIntegrations).Returns(true);
|
||||
|
||||
await Subject.CreateManyAsync(eventMessages);
|
||||
|
||||
await _broadcastEventWriteService.Received(1).CreateManyAsync(eventMessages);
|
||||
await _storageEventWriteService.DidNotReceiveWithAnyArgs().CreateManyAsync(Arg.Any<IEnumerable<EventMessage>>());
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,35 @@ public class IntegrationFilterServiceTests
|
||||
Assert.True(_service.EvaluateFilterGroup(roundtrippedGroup, eventMessage));
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public void EvaluateFilterGroup_EqualsUserIdString_Matches(EventMessage eventMessage)
|
||||
{
|
||||
var userId = Guid.NewGuid();
|
||||
eventMessage.UserId = userId;
|
||||
|
||||
var group = new IntegrationFilterGroup
|
||||
{
|
||||
AndOperator = true,
|
||||
Rules =
|
||||
[
|
||||
new()
|
||||
{
|
||||
Property = "UserId",
|
||||
Operation = IntegrationFilterOperation.Equals,
|
||||
Value = userId.ToString()
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var result = _service.EvaluateFilterGroup(group, eventMessage);
|
||||
Assert.True(result);
|
||||
|
||||
var jsonGroup = JsonSerializer.Serialize(group);
|
||||
var roundtrippedGroup = JsonSerializer.Deserialize<IntegrationFilterGroup>(jsonGroup);
|
||||
Assert.NotNull(roundtrippedGroup);
|
||||
Assert.True(_service.EvaluateFilterGroup(roundtrippedGroup, eventMessage));
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public void EvaluateFilterGroup_EqualsUserId_DoesNotMatch(EventMessage eventMessage)
|
||||
{
|
||||
@@ -281,6 +310,45 @@ public class IntegrationFilterServiceTests
|
||||
Assert.True(_service.EvaluateFilterGroup(roundtrippedGroup, eventMessage));
|
||||
}
|
||||
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public void EvaluateFilterGroup_NestedGroups_AnyMatch(EventMessage eventMessage)
|
||||
{
|
||||
var id = Guid.NewGuid();
|
||||
var collectionId = Guid.NewGuid();
|
||||
eventMessage.UserId = id;
|
||||
eventMessage.CollectionId = collectionId;
|
||||
|
||||
var nestedGroup = new IntegrationFilterGroup
|
||||
{
|
||||
AndOperator = false,
|
||||
Rules =
|
||||
[
|
||||
new() { Property = "UserId", Operation = IntegrationFilterOperation.Equals, Value = id },
|
||||
new()
|
||||
{
|
||||
Property = "CollectionId",
|
||||
Operation = IntegrationFilterOperation.In,
|
||||
Value = new Guid?[] { Guid.NewGuid() }
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var topGroup = new IntegrationFilterGroup
|
||||
{
|
||||
AndOperator = false,
|
||||
Groups = [nestedGroup]
|
||||
};
|
||||
|
||||
var result = _service.EvaluateFilterGroup(topGroup, eventMessage);
|
||||
Assert.True(result);
|
||||
|
||||
var jsonGroup = JsonSerializer.Serialize(topGroup);
|
||||
var roundtrippedGroup = JsonSerializer.Deserialize<IntegrationFilterGroup>(jsonGroup);
|
||||
Assert.NotNull(roundtrippedGroup);
|
||||
Assert.True(_service.EvaluateFilterGroup(roundtrippedGroup, eventMessage));
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public void EvaluateFilterGroup_UnknownProperty_ReturnsFalse(EventMessage eventMessage)
|
||||
{
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
||||
using Bit.Core.Models.Slack;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Test.Common.AutoFixture;
|
||||
using Bit.Test.Common.AutoFixture.Attributes;
|
||||
@@ -28,6 +29,9 @@ public class SlackIntegrationHandlerTests
|
||||
var sutProvider = GetSutProvider();
|
||||
message.Configuration = new SlackIntegrationConfigurationDetails(_channelId, _token);
|
||||
|
||||
_slackService.SendSlackMessageByChannelIdAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>())
|
||||
.Returns(new SlackSendMessageResponse() { Ok = true, Channel = _channelId });
|
||||
|
||||
var result = await sutProvider.Sut.HandleAsync(message);
|
||||
|
||||
Assert.True(result.Success);
|
||||
@@ -39,4 +43,97 @@ public class SlackIntegrationHandlerTests
|
||||
Arg.Is(AssertHelper.AssertPropertyEqual(_channelId))
|
||||
);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("service_unavailable")]
|
||||
[InlineData("ratelimited")]
|
||||
[InlineData("rate_limited")]
|
||||
[InlineData("internal_error")]
|
||||
[InlineData("message_limit_exceeded")]
|
||||
public async Task HandleAsync_FailedRetryableRequest_ReturnsFailureWithRetryable(string error)
|
||||
{
|
||||
var sutProvider = GetSutProvider();
|
||||
var message = new IntegrationMessage<SlackIntegrationConfigurationDetails>()
|
||||
{
|
||||
Configuration = new SlackIntegrationConfigurationDetails(_channelId, _token),
|
||||
MessageId = "MessageId",
|
||||
RenderedTemplate = "Test Message"
|
||||
};
|
||||
|
||||
_slackService.SendSlackMessageByChannelIdAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>())
|
||||
.Returns(new SlackSendMessageResponse() { Ok = false, Channel = _channelId, Error = error });
|
||||
|
||||
var result = await sutProvider.Sut.HandleAsync(message);
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.True(result.Retryable);
|
||||
Assert.NotNull(result.FailureReason);
|
||||
Assert.Equal(result.Message, message);
|
||||
|
||||
await sutProvider.GetDependency<ISlackService>().Received(1).SendSlackMessageByChannelIdAsync(
|
||||
Arg.Is(AssertHelper.AssertPropertyEqual(_token)),
|
||||
Arg.Is(AssertHelper.AssertPropertyEqual(message.RenderedTemplate)),
|
||||
Arg.Is(AssertHelper.AssertPropertyEqual(_channelId))
|
||||
);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("access_denied")]
|
||||
[InlineData("channel_not_found")]
|
||||
[InlineData("token_expired")]
|
||||
[InlineData("token_revoked")]
|
||||
public async Task HandleAsync_FailedNonRetryableRequest_ReturnsNonRetryableFailure(string error)
|
||||
{
|
||||
var sutProvider = GetSutProvider();
|
||||
var message = new IntegrationMessage<SlackIntegrationConfigurationDetails>()
|
||||
{
|
||||
Configuration = new SlackIntegrationConfigurationDetails(_channelId, _token),
|
||||
MessageId = "MessageId",
|
||||
RenderedTemplate = "Test Message"
|
||||
};
|
||||
|
||||
_slackService.SendSlackMessageByChannelIdAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>())
|
||||
.Returns(new SlackSendMessageResponse() { Ok = false, Channel = _channelId, Error = error });
|
||||
|
||||
var result = await sutProvider.Sut.HandleAsync(message);
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.False(result.Retryable);
|
||||
Assert.NotNull(result.FailureReason);
|
||||
Assert.Equal(result.Message, message);
|
||||
|
||||
await sutProvider.GetDependency<ISlackService>().Received(1).SendSlackMessageByChannelIdAsync(
|
||||
Arg.Is(AssertHelper.AssertPropertyEqual(_token)),
|
||||
Arg.Is(AssertHelper.AssertPropertyEqual(message.RenderedTemplate)),
|
||||
Arg.Is(AssertHelper.AssertPropertyEqual(_channelId))
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleAsync_NullResponse_ReturnsNonRetryableFailure()
|
||||
{
|
||||
var sutProvider = GetSutProvider();
|
||||
var message = new IntegrationMessage<SlackIntegrationConfigurationDetails>()
|
||||
{
|
||||
Configuration = new SlackIntegrationConfigurationDetails(_channelId, _token),
|
||||
MessageId = "MessageId",
|
||||
RenderedTemplate = "Test Message"
|
||||
};
|
||||
|
||||
_slackService.SendSlackMessageByChannelIdAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>())
|
||||
.Returns((SlackSendMessageResponse?)null);
|
||||
|
||||
var result = await sutProvider.Sut.HandleAsync(message);
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.False(result.Retryable);
|
||||
Assert.Equal("Slack response was null", result.FailureReason);
|
||||
Assert.Equal(result.Message, message);
|
||||
|
||||
await sutProvider.GetDependency<ISlackService>().Received(1).SendSlackMessageByChannelIdAsync(
|
||||
Arg.Is(AssertHelper.AssertPropertyEqual(_token)),
|
||||
Arg.Is(AssertHelper.AssertPropertyEqual(message.RenderedTemplate)),
|
||||
Arg.Is(AssertHelper.AssertPropertyEqual(_channelId))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,6 +146,27 @@ public class SlackServiceTests
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetChannelIdAsync_NoChannelFound_ReturnsEmptyResult()
|
||||
{
|
||||
var emptyResponse = JsonSerializer.Serialize(
|
||||
new
|
||||
{
|
||||
ok = true,
|
||||
channels = Array.Empty<string>(),
|
||||
response_metadata = new { next_cursor = "" }
|
||||
});
|
||||
|
||||
_handler.When(HttpMethod.Get)
|
||||
.RespondWith(HttpStatusCode.OK)
|
||||
.WithContent(new StringContent(emptyResponse));
|
||||
|
||||
var sutProvider = GetSutProvider();
|
||||
var result = await sutProvider.Sut.GetChannelIdAsync(_token, "general");
|
||||
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetChannelIdAsync_ReturnsCorrectChannelId()
|
||||
{
|
||||
@@ -235,6 +256,32 @@ public class SlackServiceTests
|
||||
Assert.Equal(string.Empty, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetDmChannelByEmailAsync_ApiErrorUnparsableDmResponse_ReturnsEmptyString()
|
||||
{
|
||||
var sutProvider = GetSutProvider();
|
||||
var email = "user@example.com";
|
||||
var userId = "U12345";
|
||||
|
||||
var userResponse = new
|
||||
{
|
||||
ok = true,
|
||||
user = new { id = userId }
|
||||
};
|
||||
|
||||
_handler.When($"https://slack.com/api/users.lookupByEmail?email={email}")
|
||||
.RespondWith(HttpStatusCode.OK)
|
||||
.WithContent(new StringContent(JsonSerializer.Serialize(userResponse)));
|
||||
|
||||
_handler.When("https://slack.com/api/conversations.open")
|
||||
.RespondWith(HttpStatusCode.OK)
|
||||
.WithContent(new StringContent("NOT JSON"));
|
||||
|
||||
var result = await sutProvider.Sut.GetDmChannelByEmailAsync(_token, email);
|
||||
|
||||
Assert.Equal(string.Empty, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetDmChannelByEmailAsync_ApiErrorUserResponse_ReturnsEmptyString()
|
||||
{
|
||||
@@ -244,7 +291,7 @@ public class SlackServiceTests
|
||||
var userResponse = new
|
||||
{
|
||||
ok = false,
|
||||
error = "An error occured"
|
||||
error = "An error occurred"
|
||||
};
|
||||
|
||||
_handler.When($"https://slack.com/api/users.lookupByEmail?email={email}")
|
||||
@@ -256,6 +303,21 @@ public class SlackServiceTests
|
||||
Assert.Equal(string.Empty, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetDmChannelByEmailAsync_ApiErrorUnparsableUserResponse_ReturnsEmptyString()
|
||||
{
|
||||
var sutProvider = GetSutProvider();
|
||||
var email = "user@example.com";
|
||||
|
||||
_handler.When($"https://slack.com/api/users.lookupByEmail?email={email}")
|
||||
.RespondWith(HttpStatusCode.OK)
|
||||
.WithContent(new StringContent("Not JSON"));
|
||||
|
||||
var result = await sutProvider.Sut.GetDmChannelByEmailAsync(_token, email);
|
||||
|
||||
Assert.Equal(string.Empty, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetRedirectUrl_ReturnsCorrectUrl()
|
||||
{
|
||||
@@ -341,18 +403,29 @@ public class SlackServiceTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendSlackMessageByChannelId_Sends_Correct_Message()
|
||||
public async Task SendSlackMessageByChannelId_Success_ReturnsSuccessfulResponse()
|
||||
{
|
||||
var sutProvider = GetSutProvider();
|
||||
var channelId = "C12345";
|
||||
var message = "Hello, Slack!";
|
||||
|
||||
var jsonResponse = JsonSerializer.Serialize(new
|
||||
{
|
||||
ok = true,
|
||||
channel = channelId,
|
||||
});
|
||||
|
||||
_handler.When(HttpMethod.Post)
|
||||
.RespondWith(HttpStatusCode.OK)
|
||||
.WithContent(new StringContent(string.Empty));
|
||||
.WithContent(new StringContent(jsonResponse));
|
||||
|
||||
await sutProvider.Sut.SendSlackMessageByChannelIdAsync(_token, message, channelId);
|
||||
var result = await sutProvider.Sut.SendSlackMessageByChannelIdAsync(_token, message, channelId);
|
||||
|
||||
// Response was parsed correctly
|
||||
Assert.NotNull(result);
|
||||
Assert.True(result.Ok);
|
||||
|
||||
// Request was sent correctly
|
||||
Assert.Single(_handler.CapturedRequests);
|
||||
var request = _handler.CapturedRequests[0];
|
||||
Assert.NotNull(request);
|
||||
@@ -365,4 +438,62 @@ public class SlackServiceTests
|
||||
Assert.Equal(message, json.RootElement.GetProperty("text").GetString() ?? string.Empty);
|
||||
Assert.Equal(channelId, json.RootElement.GetProperty("channel").GetString() ?? string.Empty);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendSlackMessageByChannelId_Failure_ReturnsErrorResponse()
|
||||
{
|
||||
var sutProvider = GetSutProvider();
|
||||
var channelId = "C12345";
|
||||
var message = "Hello, Slack!";
|
||||
|
||||
var jsonResponse = JsonSerializer.Serialize(new
|
||||
{
|
||||
ok = false,
|
||||
channel = channelId,
|
||||
error = "error"
|
||||
});
|
||||
|
||||
_handler.When(HttpMethod.Post)
|
||||
.RespondWith(HttpStatusCode.OK)
|
||||
.WithContent(new StringContent(jsonResponse));
|
||||
|
||||
var result = await sutProvider.Sut.SendSlackMessageByChannelIdAsync(_token, message, channelId);
|
||||
|
||||
// Response was parsed correctly
|
||||
Assert.NotNull(result);
|
||||
Assert.False(result.Ok);
|
||||
Assert.NotNull(result.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendSlackMessageByChannelIdAsync_InvalidJson_ReturnsNull()
|
||||
{
|
||||
var sutProvider = GetSutProvider();
|
||||
var channelId = "C12345";
|
||||
var message = "Hello world!";
|
||||
|
||||
_handler.When(HttpMethod.Post)
|
||||
.RespondWith(HttpStatusCode.OK)
|
||||
.WithContent(new StringContent("Not JSON"));
|
||||
|
||||
var result = await sutProvider.Sut.SendSlackMessageByChannelIdAsync(_token, message, channelId);
|
||||
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendSlackMessageByChannelIdAsync_HttpServerError_ReturnsNull()
|
||||
{
|
||||
var sutProvider = GetSutProvider();
|
||||
var channelId = "C12345";
|
||||
var message = "Hello world!";
|
||||
|
||||
_handler.When(HttpMethod.Post)
|
||||
.RespondWith(HttpStatusCode.InternalServerError)
|
||||
.WithContent(new StringContent(string.Empty));
|
||||
|
||||
var result = await sutProvider.Sut.SendSlackMessageByChannelIdAsync(_token, message, channelId);
|
||||
|
||||
Assert.Null(result);
|
||||
}
|
||||
}
|
||||
|
||||
527
test/Core.Test/Billing/Pricing/PricingClientTests.cs
Normal file
527
test/Core.Test/Billing/Pricing/PricingClientTests.cs
Normal file
@@ -0,0 +1,527 @@
|
||||
using System.Net;
|
||||
using Bit.Core.Billing;
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Billing.Pricing;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Utilities;
|
||||
using Bit.Test.Common.AutoFixture;
|
||||
using Bit.Test.Common.AutoFixture.Attributes;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NSubstitute;
|
||||
using RichardSzalay.MockHttp;
|
||||
using Xunit;
|
||||
using GlobalSettings = Bit.Core.Settings.GlobalSettings;
|
||||
|
||||
namespace Bit.Core.Test.Billing.Pricing;
|
||||
|
||||
[SutProviderCustomize]
|
||||
public class PricingClientTests
|
||||
{
|
||||
#region GetLookupKey Tests (via GetPlan)
|
||||
|
||||
[Fact]
|
||||
public async Task GetPlan_WithFamiliesAnnually2025AndFeatureFlagEnabled_UsesFamilies2025LookupKey()
|
||||
{
|
||||
// Arrange
|
||||
var mockHttp = new MockHttpMessageHandler();
|
||||
var planJson = CreatePlanJson("families-2025", "Families 2025", "families", 40M, "price_id");
|
||||
|
||||
mockHttp.Expect(HttpMethod.Get, "https://test.com/plans/organization/families-2025")
|
||||
.Respond("application/json", planJson);
|
||||
|
||||
mockHttp.When(HttpMethod.Get, "*/plans/organization/*")
|
||||
.Respond("application/json", planJson);
|
||||
|
||||
var featureService = Substitute.For<IFeatureService>();
|
||||
featureService.IsEnabled(FeatureFlagKeys.PM26462_Milestone_3).Returns(true);
|
||||
featureService.IsEnabled(FeatureFlagKeys.UsePricingService).Returns(true);
|
||||
|
||||
var globalSettings = new GlobalSettings { SelfHosted = false };
|
||||
|
||||
var httpClient = new HttpClient(mockHttp)
|
||||
{
|
||||
BaseAddress = new Uri("https://test.com/")
|
||||
};
|
||||
|
||||
var logger = Substitute.For<ILogger<PricingClient>>();
|
||||
var pricingClient = new PricingClient(featureService, globalSettings, httpClient, logger);
|
||||
|
||||
// Act
|
||||
var result = await pricingClient.GetPlan(PlanType.FamiliesAnnually2025);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(PlanType.FamiliesAnnually2025, result.Type);
|
||||
mockHttp.VerifyNoOutstandingExpectation();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetPlan_WithFamiliesAnnually2025AndFeatureFlagDisabled_UsesFamiliesLookupKey()
|
||||
{
|
||||
// Arrange
|
||||
var mockHttp = new MockHttpMessageHandler();
|
||||
var planJson = CreatePlanJson("families", "Families", "families", 40M, "price_id");
|
||||
|
||||
mockHttp.Expect(HttpMethod.Get, "https://test.com/plans/organization/families")
|
||||
.Respond("application/json", planJson);
|
||||
|
||||
mockHttp.When(HttpMethod.Get, "*/plans/organization/*")
|
||||
.Respond("application/json", planJson);
|
||||
|
||||
var featureService = Substitute.For<IFeatureService>();
|
||||
featureService.IsEnabled(FeatureFlagKeys.PM26462_Milestone_3).Returns(false);
|
||||
featureService.IsEnabled(FeatureFlagKeys.UsePricingService).Returns(true);
|
||||
|
||||
var globalSettings = new GlobalSettings { SelfHosted = false };
|
||||
|
||||
var httpClient = new HttpClient(mockHttp)
|
||||
{
|
||||
BaseAddress = new Uri("https://test.com/")
|
||||
};
|
||||
|
||||
var logger = Substitute.For<ILogger<PricingClient>>();
|
||||
var pricingClient = new PricingClient(featureService, globalSettings, httpClient, logger);
|
||||
|
||||
// Act
|
||||
var result = await pricingClient.GetPlan(PlanType.FamiliesAnnually2025);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
// PreProcessFamiliesPreMigrationPlan should change "families" to "families-2025" when FF is disabled
|
||||
Assert.Equal(PlanType.FamiliesAnnually2025, result.Type);
|
||||
mockHttp.VerifyNoOutstandingExpectation();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region PreProcessFamiliesPreMigrationPlan Tests (via GetPlan)
|
||||
|
||||
[Fact]
|
||||
public async Task GetPlan_WithFamiliesAnnually2025AndFeatureFlagDisabled_ReturnsFamiliesAnnually2025PlanType()
|
||||
{
|
||||
// Arrange
|
||||
var mockHttp = new MockHttpMessageHandler();
|
||||
// billing-pricing returns "families" lookup key because the flag is off
|
||||
var planJson = CreatePlanJson("families", "Families", "families", 40M, "price_id");
|
||||
|
||||
mockHttp.When(HttpMethod.Get, "*/plans/organization/*")
|
||||
.Respond("application/json", planJson);
|
||||
|
||||
var featureService = Substitute.For<IFeatureService>();
|
||||
featureService.IsEnabled(FeatureFlagKeys.PM26462_Milestone_3).Returns(false);
|
||||
featureService.IsEnabled(FeatureFlagKeys.UsePricingService).Returns(true);
|
||||
|
||||
var globalSettings = new GlobalSettings { SelfHosted = false };
|
||||
|
||||
var httpClient = new HttpClient(mockHttp)
|
||||
{
|
||||
BaseAddress = new Uri("https://test.com/")
|
||||
};
|
||||
|
||||
var logger = Substitute.For<ILogger<PricingClient>>();
|
||||
var pricingClient = new PricingClient(featureService, globalSettings, httpClient, logger);
|
||||
|
||||
// Act
|
||||
var result = await pricingClient.GetPlan(PlanType.FamiliesAnnually2025);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
// PreProcessFamiliesPreMigrationPlan should convert the families lookup key to families-2025
|
||||
// and the PlanAdapter should assign the correct FamiliesAnnually2025 plan type
|
||||
Assert.Equal(PlanType.FamiliesAnnually2025, result.Type);
|
||||
mockHttp.VerifyNoOutstandingExpectation();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetPlan_WithFamiliesAnnually2025AndFeatureFlagEnabled_ReturnsFamiliesAnnually2025PlanType()
|
||||
{
|
||||
// Arrange
|
||||
var mockHttp = new MockHttpMessageHandler();
|
||||
var planJson = CreatePlanJson("families-2025", "Families", "families", 40M, "price_id");
|
||||
|
||||
mockHttp.When(HttpMethod.Get, "*/plans/organization/*")
|
||||
.Respond("application/json", planJson);
|
||||
|
||||
var featureService = Substitute.For<IFeatureService>();
|
||||
featureService.IsEnabled(FeatureFlagKeys.PM26462_Milestone_3).Returns(true);
|
||||
featureService.IsEnabled(FeatureFlagKeys.UsePricingService).Returns(true);
|
||||
|
||||
var globalSettings = new GlobalSettings { SelfHosted = false };
|
||||
|
||||
var httpClient = new HttpClient(mockHttp)
|
||||
{
|
||||
BaseAddress = new Uri("https://test.com/")
|
||||
};
|
||||
|
||||
var logger = Substitute.For<ILogger<PricingClient>>();
|
||||
var pricingClient = new PricingClient(featureService, globalSettings, httpClient, logger);
|
||||
|
||||
// Act
|
||||
var result = await pricingClient.GetPlan(PlanType.FamiliesAnnually2025);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
// PreProcessFamiliesPreMigrationPlan should ignore the lookup key because the flag is on
|
||||
// and the PlanAdapter should assign the correct FamiliesAnnually2025 plan type
|
||||
Assert.Equal(PlanType.FamiliesAnnually2025, result.Type);
|
||||
mockHttp.VerifyNoOutstandingExpectation();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetPlan_WithFamiliesAnnuallyAndFeatureFlagEnabled_ReturnsFamiliesAnnuallyPlanType()
|
||||
{
|
||||
// Arrange
|
||||
var mockHttp = new MockHttpMessageHandler();
|
||||
var planJson = CreatePlanJson("families", "Families", "families", 40M, "price_id");
|
||||
|
||||
mockHttp.When(HttpMethod.Get, "*/plans/organization/*")
|
||||
.Respond("application/json", planJson);
|
||||
|
||||
var featureService = Substitute.For<IFeatureService>();
|
||||
featureService.IsEnabled(FeatureFlagKeys.PM26462_Milestone_3).Returns(true);
|
||||
featureService.IsEnabled(FeatureFlagKeys.UsePricingService).Returns(true);
|
||||
|
||||
var globalSettings = new GlobalSettings { SelfHosted = false };
|
||||
|
||||
var httpClient = new HttpClient(mockHttp)
|
||||
{
|
||||
BaseAddress = new Uri("https://test.com/")
|
||||
};
|
||||
|
||||
var logger = Substitute.For<ILogger<PricingClient>>();
|
||||
var pricingClient = new PricingClient(featureService, globalSettings, httpClient, logger);
|
||||
|
||||
// Act
|
||||
var result = await pricingClient.GetPlan(PlanType.FamiliesAnnually);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
// PreProcessFamiliesPreMigrationPlan should ignore the lookup key because the flag is on
|
||||
// and the PlanAdapter should assign the correct FamiliesAnnually plan type
|
||||
Assert.Equal(PlanType.FamiliesAnnually, result.Type);
|
||||
mockHttp.VerifyNoOutstandingExpectation();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetPlan_WithOtherLookupKey_KeepsLookupKeyUnchanged()
|
||||
{
|
||||
// Arrange
|
||||
var mockHttp = new MockHttpMessageHandler();
|
||||
var planJson = CreatePlanJson("enterprise-annually", "Enterprise", "enterprise", 144M, "price_id");
|
||||
|
||||
mockHttp.Expect(HttpMethod.Get, "https://test.com/plans/organization/enterprise-annually")
|
||||
.Respond("application/json", planJson);
|
||||
|
||||
mockHttp.When(HttpMethod.Get, "*/plans/organization/*")
|
||||
.Respond("application/json", planJson);
|
||||
|
||||
var featureService = Substitute.For<IFeatureService>();
|
||||
featureService.IsEnabled(FeatureFlagKeys.PM26462_Milestone_3).Returns(false);
|
||||
featureService.IsEnabled(FeatureFlagKeys.UsePricingService).Returns(true);
|
||||
|
||||
var globalSettings = new GlobalSettings { SelfHosted = false };
|
||||
|
||||
var httpClient = new HttpClient(mockHttp)
|
||||
{
|
||||
BaseAddress = new Uri("https://test.com/")
|
||||
};
|
||||
|
||||
var logger = Substitute.For<ILogger<PricingClient>>();
|
||||
var pricingClient = new PricingClient(featureService, globalSettings, httpClient, logger);
|
||||
|
||||
// Act
|
||||
var result = await pricingClient.GetPlan(PlanType.EnterpriseAnnually);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(PlanType.EnterpriseAnnually, result.Type);
|
||||
mockHttp.VerifyNoOutstandingExpectation();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ListPlans Tests
|
||||
|
||||
[Fact]
|
||||
public async Task ListPlans_WithFeatureFlagDisabled_ReturnsListWithPreProcessing()
|
||||
{
|
||||
// Arrange
|
||||
var mockHttp = new MockHttpMessageHandler();
|
||||
// biling-pricing would return "families" because the flag is disabled
|
||||
var plansJson = $@"[
|
||||
{CreatePlanJson("families", "Families", "families", 40M, "price_id")},
|
||||
{CreatePlanJson("enterprise-annually", "Enterprise", "enterprise", 144M, "price_id")}
|
||||
]";
|
||||
|
||||
mockHttp.When(HttpMethod.Get, "*/plans/organization")
|
||||
.Respond("application/json", plansJson);
|
||||
|
||||
var featureService = Substitute.For<IFeatureService>();
|
||||
featureService.IsEnabled(FeatureFlagKeys.PM26462_Milestone_3).Returns(false);
|
||||
featureService.IsEnabled(FeatureFlagKeys.UsePricingService).Returns(true);
|
||||
|
||||
var globalSettings = new GlobalSettings { SelfHosted = false };
|
||||
|
||||
var httpClient = new HttpClient(mockHttp)
|
||||
{
|
||||
BaseAddress = new Uri("https://test.com/")
|
||||
};
|
||||
|
||||
var logger = Substitute.For<ILogger<PricingClient>>();
|
||||
var pricingClient = new PricingClient(featureService, globalSettings, httpClient, logger);
|
||||
|
||||
// Act
|
||||
var result = await pricingClient.ListPlans();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(2, result.Count);
|
||||
// First plan should have been preprocessed from "families" to "families-2025"
|
||||
Assert.Equal(PlanType.FamiliesAnnually2025, result[0].Type);
|
||||
// Second plan should remain unchanged
|
||||
Assert.Equal(PlanType.EnterpriseAnnually, result[1].Type);
|
||||
mockHttp.VerifyNoOutstandingExpectation();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListPlans_WithFeatureFlagEnabled_ReturnsListWithoutPreProcessing()
|
||||
{
|
||||
// Arrange
|
||||
var mockHttp = new MockHttpMessageHandler();
|
||||
var plansJson = $@"[
|
||||
{CreatePlanJson("families", "Families", "families", 40M, "price_id")}
|
||||
]";
|
||||
|
||||
mockHttp.When(HttpMethod.Get, "*/plans/organization")
|
||||
.Respond("application/json", plansJson);
|
||||
|
||||
var featureService = Substitute.For<IFeatureService>();
|
||||
featureService.IsEnabled(FeatureFlagKeys.PM26462_Milestone_3).Returns(true);
|
||||
featureService.IsEnabled(FeatureFlagKeys.UsePricingService).Returns(true);
|
||||
|
||||
var globalSettings = new GlobalSettings { SelfHosted = false };
|
||||
|
||||
var httpClient = new HttpClient(mockHttp)
|
||||
{
|
||||
BaseAddress = new Uri("https://test.com/")
|
||||
};
|
||||
|
||||
var logger = Substitute.For<ILogger<PricingClient>>();
|
||||
var pricingClient = new PricingClient(featureService, globalSettings, httpClient, logger);
|
||||
|
||||
// Act
|
||||
var result = await pricingClient.ListPlans();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Single(result);
|
||||
// Plan should remain as FamiliesAnnually when FF is enabled
|
||||
Assert.Equal(PlanType.FamiliesAnnually, result[0].Type);
|
||||
mockHttp.VerifyNoOutstandingExpectation();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetPlan - Additional Coverage
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task GetPlan_WhenSelfHosted_ReturnsNull(
|
||||
SutProvider<PricingClient> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
var globalSettings = sutProvider.GetDependency<GlobalSettings>();
|
||||
globalSettings.SelfHosted = true;
|
||||
|
||||
// Act
|
||||
var result = await sutProvider.Sut.GetPlan(PlanType.FamiliesAnnually2025);
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task GetPlan_WhenPricingServiceDisabled_ReturnsStaticStorePlan(
|
||||
SutProvider<PricingClient> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
sutProvider.GetDependency<GlobalSettings>().SelfHosted = false;
|
||||
|
||||
sutProvider.GetDependency<IFeatureService>()
|
||||
.IsEnabled(FeatureFlagKeys.UsePricingService)
|
||||
.Returns(false);
|
||||
|
||||
// Act
|
||||
var result = await sutProvider.Sut.GetPlan(PlanType.FamiliesAnnually);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(PlanType.FamiliesAnnually, result.Type);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task GetPlan_WhenLookupKeyNotFound_ReturnsNull(
|
||||
SutProvider<PricingClient> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
sutProvider.GetDependency<IFeatureService>()
|
||||
.IsEnabled(FeatureFlagKeys.UsePricingService)
|
||||
.Returns(true);
|
||||
|
||||
// Act - Using PlanType that doesn't have a lookup key mapping
|
||||
var result = await sutProvider.Sut.GetPlan(unchecked((PlanType)999));
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetPlan_WhenPricingServiceReturnsNotFound_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
var mockHttp = new MockHttpMessageHandler();
|
||||
mockHttp.When(HttpMethod.Get, "*/plans/organization/*")
|
||||
.Respond(HttpStatusCode.NotFound);
|
||||
|
||||
var featureService = Substitute.For<IFeatureService>();
|
||||
featureService.IsEnabled(FeatureFlagKeys.PM26462_Milestone_3).Returns(true);
|
||||
featureService.IsEnabled(FeatureFlagKeys.UsePricingService).Returns(true);
|
||||
|
||||
var globalSettings = new GlobalSettings { SelfHosted = false };
|
||||
|
||||
var httpClient = new HttpClient(mockHttp)
|
||||
{
|
||||
BaseAddress = new Uri("https://test.com/")
|
||||
};
|
||||
|
||||
var logger = Substitute.For<ILogger<PricingClient>>();
|
||||
var pricingClient = new PricingClient(featureService, globalSettings, httpClient, logger);
|
||||
|
||||
// Act
|
||||
var result = await pricingClient.GetPlan(PlanType.FamiliesAnnually2025);
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetPlan_WhenPricingServiceReturnsError_ThrowsBillingException()
|
||||
{
|
||||
// Arrange
|
||||
var mockHttp = new MockHttpMessageHandler();
|
||||
mockHttp.When(HttpMethod.Get, "*/plans/organization/*")
|
||||
.Respond(HttpStatusCode.InternalServerError);
|
||||
|
||||
var featureService = Substitute.For<IFeatureService>();
|
||||
featureService.IsEnabled(FeatureFlagKeys.PM26462_Milestone_3).Returns(true);
|
||||
featureService.IsEnabled(FeatureFlagKeys.UsePricingService).Returns(true);
|
||||
|
||||
var globalSettings = new GlobalSettings { SelfHosted = false };
|
||||
|
||||
var httpClient = new HttpClient(mockHttp)
|
||||
{
|
||||
BaseAddress = new Uri("https://test.com/")
|
||||
};
|
||||
|
||||
var logger = Substitute.For<ILogger<PricingClient>>();
|
||||
var pricingClient = new PricingClient(featureService, globalSettings, httpClient, logger);
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<BillingException>(() =>
|
||||
pricingClient.GetPlan(PlanType.FamiliesAnnually2025));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ListPlans - Additional Coverage
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task ListPlans_WhenSelfHosted_ReturnsEmptyList(
|
||||
SutProvider<PricingClient> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
var globalSettings = sutProvider.GetDependency<GlobalSettings>();
|
||||
globalSettings.SelfHosted = true;
|
||||
|
||||
// Act
|
||||
var result = await sutProvider.Sut.ListPlans();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task ListPlans_WhenPricingServiceDisabled_ReturnsStaticStorePlans(
|
||||
SutProvider<PricingClient> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
sutProvider.GetDependency<GlobalSettings>().SelfHosted = false;
|
||||
|
||||
sutProvider.GetDependency<IFeatureService>()
|
||||
.IsEnabled(FeatureFlagKeys.UsePricingService)
|
||||
.Returns(false);
|
||||
|
||||
// Act
|
||||
var result = await sutProvider.Sut.ListPlans();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.NotEmpty(result);
|
||||
Assert.Equal(StaticStore.Plans.Count(), result.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListPlans_WhenPricingServiceReturnsError_ThrowsBillingException()
|
||||
{
|
||||
// Arrange
|
||||
var mockHttp = new MockHttpMessageHandler();
|
||||
mockHttp.When(HttpMethod.Get, "*/plans/organization")
|
||||
.Respond(HttpStatusCode.InternalServerError);
|
||||
|
||||
var featureService = Substitute.For<IFeatureService>();
|
||||
featureService.IsEnabled(FeatureFlagKeys.UsePricingService).Returns(true);
|
||||
|
||||
var globalSettings = new GlobalSettings { SelfHosted = false };
|
||||
|
||||
var httpClient = new HttpClient(mockHttp)
|
||||
{
|
||||
BaseAddress = new Uri("https://test.com/")
|
||||
};
|
||||
|
||||
var logger = Substitute.For<ILogger<PricingClient>>();
|
||||
var pricingClient = new PricingClient(featureService, globalSettings, httpClient, logger);
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<BillingException>(() =>
|
||||
pricingClient.ListPlans());
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
private static string CreatePlanJson(
|
||||
string lookupKey,
|
||||
string name,
|
||||
string tier,
|
||||
decimal seatsPrice,
|
||||
string seatsStripePriceId,
|
||||
int seatsQuantity = 1)
|
||||
{
|
||||
return $@"{{
|
||||
""lookupKey"": ""{lookupKey}"",
|
||||
""name"": ""{name}"",
|
||||
""tier"": ""{tier}"",
|
||||
""features"": [],
|
||||
""seats"": {{
|
||||
""type"": ""packaged"",
|
||||
""quantity"": {seatsQuantity},
|
||||
""price"": {seatsPrice},
|
||||
""stripePriceId"": ""{seatsStripePriceId}""
|
||||
}},
|
||||
""canUpgradeTo"": [],
|
||||
""additionalData"": {{
|
||||
""nameLocalizationKey"": ""{lookupKey}Name"",
|
||||
""descriptionLocalizationKey"": ""{lookupKey}Description""
|
||||
}}
|
||||
}}";
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,7 @@ public class SecretsManagerSubscriptionUpdateTests
|
||||
}
|
||||
|
||||
public static TheoryData<Plan> NonSmPlans =>
|
||||
ToPlanTheory([PlanType.Custom, PlanType.FamiliesAnnually, PlanType.FamiliesAnnually2019]);
|
||||
ToPlanTheory([PlanType.Custom, PlanType.FamiliesAnnually, PlanType.FamiliesAnnually2025, PlanType.FamiliesAnnually2019]);
|
||||
|
||||
public static TheoryData<Plan> SmPlans => ToPlanTheory([
|
||||
PlanType.EnterpriseAnnually2019,
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
using Bit.Core.Platform.Mail.Mailer;
|
||||
using Bit.Core.Settings;
|
||||
using Bit.Core.Test.Platform.Mailer.TestMail;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NSubstitute;
|
||||
using Xunit;
|
||||
|
||||
namespace Bit.Core.Test.Platform.Mailer;
|
||||
@@ -9,7 +12,10 @@ public class HandlebarMailRendererTests
|
||||
[Fact]
|
||||
public async Task RenderAsync_ReturnsExpectedHtmlAndTxt()
|
||||
{
|
||||
var renderer = new HandlebarMailRenderer();
|
||||
var logger = Substitute.For<ILogger<HandlebarMailRenderer>>();
|
||||
var globalSettings = new GlobalSettings { SelfHosted = false };
|
||||
var renderer = new HandlebarMailRenderer(logger, globalSettings);
|
||||
|
||||
var view = new TestMailView { Name = "John Smith" };
|
||||
|
||||
var (html, txt) = await renderer.RenderAsync(view);
|
||||
@@ -17,4 +23,150 @@ public class HandlebarMailRendererTests
|
||||
Assert.Equal("Hello <b>John Smith</b>", html.Trim());
|
||||
Assert.Equal("Hello John Smith", txt.Trim());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RenderAsync_LoadsFromDisk_WhenSelfHostedAndFileExists()
|
||||
{
|
||||
var logger = Substitute.For<ILogger<HandlebarMailRenderer>>();
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
|
||||
Directory.CreateDirectory(tempDir);
|
||||
|
||||
try
|
||||
{
|
||||
var globalSettings = new GlobalSettings
|
||||
{
|
||||
SelfHosted = true,
|
||||
MailTemplateDirectory = tempDir
|
||||
};
|
||||
|
||||
// Create test template files on disk
|
||||
var htmlTemplatePath = Path.Combine(tempDir, "Bit.Core.Test.Platform.Mailer.TestMail.TestMailView.html.hbs");
|
||||
var txtTemplatePath = Path.Combine(tempDir, "Bit.Core.Test.Platform.Mailer.TestMail.TestMailView.text.hbs");
|
||||
await File.WriteAllTextAsync(htmlTemplatePath, "Custom HTML: <b>{{Name}}</b>");
|
||||
await File.WriteAllTextAsync(txtTemplatePath, "Custom TXT: {{Name}}");
|
||||
|
||||
var renderer = new HandlebarMailRenderer(logger, globalSettings);
|
||||
var view = new TestMailView { Name = "Jane Doe" };
|
||||
|
||||
var (html, txt) = await renderer.RenderAsync(view);
|
||||
|
||||
Assert.Equal("Custom HTML: <b>Jane Doe</b>", html.Trim());
|
||||
Assert.Equal("Custom TXT: Jane Doe", txt.Trim());
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Cleanup
|
||||
if (Directory.Exists(tempDir))
|
||||
{
|
||||
Directory.Delete(tempDir, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("../../../etc/passwd")]
|
||||
[InlineData("../../../../malicious.txt")]
|
||||
[InlineData("../../malicious.txt")]
|
||||
[InlineData("../malicious.txt")]
|
||||
public async Task ReadSourceFromDiskAsync_PrevenetsPathTraversal_WhenMaliciousPathProvided(string maliciousPath)
|
||||
{
|
||||
var logger = Substitute.For<ILogger<HandlebarMailRenderer>>();
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
|
||||
Directory.CreateDirectory(tempDir);
|
||||
|
||||
try
|
||||
{
|
||||
var globalSettings = new GlobalSettings
|
||||
{
|
||||
SelfHosted = true,
|
||||
MailTemplateDirectory = tempDir
|
||||
};
|
||||
|
||||
// Create a malicious file outside the template directory
|
||||
var maliciousFile = Path.Combine(Path.GetTempPath(), "malicious.txt");
|
||||
await File.WriteAllTextAsync(maliciousFile, "Malicious Content");
|
||||
|
||||
var renderer = new HandlebarMailRenderer(logger, globalSettings);
|
||||
|
||||
// Use reflection to call the private ReadSourceFromDiskAsync method
|
||||
var method = typeof(HandlebarMailRenderer).GetMethod("ReadSourceFromDiskAsync",
|
||||
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
|
||||
var task = (Task<string?>)method!.Invoke(renderer, new object[] { maliciousPath })!;
|
||||
var result = await task;
|
||||
|
||||
// Should return null and not load the malicious file
|
||||
Assert.Null(result);
|
||||
|
||||
// Verify that a warning was logged for the path traversal attempt
|
||||
logger.Received(1).Log(
|
||||
LogLevel.Warning,
|
||||
Arg.Any<EventId>(),
|
||||
Arg.Any<object>(),
|
||||
Arg.Any<Exception>(),
|
||||
Arg.Any<Func<object, Exception, string>>());
|
||||
|
||||
// Cleanup malicious file
|
||||
if (File.Exists(maliciousFile))
|
||||
{
|
||||
File.Delete(maliciousFile);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Cleanup
|
||||
if (Directory.Exists(tempDir))
|
||||
{
|
||||
Directory.Delete(tempDir, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadSourceFromDiskAsync_AllowsValidFileWithDifferentCase_WhenCaseInsensitiveFileSystem()
|
||||
{
|
||||
var logger = Substitute.For<ILogger<HandlebarMailRenderer>>();
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
|
||||
Directory.CreateDirectory(tempDir);
|
||||
|
||||
try
|
||||
{
|
||||
var globalSettings = new GlobalSettings
|
||||
{
|
||||
SelfHosted = true,
|
||||
MailTemplateDirectory = tempDir
|
||||
};
|
||||
|
||||
// Create a test template file
|
||||
var templateFileName = "TestTemplate.hbs";
|
||||
var templatePath = Path.Combine(tempDir, templateFileName);
|
||||
await File.WriteAllTextAsync(templatePath, "Test Content");
|
||||
|
||||
var renderer = new HandlebarMailRenderer(logger, globalSettings);
|
||||
|
||||
// Try to read with different case (should work on case-insensitive file systems like Windows/macOS)
|
||||
var method = typeof(HandlebarMailRenderer).GetMethod("ReadSourceFromDiskAsync",
|
||||
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
|
||||
var task = (Task<string?>)method!.Invoke(renderer, new object[] { templateFileName })!;
|
||||
var result = await task;
|
||||
|
||||
// Should successfully read the file
|
||||
Assert.Equal("Test Content", result);
|
||||
|
||||
// Verify no warning was logged
|
||||
logger.DidNotReceive().Log(
|
||||
LogLevel.Warning,
|
||||
Arg.Any<EventId>(),
|
||||
Arg.Any<object>(),
|
||||
Arg.Any<Exception>(),
|
||||
Arg.Any<Func<object, Exception, string>>());
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Cleanup
|
||||
if (Directory.Exists(tempDir))
|
||||
{
|
||||
Directory.Delete(tempDir, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,24 @@
|
||||
using Bit.Core.Models.Mail;
|
||||
using Bit.Core.Platform.Mail.Delivery;
|
||||
using Bit.Core.Platform.Mail.Mailer;
|
||||
using Bit.Core.Settings;
|
||||
using Bit.Core.Test.Platform.Mailer.TestMail;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NSubstitute;
|
||||
using Xunit;
|
||||
|
||||
namespace Bit.Core.Test.Platform.Mailer;
|
||||
|
||||
public class MailerTest
|
||||
{
|
||||
[Fact]
|
||||
public async Task SendEmailAsync()
|
||||
{
|
||||
var logger = Substitute.For<ILogger<HandlebarMailRenderer>>();
|
||||
var globalSettings = new GlobalSettings { SelfHosted = false };
|
||||
var deliveryService = Substitute.For<IMailDeliveryService>();
|
||||
var mailer = new Core.Platform.Mail.Mailer.Mailer(new HandlebarMailRenderer(), deliveryService);
|
||||
|
||||
var mailer = new Core.Platform.Mail.Mailer.Mailer(new HandlebarMailRenderer(logger, globalSettings), deliveryService);
|
||||
|
||||
var mail = new TestMail.TestMail()
|
||||
{
|
||||
|
||||
@@ -13,7 +13,7 @@ public class StaticStoreTests
|
||||
var plans = StaticStore.Plans.ToList();
|
||||
Assert.NotNull(plans);
|
||||
Assert.NotEmpty(plans);
|
||||
Assert.Equal(22, plans.Count);
|
||||
Assert.Equal(23, plans.Count);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
@@ -34,8 +34,8 @@ public class StaticStoreTests
|
||||
{
|
||||
// Ref: https://daniel.haxx.se/blog/2025/05/16/detecting-malicious-unicode/
|
||||
// URLs can contain unicode characters that to a computer would point to completely seperate domains but to the
|
||||
// naked eye look completely identical. For example 'g' and 'ց' look incredibly similar but when included in a
|
||||
// URL would lead you somewhere different. There is an opening for an attacker to contribute to Bitwarden with a
|
||||
// naked eye look completely identical. For example 'g' and 'ց' look incredibly similar but when included in a
|
||||
// URL would lead you somewhere different. There is an opening for an attacker to contribute to Bitwarden with a
|
||||
// url update that could be missed in code review and then if they got a user to that URL Bitwarden could
|
||||
// consider it equivalent with a cipher in the users vault and offer autofill when we should not.
|
||||
// GitHub does now show a warning on non-ascii characters but it could still be missed.
|
||||
|
||||
@@ -1484,6 +1484,8 @@ public class OrganizationUserRepositoryTests
|
||||
var organization = await organizationRepository.CreateTestOrganizationAsync();
|
||||
var user = await userRepository.CreateTestUserAsync();
|
||||
var orgUser = await organizationUserRepository.CreateAcceptedTestOrganizationUserAsync(organization, user);
|
||||
const string key = "test-key";
|
||||
orgUser.Key = key;
|
||||
|
||||
// Act
|
||||
var result = await organizationUserRepository.ConfirmOrganizationUserAsync(orgUser);
|
||||
@@ -1493,6 +1495,7 @@ public class OrganizationUserRepositoryTests
|
||||
var updatedUser = await organizationUserRepository.GetByIdAsync(orgUser.Id);
|
||||
Assert.NotNull(updatedUser);
|
||||
Assert.Equal(OrganizationUserStatusType.Confirmed, updatedUser.Status);
|
||||
Assert.Equal(key, updatedUser.Key);
|
||||
|
||||
// Annul
|
||||
await organizationRepository.DeleteAsync(organization);
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
CREATE OR ALTER PROCEDURE [dbo].[OrganizationUser_ConfirmById]
|
||||
@Id UNIQUEIDENTIFIER,
|
||||
@UserId UNIQUEIDENTIFIER,
|
||||
@RevisionDate DATETIME2(7),
|
||||
@Key NVARCHAR(MAX) = NULL
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON
|
||||
|
||||
DECLARE @RowCount INT;
|
||||
|
||||
UPDATE
|
||||
[dbo].[OrganizationUser]
|
||||
SET
|
||||
[Status] = 2, -- Set to Confirmed
|
||||
[RevisionDate] = @RevisionDate,
|
||||
[Key] = @Key
|
||||
WHERE
|
||||
[Id] = @Id
|
||||
AND [Status] = 1 -- Only update if status is Accepted
|
||||
|
||||
SET @RowCount = @@ROWCOUNT;
|
||||
|
||||
IF @RowCount > 0
|
||||
BEGIN
|
||||
EXEC [dbo].[User_BumpAccountRevisionDate] @UserId
|
||||
END
|
||||
|
||||
SELECT @RowCount;
|
||||
END
|
||||
Reference in New Issue
Block a user