diff --git a/src/Api/Controllers/OrganizationSponsorshipsController.cs b/src/Api/Controllers/OrganizationSponsorshipsController.cs index 49d0557ea8..a24b74cef7 100644 --- a/src/Api/Controllers/OrganizationSponsorshipsController.cs +++ b/src/Api/Controllers/OrganizationSponsorshipsController.cs @@ -1,4 +1,5 @@ using System; +using System.Linq; using System.Threading.Tasks; using Bit.Core.Context; using Bit.Core.Enums; @@ -35,7 +36,7 @@ namespace Bit.Api.Controllers } [HttpPost("{sponsoringOrgId}/families-for-enterprise")] - public async Task CreateSponsorship(string sponsoringOrgId, [FromBody] OrganizationSponsorshipRequestModel model) + public async Task CreateSponsorship(string sponsoringOrgId, [FromBody] OrganizationSponsorshipRequestModel model) { // TODO: validate has right to sponsor, send sponsorship email var sponsoringOrgIdGuid = new Guid(sponsoringOrgId); @@ -50,32 +51,45 @@ namespace Bit.Api.Controllers { throw new BadRequestException("Only confirm users can sponsor other organizations."); } + if (sponsoringOrgUser.UserId != _currentContext.UserId) + { + throw new BadRequestException("Can only create organization sponsorships for yourself."); + } var existingOrgSponsorship = await _organizationSponsorshipRepository.GetBySponsoringOrganizationUserIdAsync(sponsoringOrgUser.Id); if (existingOrgSponsorship != null) { - throw new BadRequestException("Can only sponsor one organization per Organization User"); + throw new BadRequestException("Can only sponsor one organization per Organization User."); } - // TODO: send sponsorship email - - throw new NotImplementedException(); + await _organizationsSponsorshipService.OfferSponsorshipAsync(sponsoringOrg, sponsoringOrgUser, model.sponsoredEmail); } [HttpPost("sponsored/redeem/families-for-enterprise")] - public async Task RedeemSponsorship([FromQuery] string sponsorshipInfo, [FromBody] OrganizationSponsorshipRedeemRequestModel model) + 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"); + throw new BadRequestException("Can only redeem sponsorship for an organization you own"); + } + var existingSponsorshipOffer = await _organizationSponsorshipRepository + .GetByOfferedToEmailAsync(_currentContext.User.Email); + if (existingSponsorshipOffer == null) + { + throw new BadRequestException("No unredeemed sponsorship offer exists for you."); } - var existingOrgSponsorship = await _organizationSponsorshipRepository.GetBySponsoredOrganizationIdAsync(model.SponsoredOrganizationId); + 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."); + throw new BadRequestException("Cannot redeem a sponsorship offer for an organization that is already sponsored. Revoke existing sponsorship first."); + } + if (_currentContext.User.Email != existingOrgSponsorship.OfferedToEmail) + { + throw new BadRequestException("This sponsorship offer was issued to a different user email address."); } var organizationToSponsor = await _organizationRepository.GetByIdAsync(model.SponsoredOrganizationId); @@ -85,36 +99,34 @@ namespace Bit.Api.Controllers 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(); + await _organizationsSponsorshipService.SetUpSponsorshipAsync(existingSponsorshipOffer, organizationToSponsor); } - [HttpDelete("{sponsoringOrgId}/{sponsoringOrgUserId}")] - [HttpPost("{sponsoringOrgId}/{sponsoringOrgUserId}/delete")] - public async Task RevokeSponsorship(string sponsoringOrgId, string sponsoringOrgUserId) + [HttpDelete("{sponsoringOrgUserId}")] + [HttpPost("{sponsoringOrgUserId}/delete")] + public async Task RevokeSponsorship(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."); + throw new BadRequestException("Can only revoke a sponsorship you granted."); } - var existingOrgSponsorship = await _organizationSponsorshipRepository.GetBySponsoringOrganizationUserIdAsync(sponsoringOrgUserIdGuid); + 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(); + await _organizationsSponsorshipService.RemoveSponsorshipAsync(existingOrgSponsorship); } [HttpDelete("sponsored/{sponsoredOrgId}")] [HttpPost("sponsored/{sponsoredOrgId}/remove")] - public async Task RemoveSponsorship(string sponsoredOrgId) + public async Task RemoveSponsorship(string sponsoredOrgId) { var sponsoredOrgIdGuid = new Guid(sponsoredOrgId); @@ -123,14 +135,14 @@ namespace Bit.Api.Controllers throw new BadRequestException("Only the owner of an organization can remove sponsorship."); } - var existingOrgSponsorship = await _organizationSponsorshipRepository.GetBySponsoringOrganizationUserIdAsync(sponsoredOrgIdGuid); + var existingOrgSponsorship = await _organizationSponsorshipRepository + .GetBySponsoredOrganizationIdAsync(sponsoredOrgIdGuid); if (existingOrgSponsorship == null) { - throw new BadRequestException("The requested organization is not currently being sponsored"); + throw new BadRequestException("The requested organization is not currently being sponsored."); } - // TODO: remove sponsorship - throw new NotImplementedException(); + await _organizationsSponsorshipService.RemoveSponsorshipAsync(existingOrgSponsorship); } } } diff --git a/src/Core/Models/Table/OrganizationSponsorship.cs b/src/Core/Models/Table/OrganizationSponsorship.cs index 3644f69dc1..33b5b8adfa 100644 --- a/src/Core/Models/Table/OrganizationSponsorship.cs +++ b/src/Core/Models/Table/OrganizationSponsorship.cs @@ -1,4 +1,5 @@ using System; +using System.ComponentModel.DataAnnotations; using Bit.Core.Utilities; namespace Bit.Core.Models.Table @@ -7,10 +8,15 @@ namespace Bit.Core.Models.Table { public Guid Id { get; set; } public Guid InstallationId { get; set; } + [Required] public Guid SponsoringOrganizationId { get; set; } + [Required] public Guid SponsoringOrganizationUserId { get; set; } public Guid SponsoringUserId { get; set; } + [MaxLength(256)] + public string OfferedToEmail { get; set; } public Guid? SponsoredOrganizationId { get; set; } + [Required] public bool CloudSponsor { get; set; } public DateTime? LastSyncDate { get; set; } public byte TimesRenewedWithoutValidation { get; set; } diff --git a/src/Core/Repositories/IOrganizationSponsorshipRepository.cs b/src/Core/Repositories/IOrganizationSponsorshipRepository.cs index 1d0fea79af..9d81cae919 100644 --- a/src/Core/Repositories/IOrganizationSponsorshipRepository.cs +++ b/src/Core/Repositories/IOrganizationSponsorshipRepository.cs @@ -10,5 +10,6 @@ namespace Bit.Core.Repositories { Task GetBySponsoringOrganizationUserIdAsync(Guid sponsoringOrganizationUserId); Task GetBySponsoredOrganizationIdAsync(Guid sponsoredOrganizationId); + Task GetByOfferedToEmailAsync(string email); } } diff --git a/src/Core/Repositories/SqlServer/OrganizationSponsorshipRepository.cs b/src/Core/Repositories/SqlServer/OrganizationSponsorshipRepository.cs index 604867a9fa..c759e906b8 100644 --- a/src/Core/Repositories/SqlServer/OrganizationSponsorshipRepository.cs +++ b/src/Core/Repositories/SqlServer/OrganizationSponsorshipRepository.cs @@ -1,5 +1,4 @@ using System; -using System.Collections.Generic; using System.Data; using System.Data.SqlClient; using System.Linq; @@ -26,7 +25,10 @@ namespace Bit.Core.Repositories.SqlServer { var results = await connection.QueryAsync( "[dbo].[OrganizationSponsorship_ReadBySponsoringOrganizationUserId]", - new { SponsoringOrganizationUserId = sponsoringOrganizationUserId }, + new + { + SponsoringOrganizationUserId = sponsoringOrganizationUserId + }, commandType: CommandType.StoredProcedure); return results.SingleOrDefault(); @@ -45,5 +47,21 @@ namespace Bit.Core.Repositories.SqlServer return results.SingleOrDefault(); } } + + public async Task GetByOfferedToEmailAsync(string offeredToEmail) + { + using (var connection = new SqlConnection(ConnectionString)) + { + var results = await connection.QueryAsync( + "[dbo].[OrganizationSponsorship_ReadByOfferedToEmail]", + new + { + OfferedToEmail = offeredToEmail + }, + commandType: CommandType.StoredProcedure); + + return results.SingleOrDefault(); + } + } } } diff --git a/src/Core/Services/IOrganizationSponsorshipService.cs b/src/Core/Services/IOrganizationSponsorshipService.cs index 01ddf14f68..070b982260 100644 --- a/src/Core/Services/IOrganizationSponsorshipService.cs +++ b/src/Core/Services/IOrganizationSponsorshipService.cs @@ -1,7 +1,12 @@ +using System.Threading.Tasks; +using Bit.Core.Models.Table; + namespace Bit.Core.Services { public interface IOrganizationSponsorshipService { - + Task OfferSponsorshipAsync(Organization sponsoringOrg, OrganizationUser sponsoringOrgUser, string sponsoredEmail); + Task SetUpSponsorshipAsync(OrganizationSponsorship sponsorship, Organization sponsoredOrganization); + Task RemoveSponsorshipAsync(OrganizationSponsorship sponsorship); } } diff --git a/src/Core/Services/Implementations/OrganizationSponsorshipService.cs b/src/Core/Services/Implementations/OrganizationSponsorshipService.cs index 5cdc9a71aa..9a9f1213f7 100644 --- a/src/Core/Services/Implementations/OrganizationSponsorshipService.cs +++ b/src/Core/Services/Implementations/OrganizationSponsorshipService.cs @@ -1,3 +1,6 @@ +using System; +using System.Threading.Tasks; +using Bit.Core.Models.Table; using Bit.Core.Repositories; namespace Bit.Core.Services @@ -10,5 +13,24 @@ namespace Bit.Core.Services { _organizationSponsorshipRepository = organizationSponsorshipRepository; } + + public async Task OfferSponsorshipAsync(Organization sponsoringOrg, OrganizationUser sponsoringOrgUser, string sponsoredEmail) + { + // TODO: send sponsorship email, update sponsorship with offered email + throw new NotImplementedException(); + } + + public async Task SetUpSponsorshipAsync(OrganizationSponsorship sponsorship, Organization sponsoredOrganization) + { + // TODO: set up sponsorship + throw new NotImplementedException(); + } + + public async Task RemoveSponsorshipAsync(OrganizationSponsorship sponsorship) + { + // TODO: remove sponsorship + throw new NotImplementedException(); + } + } } diff --git a/test/Api.Test/Controllers/OrganizationSponsorshipsControllerTests.cs b/test/Api.Test/Controllers/OrganizationSponsorshipsControllerTests.cs index 845a0f5f0e..a2231d1efa 100644 --- a/test/Api.Test/Controllers/OrganizationSponsorshipsControllerTests.cs +++ b/test/Api.Test/Controllers/OrganizationSponsorshipsControllerTests.cs @@ -12,6 +12,9 @@ using Bit.Core.Context; using NSubstitute; using Bit.Core.Exceptions; using Bit.Api.Test.AutoFixture.Attributes; +using Bit.Core.Repositories; +using Bit.Core.Models.Api.Request; +using Bit.Core.Services; namespace Bit.Api.Test.Controllers { @@ -25,16 +28,179 @@ namespace Bit.Api.Test.Controllers Enum.GetValues().Where(p => !PlanTypeHelper.IsEnterprise(p)).Select(p => new object[] { p }); [Theory] - [MemberAutoData(nameof(NonEnterprisePlanTypes))] + [BitMemberAutoData(nameof(NonEnterprisePlanTypes))] public async Task CreateSponsorship_BadSponsoringOrgPlan_ThrowsBadRequest(PlanType sponsoringOrgPlan, Organization org, SutProvider sutProvider) { org.PlanType = sponsoringOrgPlan; + sutProvider.GetDependency().GetByIdAsync(org.Id).Returns(org); var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.CreateSponsorship(org.Id.ToString(), null)); Assert.Contains("Specified Organization cannot sponsor other organizations.", exception.Message); + await sutProvider.GetDependency() + .OfferSponsorshipAsync(default, default, default) + .DidNotReceiveWithAnyArgs(); + } + + public static IEnumerable NonConfirmedOrganizationUsersStatuses => + Enum.GetValues() + .Where(s => s != OrganizationUserStatusType.Confirmed) + .Select(s => new object[] { s }); + + [Theory] + [BitMemberAutoData(nameof(NonConfirmedOrganizationUsersStatuses))] + public async Task CreateSponsorship_BadSponsoringUserStatus_ThrowsBadRequest( + OrganizationUserStatusType statusType, Guid userId, Organization org, OrganizationUser orgUser, + OrganizationSponsorshipRequestModel model, SutProvider sutProvider) + { + org.PlanType = PlanType.EnterpriseAnnually; + orgUser.Status = statusType; + orgUser.UserId = userId; + model.OrganizationUserId = orgUser.Id; + + sutProvider.GetDependency().GetByIdAsync(org.Id).Returns(org); + sutProvider.GetDependency().UserId.Returns(userId); + sutProvider.GetDependency().GetByIdAsync(orgUser.Id).Returns(orgUser); + + var exception = await Assert.ThrowsAsync(() => + sutProvider.Sut.CreateSponsorship(org.Id.ToString(), model)); + + Assert.Contains("Only confirm users can sponsor other organizations.", exception.Message); + await sutProvider.GetDependency() + .OfferSponsorshipAsync(default, default, default) + .DidNotReceiveWithAnyArgs(); + } + + [Theory] + [BitAutoData("c56c7ab4-a174-412a-a822-abe53ea71d50")] + public async Task CreateSponsorship_CreateSponsorshipAsDifferentUser_ThrowsBadRequest(Guid userId, + Organization org, OrganizationUser orgUser, OrganizationSponsorshipRequestModel model, + SutProvider sutProvider) + { + org.PlanType = PlanType.EnterpriseAnnually; + orgUser.Status = OrganizationUserStatusType.Confirmed; + model.OrganizationUserId = orgUser.Id; + + sutProvider.GetDependency().GetByIdAsync(org.Id).Returns(org); + sutProvider.GetDependency().UserId.Returns(userId); + sutProvider.GetDependency().GetByIdAsync(orgUser.Id).Returns(orgUser); + + var exception = await Assert.ThrowsAsync(() => + sutProvider.Sut.CreateSponsorship(org.Id.ToString(), model)); + + Assert.Contains("Can only create organization sponsorships for yourself.", exception.Message); + await sutProvider.GetDependency() + .OfferSponsorshipAsync(default, default, default) + .DidNotReceiveWithAnyArgs(); + } + + [Theory] + [BitAutoData] + public async Task CreateSponsorship_AlreadySponsoring_ThrowsBadRequest(Organization org, + OrganizationUser orgUser, OrganizationSponsorship sponsorship, + OrganizationSponsorshipRequestModel model, SutProvider sutProvider) + { + org.PlanType = PlanType.EnterpriseAnnually; + orgUser.Status = OrganizationUserStatusType.Confirmed; + model.OrganizationUserId = orgUser.Id; + + sutProvider.GetDependency().GetByIdAsync(org.Id).Returns(org); + sutProvider.GetDependency().UserId.Returns(orgUser.UserId); + sutProvider.GetDependency().GetByIdAsync(orgUser.Id).Returns(orgUser); + sutProvider.GetDependency() + .GetBySponsoringOrganizationUserIdAsync(orgUser.Id).Returns(sponsorship); + + var exception = await Assert.ThrowsAsync(() => + sutProvider.Sut.CreateSponsorship(org.Id.ToString(), model)); + + Assert.Contains("Can only sponsor one organization per Organization User.", exception.Message); + await sutProvider.GetDependency() + .OfferSponsorshipAsync(default, default, default) + .DidNotReceiveWithAnyArgs(); + } + + // TODO: Test redeem sponsorship + + [Theory] + [BitAutoData] + public async Task RevokeSponsorship_WrongSponsoringUser_ThrowsBadRequest(OrganizationUser sponsoringOrgUser, + Guid currentUserId, SutProvider sutProvider) + { + sutProvider.GetDependency().UserId.Returns(currentUserId); + sutProvider.GetDependency().GetByIdAsync(sponsoringOrgUser.Id) + .Returns(sponsoringOrgUser); + + var exception = await Assert.ThrowsAsync(() => + sutProvider.Sut.RevokeSponsorship(sponsoringOrgUser.Id.ToString())); + + Assert.Contains("Can only revoke a sponsorship you granted.", exception.Message); + await sutProvider.GetDependency() + .RemoveSponsorshipAsync(default) + .DidNotReceiveWithAnyArgs(); + } + + [Theory] + [BitAutoData] + public async Task RevokeSponsorship_NoExistingSponsorship_ThrowsBadRequest(OrganizationUser sponsoringOrgUser, + OrganizationSponsorship sponsorship, SutProvider sutProvider) + { + sutProvider.GetDependency().UserId.Returns(sponsoringOrgUser.UserId); + sutProvider.GetDependency().GetByIdAsync(sponsoringOrgUser.Id) + .Returns(sponsoringOrgUser); + sutProvider.GetDependency() + .GetBySponsoringOrganizationUserIdAsync(Arg.Is(v => v != sponsoringOrgUser.Id)) + .Returns(sponsorship); + sutProvider.GetDependency() + .GetBySponsoringOrganizationUserIdAsync(sponsoringOrgUser.Id) + .Returns((OrganizationSponsorship)null); + + var exception = await Assert.ThrowsAsync(() => + sutProvider.Sut.RevokeSponsorship(sponsoringOrgUser.Id.ToString())); + + Assert.Contains("You are not currently sponsoring and organization.", exception.Message); + await sutProvider.GetDependency() + .RemoveSponsorshipAsync(default) + .DidNotReceiveWithAnyArgs(); + } + + [Theory] + [BitAutoData] + public async Task RemoveSponsorship_WrongOrgUserType_ThrowsBadRequest(Organization sponsoredOrg, + SutProvider sutProvider) + { + sutProvider.GetDependency().OrganizationOwner(Arg.Any()).Returns(false); + + var exception = await Assert.ThrowsAsync(() => + sutProvider.Sut.RemoveSponsorship(sponsoredOrg.Id.ToString())); + + Assert.Contains("Only the owner of an organization can remove sponsorship.", exception.Message); + await sutProvider.GetDependency() + .RemoveSponsorshipAsync(default) + .DidNotReceiveWithAnyArgs(); + } + + [Theory] + [BitAutoData] + public async Task RemoveSponsorship_NotSponsored_ThrowsBadRequest(Organization sponsoredOrg, + OrganizationSponsorship sponsorship, SutProvider sutProvider) + { + sutProvider.GetDependency().OrganizationOwner(Arg.Any()).Returns(true); + sutProvider.GetDependency() + .GetBySponsoredOrganizationIdAsync(sponsoredOrg.Id) + .Returns((OrganizationSponsorship)null); + sutProvider.GetDependency() + .GetBySponsoredOrganizationIdAsync(Arg.Is(v => v != sponsoredOrg.Id)) + .Returns(sponsorship); + + var exception = await Assert.ThrowsAsync(() => + sutProvider.Sut.RemoveSponsorship(sponsoredOrg.Id.ToString())); + + Assert.Contains("The requested organization is not currently being sponsored.", exception.Message); + await sutProvider.GetDependency() + .RemoveSponsorshipAsync(default) + .DidNotReceiveWithAnyArgs(); } } }