diff --git a/src/Api/Controllers/OrganizationSponsorshipsController.cs b/src/Api/Controllers/OrganizationSponsorshipsController.cs index a24b74cef7..fde4f938e6 100644 --- a/src/Api/Controllers/OrganizationSponsorshipsController.cs +++ b/src/Api/Controllers/OrganizationSponsorshipsController.cs @@ -8,6 +8,7 @@ using Bit.Core.Models.Api; using Bit.Core.Models.Api.Request; using Bit.Core.Repositories; using Bit.Core.Services; +using Bit.Core.Utilities; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -36,6 +37,7 @@ namespace Bit.Api.Controllers } [HttpPost("{sponsoringOrgId}/families-for-enterprise")] + [SelfHosted(NotSelfHostedOnly = true)] public async Task CreateSponsorship(string sponsoringOrgId, [FromBody] OrganizationSponsorshipRequestModel model) { // TODO: validate has right to sponsor, send sponsorship email @@ -66,13 +68,18 @@ namespace Bit.Api.Controllers } [HttpPost("sponsored/redeem/families-for-enterprise")] - public async Task RedeemSponsorship([FromQuery] string sponsorshipInfo, [FromBody] OrganizationSponsorshipRedeemRequestModel model) + [SelfHosted(NotSelfHostedOnly = true)] + public async Task RedeemSponsorship([FromQuery] string sponsorshipToken, [FromBody] OrganizationSponsorshipRedeemRequestModel model) { // TODO: parse out sponsorshipInfo + if (!await _organizationsSponsorshipService.ValidateRedemptionTokenAsync(sponsorshipToken)) + { + throw new BadRequestException("Failed to parse sponsorship token."); + } if (!await _currentContext.OrganizationOwner(model.SponsoredOrganizationId)) { - throw new BadRequestException("Can only redeem sponsorship for an organization you own"); + throw new BadRequestException("Can only redeem sponsorship for an organization you own."); } var existingSponsorshipOffer = await _organizationSponsorshipRepository .GetByOfferedToEmailAsync(_currentContext.User.Email); @@ -80,6 +87,10 @@ namespace Bit.Api.Controllers { throw new BadRequestException("No unredeemed sponsorship offer exists for you."); } + if (_currentContext.User.Email != existingSponsorshipOffer.OfferedToEmail) + { + throw new BadRequestException("This sponsorship offer was issued to a different user email address."); + } var existingOrgSponsorship = await _organizationSponsorshipRepository .GetBySponsoredOrganizationIdAsync(model.SponsoredOrganizationId); @@ -87,16 +98,12 @@ namespace Bit.Api.Controllers { 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); // TODO: only current families plan? if (organizationToSponsor == null || !PlanTypeHelper.HasFamiliesPlan(organizationToSponsor)) { - throw new BadRequestException("Can only redeem sponsorship offer on families organizations"); + throw new BadRequestException("Can only redeem sponsorship offer on families organizations."); } await _organizationsSponsorshipService.SetUpSponsorshipAsync(existingSponsorshipOffer, organizationToSponsor); @@ -104,6 +111,7 @@ namespace Bit.Api.Controllers [HttpDelete("{sponsoringOrgUserId}")] [HttpPost("{sponsoringOrgUserId}/delete")] + [SelfHosted(NotSelfHostedOnly = true)] public async Task RevokeSponsorship(string sponsoringOrgUserId) { var sponsoringOrgUserIdGuid = new Guid(sponsoringOrgUserId); @@ -126,6 +134,7 @@ namespace Bit.Api.Controllers [HttpDelete("sponsored/{sponsoredOrgId}")] [HttpPost("sponsored/{sponsoredOrgId}/remove")] + [SelfHosted(NotSelfHostedOnly = true)] public async Task RemoveSponsorship(string sponsoredOrgId) { var sponsoredOrgIdGuid = new Guid(sponsoredOrgId); diff --git a/src/Core/Services/IOrganizationSponsorshipService.cs b/src/Core/Services/IOrganizationSponsorshipService.cs index 070b982260..ade55ac982 100644 --- a/src/Core/Services/IOrganizationSponsorshipService.cs +++ b/src/Core/Services/IOrganizationSponsorshipService.cs @@ -5,6 +5,7 @@ namespace Bit.Core.Services { public interface IOrganizationSponsorshipService { + Task ValidateRedemptionTokenAsync(string encryptedToken); 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 9a9f1213f7..b49bbd2f3b 100644 --- a/src/Core/Services/Implementations/OrganizationSponsorshipService.cs +++ b/src/Core/Services/Implementations/OrganizationSponsorshipService.cs @@ -2,27 +2,89 @@ using System; using System.Threading.Tasks; using Bit.Core.Models.Table; using Bit.Core.Repositories; +using Microsoft.AspNetCore.DataProtection; namespace Bit.Core.Services { public class OrganizationSponsorshipService : IOrganizationSponsorshipService { - private readonly IOrganizationSponsorshipRepository _organizationSponsorshipRepository; + private const string FamiliesForEnterpriseTokenName = "FamiliesForEnterpriseToken"; + private const string TokenClearTextPrefix = "BWOrganizationSponsorship_"; - public OrganizationSponsorshipService(IOrganizationSponsorshipRepository organizationSponsorshipRepository) + private readonly IOrganizationSponsorshipRepository _organizationSponsorshipRepository; + private readonly IDataProtector _dataProtector; + + public OrganizationSponsorshipService(IOrganizationSponsorshipRepository organizationSponsorshipRepository, + IDataProtector dataProtector) { _organizationSponsorshipRepository = organizationSponsorshipRepository; + _dataProtector = dataProtector; } + public async Task ValidateRedemptionTokenAsync(string encryptedToken) + { + if (!encryptedToken.StartsWith(TokenClearTextPrefix)) + { + return false; + } + + var decryptedToken = _dataProtector.Unprotect(encryptedToken); + var dataParts = decryptedToken.Split(' '); + + if (dataParts.Length != 2) + { + return false; + } + + if (dataParts[0].Equals(FamiliesForEnterpriseTokenName)) + { + if (!Guid.TryParse(dataParts[1], out Guid sponsorshipId)) + { + return false; + } + + var sponsorship = await _organizationSponsorshipRepository.GetByIdAsync(sponsorshipId); + return sponsorship != null; + } + + return false; + } + + private string RedemptionToken(Guid sponsorshipId) => + string.Concat( + TokenClearTextPrefix, + _dataProtector.Protect($"{FamiliesForEnterpriseTokenName} {sponsorshipId}") + ); + public async Task OfferSponsorshipAsync(Organization sponsoringOrg, OrganizationUser sponsoringOrgUser, string sponsoredEmail) { - // TODO: send sponsorship email, update sponsorship with offered email - throw new NotImplementedException(); + var sponsorship = new OrganizationSponsorship + { + SponsoringOrganizationId = sponsoringOrg.Id, + SponsoringOrganizationUserId = sponsoringOrgUser.Id, + OfferedToEmail = sponsoredEmail, + CloudSponsor = true, + }; + + try + { + sponsorship = await _organizationSponsorshipRepository.CreateAsync(sponsorship); + + // TODO: send email to sponsoredEmail w/ redemption token link + } + catch + { + if (sponsorship.Id != default) + { + await _organizationSponsorshipRepository.DeleteAsync(sponsorship); + } + throw; + } } public async Task SetUpSponsorshipAsync(OrganizationSponsorship sponsorship, Organization sponsoredOrganization) { - // TODO: set up sponsorship + // TODO: set up sponsorship, remember remove offeredToEmail from sponsorship throw new NotImplementedException(); } diff --git a/test/Api.Test/Controllers/OrganizationSponsorshipsControllerTests.cs b/test/Api.Test/Controllers/OrganizationSponsorshipsControllerTests.cs index 32e3e3f666..d9dd59e64f 100644 --- a/test/Api.Test/Controllers/OrganizationSponsorshipsControllerTests.cs +++ b/test/Api.Test/Controllers/OrganizationSponsorshipsControllerTests.cs @@ -15,6 +15,7 @@ using Bit.Api.Test.AutoFixture.Attributes; using Bit.Core.Repositories; using Bit.Core.Models.Api.Request; using Bit.Core.Services; +using Bit.Core.Models.Api; namespace Bit.Api.Test.Controllers { @@ -26,6 +27,8 @@ namespace Bit.Api.Test.Controllers 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 }); + public static IEnumerable NonFamiliesPlanTypes => + Enum.GetValues().Where(p => !PlanTypeHelper.IsFamilies(p)).Select(p => new object[] { p }); [Theory] [BitMemberAutoData(nameof(NonEnterprisePlanTypes))] @@ -121,7 +124,138 @@ namespace Bit.Api.Test.Controllers .OfferSponsorshipAsync(default, default, default); } - // TODO: Test redeem sponsorship + [Theory] + [BitAutoData] + public async Task RedeemSponsorship_BadToken_ThrowsBadRequest(string sponsorshipToken, + OrganizationSponsorshipRedeemRequestModel model, SutProvider sutProvider) + { + sutProvider.GetDependency().ValidateRedemptionTokenAsync(sponsorshipToken) + .Returns(false); + + var exception = await Assert.ThrowsAsync(() => + sutProvider.Sut.RedeemSponsorship(sponsorshipToken, model)); + + Assert.Contains("Failed to parse sponsorship token.", exception.Message); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .SetUpSponsorshipAsync(default, default); + } + + [Theory] + [BitAutoData] + public async Task RedeemSponsorship_NotSponsoredOrgOwner_ThrowsBadRequest(string sponsorshipToken, + OrganizationSponsorshipRedeemRequestModel model, SutProvider sutProvider) + { + sutProvider.GetDependency().ValidateRedemptionTokenAsync(sponsorshipToken) + .Returns(true); + sutProvider.GetDependency().OrganizationOwner(model.SponsoredOrganizationId).Returns(false); + + var exception = await Assert.ThrowsAsync(() => + sutProvider.Sut.RedeemSponsorship(sponsorshipToken, model)); + + Assert.Contains("Can only redeem sponsorship for an organization you own.", exception.Message); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .SetUpSponsorshipAsync(default, default); + } + + [Theory] + [BitAutoData] + public async Task RedeemSponsorship_SponsorshipNotFound_ThrowsBadRequest(string sponsorshipToken, + OrganizationSponsorshipRedeemRequestModel model, User user, + SutProvider sutProvider) + { + sutProvider.GetDependency().ValidateRedemptionTokenAsync(sponsorshipToken) + .Returns(true); + sutProvider.GetDependency().OrganizationOwner(model.SponsoredOrganizationId).Returns(true); + sutProvider.GetDependency().User.Returns(user); + sutProvider.GetDependency().GetByOfferedToEmailAsync(user.Email) + .Returns((OrganizationSponsorship)null); + + var exception = await Assert.ThrowsAsync(() => + sutProvider.Sut.RedeemSponsorship(sponsorshipToken, model)); + + Assert.Contains("No unredeemed sponsorship offer exists for you.", exception.Message); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .SetUpSponsorshipAsync(default, default); + } + + [Theory] + [BitAutoData] + public async Task RedeemSponsorship_OfferedToDifferentEmail_ThrowsBadRequest(string sponsorshipToken, + OrganizationSponsorshipRedeemRequestModel model, User user, OrganizationSponsorship sponsorship, + SutProvider sutProvider) + { + sutProvider.GetDependency().ValidateRedemptionTokenAsync(sponsorshipToken) + .Returns(true); + sutProvider.GetDependency().OrganizationOwner(model.SponsoredOrganizationId).Returns(true); + sutProvider.GetDependency().User.Returns(user); + sutProvider.GetDependency().GetByOfferedToEmailAsync(user.Email) + .Returns(sponsorship); + + var exception = await Assert.ThrowsAsync(() => + sutProvider.Sut.RedeemSponsorship(sponsorshipToken, model)); + + Assert.Contains("This sponsorship offer was issued to a different user email address.", exception.Message); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .SetUpSponsorshipAsync(default, default); + } + + [Theory] + [BitAutoData] + public async Task RedeemSponsorship_OrgAlreadySponsored_ThrowsBadRequest(string sponsorshipToken, + OrganizationSponsorshipRedeemRequestModel model, User user, OrganizationSponsorship sponsorship, + OrganizationSponsorship existingSponsorship, SutProvider sutProvider) + { + user.Email = sponsorship.OfferedToEmail; + + sutProvider.GetDependency().ValidateRedemptionTokenAsync(sponsorshipToken) + .Returns(true); + sutProvider.GetDependency().OrganizationOwner(model.SponsoredOrganizationId).Returns(true); + sutProvider.GetDependency().User.Returns(user); + sutProvider.GetDependency() + .GetByOfferedToEmailAsync(sponsorship.OfferedToEmail).Returns(sponsorship); + sutProvider.GetDependency() + .GetBySponsoredOrganizationIdAsync(model.SponsoredOrganizationId).Returns(existingSponsorship); + + var exception = await Assert.ThrowsAsync(() => + sutProvider.Sut.RedeemSponsorship(sponsorshipToken, model)); + + Assert.Contains("Cannot redeem a sponsorship offer for an organization that is already sponsored. Revoke existing sponsorship first.", exception.Message); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .SetUpSponsorshipAsync(default, default); + } + + [Theory] + [BitAutoData] + public async Task RedeemSponsorship_OrgNotFamiles_ThrowsBadRequest(PlanType planType, string sponsorshipToken, + OrganizationSponsorshipRedeemRequestModel model, User user, OrganizationSponsorship sponsorship, + Organization org, SutProvider sutProvider) + { + user.Email = sponsorship.OfferedToEmail; + org.PlanType = planType; + + sutProvider.GetDependency().ValidateRedemptionTokenAsync(sponsorshipToken) + .Returns(true); + sutProvider.GetDependency().OrganizationOwner(model.SponsoredOrganizationId).Returns(true); + sutProvider.GetDependency().User.Returns(user); + sutProvider.GetDependency() + .GetByOfferedToEmailAsync(sponsorship.OfferedToEmail).Returns(sponsorship); + sutProvider.GetDependency() + .GetBySponsoredOrganizationIdAsync(model.SponsoredOrganizationId).Returns((OrganizationSponsorship)null); + sutProvider.GetDependency().GetByIdAsync(model.SponsoredOrganizationId).Returns(org); + + var exception = await Assert.ThrowsAsync(() => + sutProvider.Sut.RedeemSponsorship(sponsorshipToken, model)); + + Assert.Contains("Can only redeem sponsorship offer on families organizations.", exception.Message); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .SetUpSponsorshipAsync(default, default); + } [Theory] [BitAutoData] diff --git a/test/Common/Helpers/AssertHelper.cs b/test/Common/Helpers/AssertHelper.cs new file mode 100644 index 0000000000..fc94fb2305 --- /dev/null +++ b/test/Common/Helpers/AssertHelper.cs @@ -0,0 +1,58 @@ +using System.Reflection; +using System.IO; +using System.Linq; +using Xunit; +using System; +using Newtonsoft.Json; + +namespace Bit.Test.Common.Helpers +{ + public static class AssertHelper + { + public static void AssertPropertyEqual(object expected, object actual, params string[] excludedPropertyStrings) + { + var relevantExcludedProperties = excludedPropertyStrings.Where(name => !name.Contains('.')).ToList(); + if (expected == null) + { + Assert.Null(actual); + return; + } + + if (actual == null) + { + throw new Exception("Expected object is null but actual is not"); + } + + foreach (var expectedPi in expected.GetType().GetProperties().Where(pi => !relevantExcludedProperties.Contains(pi.Name))) + { + var actualPi = actual.GetType().GetProperty(expectedPi.Name); + + if (actualPi == null) + { + var settings = new JsonSerializerSettings { Formatting = Formatting.Indented }; + throw new Exception(string.Concat($"Expected actual object to contain a property named {expectedPi.Name}, but it does not\n", + $"Expected:\n{JsonConvert.SerializeObject(expected, settings)}\n", + $"Actual:\n{JsonConvert.SerializeObject(actual, new JsonSerializerSettings { Formatting = Formatting.Indented })}")); + } + + if (expectedPi.PropertyType == typeof(string) || expectedPi.PropertyType.IsValueType) + { + Assert.Equal(expectedPi.GetValue(expected), actualPi.GetValue(actual)); + } + else + { + var prefix = $"{expectedPi.PropertyType.Name}."; + var nextExcludedProperties = excludedPropertyStrings.Where(name => name.StartsWith(prefix)) + .Select(name => name[prefix.Length..]).ToArray(); + AssertPropertyEqual(expectedPi.GetValue(expected), actualPi.GetValue(actual), nextExcludedProperties); + } + } + } + + public static Predicate AssertEqualExpectedPredicate(T expected) => (actual) => + { + Assert.Equal(expected, actual); + return true; + }; + } +} diff --git a/test/Core.Test/Services/OrganizationSponsorshipServiceTests.cs b/test/Core.Test/Services/OrganizationSponsorshipServiceTests.cs new file mode 100644 index 0000000000..3588345fa7 --- /dev/null +++ b/test/Core.Test/Services/OrganizationSponsorshipServiceTests.cs @@ -0,0 +1,74 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using Bit.Core.Models.Table; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Bit.Test.Common.Helpers; +using Microsoft.IdentityModel.Tokens; +using NSubstitute; +using NSubstitute.ExceptionExtensions; +using Xunit; + +namespace Bit.Core.Test.Services +{ + [SutProviderCustomize] + public class OrganizationSponsorshipServiceTests + { + private bool sponsorshipValidator(OrganizationSponsorship sponsorship, OrganizationSponsorship expectedSponsorship) + { + try + { + AssertHelper.AssertPropertyEqual(sponsorship, expectedSponsorship, nameof(OrganizationSponsorship.Id)); + return true; + } + catch + { + return false; + } + } + + [Theory] + [BitAutoData] + public async Task OfferSponsorship_CreatesSponsorship(Organization sponsoringOrg, OrganizationUser sponsoringOrgUser, + string sponsoredEmail, SutProvider sutProvider) + { + await sutProvider.Sut.OfferSponsorshipAsync(sponsoringOrg, sponsoringOrgUser, sponsoredEmail); + + var expectedSponsorship = new OrganizationSponsorship + { + SponsoringOrganizationId = sponsoringOrg.Id, + SponsoringOrganizationUserId = sponsoringOrgUser.Id, + OfferedToEmail = sponsoredEmail, + CloudSponsor = true, + }; + + await sutProvider.GetDependency().Received(1) + .CreateAsync(Arg.Is(s => sponsorshipValidator(s, expectedSponsorship))); + // TODO: Validate email called with appropriate token.s + } + + [Theory] + [BitAutoData] + public async Task OfferSponsorship_CreateSponsorshipThrows_RevertsDatabase(Organization sponsoringOrg, OrganizationUser sponsoringOrgUser, + string sponsoredEmail, SutProvider sutProvider) + { + var expectedException = new Exception(); + OrganizationSponsorship createdSponsorship = null; + sutProvider.GetDependency().CreateAsync(default).ThrowsForAnyArgs(callInfo => + { + createdSponsorship = callInfo.ArgAt(0); + createdSponsorship.Id = Guid.NewGuid(); + return expectedException; + }); + + var actualException = await Assert.ThrowsAsync(() => sutProvider.Sut.OfferSponsorshipAsync(sponsoringOrg, sponsoringOrgUser, sponsoredEmail)); + Assert.Same(expectedException, actualException); + + await sutProvider.GetDependency().Received(1) + .DeleteAsync(createdSponsorship); + } + } +}