Files
server/test/Admin.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs

1532 lines
63 KiB
C#

using Bit.Admin.AdminConsole.Controllers;
using Bit.Admin.AdminConsole.Models;
using Bit.Admin.Enums;
using Bit.Admin.Services;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Enforcement.AutoConfirm;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Organizations.PlanMigration.Entities;
using Bit.Core.Billing.Organizations.PlanMigration.Enums;
using Bit.Core.Billing.Organizations.PlanMigration.Repositories;
using Bit.Core.Billing.Providers.Services;
using Bit.Core.Enums;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.ViewFeatures;
using Microsoft.EntityFrameworkCore;
using NSubstitute;
using NSubstitute.ExceptionExtensions;
using static Bit.Core.AdminConsole.Utilities.v2.Validation.ValidationResultHelpers;
namespace Admin.Test.AdminConsole.Controllers;
[ControllerCustomize(typeof(OrganizationsController))]
[SutProviderCustomize]
public class OrganizationsControllerTests
{
private static void StubCohortAccessAllowed(SutProvider<OrganizationsController> sutProvider)
{
sutProvider.GetDependency<IAccessControlService>()
.UserHasPermission(Permission.Tools_ManagePlanMigrationCohorts)
.Returns(true);
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(Bit.Core.FeatureFlagKeys.PM35215_BusinessPlanPriceMigration)
.Returns(true);
}
#region Edit (POST)
[BitAutoData]
[SutProviderCustomize]
[Theory]
public async Task Edit_ProviderSeatScaling_NonBillableProvider_NoOp(
SutProvider<OrganizationsController> sutProvider)
{
// Arrange
var organizationId = new Guid();
var update = new OrganizationEditModel { UseSecretsManager = false };
var organization = new Organization
{
Id = organizationId
};
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organizationId)
.Returns(organization);
var provider = new Provider { Type = ProviderType.Msp, Status = ProviderStatusType.Created };
sutProvider.GetDependency<IProviderRepository>().GetByOrganizationIdAsync(organizationId).Returns(provider);
// Act
_ = await sutProvider.Sut.Edit(organizationId, update);
// Assert
await sutProvider.GetDependency<IProviderBillingService>().DidNotReceiveWithAnyArgs()
.ScaleSeats(Arg.Any<Provider>(), Arg.Any<PlanType>(), Arg.Any<int>());
}
[BitAutoData]
[SutProviderCustomize]
[Theory]
public async Task Edit_ProviderSeatScaling_UnmanagedOrganization_NoOp(
SutProvider<OrganizationsController> sutProvider)
{
// Arrange
var organizationId = new Guid();
var update = new OrganizationEditModel { UseSecretsManager = false };
var organization = new Organization
{
Id = organizationId,
Status = OrganizationStatusType.Created
};
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organizationId)
.Returns(organization);
var provider = new Provider { Type = ProviderType.Msp, Status = ProviderStatusType.Billable };
sutProvider.GetDependency<IProviderRepository>().GetByOrganizationIdAsync(organizationId).Returns(provider);
// Act
_ = await sutProvider.Sut.Edit(organizationId, update);
// Assert
await sutProvider.GetDependency<IProviderBillingService>().DidNotReceiveWithAnyArgs()
.ScaleSeats(Arg.Any<Provider>(), Arg.Any<PlanType>(), Arg.Any<int>());
}
[BitAutoData]
[SutProviderCustomize]
[Theory]
public async Task Edit_ProviderSeatScaling_NonCBPlanType_NoOp(
SutProvider<OrganizationsController> sutProvider)
{
// Arrange
var organizationId = new Guid();
var update = new OrganizationEditModel
{
UseSecretsManager = false,
Seats = 10,
PlanType = PlanType.FamiliesAnnually
};
var organization = new Organization
{
Id = organizationId,
Status = OrganizationStatusType.Managed,
Seats = 10
};
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organizationId)
.Returns(organization);
var provider = new Provider { Type = ProviderType.Msp, Status = ProviderStatusType.Billable };
sutProvider.GetDependency<IProviderRepository>().GetByOrganizationIdAsync(organizationId).Returns(provider);
// Act
_ = await sutProvider.Sut.Edit(organizationId, update);
// Assert
await sutProvider.GetDependency<IProviderBillingService>().DidNotReceiveWithAnyArgs()
.ScaleSeats(Arg.Any<Provider>(), Arg.Any<PlanType>(), Arg.Any<int>());
}
[BitAutoData]
[SutProviderCustomize]
[Theory]
public async Task Edit_ProviderSeatScaling_NoUpdateRequired_NoOp(
SutProvider<OrganizationsController> sutProvider)
{
// Arrange
var organizationId = new Guid();
var update = new OrganizationEditModel
{
UseSecretsManager = false,
Seats = 10,
PlanType = PlanType.EnterpriseMonthly
};
var organization = new Organization
{
Id = organizationId,
Status = OrganizationStatusType.Managed,
Seats = 10,
PlanType = PlanType.EnterpriseMonthly
};
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organizationId)
.Returns(organization);
var provider = new Provider { Type = ProviderType.Msp, Status = ProviderStatusType.Billable };
sutProvider.GetDependency<IProviderRepository>().GetByOrganizationIdAsync(organizationId).Returns(provider);
// Act
_ = await sutProvider.Sut.Edit(organizationId, update);
// Assert
await sutProvider.GetDependency<IProviderBillingService>().DidNotReceiveWithAnyArgs()
.ScaleSeats(Arg.Any<Provider>(), Arg.Any<PlanType>(), Arg.Any<int>());
}
[BitAutoData]
[SutProviderCustomize]
[Theory]
public async Task Edit_ProviderSeatScaling_PlanTypesUpdate_ScalesSeatsCorrectly(
SutProvider<OrganizationsController> sutProvider)
{
// Arrange
var organizationId = new Guid();
var update = new OrganizationEditModel
{
UseSecretsManager = false,
Seats = 10,
PlanType = PlanType.EnterpriseMonthly
};
var organization = new Organization
{
Id = organizationId,
Status = OrganizationStatusType.Managed,
Seats = 10,
PlanType = PlanType.TeamsMonthly
};
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organizationId)
.Returns(organization);
var provider = new Provider { Type = ProviderType.Msp, Status = ProviderStatusType.Billable };
sutProvider.GetDependency<IProviderRepository>().GetByOrganizationIdAsync(organizationId).Returns(provider);
// Act
_ = await sutProvider.Sut.Edit(organizationId, update);
// Assert
var providerBillingService = sutProvider.GetDependency<IProviderBillingService>();
await providerBillingService.Received(1).ScaleSeats(provider, organization.PlanType, -organization.Seats.Value);
await providerBillingService.Received(1).ScaleSeats(provider, update.PlanType!.Value, organization.Seats.Value);
}
[BitAutoData]
[SutProviderCustomize]
[Theory]
public async Task Edit_ProviderSeatScaling_SeatsUpdate_ScalesSeatsCorrectly(
SutProvider<OrganizationsController> sutProvider)
{
// Arrange
var organizationId = new Guid();
var update = new OrganizationEditModel
{
UseSecretsManager = false,
Seats = 15,
PlanType = PlanType.EnterpriseMonthly
};
var organization = new Organization
{
Id = organizationId,
Status = OrganizationStatusType.Managed,
Seats = 10,
PlanType = PlanType.EnterpriseMonthly
};
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organizationId)
.Returns(organization);
var provider = new Provider { Type = ProviderType.Msp, Status = ProviderStatusType.Billable };
sutProvider.GetDependency<IProviderRepository>().GetByOrganizationIdAsync(organizationId).Returns(provider);
// Act
_ = await sutProvider.Sut.Edit(organizationId, update);
// Assert
var providerBillingService = sutProvider.GetDependency<IProviderBillingService>();
await providerBillingService.Received(1).ScaleSeats(provider, organization.PlanType, update.Seats!.Value - organization.Seats.Value);
}
[BitAutoData]
[SutProviderCustomize]
[Theory]
public async Task Edit_ProviderSeatScaling_FullUpdate_ScalesSeatsCorrectly(
SutProvider<OrganizationsController> sutProvider)
{
// Arrange
var organizationId = new Guid();
var update = new OrganizationEditModel
{
UseSecretsManager = false,
Seats = 15,
PlanType = PlanType.EnterpriseMonthly
};
var organization = new Organization
{
Id = organizationId,
Status = OrganizationStatusType.Managed,
Seats = 10,
PlanType = PlanType.TeamsMonthly
};
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organizationId)
.Returns(organization);
var provider = new Provider { Type = ProviderType.Msp, Status = ProviderStatusType.Billable };
sutProvider.GetDependency<IProviderRepository>().GetByOrganizationIdAsync(organizationId).Returns(provider);
// Act
_ = await sutProvider.Sut.Edit(organizationId, update);
// Assert
var providerBillingService = sutProvider.GetDependency<IProviderBillingService>();
await providerBillingService.Received(1).ScaleSeats(provider, organization.PlanType, -organization.Seats.Value);
await providerBillingService.Received(1).ScaleSeats(provider, update.PlanType!.Value, update.Seats!.Value - organization.Seats.Value + organization.Seats.Value);
}
[BitAutoData]
[SutProviderCustomize]
[Theory]
public async Task Edit_UseAutomaticUserConfirmation_FullUpdate_SavesFeatureCorrectly(
Organization organization,
SutProvider<OrganizationsController> sutProvider)
{
// Arrange
var update = new OrganizationEditModel
{
PlanType = PlanType.TeamsMonthly,
UseAutomaticUserConfirmation = true
};
organization.UseAutomaticUserConfirmation = false;
sutProvider.GetDependency<IAccessControlService>()
.UserHasPermission(Permission.Org_Plan_Edit)
.Returns(true);
var organizationRepository = sutProvider.GetDependency<IOrganizationRepository>();
organizationRepository.GetByIdAsync(organization.Id).Returns(organization);
var request = new AutomaticUserConfirmationOrganizationPolicyComplianceHandlerRequest(organization.Id);
sutProvider.GetDependency<IAutomaticUserConfirmationOrganizationPolicyComplianceHandler>()
.IsOrganizationCompliantAsync(Arg.Any<AutomaticUserConfirmationOrganizationPolicyComplianceHandlerRequest>())
.Returns(Valid(request));
// Act
_ = await sutProvider.Sut.Edit(organization.Id, update);
// Assert
await organizationRepository.Received(1).ReplaceAsync(Arg.Is<Organization>(o => o.Id == organization.Id
&& o.UseAutomaticUserConfirmation == true));
}
[BitAutoData]
[SutProviderCustomize]
[Theory]
public async Task Edit_EnableUseAutomaticUserConfirmation_ValidationFails_RedirectsWithError(
Organization organization,
SutProvider<OrganizationsController> sutProvider)
{
// Arrange
var update = new OrganizationEditModel
{
PlanType = PlanType.TeamsMonthly,
UseAutomaticUserConfirmation = true
};
organization.UseAutomaticUserConfirmation = false;
sutProvider.GetDependency<IAccessControlService>()
.UserHasPermission(Permission.Org_Plan_Edit)
.Returns(true);
var organizationRepository = sutProvider.GetDependency<IOrganizationRepository>();
organizationRepository.GetByIdAsync(organization.Id).Returns(organization);
var request = new AutomaticUserConfirmationOrganizationPolicyComplianceHandlerRequest(organization.Id);
sutProvider.GetDependency<IAutomaticUserConfirmationOrganizationPolicyComplianceHandler>()
.IsOrganizationCompliantAsync(Arg.Any<AutomaticUserConfirmationOrganizationPolicyComplianceHandlerRequest>())
.Returns(Invalid(request, new UserNotCompliantWithSingleOrganization()));
sutProvider.Sut.TempData = new TempDataDictionary(new DefaultHttpContext(), Substitute.For<ITempDataProvider>());
// Act
var result = await sutProvider.Sut.Edit(organization.Id, update);
// Assert
var redirectResult = Assert.IsType<RedirectToActionResult>(result);
Assert.Equal("Edit", redirectResult.ActionName);
Assert.Equal(organization.Id, redirectResult.RouteValues!["id"]);
await organizationRepository.DidNotReceive().ReplaceAsync(Arg.Any<Organization>());
}
[BitAutoData]
[SutProviderCustomize]
[Theory]
public async Task Edit_EnableUseAutomaticUserConfirmation_ProviderValidationFails_RedirectsWithError(
Organization organization,
SutProvider<OrganizationsController> sutProvider)
{
// Arrange
var update = new OrganizationEditModel
{
PlanType = PlanType.TeamsMonthly,
UseAutomaticUserConfirmation = true
};
organization.UseAutomaticUserConfirmation = false;
sutProvider.GetDependency<IAccessControlService>()
.UserHasPermission(Permission.Org_Plan_Edit)
.Returns(true);
var organizationRepository = sutProvider.GetDependency<IOrganizationRepository>();
organizationRepository.GetByIdAsync(organization.Id).Returns(organization);
var request = new AutomaticUserConfirmationOrganizationPolicyComplianceHandlerRequest(organization.Id);
sutProvider.GetDependency<IAutomaticUserConfirmationOrganizationPolicyComplianceHandler>()
.IsOrganizationCompliantAsync(Arg.Any<AutomaticUserConfirmationOrganizationPolicyComplianceHandlerRequest>())
.Returns(Invalid(request, new ProviderExistsInOrganization()));
sutProvider.Sut.TempData = new TempDataDictionary(new DefaultHttpContext(), Substitute.For<ITempDataProvider>());
// Act
var result = await sutProvider.Sut.Edit(organization.Id, update);
// Assert
var redirectResult = Assert.IsType<RedirectToActionResult>(result);
Assert.Equal("Edit", redirectResult.ActionName);
await organizationRepository.DidNotReceive().ReplaceAsync(Arg.Any<Organization>());
}
[BitAutoData]
[SutProviderCustomize]
[Theory]
public async Task Edit_UseAutomaticUserConfirmation_NotChanged_DoesNotCallValidator(
SutProvider<OrganizationsController> sutProvider)
{
// Arrange
var organizationId = new Guid();
var update = new OrganizationEditModel
{
UseSecretsManager = false,
UseAutomaticUserConfirmation = false
};
var organization = new Organization
{
Id = organizationId,
UseAutomaticUserConfirmation = false
};
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organizationId)
.Returns(organization);
// Act
_ = await sutProvider.Sut.Edit(organizationId, update);
// Assert
await sutProvider.GetDependency<IAutomaticUserConfirmationOrganizationPolicyComplianceHandler>()
.DidNotReceive()
.IsOrganizationCompliantAsync(Arg.Any<AutomaticUserConfirmationOrganizationPolicyComplianceHandlerRequest>());
}
[BitAutoData]
[SutProviderCustomize]
[Theory]
public async Task Edit_UseAutomaticUserConfirmation_AlreadyEnabled_DoesNotCallValidator(
SutProvider<OrganizationsController> sutProvider)
{
// Arrange
var organizationId = new Guid();
var update = new OrganizationEditModel
{
UseSecretsManager = false,
UseAutomaticUserConfirmation = true
};
var organization = new Organization
{
Id = organizationId,
UseAutomaticUserConfirmation = true
};
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organizationId)
.Returns(organization);
// Act
_ = await sutProvider.Sut.Edit(organizationId, update);
// Assert
await sutProvider.GetDependency<IAutomaticUserConfirmationOrganizationPolicyComplianceHandler>()
.DidNotReceive()
.IsOrganizationCompliantAsync(Arg.Any<AutomaticUserConfirmationOrganizationPolicyComplianceHandlerRequest>());
}
[BitAutoData]
[SutProviderCustomize]
[Theory]
public async Task Edit_UseAutomaticUserConfirmation_EnabledByPortal_LogsEvent(
Organization organization,
SutProvider<OrganizationsController> sutProvider)
{
var update = new OrganizationEditModel
{
PlanType = PlanType.TeamsMonthly,
UseAutomaticUserConfirmation = true
};
organization.UseAutomaticUserConfirmation = false;
organization.Enabled = true;
organization.UseEvents = true;
sutProvider.GetDependency<IAccessControlService>()
.UserHasPermission(Permission.Org_Plan_Edit)
.Returns(true);
var organizationRepository = sutProvider.GetDependency<IOrganizationRepository>();
organizationRepository.GetByIdAsync(organization.Id).Returns(organization);
var request = new AutomaticUserConfirmationOrganizationPolicyComplianceHandlerRequest(organization.Id);
sutProvider.GetDependency<IAutomaticUserConfirmationOrganizationPolicyComplianceHandler>()
.IsOrganizationCompliantAsync(Arg.Any<AutomaticUserConfirmationOrganizationPolicyComplianceHandlerRequest>())
.Returns(Valid(request));
_ = await sutProvider.Sut.Edit(organization.Id, update);
await sutProvider.GetDependency<IEventService>().Received(1)
.LogOrganizationEventAsync(
Arg.Is<Organization>(o => o.Id == organization.Id),
EventType.Organization_AutoConfirmEnabled_Portal,
EventSystemUser.BitwardenPortal);
}
[BitAutoData]
[SutProviderCustomize]
[Theory]
public async Task Edit_UseAutomaticUserConfirmation_DisabledByPortal_LogsEvent(
Organization organization,
SutProvider<OrganizationsController> sutProvider)
{
var update = new OrganizationEditModel
{
PlanType = PlanType.TeamsMonthly,
UseAutomaticUserConfirmation = false
};
organization.UseAutomaticUserConfirmation = true;
organization.Enabled = true;
organization.UseEvents = true;
sutProvider.GetDependency<IAccessControlService>()
.UserHasPermission(Permission.Org_Plan_Edit)
.Returns(true);
var organizationRepository = sutProvider.GetDependency<IOrganizationRepository>();
organizationRepository.GetByIdAsync(organization.Id).Returns(organization);
_ = await sutProvider.Sut.Edit(organization.Id, update);
await sutProvider.GetDependency<IEventService>().Received(1)
.LogOrganizationEventAsync(
Arg.Is<Organization>(o => o.Id == organization.Id),
EventType.Organization_AutoConfirmDisabled_Portal,
EventSystemUser.BitwardenPortal);
}
[BitAutoData]
[SutProviderCustomize]
[Theory]
public async Task Edit_MigrationCohortAssignment_NewAssignment_CreatesAssignment(
Organization organization,
OrganizationPlanMigrationCohort cohort,
SutProvider<OrganizationsController> sutProvider)
{
cohort.MigrationPathId = null;
var update = new OrganizationEditModel
{
PlanType = PlanType.TeamsMonthly,
MigrationCohortId = cohort.Id
};
StubCohortAccessAllowed(sutProvider);
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organization.Id).Returns(organization);
sutProvider.GetDependency<IOrganizationPlanMigrationCohortAssignmentRepository>()
.GetByOrganizationIdAsync(organization.Id)
.Returns((OrganizationPlanMigrationCohortAssignment)null);
sutProvider.GetDependency<IOrganizationPlanMigrationCohortRepository>()
.GetByIdAsync(cohort.Id)
.Returns(cohort);
var result = await sutProvider.Sut.Edit(organization.Id, update);
await sutProvider.GetDependency<IOrganizationPlanMigrationCohortAssignmentRepository>()
.Received(1)
.CreateAsync(Arg.Is<OrganizationPlanMigrationCohortAssignment>(a =>
a.OrganizationId == organization.Id && a.CohortId == cohort.Id));
await sutProvider.GetDependency<IOrganizationPlanMigrationCohortAssignmentRepository>()
.DidNotReceiveWithAnyArgs()
.DeleteAsync(default);
await sutProvider.GetDependency<IOrganizationRepository>().Received(1).ReplaceAsync(Arg.Any<Organization>());
Assert.IsType<RedirectToActionResult>(result);
}
[BitAutoData]
[SutProviderCustomize]
[Theory]
public async Task Edit_MigrationCohortAssignment_WithoutCohortPermission_SkipsAssignmentWriteButStillSavesOrg(
Organization organization,
OrganizationPlanMigrationCohort cohort,
SutProvider<OrganizationsController> sutProvider)
{
// Operators without Tools_ManagePlanMigrationCohorts must not be able to mutate cohort
// assignment, even if they craft a POST that bypasses the hidden dropdown. The rest of
// the Edit save still runs because other fields are gated on different permissions.
var update = new OrganizationEditModel
{
PlanType = PlanType.TeamsMonthly,
MigrationCohortId = cohort.Id,
};
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(Bit.Core.FeatureFlagKeys.PM35215_BusinessPlanPriceMigration)
.Returns(true);
sutProvider.GetDependency<IAccessControlService>()
.UserHasPermission(Permission.Tools_ManagePlanMigrationCohorts)
.Returns(false);
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organization.Id).Returns(organization);
_ = await sutProvider.Sut.Edit(organization.Id, update);
await sutProvider.GetDependency<IOrganizationPlanMigrationCohortAssignmentRepository>()
.DidNotReceiveWithAnyArgs()
.GetByOrganizationIdAsync(default);
await sutProvider.GetDependency<IOrganizationPlanMigrationCohortAssignmentRepository>()
.DidNotReceiveWithAnyArgs()
.CreateAsync(default);
await sutProvider.GetDependency<IOrganizationPlanMigrationCohortAssignmentRepository>()
.DidNotReceiveWithAnyArgs()
.DeleteAsync(default);
await sutProvider.GetDependency<IOrganizationRepository>().Received(1).ReplaceAsync(Arg.Any<Organization>());
}
[BitAutoData]
[SutProviderCustomize]
[Theory]
public async Task Edit_MigrationCohortAssignment_WithFeatureFlagOff_SkipsAssignmentWriteButStillSavesOrg(
Organization organization,
OrganizationPlanMigrationCohort cohort,
SutProvider<OrganizationsController> sutProvider)
{
// When PM35215_BusinessPlanPriceMigration is off the dropdown is hidden, and the server
// must refuse cohort writes even from a crafted POST that includes the field.
var update = new OrganizationEditModel
{
PlanType = PlanType.TeamsMonthly,
MigrationCohortId = cohort.Id,
};
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(Bit.Core.FeatureFlagKeys.PM35215_BusinessPlanPriceMigration)
.Returns(false);
sutProvider.GetDependency<IAccessControlService>()
.UserHasPermission(Permission.Tools_ManagePlanMigrationCohorts)
.Returns(true);
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organization.Id).Returns(organization);
_ = await sutProvider.Sut.Edit(organization.Id, update);
await sutProvider.GetDependency<IOrganizationPlanMigrationCohortAssignmentRepository>()
.DidNotReceiveWithAnyArgs()
.GetByOrganizationIdAsync(default);
await sutProvider.GetDependency<IOrganizationPlanMigrationCohortAssignmentRepository>()
.DidNotReceiveWithAnyArgs()
.CreateAsync(default);
await sutProvider.GetDependency<IOrganizationPlanMigrationCohortAssignmentRepository>()
.DidNotReceiveWithAnyArgs()
.DeleteAsync(default);
await sutProvider.GetDependency<IOrganizationRepository>().Received(1).ReplaceAsync(Arg.Any<Organization>());
}
[BitAutoData]
[SutProviderCustomize]
[Theory]
public async Task Edit_MigrationCohortAssignment_UnchangedCohort_SkipsRepository(
Organization organization,
SutProvider<OrganizationsController> sutProvider)
{
var cohortId = Guid.NewGuid();
var existing = new OrganizationPlanMigrationCohortAssignment
{
Id = Guid.NewGuid(),
OrganizationId = organization.Id,
CohortId = cohortId,
};
var update = new OrganizationEditModel
{
PlanType = PlanType.TeamsMonthly,
MigrationCohortId = cohortId
};
StubCohortAccessAllowed(sutProvider);
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organization.Id).Returns(organization);
sutProvider.GetDependency<IOrganizationPlanMigrationCohortAssignmentRepository>()
.GetByOrganizationIdAsync(organization.Id)
.Returns(existing);
_ = await sutProvider.Sut.Edit(organization.Id, update);
await sutProvider.GetDependency<IOrganizationPlanMigrationCohortAssignmentRepository>()
.DidNotReceiveWithAnyArgs()
.CreateAsync(default);
await sutProvider.GetDependency<IOrganizationPlanMigrationCohortAssignmentRepository>()
.DidNotReceiveWithAnyArgs()
.DeleteAsync(default);
// The early-skip on equal cohort id must also avoid the unnecessary cohort lookup.
await sutProvider.GetDependency<IOrganizationPlanMigrationCohortRepository>()
.DidNotReceiveWithAnyArgs()
.GetByIdAsync(default);
await sutProvider.GetDependency<IOrganizationRepository>().Received(1).ReplaceAsync(Arg.Any<Organization>());
}
public enum LifecycleColumn { Scheduled, Migrated, ChurnDiscountApplied }
public static TheoryData<LifecycleColumn> LockedLifecycleColumns =>
new()
{
LifecycleColumn.Scheduled,
LifecycleColumn.Migrated,
LifecycleColumn.ChurnDiscountApplied,
};
[Theory]
[BitMemberAutoData(nameof(LockedLifecycleColumns))]
public async Task Edit_MigrationCohortAssignment_LockedReassignment_SurfacesConflictAndSkipsReplace(
LifecycleColumn lockedColumn,
Organization organization,
Guid newCohortId,
SutProvider<OrganizationsController> sutProvider)
{
// The view disables the <select> client-side, but that's just a UX hint;
// the server is the only place this invariant lives.
var locked = new OrganizationPlanMigrationCohortAssignment
{
Id = Guid.NewGuid(),
OrganizationId = organization.Id,
CohortId = Guid.NewGuid(),
};
switch (lockedColumn)
{
case LifecycleColumn.Scheduled:
locked.ScheduledDate = DateTime.UtcNow;
break;
case LifecycleColumn.Migrated:
locked.MigratedDate = DateTime.UtcNow;
break;
case LifecycleColumn.ChurnDiscountApplied:
locked.ChurnDiscountAppliedDate = DateTime.UtcNow;
break;
}
var update = new OrganizationEditModel
{
PlanType = PlanType.TeamsMonthly,
MigrationCohortId = newCohortId,
};
StubCohortAccessAllowed(sutProvider);
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organization.Id).Returns(organization);
sutProvider.GetDependency<IOrganizationPlanMigrationCohortAssignmentRepository>()
.GetByOrganizationIdAsync(organization.Id)
.Returns(locked);
sutProvider.Sut.TempData = new TempDataDictionary(new DefaultHttpContext(), Substitute.For<ITempDataProvider>());
var result = await sutProvider.Sut.Edit(organization.Id, update);
var redirect = Assert.IsType<RedirectToActionResult>(result);
Assert.Equal("Edit", redirect.ActionName);
Assert.Equal(
"This organization's migration cohort is locked because its assignment has already entered the migration pipeline.",
sutProvider.Sut.TempData["Error"]);
await sutProvider.GetDependency<IOrganizationPlanMigrationCohortAssignmentRepository>()
.DidNotReceiveWithAnyArgs()
.CreateAsync(default);
await sutProvider.GetDependency<IOrganizationPlanMigrationCohortAssignmentRepository>()
.DidNotReceiveWithAnyArgs()
.DeleteAsync(default);
await sutProvider.GetDependency<IOrganizationRepository>().DidNotReceive().ReplaceAsync(Arg.Any<Organization>());
}
[BitAutoData]
[SutProviderCustomize]
[Theory]
public async Task Edit_MigrationCohortAssignment_Locked_SameCohortSubmitted_AllowsSave(
Organization organization,
SutProvider<OrganizationsController> sutProvider)
{
// When the cohort dropdown is locked the view backs the disabled <select> with a
// hidden input that round-trips the existing cohort id, so locked orgs must be able
// to save edits to other fields without tripping the lock guard.
var cohortId = Guid.NewGuid();
var locked = new OrganizationPlanMigrationCohortAssignment
{
Id = Guid.NewGuid(),
OrganizationId = organization.Id,
CohortId = cohortId,
MigratedDate = DateTime.UtcNow,
};
var update = new OrganizationEditModel
{
PlanType = PlanType.TeamsMonthly,
MigrationCohortId = cohortId,
};
StubCohortAccessAllowed(sutProvider);
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organization.Id).Returns(organization);
sutProvider.GetDependency<IOrganizationPlanMigrationCohortAssignmentRepository>()
.GetByOrganizationIdAsync(organization.Id)
.Returns(locked);
sutProvider.Sut.TempData = new TempDataDictionary(new DefaultHttpContext(), Substitute.For<ITempDataProvider>());
var result = await sutProvider.Sut.Edit(organization.Id, update);
Assert.IsType<RedirectToActionResult>(result);
Assert.Null(sutProvider.Sut.TempData["Error"]);
await sutProvider.GetDependency<IOrganizationPlanMigrationCohortAssignmentRepository>()
.DidNotReceiveWithAnyArgs()
.CreateAsync(default);
await sutProvider.GetDependency<IOrganizationPlanMigrationCohortAssignmentRepository>()
.DidNotReceiveWithAnyArgs()
.DeleteAsync(default);
await sutProvider.GetDependency<IOrganizationRepository>().Received(1).ReplaceAsync(Arg.Any<Organization>());
}
[BitAutoData]
[SutProviderCustomize]
[Theory]
public async Task Edit_MigrationCohortAssignment_MissingTargetCohort_SurfacesBadRequestAndSkipsReplace(
Organization organization,
Guid newCohortId,
SutProvider<OrganizationsController> sutProvider)
{
var update = new OrganizationEditModel
{
PlanType = PlanType.TeamsMonthly,
MigrationCohortId = newCohortId,
};
StubCohortAccessAllowed(sutProvider);
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organization.Id).Returns(organization);
sutProvider.GetDependency<IOrganizationPlanMigrationCohortAssignmentRepository>()
.GetByOrganizationIdAsync(organization.Id)
.Returns((OrganizationPlanMigrationCohortAssignment)null);
sutProvider.GetDependency<IOrganizationPlanMigrationCohortRepository>()
.GetByIdAsync(newCohortId)
.Returns((OrganizationPlanMigrationCohort)null);
sutProvider.Sut.TempData = new TempDataDictionary(new DefaultHttpContext(), Substitute.For<ITempDataProvider>());
var result = await sutProvider.Sut.Edit(organization.Id, update);
var redirect = Assert.IsType<RedirectToActionResult>(result);
Assert.Equal("Edit", redirect.ActionName);
Assert.Equal("The selected migration cohort no longer exists.", sutProvider.Sut.TempData["Error"]);
await sutProvider.GetDependency<IOrganizationPlanMigrationCohortAssignmentRepository>()
.DidNotReceiveWithAnyArgs()
.CreateAsync(default);
await sutProvider.GetDependency<IOrganizationRepository>().DidNotReceive().ReplaceAsync(Arg.Any<Organization>());
}
[BitAutoData]
[SutProviderCustomize]
[Theory]
public async Task Edit_MigrationCohortAssignment_Unassign_DeletesAssignment(
Organization organization,
SutProvider<OrganizationsController> sutProvider)
{
var existing = new OrganizationPlanMigrationCohortAssignment
{
Id = Guid.NewGuid(),
OrganizationId = organization.Id,
CohortId = Guid.NewGuid(),
};
var update = new OrganizationEditModel
{
PlanType = PlanType.TeamsMonthly,
MigrationCohortId = null,
};
StubCohortAccessAllowed(sutProvider);
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organization.Id).Returns(organization);
sutProvider.GetDependency<IOrganizationPlanMigrationCohortAssignmentRepository>()
.GetByOrganizationIdAsync(organization.Id)
.Returns(existing);
_ = await sutProvider.Sut.Edit(organization.Id, update);
await sutProvider.GetDependency<IOrganizationPlanMigrationCohortAssignmentRepository>()
.Received(1)
.DeleteAsync(existing);
await sutProvider.GetDependency<IOrganizationPlanMigrationCohortAssignmentRepository>()
.DidNotReceiveWithAnyArgs()
.CreateAsync(default);
await sutProvider.GetDependency<IOrganizationRepository>().Received(1).ReplaceAsync(Arg.Any<Organization>());
}
[BitAutoData]
[SutProviderCustomize]
[Theory]
public async Task Edit_MigrationCohortAssignment_Move_DeletesThenCreates(
Organization organization,
OrganizationPlanMigrationCohort targetCohort,
SutProvider<OrganizationsController> sutProvider)
{
targetCohort.MigrationPathId = null;
var existing = new OrganizationPlanMigrationCohortAssignment
{
Id = Guid.NewGuid(),
OrganizationId = organization.Id,
CohortId = Guid.NewGuid(),
};
var update = new OrganizationEditModel
{
PlanType = PlanType.TeamsMonthly,
MigrationCohortId = targetCohort.Id,
};
StubCohortAccessAllowed(sutProvider);
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organization.Id).Returns(organization);
sutProvider.GetDependency<IOrganizationPlanMigrationCohortAssignmentRepository>()
.GetByOrganizationIdAsync(organization.Id)
.Returns(existing);
sutProvider.GetDependency<IOrganizationPlanMigrationCohortRepository>()
.GetByIdAsync(targetCohort.Id)
.Returns(targetCohort);
_ = await sutProvider.Sut.Edit(organization.Id, update);
var assignmentRepo = sutProvider.GetDependency<IOrganizationPlanMigrationCohortAssignmentRepository>();
// UNIQUE(OrganizationId) at the DB layer forces delete before create; the test pins that order.
// The created row must be a fresh entity, not an in-place mutation of the existing assignment.
Received.InOrder(() =>
{
assignmentRepo.DeleteAsync(existing);
assignmentRepo.CreateAsync(Arg.Is<OrganizationPlanMigrationCohortAssignment>(a =>
a.OrganizationId == organization.Id
&& a.CohortId == targetCohort.Id
&& a.Id != existing.Id));
});
await sutProvider.GetDependency<IOrganizationRepository>().Received(1).ReplaceAsync(Arg.Any<Organization>());
}
public static TheoryData<Exception> CohortWriteFailureExceptions =>
new()
{
new DbUpdateException("simulated repo failure"),
new TimeoutException("simulated timeout"),
new InvalidOperationException("simulated non-listed failure"),
};
[Theory]
[BitMemberAutoData(nameof(CohortWriteFailureExceptions))]
public async Task Edit_MigrationCohortAssignment_NewAssignment_CreateThrows_SurfacesWarningButOrgIsSaved(
Exception thrown,
Organization organization,
OrganizationPlanMigrationCohort cohort,
SutProvider<OrganizationsController> sutProvider)
{
// The cohort write runs after ReplaceAsync. A failure here must not block the org-level
// save; the operator sees a warning and is told to retry the cohort change. Any exception
// surfaces the same way -- the catch must not be narrow enough to leak a 500.
cohort.MigrationPathId = null;
var update = new OrganizationEditModel
{
PlanType = PlanType.TeamsMonthly,
MigrationCohortId = cohort.Id,
};
StubCohortAccessAllowed(sutProvider);
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organization.Id).Returns(organization);
sutProvider.GetDependency<IOrganizationPlanMigrationCohortAssignmentRepository>()
.GetByOrganizationIdAsync(organization.Id)
.Returns((OrganizationPlanMigrationCohortAssignment)null);
sutProvider.GetDependency<IOrganizationPlanMigrationCohortRepository>()
.GetByIdAsync(cohort.Id)
.Returns(cohort);
sutProvider.GetDependency<IOrganizationPlanMigrationCohortAssignmentRepository>()
.CreateAsync(Arg.Any<OrganizationPlanMigrationCohortAssignment>())
.ThrowsAsync(thrown);
sutProvider.Sut.TempData = new TempDataDictionary(new DefaultHttpContext(), Substitute.For<ITempDataProvider>());
var result = await sutProvider.Sut.Edit(organization.Id, update);
var redirect = Assert.IsType<RedirectToActionResult>(result);
Assert.Equal("Edit", redirect.ActionName);
Assert.Equal(
"Organization updated successfully, but the migration cohort assignment could not be saved. Reload this page and retry.",
sutProvider.Sut.TempData["Warning"]);
Assert.Null(sutProvider.Sut.TempData["Error"]);
await sutProvider.GetDependency<IOrganizationRepository>().Received(1).ReplaceAsync(Arg.Any<Organization>());
// Downstream pipeline must still run after a cohort write failure -- the ability cache
// would otherwise be stale relative to the just-replaced organization.
await sutProvider.GetDependency<IApplicationCacheService>().Received(1)
.UpsertOrganizationAbilityAsync(Arg.Is<Organization>(o => o.Id == organization.Id));
}
[BitAutoData]
[SutProviderCustomize]
[Theory]
public async Task Edit_MigrationCohortAssignment_Move_CreateThrowsAfterDelete_SurfacesWarningButOrgIsSaved(
Organization organization,
OrganizationPlanMigrationCohort targetCohort,
SutProvider<OrganizationsController> sutProvider)
{
targetCohort.MigrationPathId = null;
var existing = new OrganizationPlanMigrationCohortAssignment
{
Id = Guid.NewGuid(),
OrganizationId = organization.Id,
CohortId = Guid.NewGuid(),
};
var update = new OrganizationEditModel
{
PlanType = PlanType.TeamsMonthly,
MigrationCohortId = targetCohort.Id,
};
var assignmentRepo = sutProvider.GetDependency<IOrganizationPlanMigrationCohortAssignmentRepository>();
StubCohortAccessAllowed(sutProvider);
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organization.Id).Returns(organization);
assignmentRepo.GetByOrganizationIdAsync(organization.Id).Returns(existing);
sutProvider.GetDependency<IOrganizationPlanMigrationCohortRepository>()
.GetByIdAsync(targetCohort.Id)
.Returns(targetCohort);
assignmentRepo
.CreateAsync(Arg.Any<OrganizationPlanMigrationCohortAssignment>())
.ThrowsAsync(new DbUpdateException("simulated repo failure"));
sutProvider.Sut.TempData = new TempDataDictionary(new DefaultHttpContext(), Substitute.For<ITempDataProvider>());
var result = await sutProvider.Sut.Edit(organization.Id, update);
var redirect = Assert.IsType<RedirectToActionResult>(result);
Assert.Equal("Edit", redirect.ActionName);
Assert.Equal(
"Organization updated successfully, but the migration cohort assignment could not be saved. Reload this page and retry.",
sutProvider.Sut.TempData["Warning"]);
// UNIQUE(OrganizationId) at the DB layer forces delete before create on a move; pin the order.
// On failure here the row is left unassigned and the operator recovers by re-submitting Save.
await assignmentRepo.Received(1).DeleteAsync(existing);
await assignmentRepo.Received(1).CreateAsync(Arg.Is<OrganizationPlanMigrationCohortAssignment>(a =>
a.OrganizationId == organization.Id && a.CohortId == targetCohort.Id));
await sutProvider.GetDependency<IOrganizationRepository>().Received(1).ReplaceAsync(Arg.Any<Organization>());
}
[BitAutoData]
[SutProviderCustomize]
[Theory]
public async Task Edit_MigrationCohortAssignment_Move_DeleteThrows_SurfacesWarningButOrgIsSaved(
Organization organization,
OrganizationPlanMigrationCohort targetCohort,
SutProvider<OrganizationsController> sutProvider)
{
// Symmetric to the CreateThrowsAfterDelete case -- the broad catch must also swallow a
// delete-side failure, leaving the prior assignment intact and surfacing the same warning.
targetCohort.MigrationPathId = null;
var existing = new OrganizationPlanMigrationCohortAssignment
{
Id = Guid.NewGuid(),
OrganizationId = organization.Id,
CohortId = Guid.NewGuid(),
};
var update = new OrganizationEditModel
{
PlanType = PlanType.TeamsMonthly,
MigrationCohortId = targetCohort.Id,
};
var assignmentRepo = sutProvider.GetDependency<IOrganizationPlanMigrationCohortAssignmentRepository>();
StubCohortAccessAllowed(sutProvider);
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organization.Id).Returns(organization);
assignmentRepo.GetByOrganizationIdAsync(organization.Id).Returns(existing);
sutProvider.GetDependency<IOrganizationPlanMigrationCohortRepository>()
.GetByIdAsync(targetCohort.Id)
.Returns(targetCohort);
assignmentRepo
.DeleteAsync(Arg.Any<OrganizationPlanMigrationCohortAssignment>())
.ThrowsAsync(new DbUpdateException("simulated repo failure"));
sutProvider.Sut.TempData = new TempDataDictionary(new DefaultHttpContext(), Substitute.For<ITempDataProvider>());
var result = await sutProvider.Sut.Edit(organization.Id, update);
var redirect = Assert.IsType<RedirectToActionResult>(result);
Assert.Equal("Edit", redirect.ActionName);
Assert.Equal(
"Organization updated successfully, but the migration cohort assignment could not be saved. Reload this page and retry.",
sutProvider.Sut.TempData["Warning"]);
await assignmentRepo.Received(1).DeleteAsync(existing);
// The catch swallows the delete failure before any insert is attempted -- the prior row
// is preserved by the failed delete and no new row is inserted.
await assignmentRepo.DidNotReceiveWithAnyArgs().CreateAsync(default);
await sutProvider.GetDependency<IOrganizationRepository>().Received(1).ReplaceAsync(Arg.Any<Organization>());
}
[BitAutoData]
[SutProviderCustomize]
[Theory]
public async Task Edit_MigrationCohortAssignment_GuidEmptySubmittedFromUnassigned_DoesNotLookUpCohort(
Organization organization,
SutProvider<OrganizationsController> sutProvider)
{
// Form binding can surface "(Not assigned)" as Guid.Empty; the controller normalizes it
// to null so the unassigned-to-unassigned diff produces no repository writes and no
// bogus GetByIdAsync(Guid.Empty) lookup.
var update = new OrganizationEditModel
{
PlanType = PlanType.TeamsMonthly,
MigrationCohortId = Guid.Empty,
};
StubCohortAccessAllowed(sutProvider);
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organization.Id).Returns(organization);
sutProvider.GetDependency<IOrganizationPlanMigrationCohortAssignmentRepository>()
.GetByOrganizationIdAsync(organization.Id)
.Returns((OrganizationPlanMigrationCohortAssignment)null);
_ = await sutProvider.Sut.Edit(organization.Id, update);
await sutProvider.GetDependency<IOrganizationPlanMigrationCohortRepository>()
.DidNotReceiveWithAnyArgs()
.GetByIdAsync(default);
await sutProvider.GetDependency<IOrganizationPlanMigrationCohortAssignmentRepository>()
.DidNotReceiveWithAnyArgs()
.CreateAsync(default);
await sutProvider.GetDependency<IOrganizationPlanMigrationCohortAssignmentRepository>()
.DidNotReceiveWithAnyArgs()
.DeleteAsync(default);
await sutProvider.GetDependency<IOrganizationRepository>().Received(1).ReplaceAsync(Arg.Any<Organization>());
}
[BitAutoData]
[SutProviderCustomize]
[Theory]
public async Task Edit_MigrationCohortAssignment_GuidEmptySubmittedFromAssigned_UnassignsOrg(
Organization organization,
SutProvider<OrganizationsController> sutProvider)
{
// Guid.Empty must be treated as "(Not assigned)" -- the unassign path runs, not a
// bogus assignment to a non-existent cohort.
var existing = new OrganizationPlanMigrationCohortAssignment
{
Id = Guid.NewGuid(),
OrganizationId = organization.Id,
CohortId = Guid.NewGuid(),
};
var update = new OrganizationEditModel
{
PlanType = PlanType.TeamsMonthly,
MigrationCohortId = Guid.Empty,
};
StubCohortAccessAllowed(sutProvider);
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organization.Id).Returns(organization);
sutProvider.GetDependency<IOrganizationPlanMigrationCohortAssignmentRepository>()
.GetByOrganizationIdAsync(organization.Id)
.Returns(existing);
_ = await sutProvider.Sut.Edit(organization.Id, update);
await sutProvider.GetDependency<IOrganizationPlanMigrationCohortAssignmentRepository>()
.Received(1)
.DeleteAsync(existing);
await sutProvider.GetDependency<IOrganizationPlanMigrationCohortAssignmentRepository>()
.DidNotReceiveWithAnyArgs()
.CreateAsync(default);
await sutProvider.GetDependency<IOrganizationPlanMigrationCohortRepository>()
.DidNotReceiveWithAnyArgs()
.GetByIdAsync(default);
await sutProvider.GetDependency<IOrganizationRepository>().Received(1).ReplaceAsync(Arg.Any<Organization>());
}
[BitAutoData]
[SutProviderCustomize]
[Theory]
public async Task Edit_MigrationCohortAssignment_ModelStateInvalid_SkipsCohortHandling(
Organization organization,
OrganizationPlanMigrationCohort cohort,
SutProvider<OrganizationsController> sutProvider)
{
// If unrelated model validation fails the controller short-circuits before any
// persistence; the cohort block must not have run. Pins ordering so a future refactor
// that moves the cohort block above the ModelState check would fail this test.
var update = new OrganizationEditModel
{
PlanType = PlanType.TeamsMonthly,
MigrationCohortId = cohort.Id,
};
StubCohortAccessAllowed(sutProvider);
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organization.Id).Returns(organization);
sutProvider.Sut.ModelState.AddModelError("Name", "Name is required");
sutProvider.Sut.TempData = new TempDataDictionary(new DefaultHttpContext(), Substitute.For<ITempDataProvider>());
var result = await sutProvider.Sut.Edit(organization.Id, update);
Assert.IsType<RedirectToActionResult>(result);
await sutProvider.GetDependency<IOrganizationPlanMigrationCohortAssignmentRepository>()
.DidNotReceiveWithAnyArgs()
.GetByOrganizationIdAsync(default);
await sutProvider.GetDependency<IOrganizationPlanMigrationCohortAssignmentRepository>()
.DidNotReceiveWithAnyArgs()
.CreateAsync(default);
await sutProvider.GetDependency<IOrganizationPlanMigrationCohortAssignmentRepository>()
.DidNotReceiveWithAnyArgs()
.DeleteAsync(default);
await sutProvider.GetDependency<IOrganizationRepository>()
.DidNotReceive()
.ReplaceAsync(Arg.Any<Organization>());
}
[BitAutoData]
[SutProviderCustomize]
[Theory]
public async Task Edit_UseAutomaticUserConfirmation_NoChange_DoesNotLogEvent(
Organization organization,
SutProvider<OrganizationsController> sutProvider)
{
var update = new OrganizationEditModel
{
PlanType = PlanType.TeamsMonthly,
UseAutomaticUserConfirmation = true
};
organization.UseAutomaticUserConfirmation = true;
organization.Enabled = true;
organization.UseEvents = true;
sutProvider.GetDependency<IAccessControlService>()
.UserHasPermission(Permission.Org_Plan_Edit)
.Returns(true);
var organizationRepository = sutProvider.GetDependency<IOrganizationRepository>();
organizationRepository.GetByIdAsync(organization.Id).Returns(organization);
_ = await sutProvider.Sut.Edit(organization.Id, update);
await sutProvider.GetDependency<IEventService>().DidNotReceive()
.LogOrganizationEventAsync(
Arg.Any<Organization>(),
Arg.Any<EventType>(),
Arg.Any<EventSystemUser>());
}
[BitAutoData]
[SutProviderCustomize]
[Theory]
public async Task Edit_MigrationCohortAssignment_IncompatiblePlan_SurfacesErrorAndSkipsWrite(
Organization organization,
OrganizationPlanMigrationCohort cohort,
SutProvider<OrganizationsController> sutProvider)
{
// Defense-in-depth: even if a stale dropdown or crafted POST submits a cohort whose
// MigrationPath.FromPlan differs from the org's PlanType, the controller must reject the
// write and surface an error instead of persisting a mismatched assignment.
organization.PlanType = PlanType.EnterpriseAnnually2020;
cohort.MigrationPathId = MigrationPathId.Enterprise2020MonthlyToCurrent;
var update = new OrganizationEditModel
{
PlanType = PlanType.EnterpriseAnnually2020,
MigrationCohortId = cohort.Id,
};
StubCohortAccessAllowed(sutProvider);
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organization.Id).Returns(organization);
sutProvider.GetDependency<IOrganizationPlanMigrationCohortAssignmentRepository>()
.GetByOrganizationIdAsync(organization.Id)
.Returns((OrganizationPlanMigrationCohortAssignment)null);
sutProvider.GetDependency<IOrganizationPlanMigrationCohortRepository>()
.GetByIdAsync(cohort.Id)
.Returns(cohort);
sutProvider.Sut.TempData = new TempDataDictionary(new DefaultHttpContext(), Substitute.For<ITempDataProvider>());
var result = await sutProvider.Sut.Edit(organization.Id, update);
var redirect = Assert.IsType<RedirectToActionResult>(result);
Assert.Equal("Edit", redirect.ActionName);
Assert.Equal(
"The selected migration cohort is not compatible with this organization's plan.",
sutProvider.Sut.TempData["Error"]);
await sutProvider.GetDependency<IOrganizationPlanMigrationCohortAssignmentRepository>()
.DidNotReceiveWithAnyArgs()
.CreateAsync(default);
await sutProvider.GetDependency<IOrganizationPlanMigrationCohortAssignmentRepository>()
.DidNotReceiveWithAnyArgs()
.DeleteAsync(default);
}
#endregion
#region Edit (GET)
private static void StubEditGetDependencies(
SutProvider<OrganizationsController> sutProvider,
Organization organization,
OrganizationPlanMigrationCohortAssignment currentAssignment,
IReadOnlyList<OrganizationPlanMigrationCohort> availableCohorts = null)
{
StubCohortAccessAllowed(sutProvider);
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organization.Id).Returns(organization);
sutProvider.GetDependency<IOrganizationPlanMigrationCohortAssignmentRepository>()
.GetByOrganizationIdAsync(organization.Id)
.Returns(currentAssignment);
sutProvider.GetDependency<IOrganizationPlanMigrationCohortRepository>()
.GetManyAsync()
.Returns(availableCohorts ?? Array.Empty<OrganizationPlanMigrationCohort>());
}
[BitAutoData]
[SutProviderCustomize]
[Theory]
public async Task Edit_Get_Unassigned_RendersUnlockedDropdown(
Organization organization,
SutProvider<OrganizationsController> sutProvider)
{
// The view binds the dropdown items off AvailableMigrationCohorts; the GET must surface
// the repository result on the model so the dropdown is populated.
OrganizationPlanMigrationCohort[] availableCohorts =
[
new() { Id = Guid.NewGuid(), Name = "Alpha" },
new() { Id = Guid.NewGuid(), Name = "Beta" },
];
StubEditGetDependencies(sutProvider, organization, currentAssignment: null, availableCohorts);
var result = await sutProvider.Sut.Edit(organization.Id);
var view = Assert.IsType<ViewResult>(result);
var model = Assert.IsType<OrganizationEditModel>(view.Model);
Assert.Null(model.MigrationCohortId);
Assert.False(model.MigrationCohortLocked);
Assert.Null(model.MigrationCohortLockReason);
Assert.NotNull(model.AvailableMigrationCohorts);
Assert.Equal(availableCohorts.Select(c => c.Id), model.AvailableMigrationCohorts.Select(c => c.Id));
}
[BitAutoData]
[SutProviderCustomize]
[Theory]
public async Task Edit_Get_AssignedButPending_RendersUnlockedDropdown(
Organization organization,
SutProvider<OrganizationsController> sutProvider)
{
var assignment = new OrganizationPlanMigrationCohortAssignment
{
Id = Guid.NewGuid(),
OrganizationId = organization.Id,
CohortId = Guid.NewGuid(),
};
StubEditGetDependencies(sutProvider, organization, assignment);
var result = await sutProvider.Sut.Edit(organization.Id);
var view = Assert.IsType<ViewResult>(result);
var model = Assert.IsType<OrganizationEditModel>(view.Model);
Assert.Equal(assignment.CohortId, model.MigrationCohortId);
Assert.False(model.MigrationCohortLocked);
Assert.Null(model.MigrationCohortLockReason);
}
[BitAutoData]
[SutProviderCustomize]
[Theory]
public async Task Edit_Get_Scheduled_LocksDropdownWithScheduledReason(
Organization organization,
SutProvider<OrganizationsController> sutProvider)
{
var assignment = new OrganizationPlanMigrationCohortAssignment
{
Id = Guid.NewGuid(),
OrganizationId = organization.Id,
CohortId = Guid.NewGuid(),
ScheduledDate = DateTime.UtcNow,
};
StubEditGetDependencies(sutProvider, organization, assignment);
var result = await sutProvider.Sut.Edit(organization.Id);
var view = Assert.IsType<ViewResult>(result);
var model = Assert.IsType<OrganizationEditModel>(view.Model);
Assert.True(model.MigrationCohortLocked);
Assert.Equal("Locked: a migration has already been scheduled for this organization.",
model.MigrationCohortLockReason);
}
[BitAutoData]
[SutProviderCustomize]
[Theory]
public async Task Edit_Get_Migrated_LocksDropdownWithMigratedReason(
Organization organization,
SutProvider<OrganizationsController> sutProvider)
{
var assignment = new OrganizationPlanMigrationCohortAssignment
{
Id = Guid.NewGuid(),
OrganizationId = organization.Id,
CohortId = Guid.NewGuid(),
MigratedDate = DateTime.UtcNow,
};
StubEditGetDependencies(sutProvider, organization, assignment);
var result = await sutProvider.Sut.Edit(organization.Id);
var view = Assert.IsType<ViewResult>(result);
var model = Assert.IsType<OrganizationEditModel>(view.Model);
Assert.True(model.MigrationCohortLocked);
Assert.Equal("Locked: this organization has already been migrated.",
model.MigrationCohortLockReason);
}
[BitAutoData]
[SutProviderCustomize]
[Theory]
public async Task Edit_Get_ChurnMitigated_LocksDropdownWithChurnReason(
Organization organization,
SutProvider<OrganizationsController> sutProvider)
{
var assignment = new OrganizationPlanMigrationCohortAssignment
{
Id = Guid.NewGuid(),
OrganizationId = organization.Id,
CohortId = Guid.NewGuid(),
ChurnDiscountAppliedDate = DateTime.UtcNow,
};
StubEditGetDependencies(sutProvider, organization, assignment);
var result = await sutProvider.Sut.Edit(organization.Id);
var view = Assert.IsType<ViewResult>(result);
var model = Assert.IsType<OrganizationEditModel>(view.Model);
Assert.True(model.MigrationCohortLocked);
Assert.Equal("Locked: a churn-mitigation discount has already been applied to this organization.",
model.MigrationCohortLockReason);
}
[BitAutoData]
[SutProviderCustomize]
[Theory]
public async Task Edit_Get_AllLifecycleDatesSet_PrefersMigratedReason(
Organization organization,
SutProvider<OrganizationsController> sutProvider)
{
// Precedence is operator-facing: Migrated wins over Scheduled and ChurnDiscountApplied.
// Pin the order so a refactor of the switch arms can't silently change the reason.
var assignment = new OrganizationPlanMigrationCohortAssignment
{
Id = Guid.NewGuid(),
OrganizationId = organization.Id,
CohortId = Guid.NewGuid(),
ScheduledDate = DateTime.UtcNow,
MigratedDate = DateTime.UtcNow,
ChurnDiscountAppliedDate = DateTime.UtcNow,
};
StubEditGetDependencies(sutProvider, organization, assignment);
var result = await sutProvider.Sut.Edit(organization.Id);
var view = Assert.IsType<ViewResult>(result);
var model = Assert.IsType<OrganizationEditModel>(view.Model);
Assert.True(model.MigrationCohortLocked);
Assert.Equal("Locked: this organization has already been migrated.",
model.MigrationCohortLockReason);
}
[BitAutoData]
[SutProviderCustomize]
[Theory]
public async Task Edit_Get_WithoutCohortPermissionOrFlag_LeavesDropdownHidden(
Organization organization,
SutProvider<OrganizationsController> sutProvider)
{
// With FF off or the cohort permission absent, the GET must not surface dropdown data --
// a null AvailableMigrationCohorts is the signal the view uses to hide the row entirely.
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organization.Id).Returns(organization);
var result = await sutProvider.Sut.Edit(organization.Id);
var view = Assert.IsType<ViewResult>(result);
var model = Assert.IsType<OrganizationEditModel>(view.Model);
Assert.Null(model.AvailableMigrationCohorts);
Assert.Null(model.MigrationCohortId);
Assert.False(model.MigrationCohortLocked);
await sutProvider.GetDependency<IOrganizationPlanMigrationCohortRepository>()
.DidNotReceiveWithAnyArgs()
.GetManyAsync();
await sutProvider.GetDependency<IOrganizationPlanMigrationCohortAssignmentRepository>()
.DidNotReceiveWithAnyArgs()
.GetByOrganizationIdAsync(default);
}
[BitAutoData]
[SutProviderCustomize]
[Theory]
public async Task Edit_Get_FiltersCohortsByOrgPlan(
Organization organization,
SutProvider<OrganizationsController> sutProvider)
{
// Operators must not be offered cohorts whose MigrationPath.FromPlan differs from this
// org's PlanType; cohorts without a path are universally visible (e.g. pre-staged rows).
organization.PlanType = PlanType.EnterpriseAnnually2020;
var matching = new OrganizationPlanMigrationCohort
{
Id = Guid.NewGuid(),
Name = "Matches",
MigrationPathId = MigrationPathId.Enterprise2020AnnualToCurrent,
};
var mismatched = new OrganizationPlanMigrationCohort
{
Id = Guid.NewGuid(),
Name = "Mismatches",
MigrationPathId = MigrationPathId.Enterprise2020MonthlyToCurrent,
};
var pathless = new OrganizationPlanMigrationCohort
{
Id = Guid.NewGuid(),
Name = "Pathless",
MigrationPathId = null,
};
StubEditGetDependencies(sutProvider, organization, currentAssignment: null,
availableCohorts: [matching, mismatched, pathless]);
var result = await sutProvider.Sut.Edit(organization.Id);
var view = Assert.IsType<ViewResult>(result);
var model = Assert.IsType<OrganizationEditModel>(view.Model);
Assert.NotNull(model.AvailableMigrationCohorts);
Assert.Equal(
new[] { matching.Id, pathless.Id },
model.AvailableMigrationCohorts.Select(c => c.Id));
}
#endregion
}