Files
server/test/Admin.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs
cyprain-okeke 761d8d055a [PM-36964] Add per-org migration cohort assignment to the Admin portal (#7681)
* [PM-36949] Add OrganizationPlanMigrationCohort schema and Core domain types

Add the foundation for cohort-based plan migrations:
- Two tables: OrganizationPlanMigrationCohort and OrganizationPlanMigrationCohortAssignment
- Two views and nine stored procedures (four CRUD on cohort, four CRUD plus
  ReadByOrganizationId on assignment)
- Single Migrator script for MSSQL deployment
- Core entities, MigrationPath value object and its registry, and bare repository
  interfaces under Bit.Core.Billing.Organizations.PlanMigration

The cohort table holds the human-managed metadata (name, discount coupons,
MigrationPathId byte) and the assignment table records each organization's
position in the migration lifecycle (scheduled, migrated, churn-mitigated).
Both Update SPs follow the accept-but-don't-assign pattern: immutable columns
(OrganizationId, CohortId, CreatedAt) are parameters but not SET clauses.

* [PM-36949] Add Dapper repositories for plan migration cohort tables

OrganizationPlanMigrationCohortRepository inherits the base Repository<T, TId>
CRUD methods unchanged. OrganizationPlanMigrationCohortAssignmentRepository
also relies on the base for CRUD and adds GetByOrganizationIdAsync which
returns at most one row (the UNIQUE constraint on OrganizationId at the
database layer guarantees this).

* [PM-36949] Add EF Core configurations, repositories, and provider migrations for plan migration cohort tables

- EF models wrap the Core entities; the assignment model exposes nav properties
  for Organization and Cohort so the FK + cascade-delete is inferred by EF.
- EntityTypeConfiguration classes pin ID generation to application code
  (ValueGeneratedNever) and declare the UNIQUE indexes plus the composite
  (CohortId, ScheduledAt, MigratedAt) index.
- Repositories follow the OrganizationInstallationRepository template; the
  assignment repo adds GetByOrganizationIdAsync to mirror the SP exposed on
  the MSSQL side.
- DatabaseContext gets two DbSet properties; auto-discovery picks up the
  configuration classes.
- Generated migrations for MySQL, Postgres, and SQLite create matching schemas;
  EF truncates FK and index names on providers with 64-char identifier limits,
  which is consistent with the rest of the codebase.

* [PM-36949] Wire up DI and add tests for plan migration cohort repositories

Register both Dapper and EF Core repositories in their respective service
collection extensions, following the existing AddSingleton convention in
these files.

Add tests:
- MigrationPathIdsSnapshotTests guards the immortal byte IDs that downstream
  code pins on. The class- and method-level comments document why these
  values can never be renumbered.
- MigrationPathTests covers the FromId round-trip and the null-on-unknown
  behavior the registry promises to callers.
- OrganizationPlanMigrationCohortRepositoryTests exercises CRUD, the UNIQUE
  Name constraint, and verifies that ReplaceAsync ignores CreatedAt
  mutations (per the accept-but-don't-assign Update SP).
- OrganizationPlanMigrationCohortAssignmentRepositoryTests exercises CRUD,
  GetByOrganizationIdAsync, the UNIQUE OrganizationId constraint,
  cascade-delete from both Organization and Cohort, and verifies that
  ReplaceAsync ignores OrganizationId, CohortId, and CreatedAt mutations.

* [PM-36949] Use PlanType and MigrationPathId enums on MigrationPath

Replace the byte Id with a byte-backed MigrationPathId enum and replace
the string FromPlan/ToPlan fields with PlanType. Persistence is
unchanged -- EF normalises enum-backed properties to their underlying
type in the model snapshot, and Dapper handles enum-to-byte parameter
mapping automatically.

* [PM-36949] Add *.lscache to .gitignore

* [PM-36949] fix: Override ReplaceAsync on EF cohort repositories for immutability parity

The Dapper _Update SPs accept-but-don't-assign certain columns (CreatedAt
on cohort; OrganizationId, CohortId, and CreatedAt on assignment), but
the base EF Repository<T,TEntity,Guid>.ReplaceAsync uses SetValues which
writes every scalar. Override on both repos and mark the immutable
properties as IsModified = false so MySQL/Postgres/Sqlite match MSSQL
behavior. Mirrors the existing DeviceRepository.ReplaceAsync pattern.

* [PM-36949] fix: Bound cohort string columns and widen Name to 255 chars

Add [MaxLength] attributes to the three cohort string properties so the
EF providers (MySQL/Postgres/Sqlite) enforce the same limits as MSSQL,
where the columns were already NVARCHAR-capped. Widen Name from 64 to
255 chars across MSSQL DDL, both _Create/_Update SP signatures, the
Migrator script, the entity, and all three regenerated EF migrations.
Coupon codes stay at 64 (Stripe IDs are short).

* [PM-36949] test: Lock FromPlan and ToPlan per MigrationPath

The snapshot test class doc says "byte N means a specific FromPlan ->
ToPlan transition forever", but only the byte value was being asserted.
A silent refactor of the registry's PlanType references would not have
been caught. Add per-path FromPlan/ToPlan assertions to close the gap.

* [PM-36949] chore: Apply dotnet format

* [PM-36949] test: Use LaxDateTimeComparer for round-tripped DateTimes

Postgres timestamp and MySQL datetime(6) store microsecond precision (6
fractional digits), but .NET DateTime is 100ns ticks (7 digits). Exact
Assert.Equal fails by a single tick on round-trip. Switch the three
DateTime comparisons in ReplaceAsync_UpdatesMutableColumns_AndIgnoresImmutableOnes
to LaxDateTimeComparer.Default -- the same 2ms-tolerance comparer used
by SendRepositoryTests and InstallationRepositoryTests for the same
precision issue.

* Add implementation for dropdown

* Reconcile dropdown work with renamed PM-36949 schema

After merging origin/main, the cohort schema now uses *Date suffixes
(ScheduledDate / MigratedDate / ChurnDiscountAppliedDate / CreationDate).
Rename references in the dropdown work, restore IsLocked() on the merged
entity, and re-add GetManyAsync() to the cohort repository (interface +
Dapper + EF) since the merge took main's pre-dropdown versions.

Also remove the six superseded provider migration files (20260515*) -- the
20260518* renames from origin/main are now the only cohort migrations.

* Fix UTF-8 BOM on cohort assignment unit test file

* Resolve the FF and permission issue

* Extract migration cohort resolution into a helper

Addresses PR feedback to keep the Edit action focused: the cohort
resolution/validation now lives in ResolveMigrationCohortAssignmentChangeAsync
and returns a MigrationCohortAssignmentChange record. The endpoint still
owns the page return.

---------

Co-authored-by: Alex Morask <amorask@bitwarden.com>
Co-authored-by: Alex Morask <144709477+amorask-bitwarden@users.noreply.github.com>
2026-05-21 15:17:00 +01:00

1532 lines
64 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 AutomaticUserConfirmationOrganizationPolicyComplianceValidatorRequest(organization.Id);
sutProvider.GetDependency<IAutomaticUserConfirmationOrganizationPolicyComplianceValidator>()
.IsOrganizationCompliantAsync(Arg.Any<AutomaticUserConfirmationOrganizationPolicyComplianceValidatorRequest>())
.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 AutomaticUserConfirmationOrganizationPolicyComplianceValidatorRequest(organization.Id);
sutProvider.GetDependency<IAutomaticUserConfirmationOrganizationPolicyComplianceValidator>()
.IsOrganizationCompliantAsync(Arg.Any<AutomaticUserConfirmationOrganizationPolicyComplianceValidatorRequest>())
.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 AutomaticUserConfirmationOrganizationPolicyComplianceValidatorRequest(organization.Id);
sutProvider.GetDependency<IAutomaticUserConfirmationOrganizationPolicyComplianceValidator>()
.IsOrganizationCompliantAsync(Arg.Any<AutomaticUserConfirmationOrganizationPolicyComplianceValidatorRequest>())
.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<IAutomaticUserConfirmationOrganizationPolicyComplianceValidator>()
.DidNotReceive()
.IsOrganizationCompliantAsync(Arg.Any<AutomaticUserConfirmationOrganizationPolicyComplianceValidatorRequest>());
}
[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<IAutomaticUserConfirmationOrganizationPolicyComplianceValidator>()
.DidNotReceive()
.IsOrganizationCompliantAsync(Arg.Any<AutomaticUserConfirmationOrganizationPolicyComplianceValidatorRequest>());
}
[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 AutomaticUserConfirmationOrganizationPolicyComplianceValidatorRequest(organization.Id);
sutProvider.GetDependency<IAutomaticUserConfirmationOrganizationPolicyComplianceValidator>()
.IsOrganizationCompliantAsync(Arg.Any<AutomaticUserConfirmationOrganizationPolicyComplianceValidatorRequest>())
.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
}