diff --git a/test/SeederApi.IntegrationTest/Factories/UserSeederTests.cs b/test/SeederApi.IntegrationTest/Factories/UserSeederTests.cs index 2e3d15170db1..45f015a8c463 100644 --- a/test/SeederApi.IntegrationTest/Factories/UserSeederTests.cs +++ b/test/SeederApi.IntegrationTest/Factories/UserSeederTests.cs @@ -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() { diff --git a/util/Seeder/Factories/UserSeeder.cs b/util/Seeder/Factories/UserSeeder.cs index c5a1af56fda4..bc341bc27b42 100644 --- a/util/Seeder/Factories/UserSeeder.cs +++ b/util/Seeder/Factories/UserSeeder.cs @@ -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); diff --git a/util/Seeder/Models/UserSeed.cs b/util/Seeder/Models/UserSeed.cs index 43bdd78a9718..c1849512b823 100644 --- a/util/Seeder/Models/UserSeed.cs +++ b/util/Seeder/Models/UserSeed.cs @@ -110,4 +110,10 @@ internal record UserSeed /// WebAuthn credentials) is the caller's to supply. /// public Dictionary? TwoFactorProviders { get; init; } + + /// + /// Backdates for aged-account scenarios. Null leaves the entity default + /// (UtcNow). RevisionDate/AccountRevisionDate are unaffected. + /// + public DateTime? CreationDate { get; init; } } diff --git a/util/Seeder/Options/IndividualUserOptions.cs b/util/Seeder/Options/IndividualUserOptions.cs index 5191dbbde952..4ab3543f0865 100644 --- a/util/Seeder/Options/IndividualUserOptions.cs +++ b/util/Seeder/Options/IndividualUserOptions.cs @@ -48,4 +48,9 @@ public record IndividualUserOptions /// Required for self-hosted instances that validate premium status by reading the license file. /// public bool SelfHosted { get; init; } + + /// + /// Backdates the account by this many days (CreationDate = now - N). 0 (default) seeds a present-day account. + /// + public int AccountAgeDays { get; init; } } diff --git a/util/Seeder/Pipeline/RecipeBuilderExtensions.cs b/util/Seeder/Pipeline/RecipeBuilderExtensions.cs index 2bb333215247..970e8467b963 100644 --- a/util/Seeder/Pipeline/RecipeBuilderExtensions.cs +++ b/util/Seeder/Pipeline/RecipeBuilderExtensions.cs @@ -163,13 +163,15 @@ public static RecipeBuilder AddOwner(this RecipeBuilder builder) /// Whether the account has premium status /// Optional max storage override in GB /// When true, writes a license file after user creation (required for self-hosted premium validation) + /// Optional backdated CreationDate for aged-account scenarios. Null uses the current time. /// The builder for fluent chaining 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())); diff --git a/util/Seeder/Pipeline/RecipeOrchestrator.cs b/util/Seeder/Pipeline/RecipeOrchestrator.cs index c9cea87b5f61..cce00458dd79 100644 --- a/util/Seeder/Pipeline/RecipeOrchestrator.cs +++ b/util/Seeder/Pipeline/RecipeOrchestrator.cs @@ -150,7 +150,11 @@ internal async Task 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) diff --git a/util/Seeder/Seeds/docs/scenarios/README.md b/util/Seeder/Seeds/docs/scenarios/README.md index 6f696921f2be..20ef5220ffb5 100644 --- a/util/Seeder/Seeds/docs/scenarios/README.md +++ b/util/Seeder/Seeds/docs/scenarios/README.md @@ -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 diff --git a/util/Seeder/Seeds/docs/scenarios/aged-accounts.md b/util/Seeder/Seeds/docs/scenarios/aged-accounts.md new file mode 100644 index 000000000000..840e866f901e --- /dev/null +++ b/util/Seeder/Seeds/docs/scenarios/aged-accounts.md @@ -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. diff --git a/util/Seeder/Steps/CreateIndividualUserStep.cs b/util/Seeder/Steps/CreateIndividualUserStep.cs index f67ef990d5ec..295a1ff8da7b 100644 --- a/util/Seeder/Steps/CreateIndividualUserStep.cs +++ b/util/Seeder/Steps/CreateIndividualUserStep.cs @@ -8,7 +8,7 @@ namespace Bit.Seeder.Steps; /// Creates a standalone user with no organization, registering them as the context owner. /// 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) { @@ -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()); diff --git a/util/SeederUtility/Commands/IndividualArgs.cs b/util/SeederUtility/Commands/IndividualArgs.cs index dc8cbcf299cd..5484bb49757c 100644 --- a/util/SeederUtility/Commands/IndividualArgs.cs +++ b/util/SeederUtility/Commands/IndividualArgs.cs @@ -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; } @@ -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); @@ -73,6 +81,7 @@ public void Validate() GenerateVault = Vault, Password = Password, KdfIterations = KdfIterations, - SelfHosted = SelfHosted + SelfHosted = SelfHosted, + AccountAgeDays = AccountAgeDays }; } diff --git a/util/SeederUtility/README.md b/util/SeederUtility/README.md index e399d2163f47..8f4c79685a4c 100644 --- a/util/SeederUtility/README.md +++ b/util/SeederUtility/README.md @@ -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.