Skip to content
Open
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
42 changes: 42 additions & 0 deletions test/SeederApi.IntegrationTest/Factories/UserSeederTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,48 @@ public void Create_WhenNotPremium_LeavesExpirationNull()
Assert.Null(user.PremiumExpirationDate);
}

[Fact]
public void Create_WithCreationDate_BackdatesCreationDate()
{
var aged = DateTime.UtcNow.AddDays(-365);

var (user, _) = UserSeeder.Create(
new UserSeed { Email = _email, CreationDate = aged },
_passwordHasher,
new NoOpManglerService());

Assert.Equal(aged, user.CreationDate);
}

[Fact]
public void Create_WithoutCreationDate_LeavesDatesAtNow()
{
var before = DateTime.UtcNow;

var (user, _) = UserSeeder.Create(new UserSeed { Email = _email }, _passwordHasher, new NoOpManglerService());

var after = DateTime.UtcNow;
Assert.InRange(user.CreationDate, before, after);
Assert.InRange(user.RevisionDate, before, after);
Assert.InRange(user.AccountRevisionDate, before, after);
}

[Fact]
public void Create_WithCreationDate_DoesNotBackdateRevisionDates()
{
var before = DateTime.UtcNow;
var aged = before.AddDays(-365);

var (user, _) = UserSeeder.Create(
new UserSeed { Email = _email, CreationDate = aged },
_passwordHasher,
new NoOpManglerService());

var after = DateTime.UtcNow;
Assert.InRange(user.RevisionDate, before, after);
Assert.InRange(user.AccountRevisionDate, before, after);
}

[Fact]
public void Create_NullName_DefaultsToEmailLocalPart()
{
Expand Down
5 changes: 5 additions & 0 deletions util/Seeder/Factories/UserSeeder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,11 @@ internal static (User user, UserKeys keys) Create(
user.Culture = seed.Culture;
}

if (seed.CreationDate is not null)
{
user.CreationDate = seed.CreationDate.Value;
}

if (seed.TwoFactorProviders is not null)
{
user.SetTwoFactorProviders(seed.TwoFactorProviders);
Expand Down
6 changes: 6 additions & 0 deletions util/Seeder/Models/UserSeed.cs
Original file line number Diff line number Diff line change
Expand Up @@ -110,4 +110,10 @@ internal record UserSeed
/// WebAuthn credentials) is the caller's to supply.
/// </summary>
public Dictionary<TwoFactorProviderType, TwoFactorProvider>? TwoFactorProviders { get; init; }

/// <summary>
/// Backdates <see cref="User.CreationDate"/> for aged-account scenarios. Null leaves the entity default
/// (UtcNow). RevisionDate/AccountRevisionDate are unaffected.
/// </summary>
public DateTime? CreationDate { get; init; }
}
5 changes: 5 additions & 0 deletions util/Seeder/Options/IndividualUserOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -48,4 +48,9 @@ public record IndividualUserOptions
/// Required for self-hosted instances that validate premium status by reading the license file.
/// </summary>
public bool SelfHosted { get; init; }

/// <summary>
/// Backdates the account by this many days (CreationDate = now - N). 0 (default) seeds a present-day account.
/// </summary>
public int AccountAgeDays { get; init; }
}
6 changes: 4 additions & 2 deletions util/Seeder/Pipeline/RecipeBuilderExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -163,13 +163,15 @@ public static RecipeBuilder AddOwner(this RecipeBuilder builder)
/// <param name="premium">Whether the account has premium status</param>
/// <param name="maxStorageGb">Optional max storage override in GB</param>
/// <param name="selfHosted">When true, writes a license file after user creation (required for self-hosted premium validation)</param>
/// <param name="creationDate">Optional backdated CreationDate for aged-account scenarios. Null uses the current time.</param>
/// <returns>The builder for fluent chaining</returns>
public static RecipeBuilder CreateIndividualUser(
this RecipeBuilder builder, string email, bool premium, short maxStorageGb, bool selfHosted = false)
this RecipeBuilder builder, string email, bool premium, short maxStorageGb, bool selfHosted = false,
DateTime? creationDate = null)
{
builder.HasIndividualUser = true;
builder.HasOwner = true;
builder.AddStep(_ => new CreateIndividualUserStep(email, premium, maxStorageGb, true));
builder.AddStep(_ => new CreateIndividualUserStep(email, premium, maxStorageGb, true, creationDate));
if (selfHosted)
{
builder.AddAsyncStep(sp => new GenerateSelfHostUserLicenseStep(sp.GetRequiredService<ILicensingService>()));
Expand Down
6 changes: 5 additions & 1 deletion util/Seeder/Pipeline/RecipeOrchestrator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,11 @@ internal async Task<PipelineExecutionResult> ExecuteAsync(IndividualUserOptions
var recipeName = "individual-from-options";
var builder = services.AddRecipe(recipeName);

builder.CreateIndividualUser(email, premium, maxStorageGb, options.SelfHosted);
DateTime? creationDate = options.AccountAgeDays > 0
? DateTime.UtcNow.AddDays(-options.AccountAgeDays)
: null;

builder.CreateIndividualUser(email, premium, maxStorageGb, options.SelfHosted, creationDate);
builder.WithGenerator("individual.example");

if (options.GenerateVault)
Expand Down
1 change: 1 addition & 0 deletions util/Seeder/Seeds/docs/scenarios/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ For CLI flag details, see the [SeederUtility reference](../../../../SeederUtilit
| [Blob-migration testing](blob-migration.md) | I need to verify the SDK V1β†’V2 blob-encryption migration end-to-end | `preset` |
| [Encryption modes (individual)](encryption-modes.md) | I need ciphers and attachments encrypted the way older and newer clients wrote them | `preset` |
| [Encryption modes (org)](encryption-modes-org.md) | I need a shared org vault covering every encryption mode across lifecycle states | `preset` |
| [Aged accounts](aged-accounts.md) | I need a user whose account was created weeks or months ago | `individual` |

## Contributing a Scenario

Expand Down
21 changes: 21 additions & 0 deletions util/Seeder/Seeds/docs/scenarios/aged-accounts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# How do I test behavior that depends on how old an account is?

> I need a user whose account was created weeks or months ago, not just now.

## Quick start

```bash
dotnet run -- individual --subscription free --account-age-days 365
```

## What you get

A standalone individual user (password `asdfasdfasdf`) whose `CreationDate` is backdated by the given number of days. Only `CreationDate` is backdated; `RevisionDate` and `AccountRevisionDate` stay at the seed time, matching a long-lived account that was just touched.

## Who this is for

Engineers testing account-age-gated behavior, retention or dormancy windows, or any flow that branches on how long ago an account was created.

## Variations

Combine with `--vault` for personal vault data, `--email` for a predictable address, or `--subscription premium` for a premium aged account. See the [SeederUtility reference](../../../../SeederUtility/README.md) for all `individual` flags.
5 changes: 3 additions & 2 deletions util/Seeder/Steps/CreateIndividualUserStep.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ namespace Bit.Seeder.Steps;
/// Creates a standalone user with no organization, registering them as the context owner.
/// </summary>
internal sealed class CreateIndividualUserStep(
string email, bool premium, short maxStorageGb, bool emailVerified) : IStep
string email, bool premium, short maxStorageGb, bool emailVerified, DateTime? creationDate = null) : IStep
{
public void Execute(SeederContext context)
{
Expand All @@ -23,7 +23,8 @@ public void Execute(SeederContext context)
Premium = premium,
MaxStorageGb = maxStorageGb > 0 ? Math.Min(maxStorageGb, (short)5) : null,
Password = password,
KdfIterations = kdfIterations
KdfIterations = kdfIterations,
CreationDate = creationDate
},
context.GetPasswordHasher(),
context.GetMangler());
Expand Down
11 changes: 10 additions & 1 deletion util/SeederUtility/Commands/IndividualArgs.cs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ public class IndividualArgs : IArgumentModel
[Option("self-hosted", Description = "Write a user license file to LicenseDirectory after seeding (required for self-hosted premium validation)")]
public bool SelfHosted { get; set; }

[Option("account-age-days", Description = "Backdate the account's CreationDate by N days (default: 0 = today)")]
public int AccountAgeDays { get; set; }

[Option("mangle", Description = "Enable ID mangling for test isolation")]
public bool Mangle { get; set; }

Expand All @@ -49,6 +52,11 @@ public void Validate()
throw new ArgumentException("KDF iterations must be at least 5,000.");
}

if (AccountAgeDays < 0)
{
throw new ArgumentException("Account age days must be >= 0.");
}

var hasFirst = !string.IsNullOrWhiteSpace(FirstName);
var hasLast = !string.IsNullOrWhiteSpace(LastName);

Expand All @@ -73,6 +81,7 @@ public void Validate()
GenerateVault = Vault,
Password = Password,
KdfIterations = KdfIterations,
SelfHosted = SelfHosted
SelfHosted = SelfHosted,
AccountAgeDays = AccountAgeDays
};
}
5 changes: 5 additions & 0 deletions util/SeederUtility/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,10 +50,15 @@ dotnet run -- individual --subscription premium --vault

# Self-hosted instance β€” writes a license file so premium status is recognized
dotnet run -- individual --subscription premium --first-name Jane --last-name Smith --self-hosted

# Aged account β€” CreationDate backdated 365 days
dotnet run -- individual --subscription free --account-age-days 365
```

Add `--self-hosted` when targeting a self-hosted instance β€” without it, premium status won't be recognized.

Use `--account-age-days N` to backdate the account's `CreationDate` by `N` days (default `0` = today) for scenarios that depend on account age. Only `CreationDate` is backdated; the revision dates stay at the seed time.

### `preset` - Fixture-Based Seeding

Loads a named configuration from the embedded catalog. Presets are curated JSON fixtures with specific users, groups, collections, and cipher relationships β€” the same data every time. Reach for this when you need a known, reproducible scenario rather than generated data.
Expand Down
Loading