diff --git a/.gitignore b/.gitignore index 4181b4948..245e7141e 100644 --- a/.gitignore +++ b/.gitignore @@ -377,6 +377,5 @@ src/AliasVault.WebApp/wwwroot/index.html # appsettings.Development.json is generated by the build process from appsettings.Development.template.json and therefore should be ignored src/AliasVault.WebApp/wwwroot/appsettings.Development.json - # .env is generated by init.sh and therefore should be ignored .env diff --git a/docs/misc/upgrade-ef-client-model.md b/docs/misc/upgrade-ef-client-model.md new file mode 100644 index 000000000..b9bacac9e --- /dev/null +++ b/docs/misc/upgrade-ef-client-model.md @@ -0,0 +1,12 @@ +To upgrade the AliasClientDb EF model, follow these steps: + +1. Make changes to the AliasClientDb EF model in the `AliasClientDb` project. +2. Create a new migration by running the following command in the `AliasClientDb` project: + +```bash +# Important: make sure the migration name is prefixed by the Semver version number of the release. +# For example, if the release version is 1.0.0, the migration name should be `1.0.0-`. +dotnet ef migrations add "1.0.0-" +``` +4. On the next login of a user, they will be prompted (required) to upgrade their database schema to the latest version. +Make sure to manually test this. diff --git a/src/AliasVault.Api/Controllers/VaultController.cs b/src/AliasVault.Api/Controllers/VaultController.cs index c9d9dcf23..523ec9620 100644 --- a/src/AliasVault.Api/Controllers/VaultController.cs +++ b/src/AliasVault.Api/Controllers/VaultController.cs @@ -28,13 +28,14 @@ public class VaultController(AliasServerDbContext context, UserManager /// Default retention policy for vaults. /// - private readonly RetentionPolicy _retentionPolicy = new RetentionPolicy + private readonly RetentionPolicy _retentionPolicy = new() { Rules = new List { - new DailyRetentionRule() { DaysToKeep = 3 }, - new WeeklyRetentionRule() { WeeksToKeep = 1 }, - new MonthlyRetentionRule() { MonthsToKeep = 1 }, + new DailyRetentionRule { DaysToKeep = 3 }, + new WeeklyRetentionRule { WeeksToKeep = 1 }, + new MonthlyRetentionRule { MonthsToKeep = 1 }, + new VersionRetentionRule { VersionsToKeep = 3 }, }, }; @@ -61,10 +62,10 @@ public class VaultController(AliasServerDbContext context, UserManager @@ -86,6 +87,7 @@ public class VaultController(AliasServerDbContext context, UserManager +// Copyright (c) lanedirt. All rights reserved. +// Licensed under the MIT license. See LICENSE.md file in the project root for full license information. +// +//----------------------------------------------------------------------- + +namespace AliasVault.Api.Vault.RetentionRules; + +using AliasServerDb; + +/// +/// Version retention rule that keeps the latest X unique versions of the vault. +/// +public class VersionRetentionRule : IRetentionRule +{ + /// + /// Gets or sets amount of versions to keep the vault. + /// + public int VersionsToKeep { get; set; } + + /// + public IEnumerable ApplyRule(List vaults, DateTime now) + { + // For the specified amount of versions, take last vault per version. + return vaults + .GroupBy(x => x.Version) + .Select(g => g.OrderByDescending(x => x.UpdatedAt).First()) + .OrderByDescending(x => x.UpdatedAt) + .Take(VersionsToKeep); + } +} diff --git a/src/AliasVault.Shared/Models/WebApi/Vault.cs b/src/AliasVault.Shared/Models/WebApi/Vault.cs index c4ae9ea11..339af2cb1 100644 --- a/src/AliasVault.Shared/Models/WebApi/Vault.cs +++ b/src/AliasVault.Shared/Models/WebApi/Vault.cs @@ -16,11 +16,13 @@ public class Vault /// Initializes a new instance of the class. /// /// Blob. + /// Version of the vault data model (migration). /// CreatedAt. /// UpdatedAt. - public Vault(string blob, DateTime createdAt, DateTime updatedAt) + public Vault(string blob, string version, DateTime createdAt, DateTime updatedAt) { Blob = blob; + Version = version; CreatedAt = createdAt; UpdatedAt = updatedAt; } @@ -30,6 +32,11 @@ public class Vault /// public string Blob { get; set; } + /// + /// Gets or sets the vault version. + /// + public string Version { get; set; } + /// /// Gets or sets the date and time of creation. /// diff --git a/src/AliasVault.WebApp/AliasVault.WebApp.csproj b/src/AliasVault.WebApp/AliasVault.WebApp.csproj index 90aa4b9a8..356834738 100644 --- a/src/AliasVault.WebApp/AliasVault.WebApp.csproj +++ b/src/AliasVault.WebApp/AliasVault.WebApp.csproj @@ -60,6 +60,9 @@ + + + PreserveNewest @@ -89,4 +92,10 @@ Never + + + <_ContentIncludedByDefault Remove="Main\Layout\StatusMessages\ErrorVaultDecrypt.razor" /> + <_ContentIncludedByDefault Remove="Main\Layout\StatusMessages\PendingMigrations.razor" /> + <_ContentIncludedByDefault Remove="Main\Layout\StatusMessages\VaultDecryptionProgress.razor" /> + diff --git a/src/AliasVault.WebApp/Main/Layout/DbStatusIndicator.razor b/src/AliasVault.WebApp/Main/Layout/DbStatusIndicator.razor index 13ead567a..3058bc2ad 100644 --- a/src/AliasVault.WebApp/Main/Layout/DbStatusIndicator.razor +++ b/src/AliasVault.WebApp/Main/Layout/DbStatusIndicator.razor @@ -40,11 +40,6 @@ else Message = "Saving..."; await ShowLoadingIndicatorAsync(); } - else if (newState.Status == DbServiceState.DatabaseStatus.LoadingFromServer) - { - Message = "Loading..."; - await ShowLoadingIndicatorAsync(); - } LoadingIndicatorMessage = Message + " - " + newState.LastUpdated; } diff --git a/src/AliasVault.WebApp/Main/Layout/EmptyLayout.razor b/src/AliasVault.WebApp/Main/Layout/EmptyLayout.razor new file mode 100644 index 000000000..f660173c4 --- /dev/null +++ b/src/AliasVault.WebApp/Main/Layout/EmptyLayout.razor @@ -0,0 +1,16 @@ +@inherits LayoutComponentBase + + + + +
+ @Body +
+
+ +
+ @Body +
+
+
+
diff --git a/src/AliasVault.WebApp/Main/Layout/MainLayout.razor b/src/AliasVault.WebApp/Main/Layout/MainLayout.razor index 72b09d6a8..d6398f22c 100644 --- a/src/AliasVault.WebApp/Main/Layout/MainLayout.razor +++ b/src/AliasVault.WebApp/Main/Layout/MainLayout.razor @@ -1,8 +1,4 @@ @inherits LayoutComponentBase -@implements IDisposable -@inject DbService DbService -@inject AuthenticationStateProvider AuthStateProvider -@using AliasVault.WebApp.Main.Layout.StatusMessages @@ -10,25 +6,10 @@
- @if (IsDbInitialized) - { -
- @Body -
-
- } - else if(IsDbDecryptionError) - { - - } - else if(IsPendingMigrations) - { - - } - else - { - - } +
+ @Body +
+
@@ -41,119 +22,5 @@
@code { - private bool IsDbInitialized { get; set; } = false; - private bool IsDbDecryptionError { get; set; } = false; - private bool IsPendingMigrations { get; set; } = false; - private const int MinimumLoadingTimeMs = 800; - [CascadingParameter] private Task? AuthState { get; set; } - - protected override async Task OnInitializedAsync() - { - await base.OnInitializedAsync(); - - // Reset local state - IsDbInitialized = false; - IsDbDecryptionError = false; - IsPendingMigrations = false; - - DbService.GetState().StateChanged += OnDatabaseStateChanged; - AuthStateProvider.AuthenticationStateChanged += OnAuthenticationStateChanged; - - await CheckAndInitializeDatabase(); - } - - protected override async Task OnParametersSetAsync() - { - if (AuthState != null) - { - var authState = await AuthState; - if (authState.User.Identity?.IsAuthenticated == true) - { - await CheckAndInitializeDatabase(); - } - } - } - - private async void OnAuthenticationStateChanged(Task task) - { - var authState = await task; - if (authState.User.Identity?.IsAuthenticated == true) - { - await CheckAndInitializeDatabase(); - } - StateHasChanged(); - } - - private async Task CheckAndInitializeDatabase() - { - var currentState = DbService.GetState().CurrentState; - if (currentState.Status == DbServiceState.DatabaseStatus.Uninitialized) - { - await InitializeDatabaseWithProgress(); - } - else if (currentState.Status == DbServiceState.DatabaseStatus.Ready) - { - IsDbInitialized = true; - StateHasChanged(); - } - else if (currentState.Status == DbServiceState.DatabaseStatus.DecryptionFailed) - { - IsDbDecryptionError = true; - StateHasChanged(); - } - else if (currentState.Status == DbServiceState.DatabaseStatus.PendingMigrations) - { - IsPendingMigrations = true; - StateHasChanged(); - } - } - - private async void OnDatabaseStateChanged(object? sender, DbServiceState.DatabaseState newState) - { - if (newState.Status == DbServiceState.DatabaseStatus.Uninitialized) - { - await InitializeDatabaseWithProgress(); - } - else if (newState.Status == DbServiceState.DatabaseStatus.Ready) - { - IsDbInitialized = true; - } - else if (newState.Status == DbServiceState.DatabaseStatus.DecryptionFailed) - { - IsDbDecryptionError = true; - } - else if (newState.Status == DbServiceState.DatabaseStatus.PendingMigrations) - { - IsPendingMigrations = true; - } - StateHasChanged(); - } - - private async Task InitializeDatabaseWithProgress() - { - IsDbInitialized = false; - StateHasChanged(); - - var stopwatch = System.Diagnostics.Stopwatch.StartNew(); - - await DbService.InitializeDatabaseAsync(); - - stopwatch.Stop(); - var elapsedMs = (int)stopwatch.ElapsedMilliseconds; - - if (elapsedMs < MinimumLoadingTimeMs) - { - await Task.Delay(MinimumLoadingTimeMs - elapsedMs); - } - - await CheckAndInitializeDatabase(); - StateHasChanged(); - } - - public void Dispose() - { - DbService.GetState().StateChanged -= OnDatabaseStateChanged; - AuthStateProvider.AuthenticationStateChanged -= OnAuthenticationStateChanged; - } } diff --git a/src/AliasVault.WebApp/Main/Layout/MainLayout.razor.css b/src/AliasVault.WebApp/Main/Layout/MainLayout.razor.css deleted file mode 100644 index ecf25e5b2..000000000 --- a/src/AliasVault.WebApp/Main/Layout/MainLayout.razor.css +++ /dev/null @@ -1,77 +0,0 @@ -.page { - position: relative; - display: flex; - flex-direction: column; -} - -main { - flex: 1; -} - -.sidebar { - background-image: linear-gradient(180deg, rgb(5, 39, 103) 0%, #3a0647 70%); -} - -.top-row { - background-color: #f7f7f7; - border-bottom: 1px solid #d6d5d5; - justify-content: flex-end; - height: 3.5rem; - display: flex; - align-items: center; -} - - .top-row ::deep a, .top-row ::deep .btn-link { - white-space: nowrap; - margin-left: 1.5rem; - text-decoration: none; - } - - .top-row ::deep a:hover, .top-row ::deep .btn-link:hover { - text-decoration: underline; - } - - .top-row ::deep a:first-child { - overflow: hidden; - text-overflow: ellipsis; - } - -@media (max-width: 640.98px) { - .top-row { - justify-content: space-between; - } - - .top-row ::deep a, .top-row ::deep .btn-link { - margin-left: 0; - } -} - -@media (min-width: 641px) { - .page { - flex-direction: row; - } - - .sidebar { - width: 250px; - height: 100vh; - position: sticky; - top: 0; - } - - .top-row { - position: sticky; - top: 0; - z-index: 1; - } - - .top-row.auth ::deep a:first-child { - flex: 1; - text-align: right; - width: 0; - } - - .top-row, article { - padding-left: 2rem !important; - padding-right: 1.5rem !important; - } -} diff --git a/src/AliasVault.WebApp/Main/Layout/StatusMessages/ErrorVaultDecrypt.razor b/src/AliasVault.WebApp/Main/Layout/StatusMessages/ErrorVaultDecrypt.razor deleted file mode 100644 index 48d2e6fda..000000000 --- a/src/AliasVault.WebApp/Main/Layout/StatusMessages/ErrorVaultDecrypt.razor +++ /dev/null @@ -1,8 +0,0 @@ -
-
-
-

Vault decryption error.

-

An error occured while locally decrypting your vault. Your data is not accessible at this moment. Please try again (later) or contact support.

-
-
-
diff --git a/src/AliasVault.WebApp/Main/Layout/StatusMessages/PendingMigrations.razor b/src/AliasVault.WebApp/Main/Layout/StatusMessages/PendingMigrations.razor deleted file mode 100644 index c49586cd8..000000000 --- a/src/AliasVault.WebApp/Main/Layout/StatusMessages/PendingMigrations.razor +++ /dev/null @@ -1,65 +0,0 @@ -@inject DbService DbService -@inject GlobalNotificationService GlobalNotificationService - -
-
-
-

Vault needs to be upgraded.

-

AliasVault has been updated which requires your vault to be upgraded in order to be compatible with the new datastructure. - This upgrade should only take a few seconds.

- -
- @if (ErrorMessage.Length > 0) - { - - } - - @if (IsPendingMigrations) - { - - - - - } - else - { - - } -
-
-
-
- -@code { - private bool IsPendingMigrations { get; set; } = false; - private string ErrorMessage { get; set; } = string.Empty; - - private async Task MigrateDatabase() - { - // Show loading indicator - IsPendingMigrations = true; - ErrorMessage = String.Empty; - StateHasChanged(); - - // Simulate a delay. - await Task.Delay(1000); - - // Migrate the database - if (await DbService.MigrateDatabaseAsync()) - { - // Migration successful - GlobalNotificationService.AddSuccessMessage("Database upgrade successful.", true); - } - else - { - // Migration failed - ErrorMessage = "Database upgrade failed. Please try again or contact support."; - } - - // Reset local state - IsPendingMigrations = false; - StateHasChanged(); - } -} diff --git a/src/AliasVault.WebApp/Main/Layout/StatusMessages/VaultDecryptionProgress.razor b/src/AliasVault.WebApp/Main/Layout/StatusMessages/VaultDecryptionProgress.razor deleted file mode 100644 index d66557408..000000000 --- a/src/AliasVault.WebApp/Main/Layout/StatusMessages/VaultDecryptionProgress.razor +++ /dev/null @@ -1,12 +0,0 @@ -
-
-
- - - - -

Vault decryption in progress

-

Please wait while your vault is initialized. This may take a moment.

-
-
-
diff --git a/src/AliasVault.WebApp/Main/Pages/MainBase.cs b/src/AliasVault.WebApp/Main/Pages/MainBase.cs index d510475f5..c8a8901df 100644 --- a/src/AliasVault.WebApp/Main/Pages/MainBase.cs +++ b/src/AliasVault.WebApp/Main/Pages/MainBase.cs @@ -91,6 +91,19 @@ public class MainBase : OwningComponentBase await Task.Delay(200); } } + + // Check if DB is initialized, if not, redirect to setup page. + if (!DbService.GetState().CurrentState.IsInitialized()) + { + var currentUrl = NavigationManager.Uri; + await LocalStorage.SetItemAsync("returnUrl", currentUrl); + + NavigationManager.NavigateTo("/sync"); + while (true) + { + await Task.Delay(200); + } + } } /// @@ -106,6 +119,19 @@ public class MainBase : OwningComponentBase await Task.Delay(200); } } + + // Check if DB is initialized, if not, redirect to setup page. + if (!DbService.GetState().CurrentState.IsInitialized()) + { + var currentUrl = NavigationManager.Uri; + await LocalStorage.SetItemAsync("returnUrl", currentUrl); + + NavigationManager.NavigateTo("/sync"); + while (true) + { + await Task.Delay(200); + } + } } /// diff --git a/src/AliasVault.WebApp/Main/Pages/Sync/StatusMessages/Creating.razor b/src/AliasVault.WebApp/Main/Pages/Sync/StatusMessages/Creating.razor new file mode 100644 index 000000000..fb3a10642 --- /dev/null +++ b/src/AliasVault.WebApp/Main/Pages/Sync/StatusMessages/Creating.razor @@ -0,0 +1,66 @@ +@inject DbService DbService +@inject GlobalNotificationService GlobalNotificationService + +
+
+ + + + +
+

Welcome to AliasVault!

+

+ Your new encrypted vault is being created. This process may take a moment. Please wait. +

+ +
+ @if (ErrorMessage.Length > 0) + { + + } +
+
+
+
+ +@code { + private string ErrorMessage { get; set; } = string.Empty; + + /// + protected override async Task OnInitializedAsync() + { + await base.OnInitializedAsync(); + } + + /// + protected override async Task OnAfterRenderAsync(bool firstRender) + { + await base.OnAfterRenderAsync(firstRender); + + if (firstRender) + { + // Start the database migration process + await MigrateDatabase(); + } + } + + private async Task MigrateDatabase() + { + // Simulate a delay. + await Task.Delay(3000); + + // Migrate the database + if (await DbService.MigrateDatabaseAsync()) + { + // Migration successful + GlobalNotificationService.AddSuccessMessage("Vault successfully created.", true); + } + else + { + // Migration failed + ErrorMessage = "Vault creation failed. Please try again or contact support."; + } + + StateHasChanged(); + } +} diff --git a/src/AliasVault.WebApp/Main/Pages/Sync/StatusMessages/ErrorVaultDecrypt.razor b/src/AliasVault.WebApp/Main/Pages/Sync/StatusMessages/ErrorVaultDecrypt.razor new file mode 100644 index 000000000..4ebb28c9a --- /dev/null +++ b/src/AliasVault.WebApp/Main/Pages/Sync/StatusMessages/ErrorVaultDecrypt.razor @@ -0,0 +1,6 @@ +
+
+

Vault decryption error.

+

An error occured while locally decrypting your vault. Your data is not accessible at this moment. Please try again (later) or contact support.

+
+
diff --git a/src/AliasVault.WebApp/Main/Pages/Sync/StatusMessages/PendingMigrations.razor b/src/AliasVault.WebApp/Main/Pages/Sync/StatusMessages/PendingMigrations.razor new file mode 100644 index 000000000..52d6342e3 --- /dev/null +++ b/src/AliasVault.WebApp/Main/Pages/Sync/StatusMessages/PendingMigrations.razor @@ -0,0 +1,92 @@ +@inject DbService DbService +@inject GlobalNotificationService GlobalNotificationService + +
+
+
+

Vault needs to be upgraded.

+

+ AliasVault has been updated. Your personal vault needs to be upgraded in order to be compatible with the new data model. + This upgrade should only take a few seconds. +

+
+

Version Information

+
+

+ Your vault: + @CurrentVersion +

+

+ AliasVault latest version: + @LatestVersion +

+
+
+ +
+ @if (ErrorMessage.Length > 0) + { + + } + + @if (IsPendingMigrations) + { + + + + + } + else + { + + } +
+
+
+
+ +@code { + private bool IsPendingMigrations { get; set; } = false; + private string ErrorMessage { get; set; } = string.Empty; + private string CurrentVersion { get; set; } = string.Empty; + private string LatestVersion { get; set; } = string.Empty; + + /// + protected override async Task OnInitializedAsync() + { + await base.OnInitializedAsync(); + + // Get current and latest available database version + CurrentVersion = await DbService.GetCurrentDatabaseVersionAsync(); + LatestVersion = await DbService.GetLatestDatabaseVersionAsync(); + } + + private async Task MigrateDatabase() + { + // Show loading indicator + IsPendingMigrations = true; + ErrorMessage = String.Empty; + StateHasChanged(); + + // Simulate a delay. + await Task.Delay(1000); + + // Migrate the database + if (await DbService.MigrateDatabaseAsync()) + { + // Migration successful + GlobalNotificationService.AddSuccessMessage("Vault upgrade successful.", true); + } + else + { + // Migration failed + ErrorMessage = "Database upgrade failed. Please try again or contact support."; + } + + // Reset local state + IsPendingMigrations = false; + StateHasChanged(); + } +} diff --git a/src/AliasVault.WebApp/Main/Pages/Sync/StatusMessages/VaultDecryptionProgress.razor b/src/AliasVault.WebApp/Main/Pages/Sync/StatusMessages/VaultDecryptionProgress.razor new file mode 100644 index 000000000..fd15cfe2c --- /dev/null +++ b/src/AliasVault.WebApp/Main/Pages/Sync/StatusMessages/VaultDecryptionProgress.razor @@ -0,0 +1,10 @@ +
+
+ + + + +

Vault decryption in progress

+

Please wait while your vault is initialized. This may take a moment.

+
+
diff --git a/src/AliasVault.WebApp/Main/Pages/Sync/Sync.razor b/src/AliasVault.WebApp/Main/Pages/Sync/Sync.razor new file mode 100644 index 000000000..4f55573fc --- /dev/null +++ b/src/AliasVault.WebApp/Main/Pages/Sync/Sync.razor @@ -0,0 +1,117 @@ +@page "/sync" +@layout EmptyLayout +@implements IDisposable +@using AliasVault.WebApp.Main.Pages.Sync.StatusMessages +@inject ILocalStorageService LocalStorage +@inject DbService DbService +@inject NavigationManager NavigationManager + +Sync + +
+ @if (CurrentDbState.Status == DbServiceState.DatabaseStatus.DecryptionFailed) + { + + } + else if (CurrentDbState.Status == DbServiceState.DatabaseStatus.PendingMigrations) + { + + } + else if (CurrentDbState.Status == DbServiceState.DatabaseStatus.Creating) + { + + } + else + { + + } + +
+ Switch accounts? Logout +
+
+ + +@code { + private DbServiceState.DatabaseState CurrentDbState { get; set; } = new(); + private const int MinimumLoadingTimeMs = 800; + + protected override async Task OnInitializedAsync() + { + await base.OnInitializedAsync(); + + DbService.GetState().StateChanged += OnDatabaseStateChanged; + CurrentDbState = DbService.GetState().CurrentState; + + await CheckAndInitializeDatabase(); + } + + private async Task CheckAndInitializeDatabase() + { + CurrentDbState = DbService.GetState().CurrentState; + if (CurrentDbState.Status == DbServiceState.DatabaseStatus.Uninitialized) + { + await InitializeDatabaseWithProgress(); + } + else if (CurrentDbState.Status == DbServiceState.DatabaseStatus.Ready) + { + await RedirectBackToReturnUrl(); + } + + StateHasChanged(); + } + + private async void OnDatabaseStateChanged(object? sender, DbServiceState.DatabaseState newState) + { + CurrentDbState = DbService.GetState().CurrentState; + if (CurrentDbState.Status == DbServiceState.DatabaseStatus.Uninitialized) + { + await InitializeDatabaseWithProgress(); + } + else if (CurrentDbState.Status == DbServiceState.DatabaseStatus.Ready) + { + await RedirectBackToReturnUrl(); + } + + StateHasChanged(); + } + + private async Task InitializeDatabaseWithProgress() + { + StateHasChanged(); + + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + + await DbService.InitializeDatabaseAsync(); + + stopwatch.Stop(); + var elapsedMs = (int)stopwatch.ElapsedMilliseconds; + + if (elapsedMs < MinimumLoadingTimeMs) + { + await Task.Delay(MinimumLoadingTimeMs - elapsedMs); + } + + await CheckAndInitializeDatabase(); + StateHasChanged(); + } + + private async Task RedirectBackToReturnUrl() + { + var localStorageReturnUrl = await LocalStorage.GetItemAsync("returnUrl"); + if (!string.IsNullOrEmpty(localStorageReturnUrl) && localStorageReturnUrl != "/sync") + { + await LocalStorage.RemoveItemAsync("returnUrl"); + NavigationManager.NavigateTo(localStorageReturnUrl); + } + else + { + NavigationManager.NavigateTo("/"); + } + } + + public void Dispose() + { + DbService.GetState().StateChanged -= OnDatabaseStateChanged; + } +} diff --git a/src/AliasVault.WebApp/Services/Database/DbService.cs b/src/AliasVault.WebApp/Services/Database/DbService.cs index 9dc118481..29a1abb2c 100644 --- a/src/AliasVault.WebApp/Services/Database/DbService.cs +++ b/src/AliasVault.WebApp/Services/Database/DbService.cs @@ -73,32 +73,14 @@ public class DbService : IDisposable return; } - _state.UpdateState(DbServiceState.DatabaseStatus.LoadingFromServer); - - // Ensure the in-memory database representation is created and has the necessary tables. - await _dbContext.Database.MigrateAsync(); - // Attempt to fill the local database with a previously saved database stored on the server. var loaded = await LoadDatabaseFromServerAsync(); if (loaded) { - Console.WriteLine("Database successfully loaded from server."); - - // Check if database is up to date with migrations. - var pendingMigrations = await _dbContext.Database.GetPendingMigrationsAsync(); - if (pendingMigrations.Any()) - { - _state.UpdateState(DbServiceState.DatabaseStatus.PendingMigrations); - } - else - { - _isSuccessfullyInitialized = true; - _state.UpdateState(DbServiceState.DatabaseStatus.Ready); - } + _retryCount = 0; } else { - _state.UpdateState(DbServiceState.DatabaseStatus.DecryptionFailed); Console.WriteLine("Failed to load database from server."); } } @@ -147,7 +129,7 @@ public class DbService : IDisposable var success = await SaveToServerAsync(encryptedBase64String); if (success) { - Console.WriteLine("Database succesfully saved to server."); + Console.WriteLine("Database successfully saved to server."); _state.UpdateState(DbServiceState.DatabaseStatus.Ready); } else @@ -205,13 +187,65 @@ public class DbService : IDisposable return true; } + /// + /// Get the current version (applied migration) of the database that is loaded in memory. + /// + /// Version as string. + public async Task GetCurrentDatabaseVersionAsync() + { + var migrations = await _dbContext.Database.GetAppliedMigrationsAsync(); + var lastMigration = migrations.LastOrDefault(); + + // Convert migration Id in the form of "20240708094944_1.0.0-InitialMigration" to "1.0.0". + if (lastMigration is not null) + { + var parts = lastMigration.Split('_'); + if (parts.Length > 1) + { + var versionPart = parts[1].Split('-')[0]; + if (Version.TryParse(versionPart, out _)) + { + return versionPart; + } + } + } + + return "Unknown"; + } + + /// + /// Get the latest available version (EF migration) as defined in code. + /// + /// Version as string. + public async Task GetLatestDatabaseVersionAsync() + { + var migrations = await _dbContext.Database.GetPendingMigrationsAsync(); + var lastMigration = migrations.LastOrDefault(); + + // Convert migration Id in the form of "20240708094944_1.0.0-InitialMigration" to "1.0.0". + if (lastMigration is not null) + { + var parts = lastMigration.Split('_'); + if (parts.Length > 1) + { + var versionPart = parts[1].Split('-')[0]; + if (Version.TryParse(versionPart, out _)) + { + return versionPart; + } + } + } + + return "Unknown"; + } + /// /// Clears the database connection and creates a new one so that the database is empty. /// /// SqliteConnection and AliasClientDbContext. public (SqliteConnection SqliteConnection, AliasClientDbContext AliasClientDbContext) InitializeEmptyDatabase() { - if (_isSuccessfullyInitialized && _sqlConnection.State == ConnectionState.Open) + if (_sqlConnection is not null && _sqlConnection.State == ConnectionState.Open) { _sqlConnection.Close(); } @@ -221,6 +255,8 @@ public class DbService : IDisposable _dbContext = new AliasClientDbContext(_sqlConnection, log => Console.WriteLine(log)); + // Reset the database state. + _state.UpdateState(DbServiceState.DatabaseStatus.Uninitialized); _isSuccessfullyInitialized = false; return (_sqlConnection, _dbContext); @@ -264,61 +300,8 @@ public class DbService : IDisposable var tempFileName = Path.GetRandomFileName(); await File.WriteAllBytesAsync(tempFileName, bytes); - /*using (var command = _sqlConnection.CreateCommand()) - { - // Empty all tables in the original database - command.CommandText = @" - SELECT 'DELETE FROM ' || name || ';' - FROM sqlite_master - WHERE type = 'table' AND name NOT LIKE 'sqlite_%';"; - var emptyTableCommands = new List(); - using (var reader = await command.ExecuteReaderAsync()) - { - while (await reader.ReadAsync()) - { - emptyTableCommands.Add(reader.GetString(0)); - } - } - - foreach (var emptyTableCommand in emptyTableCommands) - { - command.CommandText = emptyTableCommand; - await command.ExecuteNonQueryAsync(); - } - - // Attach the imported database and copy tables - command.CommandText = "ATTACH DATABASE @fileName AS importDb"; - command.Parameters.Add(new SqliteParameter("@fileName", tempFileName)); - await command.ExecuteNonQueryAsync(); - - command.CommandText = @" - SELECT 'INSERT INTO main.' || name || ' SELECT * FROM importDb.' || name || ';' - FROM sqlite_master - WHERE type = 'table' AND name NOT LIKE 'sqlite_%';"; - var tableInsertCommands = new List(); - using (var reader = await command.ExecuteReaderAsync()) - { - while (await reader.ReadAsync()) - { - tableInsertCommands.Add(reader.GetString(0)); - } - } - - foreach (var tableInsertCommand in tableInsertCommands) - { - command.CommandText = tableInsertCommand; - await command.ExecuteNonQueryAsync(); - } - - command.CommandText = "DETACH DATABASE importDb"; - await command.ExecuteNonQueryAsync(); - } - */ - using (var command = _sqlConnection.CreateCommand()) { - Console.WriteLine("Dropping main tables.."); - // Disable foreign key constraints command.CommandText = "PRAGMA foreign_keys = OFF;"; await command.ExecuteNonQueryAsync(); @@ -339,8 +322,6 @@ public class DbService : IDisposable foreach (var dropTableCommand in dropTableCommands) { - Console.WriteLine("Dropping table.."); - Console.WriteLine("Drop command: " + dropTableCommand); command.CommandText = dropTableCommand; await command.ExecuteNonQueryAsync(); } @@ -350,8 +331,6 @@ public class DbService : IDisposable command.Parameters.Add(new SqliteParameter("@fileName", tempFileName)); await command.ExecuteNonQueryAsync(); - Console.WriteLine("Make create table statements from import db.."); - // Get CREATE TABLE statements from the imported database command.CommandText = @" SELECT sql @@ -367,8 +346,6 @@ public class DbService : IDisposable } // Create tables in the main database - Console.WriteLine("Create tables in main db.."); - foreach (var createTableCommand in createTableCommands) { command.CommandText = createTableCommand; @@ -376,8 +353,6 @@ public class DbService : IDisposable } // Copy data from imported database to main database - Console.WriteLine("Copy from import to main db."); - command.CommandText = @" SELECT 'INSERT INTO main.' || name || ' SELECT * FROM importDb.' || name || ';' FROM importDb.sqlite_master @@ -415,6 +390,8 @@ public class DbService : IDisposable /// Task. private async Task LoadDatabaseFromServerAsync() { + _state.UpdateState(DbServiceState.DatabaseStatus.Loading); + // Load from webapi. try { @@ -425,28 +402,32 @@ public class DbService : IDisposable // on client is sufficient. if (string.IsNullOrEmpty(vault.Blob)) { - return true; - } - - try - { - // Attempt to decrypt the database blob. - string decryptedBase64String = await _jsRuntime.InvokeAsync("cryptoInterop.decrypt", vault.Blob, _authService.GetEncryptionKeyAsBase64Async()); - await ImportDbContextFromBase64Async(decryptedBase64String); - } - catch (Exception ex) - { - // If decryption fails it can indicate that the master password hash is not correct anymore, - // so we logout the user just in case. - Console.WriteLine(ex.Message); + // Create the database structure from scratch to get an empty ready-to-use database. + _state.UpdateState(DbServiceState.DatabaseStatus.Creating); return false; } + // Attempt to decrypt the database blob. + string decryptedBase64String = await _jsRuntime.InvokeAsync("cryptoInterop.decrypt", vault.Blob, _authService.GetEncryptionKeyAsBase64Async()); + await ImportDbContextFromBase64Async(decryptedBase64String); + + // Check if database is up to date with migrations. + var pendingMigrations = await _dbContext.Database.GetPendingMigrationsAsync(); + if (pendingMigrations.Any()) + { + _state.UpdateState(DbServiceState.DatabaseStatus.PendingMigrations); + return false; + } + + _isSuccessfullyInitialized = true; + _state.UpdateState(DbServiceState.DatabaseStatus.Ready); return true; } } - catch + catch (Exception ex) { + Console.WriteLine(ex.Message); + _state.UpdateState(DbServiceState.DatabaseStatus.DecryptionFailed); return false; } @@ -460,7 +441,8 @@ public class DbService : IDisposable /// True if save action succeeded. private async Task SaveToServerAsync(string encryptedDatabase) { - var vaultObject = new Vault(encryptedDatabase, DateTime.Now, DateTime.Now); + var databaseVersion = await GetCurrentDatabaseVersionAsync(); + var vaultObject = new Vault(encryptedDatabase, databaseVersion, DateTime.Now, DateTime.Now); try { diff --git a/src/AliasVault.WebApp/Services/Database/DbServiceState.cs b/src/AliasVault.WebApp/Services/Database/DbServiceState.cs index 114357c51..90af6ba85 100644 --- a/src/AliasVault.WebApp/Services/Database/DbServiceState.cs +++ b/src/AliasVault.WebApp/Services/Database/DbServiceState.cs @@ -29,6 +29,16 @@ public class DbServiceState ///
Uninitialized, + /// + /// Database is loading from server. + /// + Loading, + + /// + /// Database is being created. + /// + Creating, + /// /// Database failed to decrypt. No data is accessible. /// @@ -44,11 +54,6 @@ public class DbServiceState /// Ready, - /// - /// Database is loading from server. - /// - LoadingFromServer, - /// /// Database is saving to server. /// @@ -133,5 +138,22 @@ public class DbServiceState /// Gets or sets the last time the state was updated. /// public DateTime LastUpdated { get; set; } = DateTime.Now; + + /// + /// Returns true if the database state represents a initialized state. + /// + /// Bool. + public bool IsInitialized() + { + if (Status == DatabaseStatus.Uninitialized || + Status == DatabaseStatus.PendingMigrations || + Status == DatabaseStatus.Loading || + Status == DatabaseStatus.DecryptionFailed) + { + return false; + } + + return true; + } } } diff --git a/src/AliasVault.WebApp/wwwroot/css/tailwind.css b/src/AliasVault.WebApp/wwwroot/css/tailwind.css index 530061830..2542cb744 100644 --- a/src/AliasVault.WebApp/wwwroot/css/tailwind.css +++ b/src/AliasVault.WebApp/wwwroot/css/tailwind.css @@ -1147,6 +1147,11 @@ video { background-color: rgb(255 255 255 / var(--tw-bg-opacity)); } +.bg-gray-100 { + --tw-bg-opacity: 1; + background-color: rgb(243 244 246 / var(--tw-bg-opacity)); +} + .bg-opacity-50 { --tw-bg-opacity: 0.5; } @@ -1403,6 +1408,16 @@ video { color: rgb(255 255 255 / var(--tw-text-opacity)); } +.text-blue-600 { + --tw-text-opacity: 1; + color: rgb(37 99 235 / var(--tw-text-opacity)); +} + +.text-green-600 { + --tw-text-opacity: 1; + color: rgb(22 163 74 / var(--tw-text-opacity)); +} + .opacity-0 { opacity: 0; } diff --git a/src/Databases/AliasServerDb/Migrations/20240708113743_AddVaultVersionColumn.Designer.cs b/src/Databases/AliasServerDb/Migrations/20240708113743_AddVaultVersionColumn.Designer.cs new file mode 100644 index 000000000..95de2b67a --- /dev/null +++ b/src/Databases/AliasServerDb/Migrations/20240708113743_AddVaultVersionColumn.Designer.cs @@ -0,0 +1,370 @@ +// +using System; +using AliasServerDb; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace AliasServerDb.Migrations +{ + [DbContext(typeof(AliasServerDbContext))] + [Migration("20240708113743_AddVaultVersionColumn")] + partial class AddVaultVersionColumn + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.6") + .HasAnnotation("Proxies:ChangeTracking", false) + .HasAnnotation("Proxies:CheckEquality", false) + .HasAnnotation("Proxies:LazyLoading", true); + + modelBuilder.Entity("AliasServerDb.AliasVaultUser", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("Salt") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Verifier") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("AliasServerDb.AspNetUserRefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeviceIdentifier") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("ExpireDate") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserRefreshTokens"); + }); + + modelBuilder.Entity("AliasServerDb.Vault", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("VaultBlob") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Vaults"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("AliasServerDb.AspNetUserRefreshToken", b => + { + b.HasOne("AliasServerDb.AliasVaultUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("AliasServerDb.Vault", b => + { + b.HasOne("AliasServerDb.AliasVaultUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("AliasServerDb.AliasVaultUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("AliasServerDb.AliasVaultUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("AliasServerDb.AliasVaultUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("AliasServerDb.AliasVaultUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Databases/AliasServerDb/Migrations/20240708113743_AddVaultVersionColumn.cs b/src/Databases/AliasServerDb/Migrations/20240708113743_AddVaultVersionColumn.cs new file mode 100644 index 000000000..874a44306 --- /dev/null +++ b/src/Databases/AliasServerDb/Migrations/20240708113743_AddVaultVersionColumn.cs @@ -0,0 +1,30 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace AliasServerDb.Migrations +{ + /// + public partial class AddVaultVersionColumn : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Version", + table: "Vaults", + type: "TEXT", + maxLength: 255, + nullable: false, + defaultValue: ""); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Version", + table: "Vaults"); + } + } +} diff --git a/src/Databases/AliasServerDb/Migrations/AliasServerDbContextModelSnapshot.cs b/src/Databases/AliasServerDb/Migrations/AliasServerDbContextModelSnapshot.cs index 816c96039..0ec29796d 100644 --- a/src/Databases/AliasServerDb/Migrations/AliasServerDbContextModelSnapshot.cs +++ b/src/Databases/AliasServerDb/Migrations/AliasServerDbContextModelSnapshot.cs @@ -149,6 +149,11 @@ namespace AliasServerDb.Migrations .IsRequired() .HasColumnType("TEXT"); + b.Property("Version") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + b.HasKey("Id"); b.HasIndex("UserId"); diff --git a/src/Databases/AliasServerDb/Vault.cs b/src/Databases/AliasServerDb/Vault.cs index cd9b103b5..3a2256f24 100644 --- a/src/Databases/AliasServerDb/Vault.cs +++ b/src/Databases/AliasServerDb/Vault.cs @@ -37,6 +37,12 @@ public class Vault /// public string VaultBlob { get; set; } = null!; + /// + /// Gets or sets the vault data model version. + /// + [StringLength(255)] + public string Version { get; set; } = null!; + /// /// Gets or sets created timestamp. /// diff --git a/src/Tests/AliasVault.UnitTests/Vault/VaultRetentionManagerTests.cs b/src/Tests/AliasVault.UnitTests/Vault/VaultRetentionManagerTests.cs index 2f7cbaff0..f5d2d73d3 100644 --- a/src/Tests/AliasVault.UnitTests/Vault/VaultRetentionManagerTests.cs +++ b/src/Tests/AliasVault.UnitTests/Vault/VaultRetentionManagerTests.cs @@ -30,15 +30,15 @@ public class VaultRetentionManagerTests now = new DateTime(2023, 6, 1, 12, 0, 0); // Set a fixed "now" date for testing: June 1, 2023, 12:00 PM testVaults = [ - new Vault { UpdatedAt = new DateTime(2023, 5, 31, 12, 0, 0) }, - new Vault { UpdatedAt = new DateTime(2023, 5, 31, 4, 0, 0) }, - new Vault { UpdatedAt = new DateTime(2023, 5, 30, 12, 0, 0) }, // 2 days ago - new Vault { UpdatedAt = new DateTime(2023, 5, 29, 12, 0, 0) }, // 3 days ago - new Vault { UpdatedAt = new DateTime(2023, 5, 28, 12, 0, 0) }, // 4 days ago - new Vault { UpdatedAt = new DateTime(2023, 5, 18, 12, 0, 0) }, // 2 weeks ago - new Vault { UpdatedAt = new DateTime(2023, 5, 11, 12, 0, 0) }, // 3 weeks ago - new Vault { UpdatedAt = new DateTime(2023, 5, 1, 12, 0, 0) }, // 1 month ago - new Vault { UpdatedAt = new DateTime(2023, 4, 1, 12, 0, 0) }, // 2 months ago + new Vault { Version = "1.1.0", UpdatedAt = new DateTime(2023, 5, 31, 12, 0, 0) }, + new Vault { Version = "1.1.0", UpdatedAt = new DateTime(2023, 5, 31, 4, 0, 0) }, + new Vault { Version = "1.1.0", UpdatedAt = new DateTime(2023, 5, 30, 12, 0, 0) }, // 2 days ago + new Vault { Version = "1.1.0", UpdatedAt = new DateTime(2023, 5, 29, 12, 0, 0) }, // 3 days ago + new Vault { Version = "1.0.3", UpdatedAt = new DateTime(2023, 5, 28, 12, 0, 0) }, // 4 days ago + new Vault { Version = "1.0.3", UpdatedAt = new DateTime(2023, 5, 18, 12, 0, 0) }, // 2 weeks ago + new Vault { Version = "1.0.3", UpdatedAt = new DateTime(2023, 5, 11, 12, 0, 0) }, // 3 weeks ago + new Vault { Version = "1.0.2", UpdatedAt = new DateTime(2023, 5, 1, 12, 0, 0) }, // 1 month ago + new Vault { Version = "1.0.1", UpdatedAt = new DateTime(2023, 4, 1, 12, 0, 0) }, // 2 months ago ]; } @@ -101,6 +101,23 @@ public class VaultRetentionManagerTests }); } + /// + /// Test the VersionRetentionRule. + /// + [Test] + public void VersionRetentionRuleTest() + { + var rule = new VersionRetentionRule { VersionsToKeep = 2 }; + var result = rule.ApplyRule(testVaults, now).ToList(); + + Assert.Multiple(() => + { + Assert.That(result, Has.Count.EqualTo(2)); + Assert.That(result[0].UpdatedAt, Is.EqualTo(new DateTime(2023, 5, 31, 12, 0, 0))); + Assert.That(result[1].UpdatedAt, Is.EqualTo(new DateTime(2023, 5, 28, 12, 0, 0))); + }); + } + /// /// Test the RetentionPolicy object. /// @@ -114,6 +131,7 @@ public class VaultRetentionManagerTests new DailyRetentionRule { DaysToKeep = 2 }, new WeeklyRetentionRule { WeeksToKeep = 2 }, new MonthlyRetentionRule { MonthsToKeep = 1 }, + new VersionRetentionRule { VersionsToKeep = 3 }, }, }; @@ -130,11 +148,12 @@ public class VaultRetentionManagerTests Assert.Multiple(() => { - Assert.That(vaultsToKeep, Has.Count.EqualTo(3)); - Assert.That(vaultsToDelete, Has.Count.EqualTo(6)); + Assert.That(vaultsToKeep, Has.Count.EqualTo(4)); + Assert.That(vaultsToDelete, Has.Count.EqualTo(5)); Assert.That(vaultsToKeep[0].UpdatedAt, Is.EqualTo(new DateTime(2023, 5, 31, 12, 0, 0))); Assert.That(vaultsToKeep[1].UpdatedAt, Is.EqualTo(new DateTime(2023, 5, 30, 12, 0, 0))); Assert.That(vaultsToKeep[2].UpdatedAt, Is.EqualTo(new DateTime(2023, 5, 28, 12, 0, 0))); + Assert.That(vaultsToKeep[3].UpdatedAt, Is.EqualTo(new DateTime(2023, 5, 1, 12, 0, 0))); }); }