diff --git a/bitwarden-server.sln b/bitwarden-server.sln
index ae9571a4a5..409906e2d0 100644
--- a/bitwarden-server.sln
+++ b/bitwarden-server.sln
@@ -140,10 +140,13 @@ EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SeederApi", "util\SeederApi\SeederApi.csproj", "{9F08DFBB-482B-4C9D-A5F4-6BDA6EC2E68F}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SeederApi.IntegrationTest", "test\SeederApi.IntegrationTest\SeederApi.IntegrationTest.csproj", "{A2E067EF-609C-4D13-895A-E054C61D48BB}"
+EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SSO.Test", "bitwarden_license\test\SSO.Test\SSO.Test.csproj", "{7D98784C-C253-43FB-9873-25B65C6250D6}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sso.IntegrationTest", "bitwarden_license\test\Sso.IntegrationTest\Sso.IntegrationTest.csproj", "{FFB09376-595B-6F93-36F0-70CAE90AFECB}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Server.IntegrationTest", "test\Server.IntegrationTest\Server.IntegrationTest.csproj", "{E75E1F10-BC6F-4EB1-BA75-D897C45AEA0D}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -372,6 +375,10 @@ Global
{FFB09376-595B-6F93-36F0-70CAE90AFECB}.Debug|Any CPU.Build.0 = Debug|Any CPU
{FFB09376-595B-6F93-36F0-70CAE90AFECB}.Release|Any CPU.ActiveCfg = Release|Any CPU
{FFB09376-595B-6F93-36F0-70CAE90AFECB}.Release|Any CPU.Build.0 = Release|Any CPU
+ {E75E1F10-BC6F-4EB1-BA75-D897C45AEA0D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {E75E1F10-BC6F-4EB1-BA75-D897C45AEA0D}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {E75E1F10-BC6F-4EB1-BA75-D897C45AEA0D}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {E75E1F10-BC6F-4EB1-BA75-D897C45AEA0D}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -432,6 +439,7 @@ Global
{A2E067EF-609C-4D13-895A-E054C61D48BB} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84F}
{7D98784C-C253-43FB-9873-25B65C6250D6} = {287CFF34-BBDB-4BC4-AF88-1E19A5A4679B}
{FFB09376-595B-6F93-36F0-70CAE90AFECB} = {287CFF34-BBDB-4BC4-AF88-1E19A5A4679B}
+ {E75E1F10-BC6F-4EB1-BA75-D897C45AEA0D} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84F}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {E01CBF68-2E20-425F-9EDB-E0A6510CA92F}
diff --git a/dev/verify_migrations.ps1 b/dev/verify_migrations.ps1
index ad0d34cef1..ce1754e684 100644
--- a/dev/verify_migrations.ps1
+++ b/dev/verify_migrations.ps1
@@ -5,12 +5,19 @@
Validates that new database migration files follow naming conventions and chronological order.
.DESCRIPTION
- This script validates migration files in util/Migrator/DbScripts/ to ensure:
+ This script validates migration files to ensure:
+
+ For SQL migrations in util/Migrator/DbScripts/:
1. New migrations follow the naming format: YYYY-MM-DD_NN_Description.sql
2. New migrations are chronologically ordered (filename sorts after existing migrations)
3. Dates use leading zeros (e.g., 2025-01-05, not 2025-1-5)
4. A 2-digit sequence number is included (e.g., _00, _01)
+ For Entity Framework migrations in util/MySqlMigrations, util/PostgresMigrations, util/SqliteMigrations:
+ 1. New migrations follow the naming format: YYYYMMDDHHMMSS_Description.cs
+ 2. Each migration has both .cs and .Designer.cs files
+ 3. New migrations are chronologically ordered (timestamp sorts after existing migrations)
+
.PARAMETER BaseRef
The base git reference to compare against (e.g., 'main', 'HEAD~1')
@@ -58,75 +65,288 @@ $currentMigrations = git ls-tree -r --name-only $CurrentRef -- "$migrationPath/"
# Find added migrations
$addedMigrations = $currentMigrations | Where-Object { $_ -notin $baseMigrations }
+$sqlValidationFailed = $false
+
if ($addedMigrations.Count -eq 0) {
- Write-Host "No new migration files added."
- exit 0
+ Write-Host "No new SQL migration files added."
+ Write-Host ""
+}
+else {
+ Write-Host "New SQL migration files detected:"
+ $addedMigrations | ForEach-Object { Write-Host " $_" }
+ Write-Host ""
+
+ # Get the last migration from base reference
+ if ($baseMigrations.Count -eq 0) {
+ Write-Host "No previous SQL migrations found (initial commit?). Skipping chronological validation."
+ Write-Host ""
+ }
+ else {
+ $lastBaseMigration = Split-Path -Leaf ($baseMigrations | Select-Object -Last 1)
+ Write-Host "Last SQL migration in base reference: $lastBaseMigration"
+ Write-Host ""
+
+ # Required format regex: YYYY-MM-DD_NN_Description.sql
+ $formatRegex = '^[0-9]{4}-[0-9]{2}-[0-9]{2}_[0-9]{2}_.+\.sql$'
+
+ foreach ($migration in $addedMigrations) {
+ $migrationName = Split-Path -Leaf $migration
+
+ # Validate NEW migration filename format
+ if ($migrationName -notmatch $formatRegex) {
+ Write-Host "ERROR: Migration '$migrationName' does not match required format"
+ Write-Host "Required format: YYYY-MM-DD_NN_Description.sql"
+ Write-Host " - YYYY: 4-digit year"
+ Write-Host " - MM: 2-digit month with leading zero (01-12)"
+ Write-Host " - DD: 2-digit day with leading zero (01-31)"
+ Write-Host " - NN: 2-digit sequence number (00, 01, 02, etc.)"
+ Write-Host "Example: 2025-01-15_00_MyMigration.sql"
+ $sqlValidationFailed = $true
+ continue
+ }
+
+ # Compare migration name with last base migration (using ordinal string comparison)
+ if ([string]::CompareOrdinal($migrationName, $lastBaseMigration) -lt 0) {
+ Write-Host "ERROR: New migration '$migrationName' is not chronologically after '$lastBaseMigration'"
+ $sqlValidationFailed = $true
+ }
+ else {
+ Write-Host "OK: '$migrationName' is chronologically after '$lastBaseMigration'"
+ }
+ }
+
+ Write-Host ""
+ }
+
+ if ($sqlValidationFailed) {
+ Write-Host "FAILED: One or more SQL migrations are incorrectly named or not in chronological order"
+ Write-Host ""
+ Write-Host "All new SQL migration files must:"
+ Write-Host " 1. Follow the naming format: YYYY-MM-DD_NN_Description.sql"
+ Write-Host " 2. Use leading zeros in dates (e.g., 2025-01-05, not 2025-1-5)"
+ Write-Host " 3. Include a 2-digit sequence number (e.g., _00, _01)"
+ Write-Host " 4. Have a filename that sorts after the last migration in base"
+ Write-Host ""
+ Write-Host "To fix this issue:"
+ Write-Host " 1. Locate your migration file(s) in util/Migrator/DbScripts/"
+ Write-Host " 2. Rename to follow format: YYYY-MM-DD_NN_Description.sql"
+ Write-Host " 3. Ensure the date is after $lastBaseMigration"
+ Write-Host ""
+ Write-Host "Example: 2025-01-15_00_AddNewFeature.sql"
+ }
+ else {
+ Write-Host "SUCCESS: All new SQL migrations are correctly named and in chronological order"
+ }
+
+ Write-Host ""
}
-Write-Host "New migration files detected:"
-$addedMigrations | ForEach-Object { Write-Host " $_" }
+# ===========================================================================================
+# Validate Entity Framework Migrations
+# ===========================================================================================
+
+Write-Host "==================================================================="
+Write-Host "Validating Entity Framework Migrations"
+Write-Host "==================================================================="
Write-Host ""
-# Get the last migration from base reference
-if ($baseMigrations.Count -eq 0) {
- Write-Host "No previous migrations found (initial commit?). Skipping validation."
- exit 0
-}
+$efMigrationPaths = @(
+ @{ Path = "util/MySqlMigrations/Migrations"; Name = "MySQL" },
+ @{ Path = "util/PostgresMigrations/Migrations"; Name = "Postgres" },
+ @{ Path = "util/SqliteMigrations/Migrations"; Name = "SQLite" }
+)
-$lastBaseMigration = Split-Path -Leaf ($baseMigrations | Select-Object -Last 1)
-Write-Host "Last migration in base reference: $lastBaseMigration"
-Write-Host ""
+$efValidationFailed = $false
-# Required format regex: YYYY-MM-DD_NN_Description.sql
-$formatRegex = '^[0-9]{4}-[0-9]{2}-[0-9]{2}_[0-9]{2}_.+\.sql$'
+foreach ($migrationPathInfo in $efMigrationPaths) {
+ $efPath = $migrationPathInfo.Path
+ $dbName = $migrationPathInfo.Name
-$validationFailed = $false
+ Write-Host "-------------------------------------------------------------------"
+ Write-Host "Checking $dbName EF migrations in $efPath"
+ Write-Host "-------------------------------------------------------------------"
+ Write-Host ""
-foreach ($migration in $addedMigrations) {
- $migrationName = Split-Path -Leaf $migration
+ # Get list of migrations from base reference
+ try {
+ $baseMigrations = git ls-tree -r --name-only $BaseRef -- "$efPath/" 2>$null | Where-Object { $_ -like "*.cs" -and $_ -notlike "*DatabaseContextModelSnapshot.cs" } | Sort-Object
+ if ($LASTEXITCODE -ne 0) {
+ Write-Host "Warning: Could not retrieve $dbName migrations from base reference '$BaseRef'"
+ $baseMigrations = @()
+ }
+ }
+ catch {
+ Write-Host "Warning: Could not retrieve $dbName migrations from base reference '$BaseRef'"
+ $baseMigrations = @()
+ }
- # Validate NEW migration filename format
- if ($migrationName -notmatch $formatRegex) {
- Write-Host "ERROR: Migration '$migrationName' does not match required format"
- Write-Host "Required format: YYYY-MM-DD_NN_Description.sql"
- Write-Host " - YYYY: 4-digit year"
- Write-Host " - MM: 2-digit month with leading zero (01-12)"
- Write-Host " - DD: 2-digit day with leading zero (01-31)"
- Write-Host " - NN: 2-digit sequence number (00, 01, 02, etc.)"
- Write-Host "Example: 2025-01-15_00_MyMigration.sql"
- $validationFailed = $true
+ # Get list of migrations from current reference
+ $currentMigrations = git ls-tree -r --name-only $CurrentRef -- "$efPath/" | Where-Object { $_ -like "*.cs" -and $_ -notlike "*DatabaseContextModelSnapshot.cs" } | Sort-Object
+
+ # Find added migrations
+ $addedMigrations = $currentMigrations | Where-Object { $_ -notin $baseMigrations }
+
+ if ($addedMigrations.Count -eq 0) {
+ Write-Host "No new $dbName EF migration files added."
+ Write-Host ""
continue
}
- # Compare migration name with last base migration (using ordinal string comparison)
- if ([string]::CompareOrdinal($migrationName, $lastBaseMigration) -lt 0) {
- Write-Host "ERROR: New migration '$migrationName' is not chronologically after '$lastBaseMigration'"
- $validationFailed = $true
+ Write-Host "New $dbName EF migration files detected:"
+ $addedMigrations | ForEach-Object { Write-Host " $_" }
+ Write-Host ""
+
+ # Get the last migration from base reference
+ if ($baseMigrations.Count -eq 0) {
+ Write-Host "No previous $dbName migrations found. Skipping chronological validation."
+ Write-Host ""
}
else {
- Write-Host "OK: '$migrationName' is chronologically after '$lastBaseMigration'"
+ $lastBaseMigration = Split-Path -Leaf ($baseMigrations | Select-Object -Last 1)
+ Write-Host "Last $dbName migration in base reference: $lastBaseMigration"
+ Write-Host ""
}
+
+ # Required format regex: YYYYMMDDHHMMSS_Description.cs or YYYYMMDDHHMMSS_Description.Designer.cs
+ $efFormatRegex = '^[0-9]{14}_.+\.cs$'
+
+ # Group migrations by base name (without .Designer.cs suffix)
+ $migrationGroups = @{}
+ $unmatchedFiles = @()
+
+ foreach ($migration in $addedMigrations) {
+ $migrationName = Split-Path -Leaf $migration
+
+ # Extract base name (remove .Designer.cs or .cs)
+ if ($migrationName -match '^([0-9]{14}_.+?)(?:\.Designer)?\.cs$') {
+ $baseName = $matches[1]
+ if (-not $migrationGroups.ContainsKey($baseName)) {
+ $migrationGroups[$baseName] = @()
+ }
+ $migrationGroups[$baseName] += $migrationName
+ }
+ else {
+ # Track files that don't match the expected pattern
+ $unmatchedFiles += $migrationName
+ }
+ }
+
+ # Flag any files that don't match the expected pattern
+ if ($unmatchedFiles.Count -gt 0) {
+ Write-Host "ERROR: The following migration files do not match the required format:"
+ foreach ($unmatchedFile in $unmatchedFiles) {
+ Write-Host " - $unmatchedFile"
+ }
+ Write-Host ""
+ Write-Host "Required format: YYYYMMDDHHMMSS_Description.cs or YYYYMMDDHHMMSS_Description.Designer.cs"
+ Write-Host " - YYYYMMDDHHMMSS: 14-digit timestamp (Year, Month, Day, Hour, Minute, Second)"
+ Write-Host " - Description: Descriptive name using PascalCase"
+ Write-Host "Example: 20250115120000_AddNewFeature.cs and 20250115120000_AddNewFeature.Designer.cs"
+ Write-Host ""
+ $efValidationFailed = $true
+ }
+
+ foreach ($baseName in $migrationGroups.Keys | Sort-Object) {
+ $files = $migrationGroups[$baseName]
+
+ # Validate format
+ $mainFile = "$baseName.cs"
+ $designerFile = "$baseName.Designer.cs"
+
+ if ($mainFile -notmatch $efFormatRegex) {
+ Write-Host "ERROR: Migration '$mainFile' does not match required format"
+ Write-Host "Required format: YYYYMMDDHHMMSS_Description.cs"
+ Write-Host " - YYYYMMDDHHMMSS: 14-digit timestamp (Year, Month, Day, Hour, Minute, Second)"
+ Write-Host "Example: 20250115120000_AddNewFeature.cs"
+ $efValidationFailed = $true
+ continue
+ }
+
+ # Check that both .cs and .Designer.cs files exist
+ $hasCsFile = $files -contains $mainFile
+ $hasDesignerFile = $files -contains $designerFile
+
+ if (-not $hasCsFile) {
+ Write-Host "ERROR: Missing main migration file: $mainFile"
+ $efValidationFailed = $true
+ }
+
+ if (-not $hasDesignerFile) {
+ Write-Host "ERROR: Missing designer file: $designerFile"
+ Write-Host "Each EF migration must have both a .cs and .Designer.cs file"
+ $efValidationFailed = $true
+ }
+
+ if (-not $hasCsFile -or -not $hasDesignerFile) {
+ continue
+ }
+
+ # Compare migration timestamp with last base migration (using ordinal string comparison)
+ if ($baseMigrations.Count -gt 0) {
+ if ([string]::CompareOrdinal($mainFile, $lastBaseMigration) -lt 0) {
+ Write-Host "ERROR: New migration '$mainFile' is not chronologically after '$lastBaseMigration'"
+ $efValidationFailed = $true
+ }
+ else {
+ Write-Host "OK: '$mainFile' is chronologically after '$lastBaseMigration'"
+ }
+ }
+ else {
+ Write-Host "OK: '$mainFile' (no previous migrations to compare)"
+ }
+ }
+
+ Write-Host ""
+}
+
+if ($efValidationFailed) {
+ Write-Host "FAILED: One or more EF migrations are incorrectly named or not in chronological order"
+ Write-Host ""
+ Write-Host "All new EF migration files must:"
+ Write-Host " 1. Follow the naming format: YYYYMMDDHHMMSS_Description.cs"
+ Write-Host " 2. Include both .cs and .Designer.cs files"
+ Write-Host " 3. Have a timestamp that sorts after the last migration in base"
+ Write-Host ""
+ Write-Host "To fix this issue:"
+ Write-Host " 1. Locate your migration file(s) in the respective Migrations directory"
+ Write-Host " 2. Ensure both .cs and .Designer.cs files exist"
+ Write-Host " 3. Rename to follow format: YYYYMMDDHHMMSS_Description.cs"
+ Write-Host " 4. Ensure the timestamp is after the last migration"
+ Write-Host ""
+ Write-Host "Example: 20250115120000_AddNewFeature.cs and 20250115120000_AddNewFeature.Designer.cs"
+}
+else {
+ Write-Host "SUCCESS: All new EF migrations are correctly named and in chronological order"
}
Write-Host ""
+Write-Host "==================================================================="
+Write-Host "Validation Summary"
+Write-Host "==================================================================="
+
+if ($sqlValidationFailed -or $efValidationFailed) {
+ if ($sqlValidationFailed) {
+ Write-Host "❌ SQL migrations validation FAILED"
+ }
+ else {
+ Write-Host "✓ SQL migrations validation PASSED"
+ }
+
+ if ($efValidationFailed) {
+ Write-Host "❌ EF migrations validation FAILED"
+ }
+ else {
+ Write-Host "✓ EF migrations validation PASSED"
+ }
-if ($validationFailed) {
- Write-Host "FAILED: One or more migrations are incorrectly named or not in chronological order"
Write-Host ""
- Write-Host "All new migration files must:"
- Write-Host " 1. Follow the naming format: YYYY-MM-DD_NN_Description.sql"
- Write-Host " 2. Use leading zeros in dates (e.g., 2025-01-05, not 2025-1-5)"
- Write-Host " 3. Include a 2-digit sequence number (e.g., _00, _01)"
- Write-Host " 4. Have a filename that sorts after the last migration in base"
- Write-Host ""
- Write-Host "To fix this issue:"
- Write-Host " 1. Locate your migration file(s) in util/Migrator/DbScripts/"
- Write-Host " 2. Rename to follow format: YYYY-MM-DD_NN_Description.sql"
- Write-Host " 3. Ensure the date is after $lastBaseMigration"
- Write-Host ""
- Write-Host "Example: 2025-01-15_00_AddNewFeature.sql"
+ Write-Host "OVERALL RESULT: FAILED"
exit 1
}
-
-Write-Host "SUCCESS: All new migrations are correctly named and in chronological order"
-exit 0
+else {
+ Write-Host "✓ SQL migrations validation PASSED"
+ Write-Host "✓ EF migrations validation PASSED"
+ Write-Host ""
+ Write-Host "OVERALL RESULT: SUCCESS"
+ exit 0
+}
diff --git a/src/Core/AdminConsole/OrganizationFeatures/Collections/CollectionUtils.cs b/src/Core/AdminConsole/OrganizationFeatures/Collections/CollectionUtils.cs
new file mode 100644
index 0000000000..116992146f
--- /dev/null
+++ b/src/Core/AdminConsole/OrganizationFeatures/Collections/CollectionUtils.cs
@@ -0,0 +1,53 @@
+using Bit.Core.Entities;
+using Bit.Core.Enums;
+using Bit.Core.Utilities;
+
+namespace Bit.Core.AdminConsole.OrganizationFeatures.Collections;
+
+public static class CollectionUtils
+{
+ ///
+ /// Arranges Collection and CollectionUser objects to create default user collections.
+ ///
+ /// The organization ID.
+ /// The IDs for organization users who need default collections.
+ /// The encrypted string to use as the default collection name.
+ /// A tuple containing the collections and collection users.
+ public static (ICollection collections, ICollection collectionUsers)
+ BuildDefaultUserCollections(Guid organizationId, IEnumerable organizationUserIds,
+ string defaultCollectionName)
+ {
+ var now = DateTime.UtcNow;
+
+ var collectionUsers = new List();
+ var collections = new List();
+
+ foreach (var orgUserId in organizationUserIds)
+ {
+ var collectionId = CoreHelpers.GenerateComb();
+
+ collections.Add(new Collection
+ {
+ Id = collectionId,
+ OrganizationId = organizationId,
+ Name = defaultCollectionName,
+ CreationDate = now,
+ RevisionDate = now,
+ Type = CollectionType.DefaultUserCollection,
+ DefaultUserCollectionEmail = null
+
+ });
+
+ collectionUsers.Add(new CollectionUser
+ {
+ CollectionId = collectionId,
+ OrganizationUserId = orgUserId,
+ ReadOnly = false,
+ HidePasswords = false,
+ Manage = true,
+ });
+ }
+
+ return (collections, collectionUsers);
+ }
+}
diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AutoConfirmUser/AutomaticallyConfirmOrganizationUserCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AutoConfirmUser/AutomaticallyConfirmOrganizationUserCommand.cs
index 1b488677ae..0292381857 100644
--- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AutoConfirmUser/AutomaticallyConfirmOrganizationUserCommand.cs
+++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AutoConfirmUser/AutomaticallyConfirmOrganizationUserCommand.cs
@@ -4,9 +4,7 @@ using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.OrganizationConfirmation;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
-using Bit.Core.Entities;
using Bit.Core.Enums;
-using Bit.Core.Models.Data;
using Bit.Core.Platform.Push;
using Bit.Core.Repositories;
using Bit.Core.Services;
@@ -83,19 +81,10 @@ public class AutomaticallyConfirmOrganizationUserCommand(IOrganizationUserReposi
return;
}
- await collectionRepository.CreateAsync(
- new Collection
- {
- OrganizationId = request.Organization!.Id,
- Name = request.DefaultUserCollectionName,
- Type = CollectionType.DefaultUserCollection
- },
- groups: null,
- [new CollectionAccessSelection
- {
- Id = request.OrganizationUser!.Id,
- Manage = true
- }]);
+ await collectionRepository.CreateDefaultCollectionsAsync(
+ request.Organization!.Id,
+ [request.OrganizationUser!.Id],
+ request.DefaultUserCollectionName);
}
catch (Exception ex)
{
diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommand.cs
index 0b82ac7ea4..02f3346ba6 100644
--- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommand.cs
+++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommand.cs
@@ -14,7 +14,6 @@ using Bit.Core.Billing.Enums;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
-using Bit.Core.Models.Data;
using Bit.Core.Platform.Push;
using Bit.Core.Repositories;
using Bit.Core.Services;
@@ -294,21 +293,10 @@ public class ConfirmOrganizationUserCommand : IConfirmOrganizationUserCommand
return;
}
- var defaultCollection = new Collection
- {
- OrganizationId = organizationUser.OrganizationId,
- Name = defaultUserCollectionName,
- Type = CollectionType.DefaultUserCollection
- };
- var collectionUser = new CollectionAccessSelection
- {
- Id = organizationUser.Id,
- ReadOnly = false,
- HidePasswords = false,
- Manage = true
- };
-
- await _collectionRepository.CreateAsync(defaultCollection, groups: null, users: [collectionUser]);
+ await _collectionRepository.CreateDefaultCollectionsAsync(
+ organizationUser.OrganizationId,
+ [organizationUser.Id],
+ defaultUserCollectionName);
}
///
@@ -339,7 +327,7 @@ public class ConfirmOrganizationUserCommand : IConfirmOrganizationUserCommand
return;
}
- await _collectionRepository.UpsertDefaultCollectionsAsync(organizationId, eligibleOrganizationUserIds, defaultUserCollectionName);
+ await _collectionRepository.CreateDefaultCollectionsAsync(organizationId, eligibleOrganizationUserIds, defaultUserCollectionName);
}
///
diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/OrganizationDataOwnershipPolicyValidator.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/OrganizationDataOwnershipPolicyValidator.cs
index 7a47baa65a..104a5751ff 100644
--- a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/OrganizationDataOwnershipPolicyValidator.cs
+++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/OrganizationDataOwnershipPolicyValidator.cs
@@ -57,14 +57,15 @@ public class OrganizationDataOwnershipPolicyValidator(
var userOrgIds = requirements
.Select(requirement => requirement.GetDefaultCollectionRequestOnPolicyEnable(policyUpdate.OrganizationId))
.Where(request => request.ShouldCreateDefaultCollection)
- .Select(request => request.OrganizationUserId);
+ .Select(request => request.OrganizationUserId)
+ .ToList();
if (!userOrgIds.Any())
{
return;
}
- await collectionRepository.UpsertDefaultCollectionsAsync(
+ await collectionRepository.CreateDefaultCollectionsBulkAsync(
policyUpdate.OrganizationId,
userOrgIds,
defaultCollectionName);
diff --git a/src/Core/Billing/Extensions/InvoiceExtensions.cs b/src/Core/Billing/Extensions/InvoiceExtensions.cs
index d62959c09a..774b6b93b2 100644
--- a/src/Core/Billing/Extensions/InvoiceExtensions.cs
+++ b/src/Core/Billing/Extensions/InvoiceExtensions.cs
@@ -1,4 +1,5 @@
-using System.Text.RegularExpressions;
+using System.Globalization;
+using System.Text.RegularExpressions;
using Stripe;
namespace Bit.Core.Billing.Extensions;
@@ -51,7 +52,7 @@ public static class InvoiceExtensions
if (string.IsNullOrEmpty(priceInfo) && line.Quantity > 0)
{
var pricePerItem = (line.Amount / 100m) / line.Quantity;
- priceInfo = $"(at ${pricePerItem:F2} / month)";
+ priceInfo = string.Format(CultureInfo.InvariantCulture, "(at ${0:F2} / month)", pricePerItem);
}
var taxDescription = $"{line.Quantity} × Tax {priceInfo}";
@@ -70,7 +71,7 @@ public static class InvoiceExtensions
if (tax > 0)
{
var taxAmount = tax / 100m;
- items.Add($"1 × Tax (at ${taxAmount:F2} / month)");
+ items.Add(string.Format(CultureInfo.InvariantCulture, "1 × Tax (at ${0:F2} / month)", taxAmount));
}
return items;
diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs
index 7cf00621c1..6f42778b6b 100644
--- a/src/Core/Constants.cs
+++ b/src/Core/Constants.cs
@@ -164,6 +164,7 @@ public static class FeatureFlagKeys
public const string MarketingInitiatedPremiumFlow = "pm-26140-marketing-initiated-premium-flow";
public const string RedirectOnSsoRequired = "pm-1632-redirect-on-sso-required";
public const string PrefetchPasswordPrelogin = "pm-23801-prefetch-password-prelogin";
+ public const string PM27086_UpdateAuthenticationApisForInputPassword = "pm-27086-update-authentication-apis-for-input-password";
/* Autofill Team */
public const string SSHAgent = "ssh-agent";
diff --git a/src/Core/Repositories/ICollectionRepository.cs b/src/Core/Repositories/ICollectionRepository.cs
index f86147ca7d..3f3b71d2d5 100644
--- a/src/Core/Repositories/ICollectionRepository.cs
+++ b/src/Core/Repositories/ICollectionRepository.cs
@@ -64,11 +64,22 @@ public interface ICollectionRepository : IRepository
IEnumerable users, IEnumerable groups);
///
- /// Creates default user collections for the specified organization users if they do not already have one.
+ /// Creates default user collections for the specified organization users.
+ /// Filters internally to only create collections for users who don't already have one.
///
/// The Organization ID.
/// The Organization User IDs to create default collections for.
/// The encrypted string to use as the default collection name.
- ///
- Task UpsertDefaultCollectionsAsync(Guid organizationId, IEnumerable organizationUserIds, string defaultCollectionName);
+ Task CreateDefaultCollectionsAsync(Guid organizationId, IEnumerable organizationUserIds, string defaultCollectionName);
+
+ ///
+ /// Creates default user collections for the specified organization users using bulk insert operations.
+ /// Use this if you need to create collections for > ~1k users.
+ /// Filters internally to only create collections for users who don't already have one.
+ ///
+ /// The Organization ID.
+ /// The Organization User IDs to create default collections for.
+ /// The encrypted string to use as the default collection name.
+ Task CreateDefaultCollectionsBulkAsync(Guid organizationId, IEnumerable organizationUserIds, string defaultCollectionName);
+
}
diff --git a/src/Infrastructure.Dapper/DapperHelpers.cs b/src/Infrastructure.Dapper/DapperHelpers.cs
index 9a119e1e32..4384a6f752 100644
--- a/src/Infrastructure.Dapper/DapperHelpers.cs
+++ b/src/Infrastructure.Dapper/DapperHelpers.cs
@@ -160,6 +160,21 @@ public static class DapperHelpers
return ids.ToArrayTVP("GuidId");
}
+ public static DataTable ToTwoGuidIdArrayTVP(this IEnumerable<(Guid id1, Guid id2)> values)
+ {
+ var table = new DataTable();
+ table.SetTypeName("[dbo].[TwoGuidIdArray]");
+ table.Columns.Add("Id1", typeof(Guid));
+ table.Columns.Add("Id2", typeof(Guid));
+
+ foreach (var value in values)
+ {
+ table.Rows.Add(value.id1, value.id2);
+ }
+
+ return table;
+ }
+
public static DataTable ToArrayTVP(this IEnumerable values, string columnName)
{
var table = new DataTable();
diff --git a/src/Infrastructure.Dapper/Repositories/CollectionRepository.cs b/src/Infrastructure.Dapper/Repositories/CollectionRepository.cs
index 9985b41d56..1531703427 100644
--- a/src/Infrastructure.Dapper/Repositories/CollectionRepository.cs
+++ b/src/Infrastructure.Dapper/Repositories/CollectionRepository.cs
@@ -1,6 +1,7 @@
using System.Data;
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
+using Bit.Core.AdminConsole.OrganizationFeatures.Collections;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Models.Data;
@@ -360,7 +361,45 @@ public class CollectionRepository : Repository, ICollectionRep
}
}
- public async Task UpsertDefaultCollectionsAsync(Guid organizationId, IEnumerable organizationUserIds, string defaultCollectionName)
+ public async Task CreateDefaultCollectionsAsync(Guid organizationId, IEnumerable organizationUserIds, string defaultCollectionName)
+ {
+ organizationUserIds = organizationUserIds.ToList();
+ if (!organizationUserIds.Any())
+ {
+ return;
+ }
+
+ var organizationUserCollectionIds = organizationUserIds
+ .Select(ou => (ou, CoreHelpers.GenerateComb()))
+ .ToTwoGuidIdArrayTVP();
+
+ await using var connection = new SqlConnection(ConnectionString);
+ await connection.OpenAsync();
+ await using var transaction = connection.BeginTransaction();
+
+ try
+ {
+ await connection.ExecuteAsync(
+ "[dbo].[Collection_CreateDefaultCollections]",
+ new
+ {
+ OrganizationId = organizationId,
+ DefaultCollectionName = defaultCollectionName,
+ OrganizationUserCollectionIds = organizationUserCollectionIds
+ },
+ commandType: CommandType.StoredProcedure,
+ transaction: transaction);
+
+ await transaction.CommitAsync();
+ }
+ catch
+ {
+ await transaction.RollbackAsync();
+ throw;
+ }
+ }
+
+ public async Task CreateDefaultCollectionsBulkAsync(Guid organizationId, IEnumerable organizationUserIds, string defaultCollectionName)
{
organizationUserIds = organizationUserIds.ToList();
if (!organizationUserIds.Any())
@@ -377,7 +416,8 @@ public class CollectionRepository : Repository, ICollectionRep
var missingDefaultCollectionUserIds = organizationUserIds.Except(orgUserIdWithDefaultCollection);
- var (collectionUsers, collections) = BuildDefaultCollectionForUsers(organizationId, missingDefaultCollectionUserIds, defaultCollectionName);
+ var (collections, collectionUsers) =
+ CollectionUtils.BuildDefaultUserCollections(organizationId, missingDefaultCollectionUserIds, defaultCollectionName);
if (!collectionUsers.Any() || !collections.Any())
{
@@ -387,11 +427,11 @@ public class CollectionRepository : Repository, ICollectionRep
await BulkResourceCreationService.CreateCollectionsAsync(connection, transaction, collections);
await BulkResourceCreationService.CreateCollectionsUsersAsync(connection, transaction, collectionUsers);
- transaction.Commit();
+ await transaction.CommitAsync();
}
catch
{
- transaction.Rollback();
+ await transaction.RollbackAsync();
throw;
}
}
@@ -421,40 +461,6 @@ public class CollectionRepository : Repository, ICollectionRep
return organizationUserIds.ToHashSet();
}
- private (List collectionUser, List collection) BuildDefaultCollectionForUsers(Guid organizationId, IEnumerable missingDefaultCollectionUserIds, string defaultCollectionName)
- {
- var collectionUsers = new List();
- var collections = new List();
-
- foreach (var orgUserId in missingDefaultCollectionUserIds)
- {
- var collectionId = CoreHelpers.GenerateComb();
-
- collections.Add(new Collection
- {
- Id = collectionId,
- OrganizationId = organizationId,
- Name = defaultCollectionName,
- CreationDate = DateTime.UtcNow,
- RevisionDate = DateTime.UtcNow,
- Type = CollectionType.DefaultUserCollection,
- DefaultUserCollectionEmail = null
-
- });
-
- collectionUsers.Add(new CollectionUser
- {
- CollectionId = collectionId,
- OrganizationUserId = orgUserId,
- ReadOnly = false,
- HidePasswords = false,
- Manage = true,
- });
- }
-
- return (collectionUsers, collections);
- }
-
public class CollectionWithGroupsAndUsers : Collection
{
public CollectionWithGroupsAndUsers() { }
diff --git a/src/Infrastructure.EntityFramework/Repositories/CollectionRepository.cs b/src/Infrastructure.EntityFramework/Repositories/CollectionRepository.cs
index 5aa156d1f8..74150246b1 100644
--- a/src/Infrastructure.EntityFramework/Repositories/CollectionRepository.cs
+++ b/src/Infrastructure.EntityFramework/Repositories/CollectionRepository.cs
@@ -1,11 +1,10 @@
using AutoMapper;
+using Bit.Core.AdminConsole.OrganizationFeatures.Collections;
using Bit.Core.Enums;
using Bit.Core.Models.Data;
using Bit.Core.Repositories;
-using Bit.Core.Utilities;
using Bit.Infrastructure.EntityFramework.Models;
using Bit.Infrastructure.EntityFramework.Repositories.Queries;
-using LinqToDB.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
@@ -794,7 +793,7 @@ public class CollectionRepository : Repository organizationUserIds, string defaultCollectionName)
+ public async Task CreateDefaultCollectionsAsync(Guid organizationId, IEnumerable organizationUserIds, string defaultCollectionName)
{
organizationUserIds = organizationUserIds.ToList();
if (!organizationUserIds.Any())
@@ -808,15 +807,15 @@ public class CollectionRepository : Repository>(collections));
+ await dbContext.CollectionUsers.AddRangeAsync(Mapper.Map>(collectionUsers));
await dbContext.SaveChangesAsync();
}
@@ -844,37 +843,7 @@ public class CollectionRepository : Repository collectionUser, List collection) BuildDefaultCollectionForUsers(Guid organizationId, IEnumerable missingDefaultCollectionUserIds, string defaultCollectionName)
- {
- var collectionUsers = new List();
- var collections = new List();
-
- foreach (var orgUserId in missingDefaultCollectionUserIds)
- {
- var collectionId = CoreHelpers.GenerateComb();
-
- collections.Add(new Collection
- {
- Id = collectionId,
- OrganizationId = organizationId,
- Name = defaultCollectionName,
- CreationDate = DateTime.UtcNow,
- RevisionDate = DateTime.UtcNow,
- Type = CollectionType.DefaultUserCollection,
- DefaultUserCollectionEmail = null
-
- });
-
- collectionUsers.Add(new CollectionUser
- {
- CollectionId = collectionId,
- OrganizationUserId = orgUserId,
- ReadOnly = false,
- HidePasswords = false,
- Manage = true,
- });
- }
-
- return (collectionUsers, collections);
- }
+ public Task CreateDefaultCollectionsBulkAsync(Guid organizationId, IEnumerable organizationUserIds,
+ string defaultCollectionName) =>
+ CreateDefaultCollectionsAsync(organizationId, organizationUserIds, defaultCollectionName);
}
diff --git a/src/Infrastructure.EntityFramework/Repositories/DatabaseContext.cs b/src/Infrastructure.EntityFramework/Repositories/DatabaseContext.cs
index 7b67a63912..a0ee0376c0 100644
--- a/src/Infrastructure.EntityFramework/Repositories/DatabaseContext.cs
+++ b/src/Infrastructure.EntityFramework/Repositories/DatabaseContext.cs
@@ -17,8 +17,6 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using DP = Microsoft.AspNetCore.DataProtection;
-#nullable enable
-
namespace Bit.Infrastructure.EntityFramework.Repositories;
public class DatabaseContext : DbContext
diff --git a/src/Sql/dbo/AdminConsole/Stored Procedures/Collection_CreateDefaultCollections.sql b/src/Sql/dbo/AdminConsole/Stored Procedures/Collection_CreateDefaultCollections.sql
new file mode 100644
index 0000000000..4e671bd1e4
--- /dev/null
+++ b/src/Sql/dbo/AdminConsole/Stored Procedures/Collection_CreateDefaultCollections.sql
@@ -0,0 +1,69 @@
+-- Creates default user collections for organization users
+-- Filters out existing default collections at database level
+CREATE PROCEDURE [dbo].[Collection_CreateDefaultCollections]
+ @OrganizationId UNIQUEIDENTIFIER,
+ @DefaultCollectionName VARCHAR(MAX),
+ @OrganizationUserCollectionIds AS [dbo].[TwoGuidIdArray] READONLY -- OrganizationUserId, CollectionId
+AS
+BEGIN
+ SET NOCOUNT ON
+
+ DECLARE @Now DATETIME2(7) = GETUTCDATE()
+
+ -- Filter to only users who don't have default collections
+ SELECT ids.Id1, ids.Id2
+ INTO #FilteredIds
+ FROM @OrganizationUserCollectionIds ids
+ WHERE NOT EXISTS (
+ SELECT 1
+ FROM [dbo].[CollectionUser] cu
+ INNER JOIN [dbo].[Collection] c ON c.Id = cu.CollectionId
+ WHERE c.OrganizationId = @OrganizationId
+ AND c.[Type] = 1 -- CollectionType.DefaultUserCollection
+ AND cu.OrganizationUserId = ids.Id1
+ );
+
+ -- Insert collections only for users who don't have default collections yet
+ INSERT INTO [dbo].[Collection]
+ (
+ [Id],
+ [OrganizationId],
+ [Name],
+ [CreationDate],
+ [RevisionDate],
+ [Type],
+ [ExternalId],
+ [DefaultUserCollectionEmail]
+ )
+ SELECT
+ ids.Id2, -- CollectionId
+ @OrganizationId,
+ @DefaultCollectionName,
+ @Now,
+ @Now,
+ 1, -- CollectionType.DefaultUserCollection
+ NULL,
+ NULL
+ FROM
+ #FilteredIds ids;
+
+ -- Insert collection user mappings
+ INSERT INTO [dbo].[CollectionUser]
+ (
+ [CollectionId],
+ [OrganizationUserId],
+ [ReadOnly],
+ [HidePasswords],
+ [Manage]
+ )
+ SELECT
+ ids.Id2, -- CollectionId
+ ids.Id1, -- OrganizationUserId
+ 0, -- ReadOnly = false
+ 0, -- HidePasswords = false
+ 1 -- Manage = true
+ FROM
+ #FilteredIds ids;
+
+ DROP TABLE #FilteredIds;
+END
diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/AutoConfirmUsers/AutomaticallyConfirmUsersCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/AutoConfirmUsers/AutomaticallyConfirmUsersCommandTests.cs
index 180750a9d0..252fb89c87 100644
--- a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/AutoConfirmUsers/AutomaticallyConfirmUsersCommandTests.cs
+++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/AutoConfirmUsers/AutomaticallyConfirmUsersCommandTests.cs
@@ -10,7 +10,6 @@ using Bit.Core.AdminConsole.Utilities.v2;
using Bit.Core.AdminConsole.Utilities.v2.Validation;
using Bit.Core.Entities;
using Bit.Core.Enums;
-using Bit.Core.Models.Data;
using Bit.Core.Platform.Push;
using Bit.Core.Repositories;
using Bit.Core.Services;
@@ -204,14 +203,10 @@ public class AutomaticallyConfirmUsersCommandTests
await sutProvider.GetDependency()
.Received(1)
- .CreateAsync(
- Arg.Is(c =>
- c.OrganizationId == organization.Id &&
- c.Name == defaultCollectionName &&
- c.Type == CollectionType.DefaultUserCollection),
- Arg.Is>(groups => groups == null),
- Arg.Is>(access =>
- access.FirstOrDefault(x => x.Id == organizationUser.Id && x.Manage) != null));
+ .CreateDefaultCollectionsAsync(
+ organization.Id,
+ Arg.Is>(ids => ids.Single() == organizationUser.Id),
+ defaultCollectionName);
}
[Theory]
@@ -253,9 +248,7 @@ public class AutomaticallyConfirmUsersCommandTests
await sutProvider.GetDependency()
.DidNotReceive()
- .CreateAsync(Arg.Any(),
- Arg.Any>(),
- Arg.Any>());
+ .CreateDefaultCollectionsAsync(Arg.Any(), Arg.Any>(), Arg.Any());
}
[Theory]
@@ -291,9 +284,7 @@ public class AutomaticallyConfirmUsersCommandTests
var collectionException = new Exception("Collection creation failed");
sutProvider.GetDependency()
- .CreateAsync(Arg.Any(),
- Arg.Any>(),
- Arg.Any>())
+ .CreateDefaultCollectionsAsync(Arg.Any(), Arg.Any>(), Arg.Any())
.ThrowsAsync(collectionException);
// Act
diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommandTests.cs
index 65359b8304..6643f26eb5 100644
--- a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommandTests.cs
+++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommandTests.cs
@@ -13,7 +13,6 @@ using Bit.Core.Billing.Enums;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
-using Bit.Core.Models.Data;
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
using Bit.Core.Platform.Push;
using Bit.Core.Repositories;
@@ -493,15 +492,10 @@ public class ConfirmOrganizationUserCommandTests
await sutProvider.GetDependency()
.Received(1)
- .CreateAsync(
- Arg.Is(c =>
- c.Name == collectionName &&
- c.OrganizationId == organization.Id &&
- c.Type == CollectionType.DefaultUserCollection),
- Arg.Any>(),
- Arg.Is>(cu =>
- cu.Single().Id == orgUser.Id &&
- cu.Single().Manage));
+ .CreateDefaultCollectionsAsync(
+ organization.Id,
+ Arg.Is>(ids => ids.Single() == orgUser.Id),
+ collectionName);
}
[Theory, BitAutoData]
@@ -522,7 +516,7 @@ public class ConfirmOrganizationUserCommandTests
await sutProvider.GetDependency()
.DidNotReceive()
- .UpsertDefaultCollectionsAsync(Arg.Any(), Arg.Any>(), Arg.Any());
+ .CreateDefaultCollectionsAsync(Arg.Any(), Arg.Any>(), Arg.Any());
}
[Theory, BitAutoData]
@@ -539,24 +533,15 @@ public class ConfirmOrganizationUserCommandTests
sutProvider.GetDependency().GetManyAsync(default).ReturnsForAnyArgs(new[] { orgUser });
sutProvider.GetDependency().GetManyAsync(default).ReturnsForAnyArgs(new[] { user });
- var policyDetails = new PolicyDetails
- {
- OrganizationId = org.Id,
- OrganizationUserId = orgUser.Id,
- IsProvider = false,
- OrganizationUserStatus = orgUser.Status,
- OrganizationUserType = orgUser.Type,
- PolicyType = PolicyType.OrganizationDataOwnership
- };
sutProvider.GetDependency()
.GetAsync(orgUser.UserId!.Value)
- .Returns(new OrganizationDataOwnershipPolicyRequirement(OrganizationDataOwnershipState.Disabled, [policyDetails]));
+ .Returns(new OrganizationDataOwnershipPolicyRequirement(OrganizationDataOwnershipState.Disabled, []));
await sutProvider.Sut.ConfirmUserAsync(orgUser.OrganizationId, orgUser.Id, key, confirmingUser.Id, collectionName);
await sutProvider.GetDependency()
.DidNotReceive()
- .UpsertDefaultCollectionsAsync(Arg.Any(), Arg.Any>(), Arg.Any());
+ .CreateDefaultCollectionsAsync(Arg.Any(), Arg.Any>(), Arg.Any());
}
[Theory, BitAutoData]
diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/OrganizationDataOwnershipPolicyValidatorTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/OrganizationDataOwnershipPolicyValidatorTests.cs
index 93cbde89ec..dd2f1d76e8 100644
--- a/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/OrganizationDataOwnershipPolicyValidatorTests.cs
+++ b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/OrganizationDataOwnershipPolicyValidatorTests.cs
@@ -38,7 +38,7 @@ public class OrganizationDataOwnershipPolicyValidatorTests
// Assert
await sutProvider.GetDependency()
.DidNotReceive()
- .UpsertDefaultCollectionsAsync(Arg.Any(), Arg.Any>(), Arg.Any());
+ .CreateDefaultCollectionsBulkAsync(Arg.Any(), Arg.Any>(), Arg.Any());
}
[Theory, BitAutoData]
@@ -60,7 +60,7 @@ public class OrganizationDataOwnershipPolicyValidatorTests
// Assert
await sutProvider.GetDependency()
.DidNotReceive()
- .UpsertDefaultCollectionsAsync(Arg.Any(), Arg.Any>(), Arg.Any());
+ .CreateDefaultCollectionsBulkAsync(Arg.Any(), Arg.Any>(), Arg.Any());
}
[Theory, BitAutoData]
@@ -86,7 +86,7 @@ public class OrganizationDataOwnershipPolicyValidatorTests
// Assert
await collectionRepository
.DidNotReceive()
- .UpsertDefaultCollectionsAsync(
+ .CreateDefaultCollectionsBulkAsync(
Arg.Any(),
Arg.Any>(),
Arg.Any());
@@ -172,10 +172,10 @@ public class OrganizationDataOwnershipPolicyValidatorTests
// Act
await sut.ExecuteSideEffectsAsync(policyRequest, postUpdatedPolicy, previousPolicyState);
- // Assert
+ // Assert - Should call with all user IDs (repository does internal filtering)
await collectionRepository
.Received(1)
- .UpsertDefaultCollectionsAsync(
+ .CreateDefaultCollectionsBulkAsync(
policyUpdate.OrganizationId,
Arg.Is>(ids => ids.Count() == 3),
_defaultUserCollectionName);
@@ -210,7 +210,7 @@ public class OrganizationDataOwnershipPolicyValidatorTests
// Assert
await sutProvider.GetDependency()
.DidNotReceive()
- .UpsertDefaultCollectionsAsync(Arg.Any(), Arg.Any>(), Arg.Any());
+ .CreateDefaultCollectionsBulkAsync(Arg.Any(), Arg.Any>(), Arg.Any());
}
private static IPolicyRepository ArrangePolicyRepository(IEnumerable policyDetails)
@@ -251,7 +251,7 @@ public class OrganizationDataOwnershipPolicyValidatorTests
// Assert
await sutProvider.GetDependency()
.DidNotReceiveWithAnyArgs()
- .UpsertDefaultCollectionsAsync(default, default, default);
+ .CreateDefaultCollectionsBulkAsync(default, default, default);
}
[Theory, BitAutoData]
@@ -273,7 +273,7 @@ public class OrganizationDataOwnershipPolicyValidatorTests
// Assert
await sutProvider.GetDependency()
.DidNotReceiveWithAnyArgs()
- .UpsertDefaultCollectionsAsync(default, default, default);
+ .CreateDefaultCollectionsBulkAsync(default, default, default);
}
[Theory, BitAutoData]
@@ -299,7 +299,7 @@ public class OrganizationDataOwnershipPolicyValidatorTests
// Assert
await collectionRepository
.DidNotReceiveWithAnyArgs()
- .UpsertDefaultCollectionsAsync(
+ .CreateDefaultCollectionsBulkAsync(
default,
default,
default);
@@ -336,10 +336,10 @@ public class OrganizationDataOwnershipPolicyValidatorTests
// Act
await sut.ExecutePostUpsertSideEffectAsync(policyRequest, postUpdatedPolicy, previousPolicyState);
- // Assert
+ // Assert - Should call with all user IDs (repository does internal filtering)
await collectionRepository
.Received(1)
- .UpsertDefaultCollectionsAsync(
+ .CreateDefaultCollectionsBulkAsync(
policyUpdate.OrganizationId,
Arg.Is>(ids => ids.Count() == 3),
_defaultUserCollectionName);
@@ -367,6 +367,6 @@ public class OrganizationDataOwnershipPolicyValidatorTests
// Assert
await sutProvider.GetDependency()
.DidNotReceiveWithAnyArgs()
- .UpsertDefaultCollectionsAsync(default, default, default);
+ .CreateDefaultCollectionsBulkAsync(default, default, default);
}
}
diff --git a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/CollectionRepository/CreateDefaultCollectionsBulkTests.cs b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/CollectionRepository/CreateDefaultCollectionsBulkTests.cs
new file mode 100644
index 0000000000..712ad7d62e
--- /dev/null
+++ b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/CollectionRepository/CreateDefaultCollectionsBulkTests.cs
@@ -0,0 +1,53 @@
+using Bit.Core.Repositories;
+using Xunit;
+
+namespace Bit.Infrastructure.IntegrationTest.AdminConsole.Repositories.CollectionRepository;
+
+
+public class CreateDefaultCollectionsBulkAsyncTests
+{
+ [Theory, DatabaseData]
+ public async Task CreateDefaultCollectionsBulkAsync_CreatesDefaultCollections_Success(
+ IOrganizationRepository organizationRepository,
+ IUserRepository userRepository,
+ IOrganizationUserRepository organizationUserRepository,
+ ICollectionRepository collectionRepository)
+ {
+ await CreateDefaultCollectionsSharedTests.CreatesDefaultCollections_Success(
+ collectionRepository.CreateDefaultCollectionsBulkAsync,
+ organizationRepository,
+ userRepository,
+ organizationUserRepository,
+ collectionRepository);
+ }
+
+ [Theory, DatabaseData]
+ public async Task CreateDefaultCollectionsBulkAsync_CreatesForNewUsersOnly_AndIgnoresExistingUsers(
+ IOrganizationRepository organizationRepository,
+ IUserRepository userRepository,
+ IOrganizationUserRepository organizationUserRepository,
+ ICollectionRepository collectionRepository)
+ {
+ await CreateDefaultCollectionsSharedTests.CreatesForNewUsersOnly_AndIgnoresExistingUsers(
+ collectionRepository.CreateDefaultCollectionsBulkAsync,
+ organizationRepository,
+ userRepository,
+ organizationUserRepository,
+ collectionRepository);
+ }
+
+ [Theory, DatabaseData]
+ public async Task CreateDefaultCollectionsBulkAsync_IgnoresAllExistingUsers(
+ IOrganizationRepository organizationRepository,
+ IUserRepository userRepository,
+ IOrganizationUserRepository organizationUserRepository,
+ ICollectionRepository collectionRepository)
+ {
+ await CreateDefaultCollectionsSharedTests.IgnoresAllExistingUsers(
+ collectionRepository.CreateDefaultCollectionsBulkAsync,
+ organizationRepository,
+ userRepository,
+ organizationUserRepository,
+ collectionRepository);
+ }
+}
diff --git a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/CollectionRepository/UpsertDefaultCollectionsTests.cs b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/CollectionRepository/CreateDefaultCollectionsSharedTests.cs
similarity index 69%
rename from test/Infrastructure.IntegrationTest/AdminConsole/Repositories/CollectionRepository/UpsertDefaultCollectionsTests.cs
rename to test/Infrastructure.IntegrationTest/AdminConsole/Repositories/CollectionRepository/CreateDefaultCollectionsSharedTests.cs
index 64dffa473f..0fb4a5b446 100644
--- a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/CollectionRepository/UpsertDefaultCollectionsTests.cs
+++ b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/CollectionRepository/CreateDefaultCollectionsSharedTests.cs
@@ -6,10 +6,14 @@ using Xunit;
namespace Bit.Infrastructure.IntegrationTest.AdminConsole.Repositories.CollectionRepository;
-public class UpsertDefaultCollectionsTests
+///
+/// Shared tests for CreateDefaultCollections methods - both bulk and non-bulk implementations,
+/// as they share the same behavior. Both test suites call the tests in this class.
+///
+public static class CreateDefaultCollectionsSharedTests
{
- [Theory, DatabaseData]
- public async Task UpsertDefaultCollectionsAsync_ShouldCreateDefaultCollection_WhenUsersDoNotHaveDefaultCollection(
+ public static async Task CreatesDefaultCollections_Success(
+ Func, string, Task> createDefaultCollectionsFunc,
IOrganizationRepository organizationRepository,
IUserRepository userRepository,
IOrganizationUserRepository organizationUserRepository,
@@ -21,14 +25,13 @@ public class UpsertDefaultCollectionsTests
var resultOrganizationUsers = await Task.WhenAll(
CreateUserForOrgAsync(userRepository, organizationUserRepository, organization),
CreateUserForOrgAsync(userRepository, organizationUserRepository, organization)
- );
+ );
-
- var affectedOrgUserIds = resultOrganizationUsers.Select(organizationUser => organizationUser.Id);
+ var affectedOrgUserIds = resultOrganizationUsers.Select(organizationUser => organizationUser.Id).ToList();
var defaultCollectionName = $"default-name-{organization.Id}";
// Act
- await collectionRepository.UpsertDefaultCollectionsAsync(organization.Id, affectedOrgUserIds, defaultCollectionName);
+ await createDefaultCollectionsFunc(organization.Id, affectedOrgUserIds, defaultCollectionName);
// Assert
await AssertAllUsersHaveOneDefaultCollectionAsync(collectionRepository, resultOrganizationUsers, organization.Id);
@@ -36,8 +39,8 @@ public class UpsertDefaultCollectionsTests
await CleanupAsync(organizationRepository, userRepository, organization, resultOrganizationUsers);
}
- [Theory, DatabaseData]
- public async Task UpsertDefaultCollectionsAsync_ShouldUpsertCreateDefaultCollection_ForUsersWithAndWithoutDefaultCollectionsExist(
+ public static async Task CreatesForNewUsersOnly_AndIgnoresExistingUsers(
+ Func, string, Task> createDefaultCollectionsFunc,
IOrganizationRepository organizationRepository,
IUserRepository userRepository,
IOrganizationUserRepository organizationUserRepository,
@@ -51,31 +54,30 @@ public class UpsertDefaultCollectionsTests
CreateUserForOrgAsync(userRepository, organizationUserRepository, organization)
);
- var arrangedOrgUserIds = arrangedOrganizationUsers.Select(organizationUser => organizationUser.Id);
+ var arrangedOrgUserIds = arrangedOrganizationUsers.Select(organizationUser => organizationUser.Id).ToList();
var defaultCollectionName = $"default-name-{organization.Id}";
+ await CreateUsersWithExistingDefaultCollectionsAsync(createDefaultCollectionsFunc, collectionRepository, organization.Id, arrangedOrgUserIds, defaultCollectionName, arrangedOrganizationUsers);
- await CreateUsersWithExistingDefaultCollectionsAsync(collectionRepository, organization.Id, arrangedOrgUserIds, defaultCollectionName, arrangedOrganizationUsers);
-
- var newOrganizationUsers = new List()
+ var newOrganizationUsers = new List
{
await CreateUserForOrgAsync(userRepository, organizationUserRepository, organization)
};
- var affectedOrgUsers = newOrganizationUsers.Concat(arrangedOrganizationUsers);
- var affectedOrgUserIds = affectedOrgUsers.Select(organizationUser => organizationUser.Id);
+ var affectedOrgUsers = newOrganizationUsers.Concat(arrangedOrganizationUsers).ToList();
+ var affectedOrgUserIds = affectedOrgUsers.Select(organizationUser => organizationUser.Id).ToList();
// Act
- await collectionRepository.UpsertDefaultCollectionsAsync(organization.Id, affectedOrgUserIds, defaultCollectionName);
+ await createDefaultCollectionsFunc(organization.Id, affectedOrgUserIds, defaultCollectionName);
// Assert
- await AssertAllUsersHaveOneDefaultCollectionAsync(collectionRepository, arrangedOrganizationUsers, organization.Id);
+ await AssertAllUsersHaveOneDefaultCollectionAsync(collectionRepository, affectedOrgUsers, organization.Id);
await CleanupAsync(organizationRepository, userRepository, organization, affectedOrgUsers);
}
- [Theory, DatabaseData]
- public async Task UpsertDefaultCollectionsAsync_ShouldNotCreateDefaultCollection_WhenUsersAlreadyHaveOne(
+ public static async Task IgnoresAllExistingUsers(
+ Func, string, Task> createDefaultCollectionsFunc,
IOrganizationRepository organizationRepository,
IUserRepository userRepository,
IOrganizationUserRepository organizationUserRepository,
@@ -89,26 +91,29 @@ public class UpsertDefaultCollectionsTests
CreateUserForOrgAsync(userRepository, organizationUserRepository, organization)
);
- var affectedOrgUserIds = resultOrganizationUsers.Select(organizationUser => organizationUser.Id);
+ var affectedOrgUserIds = resultOrganizationUsers.Select(organizationUser => organizationUser.Id).ToList();
var defaultCollectionName = $"default-name-{organization.Id}";
+ await CreateUsersWithExistingDefaultCollectionsAsync(createDefaultCollectionsFunc, collectionRepository, organization.Id, affectedOrgUserIds, defaultCollectionName, resultOrganizationUsers);
- await CreateUsersWithExistingDefaultCollectionsAsync(collectionRepository, organization.Id, affectedOrgUserIds, defaultCollectionName, resultOrganizationUsers);
+ // Act - Try to create again, should silently filter and not create duplicates
+ await createDefaultCollectionsFunc(organization.Id, affectedOrgUserIds, defaultCollectionName);
- // Act
- await collectionRepository.UpsertDefaultCollectionsAsync(organization.Id, affectedOrgUserIds, defaultCollectionName);
-
- // Assert
+ // Assert - Original collections should remain unchanged, still only one per user
await AssertAllUsersHaveOneDefaultCollectionAsync(collectionRepository, resultOrganizationUsers, organization.Id);
await CleanupAsync(organizationRepository, userRepository, organization, resultOrganizationUsers);
}
- private static async Task CreateUsersWithExistingDefaultCollectionsAsync(ICollectionRepository collectionRepository,
- Guid organizationId, IEnumerable affectedOrgUserIds, string defaultCollectionName,
+ private static async Task CreateUsersWithExistingDefaultCollectionsAsync(
+ Func, string, Task> createDefaultCollectionsFunc,
+ ICollectionRepository collectionRepository,
+ Guid organizationId,
+ IEnumerable affectedOrgUserIds,
+ string defaultCollectionName,
OrganizationUser[] resultOrganizationUsers)
{
- await collectionRepository.UpsertDefaultCollectionsAsync(organizationId, affectedOrgUserIds, defaultCollectionName);
+ await createDefaultCollectionsFunc(organizationId, affectedOrgUserIds, defaultCollectionName);
await AssertAllUsersHaveOneDefaultCollectionAsync(collectionRepository, resultOrganizationUsers, organizationId);
}
@@ -131,7 +136,6 @@ public class UpsertDefaultCollectionsTests
private static async Task CreateUserForOrgAsync(IUserRepository userRepository,
IOrganizationUserRepository organizationUserRepository, Organization organization)
{
-
var user = await userRepository.CreateTestUserAsync();
var orgUser = await organizationUserRepository.CreateTestOrganizationUserAsync(organization, user);
diff --git a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/CollectionRepository/CreateDefaultCollectionsTests.cs b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/CollectionRepository/CreateDefaultCollectionsTests.cs
new file mode 100644
index 0000000000..bd894e9ca5
--- /dev/null
+++ b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/CollectionRepository/CreateDefaultCollectionsTests.cs
@@ -0,0 +1,52 @@
+using Bit.Core.Repositories;
+using Xunit;
+
+namespace Bit.Infrastructure.IntegrationTest.AdminConsole.Repositories.CollectionRepository;
+
+public class CreateDefaultCollectionsAsyncTests
+{
+ [Theory, DatabaseData]
+ public async Task CreateDefaultCollectionsAsync_CreatesDefaultCollections_Success(
+ IOrganizationRepository organizationRepository,
+ IUserRepository userRepository,
+ IOrganizationUserRepository organizationUserRepository,
+ ICollectionRepository collectionRepository)
+ {
+ await CreateDefaultCollectionsSharedTests.CreatesDefaultCollections_Success(
+ collectionRepository.CreateDefaultCollectionsAsync,
+ organizationRepository,
+ userRepository,
+ organizationUserRepository,
+ collectionRepository);
+ }
+
+ [Theory, DatabaseData]
+ public async Task CreateDefaultCollectionsAsync_CreatesForNewUsersOnly_AndIgnoresExistingUsers(
+ IOrganizationRepository organizationRepository,
+ IUserRepository userRepository,
+ IOrganizationUserRepository organizationUserRepository,
+ ICollectionRepository collectionRepository)
+ {
+ await CreateDefaultCollectionsSharedTests.CreatesForNewUsersOnly_AndIgnoresExistingUsers(
+ collectionRepository.CreateDefaultCollectionsAsync,
+ organizationRepository,
+ userRepository,
+ organizationUserRepository,
+ collectionRepository);
+ }
+
+ [Theory, DatabaseData]
+ public async Task CreateDefaultCollectionsAsync_IgnoresAllExistingUsers(
+ IOrganizationRepository organizationRepository,
+ IUserRepository userRepository,
+ IOrganizationUserRepository organizationUserRepository,
+ ICollectionRepository collectionRepository)
+ {
+ await CreateDefaultCollectionsSharedTests.IgnoresAllExistingUsers(
+ collectionRepository.CreateDefaultCollectionsAsync,
+ organizationRepository,
+ userRepository,
+ organizationUserRepository,
+ collectionRepository);
+ }
+}
diff --git a/test/Server.IntegrationTest/Properties/AssemblyInfo.cs b/test/Server.IntegrationTest/Properties/AssemblyInfo.cs
new file mode 100644
index 0000000000..80afc76e2e
--- /dev/null
+++ b/test/Server.IntegrationTest/Properties/AssemblyInfo.cs
@@ -0,0 +1 @@
+[assembly: CaptureTrace]
diff --git a/test/Server.IntegrationTest/Server.IntegrationTest.csproj b/test/Server.IntegrationTest/Server.IntegrationTest.csproj
new file mode 100644
index 0000000000..362ada84a0
--- /dev/null
+++ b/test/Server.IntegrationTest/Server.IntegrationTest.csproj
@@ -0,0 +1,23 @@
+
+
+
+ Exe
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/test/Server.IntegrationTest/Server.cs b/test/Server.IntegrationTest/Server.cs
new file mode 100644
index 0000000000..073dbffb5a
--- /dev/null
+++ b/test/Server.IntegrationTest/Server.cs
@@ -0,0 +1,45 @@
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.AspNetCore.Mvc.Testing;
+using Microsoft.AspNetCore.TestHost;
+
+namespace Bit.Server.IntegrationTest;
+
+public class Server : WebApplicationFactory
+{
+ public string? ContentRoot { get; set; }
+ public string? WebRoot { get; set; }
+ public bool ServeUnknown { get; set; }
+ public bool? WebVault { get; set; }
+ public string? AppIdLocation { get; set; }
+
+ protected override IWebHostBuilder? CreateWebHostBuilder()
+ {
+ var args = new List
+ {
+ "/contentRoot",
+ ContentRoot ?? "",
+ "/webRoot",
+ WebRoot ?? "",
+ "/serveUnknown",
+ ServeUnknown.ToString().ToLowerInvariant(),
+ };
+
+ if (WebVault.HasValue)
+ {
+ args.Add("/webVault");
+ args.Add(WebVault.Value.ToString().ToLowerInvariant());
+ }
+
+ if (!string.IsNullOrEmpty(AppIdLocation))
+ {
+ args.Add("/appIdLocation");
+ args.Add(AppIdLocation);
+ }
+
+ var builder = WebHostBuilderFactory.CreateFromTypesAssemblyEntryPoint([.. args])
+ ?? throw new InvalidProgramException("Could not create builder from assembly.");
+
+ builder.UseSetting("TEST_CONTENTROOT_SERVER", ContentRoot);
+ return builder;
+ }
+}
diff --git a/test/Server.IntegrationTest/ServerTests.cs b/test/Server.IntegrationTest/ServerTests.cs
new file mode 100644
index 0000000000..e432f53775
--- /dev/null
+++ b/test/Server.IntegrationTest/ServerTests.cs
@@ -0,0 +1,102 @@
+using System.Net;
+using System.Runtime.CompilerServices;
+
+namespace Bit.Server.IntegrationTest;
+
+public class ServerTests
+{
+ [Fact]
+ public async Task AttachmentsStyleUse()
+ {
+ using var tempDir = new TempDir();
+
+ await tempDir.WriteAsync("my-file.txt", "Hello!");
+
+ using var server = new Server
+ {
+ ContentRoot = tempDir.Info.FullName,
+ WebRoot = ".",
+ ServeUnknown = true,
+ };
+
+ var client = server.CreateClient();
+
+ var response = await client.GetAsync("/my-file.txt", TestContext.Current.CancellationToken);
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ Assert.Equal("Hello!", await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken));
+ }
+
+ [Fact]
+ public async Task WebVaultStyleUse()
+ {
+ using var tempDir = new TempDir();
+
+ await tempDir.WriteAsync("index.html", "");
+ await tempDir.WriteAsync(Path.Join("app", "file.js"), "AppStuff");
+ await tempDir.WriteAsync(Path.Join("locales", "file.json"), "LocalesStuff");
+ await tempDir.WriteAsync(Path.Join("fonts", "file.ttf"), "FontsStuff");
+ await tempDir.WriteAsync(Path.Join("connectors", "file.js"), "ConnectorsStuff");
+ await tempDir.WriteAsync(Path.Join("scripts", "file.js"), "ScriptsStuff");
+ await tempDir.WriteAsync(Path.Join("images", "file.avif"), "ImagesStuff");
+ await tempDir.WriteAsync(Path.Join("test", "file.json"), "{}");
+
+ using var server = new Server
+ {
+ ContentRoot = tempDir.Info.FullName,
+ WebRoot = ".",
+ ServeUnknown = false,
+ WebVault = true,
+ AppIdLocation = Path.Join(tempDir.Info.FullName, "test", "file.json"),
+ };
+
+ var client = server.CreateClient();
+
+ // Going to root should return the default file
+ var response = await client.GetAsync("", TestContext.Current.CancellationToken);
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ Assert.Equal("", await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken));
+ // No caching on the default document
+ Assert.Null(response.Headers.CacheControl?.MaxAge);
+
+ await ExpectMaxAgeAsync("app/file.js", TimeSpan.FromDays(14));
+ await ExpectMaxAgeAsync("locales/file.json", TimeSpan.FromDays(14));
+ await ExpectMaxAgeAsync("fonts/file.ttf", TimeSpan.FromDays(14));
+ await ExpectMaxAgeAsync("connectors/file.js", TimeSpan.FromDays(14));
+ await ExpectMaxAgeAsync("scripts/file.js", TimeSpan.FromDays(14));
+ await ExpectMaxAgeAsync("images/file.avif", TimeSpan.FromDays(7));
+
+ response = await client.GetAsync("app-id.json", TestContext.Current.CancellationToken);
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ Assert.Equal("application/json", response.Content.Headers.ContentType?.MediaType);
+
+ async Task ExpectMaxAgeAsync(string path, TimeSpan maxAge)
+ {
+ response = await client.GetAsync(path);
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ Assert.NotNull(response.Headers.CacheControl);
+ Assert.Equal(maxAge, response.Headers.CacheControl.MaxAge);
+ }
+ }
+
+ private class TempDir([CallerMemberName] string test = null!) : IDisposable
+ {
+ public DirectoryInfo Info { get; } = Directory.CreateTempSubdirectory(test);
+
+ public void Dispose()
+ {
+ Info.Delete(recursive: true);
+ }
+
+ public async Task WriteAsync(string fileName, string content)
+ {
+ var fullPath = Path.Join(Info.FullName, fileName);
+ var directory = Path.GetDirectoryName(fullPath);
+ if (directory != null)
+ {
+ Directory.CreateDirectory(directory);
+ }
+
+ await File.WriteAllTextAsync(fullPath, content, TestContext.Current.CancellationToken);
+ }
+ }
+}
diff --git a/util/Migrator/DbScripts/2026-01-13_00_Collection_CreateDefaultCollections.sql b/util/Migrator/DbScripts/2026-01-13_00_Collection_CreateDefaultCollections.sql
new file mode 100644
index 0000000000..c7935db5e8
--- /dev/null
+++ b/util/Migrator/DbScripts/2026-01-13_00_Collection_CreateDefaultCollections.sql
@@ -0,0 +1,70 @@
+-- Creates default user collections for organization users
+-- Filters out existing default collections at database level
+CREATE OR ALTER PROCEDURE [dbo].[Collection_CreateDefaultCollections]
+ @OrganizationId UNIQUEIDENTIFIER,
+ @DefaultCollectionName VARCHAR(MAX),
+ @OrganizationUserCollectionIds AS [dbo].[TwoGuidIdArray] READONLY -- OrganizationUserId, CollectionId
+AS
+BEGIN
+ SET NOCOUNT ON
+
+ DECLARE @Now DATETIME2(7) = GETUTCDATE()
+
+ -- Filter to only users who don't have default collections
+ SELECT ids.Id1, ids.Id2
+ INTO #FilteredIds
+ FROM @OrganizationUserCollectionIds ids
+ WHERE NOT EXISTS (
+ SELECT 1
+ FROM [dbo].[CollectionUser] cu
+ INNER JOIN [dbo].[Collection] c ON c.Id = cu.CollectionId
+ WHERE c.OrganizationId = @OrganizationId
+ AND c.[Type] = 1 -- CollectionType.DefaultUserCollection
+ AND cu.OrganizationUserId = ids.Id1
+ );
+
+ -- Insert collections only for users who don't have default collections yet
+ INSERT INTO [dbo].[Collection]
+ (
+ [Id],
+ [OrganizationId],
+ [Name],
+ [CreationDate],
+ [RevisionDate],
+ [Type],
+ [ExternalId],
+ [DefaultUserCollectionEmail]
+ )
+ SELECT
+ ids.Id2, -- CollectionId
+ @OrganizationId,
+ @DefaultCollectionName,
+ @Now,
+ @Now,
+ 1, -- CollectionType.DefaultUserCollection
+ NULL,
+ NULL
+ FROM
+ #FilteredIds ids;
+
+ -- Insert collection user mappings
+ INSERT INTO [dbo].[CollectionUser]
+ (
+ [CollectionId],
+ [OrganizationUserId],
+ [ReadOnly],
+ [HidePasswords],
+ [Manage]
+ )
+ SELECT
+ ids.Id2, -- CollectionId
+ ids.Id1, -- OrganizationUserId
+ 0, -- ReadOnly = false
+ 0, -- HidePasswords = false
+ 1 -- Manage = true
+ FROM
+ #FilteredIds ids;
+
+ DROP TABLE #FilteredIds;
+END
+GO
diff --git a/util/Server/Program.cs b/util/Server/Program.cs
index a2d7e5f687..3d563830ab 100644
--- a/util/Server/Program.cs
+++ b/util/Server/Program.cs
@@ -6,6 +6,13 @@ namespace Bit.Server;
public class Program
{
public static void Main(string[] args)
+ {
+ var builder = CreateWebHostBuilder(args);
+ var host = builder.Build();
+ host.Run();
+ }
+
+ public static IWebHostBuilder CreateWebHostBuilder(string[] args)
{
var config = new ConfigurationBuilder()
.AddCommandLine(args)
@@ -37,7 +44,6 @@ public class Program
builder.UseWebRoot(webRoot);
}
- var host = builder.Build();
- host.Run();
+ return builder;
}
}