1
0
mirror of https://github.com/bitwarden/server.git synced 2025-04-13 17:18:14 -05:00

Merge branch 'main' into ac/pm-15621/refactor-delete-command

This commit is contained in:
Jimmy Vo 2025-02-10 08:55:58 -05:00
commit 9e95d875dc
No known key found for this signature in database
GPG Key ID: 7CB834D6F4FFCA11
77 changed files with 1756 additions and 308 deletions

View File

@ -64,7 +64,6 @@
"Braintree",
"coverlet.collector",
"CsvHelper",
"FluentAssertions",
"Kralizek.AutoFixture.Extensions.MockHttp",
"Microsoft.AspNetCore.Mvc.Testing",
"Microsoft.Extensions.Logging",

View File

@ -3,11 +3,16 @@
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Version>2025.1.5</Version>
<Version>2025.2.0</Version>
<RootNamespace>Bit.$(MSBuildProjectName)</RootNamespace>
<ImplicitUsings>enable</ImplicitUsings>
<IncludeSourceRevisionInInformationalVersion>false</IncludeSourceRevisionInInformationalVersion>
<!-- Treat it as a test project if the project hasn't set their own value and it follows our test project conventions -->
<IsTestProject Condition="'$(IsTestProject)' == '' and ($(MSBuildProjectName.EndsWith('.Test')) or $(MSBuildProjectName.EndsWith('.IntegrationTest')))">true</IsTestProject>
<Nullable Condition="'$(Nullable)' == '' and '$(IsTestProject)' == 'true'">annotations</Nullable>
<!-- Uncomment the below line when we are ready to enable nullable repo wide -->
<!-- <Nullable Condition="'$(Nullable)' == '' and '$(IsTestProject)' != 'true'">enable</Nullable> -->
</PropertyGroup>
<!--

View File

@ -248,7 +248,7 @@ public class GroupsControllerTests : IClassFixture<ScimApplicationFactory>, IAsy
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public async Task Post_InvalidDisplayName_BadRequest(string displayName)
public async Task Post_InvalidDisplayName_BadRequest(string? displayName)
{
var organizationId = ScimApplicationFactory.TestOrganizationId1;
var model = new ScimGroupRequestModel

View File

@ -324,7 +324,7 @@ public class UsersControllerTests : IClassFixture<ScimApplicationFactory>, IAsyn
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public async Task Post_InvalidEmail_BadRequest(string email)
public async Task Post_InvalidEmail_BadRequest(string? email)
{
var displayName = "Test User 5";
var externalId = "UE";

View File

@ -266,8 +266,18 @@ public class AccountsController : Controller
throw new UnauthorizedAccessException();
}
try
{
user = model.ToUser(user);
}
catch (Exception e)
{
ModelState.AddModelError(string.Empty, e.Message);
throw new BadRequestException(ModelState);
}
var result = await _setInitialMasterPasswordCommand.SetInitialMasterPasswordAsync(
model.ToUser(user),
user,
model.MasterPasswordHash,
model.Key,
model.OrgIdentifier);
@ -974,7 +984,6 @@ public class AccountsController : Controller
await _userService.ResendNewDeviceVerificationEmail(request.Email, request.Secret);
}
[RequireFeature(FeatureFlagKeys.NewDeviceVerification)]
[HttpPost("verify-devices")]
[HttpPut("verify-devices")]
public async Task SetUserVerifyDevicesAsync([FromBody] SetVerifyDevicesRequestModel request)

View File

@ -2,6 +2,7 @@
using Bit.Api.AdminConsole.Models.Request.Organizations;
using Bit.Api.Billing.Models.Requests;
using Bit.Api.Billing.Models.Responses;
using Bit.Core.Billing.Models;
using Bit.Core.Billing.Models.Sales;
using Bit.Core.Billing.Services;
using Bit.Core.Context;
@ -17,7 +18,6 @@ namespace Bit.Api.Billing.Controllers;
[Authorize("Application")]
public class OrganizationBillingController(
ICurrentContext currentContext,
IFeatureService featureService,
IOrganizationBillingService organizationBillingService,
IOrganizationRepository organizationRepository,
IPaymentService paymentService,
@ -282,7 +282,15 @@ public class OrganizationBillingController(
var plan = StaticStore.GetPlan(model.PlanType);
sale.Organization.PlanType = plan.Type;
sale.Organization.Plan = plan.Name;
sale.SubscriptionSetup.SkipTrial = true;
await organizationBillingService.Finalize(sale);
var org = await organizationRepository.GetByIdAsync(organizationId);
if (organizationSignup.PaymentMethodType != null)
{
var paymentSource = new TokenizedPaymentSource(organizationSignup.PaymentMethodType.Value, organizationSignup.PaymentToken);
var taxInformation = TaxInformation.From(organizationSignup.TaxInfo);
await organizationBillingService.UpdatePaymentMethod(org, paymentSource, taxInformation);
}
return TypedResults.Ok();
}

View File

@ -1,6 +1,5 @@
using Bit.Api.Models.Request.Organizations;
using Bit.Api.Models.Response.Organizations;
using Bit.Core;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationConnections.Interfaces;
using Bit.Core.AdminConsole.Repositories;
@ -107,7 +106,7 @@ public class OrganizationSponsorshipsController : Controller
{
var isFreeFamilyPolicyEnabled = false;
var (isValid, sponsorship) = await _validateRedemptionTokenCommand.ValidateRedemptionTokenAsync(sponsorshipToken, (await CurrentUser).Email);
if (isValid && _featureService.IsEnabled(FeatureFlagKeys.DisableFreeFamiliesSponsorship) && sponsorship.SponsoringOrganizationId.HasValue)
if (isValid && sponsorship.SponsoringOrganizationId.HasValue)
{
var policy = await _policyRepository.GetByOrganizationIdTypeAsync(sponsorship.SponsoringOrganizationId.Value,
PolicyType.FreeFamiliesSponsorshipPolicy);

View File

@ -64,8 +64,9 @@ public class ImportCiphersController : Controller
[FromBody] ImportOrganizationCiphersRequestModel model)
{
if (!_globalSettings.SelfHosted &&
(model.Ciphers.Count() > 7000 || model.CollectionRelationships.Count() > 14000 ||
model.Collections.Count() > 2000))
(model.Ciphers.Count() > _globalSettings.ImportCiphersLimitation.CiphersLimit ||
model.CollectionRelationships.Count() > _globalSettings.ImportCiphersLimitation.CollectionRelationshipsLimit ||
model.Collections.Count() > _globalSettings.ImportCiphersLimitation.CollectionsLimit))
{
throw new BadRequestException("You cannot import this much data at once.");
}

View File

@ -424,6 +424,59 @@ public class CiphersController : Controller
return false;
}
/// <summary>
/// TODO: Move this to its own authorization handler or equivalent service - AC-2062
/// </summary>
private async Task<bool> CanModifyCipherCollectionsAsync(Guid organizationId, IEnumerable<Guid> cipherIds)
{
// If the user can edit all ciphers for the organization, just check they all belong to the org
if (await CanEditAllCiphersAsync(organizationId))
{
// TODO: This can likely be optimized to only query the requested ciphers and then checking they belong to the org
var orgCiphers = (await _cipherRepository.GetManyByOrganizationIdAsync(organizationId)).ToDictionary(c => c.Id);
// Ensure all requested ciphers are in orgCiphers
if (cipherIds.Any(c => !orgCiphers.ContainsKey(c)))
{
return false;
}
return true;
}
// The user cannot access any ciphers for the organization, we're done
if (!await CanAccessOrganizationCiphersAsync(organizationId))
{
return false;
}
var userId = _userService.GetProperUserId(User).Value;
// Select all editable ciphers for this user belonging to the organization
var editableOrgCipherList = (await _cipherRepository.GetManyByUserIdAsync(userId, true))
.Where(c => c.OrganizationId == organizationId && c.UserId == null && c.Edit && c.ViewPassword).ToList();
// Special case for unassigned ciphers
if (await CanAccessUnassignedCiphersAsync(organizationId))
{
var unassignedCiphers =
(await _cipherRepository.GetManyUnassignedOrganizationDetailsByOrganizationIdAsync(
organizationId));
// Users that can access unassigned ciphers can also edit them
editableOrgCipherList.AddRange(unassignedCiphers.Select(c => new CipherDetails(c) { Edit = true }));
}
var editableOrgCiphers = editableOrgCipherList
.ToDictionary(c => c.Id);
if (cipherIds.Any(c => !editableOrgCiphers.ContainsKey(c)))
{
return false;
}
return true;
}
/// <summary>
/// TODO: Move this to its own authorization handler or equivalent service - AC-2062
/// </summary>
@ -579,7 +632,7 @@ public class CiphersController : Controller
var userId = _userService.GetProperUserId(User).Value;
var cipher = await GetByIdAsync(id, userId);
if (cipher == null || !cipher.OrganizationId.HasValue ||
!await _currentContext.OrganizationUser(cipher.OrganizationId.Value))
!await _currentContext.OrganizationUser(cipher.OrganizationId.Value) || !cipher.ViewPassword)
{
throw new NotFoundException();
}
@ -634,7 +687,7 @@ public class CiphersController : Controller
[HttpPost("bulk-collections")]
public async Task PostBulkCollections([FromBody] CipherBulkUpdateCollectionsRequestModel model)
{
if (!await CanEditCiphersAsync(model.OrganizationId, model.CipherIds) ||
if (!await CanModifyCipherCollectionsAsync(model.OrganizationId, model.CipherIds) ||
!await CanEditItemsInCollections(model.OrganizationId, model.CollectionIds))
{
throw new NotFoundException();

View File

@ -1,4 +1,5 @@
using Bit.Api.Models.Response;
using Bit.Api.Vault.Models.Request;
using Bit.Api.Vault.Models.Response;
using Bit.Core;
using Bit.Core.Services;
@ -20,17 +21,20 @@ public class SecurityTaskController : Controller
private readonly IGetTaskDetailsForUserQuery _getTaskDetailsForUserQuery;
private readonly IMarkTaskAsCompleteCommand _markTaskAsCompleteCommand;
private readonly IGetTasksForOrganizationQuery _getTasksForOrganizationQuery;
private readonly ICreateManyTasksCommand _createManyTasksCommand;
public SecurityTaskController(
IUserService userService,
IGetTaskDetailsForUserQuery getTaskDetailsForUserQuery,
IMarkTaskAsCompleteCommand markTaskAsCompleteCommand,
IGetTasksForOrganizationQuery getTasksForOrganizationQuery)
IGetTasksForOrganizationQuery getTasksForOrganizationQuery,
ICreateManyTasksCommand createManyTasksCommand)
{
_userService = userService;
_getTaskDetailsForUserQuery = getTaskDetailsForUserQuery;
_markTaskAsCompleteCommand = markTaskAsCompleteCommand;
_getTasksForOrganizationQuery = getTasksForOrganizationQuery;
_createManyTasksCommand = createManyTasksCommand;
}
/// <summary>
@ -71,4 +75,19 @@ public class SecurityTaskController : Controller
var response = securityTasks.Select(x => new SecurityTasksResponseModel(x)).ToList();
return new ListResponseModel<SecurityTasksResponseModel>(response);
}
/// <summary>
/// Bulk create security tasks for an organization.
/// </summary>
/// <param name="orgId"></param>
/// <param name="model"></param>
/// <returns>A list response model containing the security tasks created for the organization.</returns>
[HttpPost("{orgId:guid}/bulk-create")]
public async Task<ListResponseModel<SecurityTasksResponseModel>> BulkCreateTasks(Guid orgId,
[FromBody] BulkCreateSecurityTasksRequestModel model)
{
var securityTasks = await _createManyTasksCommand.CreateAsync(orgId, model.Tasks);
var response = securityTasks.Select(x => new SecurityTasksResponseModel(x)).ToList();
return new ListResponseModel<SecurityTasksResponseModel>(response);
}
}

View File

@ -0,0 +1,8 @@
using Bit.Core.Vault.Models.Api;
namespace Bit.Api.Vault.Models.Request;
public class BulkCreateSecurityTasksRequestModel
{
public IEnumerable<SecurityTaskCreateRequest> Tasks { get; set; }
}

View File

@ -56,6 +56,11 @@
"publicKey": "SECRET",
"privateKey": "SECRET"
},
"importCiphersLimitation": {
"ciphersLimit": 40000,
"collectionRelationshipsLimit": 80000,
"collectionsLimit": 2000
},
"bitPay": {
"production": false,
"token": "SECRET",

View File

@ -10,5 +10,8 @@
<ProjectReference Include="..\SharedWeb\SharedWeb.csproj" />
<ProjectReference Include="..\Core\Core.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Swashbuckle.AspNetCore" Version="7.2.0" />
</ItemGroup>
</Project>

View File

@ -12,6 +12,7 @@ public class BillingSettings
public virtual FreshDeskSettings FreshDesk { get; set; } = new FreshDeskSettings();
public virtual string FreshsalesApiKey { get; set; }
public virtual PayPalSettings PayPal { get; set; } = new PayPalSettings();
public virtual OnyxSettings Onyx { get; set; } = new OnyxSettings();
public class PayPalSettings
{
@ -31,4 +32,10 @@ public class BillingSettings
public virtual string UserFieldName { get; set; }
public virtual string OrgFieldName { get; set; }
}
public class OnyxSettings
{
public virtual string ApiKey { get; set; }
public virtual string BaseUrl { get; set; }
}
}

View File

@ -13,6 +13,7 @@ using Microsoft.Extensions.Options;
namespace Bit.Billing.Controllers;
[Route("bitpay")]
[ApiExplorerSettings(IgnoreApi = true)]
public class BitPayController : Controller
{
private readonly BillingSettings _billingSettings;

View File

@ -1,6 +1,8 @@
using System.ComponentModel.DataAnnotations;
using System.Net.Http.Headers;
using System.Reflection;
using System.Text;
using System.Text.Json;
using System.Web;
using Bit.Billing.Models;
using Bit.Core.Repositories;
@ -142,6 +144,121 @@ public class FreshdeskController : Controller
}
}
[HttpPost("webhook-onyx-ai")]
public async Task<IActionResult> PostWebhookOnyxAi([FromQuery, Required] string key,
[FromBody, Required] FreshdeskWebhookModel model)
{
// ensure that the key is from Freshdesk
if (!IsValidRequestFromFreshdesk(key))
{
return new BadRequestResult();
}
// get ticket info from Freshdesk
var getTicketRequest = new HttpRequestMessage(HttpMethod.Get,
string.Format("https://bitwarden.freshdesk.com/api/v2/tickets/{0}", model.TicketId));
var getTicketResponse = await CallFreshdeskApiAsync(getTicketRequest);
// check if we have a valid response from freshdesk
if (getTicketResponse.StatusCode != System.Net.HttpStatusCode.OK)
{
_logger.LogError("Error getting ticket info from Freshdesk. Ticket Id: {0}. Status code: {1}",
model.TicketId, getTicketResponse.StatusCode);
return BadRequest("Failed to retrieve ticket info from Freshdesk");
}
// extract info from the response
var ticketInfo = await ExtractTicketInfoFromResponse(getTicketResponse);
if (ticketInfo == null)
{
return BadRequest("Failed to extract ticket info from Freshdesk response");
}
// create the onyx `answer-with-citation` request
var onyxRequestModel = new OnyxAnswerWithCitationRequestModel(ticketInfo.DescriptionText);
var onyxRequest = new HttpRequestMessage(HttpMethod.Post,
string.Format("{0}/query/answer-with-citation", _billingSettings.Onyx.BaseUrl))
{
Content = JsonContent.Create(onyxRequestModel, mediaType: new MediaTypeHeaderValue("application/json")),
};
var (_, onyxJsonResponse) = await CallOnyxApi<OnyxAnswerWithCitationResponseModel>(onyxRequest);
// the CallOnyxApi will return a null if we have an error response
if (onyxJsonResponse?.Answer == null || !string.IsNullOrEmpty(onyxJsonResponse?.ErrorMsg))
{
return BadRequest(
string.Format("Failed to get a valid response from Onyx API. Response: {0}",
JsonSerializer.Serialize(onyxJsonResponse ?? new OnyxAnswerWithCitationResponseModel())));
}
// add the answer as a note to the ticket
await AddAnswerNoteToTicketAsync(onyxJsonResponse.Answer, model.TicketId);
return Ok();
}
private bool IsValidRequestFromFreshdesk(string key)
{
if (string.IsNullOrWhiteSpace(key)
|| !CoreHelpers.FixedTimeEquals(key, _billingSettings.FreshDesk.WebhookKey))
{
return false;
}
return true;
}
private async Task AddAnswerNoteToTicketAsync(string note, string ticketId)
{
// if there is no content, then we don't need to add a note
if (string.IsNullOrWhiteSpace(note))
{
return;
}
var noteBody = new Dictionary<string, object>
{
{ "body", $"<b>Onyx AI:</b><ul>{note}</ul>" },
{ "private", true }
};
var noteRequest = new HttpRequestMessage(HttpMethod.Post,
string.Format("https://bitwarden.freshdesk.com/api/v2/tickets/{0}/notes", ticketId))
{
Content = JsonContent.Create(noteBody),
};
var addNoteResponse = await CallFreshdeskApiAsync(noteRequest);
if (addNoteResponse.StatusCode != System.Net.HttpStatusCode.Created)
{
_logger.LogError("Error adding note to Freshdesk ticket. Ticket Id: {0}. Status: {1}",
ticketId, addNoteResponse.ToString());
}
}
private async Task<FreshdeskViewTicketModel> ExtractTicketInfoFromResponse(HttpResponseMessage getTicketResponse)
{
var responseString = string.Empty;
try
{
responseString = await getTicketResponse.Content.ReadAsStringAsync();
var ticketInfo = JsonSerializer.Deserialize<FreshdeskViewTicketModel>(responseString,
options: new System.Text.Json.JsonSerializerOptions
{
PropertyNameCaseInsensitive = true,
});
return ticketInfo;
}
catch (System.Exception ex)
{
_logger.LogError("Error deserializing ticket info from Freshdesk response. Response: {0}. Exception {1}",
responseString, ex.ToString());
}
return null;
}
private async Task<HttpResponseMessage> CallFreshdeskApiAsync(HttpRequestMessage request, int retriedCount = 0)
{
try
@ -166,6 +283,26 @@ public class FreshdeskController : Controller
return await CallFreshdeskApiAsync(request, retriedCount++);
}
private async Task<(HttpResponseMessage, T)> CallOnyxApi<T>(HttpRequestMessage request)
{
var httpClient = _httpClientFactory.CreateClient("OnyxApi");
var response = await httpClient.SendAsync(request);
if (response.StatusCode != System.Net.HttpStatusCode.OK)
{
_logger.LogError("Error calling Onyx AI API. Status code: {0}. Response {1}",
response.StatusCode, JsonSerializer.Serialize(response));
return (null, default);
}
var responseStr = await response.Content.ReadAsStringAsync();
var responseJson = JsonSerializer.Deserialize<T>(responseStr, options: new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true,
});
return (response, responseJson);
}
private TAttribute GetAttribute<TAttribute>(Enum enumValue) where TAttribute : Attribute
{
return enumValue.GetType().GetMember(enumValue.ToString()).First().GetCustomAttribute<TAttribute>();

View File

@ -0,0 +1,44 @@
namespace Bit.Billing.Models;
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
public class FreshdeskViewTicketModel
{
[JsonPropertyName("spam")]
public bool? Spam { get; set; }
[JsonPropertyName("priority")]
public int? Priority { get; set; }
[JsonPropertyName("source")]
public int? Source { get; set; }
[JsonPropertyName("status")]
public int? Status { get; set; }
[JsonPropertyName("subject")]
public string Subject { get; set; }
[JsonPropertyName("support_email")]
public string SupportEmail { get; set; }
[JsonPropertyName("id")]
public int Id { get; set; }
[JsonPropertyName("description")]
public string Description { get; set; }
[JsonPropertyName("description_text")]
public string DescriptionText { get; set; }
[JsonPropertyName("created_at")]
public DateTime CreatedAt { get; set; }
[JsonPropertyName("updated_at")]
public DateTime UpdatedAt { get; set; }
[JsonPropertyName("tags")]
public List<string> Tags { get; set; }
}

View File

@ -0,0 +1,54 @@

using System.Text.Json.Serialization;
namespace Bit.Billing.Models;
public class OnyxAnswerWithCitationRequestModel
{
[JsonPropertyName("messages")]
public List<Message> Messages { get; set; }
[JsonPropertyName("persona_id")]
public int PersonaId { get; set; } = 1;
[JsonPropertyName("prompt_id")]
public int PromptId { get; set; } = 1;
[JsonPropertyName("retrieval_options")]
public RetrievalOptions RetrievalOptions { get; set; }
public OnyxAnswerWithCitationRequestModel(string message)
{
message = message.Replace(Environment.NewLine, " ").Replace('\r', ' ').Replace('\n', ' ');
Messages = new List<Message>() { new Message() { MessageText = message } };
RetrievalOptions = new RetrievalOptions();
}
}
public class Message
{
[JsonPropertyName("message")]
public string MessageText { get; set; }
[JsonPropertyName("sender")]
public string Sender { get; set; } = "user";
}
public class RetrievalOptions
{
[JsonPropertyName("run_search")]
public string RunSearch { get; set; } = RetrievalOptionsRunSearch.Auto;
[JsonPropertyName("real_time")]
public bool RealTime { get; set; } = true;
[JsonPropertyName("limit")]
public int? Limit { get; set; } = 3;
}
public class RetrievalOptionsRunSearch
{
public const string Always = "always";
public const string Never = "never";
public const string Auto = "auto";
}

View File

@ -0,0 +1,30 @@
using System.Text.Json.Serialization;
namespace Bit.Billing.Models;
public class OnyxAnswerWithCitationResponseModel
{
[JsonPropertyName("answer")]
public string Answer { get; set; }
[JsonPropertyName("rephrase")]
public string Rephrase { get; set; }
[JsonPropertyName("citations")]
public List<Citation> Citations { get; set; }
[JsonPropertyName("llm_selected_doc_indices")]
public List<int> LlmSelectedDocIndices { get; set; }
[JsonPropertyName("error_msg")]
public string ErrorMsg { get; set; }
}
public class Citation
{
[JsonPropertyName("citation_num")]
public int CitationNum { get; set; }
[JsonPropertyName("document_id")]
public string DocumentId { get; set; }
}

View File

@ -14,19 +14,22 @@ public class CustomerUpdatedHandler : ICustomerUpdatedHandler
private readonly ICurrentContext _currentContext;
private readonly IStripeEventService _stripeEventService;
private readonly IStripeEventUtilityService _stripeEventUtilityService;
private readonly ILogger<CustomerUpdatedHandler> _logger;
public CustomerUpdatedHandler(
IOrganizationRepository organizationRepository,
IReferenceEventService referenceEventService,
ICurrentContext currentContext,
IStripeEventService stripeEventService,
IStripeEventUtilityService stripeEventUtilityService)
IStripeEventUtilityService stripeEventUtilityService,
ILogger<CustomerUpdatedHandler> logger)
{
_organizationRepository = organizationRepository;
_organizationRepository = organizationRepository ?? throw new ArgumentNullException(nameof(organizationRepository));
_referenceEventService = referenceEventService;
_currentContext = currentContext;
_stripeEventService = stripeEventService;
_stripeEventUtilityService = stripeEventUtilityService;
_logger = logger;
}
/// <summary>
@ -35,25 +38,76 @@ public class CustomerUpdatedHandler : ICustomerUpdatedHandler
/// <param name="parsedEvent"></param>
public async Task HandleAsync(Event parsedEvent)
{
var customer = await _stripeEventService.GetCustomer(parsedEvent, true, ["subscriptions"]);
if (customer.Subscriptions == null || !customer.Subscriptions.Any())
if (parsedEvent == null)
{
_logger.LogError("Parsed event was null in CustomerUpdatedHandler");
throw new ArgumentNullException(nameof(parsedEvent));
}
if (_stripeEventService == null)
{
_logger.LogError("StripeEventService was not initialized in CustomerUpdatedHandler");
throw new InvalidOperationException($"{nameof(_stripeEventService)} is not initialized");
}
var customer = await _stripeEventService.GetCustomer(parsedEvent, true, ["subscriptions"]);
if (customer?.Subscriptions == null || !customer.Subscriptions.Any())
{
_logger.LogWarning("Customer or subscriptions were null or empty in CustomerUpdatedHandler. Customer ID: {CustomerId}", customer?.Id);
return;
}
var subscription = customer.Subscriptions.First();
if (subscription.Metadata == null)
{
_logger.LogWarning("Subscription metadata was null in CustomerUpdatedHandler. Subscription ID: {SubscriptionId}", subscription.Id);
return;
}
if (_stripeEventUtilityService == null)
{
_logger.LogError("StripeEventUtilityService was not initialized in CustomerUpdatedHandler");
throw new InvalidOperationException($"{nameof(_stripeEventUtilityService)} is not initialized");
}
var (organizationId, _, providerId) = _stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata);
if (!organizationId.HasValue)
{
_logger.LogWarning("Organization ID was not found in subscription metadata. Subscription ID: {SubscriptionId}", subscription.Id);
return;
}
if (_organizationRepository == null)
{
_logger.LogError("OrganizationRepository was not initialized in CustomerUpdatedHandler");
throw new InvalidOperationException($"{nameof(_organizationRepository)} is not initialized");
}
var organization = await _organizationRepository.GetByIdAsync(organizationId.Value);
if (organization == null)
{
_logger.LogWarning("Organization not found. Organization ID: {OrganizationId}", organizationId.Value);
return;
}
organization.BillingEmail = customer.Email;
await _organizationRepository.ReplaceAsync(organization);
if (_referenceEventService == null)
{
_logger.LogError("ReferenceEventService was not initialized in CustomerUpdatedHandler");
throw new InvalidOperationException($"{nameof(_referenceEventService)} is not initialized");
}
if (_currentContext == null)
{
_logger.LogError("CurrentContext was not initialized in CustomerUpdatedHandler");
throw new InvalidOperationException($"{nameof(_currentContext)} is not initialized");
}
await _referenceEventService.RaiseEventAsync(
new ReferenceEvent(ReferenceEventType.OrganizationEditedInStripe, organization, _currentContext));
}

View File

@ -1,4 +1,5 @@
using System.Globalization;
using System.Net.Http.Headers;
using Bit.Billing.Services;
using Bit.Billing.Services.Implementations;
using Bit.Core.Billing.Extensions;
@ -34,6 +35,7 @@ public class Startup
// Settings
var globalSettings = services.AddGlobalSettingsServices(Configuration, Environment);
services.Configure<BillingSettings>(Configuration.GetSection("BillingSettings"));
var billingSettings = Configuration.GetSection("BillingSettings").Get<BillingSettings>();
// Stripe Billing
StripeConfiguration.ApiKey = globalSettings.Stripe.ApiKey;
@ -97,6 +99,10 @@ public class Startup
// Set up HttpClients
services.AddHttpClient("FreshdeskApi");
services.AddHttpClient("OnyxApi", client =>
{
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", billingSettings.Onyx.ApiKey);
});
services.AddScoped<IStripeFacade, StripeFacade>();
services.AddScoped<IStripeEventService, StripeEventService>();
@ -112,6 +118,10 @@ public class Startup
// Jobs service
Jobs.JobsHostedService.AddJobsServices(services);
services.AddHostedService<Jobs.JobsHostedService>();
// Swagger
services.AddEndpointsApiExplorer();
services.AddSwaggerGen();
}
public void Configure(
@ -128,6 +138,11 @@ public class Startup
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
app.UseSwagger();
app.UseSwaggerUI(c =>
{
c.SwaggerEndpoint("/swagger/v1/swagger.json", "Billing API V1");
});
}
app.UseStaticFiles();

View File

@ -73,6 +73,10 @@
"region": "US",
"userFieldName": "cf_user",
"orgFieldName": "cf_org"
}
},
"onyx": {
"apiKey": "SECRET",
"baseUrl": "https://cloud.onyx.app/api"
}
}
}

View File

@ -60,4 +60,12 @@ public interface IOrganizationUserRepository : IRepository<OrganizationUser, Gui
Task<ICollection<OrganizationUser>> GetManyByOrganizationWithClaimedDomainsAsync(Guid organizationId);
Task RevokeManyByIdAsync(IEnumerable<Guid> organizationUserIds);
/// <summary>
/// Returns a list of OrganizationUsersUserDetails with the specified role.
/// </summary>
/// <param name="organizationId">The organization to search within</param>
/// <param name="role">The role to search for</param>
/// <returns>A list of OrganizationUsersUserDetails with the specified role</returns>
Task<IEnumerable<OrganizationUserUserDetails>> GetManyDetailsByRoleAsync(Guid organizationId, OrganizationUserType role);
}

View File

@ -1294,6 +1294,12 @@ public class OrganizationService : IOrganizationService
}
}
var subscription = await _paymentService.GetSubscriptionAsync(organization);
if (subscription?.Subscription?.Status == StripeConstants.SubscriptionStatus.Canceled)
{
return (false, "You do not have an active subscription. Reinstate your subscription to make changes");
}
if (organization.Seats.HasValue &&
organization.MaxAutoscaleSeats.HasValue &&
organization.MaxAutoscaleSeats.Value < organization.Seats.Value + seatsToAdd)
@ -1301,12 +1307,6 @@ public class OrganizationService : IOrganizationService
return (false, $"Seat limit has been reached.");
}
var subscription = await _paymentService.GetSubscriptionAsync(organization);
if (subscription?.Subscription?.Status == StripeConstants.SubscriptionStatus.Canceled)
{
return (false, "Cannot autoscale with a canceled subscription.");
}
return (true, failureReason);
}
@ -1951,6 +1951,11 @@ public class OrganizationService : IOrganizationService
await RepositoryRevokeUserAsync(organizationUser);
await _eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Revoked);
if (_featureService.IsEnabled(FeatureFlagKeys.PushSyncOrgKeysOnRevokeRestore) && organizationUser.UserId.HasValue)
{
await _pushNotificationService.PushSyncOrgKeysAsync(organizationUser.UserId.Value);
}
}
public async Task RevokeUserAsync(OrganizationUser organizationUser,
@ -1958,6 +1963,11 @@ public class OrganizationService : IOrganizationService
{
await RepositoryRevokeUserAsync(organizationUser);
await _eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Revoked, systemUser);
if (_featureService.IsEnabled(FeatureFlagKeys.PushSyncOrgKeysOnRevokeRestore) && organizationUser.UserId.HasValue)
{
await _pushNotificationService.PushSyncOrgKeysAsync(organizationUser.UserId.Value);
}
}
private async Task RepositoryRevokeUserAsync(OrganizationUser organizationUser)
@ -2023,6 +2033,10 @@ public class OrganizationService : IOrganizationService
await _organizationUserRepository.RevokeAsync(organizationUser.Id);
organizationUser.Status = OrganizationUserStatusType.Revoked;
await _eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Revoked);
if (_featureService.IsEnabled(FeatureFlagKeys.PushSyncOrgKeysOnRevokeRestore) && organizationUser.UserId.HasValue)
{
await _pushNotificationService.PushSyncOrgKeysAsync(organizationUser.UserId.Value);
}
result.Add(Tuple.Create(organizationUser, ""));
}
@ -2050,12 +2064,22 @@ public class OrganizationService : IOrganizationService
await RepositoryRestoreUserAsync(organizationUser);
await _eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Restored);
if (_featureService.IsEnabled(FeatureFlagKeys.PushSyncOrgKeysOnRevokeRestore) && organizationUser.UserId.HasValue)
{
await _pushNotificationService.PushSyncOrgKeysAsync(organizationUser.UserId.Value);
}
}
public async Task RestoreUserAsync(OrganizationUser organizationUser, EventSystemUser systemUser)
{
await RepositoryRestoreUserAsync(organizationUser);
await _eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Restored, systemUser);
if (_featureService.IsEnabled(FeatureFlagKeys.PushSyncOrgKeysOnRevokeRestore) && organizationUser.UserId.HasValue)
{
await _pushNotificationService.PushSyncOrgKeysAsync(organizationUser.UserId.Value);
}
}
private async Task RepositoryRestoreUserAsync(OrganizationUser organizationUser)
@ -2147,6 +2171,10 @@ public class OrganizationService : IOrganizationService
await _organizationUserRepository.RestoreAsync(organizationUser.Id, status);
organizationUser.Status = status;
await _eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Restored);
if (_featureService.IsEnabled(FeatureFlagKeys.PushSyncOrgKeysOnRevokeRestore) && organizationUser.UserId.HasValue)
{
await _pushNotificationService.PushSyncOrgKeysAsync(organizationUser.UserId.Value);
}
result.Add(Tuple.Create(organizationUser, ""));
}

View File

@ -1,26 +1,36 @@
using System.ComponentModel.DataAnnotations;
using Bit.Core.Entities;
using Bit.Core.Utilities;
namespace Bit.Core.Auth.Models.Api.Request.Accounts;
public class KeysRequestModel
{
[Required]
public string PublicKey { get; set; }
[Required]
public string EncryptedPrivateKey { get; set; }
public User ToUser(User existingUser)
{
if (string.IsNullOrWhiteSpace(existingUser.PublicKey) && !string.IsNullOrWhiteSpace(PublicKey))
if (string.IsNullOrWhiteSpace(PublicKey) || string.IsNullOrWhiteSpace(EncryptedPrivateKey))
{
throw new InvalidOperationException("Public and private keys are required.");
}
if (string.IsNullOrWhiteSpace(existingUser.PublicKey) && string.IsNullOrWhiteSpace(existingUser.PrivateKey))
{
existingUser.PublicKey = PublicKey;
}
if (string.IsNullOrWhiteSpace(existingUser.PrivateKey))
{
existingUser.PrivateKey = EncryptedPrivateKey;
return existingUser;
}
else if (PublicKey == existingUser.PublicKey && CoreHelpers.FixedTimeEquals(EncryptedPrivateKey, existingUser.PrivateKey))
{
return existingUser;
}
else
{
throw new InvalidOperationException("Cannot replace existing key(s) with new key(s).");
}
return existingUser;
}
}

View File

@ -297,10 +297,34 @@ public class AuthRequestService : IAuthRequestService
return;
}
var admins = await _organizationUserRepository.GetManyByMinimumRoleAsync(
var adminEmails = await GetAdminAndAccountRecoveryEmailsAsync(organizationUser.OrganizationId);
await _mailService.SendDeviceApprovalRequestedNotificationEmailAsync(
adminEmails,
organizationUser.OrganizationId,
user.Email,
user.Name);
}
/// <summary>
/// Returns a list of emails for admins and custom users with the ManageResetPassword permission.
/// </summary>
/// <param name="organizationId">The organization to search within</param>
private async Task<List<string>> GetAdminAndAccountRecoveryEmailsAsync(Guid organizationId)
{
var admins = await _organizationUserRepository.GetManyByMinimumRoleAsync(
organizationId,
OrganizationUserType.Admin);
var adminEmails = admins.Select(a => a.Email).Distinct().ToList();
await _mailService.SendDeviceApprovalRequestedNotificationEmailAsync(adminEmails, organizationUser.OrganizationId, user.Email, user.Name);
var customUsers = await _organizationUserRepository.GetManyDetailsByRoleAsync(
organizationId,
OrganizationUserType.Custom);
return admins.Select(a => a.Email)
.Concat(customUsers
.Where(a => a.GetPermissions().ManageResetPassword)
.Select(a => a.Email))
.Distinct()
.ToList();
}
}

View File

@ -9,6 +9,7 @@ public class SubscriptionSetup
public required Plan Plan { get; set; }
public required PasswordManager PasswordManagerOptions { get; set; }
public SecretsManager? SecretsManagerOptions { get; set; }
public bool SkipTrial = false;
public class PasswordManager
{

View File

@ -379,7 +379,7 @@ public class OrganizationBillingService(
["organizationId"] = organizationId.ToString()
},
OffSession = true,
TrialPeriodDays = plan.TrialPeriodDays
TrialPeriodDays = subscriptionSetup.SkipTrial ? 0 : plan.TrialPeriodDays
};
return await stripeAdapter.SubscriptionCreateAsync(subscriptionCreateOptions);

View File

@ -108,11 +108,10 @@ public static class FeatureFlagKeys
public const string IntegrationPage = "pm-14505-admin-console-integration-page";
public const string DeviceApprovalRequestAdminNotifications = "pm-15637-device-approval-request-admin-notifications";
public const string LimitItemDeletion = "pm-15493-restrict-item-deletion-to-can-manage-permission";
public const string PushSyncOrgKeysOnRevokeRestore = "pm-17168-push-sync-org-keys-on-revoke-restore";
/* Tools Team */
public const string ItemShare = "item-share";
public const string GeneratorToolsModernization = "generator-tools-modernization";
public const string MemberAccessReport = "ac-2059-member-access-report";
public const string RiskInsightsCriticalApplication = "pm-14466-risk-insights-critical-application";
public const string EnableRiskInsightsNotifications = "enable-risk-insights-notifications";
@ -155,7 +154,6 @@ public static class FeatureFlagKeys
public const string NewDeviceVerificationTemporaryDismiss = "new-device-temporary-dismiss";
public const string NewDeviceVerificationPermanentDismiss = "new-device-permanent-dismiss";
public const string SecurityTasks = "security-tasks";
public const string DisableFreeFamiliesSponsorship = "PM-12274-disable-free-families-sponsorship";
public const string MacOsNativeCredentialSync = "macos-native-credential-sync";
public const string PM9111ExtensionPersistAddEditForm = "pm-9111-extension-persist-add-edit-form";
public const string InlineMenuTotp = "inline-menu-totp";
@ -174,7 +172,8 @@ public static class FeatureFlagKeys
public const string SingleTapPasskeyCreation = "single-tap-passkey-creation";
public const string SingleTapPasskeyAuthentication = "single-tap-passkey-authentication";
public const string EnablePMAuthenticatorSync = "enable-pm-bwa-sync";
public const string P15179_AddExistingOrgsFromProviderPortal = "PM-15179-add-existing-orgs-from-provider-portal";
public const string P15179_AddExistingOrgsFromProviderPortal = "pm-15179-add-existing-orgs-from-provider-portal";
public const string AndroidMutualTls = "mutual-tls";
public static List<string> GetAllKeys()
{

View File

@ -51,7 +51,6 @@
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Identity.Stores" Version="8.0.10" />
<PackageReference Include="OneOf" Version="3.0.271" />
<PackageReference Include="Quartz" Version="3.13.1" />
<PackageReference Include="SendGrid" Version="9.29.3" />
<PackageReference Include="Serilog.AspNetCore" Version="8.0.3" />
<PackageReference Include="Serilog.Extensions.Logging" Version="8.0.0" />
@ -73,6 +72,11 @@
<PackageReference Include="RabbitMQ.Client" Version="7.0.0" />
</ItemGroup>
<ItemGroup Label="Pinned transitive dependencies">
<PackageReference Include="System.Text.Json" Version="8.0.5" />
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="8.0.1" />
</ItemGroup>
<ItemGroup>
<Protobuf Include="Billing\Pricing\Protos\password-manager.proto" GrpcServices="Client" />
</ItemGroup>

View File

@ -130,7 +130,7 @@
<table class="header" cellpadding="0" cellspacing="0" width="100%">
<tr>
<td valign="middle" class="aligncenter middle logo" style="padding: 20px 0 10px;" align="center">
<img src="https://bitwarden.com/images/logo-horizontal-blue.png" alt="" width="250" height="39" />
<img src="https://assets.bitwarden.com/email/v1/logo-horizontal-blue.png" alt="" width="250" height="39" />
</td>
</tr>
</table>
@ -148,13 +148,13 @@
<td class="aligncenter social-icons" align="center" style="margin: 0; padding: 15px 0 0 0;" valign="top">
<table cellpadding="0" cellspacing="0" style="margin: 0 auto;">
<tr>
<td style="margin: 0; padding: 0 10px;" valign="top"><a href="https://twitter.com/bitwarden" target="_blank"><img src="https://bitwarden.com/images/mail-twitter.png" alt="Twitter" width="30" height="30" /></a></td>
<td style="margin: 0; padding: 0 10px;" valign="top"><a href="https://www.reddit.com/r/Bitwarden/" target="_blank"><img src="https://bitwarden.com/images/mail-reddit.png" alt="Reddit" width="30" height="30" /></a></td>
<td style="margin: 0; padding: 0 10px;" valign="top"><a href="https://community.bitwarden.com/" target="_blank"><img src="https://bitwarden.com/images/mail-discourse.png" alt="CommunityForums" width="30" height="30" /></a></td>
<td style="margin: 0; padding: 0 10px;" valign="top"><a href="https://github.com/bitwarden" target="_blank"><img src="https://bitwarden.com/images/mail-github.png" alt="GitHub" width="30" height="30" /></a></td>
<td style="margin: 0; padding: 0 10px;" valign="top"><a href="https://www.youtube.com/channel/UCId9a_jQqvJre0_dE2lE_Rw" target="_blank"><img src="https://bitwarden.com/images/mail-youtube.png" alt="Youtube" width="30" height="30" /></a></td>
<td style="margin: 0; padding: 0 10px;" valign="top"><a href="https://www.linkedin.com/company/bitwarden1/" target="_blank"><img src="https://bitwarden.com/images/mail-linkedin.png" alt="LinkedIn" width="30" height="30" /></a></td>
<td style="margin: 0; padding: 0 10px;" valign="top"><a href="https://www.facebook.com/bitwarden/" target="_blank"><img src="https://bitwarden.com/images/mail-facebook.png" alt="Facebook" width="30" height="30" /></a></td>
<td style="margin: 0; padding: 0 10px;" valign="top"><a href="https://twitter.com/bitwarden" target="_blank"><img src="https://assets.bitwarden.com/email/v1/mail-twitter.png" alt="Twitter" width="30" height="30" /></a></td>
<td style="margin: 0; padding: 0 10px;" valign="top"><a href="https://www.reddit.com/r/Bitwarden/" target="_blank"><img src="https://assets.bitwarden.com/email/v1/mail-reddit.png" alt="Reddit" width="30" height="30" /></a></td>
<td style="margin: 0; padding: 0 10px;" valign="top"><a href="https://community.bitwarden.com/" target="_blank"><img src="https://assets.bitwarden.com/email/v1/mail-discourse.png" alt="CommunityForums" width="30" height="30" /></a></td>
<td style="margin: 0; padding: 0 10px;" valign="top"><a href="https://github.com/bitwarden" target="_blank"><img src="https://assets.bitwarden.com/email/v1/mail-github.png" alt="GitHub" width="30" height="30" /></a></td>
<td style="margin: 0; padding: 0 10px;" valign="top"><a href="https://www.youtube.com/channel/UCId9a_jQqvJre0_dE2lE_Rw" target="_blank"><img src="https://assets.bitwarden.com/email/v1/mail-youtube.png" alt="Youtube" width="30" height="30" /></a></td>
<td style="margin: 0; padding: 0 10px;" valign="top"><a href="https://www.linkedin.com/company/bitwarden1/" target="_blank"><img src="https://assets.bitwarden.com/email/v1/mail-linkedin.png" alt="LinkedIn" width="30" height="30" /></a></td>
<td style="margin: 0; padding: 0 10px;" valign="top"><a href="https://www.facebook.com/bitwarden/" target="_blank"><img src="https://assets.bitwarden.com/email/v1/mail-facebook.png" alt="Facebook" width="30" height="30" /></a></td>
</tr>
</table>
</td>

View File

@ -107,10 +107,10 @@
.footer-text {
width: 100% !important;
}
.center {
text-align: center !important;
}
.center {
text-align: center !important;
}
.templateColumnContainer{
@ -159,7 +159,7 @@
<table class="header" cellpadding="0" cellspacing="0" width="100%">
<tr>
<td valign="middle" class="aligncenter middle logo" style="padding: 20px 0 10px;" align="center">
<img src="https://bitwarden.com/images/logo-horizontal-blue.png" alt="" width="250" height="39" />
<img src="https://assets.bitwarden.com/email/v1/logo-horizontal-blue.png" alt="" width="250" height="39" />
</td>
</tr>
</table>
@ -177,13 +177,13 @@
<td class="aligncenter social-icons" align="center" style="margin: 0; padding: 15px 0 0 0;" valign="top">
<table cellpadding="0" cellspacing="0" style="margin: 0 auto;">
<tr>
<td style="margin: 0; padding: 0 10px;" valign="top"><a href="https://twitter.com/bitwarden" target="_blank"><img src="https://bitwarden.com/images/mail-twitter.png" alt="Twitter" width="30" height="30" /></a></td>
<td style="margin: 0; padding: 0 10px;" valign="top"><a href="https://www.reddit.com/r/Bitwarden/" target="_blank"><img src="https://bitwarden.com/images/mail-reddit.png" alt="Reddit" width="30" height="30" /></a></td>
<td style="margin: 0; padding: 0 10px;" valign="top"><a href="https://community.bitwarden.com/" target="_blank"><img src="https://bitwarden.com/images/mail-discourse.png" alt="CommunityForums" width="30" height="30" /></a></td>
<td style="margin: 0; padding: 0 10px;" valign="top"><a href="https://github.com/bitwarden" target="_blank"><img src="https://bitwarden.com/images/mail-github.png" alt="GitHub" width="30" height="30" /></a></td>
<td style="margin: 0; padding: 0 10px;" valign="top"><a href="https://www.youtube.com/channel/UCId9a_jQqvJre0_dE2lE_Rw" target="_blank"><img src="https://bitwarden.com/images/mail-youtube.png" alt="Youtube" width="30" height="30" /></a></td>
<td style="margin: 0; padding: 0 10px;" valign="top"><a href="https://www.linkedin.com/company/bitwarden1/" target="_blank"><img src="https://bitwarden.com/images/mail-linkedin.png" alt="LinkedIn" width="30" height="30" /></a></td>
<td style="margin: 0; padding: 0 10px;" valign="top"><a href="https://www.facebook.com/bitwarden/" target="_blank"><img src="https://bitwarden.com/images/mail-facebook.png" alt="Facebook" width="30" height="30" /></a></td>
<td style="margin: 0; padding: 0 10px;" valign="top"><a href="https://twitter.com/bitwarden" target="_blank"><img src="https://assets.bitwarden.com/email/v1/mail-twitter.png" alt="Twitter" width="30" height="30" /></a></td>
<td style="margin: 0; padding: 0 10px;" valign="top"><a href="https://www.reddit.com/r/Bitwarden/" target="_blank"><img src="https://assets.bitwarden.com/email/v1/mail-reddit.png" alt="Reddit" width="30" height="30" /></a></td>
<td style="margin: 0; padding: 0 10px;" valign="top"><a href="https://community.bitwarden.com/" target="_blank"><img src="https://assets.bitwarden.com/email/v1/mail-discourse.png" alt="CommunityForums" width="30" height="30" /></a></td>
<td style="margin: 0; padding: 0 10px;" valign="top"><a href="https://github.com/bitwarden" target="_blank"><img src="https://assets.bitwarden.com/email/v1/mail-github.png" alt="GitHub" width="30" height="30" /></a></td>
<td style="margin: 0; padding: 0 10px;" valign="top"><a href="https://www.youtube.com/channel/UCId9a_jQqvJre0_dE2lE_Rw" target="_blank"><img src="https://assets.bitwarden.com/email/v1/mail-youtube.png" alt="Youtube" width="30" height="30" /></a></td>
<td style="margin: 0; padding: 0 10px;" valign="top"><a href="https://www.linkedin.com/company/bitwarden1/" target="_blank"><img src="https://assets.bitwarden.com/email/v1/mail-linkedin.png" alt="LinkedIn" width="30" height="30" /></a></td>
<td style="margin: 0; padding: 0 10px;" valign="top"><a href="https://www.facebook.com/bitwarden/" target="_blank"><img src="https://assets.bitwarden.com/email/v1/mail-facebook.png" alt="Facebook" width="30" height="30" /></a></td>
</tr>
</table>
</td>

View File

@ -70,6 +70,7 @@ public class GlobalSettings : IGlobalSettings
public virtual YubicoSettings Yubico { get; set; } = new YubicoSettings();
public virtual DuoSettings Duo { get; set; } = new DuoSettings();
public virtual BraintreeSettings Braintree { get; set; } = new BraintreeSettings();
public virtual ImportCiphersLimitationSettings ImportCiphersLimitation { get; set; } = new ImportCiphersLimitationSettings();
public virtual BitPaySettings BitPay { get; set; } = new BitPaySettings();
public virtual AmazonSettings Amazon { get; set; } = new AmazonSettings();
public virtual ServiceBusSettings ServiceBus { get; set; } = new ServiceBusSettings();
@ -521,6 +522,13 @@ public class GlobalSettings : IGlobalSettings
public string PrivateKey { get; set; }
}
public class ImportCiphersLimitationSettings
{
public int CiphersLimit { get; set; }
public int CollectionRelationshipsLimit { get; set; }
public int CollectionsLimit { get; set; }
}
public class BitPaySettings
{
public bool Production { get; set; }

View File

@ -1,16 +0,0 @@
using Microsoft.AspNetCore.Authorization.Infrastructure;
namespace Bit.Core.Vault.Authorization;
public class SecurityTaskOperationRequirement : OperationAuthorizationRequirement
{
public SecurityTaskOperationRequirement(string name)
{
Name = name;
}
}
public static class SecurityTaskOperations
{
public static readonly SecurityTaskOperationRequirement Update = new(nameof(Update));
}

View File

@ -0,0 +1,65 @@
using Bit.Core.Context;
using Bit.Core.Exceptions;
using Bit.Core.Utilities;
using Bit.Core.Vault.Authorization.SecurityTasks;
using Bit.Core.Vault.Commands.Interfaces;
using Bit.Core.Vault.Entities;
using Bit.Core.Vault.Enums;
using Bit.Core.Vault.Models.Api;
using Bit.Core.Vault.Repositories;
using Microsoft.AspNetCore.Authorization;
namespace Bit.Core.Vault.Commands;
public class CreateManyTasksCommand : ICreateManyTasksCommand
{
private readonly IAuthorizationService _authorizationService;
private readonly ICurrentContext _currentContext;
private readonly ISecurityTaskRepository _securityTaskRepository;
public CreateManyTasksCommand(
ISecurityTaskRepository securityTaskRepository,
IAuthorizationService authorizationService,
ICurrentContext currentContext)
{
_securityTaskRepository = securityTaskRepository;
_authorizationService = authorizationService;
_currentContext = currentContext;
}
/// <inheritdoc />
public async Task<ICollection<SecurityTask>> CreateAsync(Guid organizationId,
IEnumerable<SecurityTaskCreateRequest> tasks)
{
if (!_currentContext.UserId.HasValue)
{
throw new NotFoundException();
}
var tasksList = tasks?.ToList();
if (tasksList is null || tasksList.Count == 0)
{
throw new BadRequestException("No tasks provided.");
}
var securityTasks = tasksList.Select(t => new SecurityTask
{
OrganizationId = organizationId,
CipherId = t.CipherId,
Type = t.Type,
Status = SecurityTaskStatus.Pending
}).ToList();
// Verify authorization for each task
foreach (var task in securityTasks)
{
await _authorizationService.AuthorizeOrThrowAsync(
_currentContext.HttpContext.User,
task,
SecurityTaskOperations.Create);
}
return await _securityTaskRepository.CreateManyAsync(securityTasks);
}
}

View File

@ -0,0 +1,17 @@
using Bit.Core.Vault.Entities;
using Bit.Core.Vault.Models.Api;
namespace Bit.Core.Vault.Commands.Interfaces;
public interface ICreateManyTasksCommand
{
/// <summary>
/// Creates multiple security tasks for an organization.
/// Each task must be authorized and the user must have the Create permission
/// and associated ciphers must belong to the organization.
/// </summary>
/// <param name="organizationId">The </param>
/// <param name="tasks"></param>
/// <returns>Collection of created security tasks</returns>
Task<ICollection<SecurityTask>> CreateAsync(Guid organizationId, IEnumerable<SecurityTaskCreateRequest> tasks);
}

View File

@ -1,7 +1,7 @@
using Bit.Core.Context;
using Bit.Core.Exceptions;
using Bit.Core.Utilities;
using Bit.Core.Vault.Authorization;
using Bit.Core.Vault.Authorization.SecurityTasks;
using Bit.Core.Vault.Commands.Interfaces;
using Bit.Core.Vault.Enums;
using Bit.Core.Vault.Repositories;

View File

@ -0,0 +1,9 @@
using Bit.Core.Vault.Enums;
namespace Bit.Core.Vault.Models.Api;
public class SecurityTaskCreateRequest
{
public SecurityTaskType Type { get; set; }
public Guid? CipherId { get; set; }
}

View File

@ -21,4 +21,11 @@ public interface ISecurityTaskRepository : IRepository<SecurityTask, Guid>
/// <param name="status">Optional filter for task status. If not provided, returns tasks of all statuses</param>
/// <returns></returns>
Task<ICollection<SecurityTask>> GetManyByOrganizationIdStatusAsync(Guid organizationId, SecurityTaskStatus? status = null);
/// <summary>
/// Creates bulk security tasks for an organization.
/// </summary>
/// <param name="tasks">Collection of tasks to create</param>
/// <returns>Collection of created security tasks</returns>
Task<ICollection<SecurityTask>> CreateManyAsync(IEnumerable<SecurityTask> tasks);
}

View File

@ -21,5 +21,6 @@ public static class VaultServiceCollectionExtensions
services.AddScoped<IMarkTaskAsCompleteCommand, MarkTaskAsCompletedCommand>();
services.AddScoped<IGetCipherPermissionsForUserQuery, GetCipherPermissionsForUserQuery>();
services.AddScoped<IGetTasksForOrganizationQuery, GetTasksForOrganizationQuery>();
services.AddScoped<ICreateManyTasksCommand, CreateManyTasksCommand>();
}
}

View File

@ -567,4 +567,17 @@ public class OrganizationUserRepository : Repository<OrganizationUser, Guid>, IO
new { OrganizationUserIds = JsonSerializer.Serialize(organizationUserIds), Status = OrganizationUserStatusType.Revoked },
commandType: CommandType.StoredProcedure);
}
public async Task<IEnumerable<OrganizationUserUserDetails>> GetManyDetailsByRoleAsync(Guid organizationId, OrganizationUserType role)
{
using (var connection = new SqlConnection(ConnectionString))
{
var results = await connection.QueryAsync<OrganizationUserUserDetails>(
"[dbo].[OrganizationUser_ReadManyDetailsByRole]",
new { OrganizationId = organizationId, Role = role },
commandType: CommandType.StoredProcedure);
return results.ToList();
}
}
}

View File

@ -81,7 +81,7 @@ public class DataTableBuilder<T>
return true;
}
// Value type properties will implicitly box into the object so
// Value type properties will implicitly box into the object so
// we need to look past the Convert expression
// i => (System.Object?)i.Id
if (

View File

@ -98,7 +98,7 @@ public class CipherRepository : Repository<Cipher, Guid>, ICipherRepository
return results
.GroupBy(c => c.Id)
.Select(g => g.OrderByDescending(og => og.Edit).First())
.Select(g => g.OrderByDescending(og => og.Edit).ThenByDescending(og => og.ViewPassword).First())
.ToList();
}
}

View File

@ -1,4 +1,5 @@
using System.Data;
using System.Text.Json;
using Bit.Core.Settings;
using Bit.Core.Vault.Entities;
using Bit.Core.Vault.Enums;
@ -46,4 +47,29 @@ public class SecurityTaskRepository : Repository<SecurityTask, Guid>, ISecurityT
return results.ToList();
}
/// <inheritdoc />
public async Task<ICollection<SecurityTask>> CreateManyAsync(IEnumerable<SecurityTask> tasks)
{
var tasksList = tasks?.ToList();
if (tasksList is null || tasksList.Count == 0)
{
return Array.Empty<SecurityTask>();
}
foreach (var task in tasksList)
{
task.SetNewId();
}
var tasksJson = JsonSerializer.Serialize(tasksList);
await using var connection = new SqlConnection(ConnectionString);
await connection.ExecuteAsync(
$"[{Schema}].[{Table}_CreateMany]",
new { SecurityTasksJson = tasksJson },
commandType: CommandType.StoredProcedure);
return tasksList;
}
}

View File

@ -733,4 +733,25 @@ public class OrganizationUserRepository : Repository<Core.Entities.OrganizationU
await dbContext.UserBumpAccountRevisionDateByOrganizationUserIdsAsync(organizationUserIds);
}
public async Task<IEnumerable<OrganizationUserUserDetails>> GetManyDetailsByRoleAsync(Guid organizationId, OrganizationUserType role)
{
using (var scope = ServiceScopeFactory.CreateScope())
{
var dbContext = GetDatabaseContext(scope);
var query = from ou in dbContext.OrganizationUsers
join u in dbContext.Users
on ou.UserId equals u.Id
where ou.OrganizationId == organizationId &&
ou.Type == role &&
ou.Status == OrganizationUserStatusType.Confirmed
select new OrganizationUserUserDetails
{
Id = ou.Id,
Email = ou.Email ?? u.Email,
Permissions = ou.Permissions
};
return await query.ToListAsync();
}
}
}

View File

@ -52,4 +52,28 @@ public class SecurityTaskRepository : Repository<Core.Vault.Entities.SecurityTas
return await query.OrderByDescending(st => st.CreationDate).ToListAsync();
}
/// <inheritdoc />
public async Task<ICollection<Core.Vault.Entities.SecurityTask>> CreateManyAsync(
IEnumerable<Core.Vault.Entities.SecurityTask> tasks)
{
var tasksList = tasks?.ToList();
if (tasksList is null || tasksList.Count == 0)
{
return Array.Empty<SecurityTask>();
}
foreach (var task in tasksList)
{
task.SetNewId();
}
using var scope = ServiceScopeFactory.CreateScope();
var dbContext = GetDatabaseContext(scope);
var entities = Mapper.Map<List<SecurityTask>>(tasksList);
await dbContext.AddRangeAsync(entities);
await dbContext.SaveChangesAsync();
return tasksList;
}
}

View File

@ -11,6 +11,10 @@
<PackageReference Include="Microsoft.AspNetCore.SignalR.StackExchangeRedis" Version="8.0.8" />
</ItemGroup>
<ItemGroup Label="Pinned transitive dependencies">
<PackageReference Include="MessagePack" Version="2.5.192" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Core\Core.csproj" />
<ProjectReference Include="..\SharedWeb\SharedWeb.csproj" />

View File

@ -5,12 +5,40 @@ AS
BEGIN
SET NOCOUNT ON
SELECT TOP 1
*
SELECT
[Id],
[UserId],
[OrganizationId],
[Type],
[Data],
[Attachments],
[CreationDate],
[RevisionDate],
[Favorite],
[FolderId],
[DeletedDate],
[Reprompt],
[Key],
[OrganizationUseTotp],
MAX ([Edit]) AS [Edit],
MAX ([ViewPassword]) AS [ViewPassword]
FROM
[dbo].[UserCipherDetails](@UserId)
WHERE
[Id] = @Id
ORDER BY
[Edit] DESC
END
GROUP BY
[Id],
[UserId],
[OrganizationId],
[Type],
[Data],
[Attachments],
[CreationDate],
[RevisionDate],
[Favorite],
[FolderId],
[DeletedDate],
[Reprompt],
[Key],
[OrganizationUseTotp]
END

View File

@ -0,0 +1,55 @@
CREATE PROCEDURE [dbo].[SecurityTask_CreateMany]
@SecurityTasksJson NVARCHAR(MAX)
AS
BEGIN
SET NOCOUNT ON
CREATE TABLE #TempSecurityTasks
(
[Id] UNIQUEIDENTIFIER,
[OrganizationId] UNIQUEIDENTIFIER,
[CipherId] UNIQUEIDENTIFIER,
[Type] TINYINT,
[Status] TINYINT,
[CreationDate] DATETIME2(7),
[RevisionDate] DATETIME2(7)
)
INSERT INTO #TempSecurityTasks
([Id],
[OrganizationId],
[CipherId],
[Type],
[Status],
[CreationDate],
[RevisionDate])
SELECT CAST(JSON_VALUE([value], '$.Id') AS UNIQUEIDENTIFIER),
CAST(JSON_VALUE([value], '$.OrganizationId') AS UNIQUEIDENTIFIER),
CAST(JSON_VALUE([value], '$.CipherId') AS UNIQUEIDENTIFIER),
CAST(JSON_VALUE([value], '$.Type') AS TINYINT),
CAST(JSON_VALUE([value], '$.Status') AS TINYINT),
CAST(JSON_VALUE([value], '$.CreationDate') AS DATETIME2(7)),
CAST(JSON_VALUE([value], '$.RevisionDate') AS DATETIME2(7))
FROM OPENJSON(@SecurityTasksJson) ST
INSERT INTO [dbo].[SecurityTask]
(
[Id],
[OrganizationId],
[CipherId],
[Type],
[Status],
[CreationDate],
[RevisionDate]
)
SELECT [Id],
[OrganizationId],
[CipherId],
[Type],
[Status],
[CreationDate],
[RevisionDate]
FROM #TempSecurityTasks
DROP TABLE #TempSecurityTasks
END

View File

@ -14,10 +14,9 @@ BEGIN
WHERE
[Id] = @CipherId
)
;WITH [AvailableCollectionsCTE] AS(
SELECT
SELECT
C.[Id]
INTO #TempAvailableCollections
FROM
[dbo].[Collection] C
INNER JOIN
@ -40,38 +39,33 @@ BEGIN
CU.[ReadOnly] = 0
OR CG.[ReadOnly] = 0
)
),
[CollectionCiphersCTE] AS(
SELECT
[CollectionId],
[CipherId]
FROM
[dbo].[CollectionCipher]
WHERE
[CipherId] = @CipherId
-- Insert new collection assignments
INSERT INTO [dbo].[CollectionCipher] (
[CollectionId],
[CipherId]
)
MERGE
[CollectionCiphersCTE] AS [Target]
USING
@CollectionIds AS [Source]
ON
[Target].[CollectionId] = [Source].[Id]
AND [Target].[CipherId] = @CipherId
WHEN NOT MATCHED BY TARGET
AND [Source].[Id] IN (SELECT [Id] FROM [AvailableCollectionsCTE]) THEN
INSERT VALUES
(
[Source].[Id],
@CipherId
)
WHEN NOT MATCHED BY SOURCE
AND [Target].[CipherId] = @CipherId
AND [Target].[CollectionId] IN (SELECT [Id] FROM [AvailableCollectionsCTE]) THEN
DELETE
;
SELECT
[Id],
@CipherId
FROM @CollectionIds
WHERE [Id] IN (SELECT [Id] FROM [#TempAvailableCollections])
AND NOT EXISTS (
SELECT 1
FROM [dbo].[CollectionCipher]
WHERE [CollectionId] = [@CollectionIds].[Id]
AND [CipherId] = @CipherId
);
-- Delete removed collection assignments
DELETE CC
FROM [dbo].[CollectionCipher] CC
WHERE CC.[CipherId] = @CipherId
AND CC.[CollectionId] IN (SELECT [Id] FROM [#TempAvailableCollections])
AND CC.[CollectionId] NOT IN (SELECT [Id] FROM @CollectionIds);
IF @OrgId IS NOT NULL
BEGIN
EXEC [dbo].[User_BumpAccountRevisionDateByOrganizationId] @OrgId
END
DROP TABLE #TempAvailableCollections;
END

View File

@ -0,0 +1,16 @@
CREATE PROCEDURE [dbo].[OrganizationUser_ReadManyDetailsByRole]
@OrganizationId UNIQUEIDENTIFIER,
@Role TINYINT
AS
BEGIN
SET NOCOUNT ON
SELECT
*
FROM
[dbo].[OrganizationUserUserDetailsView]
WHERE
OrganizationId = @OrganizationId
AND Status = 2 -- 2 = Confirmed
AND [Type] = @Role
END

View File

@ -1,6 +1,5 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>

View File

@ -419,22 +419,32 @@ public class AccountsControllerTests : IDisposable
[Theory]
[BitAutoData(true, false)] // User has PublicKey and PrivateKey, and Keys in request are NOT null
[BitAutoData(true, true)] // User has PublicKey and PrivateKey, and Keys in request are null
[BitAutoData(false, false)] // User has neither PublicKey nor PrivateKey, and Keys in request are NOT null
[BitAutoData(false, true)] // User has neither PublicKey nor PrivateKey, and Keys in request are null
[BitAutoData(true, "existingPrivateKey", "existingPublicKey", true)] // allow providing existing keys in the request
[BitAutoData(true, null, null, true)] // allow not setting the public key when the user already has a key
[BitAutoData(false, "newPrivateKey", "newPublicKey", true)] // allow setting new keys when the user has no keys
[BitAutoData(false, null, null, true)] // allow not setting the public key when the user has no keys
// do not allow single key
[BitAutoData(false, "existingPrivateKey", null, false)]
[BitAutoData(false, null, "existingPublicKey", false)]
[BitAutoData(false, "newPrivateKey", null, false)]
[BitAutoData(false, null, "newPublicKey", false)]
[BitAutoData(true, "existingPrivateKey", null, false)]
[BitAutoData(true, null, "existingPublicKey", false)]
[BitAutoData(true, "newPrivateKey", null, false)]
[BitAutoData(true, null, "newPublicKey", false)]
// reject overwriting existing keys
[BitAutoData(true, "newPrivateKey", "newPublicKey", false)]
public async Task PostSetPasswordAsync_WhenUserExistsAndSettingPasswordSucceeds_ShouldHandleKeysCorrectlyAndReturn(
bool hasExistingKeys,
bool shouldSetKeysToNull,
User user,
SetPasswordRequestModel setPasswordRequestModel)
bool hasExistingKeys,
string requestPrivateKey,
string requestPublicKey,
bool shouldSucceed,
User user,
SetPasswordRequestModel setPasswordRequestModel)
{
// Arrange
const string existingPublicKey = "existingPublicKey";
const string existingEncryptedPrivateKey = "existingEncryptedPrivateKey";
const string newPublicKey = "newPublicKey";
const string newEncryptedPrivateKey = "newEncryptedPrivateKey";
const string existingEncryptedPrivateKey = "existingPrivateKey";
if (hasExistingKeys)
{
@ -447,16 +457,16 @@ public class AccountsControllerTests : IDisposable
user.PrivateKey = null;
}
if (shouldSetKeysToNull)
if (requestPrivateKey == null && requestPublicKey == null)
{
setPasswordRequestModel.Keys = null;
}
else
{
setPasswordRequestModel.Keys = new KeysRequestModel()
setPasswordRequestModel.Keys = new KeysRequestModel
{
PublicKey = newPublicKey,
EncryptedPrivateKey = newEncryptedPrivateKey
EncryptedPrivateKey = requestPrivateKey,
PublicKey = requestPublicKey
};
}
@ -469,44 +479,66 @@ public class AccountsControllerTests : IDisposable
.Returns(Task.FromResult(IdentityResult.Success));
// Act
await _sut.PostSetPasswordAsync(setPasswordRequestModel);
// Assert
await _setInitialMasterPasswordCommand.Received(1)
.SetInitialMasterPasswordAsync(
Arg.Is<User>(u => u == user),
Arg.Is<string>(s => s == setPasswordRequestModel.MasterPasswordHash),
Arg.Is<string>(s => s == setPasswordRequestModel.Key),
Arg.Is<string>(s => s == setPasswordRequestModel.OrgIdentifier));
// Additional Assertions for User object modifications
Assert.Equal(setPasswordRequestModel.MasterPasswordHint, user.MasterPasswordHint);
Assert.Equal(setPasswordRequestModel.Kdf, user.Kdf);
Assert.Equal(setPasswordRequestModel.KdfIterations, user.KdfIterations);
Assert.Equal(setPasswordRequestModel.KdfMemory, user.KdfMemory);
Assert.Equal(setPasswordRequestModel.KdfParallelism, user.KdfParallelism);
Assert.Equal(setPasswordRequestModel.Key, user.Key);
if (hasExistingKeys)
if (shouldSucceed)
{
// User Keys should not be modified
Assert.Equal(existingPublicKey, user.PublicKey);
Assert.Equal(existingEncryptedPrivateKey, user.PrivateKey);
}
else if (!shouldSetKeysToNull)
{
// User had no keys so they should be set to the request model's keys
Assert.Equal(setPasswordRequestModel.Keys.PublicKey, user.PublicKey);
Assert.Equal(setPasswordRequestModel.Keys.EncryptedPrivateKey, user.PrivateKey);
await _sut.PostSetPasswordAsync(setPasswordRequestModel);
// Assert
await _setInitialMasterPasswordCommand.Received(1)
.SetInitialMasterPasswordAsync(
Arg.Is<User>(u => u == user),
Arg.Is<string>(s => s == setPasswordRequestModel.MasterPasswordHash),
Arg.Is<string>(s => s == setPasswordRequestModel.Key),
Arg.Is<string>(s => s == setPasswordRequestModel.OrgIdentifier));
// Additional Assertions for User object modifications
Assert.Equal(setPasswordRequestModel.MasterPasswordHint, user.MasterPasswordHint);
Assert.Equal(setPasswordRequestModel.Kdf, user.Kdf);
Assert.Equal(setPasswordRequestModel.KdfIterations, user.KdfIterations);
Assert.Equal(setPasswordRequestModel.KdfMemory, user.KdfMemory);
Assert.Equal(setPasswordRequestModel.KdfParallelism, user.KdfParallelism);
Assert.Equal(setPasswordRequestModel.Key, user.Key);
}
else
{
// User had no keys and the request model's keys were null, so they should be set to null
Assert.Null(user.PublicKey);
Assert.Null(user.PrivateKey);
await Assert.ThrowsAsync<BadRequestException>(() => _sut.PostSetPasswordAsync(setPasswordRequestModel));
}
}
[Theory]
[BitAutoData]
public async Task PostSetPasswordAsync_WhenUserExistsAndHasKeysAndKeysAreUpdated_ShouldThrowAsync(
User user,
SetPasswordRequestModel setPasswordRequestModel)
{
// Arrange
const string existingPublicKey = "existingPublicKey";
const string existingEncryptedPrivateKey = "existingEncryptedPrivateKey";
const string newPublicKey = "newPublicKey";
const string newEncryptedPrivateKey = "newEncryptedPrivateKey";
user.PublicKey = existingPublicKey;
user.PrivateKey = existingEncryptedPrivateKey;
setPasswordRequestModel.Keys = new KeysRequestModel()
{
PublicKey = newPublicKey,
EncryptedPrivateKey = newEncryptedPrivateKey
};
_userService.GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).Returns(Task.FromResult(user));
_setInitialMasterPasswordCommand.SetInitialMasterPasswordAsync(
user,
setPasswordRequestModel.MasterPasswordHash,
setPasswordRequestModel.Key,
setPasswordRequestModel.OrgIdentifier)
.Returns(Task.FromResult(IdentityResult.Success));
// Act & Assert
await Assert.ThrowsAsync<BadRequestException>(() => _sut.PostSetPasswordAsync(setPasswordRequestModel));
}
[Theory]
[BitAutoData]
public async Task PostSetPasswordAsync_WhenUserDoesNotExist_ShouldThrowUnauthorizedAccessException(
@ -525,6 +557,7 @@ public class AccountsControllerTests : IDisposable
User user,
SetPasswordRequestModel model)
{
model.Keys = null;
// Arrange
_userService.GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).Returns(Task.FromResult(user));
_setInitialMasterPasswordCommand.SetInitialMasterPasswordAsync(Arg.Any<User>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>())

View File

@ -127,6 +127,7 @@ public class CiphersControllerTests
UserId = userId,
OrganizationId = Guid.NewGuid(),
Type = CipherType.Login,
ViewPassword = true,
Data = @"
{
""Uris"": [

View File

@ -6,7 +6,6 @@
<ItemGroup>
<PackageReference Include="Divergic.Logging.Xunit" Version="4.3.0" />
<PackageReference Include="FluentAssertions" Version="7.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="$(MicrosoftNetTestSdkVersion)" />
<PackageReference Include="RichardSzalay.MockHttp" Version="7.0.0" />
<PackageReference Include="xunit" Version="$(XUnitVersion)" />

View File

@ -1,4 +1,5 @@
using Bit.Billing.Controllers;
using System.Text.Json;
using Bit.Billing.Controllers;
using Bit.Billing.Models;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Entities;
@ -70,6 +71,159 @@ public class FreshdeskControllerTests
_ = mockHttpMessageHandler.Received(1).Send(Arg.Is<HttpRequestMessage>(m => m.Method == HttpMethod.Post && m.RequestUri.ToString().EndsWith($"{model.TicketId}/notes")), Arg.Any<CancellationToken>());
}
[Theory]
[BitAutoData((string)null, null)]
[BitAutoData((string)null)]
[BitAutoData(WebhookKey, null)]
public async Task PostWebhookOnyxAi_InvalidWebhookKey_results_in_BadRequest(
string freshdeskWebhookKey, FreshdeskWebhookModel model,
BillingSettings billingSettings, SutProvider<FreshdeskController> sutProvider)
{
sutProvider.GetDependency<IOptions<BillingSettings>>()
.Value.FreshDesk.WebhookKey.Returns(billingSettings.FreshDesk.WebhookKey);
var response = await sutProvider.Sut.PostWebhookOnyxAi(freshdeskWebhookKey, model);
var statusCodeResult = Assert.IsAssignableFrom<StatusCodeResult>(response);
Assert.Equal(StatusCodes.Status400BadRequest, statusCodeResult.StatusCode);
}
[Theory]
[BitAutoData(WebhookKey)]
public async Task PostWebhookOnyxAi_invalid_ticketid_results_in_BadRequest(
string freshdeskWebhookKey, FreshdeskWebhookModel model, SutProvider<FreshdeskController> sutProvider)
{
sutProvider.GetDependency<IOptions<BillingSettings>>()
.Value.FreshDesk.WebhookKey.Returns(freshdeskWebhookKey);
var mockHttpMessageHandler = Substitute.ForPartsOf<MockHttpMessageHandler>();
var mockResponse = new HttpResponseMessage(System.Net.HttpStatusCode.BadRequest);
mockHttpMessageHandler.Send(Arg.Any<HttpRequestMessage>(), Arg.Any<CancellationToken>())
.Returns(mockResponse);
var httpClient = new HttpClient(mockHttpMessageHandler);
sutProvider.GetDependency<IHttpClientFactory>().CreateClient("FreshdeskApi").Returns(httpClient);
var response = await sutProvider.Sut.PostWebhookOnyxAi(freshdeskWebhookKey, model);
var result = Assert.IsAssignableFrom<BadRequestObjectResult>(response);
Assert.Equal(StatusCodes.Status400BadRequest, result.StatusCode);
}
[Theory]
[BitAutoData(WebhookKey)]
public async Task PostWebhookOnyxAi_invalid_freshdesk_response_results_in_BadRequest(
string freshdeskWebhookKey, FreshdeskWebhookModel model,
SutProvider<FreshdeskController> sutProvider)
{
sutProvider.GetDependency<IOptions<BillingSettings>>()
.Value.FreshDesk.WebhookKey.Returns(freshdeskWebhookKey);
var mockHttpMessageHandler = Substitute.ForPartsOf<MockHttpMessageHandler>();
var mockResponse = new HttpResponseMessage(System.Net.HttpStatusCode.OK)
{
Content = new StringContent("non json content. expect json deserializer to throw error")
};
mockHttpMessageHandler.Send(Arg.Any<HttpRequestMessage>(), Arg.Any<CancellationToken>())
.Returns(mockResponse);
var httpClient = new HttpClient(mockHttpMessageHandler);
sutProvider.GetDependency<IHttpClientFactory>().CreateClient("FreshdeskApi").Returns(httpClient);
var response = await sutProvider.Sut.PostWebhookOnyxAi(freshdeskWebhookKey, model);
var result = Assert.IsAssignableFrom<BadRequestObjectResult>(response);
Assert.Equal(StatusCodes.Status400BadRequest, result.StatusCode);
}
[Theory]
[BitAutoData(WebhookKey)]
public async Task PostWebhookOnyxAi_invalid_onyx_response_results_in_BadRequest(
string freshdeskWebhookKey, FreshdeskWebhookModel model,
FreshdeskViewTicketModel freshdeskTicketInfo, SutProvider<FreshdeskController> sutProvider)
{
var billingSettings = sutProvider.GetDependency<IOptions<BillingSettings>>().Value;
billingSettings.FreshDesk.WebhookKey.Returns(freshdeskWebhookKey);
billingSettings.Onyx.BaseUrl.Returns("http://simulate-onyx-api.com/api");
// mocking freshdesk Api request for ticket info
var mockFreshdeskHttpMessageHandler = Substitute.ForPartsOf<MockHttpMessageHandler>();
var mockFreshdeskResponse = new HttpResponseMessage(System.Net.HttpStatusCode.OK)
{
Content = new StringContent(JsonSerializer.Serialize(freshdeskTicketInfo))
};
mockFreshdeskHttpMessageHandler.Send(Arg.Any<HttpRequestMessage>(), Arg.Any<CancellationToken>())
.Returns(mockFreshdeskResponse);
var freshdeskHttpClient = new HttpClient(mockFreshdeskHttpMessageHandler);
// mocking Onyx api response given a ticket description
var mockOnyxHttpMessageHandler = Substitute.ForPartsOf<MockHttpMessageHandler>();
var mockOnyxResponse = new HttpResponseMessage(System.Net.HttpStatusCode.BadRequest);
mockOnyxHttpMessageHandler.Send(Arg.Any<HttpRequestMessage>(), Arg.Any<CancellationToken>())
.Returns(mockOnyxResponse);
var onyxHttpClient = new HttpClient(mockOnyxHttpMessageHandler);
sutProvider.GetDependency<IHttpClientFactory>().CreateClient("FreshdeskApi").Returns(freshdeskHttpClient);
sutProvider.GetDependency<IHttpClientFactory>().CreateClient("OnyxApi").Returns(onyxHttpClient);
var response = await sutProvider.Sut.PostWebhookOnyxAi(freshdeskWebhookKey, model);
var result = Assert.IsAssignableFrom<BadRequestObjectResult>(response);
Assert.Equal(StatusCodes.Status400BadRequest, result.StatusCode);
}
[Theory]
[BitAutoData(WebhookKey)]
public async Task PostWebhookOnyxAi_success(
string freshdeskWebhookKey, FreshdeskWebhookModel model,
FreshdeskViewTicketModel freshdeskTicketInfo,
OnyxAnswerWithCitationResponseModel onyxResponse,
SutProvider<FreshdeskController> sutProvider)
{
var billingSettings = sutProvider.GetDependency<IOptions<BillingSettings>>().Value;
billingSettings.FreshDesk.WebhookKey.Returns(freshdeskWebhookKey);
billingSettings.Onyx.BaseUrl.Returns("http://simulate-onyx-api.com/api");
// mocking freshdesk Api request for ticket info (GET)
var mockFreshdeskHttpMessageHandler = Substitute.ForPartsOf<MockHttpMessageHandler>();
var mockFreshdeskResponse = new HttpResponseMessage(System.Net.HttpStatusCode.OK)
{
Content = new StringContent(JsonSerializer.Serialize(freshdeskTicketInfo))
};
mockFreshdeskHttpMessageHandler.Send(
Arg.Is<HttpRequestMessage>(_ => _.Method == HttpMethod.Get),
Arg.Any<CancellationToken>())
.Returns(mockFreshdeskResponse);
// mocking freshdesk api add note request (POST)
var mockFreshdeskAddNoteResponse = new HttpResponseMessage(System.Net.HttpStatusCode.BadRequest);
mockFreshdeskHttpMessageHandler.Send(
Arg.Is<HttpRequestMessage>(_ => _.Method == HttpMethod.Post),
Arg.Any<CancellationToken>())
.Returns(mockFreshdeskAddNoteResponse);
var freshdeskHttpClient = new HttpClient(mockFreshdeskHttpMessageHandler);
// mocking Onyx api response given a ticket description
var mockOnyxHttpMessageHandler = Substitute.ForPartsOf<MockHttpMessageHandler>();
onyxResponse.ErrorMsg = string.Empty;
var mockOnyxResponse = new HttpResponseMessage(System.Net.HttpStatusCode.OK)
{
Content = new StringContent(JsonSerializer.Serialize(onyxResponse))
};
mockOnyxHttpMessageHandler.Send(Arg.Any<HttpRequestMessage>(), Arg.Any<CancellationToken>())
.Returns(mockOnyxResponse);
var onyxHttpClient = new HttpClient(mockOnyxHttpMessageHandler);
sutProvider.GetDependency<IHttpClientFactory>().CreateClient("FreshdeskApi").Returns(freshdeskHttpClient);
sutProvider.GetDependency<IHttpClientFactory>().CreateClient("OnyxApi").Returns(onyxHttpClient);
var response = await sutProvider.Sut.PostWebhookOnyxAi(freshdeskWebhookKey, model);
var result = Assert.IsAssignableFrom<OkResult>(response);
Assert.Equal(StatusCodes.Status200OK, result.StatusCode);
}
public class MockHttpMessageHandler : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)

View File

@ -8,7 +8,6 @@ using Bit.Core.Enums;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Divergic.Logging.Xunit;
using FluentAssertions;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Infrastructure;
@ -577,14 +576,14 @@ public class PayPalControllerTests
{
var statusCodeActionResult = (IStatusCodeActionResult)result;
statusCodeActionResult.StatusCode.Should().Be(statusCode);
Assert.Equal(statusCode, statusCodeActionResult.StatusCode);
}
private static void Logged(ICacheLogger logger, LogLevel logLevel, string message)
{
logger.Last.Should().NotBeNull();
logger.Last!.LogLevel.Should().Be(logLevel);
logger.Last!.Message.Should().Be(message);
Assert.NotNull(logger.Last);
Assert.Equal(logLevel, logger.Last!.LogLevel);
Assert.Equal(message, logger.Last!.Message);
}
private static void LoggedError(ICacheLogger logger, string message)

View File

@ -2,7 +2,6 @@
using Bit.Billing.Services.Implementations;
using Bit.Billing.Test.Utilities;
using Bit.Core.Settings;
using FluentAssertions;
using Microsoft.Extensions.Logging;
using NSubstitute;
using Stripe;
@ -36,10 +35,8 @@ public class StripeEventServiceTests
var function = async () => await _stripeEventService.GetCharge(stripeEvent);
// Assert
await function
.Should()
.ThrowAsync<Exception>()
.WithMessage($"Stripe event with ID '{stripeEvent.Id}' does not have object matching type '{nameof(Charge)}'");
var exception = await Assert.ThrowsAsync<Exception>(function);
Assert.Equal($"Stripe event with ID '{stripeEvent.Id}' does not have object matching type '{nameof(Charge)}'", exception.Message);
await _stripeFacade.DidNotReceiveWithAnyArgs().GetCharge(
Arg.Any<string>(),
@ -58,7 +55,7 @@ public class StripeEventServiceTests
var charge = await _stripeEventService.GetCharge(stripeEvent);
// Assert
charge.Should().BeEquivalentTo(stripeEvent.Data.Object as Charge);
Assert.Equivalent(stripeEvent.Data.Object as Charge, charge, true);
await _stripeFacade.DidNotReceiveWithAnyArgs().GetCharge(
Arg.Any<string>(),
@ -88,8 +85,8 @@ public class StripeEventServiceTests
var charge = await _stripeEventService.GetCharge(stripeEvent, true, expand);
// Assert
charge.Should().Be(apiCharge);
charge.Should().NotBeSameAs(eventCharge);
Assert.Equal(apiCharge, charge);
Assert.NotSame(eventCharge, charge);
await _stripeFacade.Received().GetCharge(
apiCharge.Id,
@ -110,10 +107,8 @@ public class StripeEventServiceTests
var function = async () => await _stripeEventService.GetCustomer(stripeEvent);
// Assert
await function
.Should()
.ThrowAsync<Exception>()
.WithMessage($"Stripe event with ID '{stripeEvent.Id}' does not have object matching type '{nameof(Customer)}'");
var exception = await Assert.ThrowsAsync<Exception>(function);
Assert.Equal($"Stripe event with ID '{stripeEvent.Id}' does not have object matching type '{nameof(Customer)}'", exception.Message);
await _stripeFacade.DidNotReceiveWithAnyArgs().GetCustomer(
Arg.Any<string>(),
@ -132,7 +127,7 @@ public class StripeEventServiceTests
var customer = await _stripeEventService.GetCustomer(stripeEvent);
// Assert
customer.Should().BeEquivalentTo(stripeEvent.Data.Object as Customer);
Assert.Equivalent(stripeEvent.Data.Object as Customer, customer, true);
await _stripeFacade.DidNotReceiveWithAnyArgs().GetCustomer(
Arg.Any<string>(),
@ -162,8 +157,8 @@ public class StripeEventServiceTests
var customer = await _stripeEventService.GetCustomer(stripeEvent, true, expand);
// Assert
customer.Should().Be(apiCustomer);
customer.Should().NotBeSameAs(eventCustomer);
Assert.Equal(apiCustomer, customer);
Assert.NotSame(eventCustomer, customer);
await _stripeFacade.Received().GetCustomer(
apiCustomer.Id,
@ -184,10 +179,8 @@ public class StripeEventServiceTests
var function = async () => await _stripeEventService.GetInvoice(stripeEvent);
// Assert
await function
.Should()
.ThrowAsync<Exception>()
.WithMessage($"Stripe event with ID '{stripeEvent.Id}' does not have object matching type '{nameof(Invoice)}'");
var exception = await Assert.ThrowsAsync<Exception>(function);
Assert.Equal($"Stripe event with ID '{stripeEvent.Id}' does not have object matching type '{nameof(Invoice)}'", exception.Message);
await _stripeFacade.DidNotReceiveWithAnyArgs().GetInvoice(
Arg.Any<string>(),
@ -206,7 +199,7 @@ public class StripeEventServiceTests
var invoice = await _stripeEventService.GetInvoice(stripeEvent);
// Assert
invoice.Should().BeEquivalentTo(stripeEvent.Data.Object as Invoice);
Assert.Equivalent(stripeEvent.Data.Object as Invoice, invoice, true);
await _stripeFacade.DidNotReceiveWithAnyArgs().GetInvoice(
Arg.Any<string>(),
@ -236,8 +229,8 @@ public class StripeEventServiceTests
var invoice = await _stripeEventService.GetInvoice(stripeEvent, true, expand);
// Assert
invoice.Should().Be(apiInvoice);
invoice.Should().NotBeSameAs(eventInvoice);
Assert.Equal(apiInvoice, invoice);
Assert.NotSame(eventInvoice, invoice);
await _stripeFacade.Received().GetInvoice(
apiInvoice.Id,
@ -258,10 +251,8 @@ public class StripeEventServiceTests
var function = async () => await _stripeEventService.GetPaymentMethod(stripeEvent);
// Assert
await function
.Should()
.ThrowAsync<Exception>()
.WithMessage($"Stripe event with ID '{stripeEvent.Id}' does not have object matching type '{nameof(PaymentMethod)}'");
var exception = await Assert.ThrowsAsync<Exception>(function);
Assert.Equal($"Stripe event with ID '{stripeEvent.Id}' does not have object matching type '{nameof(PaymentMethod)}'", exception.Message);
await _stripeFacade.DidNotReceiveWithAnyArgs().GetPaymentMethod(
Arg.Any<string>(),
@ -280,7 +271,7 @@ public class StripeEventServiceTests
var paymentMethod = await _stripeEventService.GetPaymentMethod(stripeEvent);
// Assert
paymentMethod.Should().BeEquivalentTo(stripeEvent.Data.Object as PaymentMethod);
Assert.Equivalent(stripeEvent.Data.Object as PaymentMethod, paymentMethod, true);
await _stripeFacade.DidNotReceiveWithAnyArgs().GetPaymentMethod(
Arg.Any<string>(),
@ -310,8 +301,8 @@ public class StripeEventServiceTests
var paymentMethod = await _stripeEventService.GetPaymentMethod(stripeEvent, true, expand);
// Assert
paymentMethod.Should().Be(apiPaymentMethod);
paymentMethod.Should().NotBeSameAs(eventPaymentMethod);
Assert.Equal(apiPaymentMethod, paymentMethod);
Assert.NotSame(eventPaymentMethod, paymentMethod);
await _stripeFacade.Received().GetPaymentMethod(
apiPaymentMethod.Id,
@ -332,10 +323,8 @@ public class StripeEventServiceTests
var function = async () => await _stripeEventService.GetSubscription(stripeEvent);
// Assert
await function
.Should()
.ThrowAsync<Exception>()
.WithMessage($"Stripe event with ID '{stripeEvent.Id}' does not have object matching type '{nameof(Subscription)}'");
var exception = await Assert.ThrowsAsync<Exception>(function);
Assert.Equal($"Stripe event with ID '{stripeEvent.Id}' does not have object matching type '{nameof(Subscription)}'", exception.Message);
await _stripeFacade.DidNotReceiveWithAnyArgs().GetSubscription(
Arg.Any<string>(),
@ -354,7 +343,7 @@ public class StripeEventServiceTests
var subscription = await _stripeEventService.GetSubscription(stripeEvent);
// Assert
subscription.Should().BeEquivalentTo(stripeEvent.Data.Object as Subscription);
Assert.Equivalent(stripeEvent.Data.Object as Subscription, subscription, true);
await _stripeFacade.DidNotReceiveWithAnyArgs().GetSubscription(
Arg.Any<string>(),
@ -384,8 +373,8 @@ public class StripeEventServiceTests
var subscription = await _stripeEventService.GetSubscription(stripeEvent, true, expand);
// Assert
subscription.Should().Be(apiSubscription);
subscription.Should().NotBeSameAs(eventSubscription);
Assert.Equal(apiSubscription, subscription);
Assert.NotSame(eventSubscription, subscription);
await _stripeFacade.Received().GetSubscription(
apiSubscription.Id,
@ -417,7 +406,7 @@ public class StripeEventServiceTests
var cloudRegionValid = await _stripeEventService.ValidateCloudRegion(stripeEvent);
// Assert
cloudRegionValid.Should().BeTrue();
Assert.True(cloudRegionValid);
await _stripeFacade.Received(1).GetSubscription(
subscription.Id,
@ -447,7 +436,7 @@ public class StripeEventServiceTests
var cloudRegionValid = await _stripeEventService.ValidateCloudRegion(stripeEvent);
// Assert
cloudRegionValid.Should().BeTrue();
Assert.True(cloudRegionValid);
await _stripeFacade.Received(1).GetCharge(
charge.Id,
@ -475,7 +464,7 @@ public class StripeEventServiceTests
var cloudRegionValid = await _stripeEventService.ValidateCloudRegion(stripeEvent);
// Assert
cloudRegionValid.Should().BeTrue();
Assert.True(cloudRegionValid);
await _stripeFacade.Received(1).GetCustomer(
invoice.CustomerId,
@ -505,7 +494,7 @@ public class StripeEventServiceTests
var cloudRegionValid = await _stripeEventService.ValidateCloudRegion(stripeEvent);
// Assert
cloudRegionValid.Should().BeTrue();
Assert.True(cloudRegionValid);
await _stripeFacade.Received(1).GetInvoice(
invoice.Id,
@ -535,7 +524,7 @@ public class StripeEventServiceTests
var cloudRegionValid = await _stripeEventService.ValidateCloudRegion(stripeEvent);
// Assert
cloudRegionValid.Should().BeTrue();
Assert.True(cloudRegionValid);
await _stripeFacade.Received(1).GetPaymentMethod(
paymentMethod.Id,
@ -561,7 +550,7 @@ public class StripeEventServiceTests
var cloudRegionValid = await _stripeEventService.ValidateCloudRegion(stripeEvent);
// Assert
cloudRegionValid.Should().BeTrue();
Assert.True(cloudRegionValid);
await _stripeFacade.Received(1).GetCustomer(
customer.Id,
@ -592,7 +581,7 @@ public class StripeEventServiceTests
var cloudRegionValid = await _stripeEventService.ValidateCloudRegion(stripeEvent);
// Assert
cloudRegionValid.Should().BeFalse();
Assert.False(cloudRegionValid);
await _stripeFacade.Received(1).GetSubscription(
subscription.Id,
@ -623,7 +612,7 @@ public class StripeEventServiceTests
var cloudRegionValid = await _stripeEventService.ValidateCloudRegion(stripeEvent);
// Assert
cloudRegionValid.Should().BeTrue();
Assert.True(cloudRegionValid);
await _stripeFacade.Received(1).GetSubscription(
subscription.Id,
@ -657,7 +646,7 @@ public class StripeEventServiceTests
var cloudRegionValid = await _stripeEventService.ValidateCloudRegion(stripeEvent);
// Assert
cloudRegionValid.Should().BeTrue();
Assert.True(cloudRegionValid);
await _stripeFacade.Received(1).GetSubscription(
subscription.Id,

View File

@ -19,6 +19,7 @@ using Bit.Core.Models.Data;
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
using Bit.Core.Models.Mail;
using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface;
using Bit.Core.Platform.Push;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Settings;
@ -40,8 +41,6 @@ using Xunit;
using Organization = Bit.Core.AdminConsole.Entities.Organization;
using OrganizationUser = Bit.Core.Entities.OrganizationUser;
#nullable enable
namespace Bit.Core.Test.Services;
[SutProviderCustomize]
@ -1450,76 +1449,174 @@ OrganizationUserInvite invite, SutProvider<OrganizationService> sutProvider)
[OrganizationUser] OrganizationUser organizationUser, SutProvider<OrganizationService> sutProvider)
{
RestoreRevokeUser_Setup(organization, owner, organizationUser, sutProvider);
var organizationUserRepository = sutProvider.GetDependency<IOrganizationUserRepository>();
var eventService = sutProvider.GetDependency<IEventService>();
await sutProvider.Sut.RevokeUserAsync(organizationUser, owner.Id);
await organizationUserRepository.Received().RevokeAsync(organizationUser.Id);
await eventService.Received()
await sutProvider.GetDependency<IOrganizationUserRepository>()
.Received(1)
.RevokeAsync(organizationUser.Id);
await sutProvider.GetDependency<IEventService>()
.Received(1)
.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Revoked);
}
[Theory, BitAutoData]
public async Task RevokeUser_WithPushSyncOrgKeysOnRevokeRestoreEnabled_Success(Organization organization, [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner,
[OrganizationUser] OrganizationUser organizationUser, SutProvider<OrganizationService> sutProvider)
{
RestoreRevokeUser_Setup(organization, owner, organizationUser, sutProvider);
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.PushSyncOrgKeysOnRevokeRestore)
.Returns(true);
await sutProvider.Sut.RevokeUserAsync(organizationUser, owner.Id);
await sutProvider.GetDependency<IOrganizationUserRepository>()
.Received(1)
.RevokeAsync(organizationUser.Id);
await sutProvider.GetDependency<IEventService>()
.Received(1)
.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Revoked);
await sutProvider.GetDependency<IPushNotificationService>()
.Received(1)
.PushSyncOrgKeysAsync(organizationUser.UserId!.Value);
}
[Theory, BitAutoData]
public async Task RevokeUser_WithEventSystemUser_Success(Organization organization, [OrganizationUser] OrganizationUser organizationUser, EventSystemUser eventSystemUser, SutProvider<OrganizationService> sutProvider)
{
RestoreRevokeUser_Setup(organization, null, organizationUser, sutProvider);
var organizationUserRepository = sutProvider.GetDependency<IOrganizationUserRepository>();
var eventService = sutProvider.GetDependency<IEventService>();
await sutProvider.Sut.RevokeUserAsync(organizationUser, eventSystemUser);
await organizationUserRepository.Received().RevokeAsync(organizationUser.Id);
await eventService.Received()
await sutProvider.GetDependency<IOrganizationUserRepository>()
.Received(1)
.RevokeAsync(organizationUser.Id);
await sutProvider.GetDependency<IEventService>()
.Received(1)
.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Revoked, eventSystemUser);
}
[Theory, BitAutoData]
public async Task RevokeUser_WithEventSystemUser_WithPushSyncOrgKeysOnRevokeRestoreEnabled_Success(Organization organization, [OrganizationUser] OrganizationUser organizationUser, EventSystemUser eventSystemUser, SutProvider<OrganizationService> sutProvider)
{
RestoreRevokeUser_Setup(organization, null, organizationUser, sutProvider);
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.PushSyncOrgKeysOnRevokeRestore)
.Returns(true);
await sutProvider.Sut.RevokeUserAsync(organizationUser, eventSystemUser);
await sutProvider.GetDependency<IOrganizationUserRepository>()
.Received(1)
.RevokeAsync(organizationUser.Id);
await sutProvider.GetDependency<IEventService>()
.Received(1)
.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Revoked, eventSystemUser);
await sutProvider.GetDependency<IPushNotificationService>()
.Received(1)
.PushSyncOrgKeysAsync(organizationUser.UserId!.Value);
}
[Theory, BitAutoData]
public async Task RestoreUser_Success(Organization organization, [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner,
[OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser organizationUser, SutProvider<OrganizationService> sutProvider)
{
RestoreRevokeUser_Setup(organization, owner, organizationUser, sutProvider);
var organizationUserRepository = sutProvider.GetDependency<IOrganizationUserRepository>();
var eventService = sutProvider.GetDependency<IEventService>();
await sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id);
await organizationUserRepository.Received().RestoreAsync(organizationUser.Id, OrganizationUserStatusType.Invited);
await eventService.Received()
await sutProvider.GetDependency<IOrganizationUserRepository>()
.Received(1)
.RestoreAsync(organizationUser.Id, OrganizationUserStatusType.Invited);
await sutProvider.GetDependency<IEventService>()
.Received(1)
.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Restored);
}
[Theory, BitAutoData]
public async Task RestoreUser_WithPushSyncOrgKeysOnRevokeRestoreEnabled_Success(Organization organization, [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner,
[OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser organizationUser, SutProvider<OrganizationService> sutProvider)
{
RestoreRevokeUser_Setup(organization, owner, organizationUser, sutProvider);
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.PushSyncOrgKeysOnRevokeRestore)
.Returns(true);
await sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id);
await sutProvider.GetDependency<IOrganizationUserRepository>()
.Received(1)
.RestoreAsync(organizationUser.Id, OrganizationUserStatusType.Invited);
await sutProvider.GetDependency<IEventService>()
.Received(1)
.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Restored);
await sutProvider.GetDependency<IPushNotificationService>()
.Received(1)
.PushSyncOrgKeysAsync(organizationUser.UserId!.Value);
}
[Theory, BitAutoData]
public async Task RestoreUser_WithEventSystemUser_Success(Organization organization, [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser organizationUser, EventSystemUser eventSystemUser, SutProvider<OrganizationService> sutProvider)
{
RestoreRevokeUser_Setup(organization, null, organizationUser, sutProvider);
var organizationUserRepository = sutProvider.GetDependency<IOrganizationUserRepository>();
var eventService = sutProvider.GetDependency<IEventService>();
await sutProvider.Sut.RestoreUserAsync(organizationUser, eventSystemUser);
await organizationUserRepository.Received().RestoreAsync(organizationUser.Id, OrganizationUserStatusType.Invited);
await eventService.Received()
await sutProvider.GetDependency<IOrganizationUserRepository>()
.Received(1)
.RestoreAsync(organizationUser.Id, OrganizationUserStatusType.Invited);
await sutProvider.GetDependency<IEventService>()
.Received(1)
.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Restored, eventSystemUser);
}
[Theory, BitAutoData]
public async Task RestoreUser_WithEventSystemUser_WithPushSyncOrgKeysOnRevokeRestoreEnabled_Success(Organization organization, [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser organizationUser, EventSystemUser eventSystemUser, SutProvider<OrganizationService> sutProvider)
{
RestoreRevokeUser_Setup(organization, null, organizationUser, sutProvider);
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.PushSyncOrgKeysOnRevokeRestore)
.Returns(true);
await sutProvider.Sut.RestoreUserAsync(organizationUser, eventSystemUser);
await sutProvider.GetDependency<IOrganizationUserRepository>()
.Received(1)
.RestoreAsync(organizationUser.Id, OrganizationUserStatusType.Invited);
await sutProvider.GetDependency<IEventService>()
.Received(1)
.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Restored, eventSystemUser);
await sutProvider.GetDependency<IPushNotificationService>()
.Received(1)
.PushSyncOrgKeysAsync(organizationUser.UserId!.Value);
}
[Theory, BitAutoData]
public async Task RestoreUser_RestoreThemselves_Fails(Organization organization, [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner,
[OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser organizationUser, SutProvider<OrganizationService> sutProvider)
{
organizationUser.UserId = owner.Id;
RestoreRevokeUser_Setup(organization, owner, organizationUser, sutProvider);
var organizationUserRepository = sutProvider.GetDependency<IOrganizationUserRepository>();
var eventService = sutProvider.GetDependency<IEventService>();
var exception = await Assert.ThrowsAsync<BadRequestException>(
() => sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id));
Assert.Contains("you cannot restore yourself", exception.Message.ToLowerInvariant());
await organizationUserRepository.DidNotReceiveWithAnyArgs().RestoreAsync(Arg.Any<Guid>(), Arg.Any<OrganizationUserStatusType>());
await eventService.DidNotReceiveWithAnyArgs()
await sutProvider.GetDependency<IOrganizationUserRepository>()
.DidNotReceiveWithAnyArgs()
.RestoreAsync(Arg.Any<Guid>(), Arg.Any<OrganizationUserStatusType>());
await sutProvider.GetDependency<IEventService>()
.DidNotReceiveWithAnyArgs()
.LogOrganizationUserEventAsync(Arg.Any<OrganizationUser>(), Arg.Any<EventType>(), Arg.Any<EventSystemUser>());
await sutProvider.GetDependency<IPushNotificationService>()
.DidNotReceiveWithAnyArgs()
.PushSyncOrgKeysAsync(Arg.Any<Guid>());
}
[Theory]
@ -1531,17 +1628,21 @@ OrganizationUserInvite invite, SutProvider<OrganizationService> sutProvider)
{
restoringUser.Type = restoringUserType;
RestoreRevokeUser_Setup(organization, restoringUser, organizationUser, sutProvider);
var organizationUserRepository = sutProvider.GetDependency<IOrganizationUserRepository>();
var eventService = sutProvider.GetDependency<IEventService>();
var exception = await Assert.ThrowsAsync<BadRequestException>(
() => sutProvider.Sut.RestoreUserAsync(organizationUser, restoringUser.Id));
Assert.Contains("only owners can restore other owners", exception.Message.ToLowerInvariant());
await organizationUserRepository.DidNotReceiveWithAnyArgs().RestoreAsync(Arg.Any<Guid>(), Arg.Any<OrganizationUserStatusType>());
await eventService.DidNotReceiveWithAnyArgs()
await sutProvider.GetDependency<IOrganizationUserRepository>()
.DidNotReceiveWithAnyArgs()
.RestoreAsync(Arg.Any<Guid>(), Arg.Any<OrganizationUserStatusType>());
await sutProvider.GetDependency<IEventService>()
.DidNotReceiveWithAnyArgs()
.LogOrganizationUserEventAsync(Arg.Any<OrganizationUser>(), Arg.Any<EventType>(), Arg.Any<EventSystemUser>());
await sutProvider.GetDependency<IPushNotificationService>()
.DidNotReceiveWithAnyArgs()
.PushSyncOrgKeysAsync(Arg.Any<Guid>());
}
[Theory]
@ -1553,17 +1654,21 @@ OrganizationUserInvite invite, SutProvider<OrganizationService> sutProvider)
{
organizationUser.Status = userStatus;
RestoreRevokeUser_Setup(organization, owner, organizationUser, sutProvider);
var organizationUserRepository = sutProvider.GetDependency<IOrganizationUserRepository>();
var eventService = sutProvider.GetDependency<IEventService>();
var exception = await Assert.ThrowsAsync<BadRequestException>(
() => sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id));
Assert.Contains("already active", exception.Message.ToLowerInvariant());
await organizationUserRepository.DidNotReceiveWithAnyArgs().RestoreAsync(Arg.Any<Guid>(), Arg.Any<OrganizationUserStatusType>());
await eventService.DidNotReceiveWithAnyArgs()
await sutProvider.GetDependency<IOrganizationUserRepository>()
.DidNotReceiveWithAnyArgs()
.RestoreAsync(Arg.Any<Guid>(), Arg.Any<OrganizationUserStatusType>());
await sutProvider.GetDependency<IEventService>()
.DidNotReceiveWithAnyArgs()
.LogOrganizationUserEventAsync(Arg.Any<OrganizationUser>(), Arg.Any<EventType>(), Arg.Any<EventSystemUser>());
await sutProvider.GetDependency<IPushNotificationService>()
.DidNotReceiveWithAnyArgs()
.PushSyncOrgKeysAsync(Arg.Any<Guid>());
}
[Theory, BitAutoData]
@ -1575,8 +1680,6 @@ OrganizationUserInvite invite, SutProvider<OrganizationService> sutProvider)
{
organizationUser.Email = null; // this is required to mock that the user as had already been confirmed before the revoke
RestoreRevokeUser_Setup(organization, owner, organizationUser, sutProvider);
var organizationUserRepository = sutProvider.GetDependency<IOrganizationUserRepository>();
var eventService = sutProvider.GetDependency<IEventService>();
sutProvider.GetDependency<IPolicyService>()
.AnyPoliciesApplicableToUserAsync(organizationUser.UserId.Value, PolicyType.SingleOrg, Arg.Any<OrganizationUserStatusType>())
@ -1591,9 +1694,15 @@ OrganizationUserInvite invite, SutProvider<OrganizationService> sutProvider)
Assert.Contains("test@bitwarden.com belongs to an organization that doesn't allow them to join multiple organizations", exception.Message.ToLowerInvariant());
await organizationUserRepository.DidNotReceiveWithAnyArgs().RestoreAsync(Arg.Any<Guid>(), Arg.Any<OrganizationUserStatusType>());
await eventService.DidNotReceiveWithAnyArgs()
await sutProvider.GetDependency<IOrganizationUserRepository>()
.DidNotReceiveWithAnyArgs()
.RestoreAsync(Arg.Any<Guid>(), Arg.Any<OrganizationUserStatusType>());
await sutProvider.GetDependency<IEventService>()
.DidNotReceiveWithAnyArgs()
.LogOrganizationUserEventAsync(Arg.Any<OrganizationUser>(), Arg.Any<EventType>(), Arg.Any<EventSystemUser>());
await sutProvider.GetDependency<IPushNotificationService>()
.DidNotReceiveWithAnyArgs()
.PushSyncOrgKeysAsync(Arg.Any<Guid>());
}
[Theory, BitAutoData]
@ -1610,8 +1719,6 @@ OrganizationUserInvite invite, SutProvider<OrganizationService> sutProvider)
.Returns(new List<(Guid userId, bool twoFactorIsEnabled)>() { (organizationUser.UserId.Value, false) });
RestoreRevokeUser_Setup(organization, owner, organizationUser, sutProvider);
var organizationUserRepository = sutProvider.GetDependency<IOrganizationUserRepository>();
var eventService = sutProvider.GetDependency<IEventService>();
sutProvider.GetDependency<IPolicyService>()
.GetPoliciesApplicableToUserAsync(organizationUser.UserId.Value, PolicyType.TwoFactorAuthentication, Arg.Any<OrganizationUserStatusType>())
@ -1626,9 +1733,15 @@ OrganizationUserInvite invite, SutProvider<OrganizationService> sutProvider)
Assert.Contains("test@bitwarden.com is not compliant with the two-step login policy", exception.Message.ToLowerInvariant());
await organizationUserRepository.DidNotReceiveWithAnyArgs().RestoreAsync(Arg.Any<Guid>(), Arg.Any<OrganizationUserStatusType>());
await eventService.DidNotReceiveWithAnyArgs()
await sutProvider.GetDependency<IOrganizationUserRepository>()
.DidNotReceiveWithAnyArgs()
.RestoreAsync(Arg.Any<Guid>(), Arg.Any<OrganizationUserStatusType>());
await sutProvider.GetDependency<IEventService>()
.DidNotReceiveWithAnyArgs()
.LogOrganizationUserEventAsync(Arg.Any<OrganizationUser>(), Arg.Any<EventType>(), Arg.Any<EventSystemUser>());
await sutProvider.GetDependency<IPushNotificationService>()
.DidNotReceiveWithAnyArgs()
.PushSyncOrgKeysAsync(Arg.Any<Guid>());
}
[Theory, BitAutoData]
@ -1640,8 +1753,6 @@ OrganizationUserInvite invite, SutProvider<OrganizationService> sutProvider)
{
organizationUser.Email = null; // this is required to mock that the user as had already been confirmed before the revoke
RestoreRevokeUser_Setup(organization, owner, organizationUser, sutProvider);
var organizationUserRepository = sutProvider.GetDependency<IOrganizationUserRepository>();
var eventService = sutProvider.GetDependency<IEventService>();
sutProvider.GetDependency<IPolicyService>()
.GetPoliciesApplicableToUserAsync(organizationUser.UserId.Value, PolicyType.TwoFactorAuthentication, Arg.Any<OrganizationUserStatusType>())
@ -1652,8 +1763,11 @@ OrganizationUserInvite invite, SutProvider<OrganizationService> sutProvider)
await sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id);
await organizationUserRepository.Received().RestoreAsync(organizationUser.Id, OrganizationUserStatusType.Confirmed);
await eventService.Received()
await sutProvider.GetDependency<IOrganizationUserRepository>()
.Received(1)
.RestoreAsync(organizationUser.Id, OrganizationUserStatusType.Confirmed);
await sutProvider.GetDependency<IEventService>()
.Received(1)
.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Restored);
}
@ -1668,10 +1782,10 @@ OrganizationUserInvite invite, SutProvider<OrganizationService> sutProvider)
organizationUser.Email = null; // this is required to mock that the user as had already been confirmed before the revoke
secondOrganizationUser.UserId = organizationUser.UserId;
RestoreRevokeUser_Setup(organization, owner, organizationUser, sutProvider);
var organizationUserRepository = sutProvider.GetDependency<IOrganizationUserRepository>();
var eventService = sutProvider.GetDependency<IEventService>();
organizationUserRepository.GetManyByUserAsync(organizationUser.UserId.Value).Returns(new[] { organizationUser, secondOrganizationUser });
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetManyByUserAsync(organizationUser.UserId.Value)
.Returns(new[] { organizationUser, secondOrganizationUser });
sutProvider.GetDependency<IPolicyService>()
.GetPoliciesApplicableToUserAsync(organizationUser.UserId.Value, PolicyType.SingleOrg, Arg.Any<OrganizationUserStatusType>())
.Returns(new[]
@ -1688,9 +1802,15 @@ OrganizationUserInvite invite, SutProvider<OrganizationService> sutProvider)
Assert.Contains("test@bitwarden.com is not compliant with the single organization policy", exception.Message.ToLowerInvariant());
await organizationUserRepository.DidNotReceiveWithAnyArgs().RestoreAsync(Arg.Any<Guid>(), Arg.Any<OrganizationUserStatusType>());
await eventService.DidNotReceiveWithAnyArgs()
await sutProvider.GetDependency<IOrganizationUserRepository>()
.DidNotReceiveWithAnyArgs()
.RestoreAsync(Arg.Any<Guid>(), Arg.Any<OrganizationUserStatusType>());
await sutProvider.GetDependency<IEventService>()
.DidNotReceiveWithAnyArgs()
.LogOrganizationUserEventAsync(Arg.Any<OrganizationUser>(), Arg.Any<EventType>(), Arg.Any<EventSystemUser>());
await sutProvider.GetDependency<IPushNotificationService>()
.DidNotReceiveWithAnyArgs()
.PushSyncOrgKeysAsync(Arg.Any<Guid>());
}
[Theory, BitAutoData]
@ -1704,11 +1824,8 @@ OrganizationUserInvite invite, SutProvider<OrganizationService> sutProvider)
organizationUser.Email = null; // this is required to mock that the user as had already been confirmed before the revoke
secondOrganizationUser.UserId = organizationUser.UserId;
RestoreRevokeUser_Setup(organization, owner, organizationUser, sutProvider);
var organizationUserRepository = sutProvider.GetDependency<IOrganizationUserRepository>();
var eventService = sutProvider.GetDependency<IEventService>();
var twoFactorIsEnabledQuery = sutProvider.GetDependency<ITwoFactorIsEnabledQuery>();
twoFactorIsEnabledQuery
sutProvider.GetDependency<ITwoFactorIsEnabledQuery>()
.TwoFactorIsEnabledAsync(Arg.Is<IEnumerable<Guid>>(i => i.Contains(organizationUser.UserId.Value)))
.Returns(new List<(Guid userId, bool twoFactorIsEnabled)>() { (organizationUser.UserId.Value, true) });
@ -1725,9 +1842,15 @@ OrganizationUserInvite invite, SutProvider<OrganizationService> sutProvider)
Assert.Contains("test@bitwarden.com belongs to an organization that doesn't allow them to join multiple organizations", exception.Message.ToLowerInvariant());
await organizationUserRepository.DidNotReceiveWithAnyArgs().RestoreAsync(Arg.Any<Guid>(), Arg.Any<OrganizationUserStatusType>());
await eventService.DidNotReceiveWithAnyArgs()
await sutProvider.GetDependency<IOrganizationUserRepository>()
.DidNotReceiveWithAnyArgs()
.RestoreAsync(Arg.Any<Guid>(), Arg.Any<OrganizationUserStatusType>());
await sutProvider.GetDependency<IEventService>()
.DidNotReceiveWithAnyArgs()
.LogOrganizationUserEventAsync(Arg.Any<OrganizationUser>(), Arg.Any<EventType>(), Arg.Any<EventSystemUser>());
await sutProvider.GetDependency<IPushNotificationService>()
.DidNotReceiveWithAnyArgs()
.PushSyncOrgKeysAsync(Arg.Any<Guid>());
}
[Theory, BitAutoData]
@ -1741,10 +1864,10 @@ OrganizationUserInvite invite, SutProvider<OrganizationService> sutProvider)
organizationUser.Email = null; // this is required to mock that the user as had already been confirmed before the revoke
secondOrganizationUser.UserId = organizationUser.UserId;
RestoreRevokeUser_Setup(organization, owner, organizationUser, sutProvider);
var organizationUserRepository = sutProvider.GetDependency<IOrganizationUserRepository>();
var eventService = sutProvider.GetDependency<IEventService>();
organizationUserRepository.GetManyByUserAsync(organizationUser.UserId.Value).Returns(new[] { organizationUser, secondOrganizationUser });
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetManyByUserAsync(organizationUser.UserId.Value)
.Returns(new[] { organizationUser, secondOrganizationUser });
sutProvider.GetDependency<IPolicyService>()
.GetPoliciesApplicableToUserAsync(organizationUser.UserId.Value, PolicyType.SingleOrg, Arg.Any<OrganizationUserStatusType>())
.Returns(new[]
@ -1768,9 +1891,15 @@ OrganizationUserInvite invite, SutProvider<OrganizationService> sutProvider)
Assert.Contains("test@bitwarden.com is not compliant with the single organization and two-step login polciy", exception.Message.ToLowerInvariant());
await organizationUserRepository.DidNotReceiveWithAnyArgs().RestoreAsync(Arg.Any<Guid>(), Arg.Any<OrganizationUserStatusType>());
await eventService.DidNotReceiveWithAnyArgs()
await sutProvider.GetDependency<IOrganizationUserRepository>()
.DidNotReceiveWithAnyArgs()
.RestoreAsync(Arg.Any<Guid>(), Arg.Any<OrganizationUserStatusType>());
await sutProvider.GetDependency<IEventService>()
.DidNotReceiveWithAnyArgs()
.LogOrganizationUserEventAsync(Arg.Any<OrganizationUser>(), Arg.Any<EventType>(), Arg.Any<EventSystemUser>());
await sutProvider.GetDependency<IPushNotificationService>()
.DidNotReceiveWithAnyArgs()
.PushSyncOrgKeysAsync(Arg.Any<Guid>());
}
[Theory, BitAutoData]
@ -1783,8 +1912,6 @@ OrganizationUserInvite invite, SutProvider<OrganizationService> sutProvider)
organizationUser.Email = null;
RestoreRevokeUser_Setup(organization, owner, organizationUser, sutProvider);
var organizationUserRepository = sutProvider.GetDependency<IOrganizationUserRepository>();
var eventService = sutProvider.GetDependency<IEventService>();
sutProvider.GetDependency<IPolicyService>()
.GetPoliciesApplicableToUserAsync(organizationUser.UserId.Value, PolicyType.TwoFactorAuthentication, Arg.Any<OrganizationUserStatusType>())
@ -1799,9 +1926,15 @@ OrganizationUserInvite invite, SutProvider<OrganizationService> sutProvider)
Assert.Contains("test@bitwarden.com is not compliant with the two-step login policy", exception.Message.ToLowerInvariant());
await organizationUserRepository.DidNotReceiveWithAnyArgs().RestoreAsync(Arg.Any<Guid>(), Arg.Any<OrganizationUserStatusType>());
await eventService.DidNotReceiveWithAnyArgs()
await sutProvider.GetDependency<IOrganizationUserRepository>()
.DidNotReceiveWithAnyArgs()
.RestoreAsync(Arg.Any<Guid>(), Arg.Any<OrganizationUserStatusType>());
await sutProvider.GetDependency<IEventService>()
.DidNotReceiveWithAnyArgs()
.LogOrganizationUserEventAsync(Arg.Any<OrganizationUser>(), Arg.Any<EventType>(), Arg.Any<EventSystemUser>());
await sutProvider.GetDependency<IPushNotificationService>()
.DidNotReceiveWithAnyArgs()
.PushSyncOrgKeysAsync(Arg.Any<Guid>());
}
[Theory, BitAutoData]
@ -1813,22 +1946,22 @@ OrganizationUserInvite invite, SutProvider<OrganizationService> sutProvider)
{
organizationUser.Email = null; // this is required to mock that the user as had already been confirmed before the revoke
RestoreRevokeUser_Setup(organization, owner, organizationUser, sutProvider);
var organizationUserRepository = sutProvider.GetDependency<IOrganizationUserRepository>();
var eventService = sutProvider.GetDependency<IEventService>();
var twoFactorIsEnabledQuery = sutProvider.GetDependency<ITwoFactorIsEnabledQuery>();
sutProvider.GetDependency<IPolicyService>()
.GetPoliciesApplicableToUserAsync(organizationUser.UserId.Value, PolicyType.TwoFactorAuthentication, Arg.Any<OrganizationUserStatusType>())
.Returns(new[] { new OrganizationUserPolicyDetails { OrganizationId = organizationUser.OrganizationId, PolicyType = PolicyType.TwoFactorAuthentication } });
twoFactorIsEnabledQuery
sutProvider.GetDependency<ITwoFactorIsEnabledQuery>()
.TwoFactorIsEnabledAsync(Arg.Is<IEnumerable<Guid>>(i => i.Contains(organizationUser.UserId.Value)))
.Returns(new List<(Guid userId, bool twoFactorIsEnabled)>() { (organizationUser.UserId.Value, true) });
await sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id);
await organizationUserRepository.Received().RestoreAsync(organizationUser.Id, OrganizationUserStatusType.Confirmed);
await eventService.Received()
await sutProvider.GetDependency<IOrganizationUserRepository>()
.Received(1)
.RestoreAsync(organizationUser.Id, OrganizationUserStatusType.Confirmed);
await sutProvider.GetDependency<IEventService>()
.Received(1)
.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Restored);
}

View File

@ -109,7 +109,7 @@ public class OrgUserInviteTokenableTests
[Theory]
[InlineData(null)]
[InlineData("")]
public void Valid_NullOrEmptyOrgUserEmail_ReturnsFalse(string email)
public void Valid_NullOrEmptyOrgUserEmail_ReturnsFalse(string? email)
{
var token = new OrgUserInviteTokenable
{

View File

@ -7,11 +7,13 @@ using Bit.Core.Context;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Models.Data;
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
using Bit.Core.Platform.Push;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Settings;
using Bit.Core.Utilities;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using Bit.Test.Common.Helpers;
@ -347,14 +349,24 @@ public class AuthRequestServiceTests
User user,
OrganizationUser organizationUser1,
OrganizationUserUserDetails admin1,
OrganizationUserUserDetails customUser1,
OrganizationUser organizationUser2,
OrganizationUserUserDetails admin2,
OrganizationUserUserDetails admin3)
OrganizationUserUserDetails admin3,
OrganizationUserUserDetails customUser2)
{
createModel.Type = AuthRequestType.AdminApproval;
user.Email = createModel.Email;
organizationUser1.UserId = user.Id;
organizationUser2.UserId = user.Id;
customUser1.Permissions = CoreHelpers.ClassToJsonData(new Permissions
{
ManageResetPassword = false,
});
customUser2.Permissions = CoreHelpers.ClassToJsonData(new Permissions
{
ManageResetPassword = true,
});
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.DeviceApprovalRequestAdminNotifications)
@ -392,6 +404,13 @@ public class AuthRequestServiceTests
admin1,
]);
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetManyDetailsByRoleAsync(organizationUser1.OrganizationId, OrganizationUserType.Custom)
.Returns(
[
customUser1,
]);
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetManyByMinimumRoleAsync(organizationUser2.OrganizationId, OrganizationUserType.Admin)
.Returns(
@ -400,6 +419,13 @@ public class AuthRequestServiceTests
admin3,
]);
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetManyDetailsByRoleAsync(organizationUser2.OrganizationId, OrganizationUserType.Custom)
.Returns(
[
customUser2,
]);
sutProvider.GetDependency<IAuthRequestRepository>()
.CreateAsync(Arg.Any<AuthRequest>())
.Returns(c => c.ArgAt<AuthRequest>(0));
@ -435,7 +461,9 @@ public class AuthRequestServiceTests
await sutProvider.GetDependency<IMailService>()
.Received(1)
.SendDeviceApprovalRequestedNotificationEmailAsync(
Arg.Is<IEnumerable<string>>(emails => emails.Count() == 2 && emails.Contains(admin2.Email) && emails.Contains(admin3.Email)),
Arg.Is<IEnumerable<string>>(emails => emails.Count() == 3 &&
emails.Contains(admin2.Email) && emails.Contains(admin3.Email) &&
emails.Contains(customUser2.Email)),
organizationUser2.OrganizationId,
user.Email,
user.Name);

View File

@ -271,7 +271,7 @@ public class CoreHelpersTests
[InlineData("ascii.com", "ascii.com")]
[InlineData("", "")]
[InlineData(null, null)]
public void PunyEncode_Success(string text, string expected)
public void PunyEncode_Success(string? text, string? expected)
{
var actual = CoreHelpers.PunyEncode(text);
Assert.Equal(expected, actual);
@ -435,7 +435,7 @@ public class CoreHelpersTests
[InlineData("name@", "name@")] // @ symbol but no domain
[InlineData("", "")] // Empty string
[InlineData(null, null)] // null
public void ObfuscateEmail_Success(string input, string expected)
public void ObfuscateEmail_Success(string? input, string? expected)
{
Assert.Equal(expected, CoreHelpers.ObfuscateEmail(input));
}
@ -456,7 +456,7 @@ public class CoreHelpersTests
[InlineData("user@")]
[InlineData("@example.com")]
[InlineData("user@ex@ample.com")]
public void GetEmailDomain_ReturnsNull(string wrongEmail)
public void GetEmailDomain_ReturnsNull(string? wrongEmail)
{
Assert.Null(CoreHelpers.GetEmailDomain(wrongEmail));
}

View File

@ -25,7 +25,7 @@ public class EncryptedStringAttributeTests
[InlineData("Rsa2048_OaepSha256_HmacSha256_B64.QmFzZTY0UGFydA==|QmFzZTY0UGFydA==")] // Valid Rsa2048_OaepSha256_HmacSha256_B64 as a string
[InlineData("6.QmFzZTY0UGFydA==|QmFzZTY0UGFydA==")] // Valid Rsa2048_OaepSha1_HmacSha256_B64 as a number
[InlineData("Rsa2048_OaepSha1_HmacSha256_B64.QmFzZTY0UGFydA==|QmFzZTY0UGFydA==")]
public void IsValid_ReturnsTrue_WhenValid(string input)
public void IsValid_ReturnsTrue_WhenValid(string? input)
{
var sut = new EncryptedStringAttribute();

View File

@ -47,7 +47,7 @@ public class StrictEmailAttributeTests
[InlineData("hellothere@world.com-")] // domain ending in hyphen
[InlineData("hellö@world.com")] // unicode at end of local-part
[InlineData("héllo@world.com")] // unicode in middle of local-part
public void IsValid_ReturnsFalseWhenInvalid(string email)
public void IsValid_ReturnsFalseWhenInvalid(string? email)
{
var sut = new StrictEmailAddressAttribute();

View File

@ -42,7 +42,7 @@ public class StrictEmailAddressListAttributeTests
[Theory]
[InlineData("single@email.com", false)]
[InlineData(null, false)]
public void IsValid_ReturnsTrue_WhenValid(string email, bool valid)
public void IsValid_ReturnsTrue_WhenValid(string? email, bool valid)
{
var sut = new StrictEmailAddressListAttribute();

View File

@ -0,0 +1,85 @@
using System.Security.Claims;
using Bit.Core.Context;
using Bit.Core.Exceptions;
using Bit.Core.Test.Vault.AutoFixture;
using Bit.Core.Vault.Authorization.SecurityTasks;
using Bit.Core.Vault.Commands;
using Bit.Core.Vault.Entities;
using Bit.Core.Vault.Models.Api;
using Bit.Core.Vault.Repositories;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using Microsoft.AspNetCore.Authorization;
using NSubstitute;
using Xunit;
namespace Bit.Core.Test.Vault.Commands;
[SutProviderCustomize]
[SecurityTaskCustomize]
public class CreateManyTasksCommandTest
{
private static void Setup(SutProvider<CreateManyTasksCommand> sutProvider, Guid? userId,
bool authorizedCreate = false)
{
sutProvider.GetDependency<ICurrentContext>().UserId.Returns(userId);
sutProvider.GetDependency<IAuthorizationService>()
.AuthorizeAsync(Arg.Any<ClaimsPrincipal>(), Arg.Any<object>(),
Arg.Is<IEnumerable<IAuthorizationRequirement>>(reqs =>
reqs.Contains(SecurityTaskOperations.Create)))
.Returns(authorizedCreate ? AuthorizationResult.Success() : AuthorizationResult.Failed());
}
[Theory]
[BitAutoData]
public async Task CreateAsync_NotLoggedIn_NotFoundException(
SutProvider<CreateManyTasksCommand> sutProvider,
Guid organizationId,
IEnumerable<SecurityTaskCreateRequest> tasks)
{
Setup(sutProvider, null, true);
await Assert.ThrowsAsync<NotFoundException>(() => sutProvider.Sut.CreateAsync(organizationId, tasks));
}
[Theory]
[BitAutoData]
public async Task CreateAsync_NoTasksProvided_BadRequestException(
SutProvider<CreateManyTasksCommand> sutProvider,
Guid organizationId)
{
Setup(sutProvider, Guid.NewGuid());
await Assert.ThrowsAsync<BadRequestException>(() => sutProvider.Sut.CreateAsync(organizationId, null));
}
[Theory]
[BitAutoData]
public async Task CreateAsync_AuthorizationFailed_NotFoundException(
SutProvider<CreateManyTasksCommand> sutProvider,
Guid organizationId,
IEnumerable<SecurityTaskCreateRequest> tasks)
{
Setup(sutProvider, Guid.NewGuid());
await Assert.ThrowsAsync<NotFoundException>(() => sutProvider.Sut.CreateAsync(organizationId, tasks));
}
[Theory]
[BitAutoData]
public async Task CreateAsync_AuthorizationSucceeded_ReturnsSecurityTasks(
SutProvider<CreateManyTasksCommand> sutProvider,
Guid organizationId,
IEnumerable<SecurityTaskCreateRequest> tasks,
ICollection<SecurityTask> securityTasks)
{
Setup(sutProvider, Guid.NewGuid(), true);
sutProvider.GetDependency<ISecurityTaskRepository>()
.CreateManyAsync(Arg.Any<ICollection<SecurityTask>>())
.Returns(securityTasks);
var result = await sutProvider.Sut.CreateAsync(organizationId, tasks);
Assert.Equal(securityTasks, result);
}
}

View File

@ -3,7 +3,7 @@ using System.Security.Claims;
using Bit.Core.Context;
using Bit.Core.Exceptions;
using Bit.Core.Test.Vault.AutoFixture;
using Bit.Core.Vault.Authorization;
using Bit.Core.Vault.Authorization.SecurityTasks;
using Bit.Core.Vault.Commands;
using Bit.Core.Vault.Entities;
using Bit.Core.Vault.Repositories;

View File

@ -3,7 +3,6 @@
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
@ -21,9 +20,9 @@
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Events\Events.csproj" />
<ProjectReference Include="..\IntegrationTestCommon\IntegrationTestCommon.csproj" />
<ItemGroup>
<ProjectReference Include="..\..\src\Events\Events.csproj" />
<ProjectReference Include="..\IntegrationTestCommon\IntegrationTestCommon.csproj" />
</ItemGroup>
</Project>

View File

@ -45,7 +45,7 @@ public class IconLinkTests
[InlineData(" ", false)]
[InlineData("unusable", false)]
[InlineData("ico", true)]
public void WithNoRel_IsUsable(string extension, bool expectedResult)
public void WithNoRel_IsUsable(string? extension, bool expectedResult)
{
SetAttributeValue("href", $"/favicon.{extension}");

View File

@ -1,8 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>

View File

@ -3,8 +3,6 @@
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
@ -22,8 +20,8 @@
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Infrastructure.Dapper\Infrastructure.Dapper.csproj" />
<ItemGroup>
<ProjectReference Include="..\..\src\Infrastructure.Dapper\Infrastructure.Dapper.csproj" />
</ItemGroup>
</Project>

View File

@ -252,12 +252,12 @@ public class OrganizationDomainRepositoryTests
Txt = "btw+12345"
};
var outside36HoursWindow = 20;
var outside36HoursWindow = 50;
organizationDomain.SetNextRunDate(outside36HoursWindow);
await organizationDomainRepository.CreateAsync(organizationDomain);
var date = DateTimeOffset.UtcNow.Date.AddDays(1);
var date = DateTime.UtcNow.AddDays(1);
// Act
var domains = await organizationDomainRepository.GetManyByNextRunDateAsync(date);

View File

@ -1,7 +1,6 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<UserSecretsId>6570f288-5c2c-47ad-8978-f3da255079c2</UserSecretsId>
</PropertyGroup>

View File

@ -223,4 +223,47 @@ public class SecurityTaskRepositoryTests
Assert.DoesNotContain(task1, completedTasks, new SecurityTaskComparer());
Assert.DoesNotContain(task3, completedTasks, new SecurityTaskComparer());
}
[DatabaseTheory, DatabaseData]
public async Task CreateManyAsync(
IOrganizationRepository organizationRepository,
ICipherRepository cipherRepository,
ISecurityTaskRepository securityTaskRepository)
{
var organization = await organizationRepository.CreateAsync(new Organization
{
Name = "Test Org",
PlanType = PlanType.EnterpriseAnnually,
Plan = "Test Plan",
BillingEmail = ""
});
var cipher1 = new Cipher { Type = CipherType.Login, OrganizationId = organization.Id, Data = "", };
await cipherRepository.CreateAsync(cipher1);
var cipher2 = new Cipher { Type = CipherType.Login, OrganizationId = organization.Id, Data = "", };
await cipherRepository.CreateAsync(cipher2);
var tasks = new List<SecurityTask>
{
new()
{
OrganizationId = organization.Id,
CipherId = cipher1.Id,
Status = SecurityTaskStatus.Pending,
Type = SecurityTaskType.UpdateAtRiskCredential,
},
new()
{
OrganizationId = organization.Id,
CipherId = cipher2.Id,
Status = SecurityTaskStatus.Completed,
Type = SecurityTaskType.UpdateAtRiskCredential,
}
};
var taskIds = await securityTaskRepository.CreateManyAsync(tasks);
Assert.Equal(2, taskIds.Count);
}
}

View File

@ -0,0 +1,55 @@
-- SecurityTask_CreateMany
CREATE OR ALTER PROCEDURE [dbo].[SecurityTask_CreateMany]
@SecurityTasksJson NVARCHAR(MAX)
AS
BEGIN
SET NOCOUNT ON
CREATE TABLE #TempSecurityTasks
(
[Id] UNIQUEIDENTIFIER,
[OrganizationId] UNIQUEIDENTIFIER,
[CipherId] UNIQUEIDENTIFIER,
[Type] TINYINT,
[Status] TINYINT,
[CreationDate] DATETIME2(7),
[RevisionDate] DATETIME2(7)
)
INSERT INTO #TempSecurityTasks
([Id],
[OrganizationId],
[CipherId],
[Type],
[Status],
[CreationDate],
[RevisionDate])
SELECT CAST(JSON_VALUE([value], '$.Id') AS UNIQUEIDENTIFIER),
CAST(JSON_VALUE([value], '$.OrganizationId') AS UNIQUEIDENTIFIER),
CAST(JSON_VALUE([value], '$.CipherId') AS UNIQUEIDENTIFIER),
CAST(JSON_VALUE([value], '$.Type') AS TINYINT),
CAST(JSON_VALUE([value], '$.Status') AS TINYINT),
CAST(JSON_VALUE([value], '$.CreationDate') AS DATETIME2(7)),
CAST(JSON_VALUE([value], '$.RevisionDate') AS DATETIME2(7))
FROM OPENJSON(@SecurityTasksJson) ST
INSERT INTO [dbo].[SecurityTask]
([Id],
[OrganizationId],
[CipherId],
[Type],
[Status],
[CreationDate],
[RevisionDate])
SELECT [Id],
[OrganizationId],
[CipherId],
[Type],
[Status],
[CreationDate],
[RevisionDate]
FROM #TempSecurityTasks
DROP TABLE #TempSecurityTasks
END
GO

View File

@ -0,0 +1,16 @@
CREATE OR ALTER PROCEDURE [dbo].[OrganizationUser_ReadManyDetailsByRole]
@OrganizationId UNIQUEIDENTIFIER,
@Role TINYINT
AS
BEGIN
SET NOCOUNT ON
SELECT
*
FROM
[dbo].[OrganizationUserUserDetailsView]
WHERE
OrganizationId = @OrganizationId
AND Status = 2 -- 2 = Confirmed
AND [Type] = @Role
END

View File

@ -0,0 +1,118 @@
CREATE OR ALTER PROCEDURE [dbo].[CipherDetails_ReadByIdUserId]
@Id UNIQUEIDENTIFIER,
@UserId UNIQUEIDENTIFIER
AS
BEGIN
SET NOCOUNT ON
SELECT
[Id],
[UserId],
[OrganizationId],
[Type],
[Data],
[Attachments],
[CreationDate],
[RevisionDate],
[Favorite],
[FolderId],
[DeletedDate],
[Reprompt],
[Key],
[OrganizationUseTotp],
MAX ([Edit]) AS [Edit],
MAX ([ViewPassword]) AS [ViewPassword]
FROM
[dbo].[UserCipherDetails](@UserId)
WHERE
[Id] = @Id
GROUP BY
[Id],
[UserId],
[OrganizationId],
[Type],
[Data],
[Attachments],
[CreationDate],
[RevisionDate],
[Favorite],
[FolderId],
[DeletedDate],
[Reprompt],
[Key],
[OrganizationUseTotp]
END
GO
CREATE OR ALTER PROCEDURE [dbo].[CollectionCipher_UpdateCollections]
@CipherId UNIQUEIDENTIFIER,
@UserId UNIQUEIDENTIFIER,
@CollectionIds AS [dbo].[GuidIdArray] READONLY
AS
BEGIN
SET NOCOUNT ON
DECLARE @OrgId UNIQUEIDENTIFIER = (
SELECT TOP 1
[OrganizationId]
FROM
[dbo].[Cipher]
WHERE
[Id] = @CipherId
)
SELECT
C.[Id]
INTO #TempAvailableCollections
FROM
[dbo].[Collection] C
INNER JOIN
[Organization] O ON O.[Id] = C.[OrganizationId]
INNER JOIN
[dbo].[OrganizationUser] OU ON OU.[OrganizationId] = O.[Id] AND OU.[UserId] = @UserId
LEFT JOIN
[dbo].[CollectionUser] CU ON CU.[CollectionId] = C.[Id] AND CU.[OrganizationUserId] = OU.[Id]
LEFT JOIN
[dbo].[GroupUser] GU ON CU.[CollectionId] IS NULL AND GU.[OrganizationUserId] = OU.[Id]
LEFT JOIN
[dbo].[Group] G ON G.[Id] = GU.[GroupId]
LEFT JOIN
[dbo].[CollectionGroup] CG ON CG.[CollectionId] = C.[Id] AND CG.[GroupId] = GU.[GroupId]
WHERE
O.[Id] = @OrgId
AND O.[Enabled] = 1
AND OU.[Status] = 2 -- Confirmed
AND (
CU.[ReadOnly] = 0
OR CG.[ReadOnly] = 0
)
-- Insert new collection assignments
INSERT INTO [dbo].[CollectionCipher] (
[CollectionId],
[CipherId]
)
SELECT
[Id],
@CipherId
FROM @CollectionIds
WHERE [Id] IN (SELECT [Id] FROM [#TempAvailableCollections])
AND NOT EXISTS (
SELECT 1
FROM [dbo].[CollectionCipher]
WHERE [CollectionId] = [@CollectionIds].[Id]
AND [CipherId] = @CipherId
);
-- Delete removed collection assignments
DELETE CC
FROM [dbo].[CollectionCipher] CC
WHERE CC.[CipherId] = @CipherId
AND CC.[CollectionId] IN (SELECT [Id] FROM [#TempAvailableCollections])
AND CC.[CollectionId] NOT IN (SELECT [Id] FROM @CollectionIds);
IF @OrgId IS NOT NULL
BEGIN
EXEC [dbo].[User_BumpAccountRevisionDateByOrganizationId] @OrgId
END
DROP TABLE #TempAvailableCollections;
END
GO