From b8fdbbcb9f2e044b8c5c6194df574d477ae6abf4 Mon Sep 17 00:00:00 2001 From: Matt Gibson Date: Fri, 29 Oct 2021 18:43:45 -0400 Subject: [PATCH] WIP: Organization sponsorship flow --- .../OrganizationSponsorshipsController.cs | 136 ++++++++++++++++++ ...ganizationSponsorshipRedeemRequestModel.cs | 11 ++ .../OrganizationSponsorshipRequestModel.cs | 16 +++ .../Models/Table/OrganizationSponsorship.cs | 24 ++++ .../IOrganizationSponsorshipRepository.cs | 14 ++ .../OrganizationSponsorshipRepository.cs | 49 +++++++ .../IOrganizationSponsorshipService.cs | 7 + .../OrganizationSponsorshipService.cs | 14 ++ .../ControllerCustomizeAttribute.cs | 17 +++ ...OrganizationSponsorshipsControllerTests.cs | 40 ++++++ .../Attributes/SutAutoDataAttribute.cs | 5 + 11 files changed, 333 insertions(+) create mode 100644 src/Api/Controllers/OrganizationSponsorshipsController.cs create mode 100644 src/Core/Models/Api/Request/Organizations/OrganizationSponsorshipRedeemRequestModel.cs create mode 100644 src/Core/Models/Api/Request/Organizations/OrganizationSponsorshipRequestModel.cs create mode 100644 src/Core/Models/Table/OrganizationSponsorship.cs create mode 100644 src/Core/Repositories/IOrganizationSponsorshipRepository.cs create mode 100644 src/Core/Repositories/SqlServer/OrganizationSponsorshipRepository.cs create mode 100644 src/Core/Services/IOrganizationSponsorshipService.cs create mode 100644 src/Core/Services/Implementations/OrganizationSponsorshipService.cs create mode 100644 test/Api.Test/AutoFixture/Attributes/ControllerCustomizeAttribute.cs create mode 100644 test/Api.Test/Controllers/OrganizationSponsorshipsControllerTests.cs diff --git a/src/Api/Controllers/OrganizationSponsorshipsController.cs b/src/Api/Controllers/OrganizationSponsorshipsController.cs new file mode 100644 index 000000000..49d0557ea --- /dev/null +++ b/src/Api/Controllers/OrganizationSponsorshipsController.cs @@ -0,0 +1,136 @@ +using System; +using System.Threading.Tasks; +using Bit.Core.Context; +using Bit.Core.Enums; +using Bit.Core.Exceptions; +using Bit.Core.Models.Api; +using Bit.Core.Models.Api.Request; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Bit.Api.Controllers +{ + [Route("organization/sponsorship")] + [Authorize("Application")] + public class OrganizationSponsorshipsController : Controller + { + private readonly IOrganizationSponsorshipService _organizationsSponsorshipService; + private readonly IOrganizationSponsorshipRepository _organizationSponsorshipRepository; + private readonly IOrganizationRepository _organizationRepository; + private readonly IOrganizationUserRepository _organizationUserRepository; + private readonly ICurrentContext _currentContext; + public OrganizationSponsorshipsController(IOrganizationSponsorshipService organizationSponsorshipService, + IOrganizationSponsorshipRepository organizationSponsorshipRepository, + IOrganizationRepository organizationRepository, + IOrganizationUserRepository organizationUserRepository, + ICurrentContext currentContext) + { + _organizationsSponsorshipService = organizationSponsorshipService; + _organizationSponsorshipRepository = organizationSponsorshipRepository; + _organizationRepository = organizationRepository; + _organizationUserRepository = organizationUserRepository; + _currentContext = currentContext; + } + + [HttpPost("{sponsoringOrgId}/families-for-enterprise")] + public async Task CreateSponsorship(string sponsoringOrgId, [FromBody] OrganizationSponsorshipRequestModel model) + { + // TODO: validate has right to sponsor, send sponsorship email + var sponsoringOrgIdGuid = new Guid(sponsoringOrgId); + var sponsoringOrg = await _organizationRepository.GetByIdAsync(sponsoringOrgIdGuid); + if (sponsoringOrg == null || !PlanTypeHelper.HasEnterprisePlan(sponsoringOrg)) + { + throw new BadRequestException("Specified Organization cannot sponsor other organizations."); + } + + var sponsoringOrgUser = await _organizationUserRepository.GetByIdAsync(model.OrganizationUserId); + if (sponsoringOrgUser == null || sponsoringOrgUser.Status != OrganizationUserStatusType.Confirmed) + { + throw new BadRequestException("Only confirm users can sponsor other organizations."); + } + + var existingOrgSponsorship = await _organizationSponsorshipRepository.GetBySponsoringOrganizationUserIdAsync(sponsoringOrgUser.Id); + if (existingOrgSponsorship != null) + { + throw new BadRequestException("Can only sponsor one organization per Organization User"); + } + + // TODO: send sponsorship email + + throw new NotImplementedException(); + } + + [HttpPost("sponsored/redeem/families-for-enterprise")] + public async Task RedeemSponsorship([FromQuery] string sponsorshipInfo, [FromBody] OrganizationSponsorshipRedeemRequestModel model) + { + // TODO: parse out sponsorshipInfo + + if (!await _currentContext.OrganizationOwner(model.SponsoredOrganizationId)) + { + throw new BadRequestException("Can only redeem sponsorship for and organization you own"); + } + + var existingOrgSponsorship = await _organizationSponsorshipRepository.GetBySponsoredOrganizationIdAsync(model.SponsoredOrganizationId); + if (existingOrgSponsorship != null) + { + throw new BadRequestException("Cannot redeem a sponsorship offer for and organization that is already sponsored. Revoke existing sponsorship first."); + } + + var organizationToSponsor = await _organizationRepository.GetByIdAsync(model.SponsoredOrganizationId); + // TODO: only current families plan? + if (organizationToSponsor == null || !PlanTypeHelper.HasFamiliesPlan(organizationToSponsor)) + { + throw new BadRequestException("Can only redeem sponsorship offer on families organizations"); + } + + // TODO: check user is owner of proposed org, it isn't currently sponsored, and set up sponsorship + throw new NotImplementedException(); + } + + [HttpDelete("{sponsoringOrgId}/{sponsoringOrgUserId}")] + [HttpPost("{sponsoringOrgId}/{sponsoringOrgUserId}/delete")] + public async Task RevokeSponsorship(string sponsoringOrgId, string sponsoringOrgUserId) + { + var sponsoringOrgIdGuid = new Guid(sponsoringOrgId); + var sponsoringOrgUserIdGuid = new Guid(sponsoringOrgUserId); + + var orgUser = await _organizationUserRepository.GetByIdAsync(sponsoringOrgUserIdGuid); + if (_currentContext.UserId != orgUser?.UserId) + { + throw new BadRequestException("Can only revoke a sponsorship you own."); + } + + var existingOrgSponsorship = await _organizationSponsorshipRepository.GetBySponsoringOrganizationUserIdAsync(sponsoringOrgUserIdGuid); + if (existingOrgSponsorship == null) + { + throw new BadRequestException("You are not currently sponsoring and organization."); + } + + // TODO: remove sponsorship + throw new NotImplementedException(); + } + + [HttpDelete("sponsored/{sponsoredOrgId}")] + [HttpPost("sponsored/{sponsoredOrgId}/remove")] + public async Task RemoveSponsorship(string sponsoredOrgId) + { + var sponsoredOrgIdGuid = new Guid(sponsoredOrgId); + + if (!await _currentContext.OrganizationOwner(sponsoredOrgIdGuid)) + { + throw new BadRequestException("Only the owner of an organization can remove sponsorship."); + } + + var existingOrgSponsorship = await _organizationSponsorshipRepository.GetBySponsoringOrganizationUserIdAsync(sponsoredOrgIdGuid); + if (existingOrgSponsorship == null) + { + throw new BadRequestException("The requested organization is not currently being sponsored"); + } + + // TODO: remove sponsorship + throw new NotImplementedException(); + } + } +} diff --git a/src/Core/Models/Api/Request/Organizations/OrganizationSponsorshipRedeemRequestModel.cs b/src/Core/Models/Api/Request/Organizations/OrganizationSponsorshipRedeemRequestModel.cs new file mode 100644 index 000000000..a23ac5f9a --- /dev/null +++ b/src/Core/Models/Api/Request/Organizations/OrganizationSponsorshipRedeemRequestModel.cs @@ -0,0 +1,11 @@ +using System; +using System.ComponentModel.DataAnnotations; + +namespace Bit.Core.Models.Api +{ + public class OrganizationSponsorshipRedeemRequestModel + { + [Required] + public Guid SponsoredOrganizationId { get; set; } + } +} diff --git a/src/Core/Models/Api/Request/Organizations/OrganizationSponsorshipRequestModel.cs b/src/Core/Models/Api/Request/Organizations/OrganizationSponsorshipRequestModel.cs new file mode 100644 index 000000000..1e8c8a981 --- /dev/null +++ b/src/Core/Models/Api/Request/Organizations/OrganizationSponsorshipRequestModel.cs @@ -0,0 +1,16 @@ +using System; +using System.ComponentModel.DataAnnotations; +using Bit.Core.Utilities; + +namespace Bit.Core.Models.Api.Request +{ + public class OrganizationSponsorshipRequestModel + { + [Required] + public Guid OrganizationUserId { get; set; } + [Required] + [StringLength(256)] + [StrictEmailAddress] + public string sponsoredEmail { get; set; } + } +} diff --git a/src/Core/Models/Table/OrganizationSponsorship.cs b/src/Core/Models/Table/OrganizationSponsorship.cs new file mode 100644 index 000000000..3644f69dc --- /dev/null +++ b/src/Core/Models/Table/OrganizationSponsorship.cs @@ -0,0 +1,24 @@ +using System; +using Bit.Core.Utilities; + +namespace Bit.Core.Models.Table +{ + public class OrganizationSponsorship : ITableObject + { + public Guid Id { get; set; } + public Guid InstallationId { get; set; } + public Guid SponsoringOrganizationId { get; set; } + public Guid SponsoringOrganizationUserId { get; set; } + public Guid SponsoringUserId { get; set; } + public Guid? SponsoredOrganizationId { get; set; } + public bool CloudSponsor { get; set; } + public DateTime? LastSyncDate { get; set; } + public byte TimesRenewedWithoutValidation { get; set; } + public DateTime? SponsorshipLapsedDate { get; set; } + + public void SetNewId() + { + Id = CoreHelpers.GenerateComb(); + } + } +} diff --git a/src/Core/Repositories/IOrganizationSponsorshipRepository.cs b/src/Core/Repositories/IOrganizationSponsorshipRepository.cs new file mode 100644 index 000000000..1d0fea79a --- /dev/null +++ b/src/Core/Repositories/IOrganizationSponsorshipRepository.cs @@ -0,0 +1,14 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Threading.Tasks; +using Bit.Core.Models.Table; + +namespace Bit.Core.Repositories +{ + public interface IOrganizationSponsorshipRepository : IRepository + { + Task GetBySponsoringOrganizationUserIdAsync(Guid sponsoringOrganizationUserId); + Task GetBySponsoredOrganizationIdAsync(Guid sponsoredOrganizationId); + } +} diff --git a/src/Core/Repositories/SqlServer/OrganizationSponsorshipRepository.cs b/src/Core/Repositories/SqlServer/OrganizationSponsorshipRepository.cs new file mode 100644 index 000000000..604867a9f --- /dev/null +++ b/src/Core/Repositories/SqlServer/OrganizationSponsorshipRepository.cs @@ -0,0 +1,49 @@ +using System; +using System.Collections.Generic; +using System.Data; +using System.Data.SqlClient; +using System.Linq; +using System.Threading.Tasks; +using Bit.Core.Models.Table; +using Bit.Core.Settings; +using Dapper; + +namespace Bit.Core.Repositories.SqlServer +{ + public class OrganizationSponsorshipRepository : Repository, IOrganizationSponsorshipRepository + { + public OrganizationSponsorshipRepository(GlobalSettings globalSettings) + : this(globalSettings.SqlServer.ConnectionString, globalSettings.SqlServer.ReadOnlyConnectionString) + { } + + public OrganizationSponsorshipRepository(string connectionString, string readOnlyConnectionString) + : base(connectionString, readOnlyConnectionString) + { } + + public async Task GetBySponsoringOrganizationUserIdAsync(Guid sponsoringOrganizationUserId) + { + using (var connection = new SqlConnection(ConnectionString)) + { + var results = await connection.QueryAsync( + "[dbo].[OrganizationSponsorship_ReadBySponsoringOrganizationUserId]", + new { SponsoringOrganizationUserId = sponsoringOrganizationUserId }, + commandType: CommandType.StoredProcedure); + + return results.SingleOrDefault(); + } + } + + public async Task GetBySponsoredOrganizationIdAsync(Guid sponsoredOrganizationId) + { + using (var connection = new SqlConnection(ConnectionString)) + { + var results = await connection.QueryAsync( + "[dbo].[OrganizationSponsorship_ReadBySponsoredOrganizationId]", + new { SponsoredOrganizationId = sponsoredOrganizationId }, + commandType: CommandType.StoredProcedure); + + return results.SingleOrDefault(); + } + } + } +} diff --git a/src/Core/Services/IOrganizationSponsorshipService.cs b/src/Core/Services/IOrganizationSponsorshipService.cs new file mode 100644 index 000000000..01ddf14f6 --- /dev/null +++ b/src/Core/Services/IOrganizationSponsorshipService.cs @@ -0,0 +1,7 @@ +namespace Bit.Core.Services +{ + public interface IOrganizationSponsorshipService + { + + } +} diff --git a/src/Core/Services/Implementations/OrganizationSponsorshipService.cs b/src/Core/Services/Implementations/OrganizationSponsorshipService.cs new file mode 100644 index 000000000..5cdc9a71a --- /dev/null +++ b/src/Core/Services/Implementations/OrganizationSponsorshipService.cs @@ -0,0 +1,14 @@ +using Bit.Core.Repositories; + +namespace Bit.Core.Services +{ + public class OrganizationSponsorshipService : IOrganizationSponsorshipService + { + private readonly IOrganizationSponsorshipRepository _organizationSponsorshipRepository; + + public OrganizationSponsorshipService(IOrganizationSponsorshipRepository organizationSponsorshipRepository) + { + _organizationSponsorshipRepository = organizationSponsorshipRepository; + } + } +} diff --git a/test/Api.Test/AutoFixture/Attributes/ControllerCustomizeAttribute.cs b/test/Api.Test/AutoFixture/Attributes/ControllerCustomizeAttribute.cs new file mode 100644 index 000000000..fa5f764b9 --- /dev/null +++ b/test/Api.Test/AutoFixture/Attributes/ControllerCustomizeAttribute.cs @@ -0,0 +1,17 @@ +using System; +using AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; + +namespace Bit.Api.Test.AutoFixture.Attributes +{ + public class ControllerCustomizeAttribute : BitCustomizeAttribute + { + private readonly Type _controllerType; + public ControllerCustomizeAttribute(Type controllerType) + { + _controllerType = controllerType; + } + + public override ICustomization GetCustomization() => new ControllerCustomization(_controllerType); + } +} diff --git a/test/Api.Test/Controllers/OrganizationSponsorshipsControllerTests.cs b/test/Api.Test/Controllers/OrganizationSponsorshipsControllerTests.cs new file mode 100644 index 000000000..845a0f5f0 --- /dev/null +++ b/test/Api.Test/Controllers/OrganizationSponsorshipsControllerTests.cs @@ -0,0 +1,40 @@ +using Xunit; +using Bit.Test.Common.AutoFixture.Attributes; +using System.Threading.Tasks; +using System; +using Bit.Core.Enums; +using System.Linq; +using System.Collections.Generic; +using Bit.Core.Models.Table; +using Bit.Test.Common.AutoFixture; +using Bit.Api.Controllers; +using Bit.Core.Context; +using NSubstitute; +using Bit.Core.Exceptions; +using Bit.Api.Test.AutoFixture.Attributes; + +namespace Bit.Api.Test.Controllers +{ + [ControllerCustomize(typeof(OrganizationSponsorshipsController))] + [SutProviderCustomize] + public class OrganizationSponsorshipsControllerTests + { + public static IEnumerable EnterprisePlanTypes => + Enum.GetValues().Where(p => PlanTypeHelper.IsEnterprise(p)).Select(p => new object[] { p }); + public static IEnumerable NonEnterprisePlanTypes => + Enum.GetValues().Where(p => !PlanTypeHelper.IsEnterprise(p)).Select(p => new object[] { p }); + + [Theory] + [MemberAutoData(nameof(NonEnterprisePlanTypes))] + public async Task CreateSponsorship_BadSponsoringOrgPlan_ThrowsBadRequest(PlanType sponsoringOrgPlan, Organization org, + SutProvider sutProvider) + { + org.PlanType = sponsoringOrgPlan; + + var exception = await Assert.ThrowsAsync(() => + sutProvider.Sut.CreateSponsorship(org.Id.ToString(), null)); + + Assert.Contains("Specified Organization cannot sponsor other organizations.", exception.Message); + } + } +} diff --git a/test/Common/AutoFixture/Attributes/SutAutoDataAttribute.cs b/test/Common/AutoFixture/Attributes/SutAutoDataAttribute.cs index 0aa666dad..1c8bd089b 100644 --- a/test/Common/AutoFixture/Attributes/SutAutoDataAttribute.cs +++ b/test/Common/AutoFixture/Attributes/SutAutoDataAttribute.cs @@ -6,6 +6,11 @@ using AutoFixture.Xunit2; namespace Bit.Test.Common.AutoFixture.Attributes { + public class SutProviderCustomizeAttribute : BitCustomizeAttribute + { + public override ICustomization GetCustomization() => new SutProviderCustomization(); + } + public class SutAutoDataAttribute : CustomAutoDataAttribute { public SutAutoDataAttribute(params Type[] iCustomizationTypes) : base(