Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -124,15 +124,19 @@ public void BuildCollectionGroups_Uniform_AssignsGroupsToEveryCollection()
[Fact]
public void BuildCollectionUsers_AllCollectionIdsAreValid()
{
var result = CreateCollectionsStep.BuildCollectionUsers(_collectionIds, _userIds, 10);
var step = CreateStep(CollectionFanOutShape.Uniform, min: 1, max: 3);

var result = step.BuildCollectionUsers(_collectionIds, _userIds, 10);

Assert.All(result, cu => Assert.Contains(cu.CollectionId, _collectionIds));
}

[Fact]
public void BuildCollectionUsers_AssignsOneToThreeCollectionsPerUser()
{
var result = CreateCollectionsStep.BuildCollectionUsers(_collectionIds, _userIds, 10);
var step = CreateStep(CollectionFanOutShape.Uniform, min: 1, max: 3);

var result = step.BuildCollectionUsers(_collectionIds, _userIds, 10);

var perUser = result.GroupBy(cu => cu.OrganizationUserId).ToList();
Assert.All(perUser, group => Assert.InRange(group.Count(), 1, 3));
Expand All @@ -141,7 +145,9 @@ public void BuildCollectionUsers_AssignsOneToThreeCollectionsPerUser()
[Fact]
public void BuildCollectionUsers_RespectsDirectUserCount()
{
var result = CreateCollectionsStep.BuildCollectionUsers(_collectionIds, _userIds, 5);
var step = CreateStep(CollectionFanOutShape.Uniform, min: 1, max: 3);

var result = step.BuildCollectionUsers(_collectionIds, _userIds, 5);

var distinctUsers = result.Select(cu => cu.OrganizationUserId).Distinct().ToList();
Assert.Equal(5, distinctUsers.Count);
Expand Down Expand Up @@ -201,6 +207,67 @@ public void ComputeFanOut_Uniform_CyclesThroughRange()
Assert.Equal(1, step.ComputeFanOut(3, 10, 1, 3));
}

[Fact]
public void ComputeCollectionsPerUser_Uniform_CyclesThroughRange()
{
var step = CreateUserCollectionStep(CollectionFanOutShape.Uniform, min: 1, max: 5);

Assert.Equal(1, step.ComputeCollectionsPerUser(0, 100, 1, 5));
Assert.Equal(2, step.ComputeCollectionsPerUser(1, 100, 1, 5));
Assert.Equal(5, step.ComputeCollectionsPerUser(4, 100, 1, 5));
Assert.Equal(1, step.ComputeCollectionsPerUser(5, 100, 1, 5));
}

[Fact]
public void ComputeCollectionsPerUser_PowerLaw_FirstUserGetsMax()
{
var step = CreateUserCollectionStep(CollectionFanOutShape.PowerLaw, min: 1, max: 50, skew: 0.7);

Assert.Equal(50, step.ComputeCollectionsPerUser(0, 1000, 1, 50));
}

[Fact]
public void ComputeCollectionsPerUser_PowerLaw_LastUsersGetMin()
{
var step = CreateUserCollectionStep(CollectionFanOutShape.PowerLaw, min: 1, max: 50, skew: 0.7);

Assert.Equal(1, step.ComputeCollectionsPerUser(999, 1000, 1, 50));
}

[Fact]
public void ComputeCollectionsPerUser_PowerLaw_DecaysMonotonically()
{
var step = CreateUserCollectionStep(CollectionFanOutShape.PowerLaw, min: 1, max: 25, skew: 0.6);

var prev = step.ComputeCollectionsPerUser(0, 500, 1, 25);
for (var i = 1; i < 500; i++)
{
var current = step.ComputeCollectionsPerUser(i, 500, 1, 25);
Assert.True(current <= prev, $"Index {i}: {current} > {prev}");
prev = current;
}
}

[Fact]
public void ComputeCollectionsPerUser_FrontLoaded_FirstTenPercentGetMax()
{
var step = CreateUserCollectionStep(CollectionFanOutShape.FrontLoaded, min: 1, max: 20);

Assert.Equal(20, step.ComputeCollectionsPerUser(0, 100, 1, 20));
Assert.Equal(20, step.ComputeCollectionsPerUser(9, 100, 1, 20));
Assert.Equal(1, step.ComputeCollectionsPerUser(10, 100, 1, 20));
Assert.Equal(1, step.ComputeCollectionsPerUser(99, 100, 1, 20));
}

[Fact]
public void ComputeCollectionsPerUser_RangeOfOne_ReturnsMin()
{
var step = CreateUserCollectionStep(CollectionFanOutShape.PowerLaw, min: 3, max: 3, skew: 0.8);

Assert.Equal(3, step.ComputeCollectionsPerUser(0, 100, 3, 3));
Assert.Equal(3, step.ComputeCollectionsPerUser(99, 100, 3, 3));
}

private static CreateCollectionsStep CreateStep(CollectionFanOutShape shape, int min, int max)
{
var density = new DensityProfile
Expand All @@ -211,4 +278,17 @@ private static CreateCollectionsStep CreateStep(CollectionFanOutShape shape, int
};
return CreateCollectionsStep.FromCount(0, density);
}

private static CreateCollectionsStep CreateUserCollectionStep(
CollectionFanOutShape shape, int min, int max, double skew = 0)
{
var density = new DensityProfile
{
UserCollectionShape = shape,
UserCollectionMin = min,
UserCollectionMax = max,
UserCollectionSkew = skew
};
return CreateCollectionsStep.FromCount(0, density);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
ο»Ώusing Xunit;

namespace Bit.SeederApi.IntegrationTest.DensityModel;

/// <summary>
/// Validates the multi-collection cipher assignment math from GenerateCiphersStep
/// to ensure no duplicate (CipherId, CollectionId) pairs are produced.
/// </summary>
public class MultiCollectionAssignmentTests
{
/// <summary>
/// Simulates the secondary collection assignment loop from GenerateCiphersStep
/// with the extraCount clamp fix applied. Returns the list of (cipherIndex, collectionIndex) pairs.
/// </summary>
private static List<(int CipherIndex, int CollectionIndex)> SimulateMultiCollectionAssignment(
int cipherCount,
int collectionCount,
double multiCollectionRate,
int maxCollectionsPerCipher)
{
var primaryIndices = new int[cipherCount];
var pairs = new List<(int, int)>();

for (var i = 0; i < cipherCount; i++)
{
primaryIndices[i] = i % collectionCount;
pairs.Add((i, primaryIndices[i]));
}

if (multiCollectionRate > 0 && collectionCount > 1)
{
var multiCount = (int)(cipherCount * multiCollectionRate);
for (var i = 0; i < multiCount; i++)
{
var extraCount = 1 + (i % Math.Max(maxCollectionsPerCipher - 1, 1));
extraCount = Math.Min(extraCount, collectionCount - 1);
for (var j = 0; j < extraCount; j++)
{
var secondaryIndex = (primaryIndices[i] + 1 + j) % collectionCount;
pairs.Add((i, secondaryIndex));
}
}
}

return pairs;
}

[Fact]
public void MultiCollectionAssignment_SmallCollectionCount_NoDuplicates()
{
var pairs = SimulateMultiCollectionAssignment(
cipherCount: 20,
collectionCount: 3,
multiCollectionRate: 1.0,
maxCollectionsPerCipher: 5);

var grouped = pairs.GroupBy(p => p);
Assert.All(grouped, g => Assert.Single(g));
}

[Fact]
public void MultiCollectionAssignment_TwoCollections_NoDuplicates()
{
var pairs = SimulateMultiCollectionAssignment(
cipherCount: 50,
collectionCount: 2,
multiCollectionRate: 1.0,
maxCollectionsPerCipher: 10);

var grouped = pairs.GroupBy(p => p);
Assert.All(grouped, g => Assert.Single(g));
}

[Fact]
public void MultiCollectionAssignment_ExtraCountClamped_ToAvailableCollections()
{
// With 2 collections, extraCount should never exceed 1 (collectionCount - 1)
var collectionCount = 2;
var maxCollectionsPerCipher = 10;
var cipherCount = 20;

for (var i = 0; i < cipherCount; i++)
{
var extraCount = 1 + (i % Math.Max(maxCollectionsPerCipher - 1, 1));
extraCount = Math.Min(extraCount, collectionCount - 1);
Assert.True(extraCount <= collectionCount - 1,
$"extraCount {extraCount} exceeds available secondary slots {collectionCount - 1} at i={i}");
}
}

[Fact]
public void MultiCollectionAssignment_SecondaryNeverEqualsPrimary()
{
var pairs = SimulateMultiCollectionAssignment(
cipherCount: 30,
collectionCount: 3,
multiCollectionRate: 1.0,
maxCollectionsPerCipher: 5);

// Group by cipher index β€” for each cipher, no secondary should equal primary
var byCipher = pairs.GroupBy(p => p.CipherIndex);
foreach (var group in byCipher)
{
var primary = group.First().CollectionIndex;
var secondaries = group.Skip(1).Select(p => p.CollectionIndex);
Assert.DoesNotContain(primary, secondaries);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
ο»Ώusing Xunit;

namespace Bit.SeederApi.IntegrationTest.DensityModel;

/// <summary>
/// Validates the range calculation formula used in GeneratePersonalCiphersStep and GenerateFoldersStep.
/// The formula: range.Min + (index % Math.Max(range.Max - range.Min + 1, 1))
/// </summary>
public class RangeCalculationTests
{
private static int ComputeFromRange(int min, int max, int index)
{
return min + (index % Math.Max(max - min + 1, 1));
}

[Fact]
public void RangeFormula_SmallRange_ProducesBothMinAndMax()
{
var values = Enumerable.Range(0, 100).Select(i => ComputeFromRange(0, 1, i)).ToHashSet();

Assert.Contains(0, values);
Assert.Contains(1, values);
}

[Fact]
public void RangeFormula_LargerRange_MaxIsReachable()
{
var values = Enumerable.Range(0, 1000).Select(i => ComputeFromRange(5, 15, i)).ToHashSet();

Assert.Contains(5, values);
Assert.Contains(15, values);
Assert.Equal(11, values.Count); // 5,6,7,...,15
}

[Fact]
public void RangeFormula_SingleValue_AlwaysReturnsMin()
{
var values = Enumerable.Range(0, 50).Select(i => ComputeFromRange(3, 3, i)).Distinct().ToList();

Assert.Single(values);
Assert.Equal(3, values[0]);
}

[Fact]
public void RangeFormula_AllValuesInBounds()
{
for (var i = 0; i < 500; i++)
{
var result = ComputeFromRange(50, 200, i);
Assert.InRange(result, 50, 200);
}
}
}
38 changes: 38 additions & 0 deletions test/SeederApi.IntegrationTest/DistributionTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -172,4 +172,42 @@ public void Select_IsDeterministic_SameInputSameOutput()
Assert.Equal(first, second);
}
}

[Fact]
public void Select_ZeroWeightBucket_NeverSelected()
{
var distribution = new Distribution<string>(
("Manage", 0.50),
("ReadWrite", 0.40),
("ReadOnly", 0.10),
("HidePasswords", 0.0)
);

for (var i = 0; i < 7; i++)
{
Assert.NotEqual("HidePasswords", distribution.Select(i, 7));
}
}

[Fact]
public void GetCounts_SmallTotal_RemainderGoesToLargestFraction()
{
var distribution = new Distribution<string>(
("A", 0.50),
("B", 0.40),
("C", 0.10),
("D", 0.0)
);

var counts = distribution.GetCounts(7).ToList();

// Exact: A=3.5, B=2.8, C=0.7, D=0.0
// Floors: A=3, B=2, C=0, D=0 (sum=5, deficit=2)
// Remainders: A=0.5, B=0.8, C=0.7, D=0.0
// Deficit 1 β†’ B (0.8), Deficit 2 β†’ C (0.7)
Assert.Equal(("A", 3), counts[0]);
Assert.Equal(("B", 3), counts[1]);
Assert.Equal(("C", 1), counts[2]);
Assert.Equal(("D", 0), counts[3]);
}
}
Loading
Loading