diff --git a/src/Api/AdminConsole/Controllers/BaseAdminConsoleController.cs b/src/Api/AdminConsole/Controllers/BaseAdminConsoleController.cs index 6fd741bf3a5a..33efdb0c8253 100644 --- a/src/Api/AdminConsole/Controllers/BaseAdminConsoleController.cs +++ b/src/Api/AdminConsole/Controllers/BaseAdminConsoleController.cs @@ -3,23 +3,34 @@ using Bit.Core.Models.Api; using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Mvc; +using CommandError = Bit.Core.AdminConsole.Utilities.v2.Error; namespace Bit.Api.AdminConsole.Controllers; public abstract class BaseAdminConsoleController : Controller { + /// + /// Maps a void to an HTTP response. + /// Returns 204 No Content on success, or the appropriate error status code on failure. + /// protected static IResult Handle(CommandResult commandResult) => commandResult.Match( - error => error switch - { - BadRequestError badRequest => Error.BadRequest(badRequest.Message), - NotFoundError notFound => Error.NotFound(notFound.Message), - InternalError internalError => Error.InternalError(internalError.Message), - _ => Error.InternalError(error.Message) - }, + error => MapError(error), _ => TypedResults.NoContent() ); + /// + /// Maps a to an HTTP response. + /// On success, delegates to so the caller can choose the response shape + /// (e.g. TypedResults.Created for POST, TypedResults.Ok for GET/PUT). + /// On failure, returns the appropriate error status code. + /// + protected static IResult Handle(CommandResult commandResult, Func success) => + commandResult.Match( + error => MapError(error), + success + ); + protected static class Error { public static NotFound NotFound(string message = "Resource not found.") => @@ -37,4 +48,21 @@ public static JsonHttpResult InternalError( new ErrorResponseModel(message), statusCode: StatusCodes.Status500InternalServerError); } + + private static IResult MapError(CommandError error) => + error switch + { + BadRequestError badRequest => TypedResults.BadRequest(new ErrorResponseModel(badRequest.Message)), + NotFoundError notFound => TypedResults.NotFound(new ErrorResponseModel(notFound.Message)), + ConflictError conflict => TypedResults.Json( + new ErrorResponseModel(conflict.Message), + statusCode: StatusCodes.Status409Conflict), + InternalError internalError => TypedResults.Json( + new ErrorResponseModel(internalError.Message), + statusCode: StatusCodes.Status500InternalServerError), + _ => TypedResults.Json( + new ErrorResponseModel(error.Message), + statusCode: StatusCodes.Status500InternalServerError + ) + }; } diff --git a/src/Api/AdminConsole/Controllers/OrganizationInviteLinksController.cs b/src/Api/AdminConsole/Controllers/OrganizationInviteLinksController.cs new file mode 100644 index 000000000000..0ad76fbc39e4 --- /dev/null +++ b/src/Api/AdminConsole/Controllers/OrganizationInviteLinksController.cs @@ -0,0 +1,32 @@ +using Bit.Api.AdminConsole.Authorization; +using Bit.Api.AdminConsole.Authorization.Requirements; +using Bit.Api.AdminConsole.Models.Request.Organizations; +using Bit.Api.AdminConsole.Models.Response.Organizations; +using Bit.Core; +using Bit.Core.AdminConsole.OrganizationFeatures.InviteLinks.Interfaces; +using Bit.Core.Utilities; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Bit.Api.AdminConsole.Controllers; + +[Route("organizations/{orgId}/invite-link")] +[Authorize("Application")] +[RequireFeature(FeatureFlagKeys.GenerateInviteLink)] +public class OrganizationInviteLinksController( + ICreateOrganizationInviteLinkCommand createOrganizationInviteLinkCommand) + : BaseAdminConsoleController +{ + [HttpPost("")] + [Authorize] + public async Task Create(Guid orgId, [FromBody] CreateOrganizationInviteLinkRequestModel model) + { + var result = await createOrganizationInviteLinkCommand.CreateAsync( + model.ToCommandRequest(orgId)); + + return Handle(result, link => + TypedResults.Created( + $"organizations/{orgId}/invite-link", + new OrganizationInviteLinkResponseModel(link))); + } +} diff --git a/src/Api/AdminConsole/Models/Request/Organizations/CreateOrganizationInviteLinkRequestModel.cs b/src/Api/AdminConsole/Models/Request/Organizations/CreateOrganizationInviteLinkRequestModel.cs new file mode 100644 index 000000000000..1070525c5949 --- /dev/null +++ b/src/Api/AdminConsole/Models/Request/Organizations/CreateOrganizationInviteLinkRequestModel.cs @@ -0,0 +1,37 @@ +using System.ComponentModel.DataAnnotations; +using Bit.Core.AdminConsole.OrganizationFeatures.InviteLinks; +using Bit.Core.Utilities; + +namespace Bit.Api.AdminConsole.Models.Request.Organizations; + +public class CreateOrganizationInviteLinkRequestModel +{ + /// + /// Email domains permitted to accept the invite link (e.g. ["acme.com"]). + /// + [Required] + [MinLength(1)] + [ValidateSequence] + public required IEnumerable AllowedDomains { get; set; } + + /// + /// The invite key encrypted with the organization key. + /// + [Required] + [EncryptedString] + public required string EncryptedInviteKey { get; set; } + + /// + /// The organization key encrypted for the invite link. Currently unused; will be populated in a future stage. + /// + [EncryptedString] + public string? EncryptedOrgKey { get; set; } + + public CreateOrganizationInviteLinkRequest ToCommandRequest(Guid organizationId) => new() + { + OrganizationId = organizationId, + AllowedDomains = AllowedDomains, + EncryptedInviteKey = EncryptedInviteKey, + EncryptedOrgKey = EncryptedOrgKey, + }; +} diff --git a/src/Api/AdminConsole/Models/Response/Organizations/OrganizationInviteLinkResponseModel.cs b/src/Api/AdminConsole/Models/Response/Organizations/OrganizationInviteLinkResponseModel.cs new file mode 100644 index 000000000000..9988db033e63 --- /dev/null +++ b/src/Api/AdminConsole/Models/Response/Organizations/OrganizationInviteLinkResponseModel.cs @@ -0,0 +1,31 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Models.Api; + +namespace Bit.Api.AdminConsole.Models.Response.Organizations; + +public class OrganizationInviteLinkResponseModel : ResponseModel +{ + public OrganizationInviteLinkResponseModel() : base("organizationInviteLink") { } + + public OrganizationInviteLinkResponseModel(OrganizationInviteLink inviteLink) + : base("organizationInviteLink") + { + ArgumentNullException.ThrowIfNull(inviteLink); + + Id = inviteLink.Id; + Code = inviteLink.Code; + OrganizationId = inviteLink.OrganizationId; + AllowedDomains = inviteLink.GetAllowedDomains(); + EncryptedInviteKey = inviteLink.EncryptedInviteKey; + EncryptedOrgKey = inviteLink.EncryptedOrgKey; + CreationDate = inviteLink.CreationDate; + } + + public Guid Id { get; set; } + public Guid Code { get; set; } + public Guid OrganizationId { get; set; } + public IEnumerable AllowedDomains { get; set; } = []; + public string EncryptedInviteKey { get; set; } = null!; + public string? EncryptedOrgKey { get; set; } + public DateTime CreationDate { get; set; } +} diff --git a/src/Core/AdminConsole/Entities/OrganizationInviteLink.cs b/src/Core/AdminConsole/Entities/OrganizationInviteLink.cs index 4000a1d44de2..26883a424de9 100644 --- a/src/Core/AdminConsole/Entities/OrganizationInviteLink.cs +++ b/src/Core/AdminConsole/Entities/OrganizationInviteLink.cs @@ -1,4 +1,5 @@ -using Bit.Core.Entities; +using System.Text.Json; +using Bit.Core.Entities; using Bit.Core.Utilities; namespace Bit.Core.AdminConsole.Entities; @@ -6,7 +7,13 @@ namespace Bit.Core.AdminConsole.Entities; public class OrganizationInviteLink : ITableObject { public Guid Id { get; set; } - public Guid Code { get; set; } + /// + /// A random, publicly shareable code used to identify the invite link. + /// Uses rather than a sequential/comb GUID because this is not + /// a table identifier and therefore does not need index-friendly ordering. A comb GUID's embedded + /// timestamp would also make the code partially predictable. + /// + public Guid Code { get; set; } = Guid.NewGuid(); public Guid OrganizationId { get; set; } public string AllowedDomains { get; set; } = null!; public string EncryptedInviteKey { get; set; } = null!; @@ -14,6 +21,12 @@ public class OrganizationInviteLink : ITableObject public DateTime CreationDate { get; set; } = DateTime.UtcNow; public DateTime RevisionDate { get; set; } = DateTime.UtcNow; + public IEnumerable GetAllowedDomains() => + JsonSerializer.Deserialize>(AllowedDomains) ?? []; + + public void SetAllowedDomains(IEnumerable domains) => + AllowedDomains = JsonSerializer.Serialize(domains); + public void SetNewId() { if (Id == default) diff --git a/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/CreateOrganizationInviteLinkCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/CreateOrganizationInviteLinkCommand.cs new file mode 100644 index 000000000000..cd3cbdc36c1f --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/CreateOrganizationInviteLinkCommand.cs @@ -0,0 +1,67 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.OrganizationFeatures.InviteLinks.Interfaces; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.AdminConsole.Utilities.v2.Results; +using Bit.Core.Services; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.InviteLinks; + +public class CreateOrganizationInviteLinkCommand( + IOrganizationInviteLinkRepository organizationInviteLinkRepository, + IApplicationCacheService applicationCacheService, + TimeProvider timeProvider) + : ICreateOrganizationInviteLinkCommand +{ + public async Task> CreateAsync( + CreateOrganizationInviteLinkRequest request) + { + if (!await OrganizationHasInviteLinksAbilityAsync(request.OrganizationId)) + { + return new InviteLinkNotAvailable(); + } + + var sanitizedDomains = SanitizeDomains(request.AllowedDomains); + if (sanitizedDomains.Count == 0) + { + return new InviteLinkDomainsRequired(); + } + + var existingLink = await organizationInviteLinkRepository.GetByOrganizationIdAsync(request.OrganizationId); + if (existingLink != null) + { + return new InviteLinkAlreadyExists(); + } + + var now = timeProvider.GetUtcNow().UtcDateTime; + var inviteLink = new OrganizationInviteLink + { + OrganizationId = request.OrganizationId, + EncryptedInviteKey = request.EncryptedInviteKey, + EncryptedOrgKey = request.EncryptedOrgKey, + CreationDate = now, + RevisionDate = now, + }; + inviteLink.SetAllowedDomains(sanitizedDomains); + inviteLink.SetNewId(); + + await organizationInviteLinkRepository.CreateAsync(inviteLink); + + return inviteLink; + } + + private async Task OrganizationHasInviteLinksAbilityAsync(Guid organizationId) + { + var ability = await applicationCacheService.GetOrganizationAbilityAsync(organizationId); + return ability is not null && ability.UseInviteLinks; + } + + /// + /// Normalizes domains to lowercase and removes blank entries. + /// + private static List SanitizeDomains(IEnumerable? domains) => + domains? + .Select(d => d?.Trim().ToLowerInvariant()) + .Where(d => !string.IsNullOrEmpty(d)) + .Cast() + .ToList() ?? []; +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/CreateOrganizationInviteLinkRequest.cs b/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/CreateOrganizationInviteLinkRequest.cs new file mode 100644 index 000000000000..66fdcca412a3 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/CreateOrganizationInviteLinkRequest.cs @@ -0,0 +1,13 @@ +namespace Bit.Core.AdminConsole.OrganizationFeatures.InviteLinks; + +public record CreateOrganizationInviteLinkRequest +{ + public required Guid OrganizationId { get; init; } + public required IEnumerable AllowedDomains { get; init; } + public required string EncryptedInviteKey { get; init; } + + /// + /// The organization key encrypted for the invite link. Currently unused; will be populated in a future stage. + /// + public string? EncryptedOrgKey { get; init; } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/Errors.cs b/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/Errors.cs new file mode 100644 index 000000000000..375903db92b7 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/Errors.cs @@ -0,0 +1,12 @@ +using Bit.Core.AdminConsole.Utilities.v2; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.InviteLinks; + +public record InviteLinkAlreadyExists() + : ConflictError("An invite link already exists for this organization."); + +public record InviteLinkDomainsRequired() + : BadRequestError("At least one allowed domain is required."); + +public record InviteLinkNotAvailable() + : BadRequestError("Your organization's plan does not support invite links."); diff --git a/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/Interfaces/ICreateOrganizationInviteLinkCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/Interfaces/ICreateOrganizationInviteLinkCommand.cs new file mode 100644 index 000000000000..126cb5843273 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/Interfaces/ICreateOrganizationInviteLinkCommand.cs @@ -0,0 +1,14 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Utilities.v2.Results; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.InviteLinks.Interfaces; + +public interface ICreateOrganizationInviteLinkCommand +{ + /// + /// Creates a new invite link for the specified organization. + /// + /// The details for the invite link to create. + /// The created , or an error if validation fails or a link already exists. + Task> CreateAsync(CreateOrganizationInviteLinkRequest request); +} diff --git a/src/Core/AdminConsole/Utilities/v2/Errors.cs b/src/Core/AdminConsole/Utilities/v2/Errors.cs index c1c66b2630e5..541e8217d4d0 100644 --- a/src/Core/AdminConsole/Utilities/v2/Errors.cs +++ b/src/Core/AdminConsole/Utilities/v2/Errors.cs @@ -12,4 +12,5 @@ public abstract record Error(string Message); public abstract record NotFoundError(string Message) : Error(Message); public abstract record BadRequestError(string Message) : Error(Message); +public abstract record ConflictError(string Message) : Error(Message); public abstract record InternalError(string Message) : Error(Message); diff --git a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs index 71edf1c79795..d732f7582dca 100644 --- a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs +++ b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs @@ -6,6 +6,8 @@ using Bit.Core.AdminConsole.OrganizationFeatures.Groups; using Bit.Core.AdminConsole.OrganizationFeatures.Groups.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.Import; +using Bit.Core.AdminConsole.OrganizationFeatures.InviteLinks; +using Bit.Core.AdminConsole.OrganizationFeatures.InviteLinks.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationApiKeys; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationApiKeys.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationConnections; @@ -45,6 +47,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.DataProtection; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Logging; using AccountRecoveryV2 = Bit.Core.AdminConsole.OrganizationFeatures.AccountRecovery.v2; using V1_RevokeUsersCommand = Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RevokeUser.v1; @@ -64,6 +67,7 @@ public static void AddOrganizationServices(this IServiceCollection services, IGl services.AddOrganizationApiKeyCommandsQueries(); services.AddOrganizationCollectionCommands(); services.AddOrganizationGroupCommands(); + services.AddOrganizationInviteLinkCommands(); services.AddOrganizationDomainCommandsQueries(); services.AddOrganizationSignUpCommands(); services.AddOrganizationDeleteCommands(); @@ -187,6 +191,11 @@ private static void AddOrganizationGroupCommands(this IServiceCollection service services.AddScoped(); } + private static void AddOrganizationInviteLinkCommands(this IServiceCollection services) + { + services.TryAddScoped(); + } + private static void AddOrganizationDomainCommandsQueries(this IServiceCollection services) { services.AddScoped(); diff --git a/src/Core/Utilities/ValidateSequenceAttribute.cs b/src/Core/Utilities/ValidateSequenceAttribute.cs new file mode 100644 index 000000000000..e50b39ecc001 --- /dev/null +++ b/src/Core/Utilities/ValidateSequenceAttribute.cs @@ -0,0 +1,42 @@ +using System.ComponentModel.DataAnnotations; +using System.Globalization; + +namespace Bit.Core.Utilities; + +/// +/// Validates each element of a collection using . +/// The property must be of a reference type (e.g. IEnumerable<string>). +/// An empty collection passes validation; use [MinLength(1)] if an empty collection should be invalid. +/// +[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field | AttributeTargets.Parameter)] +public class ValidateSequenceAttribute : ValidationAttribute + where TValidator : ValidationAttribute, new() +{ + private const string _invalidItemsMessage = "The following items are not valid: {0}"; + + protected override ValidationResult? IsValid(object? value, ValidationContext validationContext) + { + if (value is null) + { + return ValidationResult.Success; + } + + if (value is not IEnumerable items) + { + throw new ArgumentException("ValidateSequenceAttribute can only be used with IEnumerable properties."); + } + + var validator = new TValidator(); + var invalid = items.Where(item => !validator.IsValid(item)).ToList(); + + if (invalid.Count == 0) + { + return ValidationResult.Success; + } + + var memberNames = new[] { validationContext.MemberName ?? validationContext.DisplayName }; + var message = string.Format(CultureInfo.InvariantCulture, _invalidItemsMessage, string.Join(", ", invalid.Select(value => $"'{value}'"))); + + return new ValidationResult(message, memberNames!); + } +} diff --git a/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationInviteLinksControllerTests.cs b/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationInviteLinksControllerTests.cs new file mode 100644 index 000000000000..dd36d7471b55 --- /dev/null +++ b/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationInviteLinksControllerTests.cs @@ -0,0 +1,97 @@ +using System.Net; +using Bit.Api.AdminConsole.Models.Request.Organizations; +using Bit.Api.AdminConsole.Models.Response.Organizations; +using Bit.Api.IntegrationTest.Factories; +using Bit.Api.IntegrationTest.Helpers; +using Bit.Core; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Billing.Enums; +using Bit.Core.Enums; +using Bit.Core.Models.Data.Organizations; +using Bit.Core.Services; +using NSubstitute; +using Xunit; + +namespace Bit.Api.IntegrationTest.AdminConsole.Controllers; + +public class OrganizationInviteLinksControllerTests : IClassFixture, IAsyncLifetime +{ + private readonly HttpClient _client; + private readonly ApiApplicationFactory _factory; + private readonly LoginHelper _loginHelper; + + private const string _validEncryptedKey = + "2.AOs41Hd8OQiCPXjyJKCiDA==|O6OHgt2U2hJGBSNGnimJmg==|iD33s8B69C8JhYYhSa4V1tArjvLr8eEaGqOV7BRo5Jk="; + + private Organization _organization = null!; + private string _ownerEmail = null!; + + public OrganizationInviteLinksControllerTests(ApiApplicationFactory factory) + { + _factory = factory; + _factory.SubstituteService(featureService => + { + featureService + .IsEnabled(FeatureFlagKeys.GenerateInviteLink) + .Returns(true); + }); + _factory.SubstituteService(cacheService => + { + cacheService + .GetOrganizationAbilityAsync(Arg.Any()) + .Returns(new OrganizationAbility { UseInviteLinks = true }); + }); + _client = factory.CreateClient(); + _loginHelper = new LoginHelper(_factory, _client); + } + + public async Task InitializeAsync() + { + _ownerEmail = $"integration-test{Guid.NewGuid()}@example.com"; + await _factory.LoginWithNewAccount(_ownerEmail); + + (_organization, _) = await OrganizationTestHelpers.SignUpAsync( + _factory, + plan: PlanType.EnterpriseAnnually, + ownerEmail: _ownerEmail, + passwordManagerSeats: 10, + paymentMethod: PaymentMethodType.Card); + + await _loginHelper.LoginAsync(_ownerEmail); + } + + public Task DisposeAsync() + { + _client.Dispose(); + return Task.CompletedTask; + } + + [Fact] + public async Task Create_AsOwner_ReturnsCreated() + { + var request = new CreateOrganizationInviteLinkRequestModel + { + AllowedDomains = ["acme.com", "example.com"], + EncryptedInviteKey = _validEncryptedKey, + }; + + var response = await _client.PostAsJsonAsync( + $"/organizations/{_organization.Id}/invite-link", request); + + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + + var content = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(content); + Assert.NotEqual(Guid.Empty, content.Id); + Assert.NotEqual(Guid.Empty, content.Code); + Assert.Equal(_organization.Id, content.OrganizationId); + Assert.Equal(["acme.com", "example.com"], content.AllowedDomains); + Assert.Equal(_validEncryptedKey, content.EncryptedInviteKey); + + var repository = _factory.GetService(); + var persisted = await repository.GetByOrganizationIdAsync(_organization.Id); + Assert.NotNull(persisted); + Assert.Equal(content.Id, persisted.Id); + } +} diff --git a/test/Api.Test/AdminConsole/Controllers/OrganizationInviteLinksControllerTests.cs b/test/Api.Test/AdminConsole/Controllers/OrganizationInviteLinksControllerTests.cs new file mode 100644 index 000000000000..0c00b839d21c --- /dev/null +++ b/test/Api.Test/AdminConsole/Controllers/OrganizationInviteLinksControllerTests.cs @@ -0,0 +1,97 @@ +using Bit.Api.AdminConsole.Controllers; +using Bit.Api.AdminConsole.Models.Request.Organizations; +using Bit.Api.AdminConsole.Models.Response.Organizations; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.OrganizationFeatures.InviteLinks; +using Bit.Core.AdminConsole.OrganizationFeatures.InviteLinks.Interfaces; +using Bit.Core.AdminConsole.Utilities.v2.Results; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; +using NSubstitute; +using Xunit; + +namespace Bit.Api.Test.AdminConsole.Controllers; + +[ControllerCustomize(typeof(OrganizationInviteLinksController))] +[SutProviderCustomize] +public class OrganizationInviteLinksControllerTests +{ + [Theory, BitAutoData] + public async Task Create_WithValidInput_Success( + Guid orgId, + OrganizationInviteLink inviteLink, + SutProvider sutProvider) + { + inviteLink.OrganizationId = orgId; + inviteLink.AllowedDomains = "[\"acme.com\"]"; + + var model = new CreateOrganizationInviteLinkRequestModel + { + AllowedDomains = ["acme.com"], + EncryptedInviteKey = "encrypted-key", + }; + + sutProvider.GetDependency() + .CreateAsync(Arg.Any()) + .Returns(new CommandResult(inviteLink)); + + var result = await sutProvider.Sut.Create(orgId, model); + + var createdResult = Assert.IsType>(result); + Assert.Equal($"organizations/{orgId}/invite-link", createdResult.Location); + Assert.NotNull(createdResult.Value); + Assert.Equal(inviteLink.Id, createdResult.Value.Id); + Assert.Equal(inviteLink.Code, createdResult.Value.Code); + Assert.Equal(orgId, createdResult.Value.OrganizationId); + + await sutProvider.GetDependency() + .Received(1) + .CreateAsync(Arg.Is(r => + r.OrganizationId == orgId && + r.EncryptedInviteKey == "encrypted-key")); + } + + [Theory, BitAutoData] + public async Task Create_WithExistingLink_Returns409( + Guid orgId, + SutProvider sutProvider) + { + var model = new CreateOrganizationInviteLinkRequestModel + { + AllowedDomains = ["acme.com"], + EncryptedInviteKey = "encrypted-key", + }; + + sutProvider.GetDependency() + .CreateAsync(Arg.Any()) + .Returns(new CommandResult(new InviteLinkAlreadyExists())); + + var result = await sutProvider.Sut.Create(orgId, model); + + var jsonResult = Assert.IsType>(result); + Assert.Equal(StatusCodes.Status409Conflict, jsonResult.StatusCode); + } + + [Theory, BitAutoData] + public async Task Create_WithValidationError_Returns400( + Guid orgId, + SutProvider sutProvider) + { + var model = new CreateOrganizationInviteLinkRequestModel + { + AllowedDomains = [], + EncryptedInviteKey = "encrypted-key", + }; + + sutProvider.GetDependency() + .CreateAsync(Arg.Any()) + .Returns(new CommandResult(new InviteLinkDomainsRequired())); + + var result = await sutProvider.Sut.Create(orgId, model); + + var badRequestResult = Assert.IsType>(result); + Assert.NotNull(badRequestResult.Value); + } +} diff --git a/test/Api.Test/AdminConsole/Models/Request/Organizations/CreateOrganizationInviteLinkRequestModelTests.cs b/test/Api.Test/AdminConsole/Models/Request/Organizations/CreateOrganizationInviteLinkRequestModelTests.cs new file mode 100644 index 000000000000..f1c87208d7db --- /dev/null +++ b/test/Api.Test/AdminConsole/Models/Request/Organizations/CreateOrganizationInviteLinkRequestModelTests.cs @@ -0,0 +1,83 @@ +using System.ComponentModel.DataAnnotations; +using Bit.Api.AdminConsole.Models.Request.Organizations; +using Xunit; + +namespace Bit.Api.Test.AdminConsole.Models.Request.Organizations; + +public class CreateOrganizationInviteLinkRequestModelTests +{ + private const string _validEncryptedString = + "2.AOs41Hd8OQiCPXjyJKCiDA==|O6OHgt2U2hJGBSNGnimJmg==|iD33s8B69C8JhYYhSa4V1tArjvLr8eEaGqOV7BRo5Jk="; + + [Fact] + public void Validate_ValidModel_ReturnsNoErrors() + { + var model = new CreateOrganizationInviteLinkRequestModel + { + AllowedDomains = new[] { "acme.com" }, + EncryptedInviteKey = _validEncryptedString, + }; + + var results = Validate(model); + + Assert.Empty(results); + } + + [Theory] + [InlineData("not a domain")] + [InlineData("")] + [InlineData("double..dot.com")] + [InlineData("-starts-with-hyphen.com")] + [InlineData(" acme.com ")] + public void Validate_WithInvalidDomainFormat_ReturnsError(string invalidDomain) + { + var model = new CreateOrganizationInviteLinkRequestModel + { + AllowedDomains = new[] { invalidDomain }, + EncryptedInviteKey = _validEncryptedString, + }; + + var results = Validate(model); + + Assert.Single(results); + Assert.Contains(results, r => r.MemberNames.Contains(nameof(model.AllowedDomains))); + } + + [Fact] + public void Validate_WithEmptyAllowedDomains_ReturnsError() + { + var model = new CreateOrganizationInviteLinkRequestModel + { + AllowedDomains = Array.Empty(), + EncryptedInviteKey = _validEncryptedString, + }; + + var results = Validate(model); + + Assert.Single(results); + Assert.Contains(results, r => r.MemberNames.Contains(nameof(model.AllowedDomains))); + } + + [Fact] + public void Validate_WithMixedValidAndInvalidDomains_ReturnsError() + { + var model = new CreateOrganizationInviteLinkRequestModel + { + AllowedDomains = new[] { "acme.com", "not a domain", "