diff --git a/.github/workflows/csharp-dotnet-linux.yml b/.github/workflows/csharp-dotnet-linux.yml new file mode 100644 index 0000000..6437575 --- /dev/null +++ b/.github/workflows/csharp-dotnet-linux.yml @@ -0,0 +1,36 @@ +name: C# Build with Dot Net - Linux + +on: + push: + branches: [ "master" ] + pull_request: + branches: [ "master" ] + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 10.0.x + + - name: Restore dependencies + run: dotnet restore Upsilon.Apps.Passkey.Linux.slnx + + - name: Build Debug + run: dotnet build Upsilon.Apps.Passkey.Linux.slnx --no-restore --configuration Debug + + - name: Build Relesae + run: dotnet build Upsilon.Apps.Passkey.Linux.slnx --no-restore --configuration Release + + - name: Test + run: dotnet test --no-build --verbosity normal Upsilon.Apps.Passkey.Linux.slnx + + +permissions: + contents: read + issues: write + pull-requests: write \ No newline at end of file diff --git a/.github/workflows/csharp-dotnet.yml b/.github/workflows/csharp-dotnet-windows.yml similarity index 60% rename from .github/workflows/csharp-dotnet.yml rename to .github/workflows/csharp-dotnet-windows.yml index e89950a..97ec866 100644 --- a/.github/workflows/csharp-dotnet.yml +++ b/.github/workflows/csharp-dotnet-windows.yml @@ -1,4 +1,4 @@ -name: C# Build with CMake +name: C# Build with Dot Net - Windows on: push: @@ -15,19 +15,19 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@v4 with: - dotnet-version: 8.0.x + dotnet-version: 10.0.x - name: Restore dependencies - run: dotnet restore Upsilon.Apps.Passkey.sln + run: dotnet restore Upsilon.Apps.Passkey.Windows.slnx - name: Build Debug - run: dotnet build Upsilon.Apps.Passkey.sln --no-restore --configuration Debug + run: dotnet build Upsilon.Apps.Passkey.Windows.slnx --no-restore --configuration Debug - name: Build Relesae - run: dotnet build Upsilon.Apps.Passkey.sln --no-restore --configuration Release + run: dotnet build Upsilon.Apps.Passkey.Windows.slnx --no-restore --configuration Release - name: Test - run: dotnet test --no-build --verbosity normal Upsilon.Apps.Passkey.sln + run: dotnet test --no-build --verbosity normal Upsilon.Apps.Passkey.Windows.slnx permissions: diff --git a/Core/Internal/Models/AutoSave.cs b/Core/Internal/Models/AutoSave.cs deleted file mode 100644 index e1c73b1..0000000 --- a/Core/Internal/Models/AutoSave.cs +++ /dev/null @@ -1,95 +0,0 @@ -using Upsilon.Apps.PassKey.Core.Internal.Utils; - -namespace Upsilon.Apps.PassKey.Core.Internal.Models -{ - internal sealed class AutoSave - { - private Database? _database; - internal Database Database - { - get => _database ?? throw new NullReferenceException(nameof(Database)); - set => _database = value; - } - - public Queue Changes { get; set; } = new(); - - internal T UpdateValue(string itemId, string itemName, string fieldName, bool needsReview, T value, string readableValue) where T : notnull - { - _addChange(itemId, itemName, string.Empty, fieldName, Database.SerializationCenter.Serialize(value), readableValue, needsReview, Change.Type.Update); - - return value; - } - - internal T AddValue(string itemId, string itemName, string containerName, bool needsReview, T value) where T : notnull - { - _addChange(itemId, itemName, containerName, string.Empty, Database.SerializationCenter.Serialize(value), string.Empty, needsReview, Change.Type.Add); - - return value; - } - - internal T DeleteValue(string itemId, string itemName, string containerName, bool needsReview, T value) where T : notnull - { - _addChange(itemId, itemName, containerName, string.Empty, Database.SerializationCenter.Serialize(value), string.Empty, needsReview, Change.Type.Delete); - - return value; - } - - private void _addChange(string itemId, string itemName, string containerName, string fieldName, string value, string readableValue, bool needsReview, Change.Type action) - { - Changes.Enqueue(new Change - { - ActionType = action, - ItemId = itemId, - FieldName = fieldName, - Value = value, - }); - - if (Database.AutoSaveFileLocker == null) - { - Database.AutoSaveFileLocker = new(Database.CryptographyCenter, Database.SerializationCenter, Database.AutoSaveFile, FileMode.OpenOrCreate); - } - - Database.AutoSaveFileLocker.Save(this, Database.Passkeys); - string logMessage; - - switch (action) - { - case Change.Type.Add: - logMessage = $"{itemName} has been added to {containerName}"; - break; - case Change.Type.Delete: - logMessage = $"{itemName} has been removed from {containerName}"; - break; - case Change.Type.Update: - default: - logMessage = $"{itemName}'s {fieldName.ToSentenceCase().ToLower()} has been {(string.IsNullOrWhiteSpace(readableValue) ? $"updated" : $"set to {readableValue}")}"; - break; - } - - Database.Logs.AddLog(logMessage, needsReview); - } - - internal void MergeChange() - { - while (Changes.Count != 0) - { - Database.User?.Apply(Changes.Dequeue()); - } - - Clear(); - } - - internal void Clear() - { - Changes.Clear(); - - Database.AutoSaveFileLocker?.Dispose(); - Database.AutoSaveFileLocker = null; - - if (File.Exists(Database.AutoSaveFile)) - { - File.Delete(Database.AutoSaveFile); - } - } - } -} \ No newline at end of file diff --git a/Core/Internal/Utils/StaticMethods.cs b/Core/Internal/Utils/StaticMethods.cs deleted file mode 100644 index 01c9852..0000000 --- a/Core/Internal/Utils/StaticMethods.cs +++ /dev/null @@ -1,21 +0,0 @@ -using System.Text.RegularExpressions; - -namespace Upsilon.Apps.PassKey.Core.Internal.Utils -{ - internal static class StaticMethods - { - public static string ToSentenceCase(this string str) => Regex.Replace(str, "[a-z][A-Z]", m => $"{m.Value[0]} {char.ToLower(m.Value[1])}"); - - public static bool ContainsFlag(this T value, T lookingForFlag) where T : Enum - { - if (!typeof(T).IsEnum) - { - throw new ArgumentException("T must be an enumerated type"); - } - - int intValue = (int)(object)value; - int intLookingForFlag = (int)(object)lookingForFlag; - return (intValue & intLookingForFlag) == intLookingForFlag; - } - } -} diff --git a/Core/Internal/Models/Account.cs b/Core/Models/Account.cs similarity index 66% rename from Core/Internal/Models/Account.cs rename to Core/Models/Account.cs index a96fd67..aec8c87 100644 --- a/Core/Internal/Models/Account.cs +++ b/Core/Models/Account.cs @@ -1,15 +1,18 @@ using System.ComponentModel; -using Upsilon.Apps.PassKey.Core.Internal.Utils; -using Upsilon.Apps.PassKey.Core.Public.Enums; -using Upsilon.Apps.PassKey.Core.Public.Interfaces; +using Upsilon.Apps.Passkey.Core.Utils; +using Upsilon.Apps.Passkey.Interfaces; +using Upsilon.Apps.Passkey.Interfaces.Enums; -namespace Upsilon.Apps.PassKey.Core.Internal.Models +namespace Upsilon.Apps.Passkey.Core.Models { internal sealed class Account : IAccount { #region IAccount interface explicit Internal string IItem.ItemId => Database.Get(ItemId); + + IDatabase IItem.Database => Database; + IService IAccount.Service => Database.Get(Service); string IAccount.Label @@ -19,18 +22,20 @@ string IAccount.Label itemName: ToString(), fieldName: nameof(Label), needsReview: false, - value: value, + oldValue: Label, + newValue: value, readableValue: value); } - string[] IAccount.Identifiants + string[] IAccount.Identifiers { - get => Database.Get(Identifiants); - set => Identifiants = Database.AutoSave.UpdateValue(ItemId, + get => Database.Get(Identifiers); + set => Identifiers = Database.AutoSave.UpdateValue(ItemId, itemName: ToString(), - fieldName: nameof(Identifiants), + fieldName: nameof(Identifiers), needsReview: true, - value: value, + oldValue: Identifiers, + newValue: value, readableValue: $"({string.Join(", ", value)})"); } @@ -39,17 +44,34 @@ string IAccount.Password get => Database.Get(Password); set { - if (!string.IsNullOrEmpty(value)) + if (!string.IsNullOrEmpty(value) + && Password != value) { + Dictionary oldPasswords = Passwords.CloneWith(Database.SerializationCenter); Passwords[DateTime.Now] = Password = value; if (_service != null) { + if (Service.User.NumberOfOldPasswordToKeep != 0) + { + DateTime[] datesToRemove = [.. Passwords.Keys + .OrderBy(x => x) + .Take(Passwords.Count > Service.User.NumberOfOldPasswordToKeep + ? Passwords.Count - Service.User.NumberOfOldPasswordToKeep + : 0)]; + + foreach (DateTime dateToRemove in datesToRemove) + { + _ = Passwords.Remove(dateToRemove); + } + } + _ = Database.AutoSave.UpdateValue(ItemId, itemName: ToString(), fieldName: nameof(Password), needsReview: true, - value: Passwords, + oldValue: oldPasswords, + newValue: Passwords, readableValue: string.Empty); } } @@ -65,7 +87,8 @@ string IAccount.Notes itemName: ToString(), fieldName: nameof(Notes), needsReview: false, - value: value, + oldValue: Notes, + newValue: value, readableValue: value); } @@ -76,7 +99,8 @@ int IAccount.PasswordUpdateReminderDelay itemName: ToString(), fieldName: nameof(PasswordUpdateReminderDelay), needsReview: false, - value: value, + oldValue: PasswordUpdateReminderDelay, + newValue: value, readableValue: value.ToString()); } @@ -87,7 +111,8 @@ AccountOption IAccount.Options itemName: ToString(), fieldName: nameof(Options), needsReview: false, - value: value, + oldValue: Options, + newValue: value, readableValue: value.ToString()); } @@ -105,7 +130,7 @@ internal Service Service } public string Label { get; set; } = string.Empty; - public string[] Identifiants { get; set; } = []; + public string[] Identifiers { get; set; } = []; public string Password { get; set; } = string.Empty; public Dictionary Passwords { get; set; } = []; public string Notes { get; set; } = string.Empty; @@ -136,23 +161,23 @@ public void Apply(Change change) switch (change.FieldName) { case nameof(Label): - Label = Database.SerializationCenter.Deserialize(change.Value); + Label = change.NewValue.DeserializeTo(Database.SerializationCenter); break; - case nameof(Identifiants): - Identifiants = Database.SerializationCenter.Deserialize(change.Value); + case nameof(Identifiers): + Identifiers = change.NewValue.DeserializeTo(Database.SerializationCenter); break; case nameof(Notes): - Notes = Database.SerializationCenter.Deserialize(change.Value); + Notes = change.NewValue.DeserializeTo(Database.SerializationCenter); break; case nameof(Password): - Passwords = Database.SerializationCenter.Deserialize>(change.Value); + Passwords = change.NewValue.DeserializeTo>(Database.SerializationCenter); Password = Passwords.Count != 0 ? Passwords[Passwords.Keys.Max()] : string.Empty; break; case nameof(PasswordUpdateReminderDelay): - PasswordUpdateReminderDelay = Database.SerializationCenter.Deserialize(change.Value); + PasswordUpdateReminderDelay = change.NewValue.DeserializeTo(Database.SerializationCenter); break; case nameof(Options): - Options = Database.SerializationCenter.Deserialize(change.Value); + Options = change.NewValue.DeserializeTo(Database.SerializationCenter); break; default: throw new InvalidDataException("FieldName not valid"); @@ -172,7 +197,7 @@ public override string ToString() account += $"{Label} "; } - return account + $"({string.Join(", ", Identifiants)})"; + return account + $"({string.Join(", ", Identifiers)})"; } } } \ No newline at end of file diff --git a/Core/Models/AutoSave.cs b/Core/Models/AutoSave.cs new file mode 100644 index 0000000..16c9d5d --- /dev/null +++ b/Core/Models/AutoSave.cs @@ -0,0 +1,183 @@ +using Upsilon.Apps.Passkey.Core.Utils; + +namespace Upsilon.Apps.Passkey.Core.Models +{ + internal sealed class AutoSave + { + internal Database Database + { + get => field ?? throw new NullReferenceException(nameof(Database)); + set; + } + + public Dictionary> Changes { get; set; } = []; + + internal T UpdateValue(string itemId, + string itemName, + string fieldName, + bool needsReview, + T oldValue, + T newValue, + string readableValue) where T : notnull + { + if (Database.SerializationCenter.AreDifferent(oldValue, newValue)) + { + _addChange(itemId, + itemName, + string.Empty, + fieldName, + oldValue.SerializeWith(Database.SerializationCenter), + newValue.SerializeWith(Database.SerializationCenter), + readableValue, + needsReview, + Change.Type.Update); + } + + return newValue; + } + + internal T AddValue(string itemId, + string itemName, + string containerName, + bool needsReview, + T value) where T : notnull + { + _addChange(itemId, itemName, containerName, string.Empty, value.SerializeWith(Database.SerializationCenter), string.Empty, needsReview, Change.Type.Add); + + return value; + } + + internal T DeleteValue(string itemId, + string itemName, + string containerName, + bool needsReview, + T value) where T : notnull + { + _addChange(itemId, itemName, containerName, string.Empty, value.SerializeWith(Database.SerializationCenter), string.Empty, needsReview, Change.Type.Delete); + + return value; + } + + private void _addChange(string itemId, + string itemName, + string containerName, + string fieldName, + string newValue, + string readableValue, + bool needsReview, + Change.Type action) + { + _addChange(itemId, + itemName, + containerName, + fieldName, + null, + newValue, + readableValue, + needsReview, + action); + } + + private void _addChange(string itemId, + string itemName, + string containerName, + string fieldName, + string? oldValue, + string newValue, + string readableValue, + bool needsReview, + Change.Type action) + { + string changeKey = $"{itemId}\t{fieldName}"; + if (!Changes.ContainsKey(changeKey)) + { + Changes[changeKey] = []; + } + + Change currentChange = new() + { + Index = DateTime.Now.Ticks, + ActionType = action, + ItemId = itemId, + FieldName = fieldName, + OldValue = oldValue, + NewValue = newValue, + }; + + _mergeChanges(changeKey, currentChange); + + if (Database.AutoSaveFileLocker == null) + { + Database.AutoSaveFileLocker = new(Database.CryptographyCenter, Database.SerializationCenter, Database.AutoSaveFile, FileMode.OpenOrCreate); + } + + Database.AutoSaveFileLocker.Save(this, Database.Passkeys); + string logMessage = action switch + { + Change.Type.Add => $"{itemName} has been added to {containerName}", + Change.Type.Delete => $"{itemName} has been removed from {containerName}", + _ => $"{itemName}'s {fieldName.ToSentenceCase().ToLower()} has been {(string.IsNullOrWhiteSpace(readableValue) ? $"updated" : $"set to {readableValue}")}", + }; + Database.Logs.AddLog(logMessage, needsReview); + } + + private void _mergeChanges(string changeKey, Change currentChange) + { + Change? lastUpdate = Changes[changeKey].LastOrDefault(x => x.ActionType == Change.Type.Update); + + if (currentChange.ActionType != Change.Type.Update + || lastUpdate is null) + { + Changes[changeKey].Add(currentChange); + return; + } + + _ = Changes[changeKey].Remove(lastUpdate); + currentChange.OldValue = lastUpdate.OldValue; + + if (currentChange.OldValue != currentChange.NewValue) + { + Changes[changeKey].Add(currentChange); + } + else if (Changes[changeKey].Count == 0) + { + _ = Changes.Remove(changeKey); + } + } + + internal void ApplyChanges(bool deleteFile) + { + List changes = Changes.Values.SelectMany(x => x).OrderBy(x => x.Index).ToList(); + + foreach (Change change in changes) + { + Database.User?.Apply(change); + } + + if (deleteFile) + { + Clear(deleteFile: true); + } + } + + internal bool Any() => Any(string.Empty); + + internal bool Any(string itemId) => Changes.Any(x => x.Key.StartsWith(itemId)); + + internal bool Any(string itemId, string fieldName) => Changes.Any(x => x.Key == $"{itemId}\t{fieldName}"); + + internal void Clear(bool deleteFile) + { + Changes.Clear(); + + Database.AutoSaveFileLocker?.Dispose(); + Database.AutoSaveFileLocker = null; + + if (deleteFile + && File.Exists(Database.AutoSaveFile)) + { + File.Delete(Database.AutoSaveFile); + } + } + } +} \ No newline at end of file diff --git a/Core/Internal/Models/Change.cs b/Core/Models/Change.cs similarity index 61% rename from Core/Internal/Models/Change.cs rename to Core/Models/Change.cs index ac79abc..67ba50c 100644 --- a/Core/Internal/Models/Change.cs +++ b/Core/Models/Change.cs @@ -1,4 +1,4 @@ -namespace Upsilon.Apps.PassKey.Core.Internal.Models +namespace Upsilon.Apps.Passkey.Core.Models { internal sealed class Change { @@ -10,9 +10,11 @@ public enum Type Delete = 3, } + public long Index { get; set; } = long.MaxValue; public Type ActionType { get; set; } = Type.None; public string ItemId { get; set; } = string.Empty; public string FieldName { get; set; } = string.Empty; - public string Value { get; set; } = string.Empty; + public string? OldValue { get; set; } = null; + public string NewValue { get; set; } = string.Empty; } } \ No newline at end of file diff --git a/Core/Internal/Models/Database.cs b/Core/Models/Database.cs similarity index 66% rename from Core/Internal/Models/Database.cs rename to Core/Models/Database.cs index 2979a2b..f211829 100644 --- a/Core/Internal/Models/Database.cs +++ b/Core/Models/Database.cs @@ -1,12 +1,12 @@ -using Upsilon.Apps.PassKey.Core.Internal.Utils; -using Upsilon.Apps.PassKey.Core.Public.Enums; -using Upsilon.Apps.PassKey.Core.Public.Events; -using Upsilon.Apps.PassKey.Core.Public.Interfaces; -using Upsilon.Apps.PassKey.Core.Public.Utils; +using Upsilon.Apps.Passkey.Core.Utils; +using Upsilon.Apps.Passkey.Interfaces; +using Upsilon.Apps.Passkey.Interfaces.Enums; +using Upsilon.Apps.Passkey.Interfaces.Events; +using Upsilon.Apps.Passkey.Interfaces.Utils; -namespace Upsilon.Apps.PassKey.Core.Internal.Models +namespace Upsilon.Apps.Passkey.Core.Models { - internal sealed class Database : IDatabase + public sealed class Database : IDatabase { #region IUser interface explicit Internal @@ -15,6 +15,7 @@ internal sealed class Database : IDatabase public string LogFile { get; set; } IUser? IDatabase.User => Get(User); + int? IDatabase.SessionLeftTime => User?.SessionLeftTime; ILog[]? IDatabase.Logs => Get(Logs.Logs); @@ -23,6 +24,7 @@ internal sealed class Database : IDatabase public ICryptographyCenter CryptographyCenter { get; private set; } public ISerializationCenter SerializationCenter { get; private set; } public IPasswordFactory PasswordFactory { get; private set; } + public IClipboardManager ClipboardManager { get; private set; } public event EventHandler? WarningDetected; public event EventHandler? AutoSaveDetected; @@ -31,7 +33,7 @@ internal sealed class Database : IDatabase public void Delete() { - if (User == null) throw new NullReferenceException(nameof(User)); + if (User is null) throw new NullReferenceException(nameof(User)); DatabaseFileLocker?.Delete(); LogFileLocker?.Delete(); @@ -80,21 +82,7 @@ public void Delete() _handleAutoSave(eventArg.MergeBehavior); } - Warning[] logWarnings = _lookAtLogWarnings(); - Warning[] passwordUpdateReminderWarnings = _lookAtPasswordUpdateReminderWarnings(); - Warning[] passwordLeakedWarnings = _lookAtPasswordLeakedWarnings(); - Warning[] duplicatedPasswordsWarnings = _lookAtDuplicatedPasswordsWarnings(); - - Warnings = [..logWarnings, - ..passwordUpdateReminderWarnings, - ..passwordLeakedWarnings, - ..duplicatedPasswordsWarnings]; - - WarningDetected?.Invoke(this, new WarningDetectedEventArgs( - [..User.WarningsToNotify.ContainsFlag(WarningType.LogReviewWarning) ? logWarnings : [], - ..User.WarningsToNotify.ContainsFlag(WarningType.PasswordUpdateReminderWarning) ? passwordUpdateReminderWarnings : [], - ..User.WarningsToNotify.ContainsFlag(WarningType.PasswordLeakedWarning) ? passwordLeakedWarnings : [], - ..User.WarningsToNotify.ContainsFlag(WarningType.DuplicatedPasswordsWarning) ? duplicatedPasswordsWarnings : []])); + _ = Task.Run(_lookAtWarnings); User.ResetTimer(); } @@ -104,6 +92,90 @@ public void Delete() public void Close() => Dispose(); + public bool HasChanged() => User is not null && HasChanged(User.ItemId); + + public bool HasChanged(string itemId) => AutoSave.Any(itemId); + + public bool HasChanged(string itemId, string fieldName) => AutoSave.Any(itemId, fieldName); + + public bool ImportFromFile(string filePath) + { + _save(logSaveEvent: true); + Logs.AddLog($"Importing data from file : '{filePath}'", needsReview: true); + + string importContent = string.Empty; + string errorLog = string.Empty; + + try + { + importContent = File.ReadAllText(filePath); + } + catch + { + errorLog = $"import file is not accessible"; + } + + if (string.IsNullOrWhiteSpace(errorLog)) + { + string extention = Path.GetExtension(filePath); + + errorLog = extention switch + { + ".json" => this.ImportJson(importContent), + ".csv" => this.ImportCSV(importContent), + _ => $"'{extention}' extention type is not handled", + }; + } + + if (string.IsNullOrWhiteSpace(errorLog)) + { + Logs.AddLog($"Import completed successfully", needsReview: true); + _save(logSaveEvent: true); + } + else + { + Logs.AddLog($"Import failed because {errorLog}", needsReview: true); + } + + return string.IsNullOrWhiteSpace(errorLog); + } + + public bool ExportToFile(string filePath) + { + _save(logSaveEvent: true); + Logs.AddLog($"Exporting data to file : '{filePath}'", needsReview: true); + + string errorLog = string.Empty; + + if (File.Exists(filePath)) + { + errorLog = $"export file already exists"; + } + + if (string.IsNullOrWhiteSpace(errorLog)) + { + string extention = Path.GetExtension(filePath); + + errorLog = extention switch + { + ".json" => this.ExportJson(filePath), + ".csv" => this.ExportCSV(filePath), + _ => $"'{extention}' extention type is not handled", + }; + } + + if (string.IsNullOrWhiteSpace(errorLog)) + { + Logs.AddLog($"Export completed successfully", needsReview: true); + } + else + { + Logs.AddLog($"Export failed because {errorLog}", needsReview: true); + } + + return string.IsNullOrWhiteSpace(errorLog); + } + #endregion internal User? User; @@ -121,6 +193,7 @@ public void Delete() private Database(ICryptographyCenter cryptographicCenter, ISerializationCenter serializationCenter, IPasswordFactory passwordFactory, + IClipboardManager clipboardManager, string databaseFile, string autoSaveFile, string logFile, @@ -136,6 +209,7 @@ private Database(ICryptographyCenter cryptographicCenter, CryptographyCenter = cryptographicCenter; SerializationCenter = serializationCenter; PasswordFactory = passwordFactory; + ClipboardManager = clipboardManager; Username = username; Passkeys = [CryptographyCenter.GetHash(username)]; @@ -165,9 +239,10 @@ private Database(ICryptographyCenter cryptographicCenter, Logs.Database = this; } - internal static IDatabase Create(ICryptographyCenter cryptographicCenter, + public static IDatabase Create(ICryptographyCenter cryptographicCenter, ISerializationCenter serializationCenter, IPasswordFactory passwordFactory, + IClipboardManager clipboardManager, string databaseFile, string autoSaveFile, string logFile, @@ -191,6 +266,7 @@ internal static IDatabase Create(ICryptographyCenter cryptographicCenter, Database database = new(cryptographicCenter, serializationCenter, passwordFactory, + clipboardManager, databaseFile, autoSaveFile, logFile, @@ -212,20 +288,13 @@ internal static IDatabase Create(ICryptographyCenter cryptographicCenter, database._save(logSaveEvent: false); - database.Close(logCloseEvent: false, loginTimeoutReached: false); - - return Open(cryptographicCenter, - serializationCenter, - passwordFactory, - databaseFile, - autoSaveFile, - logFile, - username); + return database; } - internal static IDatabase Open(ICryptographyCenter cryptographicCenter, + public static IDatabase Open(ICryptographyCenter cryptographicCenter, ISerializationCenter serializationCenter, IPasswordFactory passwordFactory, + IClipboardManager clipboardManager, string databaseFile, string autoSaveFile, string logFile, @@ -234,6 +303,7 @@ internal static IDatabase Open(ICryptographyCenter cryptographicCenter, Database database = new(cryptographicCenter, serializationCenter, passwordFactory, + clipboardManager, databaseFile, autoSaveFile, logFile, @@ -254,28 +324,39 @@ internal T Get(T value) private void _save(bool logSaveEvent) { - if (User == null) throw new NullReferenceException(nameof(User)); + _saveLogs(); + _saveDatabase(logSaveEvent); + } + + private void _saveDatabase(bool logSaveEvent) + { + if (User is null) throw new NullReferenceException(nameof(User)); if (DatabaseFileLocker == null) throw new NullReferenceException(nameof(DatabaseFileLocker)); Username = User.Username; - Passkeys = [CryptographyCenter.GetHash(User.Username), .. User.Passkeys.Select(x => CryptographyCenter.GetSlowHash(x))]; + Passkeys = [CryptographyCenter.GetHash(User.Username), .. User.Passkeys.Select(CryptographyCenter.GetSlowHash)]; DatabaseFileLocker.Save(User, Passkeys); - Logs.Username = Username; - LogFileLocker?.Save(Logs, [CryptographyCenter.GetHash(User.Username)]); - if (logSaveEvent) { Logs.AddLog($"User {Username}'s database saved", needsReview: false); } - AutoSave.Clear(); + AutoSave.Clear(deleteFile: true); User.ResetTimer(); DatabaseSaved?.Invoke(this, EventArgs.Empty); } + private void _saveLogs() + { + if (User is null) throw new NullReferenceException(nameof(User)); + + Logs.Username = User.Username; + LogFileLocker?.Save(Logs, [CryptographyCenter.GetHash(User.Username)]); + } + internal void Close(bool logCloseEvent, bool loginTimeoutReached) { if (logCloseEvent) @@ -283,12 +364,16 @@ internal void Close(bool logCloseEvent, bool loginTimeoutReached) if (User != null) { string logoutLog = $"User {Username} logged out"; - bool needsReview = AutoSave.Changes.Count != 0; + bool needsReview = AutoSave.Any(); if (needsReview) { logoutLog += " without saving"; } + else + { + AutoSave.Clear(deleteFile: true); + } Logs.AddLog(logoutLog, needsReview); } @@ -297,7 +382,6 @@ internal void Close(bool logCloseEvent, bool loginTimeoutReached) } User = null; - AutoSave.Changes.Clear(); Username = string.Empty; Passkeys = []; Warnings = null; @@ -308,8 +392,7 @@ internal void Close(bool logCloseEvent, bool loginTimeoutReached) LogFileLocker?.Dispose(); LogFileLocker = null; - AutoSaveFileLocker?.Dispose(); - AutoSaveFileLocker = null; + AutoSave.Clear(deleteFile: false); DatabaseFile = string.Empty; AutoSaveFile = string.Empty; @@ -320,7 +403,7 @@ internal void Close(bool logCloseEvent, bool loginTimeoutReached) private void _handleAutoSave(AutoSaveMergeBehavior mergeAutoSave) { - if (User == null) throw new NullReferenceException(nameof(User)); + if (User is null) throw new NullReferenceException(nameof(User)); if (!File.Exists(AutoSaveFile)) { @@ -329,13 +412,18 @@ private void _handleAutoSave(AutoSaveMergeBehavior mergeAutoSave) switch (mergeAutoSave) { - case AutoSaveMergeBehavior.MergeThenRemoveAutoSaveFile: - AutoSave.MergeChange(); - Logs.AddLog($"User {Username}'s autosave merged", needsReview: true); + case AutoSaveMergeBehavior.MergeAndSaveThenRemoveAutoSaveFile: + AutoSave.ApplyChanges(deleteFile: true); + Logs.AddLog($"User {Username}'s autosave merged and saved", needsReview: true); _save(logSaveEvent: false); break; + case AutoSaveMergeBehavior.MergeWithoutSavingAndKeepAutoSaveFile: + AutoSave.ApplyChanges(deleteFile: false); + Logs.AddLog($"User {Username}'s autosave merged without saving", needsReview: true); + _saveLogs(); + break; case AutoSaveMergeBehavior.DontMergeAndRemoveAutoSaveFile: - AutoSave.Clear(); + AutoSave.Clear(deleteFile: true); Logs.AddLog($"User {Username}'s autosave not merged and removed", needsReview: true); break; case AutoSaveMergeBehavior.DontMergeAndKeepAutoSaveFile: @@ -345,12 +433,37 @@ private void _handleAutoSave(AutoSaveMergeBehavior mergeAutoSave) } } + private void _lookAtWarnings() + { + if (User is null) return; + + try + { + Warning[] logWarnings = _lookAtLogWarnings(); + Warning[] passwordUpdateReminderWarnings = _lookAtPasswordUpdateReminderWarnings(); + Warning[] passwordLeakedWarnings = _lookAtPasswordLeakedWarnings(); + Warning[] duplicatedPasswordsWarnings = _lookAtDuplicatedPasswordsWarnings(); + + Warnings = [..logWarnings, + ..passwordUpdateReminderWarnings, + ..passwordLeakedWarnings, + ..duplicatedPasswordsWarnings]; + + WarningDetected?.Invoke(this, new WarningDetectedEventArgs( + [..User.WarningsToNotify.ContainsFlag(WarningType.LogReviewWarning) ? logWarnings : [], + ..User.WarningsToNotify.ContainsFlag(WarningType.PasswordUpdateReminderWarning) ? passwordUpdateReminderWarnings : [], + ..User.WarningsToNotify.ContainsFlag(WarningType.PasswordLeakedWarning) ? passwordLeakedWarnings : [], + ..User.WarningsToNotify.ContainsFlag(WarningType.DuplicatedPasswordsWarning) ? duplicatedPasswordsWarnings : []])); + } + catch { } + } + private Warning[] _lookAtLogWarnings() { - if (User == null) throw new NullReferenceException(nameof(User)); + if (User is null) throw new NullReferenceException(nameof(User)); if (Logs.Logs == null) throw new NullReferenceException(nameof(Logs.Logs)); - List logs = Logs.Logs.Cast().ToList(); + List logs = [.. Logs.Logs.Cast()]; for (int i = 0; i < logs.Count && logs[i].Message != $"User {Username} logged in"; i++) { @@ -367,37 +480,34 @@ private Warning[] _lookAtLogWarnings() private Warning[] _lookAtPasswordUpdateReminderWarnings() { - if (User == null) throw new NullReferenceException(nameof(User)); + if (User is null) return []; - Account[] accounts = User.Services + Account[] accounts = [.. User.Services .SelectMany(x => x.Accounts) - .Where(x => x.PasswordExpired) - .ToArray(); + .Where(x => x.PasswordExpired)]; - return accounts.Length != 0 ? ([new Warning(WarningType.PasswordUpdateReminderWarning, accounts)]) : ([]); + return accounts.Length != 0 ? [new Warning(WarningType.PasswordUpdateReminderWarning, accounts)] : []; } private Warning[] _lookAtPasswordLeakedWarnings() { - if (User == null) throw new NullReferenceException(nameof(User)); + if (User is null) return []; - Account[] accounts = User.Services + Account[] accounts = [.. User.Services .SelectMany(x => x.Accounts) - .Where(x => x.PasswordLeaked) - .ToArray(); + .Where(x => x.PasswordLeaked)]; - return accounts.Length != 0 ? ([new Warning(WarningType.PasswordLeakedWarning, accounts)]) : ([]); + return accounts.Length != 0 ? [new Warning(WarningType.PasswordLeakedWarning, accounts)] : []; } private Warning[] _lookAtDuplicatedPasswordsWarnings() { - if (User == null) throw new NullReferenceException(nameof(User)); + if (User is null) return []; - IGrouping[] duplicatedPasswords = User.Services + IGrouping[] duplicatedPasswords = [.. User.Services .SelectMany(x => x.Accounts) .GroupBy(x => x.Password) - .Where(x => x.Count() > 1) - .ToArray(); + .Where(x => x.Count() > 1)]; List warnings = []; diff --git a/Core/Internal/Models/Log.cs b/Core/Models/Log.cs similarity index 64% rename from Core/Internal/Models/Log.cs rename to Core/Models/Log.cs index 0f6bf63..15837fd 100644 --- a/Core/Internal/Models/Log.cs +++ b/Core/Models/Log.cs @@ -1,6 +1,6 @@ -using Upsilon.Apps.PassKey.Core.Public.Interfaces; +using Upsilon.Apps.Passkey.Interfaces; -namespace Upsilon.Apps.PassKey.Core.Internal.Models +namespace Upsilon.Apps.Passkey.Core.Models { internal class Log : ILog { diff --git a/Core/Internal/Models/Service.cs b/Core/Models/Service.cs similarity index 68% rename from Core/Internal/Models/Service.cs rename to Core/Models/Service.cs index 8ba94e8..13f1dab 100644 --- a/Core/Internal/Models/Service.cs +++ b/Core/Models/Service.cs @@ -1,13 +1,17 @@ using System.ComponentModel; -using Upsilon.Apps.PassKey.Core.Public.Interfaces; +using Upsilon.Apps.Passkey.Core.Utils; +using Upsilon.Apps.Passkey.Interfaces; -namespace Upsilon.Apps.PassKey.Core.Internal.Models +namespace Upsilon.Apps.Passkey.Core.Models { internal sealed class Service : IService { #region IService interface explicit Internal string IItem.ItemId => Database.Get(ItemId); + + IDatabase IItem.Database => Database; + IUser IService.User => Database.Get(User); IAccount[] IService.Accounts => [.. Database.Get(Accounts)]; @@ -18,7 +22,8 @@ string IService.ServiceName itemName: ToString(), fieldName: nameof(ServiceName), needsReview: true, - value: value, + oldValue: ServiceName, + newValue: value, readableValue: value); } @@ -29,7 +34,8 @@ string IService.Url itemName: ToString(), fieldName: nameof(Url), needsReview: false, - value: value, + oldValue: Url, + newValue: value, readableValue: value); } @@ -40,27 +46,50 @@ string IService.Notes itemName: ToString(), fieldName: nameof(Notes), needsReview: false, - value: value, + oldValue: Notes, + newValue: value, readableValue: value); } - IAccount IService.AddAccount(string label, IEnumerable identifiants, string password) + public IAccount AddAccount(string label, IEnumerable identifiers, string password) { Account account = new() { Service = this, - ItemId = ItemId + Database.CryptographyCenter.GetHash(label + string.Join(string.Empty, identifiants)), + ItemId = ItemId + Database.CryptographyCenter.GetHash(label + string.Join(string.Empty, identifiers)), Label = label, - Identifiants = identifiants.ToArray(), + Identifiers = [.. identifiers], Password = password, }; - account.Passwords[DateTime.Now] = password; Accounts.Add(Database.AutoSave.AddValue(ItemId, itemName: account.ToString(), containerName: ToString(), needsReview: false, account)); + account.Passwords[DateTime.Now] = Database.AutoSave.UpdateValue(account.ItemId, + itemName: account.ToString(), + fieldName: nameof(account.Password), + needsReview: true, + oldValue: string.Empty, + newValue: account.Password, + readableValue: string.Empty); + return account; } + public IAccount AddAccount(string label, IEnumerable identifiers) + { + return AddAccount(label, identifiers, password: string.Empty); + } + + public IAccount AddAccount(IEnumerable identifiers, string password) + { + return AddAccount(label: string.Empty, identifiers, password); + } + + public IAccount AddAccount(IEnumerable identifiers) + { + return AddAccount(label: string.Empty, identifiers, password: string.Empty); + } + void IService.DeleteAccount(IAccount account) { Account accountToRemove = Accounts.FirstOrDefault(x => x.ItemId == account.ItemId) @@ -76,13 +105,12 @@ void IService.DeleteAccount(IAccount account) public string ItemId { get; set; } = string.Empty; - private User? _user; internal User User { - get => _user ?? throw new NullReferenceException(nameof(User)); + get => field ?? throw new NullReferenceException(nameof(User)); set { - _user = value; + field = value; foreach (Account account in Accounts) { @@ -123,25 +151,25 @@ private void _apply(Change change) switch (change.FieldName) { case nameof(ServiceName): - ServiceName = Database.SerializationCenter.Deserialize(change.Value); + ServiceName = change.NewValue.DeserializeTo(Database.SerializationCenter); break; case nameof(Url): - Url = Database.SerializationCenter.Deserialize(change.Value); + Url = change.NewValue.DeserializeTo(Database.SerializationCenter); break; case nameof(Notes): - Notes = Database.SerializationCenter.Deserialize(change.Value); + Notes = change.NewValue.DeserializeTo(Database.SerializationCenter); break; default: throw new InvalidDataException("FieldName not valid"); } break; case Change.Type.Add: - Account accountToAdd = Database.SerializationCenter.Deserialize(change.Value); + Account accountToAdd = change.NewValue.DeserializeTo(Database.SerializationCenter); accountToAdd.Service = this; Accounts.Add(accountToAdd); break; case Change.Type.Delete: - Account accountToDelete = Database.SerializationCenter.Deserialize(change.Value); + Account accountToDelete = change.NewValue.DeserializeTo(Database.SerializationCenter); _ = Accounts.RemoveAll(x => x.ItemId == accountToDelete.ItemId); break; default: diff --git a/Core/Internal/Models/User.cs b/Core/Models/User.cs similarity index 60% rename from Core/Internal/Models/User.cs rename to Core/Models/User.cs index 893bde0..023aa6d 100644 --- a/Core/Internal/Models/User.cs +++ b/Core/Models/User.cs @@ -1,37 +1,52 @@ using System.ComponentModel; -using Upsilon.Apps.PassKey.Core.Public.Enums; -using Upsilon.Apps.PassKey.Core.Public.Interfaces; -using Upsilon.Apps.PassKey.Core.Public.Utils; +using Upsilon.Apps.Passkey.Core.Utils; +using Upsilon.Apps.Passkey.Interfaces; +using Upsilon.Apps.Passkey.Interfaces.Enums; -namespace Upsilon.Apps.PassKey.Core.Internal.Models +namespace Upsilon.Apps.Passkey.Core.Models { internal sealed class User : IUser { #region IUser interface explicit Internal string IItem.ItemId => Database.Get(ItemId); + + IDatabase IItem.Database => Database; + IService[] IUser.Services => [.. Database.Get(Services)]; string IUser.Username { get => Database.Get(Username); - set => Username = Database.AutoSave.UpdateValue(ItemId, - itemName: ToString(), - fieldName: nameof(Username), - needsReview: true, - value: value, - readableValue: value); + set + { + CredentialChanged |= Username != value; + + Username = Database.AutoSave.UpdateValue(ItemId, + itemName: ToString(), + fieldName: nameof(Username), + needsReview: true, + oldValue: Username, + newValue: value, + readableValue: value); + } } string[] IUser.Passkeys { get => Database.Get(Passkeys); - set => Passkeys = Database.AutoSave.UpdateValue(ItemId, - itemName: ToString(), - fieldName: nameof(Passkeys), - needsReview: true, - value: value, - readableValue: string.Empty); + set + { + CredentialChanged |= Database.SerializationCenter.AreDifferent(Passkeys, value); + + Passkeys = Database.AutoSave.UpdateValue(ItemId, + itemName: ToString(), + fieldName: nameof(Passkeys), + needsReview: true, + oldValue: Passkeys, + newValue: value, + readableValue: string.Empty); + } } int IUser.LogoutTimeout @@ -41,12 +56,11 @@ int IUser.LogoutTimeout itemName: ToString(), fieldName: nameof(LogoutTimeout), needsReview: false, - value: value, + oldValue: LogoutTimeout, + newValue: value, readableValue: value.ToString()); } - int IUser.SessionLeftTime => _sessionLeftTime; - int IUser.CleaningClipboardTimeout { get => Database.Get(CleaningClipboardTimeout); @@ -54,10 +68,54 @@ int IUser.CleaningClipboardTimeout itemName: ToString(), fieldName: nameof(CleaningClipboardTimeout), needsReview: false, - value: value, + oldValue: CleaningClipboardTimeout, + newValue: value, readableValue: value.ToString()); } + int IUser.ShowPasswordDelay + { + get => Database.Get(ShowPasswordDelay); + set => ShowPasswordDelay = Database.AutoSave.UpdateValue(ItemId, + itemName: ToString(), + fieldName: nameof(ShowPasswordDelay), + needsReview: false, + oldValue: ShowPasswordDelay, + newValue: value, + readableValue: value.ToString()); + } + + int IUser.NumberOfOldPasswordToKeep + { + get => Database.Get(NumberOfOldPasswordToKeep); + set + { + NumberOfOldPasswordToKeep = Database.AutoSave.UpdateValue(ItemId, + itemName: ToString(), + fieldName: nameof(NumberOfOldPasswordToKeep), + needsReview: true, + oldValue: NumberOfOldPasswordToKeep, + newValue: value, + readableValue: value.ToString()); + + if (NumberOfOldPasswordToKeep == 0) return; + + Account[] accounts = [.. Services.SelectMany(x => x.Accounts).Where(x => x.Passwords.Count > NumberOfOldPasswordToKeep)]; + + foreach (Account account in accounts) + { + DateTime[] datesToRemove = [.. account.Passwords.Keys + .OrderBy(x => x) + .Take(account.Passwords.Count - NumberOfOldPasswordToKeep)]; + + foreach (DateTime dateToRemove in datesToRemove) + { + _ = account.Passwords.Remove(dateToRemove); + } + } + } + } + WarningType IUser.WarningsToNotify { get => Database.Get(WarningsToNotify); @@ -65,7 +123,8 @@ WarningType IUser.WarningsToNotify itemName: ToString(), fieldName: nameof(WarningsToNotify), needsReview: true, - value: value, + oldValue: WarningsToNotify, + newValue: value, readableValue: value.ToString()); } @@ -93,13 +152,12 @@ void IUser.DeleteService(IService service) #endregion - private Database? _database; internal Database Database { - get => _database ?? throw new NullReferenceException(nameof(Database)); + get => field ?? throw new NullReferenceException(nameof(Database)); set { - _database = value; + field = value; foreach (Service service in Services) { @@ -115,8 +173,11 @@ internal Database Database public string Username { get; set; } = string.Empty; public string[] Passkeys { get; set; } = []; + public bool CredentialChanged { get; set; } = false; public int LogoutTimeout { get; set; } = 0; public int CleaningClipboardTimeout { get; set; } = 0; + public int ShowPasswordDelay { get; set; } = 0; + public int NumberOfOldPasswordToKeep { get; set; } = 0; public WarningType WarningsToNotify { get; set; } = WarningType.LogReviewWarning | WarningType.PasswordUpdateReminderWarning @@ -130,7 +191,7 @@ internal Database Database Interval = 1000, }; - private int _sessionLeftTime = 0; + public int SessionLeftTime = 0; private int _clipboardLeftTime = 0; public User() @@ -142,9 +203,9 @@ private void _timer_Elapsed(object? sender, System.Timers.ElapsedEventArgs e) { if (LogoutTimeout != 0) { - _sessionLeftTime--; + SessionLeftTime--; - if (_sessionLeftTime == 0) + if (SessionLeftTime == 0) { Database.Logs.AddLog($"User {Username}'s login session timeout reached", needsReview: true); Database.Close(logCloseEvent: true, loginTimeoutReached: true); @@ -169,9 +230,9 @@ private void _timer_Elapsed(object? sender, System.Timers.ElapsedEventArgs e) private void _cleanClipboard() { - var passwords = Services.SelectMany(x => x.Accounts).Select(x => x.Password).ToArray(); + string[] passwords = [.. Services.SelectMany(x => x.Accounts).SelectMany(x => x.Passwords.Values)]; - var cleanedPasswordsCount = ClipboardManager.RemoveAllOccurence(passwords); + int cleanedPasswordsCount = Database.ClipboardManager.RemoveAllOccurence(passwords); if (cleanedPasswordsCount != 0) { @@ -181,7 +242,7 @@ private void _cleanClipboard() public void ResetTimer() { - _sessionLeftTime = LogoutTimeout * 60; + SessionLeftTime = LogoutTimeout * 60; _clipboardLeftTime = CleaningClipboardTimeout; } @@ -212,31 +273,33 @@ private void _apply(Change change) switch (change.FieldName) { case nameof(Username): - Username = Database.SerializationCenter.Deserialize(change.Value); + CredentialChanged = true; + Username = change.NewValue.DeserializeTo(Database.SerializationCenter); break; case nameof(Passkeys): - Passkeys = Database.SerializationCenter.Deserialize(change.Value); + CredentialChanged = true; + Passkeys = change.NewValue.DeserializeTo(Database.SerializationCenter); break; case nameof(LogoutTimeout): - LogoutTimeout = Database.SerializationCenter.Deserialize(change.Value); + LogoutTimeout = change.NewValue.DeserializeTo(Database.SerializationCenter); break; case nameof(CleaningClipboardTimeout): - CleaningClipboardTimeout = Database.SerializationCenter.Deserialize(change.Value); + CleaningClipboardTimeout = change.NewValue.DeserializeTo(Database.SerializationCenter); break; case nameof(WarningsToNotify): - WarningsToNotify = Database.SerializationCenter.Deserialize(change.Value); + WarningsToNotify = change.NewValue.DeserializeTo(Database.SerializationCenter); break; default: throw new InvalidDataException("FieldName not valid"); } break; case Change.Type.Add: - Service serviceToAdd = Database.SerializationCenter.Deserialize(change.Value); + Service serviceToAdd = change.NewValue.DeserializeTo(Database.SerializationCenter); serviceToAdd.User = this; Services.Add(serviceToAdd); break; case Change.Type.Delete: - Service serviceToDelete = Database.SerializationCenter.Deserialize(change.Value); + Service serviceToDelete = change.NewValue.DeserializeTo(Database.SerializationCenter); _ = Services.RemoveAll(x => x.ItemId == serviceToDelete.ItemId); break; default: diff --git a/Core/Internal/Models/Warning.cs b/Core/Models/Warning.cs similarity index 75% rename from Core/Internal/Models/Warning.cs rename to Core/Models/Warning.cs index 7bacef9..06ac3d2 100644 --- a/Core/Internal/Models/Warning.cs +++ b/Core/Models/Warning.cs @@ -1,7 +1,7 @@ -using Upsilon.Apps.PassKey.Core.Public.Enums; -using Upsilon.Apps.PassKey.Core.Public.Interfaces; +using Upsilon.Apps.Passkey.Interfaces; +using Upsilon.Apps.Passkey.Interfaces.Enums; -namespace Upsilon.Apps.PassKey.Core.Internal.Models +namespace Upsilon.Apps.Passkey.Core.Models { internal class Warning : IWarning { diff --git a/Core/Public/Interfaces/IDatabase.cs b/Core/Public/Interfaces/IDatabase.cs deleted file mode 100644 index 2e03f04..0000000 --- a/Core/Public/Interfaces/IDatabase.cs +++ /dev/null @@ -1,156 +0,0 @@ -using Upsilon.Apps.PassKey.Core.Public.Events; - -namespace Upsilon.Apps.PassKey.Core.Public.Interfaces -{ - /// - /// Represent a database. - /// - public interface IDatabase : IDisposable - { - /// - /// The path to the database file. - /// - string DatabaseFile { get; set; } - - /// - /// The path to the autosave file. - /// - string AutoSaveFile { get; set; } - - /// - /// The path to the log file. - /// - string LogFile { get; set; } - - /// - /// The user loaded. - /// - IUser? User { get; } - - /// - /// The logs. - /// - ILog[]? Logs { get; } - - /// - /// The warnings detected. - /// - IWarning[]? Warnings { get; } - - /// - /// The serialization center implementation. - /// - ISerializationCenter SerializationCenter { get; } - - /// - /// The cryptographic center implementation. - /// - ICryptographyCenter CryptographyCenter { get; } - - /// - /// The password factory implementation. - /// - IPasswordFactory PasswordFactory { get; } - - /// - /// Occurs when a warning is detected. - /// - event EventHandler? WarningDetected; - - /// - /// Occurs when an autosave is detected. - /// - event EventHandler? AutoSaveDetected; - - /// - /// Occurs when the database is saved. - /// - event EventHandler? DatabaseSaved; - - /// - /// Occurs when an database is closed. - /// - event EventHandler? DatabaseClosed; - - /// - /// Try to load the current user. - /// - /// The current passkey. - /// The loaded user. - IUser? Login(string passkey); - - /// - /// Save the current user to database file. - /// The User must be loaded, else it will throw a NullReferenceException. - /// - void Save(); - - /// - /// Delete the current user with all its files. - /// The User must be loaded, else it will throw a NullReferenceException. - /// - void Delete(); - - /// - /// Close the current user and database. - /// - void Close(); - - /// - /// Create a new user database and returns the database. - /// After creating, the User should be loaded with the Login method. - /// - /// An implementation of the cryptographic center. - /// An implementation of the serialization center. - /// An implementation of the password factory. - /// The path to the database file. - /// The path to the autosave file. - /// The path to the log file. - /// The username. - /// The passkeys. - /// The database created. - static IDatabase Create(ICryptographyCenter cryptographicCenter, - ISerializationCenter serializationCenter, - IPasswordFactory passwordFactory, - string databaseFile, - string autoSaveFile, - string logFile, - string username, - string[] passkeys) - => Internal.Models.Database.Create(cryptographicCenter, - serializationCenter, - passwordFactory, - databaseFile, - autoSaveFile, - logFile, - username, - passkeys); - - /// - /// Open an user and returns the database. - /// After opening, the User should be loaded with the Login method. - /// - /// An implementation of the cryptographic center. - /// An implementation of the serialization center. - /// An implementation of the password factory. - /// The path to the database file. - /// The path to the autosave file. - /// The path to the log file. - /// The username. - /// The database opened. - static IDatabase Open(ICryptographyCenter cryptographicCenter, - ISerializationCenter serializationCenter, - IPasswordFactory passwordFactory, - string databaseFile, - string autoSaveFile, - string logFile, - string username) - => Internal.Models.Database.Open(cryptographicCenter, - serializationCenter, - passwordFactory, - databaseFile, - autoSaveFile, - logFile, - username); - } -} \ No newline at end of file diff --git a/Core/Public/Interfaces/IPasswordFactory.cs b/Core/Public/Interfaces/IPasswordFactory.cs deleted file mode 100644 index 504f633..0000000 --- a/Core/Public/Interfaces/IPasswordFactory.cs +++ /dev/null @@ -1,60 +0,0 @@ -namespace Upsilon.Apps.PassKey.Core.Public.Interfaces -{ - /// - /// Represent a Password factory engine. - /// - public interface IPasswordFactory - { - /// - /// The letters used by the factory. - /// - public string Alphabetic { get; } - - /// - /// The digits used by the factory. - /// - public string Numeric { get; } - - /// - /// The special characters used by the factory. - /// - public string SpecialChars { get; } - - /// - /// Generate a random password. - /// - /// The length of the password. - /// Include the upper case letters. - /// Include the lower case letters. - /// Include the digits. - /// Include the special characters. - /// Exclude some specific characters. - /// Ensure that the generated password has been already leaked. - /// The random geenrated password. - public string GeneratePassword(int length, - bool includeUpperCaseAlphabeticChars = true, - bool includeLowerCaseAlphabeticChars = true, - bool includeNumericChars = true, - bool includeSpecialChars = true, - string excludedChars = "", - bool checkIfLeaked = true); - - /// - /// Generate a random password. - /// - /// The length of the password. - /// The alphabet used. - /// Ensure that the generated password has been already leaked. - /// The random geenrated password. - public string GeneratePassword(int length, - string alphabet, - bool checkIfLeaked = true); - - /// - /// Check if the password has been leaked. - /// - /// The password to check. - /// Returns true if the password has been leaked. - public bool PasswordLeaked(string password); - } -} diff --git a/Core/Public/Interfaces/Interfaces.cd b/Core/Public/Interfaces/Interfaces.cd deleted file mode 100644 index 0108aab..0000000 --- a/Core/Public/Interfaces/Interfaces.cd +++ /dev/null @@ -1,185 +0,0 @@ - - - - - - AAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAA= - Public\Events\AutoSaveDetectedEventArgs.cs - - - - - - AAAAAAAAAAAAgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= - Public\Events\WarningDetectedEventArgs.cs - - - - - - AAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= - Public\Events\LogoutEventArgs.cs - - - - - - AAAgAAAAEAAAAAAAAAAAAAAAAAAAQAACAAAAAABAAAA= - Public\Interfaces\IAccount.cs - - - - - - - - - - AAACAAAAAAAAABAAAMAAAAAAAAAAAAASAAAAAABABAE= - Public\Interfaces\ICryptographyCenter.cs - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - AgABAIgQAAAAoAAAQAAACAAggAGEAUAAAAAQAACAAAA= - Public\Interfaces\IDatabase.cs - - - - - - - - - - - - - - - - AAAAAAAAAAAAAAAAgAAAAAAAAAAAAAAAAAAAAAAAAAA= - Public\Interfaces\IItem.cs - - - - - - AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAAAIAIAAAAAA= - Public\Interfaces\ILog.cs - - - - - - AAAAAAAAAAAAAAAAAAAAAAAAACBAAAAAAAAAAAAAAAA= - Public\Interfaces\ISerializationCenter.cs - - - - - - AAAhAAAAAAAAAAAAAAAAAAAAAAAAAACACAACAQAABAA= - Public\Interfaces\IService.cs - - - - - - - - - - - - - - - - - - - - gABAAAEAAAAAAAAAAQAAAAAAAAAACAAAAAgAECAAAAA= - Public\Interfaces\IUser.cs - - - - - - - - - - - - - - - - - AAAAAAAQgAAAAAAAAAAAAAAAAAAAAACAAAAAAAAAAAA= - Public\Interfaces\IWarning.cs - - - - - - - - - - - - - AAAAAQAAAAAAAAAAAAAAAAACAAAAAAQAgABAAAAAAAA= - Public\Interfaces\IPasswordFactory.cs - - - - - - AIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAA= - Public\Enums\AccountOption.cs - - - - - - AAAAQAAAAABgAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= - Public\Enums\WarningType.cs - - - - \ No newline at end of file diff --git a/Core/Public/Utils/ClipboardManager.cs b/Core/Public/Utils/ClipboardManager.cs deleted file mode 100644 index 170c649..0000000 --- a/Core/Public/Utils/ClipboardManager.cs +++ /dev/null @@ -1,31 +0,0 @@ -using Windows.ApplicationModel.DataTransfer; - -namespace Upsilon.Apps.PassKey.Core.Public.Utils -{ - public static class ClipboardManager - { - public static int RemoveAllOccurence(string[] removeList) - { - int cleanedPasswordCount = 0; - - var clipboardHistory = Clipboard.GetHistoryItemsAsync().AsTask().GetAwaiter().GetResult().Items; - - foreach (var item in clipboardHistory) - { - var content = item.Content; - if (content.Contains(StandardDataFormats.Text)) - { - string text = content.GetTextAsync().AsTask().GetAwaiter().GetResult(); - - if (removeList.Any(x => x == text)) - { - Clipboard.DeleteItemFromHistory(item); - cleanedPasswordCount++; - } - } - } - - return cleanedPasswordCount; - } - } -} diff --git a/Core/Public/Utils/JsonSerializationCenter.cs b/Core/Public/Utils/JsonSerializationCenter.cs deleted file mode 100644 index f00e3bc..0000000 --- a/Core/Public/Utils/JsonSerializationCenter.cs +++ /dev/null @@ -1,20 +0,0 @@ -using System.Text.Json; -using Upsilon.Apps.PassKey.Core.Public.Interfaces; - -namespace Upsilon.Apps.PassKey.Core.Public.Utils -{ - public class JsonSerializationCenter : ISerializationCenter - { - public string Serialize(T toSerialize) where T : notnull - { - return JsonSerializer.Serialize(toSerialize); - } - - public T Deserialize(string toDeserialize) where T : notnull - { - T? obj = JsonSerializer.Deserialize(toDeserialize); - - return obj ?? throw new NullReferenceException(nameof(obj)); - } - } -} diff --git a/Core/Upsilon.Apps.Passkey.Core.csproj b/Core/Upsilon.Apps.Passkey.Core.csproj index ca9a385..e3e95b6 100644 --- a/Core/Upsilon.Apps.Passkey.Core.csproj +++ b/Core/Upsilon.Apps.Passkey.Core.csproj @@ -1,7 +1,7 @@  - net8.0-windows10.0.18362.0 + net10.0 enable enable $(AssemblyName) @@ -19,4 +19,8 @@ 9999 + + + + diff --git a/Core/Public/Utils/CryptographyCenter.cs b/Core/Utils/CryptographyCenter.cs similarity index 65% rename from Core/Public/Utils/CryptographyCenter.cs rename to Core/Utils/CryptographyCenter.cs index a40faf6..ee83b9d 100644 --- a/Core/Public/Utils/CryptographyCenter.cs +++ b/Core/Utils/CryptographyCenter.cs @@ -1,23 +1,24 @@ using System.Security.Cryptography; using System.Text; using System.Text.Json; -using Upsilon.Apps.PassKey.Core.Public.Interfaces; +using Upsilon.Apps.Passkey.Interfaces; +using Upsilon.Apps.Passkey.Interfaces.Utils; -namespace Upsilon.Apps.PassKey.Core.Public.Utils +namespace Upsilon.Apps.Passkey.Core.Utils { public class CryptographyCenter : ICryptographyCenter { public string GetHash(string source) { - string md5Hash = Convert.ToBase64String(MD5.HashData(Encoding.Unicode.GetBytes(source))).TrimEnd('='); - string sha1Hash = Convert.ToBase64String(SHA1.HashData(Encoding.Unicode.GetBytes(source))).TrimEnd('='); + string md5Hash = Convert.ToBase64String(MD5.HashData(Encoding.Unicode.GetBytes(source))); + string sha1Hash = Convert.ToBase64String(SHA1.HashData(Encoding.Unicode.GetBytes(source))); - return md5Hash + sha1Hash; + return (md5Hash + sha1Hash).Replace("/", "-"); } public string GetSlowHash(string source) { - long realTimeFactor = (long)Math.Pow(0b1000, 5); + long realTimeFactor = (long)Math.Pow(0b1001, 6); for (int i = 0; i < realTimeFactor; i++) { @@ -81,39 +82,21 @@ public string DecryptSymmetrically(string source, string[] passwords) public void GenerateRandomKeys(out string publicKey, out string privateKey) { - RSACryptoServiceProvider csp = new(2048); + using RSA rsa = RSA.Create(4096); - StringWriter sw = new(); - System.Xml.Serialization.XmlSerializer xs = new(typeof(RSAParameters)); - - xs.Serialize(sw, csp.ExportParameters(includePrivateParameters: false)); - publicKey = sw.ToString(); - - sw = new System.IO.StringWriter(); - xs = new System.Xml.Serialization.XmlSerializer(typeof(RSAParameters)); - - xs.Serialize(sw, csp.ExportParameters(includePrivateParameters: true)); - privateKey = sw.ToString(); + privateKey = rsa.ExportRSAPrivateKeyPem(); + publicKey = rsa.ExportRSAPublicKeyPem(); } public string EncryptAsymmetrically(string source, string key) { - RSACryptoServiceProvider csp = new(); - - StringReader sr = new(key); - System.Xml.Serialization.XmlSerializer xs = new(typeof(RSAParameters)); - - RSAParameters pubKey = (RSAParameters?)xs.Deserialize(sr) ?? throw new WrongPasswordException(0); - - csp.ImportParameters(pubKey); - Random random = new((int)DateTime.Now.Ticks); byte[] randomBytes = new byte[100]; random.NextBytes(randomBytes); string aesKey = Encoding.UTF8.GetString(randomBytes); source = EncryptSymmetrically(source, [aesKey]); - aesKey = _encryptRsa(aesKey, csp); - var s = new KeyValuePair(aesKey, source); + aesKey = _encryptRsa(aesKey, key); + KeyValuePair s = new(aesKey, source); source = JsonSerializer.Serialize(s); Sign(ref source); @@ -128,44 +111,33 @@ public string DecryptAsymmetrically(string source, string key) throw new CheckSignFailedException(); } - RSACryptoServiceProvider csp = new(); - - StringReader sr = new(key); - System.Xml.Serialization.XmlSerializer xs = new(typeof(RSAParameters)); - - RSAParameters privKey = (RSAParameters?)xs.Deserialize(sr) ?? throw new Exception(); - - csp.ImportParameters(privKey); - - var s = JsonSerializer.Deserialize>(source); - string aesKey = _decryptRsa(s.Key, 0, csp); + KeyValuePair s = JsonSerializer.Deserialize>(source); + string aesKey = _decryptRsa(s.Key, 0, key); source = DecryptSymmetrically(s.Value, [aesKey]); return source; } - private string _cipherAes(string plainText, string key) + private static string _cipherAes(string plainText, string key) { if (string.IsNullOrEmpty(key) || string.IsNullOrEmpty(plainText)) { return plainText; } - MD5 mD5 = MD5.Create(); - - key = Encoding.ASCII.GetString(mD5.ComputeHash(Encoding.ASCII.GetBytes(key))); - key += Encoding.ASCII.GetString(mD5.ComputeHash(Encoding.ASCII.GetBytes(key))); - key += Encoding.ASCII.GetString(mD5.ComputeHash(Encoding.ASCII.GetBytes(key))); + key = Encoding.ASCII.GetString(MD5.HashData(Encoding.ASCII.GetBytes(key))); + key += Encoding.ASCII.GetString(MD5.HashData(Encoding.ASCII.GetBytes(key))); + key += Encoding.ASCII.GetString(MD5.HashData(Encoding.ASCII.GetBytes(key))); byte[] _key = Encoding.ASCII.GetBytes(key[..32]); byte[] IV = Encoding.ASCII.GetBytes(key.Substring(32, 16)); byte[] bytes = _cipherAes(plainText, _key, IV); - return new string(bytes.Select(x => (char)x).ToArray()); + return new string([.. bytes.Select(x => (char)x)]); } - private byte[] _cipherAes(string plainText, byte[] key, byte[] IV) + private static byte[] _cipherAes(string plainText, byte[] key, byte[] IV) { using Aes aesAlg = Aes.Create(); aesAlg.Key = key; @@ -183,27 +155,25 @@ private byte[] _cipherAes(string plainText, byte[] key, byte[] IV) return msEncrypt.ToArray(); } - private string _uncipherAes(string cipherText, string key) + private static string _uncipherAes(string cipherText, string key) { if (string.IsNullOrEmpty(key) || string.IsNullOrEmpty(cipherText)) { return cipherText; } - - MD5 mD5 = MD5.Create(); - key = Encoding.ASCII.GetString(mD5.ComputeHash(Encoding.ASCII.GetBytes(key))); - key += Encoding.ASCII.GetString(mD5.ComputeHash(Encoding.ASCII.GetBytes(key))); - key += Encoding.ASCII.GetString(mD5.ComputeHash(Encoding.ASCII.GetBytes(key))); + key = Encoding.ASCII.GetString(MD5.HashData(Encoding.ASCII.GetBytes(key))); + key += Encoding.ASCII.GetString(MD5.HashData(Encoding.ASCII.GetBytes(key))); + key += Encoding.ASCII.GetString(MD5.HashData(Encoding.ASCII.GetBytes(key))); byte[] _key = Encoding.ASCII.GetBytes(key[..32]); byte[] IV = Encoding.ASCII.GetBytes(key.Substring(32, 16)); - byte[] bytes = cipherText.Select(x => (byte)x).ToArray(); + byte[] bytes = [.. cipherText.Select(x => (byte)x)]; return _uncitherAes(bytes, _key, IV); } - private string _uncitherAes(byte[] cipherText, byte[] key, byte[] IV) + private static string _uncitherAes(byte[] cipherText, byte[] key, byte[] IV) { using Aes aesAlg = Aes.Create(); aesAlg.Key = key; @@ -220,7 +190,7 @@ private string _uncitherAes(byte[] cipherText, byte[] key, byte[] IV) private string _encryptAes(string source, string[] passwords) { - passwords = passwords.Select(x => GetHash(x)).ToArray(); + passwords = [.. passwords.Select(GetHash)]; for (int i = passwords.Length - 1; i >= 0; i--) { @@ -236,7 +206,7 @@ private string _encryptAes(string source, string[] passwords) private string _decryptAes(string source, string[] passwords) { - passwords = passwords.Select(x => GetHash(x)).ToArray(); + passwords = [.. passwords.Select(GetHash)]; try { @@ -272,23 +242,28 @@ private string _decryptAes(string source, string[] passwords) return source; } - private string _encryptRsa(string source, RSACryptoServiceProvider csp) + private static string _encryptRsa(string source, string publicKeyPem) { + using RSA rsa = RSA.Create(); + rsa.ImportFromPem(publicKeyPem); + byte[] bytesPlainTextData = Encoding.Unicode.GetBytes(source); - byte[] bytesCypherText = csp.Encrypt(bytesPlainTextData, true); + byte[] bytesCypherText = rsa.Encrypt(bytesPlainTextData, RSAEncryptionPadding.OaepSHA256); source = Convert.ToBase64String(bytesCypherText); return source; } - private string _decryptRsa(string source, int level, RSACryptoServiceProvider csp) + private static string _decryptRsa(string source, int level, string privateKeyPem) { try { - byte[] bytesCypherText = Convert.FromBase64String(source); - byte[] bytesPlainTextData = csp.Decrypt(bytesCypherText, true); + using RSA rsa = RSA.Create(); + rsa.ImportFromPem(privateKeyPem); + byte[] bytesCypherText = Convert.FromBase64String(source); + byte[] bytesPlainTextData = rsa.Decrypt(bytesCypherText, RSAEncryptionPadding.OaepSHA256); return Encoding.Unicode.GetString(bytesPlainTextData); } catch diff --git a/Core/Internal/Utils/FileLocker.cs b/Core/Utils/FileLocker.cs similarity index 58% rename from Core/Internal/Utils/FileLocker.cs rename to Core/Utils/FileLocker.cs index 06efaf2..a6ad8e7 100644 --- a/Core/Internal/Utils/FileLocker.cs +++ b/Core/Utils/FileLocker.cs @@ -1,6 +1,9 @@ -using Upsilon.Apps.PassKey.Core.Public.Interfaces; +using System.IO.Compression; +using System.Text; +using Upsilon.Apps.Passkey.Interfaces; +using Upsilon.Apps.Passkey.Interfaces.Utils; -namespace Upsilon.Apps.PassKey.Core.Internal.Utils +namespace Upsilon.Apps.Passkey.Core.Utils { internal class FileLocker : IDisposable { @@ -39,7 +42,7 @@ internal string ReadAllText() { Unlock(); - string text = File.ReadAllText(FilePath); + string text = _decompressString(File.ReadAllText(FilePath)); Lock(); @@ -55,14 +58,14 @@ internal string ReadAllText(string[] passkeys) internal T Open(string[] passkeys) where T : notnull { - return _serializationCenter.Deserialize(ReadAllText(passkeys)); + return ReadAllText(passkeys).DeserializeTo(_serializationCenter); } internal void WriteAllText(string text) { Unlock(); - File.WriteAllText(FilePath, text); + File.WriteAllText(FilePath, _compressString(text)); Lock(); } @@ -76,7 +79,7 @@ internal void WriteAllText(string text, string[] passkeys) internal void Save(T obj, string[] passkeys) where T : notnull { - WriteAllText(_serializationCenter.Serialize(obj), passkeys); + WriteAllText(obj.SerializeWith(_serializationCenter), passkeys); } internal void Delete() @@ -94,5 +97,37 @@ public void Dispose() Unlock(); FilePath = string.Empty; } + + private static string _compressString(string text) + { + byte[] bytes = Encoding.UTF8.GetBytes(text); + using MemoryStream msi = new(bytes); + using MemoryStream mso = new(); + using (GZipStream gs = new(mso, CompressionLevel.SmallestSize)) + { + msi.CopyTo(gs); + } + return Convert.ToBase64String(mso.ToArray()); + } + + private static string _decompressString(string compressedText) + { + try + { + byte[] bytes = Convert.FromBase64String(compressedText); + using MemoryStream msi = new(bytes); + using MemoryStream mso = new(); + using (GZipStream gs = new(msi, CompressionMode.Decompress)) + { + gs.CopyTo(mso); + } + return Encoding.UTF8.GetString(mso.ToArray()); + } + catch + { + throw new CorruptedSourceException(); + } + } + } } diff --git a/Core/Utils/ImportExportHelper.cs b/Core/Utils/ImportExportHelper.cs new file mode 100644 index 0000000..e63bf98 --- /dev/null +++ b/Core/Utils/ImportExportHelper.cs @@ -0,0 +1,199 @@ +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using Upsilon.Apps.Passkey.Core.Models; +using Upsilon.Apps.Passkey.Interfaces; +using Upsilon.Apps.Passkey.Interfaces.Enums; + +namespace Upsilon.Apps.Passkey.Core.Utils +{ + internal static class ImportExportHelper + { + private enum Headers + { + ServiceName, + ServiceUrl, + ServiceNotes, + AccountLabel, + Identifiers, + Password, + AccountNotes, + AccountOptions, + PasswordUpdateReminderDelay, + } + + private static string _jsonSerialize(T obj) + => JsonSerializer.Serialize(obj, _options); + + private static T _jsonDeserializeAs(string json) + => JsonSerializer.Deserialize(json, _options) ?? throw new NullReferenceException(); + + private static readonly JsonSerializerOptions _options = new() { Converters = { new JsonStringEnumConverter() }, WriteIndented = true, }; + + public static string ImportCSV(this IDatabase database, string importContent) + { + List services = []; + + try + { + string[] csvLines = [.. importContent.Split('\n').Select(x => x.Replace("\r", "")).Where(x => !string.IsNullOrWhiteSpace(x))]; + + string[] headers = csvLines[0].Split("\t"); + + Dictionary headersIndexes = []; + + headersIndexes[Headers.ServiceName] = headers.IndexOf(Headers.ServiceName.ToString()); + headersIndexes[Headers.ServiceUrl] = headers.IndexOf(Headers.ServiceUrl.ToString()); + headersIndexes[Headers.ServiceNotes] = headers.IndexOf(Headers.ServiceNotes.ToString()); + headersIndexes[Headers.AccountLabel] = headers.IndexOf(Headers.AccountLabel.ToString()); + headersIndexes[Headers.Identifiers] = headers.IndexOf(Headers.Identifiers.ToString()); + headersIndexes[Headers.Password] = headers.IndexOf(Headers.Password.ToString()); + headersIndexes[Headers.AccountNotes] = headers.IndexOf(Headers.AccountNotes.ToString()); + headersIndexes[Headers.AccountOptions] = headers.IndexOf(Headers.AccountOptions.ToString()); + headersIndexes[Headers.PasswordUpdateReminderDelay] = headers.IndexOf(Headers.PasswordUpdateReminderDelay.ToString()); + + if (headersIndexes.Values.Any(x => x == -1)) return $"the CSV headers should be : {string.Join(", ", headersIndexes.Keys.Select(x => $"'{x}'"))}"; + + Service? service = null; + + for (int i = 1; i < csvLines.Length; i++) + { + string csvLine = csvLines[i]; + string[] csvColumns = csvLine.Split('\t'); + string serviceName = _jsonDeserializeAs(csvColumns[headersIndexes[Headers.ServiceName]]); + string serviceUrl = _jsonDeserializeAs(csvColumns[headersIndexes[Headers.ServiceUrl]]); + string serviceNotes = _jsonDeserializeAs(csvColumns[headersIndexes[Headers.ServiceNotes]]); + string accountLabel = _jsonDeserializeAs(csvColumns[headersIndexes[Headers.AccountLabel]]); + string identifiers = _jsonDeserializeAs(csvColumns[headersIndexes[Headers.Identifiers]]); + string password = _jsonDeserializeAs(csvColumns[headersIndexes[Headers.Password]]); + string accountNotes = _jsonDeserializeAs(csvColumns[headersIndexes[Headers.AccountNotes]]); + AccountOption accountOptions = _jsonDeserializeAs(csvColumns[headersIndexes[Headers.AccountOptions]]); + int passwordUpdateReminderDelay = _jsonDeserializeAs(csvColumns[headersIndexes[Headers.PasswordUpdateReminderDelay]]); + + if (service is null + || service.ServiceName != serviceName) + { + service = new() + { + ServiceName = serviceName, + Url = serviceUrl, + Notes = serviceNotes, + }; + + services.Add(service); + } + + Account account = new() + { + Label = accountLabel, + Identifiers = [.. identifiers.Split('|').Select(x => x.Trim())], + Password = password, + Notes = accountNotes, + Options = accountOptions, + PasswordUpdateReminderDelay = passwordUpdateReminderDelay + }; + + service.Accounts.Add(account); + } + } + catch + { + return "the CSV data format is incorrect"; + } + + return _importServices(database, services); + } + + public static string ImportJson(this IDatabase database, string importContent) + { + Service[] services; + + try + { + services = _jsonDeserializeAs(importContent); + } + catch + { + return "import file deserialization failed"; + } + + return _importServices(database, services); + } + + private static string _importServices(IDatabase database, IEnumerable services) + { + if (database.User is null) return string.Empty; + + if (!services.Any()) return "there is no data to import"; + + Service? s0 = services.FirstOrDefault(x => database.User.Services.Any(y => y.ServiceName == x.ServiceName)); + if (s0 is not null) + { + return $"service '{s0.ServiceName}' already exists"; + } + + s0 = services.FirstOrDefault(x => string.IsNullOrWhiteSpace(x.ServiceName)); + if (s0 is not null) + { + return $"service name cannot be blank"; + } + + foreach (Service s in services) + { + IService service = database.User.AddService(s.ServiceName); + service.Url = s.Url; + service.Notes = s.Notes; + + foreach (Account a in s.Accounts) + { + IAccount account = service.AddAccount(a.Label, a.Identifiers, a.Password); + account.Notes = a.Notes; + account.Options = a.Options; + account.PasswordUpdateReminderDelay = a.PasswordUpdateReminderDelay; + } + } + + return string.Empty; + } + + public static string ExportCSV(this Database database, string filePath) + { + if (database.User is null) return string.Empty; + + StringBuilder sb = new(string.Join("\t", Enum.GetNames()) + "\n"); + + foreach (Service service in database.User.Services) + { + string serviceLine = $"{_jsonSerialize(service.ServiceName.Trim())}\t" + + $"{_jsonSerialize(service.Url.Trim())}\t" + + $"{_jsonSerialize(service.Notes.Trim())}\t"; + + foreach (Account account in service.Accounts) + { + string identifiers = string.Join("|", account.Identifiers.Where(x => !string.IsNullOrWhiteSpace(x))); + + _ = sb.Append(serviceLine); + _ = sb.Append($"{_jsonSerialize(account.Label.Trim())}\t" + + $"{_jsonSerialize(identifiers)}\t" + + $"{_jsonSerialize(account.Password.Trim())}\t" + + $"{_jsonSerialize(account.Notes.Trim())}\t" + + $"{_jsonSerialize(account.Options)}\t" + + $"{_jsonSerialize(account.PasswordUpdateReminderDelay)}\n"); + } + } + + File.WriteAllText(filePath, sb.ToString()); + + return string.Empty; + } + + public static string ExportJson(this Database database, string filePath) + { + if (database.User is null) return string.Empty; + + File.WriteAllText(filePath, _jsonSerialize(database.User.Services)); + + return string.Empty; + } + } +} diff --git a/Core/Utils/JsonSerializationCenter.cs b/Core/Utils/JsonSerializationCenter.cs new file mode 100644 index 0000000..50d2619 --- /dev/null +++ b/Core/Utils/JsonSerializationCenter.cs @@ -0,0 +1,17 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Upsilon.Apps.Passkey.Interfaces; + +namespace Upsilon.Apps.Passkey.Core.Utils +{ + public class JsonSerializationCenter : ISerializationCenter + { + private static readonly JsonSerializerOptions _options = new() { Converters = { new JsonStringEnumConverter() }, }; + + public string Serialize(T toSerialize) where T : notnull + => JsonSerializer.Serialize(toSerialize, _options); + + public T Deserialize(string toDeserialize) where T : notnull + => JsonSerializer.Deserialize(toDeserialize, _options) ?? throw new NullReferenceException(nameof(toDeserialize)); + } +} diff --git a/Core/Internal/Utils/LogCenter.cs b/Core/Utils/LogCenter.cs similarity index 57% rename from Core/Internal/Utils/LogCenter.cs rename to Core/Utils/LogCenter.cs index 816f7c5..22c931a 100644 --- a/Core/Internal/Utils/LogCenter.cs +++ b/Core/Utils/LogCenter.cs @@ -1,34 +1,25 @@ using System.Text.Json.Serialization; -using Upsilon.Apps.PassKey.Core.Internal.Models; -using Upsilon.Apps.PassKey.Core.Public.Interfaces; +using Upsilon.Apps.Passkey.Core.Models; +using Upsilon.Apps.Passkey.Interfaces; -namespace Upsilon.Apps.PassKey.Core.Internal.Utils +namespace Upsilon.Apps.Passkey.Core.Utils { internal class LogCenter { - private Database? _database; internal Database Database { - get => _database ?? throw new NullReferenceException(nameof(Database)); - set => _database = value; + get => field ?? throw new NullReferenceException(nameof(Database)); + set; } [JsonIgnore] - public ILog[]? Logs - { - get - { - return Database.User == null + public ILog[]? Logs => Database.User == null ? null - : LogList.Select(x => - { - string textLog = Database.CryptographyCenter.DecryptAsymmetrically(x, Database.User.PrivateKey); - return Database.SerializationCenter.Deserialize(textLog); - }) + : LogList.Select(x => Database.CryptographyCenter + .DecryptAsymmetrically(x, Database.User.PrivateKey) + .DeserializeTo(Database.SerializationCenter)) .OrderByDescending(x => x.DateTime) .ToArray(); - } - } public List LogList { get; set; } = []; public string Username { get; set; } = string.Empty; @@ -43,7 +34,7 @@ public void AddLog(string message, bool needsReview) NeedsReview = needsReview, }; - string textLog = Database.SerializationCenter.Serialize(log); + string textLog = log.SerializeWith(Database.SerializationCenter); LogList.Add(Database.CryptographyCenter.EncryptAsymmetrically(textLog, PublicKey)); _save(); diff --git a/Core/Public/Utils/PasswordFactory.cs b/Core/Utils/PasswordFactory.cs similarity index 53% rename from Core/Public/Utils/PasswordFactory.cs rename to Core/Utils/PasswordFactory.cs index 691eed7..f591cc6 100644 --- a/Core/Public/Utils/PasswordFactory.cs +++ b/Core/Utils/PasswordFactory.cs @@ -1,9 +1,8 @@ -using System.Net; -using System.Security.Cryptography; +using System.Security.Cryptography; using System.Text; -using Upsilon.Apps.PassKey.Core.Public.Interfaces; +using Upsilon.Apps.Passkey.Interfaces; -namespace Upsilon.Apps.PassKey.Core.Public.Utils +namespace Upsilon.Apps.Passkey.Core.Utils { public class PasswordFactory : IPasswordFactory { @@ -11,41 +10,6 @@ public class PasswordFactory : IPasswordFactory public string Numeric => "0123456789"; public string SpecialChars => "~!@#$%^&*()_-+={[}]\\|'\";:,<.>/?"; - public string GeneratePassword(int length, - bool includeUpperCaseAlphabeticChars = true, - bool includeLowerCaseAlphabeticChars = true, - bool includeNumericChars = true, - bool includeSpecialChars = true, - string excludedChars = "", - bool checkIfLeaked = true) - { - string alphabet = ""; - - if (includeUpperCaseAlphabeticChars) - { - alphabet += Alphabetic.ToUpper(); - } - - if (includeLowerCaseAlphabeticChars) - { - alphabet += Alphabetic.ToLower(); - } - - if (includeNumericChars) - { - alphabet += Numeric; - } - - if (includeSpecialChars) - { - alphabet += SpecialChars; - } - - alphabet = string.Join("", alphabet.ToCharArray().Except(excludedChars.ToCharArray())); - - return GeneratePassword(length, alphabet, checkIfLeaked); - } - public string GeneratePassword(int length, string alphabet, bool checkIfLeaked = true) { if (string.IsNullOrWhiteSpace(alphabet) @@ -75,10 +39,6 @@ public bool PasswordLeaked(string password) { string hash = Convert.ToHexString(SHA1.HashData(Encoding.UTF8.GetBytes(password))); - ServicePointManager.Expect100Continue = true; - ServicePointManager.SecurityProtocol = (SecurityProtocolType)3072; - ServicePointManager.DefaultConnectionLimit = 9999; - using HttpClient httpClient = new(); HttpRequestMessage request = new(HttpMethod.Get, $"https://api.pwnedpasswords.com/range/{hash[..5]}"); HttpResponseMessage response = httpClient.Send(request); diff --git a/Core/Utils/StaticMethods.cs b/Core/Utils/StaticMethods.cs new file mode 100644 index 0000000..f868257 --- /dev/null +++ b/Core/Utils/StaticMethods.cs @@ -0,0 +1,38 @@ +using System.Text.RegularExpressions; +using Upsilon.Apps.Passkey.Interfaces; + +namespace Upsilon.Apps.Passkey.Core.Utils +{ + public static class StaticMethods + { + public static string ToSentenceCase(this string str) => Regex.Replace(str, "[a-z][A-Z]", m => $"{m.Value[0]} {char.ToLower(m.Value[1])}"); + + public static bool ContainsFlag(this T value, T lookingForFlag) where T : Enum + { + if (!typeof(T).IsEnum) + { + throw new ArgumentException("T must be an enumerated type"); + } + + int intValue = (int)(object)value; + int intLookingForFlag = (int)(object)lookingForFlag; + return (intValue & intLookingForFlag) == intLookingForFlag; + } + + public static string SerializeWith(this T obj, ISerializationCenter serializationCenter) where T : notnull + => serializationCenter.Serialize(obj); + + public static T DeserializeTo(this string serializedString, ISerializationCenter serializationCenter) where T : notnull + => serializationCenter.Deserialize(serializedString); + + public static T CloneWith(this T source, ISerializationCenter serializationCenter) where T : notnull + { + return source.SerializeWith(serializationCenter).DeserializeTo(serializationCenter); + } + + public static bool AreDifferent(this ISerializationCenter serializationCenter, object object1, object object2) + { + return object1.SerializeWith(serializationCenter) != object2.SerializeWith(serializationCenter); + } + } +} diff --git a/Core/Public/Enums/AccountOption.cs b/Interfaces/Enums/AccountOption.cs similarity index 86% rename from Core/Public/Enums/AccountOption.cs rename to Interfaces/Enums/AccountOption.cs index 98702bd..0677387 100644 --- a/Core/Public/Enums/AccountOption.cs +++ b/Interfaces/Enums/AccountOption.cs @@ -1,4 +1,4 @@ -namespace Upsilon.Apps.PassKey.Core.Public.Enums +namespace Upsilon.Apps.Passkey.Interfaces.Enums { /// /// Represent an account option. diff --git a/Core/Public/Enums/AutoSaveMergeBehavior.cs b/Interfaces/Enums/AutoSaveMergeBehavior.cs similarity index 57% rename from Core/Public/Enums/AutoSaveMergeBehavior.cs rename to Interfaces/Enums/AutoSaveMergeBehavior.cs index ee77167..b4d32a9 100644 --- a/Core/Public/Enums/AutoSaveMergeBehavior.cs +++ b/Interfaces/Enums/AutoSaveMergeBehavior.cs @@ -1,4 +1,4 @@ -namespace Upsilon.Apps.PassKey.Core.Public.Enums +namespace Upsilon.Apps.Passkey.Interfaces.Enums { /// /// Represent the behavior of auto-save handling. @@ -6,9 +6,13 @@ public enum AutoSaveMergeBehavior { /// - /// The auto-save will be merged into the database then the auto-save file will be removed. + /// The auto-save will be merged into the database and saved then the auto-save file will be removed. /// - MergeThenRemoveAutoSaveFile, + MergeAndSaveThenRemoveAutoSaveFile, + /// + /// The auto-save will be merged into the database without saving and the auto-save file will be keeped. + /// + MergeWithoutSavingAndKeepAutoSaveFile, /// /// The auto-save will not be merged into the database but the auto-save file will be removed. /// diff --git a/Core/Public/Enums/WarningType.cs b/Interfaces/Enums/WarningType.cs similarity index 93% rename from Core/Public/Enums/WarningType.cs rename to Interfaces/Enums/WarningType.cs index 3fe60bb..587de34 100644 --- a/Core/Public/Enums/WarningType.cs +++ b/Interfaces/Enums/WarningType.cs @@ -1,4 +1,4 @@ -namespace Upsilon.Apps.PassKey.Core.Public.Enums +namespace Upsilon.Apps.Passkey.Interfaces.Enums { /// /// Represent a type of warning. diff --git a/Core/Public/Events/AutoSaveDetectedEventArgs.cs b/Interfaces/Events/AutoSaveDetectedEventArgs.cs similarity index 70% rename from Core/Public/Events/AutoSaveDetectedEventArgs.cs rename to Interfaces/Events/AutoSaveDetectedEventArgs.cs index c099ba3..ef06808 100644 --- a/Core/Public/Events/AutoSaveDetectedEventArgs.cs +++ b/Interfaces/Events/AutoSaveDetectedEventArgs.cs @@ -1,6 +1,6 @@ -using Upsilon.Apps.PassKey.Core.Public.Enums; +using Upsilon.Apps.Passkey.Interfaces.Enums; -namespace Upsilon.Apps.PassKey.Core.Public.Events +namespace Upsilon.Apps.Passkey.Interfaces.Events { /// /// Represent the behavior of auto-save handling event argument. @@ -11,6 +11,6 @@ public class AutoSaveDetectedEventArgs : EventArgs /// The behavior selected. /// By default it will merge then remove the auto-save file. /// - public AutoSaveMergeBehavior MergeBehavior { get; set; } = AutoSaveMergeBehavior.MergeThenRemoveAutoSaveFile; + public AutoSaveMergeBehavior MergeBehavior { get; set; } = AutoSaveMergeBehavior.MergeAndSaveThenRemoveAutoSaveFile; } } diff --git a/Core/Public/Events/LogoutEventArgs.cs b/Interfaces/Events/LogoutEventArgs.cs similarity index 86% rename from Core/Public/Events/LogoutEventArgs.cs rename to Interfaces/Events/LogoutEventArgs.cs index f8e812d..9d7c068 100644 --- a/Core/Public/Events/LogoutEventArgs.cs +++ b/Interfaces/Events/LogoutEventArgs.cs @@ -1,4 +1,4 @@ -namespace Upsilon.Apps.PassKey.Core.Public.Events +namespace Upsilon.Apps.Passkey.Interfaces.Events { /// /// Represent a loggout event argument. diff --git a/Core/Public/Events/WarningDetectedEventArgs.cs b/Interfaces/Events/WarningDetectedEventArgs.cs similarity index 78% rename from Core/Public/Events/WarningDetectedEventArgs.cs rename to Interfaces/Events/WarningDetectedEventArgs.cs index 36831b6..48d06a2 100644 --- a/Core/Public/Events/WarningDetectedEventArgs.cs +++ b/Interfaces/Events/WarningDetectedEventArgs.cs @@ -1,6 +1,4 @@ -using Upsilon.Apps.PassKey.Core.Public.Interfaces; - -namespace Upsilon.Apps.PassKey.Core.Public.Events +namespace Upsilon.Apps.Passkey.Interfaces.Events { /// /// Represent a warning detected event argument. diff --git a/Core/Public/Interfaces/IAccount.cs b/Interfaces/IAccount.cs similarity index 87% rename from Core/Public/Interfaces/IAccount.cs rename to Interfaces/IAccount.cs index 35a2853..9bdafd7 100644 --- a/Core/Public/Interfaces/IAccount.cs +++ b/Interfaces/IAccount.cs @@ -1,6 +1,6 @@ -using Upsilon.Apps.PassKey.Core.Public.Enums; +using Upsilon.Apps.Passkey.Interfaces.Enums; -namespace Upsilon.Apps.PassKey.Core.Public.Interfaces +namespace Upsilon.Apps.Passkey.Interfaces { /// /// Represent an account. @@ -25,7 +25,7 @@ public interface IAccount : IItem /// /// The identifants. /// - string[] Identifiants { get; set; } + string[] Identifiers { get; set; } /// /// The actual password. diff --git a/Interfaces/IClipboardManager.cs b/Interfaces/IClipboardManager.cs new file mode 100644 index 0000000..f557d2d --- /dev/null +++ b/Interfaces/IClipboardManager.cs @@ -0,0 +1,15 @@ +namespace Upsilon.Apps.Passkey.Interfaces +{ + /// + /// Represent a OS specific Clipboard manager. + /// + public interface IClipboardManager + { + /// + /// Remove any occurence of elements in a the given list from the clipboard history. + /// + /// The list of eleemnts to remove. + /// The number of item removed. + int RemoveAllOccurence(string[] removeList); + } +} diff --git a/Core/Public/Interfaces/ICryptographyCenter.cs b/Interfaces/ICryptographyCenter.cs similarity index 98% rename from Core/Public/Interfaces/ICryptographyCenter.cs rename to Interfaces/ICryptographyCenter.cs index d28c73c..7291e54 100644 --- a/Core/Public/Interfaces/ICryptographyCenter.cs +++ b/Interfaces/ICryptographyCenter.cs @@ -1,4 +1,4 @@ -namespace Upsilon.Apps.PassKey.Core.Public.Interfaces +namespace Upsilon.Apps.Passkey.Interfaces { /// /// Represent a cryptographic center. diff --git a/Interfaces/IDatabase.cs b/Interfaces/IDatabase.cs new file mode 100644 index 0000000..e7698d8 --- /dev/null +++ b/Interfaces/IDatabase.cs @@ -0,0 +1,144 @@ +using Upsilon.Apps.Passkey.Interfaces.Events; + +namespace Upsilon.Apps.Passkey.Interfaces +{ + /// + /// Represent a database. + /// + public interface IDatabase : IDisposable + { + /// + /// The path to the database file. + /// + string DatabaseFile { get; set; } + + /// + /// The path to the autosave file. + /// + string AutoSaveFile { get; set; } + + /// + /// The path to the log file. + /// + string LogFile { get; set; } + + /// + /// The user loaded. + /// + IUser? User { get; } + + /// + /// The number of seconds left before the session ended. + /// + int? SessionLeftTime { get; } + + /// + /// The logs. + /// + ILog[]? Logs { get; } + + /// + /// The warnings detected. + /// + IWarning[]? Warnings { get; } + + /// + /// The serialization center implementation. + /// + ISerializationCenter SerializationCenter { get; } + + /// + /// The cryptographic center implementation. + /// + ICryptographyCenter CryptographyCenter { get; } + + /// + /// The password factory implementation. + /// + IPasswordFactory PasswordFactory { get; } + + /// + /// The OS specific Clipboard manager implementation. + /// + IClipboardManager ClipboardManager { get; } + + /// + /// Occurs when a warning is detected. + /// + event EventHandler? WarningDetected; + + /// + /// Occurs when an autosave is detected. + /// + event EventHandler? AutoSaveDetected; + + /// + /// Occurs when the database is saved. + /// + event EventHandler? DatabaseSaved; + + /// + /// Occurs when an database is closed. + /// + event EventHandler? DatabaseClosed; + + /// + /// Try to load the current user. + /// + /// The current passkey. + /// The loaded user. + IUser? Login(string passkey); + + /// + /// Save the current user to database file. + /// The User must be loaded, else it will throw a NullReferenceException. + /// + void Save(); + + /// + /// Delete the current user with all its files. + /// The User must be loaded, else it will throw a NullReferenceException. + /// + void Delete(); + + /// + /// Close the current user and database. + /// + void Close(); + + /// + /// Check if the database has changed. + /// + /// True if the database changed, False else. + bool HasChanged(); + + /// + /// Check if the given item has changed. + /// + /// The item id to check. + /// True if the item changed, False else. + bool HasChanged(string itemId); + + /// + /// Check if the field of the given item has changed. + /// + /// The item id to check. + /// The field name to check. + /// True if the field changed, False else. + bool HasChanged(string itemId, string fieldName); + + /// + /// Import services and/or accounts from a file. + /// + /// The file path. + /// True if the import succeded, False else. + bool ImportFromFile(string filePath); + + /// + /// Export services and accounts to a file. + /// + /// The file path. + /// True if the export succeded, False else. + bool ExportToFile(string filePath); + } +} \ No newline at end of file diff --git a/Core/Public/Interfaces/IItem.cs b/Interfaces/IItem.cs similarity index 54% rename from Core/Public/Interfaces/IItem.cs rename to Interfaces/IItem.cs index eeb48ea..caaa06e 100644 --- a/Core/Public/Interfaces/IItem.cs +++ b/Interfaces/IItem.cs @@ -1,4 +1,4 @@ -namespace Upsilon.Apps.PassKey.Core.Public.Interfaces +namespace Upsilon.Apps.Passkey.Interfaces { /// /// Represent an item. @@ -9,5 +9,10 @@ public interface IItem /// The Id of the item. /// string ItemId { get; } + + /// + /// The database that contains the item. + /// + IDatabase Database { get; } } } diff --git a/Core/Public/Interfaces/ILog.cs b/Interfaces/ILog.cs similarity index 66% rename from Core/Public/Interfaces/ILog.cs rename to Interfaces/ILog.cs index 60d3639..9e3dd57 100644 --- a/Core/Public/Interfaces/ILog.cs +++ b/Interfaces/ILog.cs @@ -1,4 +1,4 @@ -namespace Upsilon.Apps.PassKey.Core.Public.Interfaces +namespace Upsilon.Apps.Passkey.Interfaces { /// /// Represent an event log. @@ -8,16 +8,16 @@ public interface ILog /// /// The date and time the event occured. /// - public DateTime DateTime { get; } + DateTime DateTime { get; } /// /// The event message. /// - public string Message { get; } + string Message { get; } /// /// Indicate if the current log needs review. /// - public bool NeedsReview { get; set; } + bool NeedsReview { get; set; } } } diff --git a/Interfaces/IPasswordFactory.cs b/Interfaces/IPasswordFactory.cs new file mode 100644 index 0000000..231333c --- /dev/null +++ b/Interfaces/IPasswordFactory.cs @@ -0,0 +1,41 @@ +namespace Upsilon.Apps.Passkey.Interfaces +{ + /// + /// Represent a Password factory engine. + /// + public interface IPasswordFactory + { + /// + /// The letters used by the factory. + /// + string Alphabetic { get; } + + /// + /// The digits used by the factory. + /// + string Numeric { get; } + + /// + /// The special characters used by the factory. + /// + string SpecialChars { get; } + + /// + /// Generate a random password. + /// + /// The length of the password. + /// The alphabet used. + /// Ensure that the generated password has been already leaked. + /// The random geenrated password. + string GeneratePassword(int length, + string alphabet, + bool checkIfLeaked = true); + + /// + /// Check if the password has been leaked. + /// + /// The password to check. + /// Returns true if the password has been leaked. + bool PasswordLeaked(string password); + } +} diff --git a/Core/Public/Interfaces/ISerializationCenter.cs b/Interfaces/ISerializationCenter.cs similarity index 93% rename from Core/Public/Interfaces/ISerializationCenter.cs rename to Interfaces/ISerializationCenter.cs index b76e8ac..6ead7d2 100644 --- a/Core/Public/Interfaces/ISerializationCenter.cs +++ b/Interfaces/ISerializationCenter.cs @@ -1,4 +1,4 @@ -namespace Upsilon.Apps.PassKey.Core.Public.Interfaces +namespace Upsilon.Apps.Passkey.Interfaces { /// /// Represent a serialization center. diff --git a/Core/Public/Interfaces/IService.cs b/Interfaces/IService.cs similarity index 53% rename from Core/Public/Interfaces/IService.cs rename to Interfaces/IService.cs index fe3437f..e95535d 100644 --- a/Core/Public/Interfaces/IService.cs +++ b/Interfaces/IService.cs @@ -1,4 +1,4 @@ -namespace Upsilon.Apps.PassKey.Core.Public.Interfaces +namespace Upsilon.Apps.Passkey.Interfaces { /// /// Represent a service. @@ -34,10 +34,33 @@ public interface IService : IItem /// Add a new account to this service. /// /// The label of the account. - /// The identifiants. + /// The identifiers. /// The password. /// The created account. - IAccount AddAccount(string label, IEnumerable identifiants, string password); + IAccount AddAccount(string label, IEnumerable identifiers, string password); + + /// + /// Add a new account to this service. + /// + /// The label of the account. + /// The identifiers. + /// The created account. + IAccount AddAccount(string label, IEnumerable identifiers); + + /// + /// Add a new account to this service. + /// + /// The identifiers. + /// The password. + /// The created account. + IAccount AddAccount(IEnumerable identifiers, string password); + + /// + /// Add a new account to this service. + /// + /// The identifiers. + /// The created account. + IAccount AddAccount(IEnumerable identifiers); /// /// Delete the given account from this service. diff --git a/Core/Public/Interfaces/IUser.cs b/Interfaces/IUser.cs similarity index 81% rename from Core/Public/Interfaces/IUser.cs rename to Interfaces/IUser.cs index caa7cd3..5b1b3b5 100644 --- a/Core/Public/Interfaces/IUser.cs +++ b/Interfaces/IUser.cs @@ -1,6 +1,6 @@ -using Upsilon.Apps.PassKey.Core.Public.Enums; +using Upsilon.Apps.Passkey.Interfaces.Enums; -namespace Upsilon.Apps.PassKey.Core.Public.Interfaces +namespace Upsilon.Apps.Passkey.Interfaces { /// /// Represent an user. @@ -23,14 +23,19 @@ public interface IUser : IItem int LogoutTimeout { get; set; } /// - /// The number of seconds left before the session ended. + /// The number of second to keep existing passwords in the clipboard. /// - int SessionLeftTime { get; } + int CleaningClipboardTimeout { get; set; } /// - /// The number of second to keep existing passwords in the clipboard. + /// The delay to keep password visible. /// - int CleaningClipboardTimeout { get; set; } + int ShowPasswordDelay { get; set; } + + /// + /// The number of old paswords to keep. + /// + int NumberOfOldPasswordToKeep { get; set; } /// /// The warnings types which will be notified if detected. diff --git a/Core/Public/Interfaces/IWarning.cs b/Interfaces/IWarning.cs similarity index 82% rename from Core/Public/Interfaces/IWarning.cs rename to Interfaces/IWarning.cs index 9e9ed5a..aaa584d 100644 --- a/Core/Public/Interfaces/IWarning.cs +++ b/Interfaces/IWarning.cs @@ -1,6 +1,6 @@ -using Upsilon.Apps.PassKey.Core.Public.Enums; +using Upsilon.Apps.Passkey.Interfaces.Enums; -namespace Upsilon.Apps.PassKey.Core.Public.Interfaces +namespace Upsilon.Apps.Passkey.Interfaces { /// /// Represent a warning. diff --git a/Interfaces/Interfaces.cd b/Interfaces/Interfaces.cd new file mode 100644 index 0000000..acb986d --- /dev/null +++ b/Interfaces/Interfaces.cd @@ -0,0 +1,190 @@ + + + + + + AAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAA= + Events\AutoSaveDetectedEventArgs.cs + + + + + + + + + AAAAAAAAAAAAgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= + Events\WarningDetectedEventArgs.cs + + + + + + AAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= + Events\LogoutEventArgs.cs + + + + + + AAAgAAAAEAAAAAAAAAAAAAAAAAAAQAACAAAAAAJAAAA= + IAccount.cs + + + + + + + + + + AAACAAAAAAAAABAAAMAAAAAAAAAAAAASAAAAAABABAE= + ICryptographyCenter.cs + + + + + + + + + + + + + + + + + + + + + + + + + + + + AgABAIgQAAAAgAAAYAAACCAggAGEAQAAEAARAACABAA= + IDatabase.cs + + + + + + + + + + + + + + + + + AAAAAAAAAEAAAAAAgAAAAAAAAAAAAAAAAAAAAAAAAAA= + IItem.cs + + + + + + + + + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAAAIAIAAAAAA= + ILog.cs + + + + + + AAAAAAAAAAAAAAAAAAAAAAAAACBAAAAAAAAAAAAAAAA= + ISerializationCenter.cs + + + + + + AAAhAAAAAAAAAAAAAAAAAAAAAAAAAACACAACAQAABAA= + IService.cs + + + + + + + + + + + + gABAAAEAAAAAAAAAAQAAAAAAAAAACAAIAAkAECAAAAA= + IUser.cs + + + + + + + + + + + + + + + + + AAAAAAAQgAAAAAAAAAAAAAAAAAAAAACAAAAAAAAAAAA= + IWarning.cs + + + + + + + + + + + + + AAAAAQAAAAAAAAAAAAAAAAACAAAAAAQAgABAAAAAAAA= + IPasswordFactory.cs + + + + + + AAAAAAAAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= + IClipboardManager.cs + + + + + + AIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAA= + Enums\AccountOption.cs + + + + + + AAAAQAAAAABgAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= + Enums\WarningType.cs + + + + + + AAAAIBAgAAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAA= + Enums\AutoSaveMergeBehavior.cs + + + + \ No newline at end of file diff --git a/Interfaces/Upsilon.Apps.Passkey.Interfaces.csproj b/Interfaces/Upsilon.Apps.Passkey.Interfaces.csproj new file mode 100644 index 0000000..9355386 --- /dev/null +++ b/Interfaces/Upsilon.Apps.Passkey.Interfaces.csproj @@ -0,0 +1,22 @@ + + + + net10.0 + enable + enable + $(AssemblyName) + Yassin Lokhat + A local stored Password Manager Interfaces. + + + + True + 9999 + + + + True + 9999 + + + diff --git a/Core/Public/Utils/Exceptions.cs b/Interfaces/Utils/Exceptions.cs similarity index 95% rename from Core/Public/Utils/Exceptions.cs rename to Interfaces/Utils/Exceptions.cs index 2239cda..47df615 100644 --- a/Core/Public/Utils/Exceptions.cs +++ b/Interfaces/Utils/Exceptions.cs @@ -1,4 +1,4 @@ -namespace Upsilon.Apps.PassKey.Core.Public.Utils +namespace Upsilon.Apps.Passkey.Interfaces.Utils { public sealed class CheckSignFailedException : Exception { diff --git a/Interfaces/Utils/StaticMethods.cs b/Interfaces/Utils/StaticMethods.cs new file mode 100644 index 0000000..91aa374 --- /dev/null +++ b/Interfaces/Utils/StaticMethods.cs @@ -0,0 +1,15 @@ +namespace Upsilon.Apps.Passkey.Interfaces.Utils +{ + public static class StaticMethods + { + public static bool HasChanged(this IItem item) + { + return item.Database.HasChanged(item.ItemId); + } + + public static bool HasChanged(this IItem item, string fieldName) + { + return item.Database.HasChanged(item.ItemId, fieldName); + } + } +} diff --git a/README.md b/README.md index 1a332e6..145c876 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,10 @@ - - -**Upsilon.Apps.Passkey.Core** +**Upsilon.Apps.Passkey** ============================================= **Overview** ------------ -This is a C# implementation of a local stored password manager core API. The API provides a secure way to store and manage passwords locally on a user's device. +This is a C# implementation of a local stored password manager. The application provides a secure way to store and manage passwords locally on the user's device. **Features** ------------ @@ -28,7 +26,199 @@ This is a C# implementation of a local stored password manager core API. The API ---------- ### Class diagram -![Interfaces](https://github.com/user-attachments/assets/a4ad591c-1334-426f-86de-b7d264ea904b) +```mermaid +classDiagram + direction TB + + %% Main Interfaces + class ISerializationCenter { + <> + +Serialize~T~(in toSerialize T) string + +Deserialize~T~(in toDeserialize string) T + } + + class IClipboardManager { + <> + +RemoveAllOccurence(in removeList IEnumerable~string~) int + } + + class IPasswordFactory { + <> + +Alphabetic : string + +Numeric : string + +SpecialChars : string + + +GeneratePassword(in length int, in alphabet string, in checkIfLeaked bool) string + +PasswordLeaked(in password string) bool + } + + class ICryptographyCenter { + <> + +HashLength : int + + +GetHash(in source string) string + +GetSlowHash(in source string) string + +Sign(inout source string) void + +CheckSign(inout source string) bool + +EncryptSymmetrically(inout source string, in passwords IEnumerable~string~) string + +DecryptSymmetrically(inout source string, in passwords IEnumerable~string~) string + +GenerateRandomKeys(out publicKey string, out privateKey string) void + +EncryptAsymmetrically(inout source string, in key string) string + +DecryptAsymmetrically(inout source string, in key string) string + } + + class IItem { + <> + +ItemId : string + +Database : IDatabase + } + + class IAccount { + <> + +Service : IService + +Label : string + +Notes : string + +Identifiers : IEnumerable~string~ + +Password : string + +Passwords : IDictionary~DateTime, string~ + +PasswordUpdateReminderDelay : int + +Options : AccountOption + } + + class IService { + <> + +User : IUser + +ServiceName : string + +Url : string + +Notes : string + +Accounts : IEnumerable~IAccount~ + +AddAccount(in label string, in identifiers IEnumerable~string~, in password string) IAccount + +AddAccount(in label string, in identifiers IEnumerable~string~) IAccount + +AddAccount(in identifiers IEnumerable~string~, in password string) IAccount + +AddAccount(in identifiers IEnumerable~string~) IAccount + +DeleteAccount(in account IAccount) void + } + + class IUser { + <> + +Username : string + +Passkeys : IEnumerable~string~ + +LogoutTimeout : int + +CleaningClipboardTimeout : int + +ShowPasswordDelay : int + +NumberOfOldPasswordToKeep : int + +WarningsToNotify : WarningType + +Services : IEnumerable~IService~ + +AddService(in serviceName string) IService + +DeleteService(in service IService) void + } + + class IDatabase { + <> + +DatabaseFile : string + +AutoSaveFile : string + +LogFile : string + +User : IUser + +SessionLeftTime : int + +Logs : IEnumerable~ILog~ + +Warnings : IEnumerable~IWarning~ + +SerializationCenter : ISerializationCenter + +CryptographyCenter : ICryptographyCenter + +PasswordFactory : IPasswordFactory + +ClipboardManager : IClipboardManager + +WarningDetected : EventHandler~WarningDetectedEventArgs~ + +AutoSaveDetected : EventHandler~AutoSaveDetectedEventArgs~ + +DatabaseSaved : EventHandler + +DatabaseClosed : EventHandler~LogoutEventArgs~ + +Login(in passkey string) IUser + +Save(void) void + +Delete(void) void + +Close(void) void + +HasChanged(void) bool + +HasChanged(in itemId string) bool + +HasChanged(in itemId string, in fieldName string) bool + +ImportFromFile(in filePath string) bool + +ExportToFile(in filePath string) bool + } + + class ILog { + <> + +DateTime : DateTime + +Message : string + +NeedsReview : bool + } + + class IWarning { + <> + +WarningType : WarningType + +Logs : IEnumerable~ILog~ + +Accounts : IEnumerable~IAccount~ + } + + %% Enums + class AccountOption { + <> + None + WarnIfPasswordLeaked + } + + class WarningType { + <> + LogReviewWarning + PasswordUpdateReminderWarning + DuplicatedPasswordsWarning + PasswordLeakedWarning + } + + class AutoSaveMergeBehavior { + <> + MergeAndSaveThenRemoveAutoSaveFile + MergeWithoutSavingAndKeepAutoSaveFile + DontMergeAndRemoveAutoSaveFile + DontMergeAndKeepAutoSaveFile + } + + %% Event Args Classes + class AutoSaveDetectedEventArgs { + +MergeBehavior : AutoSaveMergeBehavior + } + + class WarningDetectedEventArgs { + +Warnings : IEnumerable~IWarning~ + } + + class LogoutEventArgs { + +LoginTimeoutReached : bool + } + + %% Inheritance Relations + IUser --|> IItem + IService --|> IItem + IAccount --|> IItem + + %% Link Relations + IItem --> IDatabase : Database + IAccount --> IService : Service + IAccount --> AccountOption : Options + IService "0" --> "*" IAccount : Accounts + IService --> IUser : User + IUser "0" --> "*" IService : Services + IDatabase --> ISerializationCenter : SerializationCenter + IDatabase --> ICryptographyCenter : CryptographyCenter + IDatabase --> IPasswordFactory : PasswordFactory + IDatabase --> IClipboardManager : ClipboardManager + IDatabase --> IUser : User + IDatabase "0" --> "*" IWarning : Warnings + IDatabase "0" --> "*" ILog : Logs + IDatabase --> WarningDetectedEventArgs : WarningDetected + IDatabase --> AutoSaveDetectedEventArgs : AutoSaveDetected + IDatabase --> LogoutEventArgs : DatabaseClosed + IWarning --> WarningType : WarningType + IWarning "0" --> "*" ILog : Logs + IWarning "0" --> "*" IAccount : Accounts + AutoSaveDetectedEventArgs --> AutoSaveMergeBehavior : MergeBehavior + WarningDetectedEventArgs "0" --> "*" IWarning : Warnings +``` **Example Use Cases** @@ -36,10 +226,10 @@ This is a C# implementation of a local stored password manager core API. The API ### Create a new database -To create a new database, use the `IDatabase.Create` static method. +To create a new database, use the `Upsilon.Apps.Passkey.Core.Models.Database.Create` static method. -This method needs an `ICryptographyCenter` implementation, an `ISerializationCenter` implementation and an `IPasswordFactory` implementation. -The namespace `Upsilon.Apps.PassKey.Core.Public.Utils` already contains implementations for all of these intefaces. +This method needs an `ICryptographyCenter` implementation, an `ISerializationCenter` implementation, an `IPasswordFactory` implementation and an `IClipboardManager` implementation. +The namespace `Upsilon.Apps.Passkey.Core.Utils` already contains implementations for all of these intefaces except for the `IClipboardManager` which needs an OS specific implementation. The next parameters are a set of files : the database file itself, the autosave file and the log file. These files will be created during the process. @@ -48,9 +238,10 @@ Finally, the method take the username and the passkeys. Note that the passkeys are used as master passwords to encrypt the database (and the other files). ```csharp -IDatabase database = IDatabase.Create(new Upsilon.Apps.PassKey.Core.Public.Utils.CryptographyCenter(), - new Upsilon.Apps.PassKey.Core.Public.Utils.JsonSerializationCenter(), - new Upsilon.Apps.PassKey.Core.Public.Utils.PasswordFactory(), +IDatabase database = Upsilon.Apps.Passkey.Core.Models.Database.Create(new Upsilon.Apps.Passkey.Core.Utils.CryptographyCenter(), + new Upsilon.Apps.Passkey.Core.Utils.JsonSerializationCenter(), + new Upsilon.Apps.Passkey.Core.Utils.PasswordFactory(), + new OSSpecificClipboardManager(), "./database.pku", "./autosave.pks", "./log.pkl", @@ -63,9 +254,9 @@ So to login, check the **Login to an user** use case. ### Open an existing database -To open an existing database, use the `IDatabase.Open` static method. +To open an existing database, use the `Upsilon.Apps.Passkey.Core.Models.Database.Open` static method. -This method needs the same `ICryptographyCenter` implementation, `ISerializationCenter` implementation and `IPasswordFactory` implementation as in the creation step. +This method needs an `ICryptographyCenter` implementation, an `ISerializationCenter` implementation, an `IPasswordFactory` implementation and an `IClipboardManager` implementation as in the creation step. The next parameters are a set of files : the database file itself, the autosave file and the log file. The database file must, obviously, exist, the autosave file and log files are optional but must be the same as provided during the creating process. @@ -73,9 +264,10 @@ The database file must, obviously, exist, the autosave file and log files are op Finally, the method take the username. ```csharp -IDatabase database = IDatabase.Open(new Upsilon.Apps.PassKey.Core.Public.Utils.CryptographyCenter(), - new Upsilon.Apps.PassKey.Core.Public.Utils.JsonSerializationCenter(), - new Upsilon.Apps.PassKey.Core.Public.Utils.PasswordFactory(), +IDatabase database = Upsilon.Apps.Passkey.Core.Models.Database.Open(new Upsilon.Apps.Passkey.Core.Utils.CryptographyCenter(), + new Upsilon.Apps.Passkey.Core.Utils.JsonSerializationCenter(), + new Upsilon.Apps.Passkey.Core.Utils.PasswordFactory(), + new OSSpecificClipboardManager(), "./database.pku", "./autosave.pks", "./log.pkl", @@ -119,7 +311,7 @@ database.Close(); **Getting Started** ------------------- -1. Clone the repository: `git clone https://github.com/YassinLokhat/Upsilon.Apps.Passkey.Core.git` +1. Clone the repository: `git clone https://github.com/YassinLokhat/Upsilon.Apps.Passkey.git` 2. Build the solution: `dotnet build` 3. Run the API: `dotnet run` diff --git a/UnitTests/ClipboardManager.cs b/UnitTests/ClipboardManager.cs new file mode 100644 index 0000000..46d1d92 --- /dev/null +++ b/UnitTests/ClipboardManager.cs @@ -0,0 +1,32 @@ +using Upsilon.Apps.Passkey.Interfaces; +using Windows.ApplicationModel.DataTransfer; + +namespace Upsilon.Apps.Passkey.Core.Utils +{ + public class ClipboardManager : IClipboardManager + { + public int RemoveAllOccurence(string[] removeList) + { + int cleanedPasswordCount = 0; + + IReadOnlyList clipboardHistory = Clipboard.GetHistoryItemsAsync().AsTask().GetAwaiter().GetResult().Items; + + foreach (ClipboardHistoryItem? item in clipboardHistory) + { + DataPackageView content = item.Content; + if (content.Contains(StandardDataFormats.Text)) + { + string text = content.GetTextAsync().AsTask().GetAwaiter().GetResult(); + + if (removeList.Any(x => x == text)) + { + _ = Clipboard.DeleteItemFromHistory(item); + cleanedPasswordCount++; + } + } + } + + return cleanedPasswordCount; + } + } +} diff --git a/UnitTests/Models/AccountUnitTests.cs b/UnitTests/Models/AccountUnitTests.cs index 4b687ed..b725bec 100644 --- a/UnitTests/Models/AccountUnitTests.cs +++ b/UnitTests/Models/AccountUnitTests.cs @@ -1,8 +1,9 @@ using FluentAssertions; -using Upsilon.Apps.PassKey.Core.Public.Enums; -using Upsilon.Apps.PassKey.Core.Public.Interfaces; +using Upsilon.Apps.Passkey.Interfaces; +using Upsilon.Apps.Passkey.Interfaces.Enums; +using Upsilon.Apps.Passkey.Interfaces.Utils; -namespace Upsilon.Apps.PassKey.UnitTests.Models +namespace Upsilon.Apps.Passkey.UnitTests.Models { [TestClass] public sealed class AccountUnitTests @@ -23,43 +24,66 @@ public void Case01_AddAccountUpdateSaved() IService service = databaseCreated.User.AddService("Service_" + UnitTestsHelper.GetUsername()); string oldAccountLabel = "Account_" + UnitTestsHelper.GetUsername(); string newAccountLabel = "new_" + oldAccountLabel; - string[] oldIdentifiants = UnitTestsHelper.GetRandomStringArray(); - string[] newIdentifiants = UnitTestsHelper.GetRandomStringArray(); + string[] oldIdentifiers = UnitTestsHelper.GetRandomStringArray(); + string[] newIdentifiers = UnitTestsHelper.GetRandomStringArray(); string oldPassword = UnitTestsHelper.GetRandomString(); string newPassword = UnitTestsHelper.GetRandomString(); string notes = UnitTestsHelper.GetRandomString(); int passwordUpdateReminderDelay = UnitTestsHelper.GetRandomInt(12); - AccountOption options = AccountOption.WarnIfPasswordLeaked; + AccountOption options = AccountOption.None; Stack expectedLogs = new(); Stack expectedLogWarnings = new(); // When - IAccount account = service.AddAccount(oldAccountLabel, oldIdentifiants, oldPassword); - expectedLogs.Push($"Information : Account {oldAccountLabel} ({string.Join(", ", oldIdentifiants)}) has been added to Service {service.ServiceName}"); + IAccount account = service.AddAccount(oldAccountLabel, oldIdentifiers, oldPassword); + expectedLogs.Push($"Information : Account {oldAccountLabel} ({string.Join(", ", oldIdentifiers)}) has been added to Service {service.ServiceName}"); + expectedLogs.Push($"Warning : Account {oldAccountLabel} ({string.Join(", ", oldIdentifiers)})'s password has been updated"); + expectedLogWarnings.Push($"Warning : Account {oldAccountLabel} ({string.Join(", ", oldIdentifiers)})'s password has been updated"); // Then + databaseCreated.User.HasChanged().Should().BeTrue(); + service.HasChanged().Should().BeTrue(); + account.HasChanged().Should().BeTrue(); _ = service.Accounts.Length.Should().Be(1); _ = account.Label.Should().Be(oldAccountLabel); - _ = account.Identifiants.Should().BeEquivalentTo(oldIdentifiants); + _ = account.Identifiers.Should().BeEquivalentTo(oldIdentifiers); _ = account.Password.Should().Be(oldPassword); _ = account.Passwords.Values.Should().BeEquivalentTo([oldPassword]); // When account.Label = newAccountLabel; - expectedLogs.Push($"Information : Account {oldAccountLabel} ({string.Join(", ", oldIdentifiants)})'s label has been set to {newAccountLabel}"); - account.Identifiants = newIdentifiants; - expectedLogs.Push($"Warning : Account {newAccountLabel} ({string.Join(", ", oldIdentifiants)})'s identifiants has been set to ({string.Join(", ", newIdentifiants)})"); - expectedLogWarnings.Push($"Warning : Account {newAccountLabel} ({string.Join(", ", oldIdentifiants)})'s identifiants has been set to ({string.Join(", ", newIdentifiants)})"); + account.Label = newAccountLabel; + expectedLogs.Push($"Information : Account {oldAccountLabel} ({string.Join(", ", oldIdentifiers)})'s label has been set to {newAccountLabel}"); + account.Identifiers = newIdentifiers; + account.Identifiers = newIdentifiers; + expectedLogs.Push($"Warning : Account {newAccountLabel} ({string.Join(", ", oldIdentifiers)})'s identifiers has been set to ({string.Join(", ", newIdentifiers)})"); + expectedLogWarnings.Push($"Warning : Account {newAccountLabel} ({string.Join(", ", oldIdentifiers)})'s identifiers has been set to ({string.Join(", ", newIdentifiers)})"); + account.Password = newPassword; account.Password = newPassword; - expectedLogs.Push($"Warning : Account {newAccountLabel} ({string.Join(", ", newIdentifiants)})'s password has been updated"); - expectedLogWarnings.Push($"Warning : Account {newAccountLabel} ({string.Join(", ", newIdentifiants)})'s password has been updated"); + expectedLogs.Push($"Warning : Account {newAccountLabel} ({string.Join(", ", newIdentifiers)})'s password has been updated"); + expectedLogWarnings.Push($"Warning : Account {newAccountLabel} ({string.Join(", ", newIdentifiers)})'s password has been updated"); + account.Notes = notes; account.Notes = notes; - expectedLogs.Push($"Information : Account {newAccountLabel} ({string.Join(", ", newIdentifiants)})'s notes has been set to {notes}"); + expectedLogs.Push($"Information : Account {newAccountLabel} ({string.Join(", ", newIdentifiers)})'s notes has been set to {notes}"); account.PasswordUpdateReminderDelay = passwordUpdateReminderDelay; - expectedLogs.Push($"Information : Account {newAccountLabel} ({string.Join(", ", newIdentifiants)})'s password update reminder delay has been set to {passwordUpdateReminderDelay}"); + expectedLogs.Push($"Information : Account {newAccountLabel} ({string.Join(", ", newIdentifiers)})'s password update reminder delay has been set to {passwordUpdateReminderDelay}"); account.Options = options; - expectedLogs.Push($"Information : Account {newAccountLabel} ({string.Join(", ", newIdentifiants)})'s options has been set to {options}"); + account.Options = options; + expectedLogs.Push($"Information : Account {newAccountLabel} ({string.Join(", ", newIdentifiers)})'s options has been set to {options}"); + + // Then + databaseCreated.User.HasChanged().Should().BeTrue(); + service.HasChanged().Should().BeTrue(); + service.HasChanged().Should().BeTrue(); + account.HasChanged().Should().BeTrue(); + account.HasChanged(nameof(account.Label)).Should().BeTrue(); + account.HasChanged(nameof(account.Identifiers)).Should().BeTrue(); + account.HasChanged(nameof(account.Password)).Should().BeTrue(); + account.HasChanged(nameof(account.Notes)).Should().BeTrue(); + account.HasChanged(nameof(account.PasswordUpdateReminderDelay)).Should().BeTrue(); + account.HasChanged(nameof(account.Options)).Should().BeTrue(); + // When databaseCreated.Save(); expectedLogs.Push($"Information : User {username}'s database saved"); databaseCreated.Close(); @@ -79,7 +103,7 @@ public void Case01_AddAccountUpdateSaved() // Then _ = account.Label.Should().Be(newAccountLabel); - _ = account.Identifiants.Should().BeEquivalentTo(newIdentifiants); + _ = account.Identifiers.Should().BeEquivalentTo(newIdentifiers); _ = account.Password.Should().Be(newPassword); _ = account.Passwords.OrderByDescending(x => x.Key).Select(x => x.Value).Should().BeEquivalentTo([newPassword, oldPassword]); _ = account.Notes.Should().Be(notes); @@ -98,7 +122,7 @@ public void Case01_AddAccountUpdateSaved() /* * Service.AddAccount adds the new account, * Then updating the account without saving will create the autosave file, - * Then Database.Open with AutoSaveMergeBehavior.MergeThenRemoveAutoSaveFile loads correctly the updated database file with the updated account. + * Then Database.Open with AutoSaveMergeBehavior.MergeAndSaveThenRemoveAutoSaveFile loads correctly the updated database file with the updated account. */ public void Case02_AddAccountUpdateAutoSave() { @@ -110,53 +134,60 @@ public void Case02_AddAccountUpdateAutoSave() IService service = databaseCreated.User.AddService("Service_" + UnitTestsHelper.GetUsername()); string oldAccountLabel = "Account_" + UnitTestsHelper.GetUsername(); string newAccountLabel = "new_" + oldAccountLabel; - string[] oldIdentifiants = UnitTestsHelper.GetRandomStringArray(); - string[] newIdentifiants = UnitTestsHelper.GetRandomStringArray(); + string[] oldIdentifiers = UnitTestsHelper.GetRandomStringArray(); + string[] newIdentifiers = UnitTestsHelper.GetRandomStringArray(); string oldPassword = UnitTestsHelper.GetRandomString(); string newPassword = UnitTestsHelper.GetRandomString(); string notes = UnitTestsHelper.GetRandomString(); int passwordUpdateReminderDelay = UnitTestsHelper.GetRandomInt(12); - AccountOption options = AccountOption.WarnIfPasswordLeaked; + AccountOption options = AccountOption.None; Stack expectedLogs = new(); Stack expectedLogWarnings = new(); // When - IAccount account = service.AddAccount(oldAccountLabel, oldIdentifiants, oldPassword); - expectedLogs.Push($"Information : Account {oldAccountLabel} ({string.Join(", ", oldIdentifiants)}) has been added to Service {service.ServiceName}"); + IAccount account = service.AddAccount(oldAccountLabel, oldIdentifiers, oldPassword); + expectedLogs.Push($"Information : Account {oldAccountLabel} ({string.Join(", ", oldIdentifiers)}) has been added to Service {service.ServiceName}"); + expectedLogs.Push($"Warning : Account {oldAccountLabel} ({string.Join(", ", oldIdentifiers)})'s password has been updated"); + expectedLogWarnings.Push($"Warning : Account {oldAccountLabel} ({string.Join(", ", oldIdentifiers)})'s password has been updated"); // Then _ = service.Accounts.Length.Should().Be(1); _ = account.Label.Should().Be(oldAccountLabel); - _ = account.Identifiants.Should().BeEquivalentTo(oldIdentifiants); + _ = account.Identifiers.Should().BeEquivalentTo(oldIdentifiers); _ = account.Password.Should().Be(oldPassword); _ = account.Passwords.Values.Should().BeEquivalentTo([oldPassword]); // When account.Label = newAccountLabel; - expectedLogs.Push($"Information : Account {oldAccountLabel} ({string.Join(", ", oldIdentifiants)})'s label has been set to {newAccountLabel}"); - account.Identifiants = newIdentifiants; - expectedLogs.Push($"Warning : Account {newAccountLabel} ({string.Join(", ", oldIdentifiants)})'s identifiants has been set to ({string.Join(", ", newIdentifiants)})"); - expectedLogWarnings.Push($"Warning : Account {newAccountLabel} ({string.Join(", ", oldIdentifiants)})'s identifiants has been set to ({string.Join(", ", newIdentifiants)})"); + account.Label = newAccountLabel; + expectedLogs.Push($"Information : Account {oldAccountLabel} ({string.Join(", ", oldIdentifiers)})'s label has been set to {newAccountLabel}"); + account.Identifiers = newIdentifiers; + account.Identifiers = newIdentifiers; + expectedLogs.Push($"Warning : Account {newAccountLabel} ({string.Join(", ", oldIdentifiers)})'s identifiers has been set to ({string.Join(", ", newIdentifiers)})"); + expectedLogWarnings.Push($"Warning : Account {newAccountLabel} ({string.Join(", ", oldIdentifiers)})'s identifiers has been set to ({string.Join(", ", newIdentifiers)})"); + account.Password = newPassword; account.Password = newPassword; - expectedLogs.Push($"Warning : Account {newAccountLabel} ({string.Join(", ", newIdentifiants)})'s password has been updated"); - expectedLogWarnings.Push($"Warning : Account {newAccountLabel} ({string.Join(", ", newIdentifiants)})'s password has been updated"); + expectedLogs.Push($"Warning : Account {newAccountLabel} ({string.Join(", ", newIdentifiers)})'s password has been updated"); + expectedLogWarnings.Push($"Warning : Account {newAccountLabel} ({string.Join(", ", newIdentifiers)})'s password has been updated"); account.Notes = notes; - expectedLogs.Push($"Information : Account {newAccountLabel} ({string.Join(", ", newIdentifiants)})'s notes has been set to {notes}"); + account.Notes = notes; + expectedLogs.Push($"Information : Account {newAccountLabel} ({string.Join(", ", newIdentifiers)})'s notes has been set to {notes}"); account.PasswordUpdateReminderDelay = passwordUpdateReminderDelay; - expectedLogs.Push($"Information : Account {newAccountLabel} ({string.Join(", ", newIdentifiants)})'s password update reminder delay has been set to {passwordUpdateReminderDelay}"); + expectedLogs.Push($"Information : Account {newAccountLabel} ({string.Join(", ", newIdentifiers)})'s password update reminder delay has been set to {passwordUpdateReminderDelay}"); + account.Options = options; account.Options = options; - expectedLogs.Push($"Information : Account {newAccountLabel} ({string.Join(", ", newIdentifiants)})'s options has been set to {options}"); + expectedLogs.Push($"Information : Account {newAccountLabel} ({string.Join(", ", newIdentifiers)})'s options has been set to {options}"); databaseCreated.Close(); expectedLogs.Push($"Warning : User {username} logged out without saving"); expectedLogWarnings.Push($"Warning : User {username} logged out without saving"); expectedLogs.Push($"Information : User {username}'s database closed"); - IDatabase databaseLoaded = UnitTestsHelper.OpenTestDatabase(passkeys, out _, AutoSaveMergeBehavior.MergeThenRemoveAutoSaveFile); + IDatabase databaseLoaded = UnitTestsHelper.OpenTestDatabase(passkeys, out _, AutoSaveMergeBehavior.MergeAndSaveThenRemoveAutoSaveFile); expectedLogs.Push($"Information : User {username}'s database opened"); expectedLogs.Push($"Information : User {username} logged in"); - expectedLogs.Push($"Warning : User {username}'s autosave merged"); - expectedLogWarnings.Push($"Warning : User {username}'s autosave merged"); + expectedLogs.Push($"Warning : User {username}'s autosave merged and saved"); + expectedLogWarnings.Push($"Warning : User {username}'s autosave merged and saved"); IService serviceLoaded = databaseLoaded.User.Services.First(); // Then @@ -167,7 +198,7 @@ public void Case02_AddAccountUpdateAutoSave() // Then _ = account.Label.Should().Be(newAccountLabel); - _ = account.Identifiants.Should().BeEquivalentTo(newIdentifiants); + _ = account.Identifiers.Should().BeEquivalentTo(newIdentifiers); _ = account.Password.Should().Be(newPassword); _ = account.Passwords.OrderByDescending(x => x.Key).Select(x => x.Value).Should().BeEquivalentTo([newPassword, oldPassword]); _ = account.Notes.Should().Be(notes); @@ -196,9 +227,9 @@ public void Case03_DeleteAccountUpdateSaved() IDatabase databaseCreated = UnitTestsHelper.CreateTestDatabase(passkeys); IService service = databaseCreated.User.AddService("Service_" + UnitTestsHelper.GetUsername()); string accountLabel = "Account_" + UnitTestsHelper.GetUsername(); - string[] identifiants = UnitTestsHelper.GetRandomStringArray(); + string[] identifiers = UnitTestsHelper.GetRandomStringArray(); string password = UnitTestsHelper.GetRandomString(); - _ = service.AddAccount(accountLabel, identifiants, password); + _ = service.AddAccount(accountLabel, identifiers, password); databaseCreated.Save(); databaseCreated.Close(); Stack expectedLogs = new(); @@ -210,8 +241,8 @@ public void Case03_DeleteAccountUpdateSaved() // When serviceLoaded.DeleteAccount(accountLoaded); - expectedLogs.Push($"Warning : Account {accountLabel} ({string.Join(", ", identifiants)}) has been removed from Service {service.ServiceName}"); - expectedLogWarnings.Push($"Warning : Account {accountLabel} ({string.Join(", ", identifiants)}) has been removed from Service {service.ServiceName}"); + expectedLogs.Push($"Warning : Account {accountLabel} ({string.Join(", ", identifiers)}) has been removed from Service {service.ServiceName}"); + expectedLogWarnings.Push($"Warning : Account {accountLabel} ({string.Join(", ", identifiers)}) has been removed from Service {service.ServiceName}"); // Then _ = serviceLoaded.Accounts.Length.Should().Be(0); @@ -243,7 +274,7 @@ public void Case03_DeleteAccountUpdateSaved() [TestMethod] /* * Service.DeleteAccount adeletes the account, - * Then Database.Open with AutoSaveMergeBehavior.MergeThenRemoveAutoSaveFile loads correctly the updated database file with the updated account. + * Then Database.Open with AutoSaveMergeBehavior.MergeAndSaveThenRemoveAutoSaveFile loads correctly the updated database file with the updated account. */ public void Case04_DeleteAccountUpdateAutoSave() { @@ -254,9 +285,9 @@ public void Case04_DeleteAccountUpdateAutoSave() IDatabase databaseCreated = UnitTestsHelper.CreateTestDatabase(passkeys); IService service = databaseCreated.User.AddService("Service_" + UnitTestsHelper.GetUsername()); string accountLabel = "Account_" + UnitTestsHelper.GetUsername(); - string[] identifiants = UnitTestsHelper.GetRandomStringArray(); + string[] identifiers = UnitTestsHelper.GetRandomStringArray(); string password = UnitTestsHelper.GetRandomString(); - _ = service.AddAccount(accountLabel, identifiants, password); + _ = service.AddAccount(accountLabel, identifiers, password); databaseCreated.Save(); databaseCreated.Close(); Stack expectedLogs = new(); @@ -268,8 +299,8 @@ public void Case04_DeleteAccountUpdateAutoSave() // When serviceLoaded.DeleteAccount(accountLoaded); - expectedLogs.Push($"Warning : Account {accountLabel} ({string.Join(", ", identifiants)}) has been removed from Service {service.ServiceName}"); - expectedLogWarnings.Push($"Warning : Account {accountLabel} ({string.Join(", ", identifiants)}) has been removed from Service {service.ServiceName}"); + expectedLogs.Push($"Warning : Account {accountLabel} ({string.Join(", ", identifiers)}) has been removed from Service {service.ServiceName}"); + expectedLogWarnings.Push($"Warning : Account {accountLabel} ({string.Join(", ", identifiers)}) has been removed from Service {service.ServiceName}"); // Then _ = serviceLoaded.Accounts.Length.Should().Be(0); @@ -280,11 +311,11 @@ public void Case04_DeleteAccountUpdateAutoSave() expectedLogWarnings.Push($"Warning : User {username} logged out without saving"); expectedLogs.Push($"Information : User {username}'s database closed"); - databaseLoaded = UnitTestsHelper.OpenTestDatabase(passkeys, out _, AutoSaveMergeBehavior.MergeThenRemoveAutoSaveFile); + databaseLoaded = UnitTestsHelper.OpenTestDatabase(passkeys, out _, AutoSaveMergeBehavior.MergeAndSaveThenRemoveAutoSaveFile); expectedLogs.Push($"Information : User {username}'s database opened"); expectedLogs.Push($"Information : User {username} logged in"); - expectedLogs.Push($"Warning : User {username}'s autosave merged"); - expectedLogWarnings.Push($"Warning : User {username}'s autosave merged"); + expectedLogs.Push($"Warning : User {username}'s autosave merged and saved"); + expectedLogWarnings.Push($"Warning : User {username}'s autosave merged and saved"); serviceLoaded = databaseLoaded.User.Services.First(); diff --git a/UnitTests/Models/DatabaseUnitTests.cs b/UnitTests/Models/DatabaseUnitTests.cs index e4c6fac..99098c7 100644 --- a/UnitTests/Models/DatabaseUnitTests.cs +++ b/UnitTests/Models/DatabaseUnitTests.cs @@ -1,11 +1,67 @@ using FluentAssertions; -using Upsilon.Apps.PassKey.Core.Public.Interfaces; +using Upsilon.Apps.Passkey.Core.Models; +using Upsilon.Apps.Passkey.Interfaces; +using Upsilon.Apps.Passkey.Interfaces.Enums; -namespace Upsilon.Apps.PassKey.UnitTests.Models +namespace Upsilon.Apps.Passkey.UnitTests.Models { [TestClass] public sealed class DatabaseUnitTests { + [TestMethod, Ignore] + public void Case00_GenerateNewDatabase() + { + IDatabase database = UnitTestsHelper.CreateTestDatabase(["a", "b"], "_"); + IUser user = database.User; + user.LogoutTimeout = 10; + user.CleaningClipboardTimeout = 15; + user.WarningsToNotify = (WarningType)0; + + for (int i = 0; i < 50; i++) + { + IService service = user.AddService($"Service{i} ({UnitTestsHelper.GetRandomString(min: 10, max: 15)})"); + service.Url = $"www.service{i}.xyz"; + int random = UnitTestsHelper.GetRandomInt(100) % 10; + service.Notes = random == 0 ? $"Service{i} notes : \n{UnitTestsHelper.GetRandomString(min: 10, max: 150)}" : ""; + + int accountNumber = UnitTestsHelper.GetRandomInt(min: 1, max: 5); + + for (int j = 0; j < accountNumber; j++) + { + random = UnitTestsHelper.GetRandomInt(10) + 1; + + IAccount account; + switch (random % 4) + { + case 1: + account = service.AddAccount(label: $"Account{j}", + identifiers: UnitTestsHelper.GetRandomStringArray(random / 2).Select(x => x + "@test.te")); + break; + case 2: + account = service.AddAccount(identifiers: UnitTestsHelper.GetRandomStringArray(random / 2).Select(x => x + "@test.te"), + password: UnitTestsHelper.GetRandomString(min: 20, max: 25)); + break; + case 3: + account = service.AddAccount(identifiers: UnitTestsHelper.GetRandomStringArray(random / 2).Select(x => x + "@test.te")); + break; + default: + account = service.AddAccount(label: $"Account{j}", + identifiers: UnitTestsHelper.GetRandomStringArray(random / 2).Select(x => x + "@test.te"), + password: UnitTestsHelper.GetRandomString(min: 20, max: 25)); + break; + } + + random = UnitTestsHelper.GetRandomInt(100); + account.Notes = random % 10 == 0 ? $"Service{i}'s Account{j} notes : \n{UnitTestsHelper.GetRandomString(min: 10, max: 150)}" : ""; + account.PasswordUpdateReminderDelay = random < 10 ? random : 0; + account.Options = random % 2 == 0 ? AccountOption.WarnIfPasswordLeaked : AccountOption.None; + } + } + + database.Save(); + database.Close(); + } + [TestMethod] /* * Database.Create creates an empty database file, @@ -28,8 +84,6 @@ public void Case01_DatabaseCreationOpenDelete() // When IDatabase databaseCreated = UnitTestsHelper.CreateTestDatabase(passkeys); expectedLogs.Push($"Information : User {username}'s database created"); - expectedLogs.Push($"Information : User {username}'s database opened"); - expectedLogs.Push($"Information : User {username} logged in"); // Then _ = databaseCreated.DatabaseFile.Should().Be(databaseFile); @@ -260,9 +314,10 @@ public void Case05_DatabaseAutoLogout() Stack expectedLogWarnings = new(); UnitTestsHelper.ClearTestEnvironment(); - IDatabase database = IDatabase.Create(UnitTestsHelper.CryptographicCenter, + IDatabase database = Database.Create(UnitTestsHelper.CryptographicCenter, UnitTestsHelper.SerializationCenter, UnitTestsHelper.PasswordFactory, + UnitTestsHelper.ClipboardManager, databaseFile, autoSaveFile, logFile, @@ -271,11 +326,6 @@ public void Case05_DatabaseAutoLogout() database.DatabaseClosed += (s, e) => { closedDueToTimeout = e.LoginTimeoutReached; }; - foreach (string passkey in passkeys) - { - _ = database.Login(passkey); - } - database.User.LogoutTimeout = 1; database.Save(); DateTime start = DateTime.Now; @@ -286,8 +336,6 @@ public void Case05_DatabaseAutoLogout() Thread.Sleep(500); } - int elapsedTime = (int)(DateTime.Now - start).TotalMinutes; - // Then _ = closedDueToTimeout.Should().BeTrue(); @@ -295,8 +343,7 @@ public void Case05_DatabaseAutoLogout() database = UnitTestsHelper.OpenTestDatabase(passkeys, out _); // Then - elapsedTime.Should().Be(database.User.LogoutTimeout); - database.Logs.FirstOrDefault(x => x.Message == $"User {username}'s login session timeout reached" && x.NeedsReview).Should().NotBeNull(); + _ = database.Logs.FirstOrDefault(x => x.Message == $"User {username}'s login session timeout reached" && x.NeedsReview).Should().NotBeNull(); // Finaly database.Close(); diff --git a/UnitTests/Models/ImportExportUnitTests.cs b/UnitTests/Models/ImportExportUnitTests.cs new file mode 100644 index 0000000..b5867a2 --- /dev/null +++ b/UnitTests/Models/ImportExportUnitTests.cs @@ -0,0 +1,566 @@ +using ABI.System; +using FluentAssertions; +using System; +using System.Collections.Generic; +using System.Text; +using Upsilon.Apps.Passkey.Interfaces; +using Upsilon.Apps.Passkey.Interfaces.Enums; +using Upsilon.Apps.Passkey.UnitTests; +using static Microsoft.ApplicationInsights.MetricDimensionNames.TelemetryContext; + +namespace Upsilon.Apps.Passkey.UnitTests.Models +{ + [TestClass] + public class ImportExportUnitTests + { + [TestMethod] + public void Case01_Import_MissingFile() + { + // Given + UnitTestsHelper.ClearTestEnvironment(); + + string username = UnitTestsHelper.GetUsername(); + string[] passkeys = UnitTestsHelper.GetRandomStringArray(); + string databaseFile = UnitTestsHelper.ComputeDatabaseFilePath(); + string autoSaveFile = UnitTestsHelper.ComputeAutoSaveFilePath(); + string logFile = UnitTestsHelper.ComputeLogFilePath(); + string importFile = UnitTestsHelper.GetTestFilePath("missing_import.csv"); + IDatabase database = UnitTestsHelper.CreateTestDatabase(passkeys); + Stack expectedLogs = new(); + + // When + database.ImportFromFile(importFile); + + expectedLogs.Push($"Information : User {username}'s database saved"); + expectedLogs.Push($"Warning : Importing data from file : '{importFile}'"); + expectedLogs.Push($"Warning : Import failed because import file is not accessible"); + + // Then + database.User.Services.Should().BeEmpty(); + + UnitTestsHelper.LastLogsShouldMatch(database, [.. expectedLogs]); + + // Finaly + database.Close(); + UnitTestsHelper.ClearTestEnvironment(); + } + + [TestMethod] + public void Case02_Import_WrongExtention() + { + // Given + UnitTestsHelper.ClearTestEnvironment(); + + string username = UnitTestsHelper.GetUsername(); + string[] passkeys = UnitTestsHelper.GetRandomStringArray(); + string databaseFile = UnitTestsHelper.ComputeDatabaseFilePath(); + string autoSaveFile = UnitTestsHelper.ComputeAutoSaveFilePath(); + string logFile = UnitTestsHelper.ComputeLogFilePath(); + string importFile = UnitTestsHelper.GetTestFilePath($"{username}/import.txt", createIfNotExists: true); + IDatabase database = UnitTestsHelper.CreateTestDatabase(passkeys); + Stack expectedLogs = new(); + + // When + database.ImportFromFile(importFile); + + expectedLogs.Push($"Information : User {username}'s database saved"); + expectedLogs.Push($"Warning : Importing data from file : '{importFile}'"); + expectedLogs.Push($"Warning : Import failed because '.txt' extention type is not handled"); + + // Then + database.User.Services.Should().BeEmpty(); + + UnitTestsHelper.LastLogsShouldMatch(database, [.. expectedLogs]); + + // Finaly + database.Close(); + UnitTestsHelper.ClearTestEnvironment(); + } + + [TestMethod] + public void Case03_Import_NoData() + { + // Given + UnitTestsHelper.ClearTestEnvironment(); + + string username = UnitTestsHelper.GetUsername(); + string[] passkeys = UnitTestsHelper.GetRandomStringArray(); + string databaseFile = UnitTestsHelper.ComputeDatabaseFilePath(); + string autoSaveFile = UnitTestsHelper.ComputeAutoSaveFilePath(); + string logFile = UnitTestsHelper.ComputeLogFilePath(); + string importFile = UnitTestsHelper.GetTestFilePath($"import_noData.csv"); + IDatabase database = UnitTestsHelper.CreateTestDatabase(passkeys); + Stack expectedLogs = new(); + + // When + database.ImportFromFile(importFile); + + expectedLogs.Push($"Information : User {username}'s database saved"); + expectedLogs.Push($"Warning : Importing data from file : '{importFile}'"); + expectedLogs.Push($"Warning : Import failed because there is no data to import"); + + // Then + database.User.Services.Should().BeEmpty(); + + UnitTestsHelper.LastLogsShouldMatch(database, [.. expectedLogs]); + + // Finaly + database.Close(); + UnitTestsHelper.ClearTestEnvironment(); + } + + [TestMethod] + public void Case04_Import_ServiceAlreadyExists() + { + // Given + UnitTestsHelper.ClearTestEnvironment(); + + string username = UnitTestsHelper.GetUsername(); + string[] passkeys = UnitTestsHelper.GetRandomStringArray(); + string databaseFile = UnitTestsHelper.ComputeDatabaseFilePath(); + string autoSaveFile = UnitTestsHelper.ComputeAutoSaveFilePath(); + string logFile = UnitTestsHelper.ComputeLogFilePath(); + string importFile = UnitTestsHelper.GetTestFilePath($"import.csv"); + IDatabase database = UnitTestsHelper.CreateTestDatabase(passkeys); + Stack expectedLogs = new(); + database.User.AddService("Service1"); + + // When + database.ImportFromFile(importFile); + + expectedLogs.Push($"Information : User {username}'s database saved"); + expectedLogs.Push($"Warning : Importing data from file : '{importFile}'"); + expectedLogs.Push($"Warning : Import failed because service 'Service1' already exists"); + + // Then + database.User.Services.Length.Should().Be(1); + database.User.Services[0].Url.Should().BeEmpty(); + + UnitTestsHelper.LastLogsShouldMatch(database, [.. expectedLogs]); + + // Finaly + database.Close(); + UnitTestsHelper.ClearTestEnvironment(); + } + + [TestMethod] + public void Case05_ImportBlanckService() + { + // Given + UnitTestsHelper.ClearTestEnvironment(); + + string username = UnitTestsHelper.GetUsername(); + string[] passkeys = UnitTestsHelper.GetRandomStringArray(); + string databaseFile = UnitTestsHelper.ComputeDatabaseFilePath(); + string autoSaveFile = UnitTestsHelper.ComputeAutoSaveFilePath(); + string logFile = UnitTestsHelper.ComputeLogFilePath(); + string importFile = UnitTestsHelper.GetTestFilePath($"import_blanckService.csv"); + IDatabase database = UnitTestsHelper.CreateTestDatabase(passkeys); + Stack expectedLogs = new(); + + // When + database.ImportFromFile(importFile); + + expectedLogs.Push($"Information : User {username}'s database saved"); + expectedLogs.Push($"Warning : Importing data from file : '{importFile}'"); + expectedLogs.Push($"Warning : Import failed because service name cannot be blank"); + + // Then + database.User.Services.Should().BeEmpty(); + + UnitTestsHelper.LastLogsShouldMatch(database, [.. expectedLogs]); + + // Finaly + database.Close(); + UnitTestsHelper.ClearTestEnvironment(); + } + + [TestMethod] + public void Case06_ImportCSV_OK() + { + // Given + UnitTestsHelper.ClearTestEnvironment(); + + string username = UnitTestsHelper.GetUsername(); + string[] passkeys = UnitTestsHelper.GetRandomStringArray(); + string databaseFile = UnitTestsHelper.ComputeDatabaseFilePath(); + string autoSaveFile = UnitTestsHelper.ComputeAutoSaveFilePath(); + string logFile = UnitTestsHelper.ComputeLogFilePath(); + string importFile = UnitTestsHelper.GetTestFilePath("import.csv"); + string exportFile = UnitTestsHelper.GetTestFilePath($"{username}/export.csv"); + IDatabase database = UnitTestsHelper.CreateTestDatabase(passkeys); + Stack expectedLogs = new(); + + // When + database.ImportFromFile(importFile); + + expectedLogs.Push($"Information : User {username}'s database saved"); + expectedLogs.Push($"Warning : Importing data from file : '{importFile}'"); + + expectedLogs.Push($"Information : Service Service0 has been added to User {username}"); + expectedLogs.Push($"Information : Service Service0's url has been set to www.service0.xyz"); + expectedLogs.Push($"Information : Service Service0's notes has been set to Service0's notes"); + + expectedLogs.Push($"Information : Account Account0 (account0@service0.xyz, account0_backup@service0.xyz) has been added to Service Service0"); + expectedLogs.Push($"Warning : Account Account0 (account0@service0.xyz, account0_backup@service0.xyz)'s password has been updated"); + expectedLogs.Push($"Information : Account Account0 (account0@service0.xyz, account0_backup@service0.xyz)'s notes has been set to Service0's Account0's notes"); + expectedLogs.Push($"Information : Account Account0 (account0@service0.xyz, account0_backup@service0.xyz)'s options has been set to None"); + expectedLogs.Push($"Information : Account Account0 (account0@service0.xyz, account0_backup@service0.xyz)'s password update reminder delay has been set to 3"); + + expectedLogs.Push($"Information : Account Account1 (account1@service0.xyz, account1_backup@service0.xyz) has been added to Service Service0"); + expectedLogs.Push($"Warning : Account Account1 (account1@service0.xyz, account1_backup@service0.xyz)'s password has been updated"); + expectedLogs.Push($"Information : Account Account1 (account1@service0.xyz, account1_backup@service0.xyz)'s notes has been set to Service0's Account1's notes"); + expectedLogs.Push($"Information : Account Account1 (account1@service0.xyz, account1_backup@service0.xyz)'s options has been set to None"); + expectedLogs.Push($"Information : Account Account1 (account1@service0.xyz, account1_backup@service0.xyz)'s password update reminder delay has been set to 3"); + + expectedLogs.Push($"Information : Service Service1 has been added to User {username}"); + expectedLogs.Push($"Information : Service Service1's url has been set to www.service1.xyz"); + expectedLogs.Push($"Information : Service Service1's notes has been set to Service1's notes"); + + expectedLogs.Push($"Information : Account Account0 (account0@service1.xyz, account0_backup@service1.xyz) has been added to Service Service1"); + expectedLogs.Push($"Warning : Account Account0 (account0@service1.xyz, account0_backup@service1.xyz)'s password has been updated"); + expectedLogs.Push($"Information : Account Account0 (account0@service1.xyz, account0_backup@service1.xyz)'s notes has been set to Service1's Account0's notes"); + expectedLogs.Push($"Information : Account Account0 (account0@service1.xyz, account0_backup@service1.xyz)'s options has been set to None"); + expectedLogs.Push($"Information : Account Account0 (account0@service1.xyz, account0_backup@service1.xyz)'s password update reminder delay has been set to 3"); + + expectedLogs.Push($"Information : Account Account1 (account1@service1.xyz, account1_backup@service1.xyz) has been added to Service Service1"); + expectedLogs.Push($"Warning : Account Account1 (account1@service1.xyz, account1_backup@service1.xyz)'s password has been updated"); + expectedLogs.Push($"Information : Account Account1 (account1@service1.xyz, account1_backup@service1.xyz)'s notes has been set to Service1's Account1's notes"); + expectedLogs.Push($"Information : Account Account1 (account1@service1.xyz, account1_backup@service1.xyz)'s options has been set to None"); + expectedLogs.Push($"Information : Account Account1 (account1@service1.xyz, account1_backup@service1.xyz)'s password update reminder delay has been set to 3"); + + expectedLogs.Push($"Warning : Import completed successfully"); + expectedLogs.Push($"Information : User {username}'s database saved"); + + // Then + database.User.Services.Length.Should().Be(2); + + database.User.Services[0].ServiceName.Should().Be("Service0"); + database.User.Services[0].Url.Should().Be("www.service0.xyz"); + database.User.Services[0].Notes.Should().Be("Service0's notes"); + + database.User.Services[0].Accounts.Length.Should().Be(2); + + database.User.Services[0].Accounts[0].Label.Should().Be("Account0"); + database.User.Services[0].Accounts[0].Identifiers.Should().BeEquivalentTo(new[] { "account0@service0.xyz", "account0_backup@service0.xyz" }); + database.User.Services[0].Accounts[0].Password.Should().Be("0000"); + database.User.Services[0].Accounts[0].Notes.Should().Be("Service0's Account0's notes"); + database.User.Services[0].Accounts[0].Options.Should().Be(AccountOption.None); + database.User.Services[0].Accounts[0].PasswordUpdateReminderDelay.Should().Be(3); + + database.User.Services[0].Accounts[1].Label.Should().Be("Account1"); + database.User.Services[0].Accounts[1].Identifiers.Should().BeEquivalentTo(new[] { "account1@service0.xyz", "account1_backup@service0.xyz" }); + database.User.Services[0].Accounts[1].Password.Should().Be("1111"); + database.User.Services[0].Accounts[1].Notes.Should().Be("Service0's Account1's notes"); + database.User.Services[0].Accounts[1].Options.Should().Be(AccountOption.None); + database.User.Services[0].Accounts[1].PasswordUpdateReminderDelay.Should().Be(3); + + database.User.Services[1].ServiceName.Should().Be("Service1"); + database.User.Services[1].Url.Should().Be("www.service1.xyz"); + database.User.Services[1].Notes.Should().Be("Service1's notes"); + + database.User.Services[1].Accounts.Length.Should().Be(2); + + database.User.Services[1].Accounts[0].Label.Should().Be("Account0"); + database.User.Services[1].Accounts[0].Identifiers.Should().BeEquivalentTo(new[] { "account0@service1.xyz", "account0_backup@service1.xyz" }); + database.User.Services[1].Accounts[0].Password.Should().Be("AAAA"); + database.User.Services[1].Accounts[0].Notes.Should().Be("Service1's Account0's notes"); + database.User.Services[1].Accounts[0].Options.Should().Be(AccountOption.None); + database.User.Services[1].Accounts[0].PasswordUpdateReminderDelay.Should().Be(3); + + database.User.Services[1].Accounts[1].Label.Should().Be("Account1"); + database.User.Services[1].Accounts[1].Identifiers.Should().BeEquivalentTo(new[] { "account1@service1.xyz", "account1_backup@service1.xyz" }); + database.User.Services[1].Accounts[1].Password.Should().Be("BBBB"); + database.User.Services[1].Accounts[1].Notes.Should().Be("Service1's Account1's notes"); + database.User.Services[1].Accounts[1].Options.Should().Be(AccountOption.None); + database.User.Services[1].Accounts[1].PasswordUpdateReminderDelay.Should().Be(3); + + // When + database.ExportToFile(exportFile); + expectedLogs.Push($"Information : User {username}'s database saved"); + expectedLogs.Push($"Warning : Exporting data to file : '{exportFile}'"); + expectedLogs.Push($"Warning : Export completed successfully"); + + // Then + File.ReadAllText(importFile).Replace("\r", "").Should().Be(File.ReadAllText(exportFile).Replace("\r", "")); + + UnitTestsHelper.LastLogsShouldMatch(database, [.. expectedLogs]); + + // Finaly + database.Close(); + UnitTestsHelper.ClearTestEnvironment(); + } + + [TestMethod] + public void Case07_ImportCSV_MissingHeader() + { + // Given + UnitTestsHelper.ClearTestEnvironment(); + + string username = UnitTestsHelper.GetUsername(); + string[] passkeys = UnitTestsHelper.GetRandomStringArray(); + string databaseFile = UnitTestsHelper.ComputeDatabaseFilePath(); + string autoSaveFile = UnitTestsHelper.ComputeAutoSaveFilePath(); + string logFile = UnitTestsHelper.ComputeLogFilePath(); + string importFile = UnitTestsHelper.GetTestFilePath($"import_MissingHearder.csv"); + IDatabase database = UnitTestsHelper.CreateTestDatabase(passkeys); + Stack expectedLogs = new(); + + // When + database.ImportFromFile(importFile); + + expectedLogs.Push($"Information : User {username}'s database saved"); + expectedLogs.Push($"Warning : Importing data from file : '{importFile}'"); + expectedLogs.Push($"Warning : Import failed because the CSV headers should be : 'ServiceName', 'ServiceUrl', 'ServiceNotes', 'AccountLabel', 'Identifiers', 'Password', 'AccountNotes', 'AccountOptions', 'PasswordUpdateReminderDelay'"); + + // Then + database.User.Services.Should().BeEmpty(); + + UnitTestsHelper.LastLogsShouldMatch(database, [.. expectedLogs]); + + // Finaly + database.Close(); + UnitTestsHelper.ClearTestEnvironment(); + } + + [TestMethod] + public void Case08_ImportCSV_MissingCollumn() + { + // Given + UnitTestsHelper.ClearTestEnvironment(); + + string username = UnitTestsHelper.GetUsername(); + string[] passkeys = UnitTestsHelper.GetRandomStringArray(); + string databaseFile = UnitTestsHelper.ComputeDatabaseFilePath(); + string autoSaveFile = UnitTestsHelper.ComputeAutoSaveFilePath(); + string logFile = UnitTestsHelper.ComputeLogFilePath(); + string importFile = UnitTestsHelper.GetTestFilePath($"import_MissingCollumn.csv"); + IDatabase database = UnitTestsHelper.CreateTestDatabase(passkeys); + Stack expectedLogs = new(); + + // When + database.ImportFromFile(importFile); + + expectedLogs.Push($"Information : User {username}'s database saved"); + expectedLogs.Push($"Warning : Importing data from file : '{importFile}'"); + expectedLogs.Push($"Warning : Import failed because the CSV data format is incorrect"); + + // Then + database.User.Services.Should().BeEmpty(); + + UnitTestsHelper.LastLogsShouldMatch(database, [.. expectedLogs]); + + // Finaly + database.Close(); + UnitTestsHelper.ClearTestEnvironment(); + } + + [TestMethod] + public void Case09_ImportJson_OK() + { + // Given + UnitTestsHelper.ClearTestEnvironment(); + + string username = UnitTestsHelper.GetUsername(); + string[] passkeys = UnitTestsHelper.GetRandomStringArray(); + string databaseFile = UnitTestsHelper.ComputeDatabaseFilePath(); + string autoSaveFile = UnitTestsHelper.ComputeAutoSaveFilePath(); + string logFile = UnitTestsHelper.ComputeLogFilePath(); + string importFile = UnitTestsHelper.GetTestFilePath("import.json"); + IDatabase database = UnitTestsHelper.CreateTestDatabase(passkeys); + Stack expectedLogs = new(); + + // When + database.ImportFromFile(importFile); + + expectedLogs.Push($"Information : User {username}'s database saved"); + expectedLogs.Push($"Warning : Importing data from file : '{importFile}'"); + + expectedLogs.Push($"Information : Service Service0 has been added to User {username}"); + expectedLogs.Push($"Information : Service Service0's url has been set to www.service0.xyz"); + expectedLogs.Push($"Information : Service Service0's notes has been set to Service0's notes"); + + expectedLogs.Push($"Information : Account Account0 (account0@service0.xyz, account0_backup@service0.xyz) has been added to Service Service0"); + expectedLogs.Push($"Warning : Account Account0 (account0@service0.xyz, account0_backup@service0.xyz)'s password has been updated"); + expectedLogs.Push($"Information : Account Account0 (account0@service0.xyz, account0_backup@service0.xyz)'s notes has been set to Service0's Account0's notes"); + expectedLogs.Push($"Information : Account Account0 (account0@service0.xyz, account0_backup@service0.xyz)'s options has been set to None"); + expectedLogs.Push($"Information : Account Account0 (account0@service0.xyz, account0_backup@service0.xyz)'s password update reminder delay has been set to 3"); + + expectedLogs.Push($"Information : Account Account1 (account1@service0.xyz, account1_backup@service0.xyz) has been added to Service Service0"); + expectedLogs.Push($"Warning : Account Account1 (account1@service0.xyz, account1_backup@service0.xyz)'s password has been updated"); + expectedLogs.Push($"Information : Account Account1 (account1@service0.xyz, account1_backup@service0.xyz)'s notes has been set to Service0's Account1's notes"); + expectedLogs.Push($"Information : Account Account1 (account1@service0.xyz, account1_backup@service0.xyz)'s options has been set to None"); + expectedLogs.Push($"Information : Account Account1 (account1@service0.xyz, account1_backup@service0.xyz)'s password update reminder delay has been set to 3"); + + expectedLogs.Push($"Information : Service Service1 has been added to User {username}"); + expectedLogs.Push($"Information : Service Service1's url has been set to www.service1.xyz"); + expectedLogs.Push($"Information : Service Service1's notes has been set to Service1's notes"); + + expectedLogs.Push($"Information : Account Account0 (account0@service1.xyz, account0_backup@service1.xyz) has been added to Service Service1"); + expectedLogs.Push($"Warning : Account Account0 (account0@service1.xyz, account0_backup@service1.xyz)'s password has been updated"); + expectedLogs.Push($"Information : Account Account0 (account0@service1.xyz, account0_backup@service1.xyz)'s notes has been set to Service1's Account0's notes"); + expectedLogs.Push($"Information : Account Account0 (account0@service1.xyz, account0_backup@service1.xyz)'s options has been set to None"); + expectedLogs.Push($"Information : Account Account0 (account0@service1.xyz, account0_backup@service1.xyz)'s password update reminder delay has been set to 3"); + + expectedLogs.Push($"Information : Account Account1 (account1@service1.xyz, account1_backup@service1.xyz) has been added to Service Service1"); + expectedLogs.Push($"Warning : Account Account1 (account1@service1.xyz, account1_backup@service1.xyz)'s password has been updated"); + expectedLogs.Push($"Information : Account Account1 (account1@service1.xyz, account1_backup@service1.xyz)'s notes has been set to Service1's Account1's notes"); + expectedLogs.Push($"Information : Account Account1 (account1@service1.xyz, account1_backup@service1.xyz)'s options has been set to None"); + expectedLogs.Push($"Information : Account Account1 (account1@service1.xyz, account1_backup@service1.xyz)'s password update reminder delay has been set to 3"); + + expectedLogs.Push($"Warning : Import completed successfully"); + expectedLogs.Push($"Information : User {username}'s database saved"); + + // Then + database.User.Services.Length.Should().Be(2); + + database.User.Services[0].ServiceName.Should().Be("Service0"); + database.User.Services[0].Url.Should().Be("www.service0.xyz"); + database.User.Services[0].Notes.Should().Be("Service0's notes"); + + database.User.Services[0].Accounts.Length.Should().Be(2); + + database.User.Services[0].Accounts[0].Label.Should().Be("Account0"); + database.User.Services[0].Accounts[0].Identifiers.Should().BeEquivalentTo(new[] { "account0@service0.xyz", "account0_backup@service0.xyz" }); + database.User.Services[0].Accounts[0].Password.Should().Be("0000"); + database.User.Services[0].Accounts[0].Notes.Should().Be("Service0's Account0's notes"); + database.User.Services[0].Accounts[0].Options.Should().Be(AccountOption.None); + database.User.Services[0].Accounts[0].PasswordUpdateReminderDelay.Should().Be(3); + + database.User.Services[0].Accounts[1].Label.Should().Be("Account1"); + database.User.Services[0].Accounts[1].Identifiers.Should().BeEquivalentTo(new[] { "account1@service0.xyz", "account1_backup@service0.xyz" }); + database.User.Services[0].Accounts[1].Password.Should().Be("1111"); + database.User.Services[0].Accounts[1].Notes.Should().Be("Service0's Account1's notes"); + database.User.Services[0].Accounts[1].Options.Should().Be(AccountOption.None); + database.User.Services[0].Accounts[1].PasswordUpdateReminderDelay.Should().Be(3); + + database.User.Services[1].ServiceName.Should().Be("Service1"); + database.User.Services[1].Url.Should().Be("www.service1.xyz"); + database.User.Services[1].Notes.Should().Be("Service1's notes"); + + database.User.Services[1].Accounts.Length.Should().Be(2); + + database.User.Services[1].Accounts[0].Label.Should().Be("Account0"); + database.User.Services[1].Accounts[0].Identifiers.Should().BeEquivalentTo(new[] { "account0@service1.xyz", "account0_backup@service1.xyz" }); + database.User.Services[1].Accounts[0].Password.Should().Be("AAAA"); + database.User.Services[1].Accounts[0].Notes.Should().Be("Service1's Account0's notes"); + database.User.Services[1].Accounts[0].Options.Should().Be(AccountOption.None); + database.User.Services[1].Accounts[0].PasswordUpdateReminderDelay.Should().Be(3); + + database.User.Services[1].Accounts[1].Label.Should().Be("Account1"); + database.User.Services[1].Accounts[1].Identifiers.Should().BeEquivalentTo(new[] { "account1@service1.xyz", "account1_backup@service1.xyz" }); + database.User.Services[1].Accounts[1].Password.Should().Be("BBBB"); + database.User.Services[1].Accounts[1].Notes.Should().Be("Service1's Account1's notes"); + database.User.Services[1].Accounts[1].Options.Should().Be(AccountOption.None); + database.User.Services[1].Accounts[1].PasswordUpdateReminderDelay.Should().Be(3); + + UnitTestsHelper.LastLogsShouldMatch(database, [.. expectedLogs]); + + // Finaly + database.Close(); + UnitTestsHelper.ClearTestEnvironment(); + } + + [TestMethod] + public void Case10_ImportJson_WrongFormat() + { + // Given + UnitTestsHelper.ClearTestEnvironment(); + + string username = UnitTestsHelper.GetUsername(); + string[] passkeys = UnitTestsHelper.GetRandomStringArray(); + string databaseFile = UnitTestsHelper.ComputeDatabaseFilePath(); + string autoSaveFile = UnitTestsHelper.ComputeAutoSaveFilePath(); + string logFile = UnitTestsHelper.ComputeLogFilePath(); + string importFile = UnitTestsHelper.GetTestFilePath($"import_WrongFormat.json"); + IDatabase database = UnitTestsHelper.CreateTestDatabase(passkeys); + Stack expectedLogs = new(); + + // When + database.ImportFromFile(importFile); + + expectedLogs.Push($"Information : User {username}'s database saved"); + expectedLogs.Push($"Warning : Importing data from file : '{importFile}'"); + expectedLogs.Push($"Warning : Import failed because import file deserialization failed"); + + // Then + database.User.Services.Should().BeEmpty(); + + UnitTestsHelper.LastLogsShouldMatch(database, [.. expectedLogs]); + + // Finaly + database.Close(); + UnitTestsHelper.ClearTestEnvironment(); + } + + + [TestMethod] + public void Case11_Export_FileAlreadyExists() + { + // Given + UnitTestsHelper.ClearTestEnvironment(); + + string username = UnitTestsHelper.GetUsername(); + string[] passkeys = UnitTestsHelper.GetRandomStringArray(); + string databaseFile = UnitTestsHelper.ComputeDatabaseFilePath(); + string autoSaveFile = UnitTestsHelper.ComputeAutoSaveFilePath(); + string logFile = UnitTestsHelper.ComputeLogFilePath(); + string importFile = UnitTestsHelper.GetTestFilePath($"import.json"); + string exportFile = UnitTestsHelper.GetTestFilePath($"{username}/export.json", createIfNotExists: true); + IDatabase database = UnitTestsHelper.CreateTestDatabase(passkeys); + Stack expectedLogs = new(); + database.ImportFromFile(importFile); + + // When + database.ExportToFile(exportFile); + + expectedLogs.Push($"Information : User {username}'s database saved"); + expectedLogs.Push($"Warning : Exporting data to file : '{exportFile}'"); + expectedLogs.Push($"Warning : Export failed because export file already exists"); + + // Then + File.Exists(exportFile).Should().BeTrue(); + + UnitTestsHelper.LastLogsShouldMatch(database, [.. expectedLogs]); + + // Finaly + database.Close(); + UnitTestsHelper.ClearTestEnvironment(); + } + + + [TestMethod] + public void Case12_Export_FileExtensionNotHandled() + { + // Given + UnitTestsHelper.ClearTestEnvironment(); + + string username = UnitTestsHelper.GetUsername(); + string[] passkeys = UnitTestsHelper.GetRandomStringArray(); + string databaseFile = UnitTestsHelper.ComputeDatabaseFilePath(); + string autoSaveFile = UnitTestsHelper.ComputeAutoSaveFilePath(); + string logFile = UnitTestsHelper.ComputeLogFilePath(); + string importFile = UnitTestsHelper.GetTestFilePath($"import.json"); + string exportFile = UnitTestsHelper.GetTestFilePath($"{username}/export.txt"); + IDatabase database = UnitTestsHelper.CreateTestDatabase(passkeys); + Stack expectedLogs = new(); + database.ImportFromFile(importFile); + + // When + database.ExportToFile(exportFile); + + expectedLogs.Push($"Information : User {username}'s database saved"); + expectedLogs.Push($"Warning : Exporting data to file : '{exportFile}'"); + expectedLogs.Push($"Warning : Export failed because '.txt' extention type is not handled"); + + // Then + File.Exists(exportFile).Should().BeFalse(); + + UnitTestsHelper.LastLogsShouldMatch(database, [.. expectedLogs]); + + // Finaly + database.Close(); + UnitTestsHelper.ClearTestEnvironment(); + } + } +} diff --git a/UnitTests/Models/ServiceUnitTests.cs b/UnitTests/Models/ServiceUnitTests.cs index cee41f5..7bc631d 100644 --- a/UnitTests/Models/ServiceUnitTests.cs +++ b/UnitTests/Models/ServiceUnitTests.cs @@ -1,8 +1,9 @@ using FluentAssertions; -using Upsilon.Apps.PassKey.Core.Public.Enums; -using Upsilon.Apps.PassKey.Core.Public.Interfaces; +using Upsilon.Apps.Passkey.Interfaces; +using Upsilon.Apps.Passkey.Interfaces.Enums; +using Upsilon.Apps.Passkey.Interfaces.Utils; -namespace Upsilon.Apps.PassKey.UnitTests.Models +namespace Upsilon.Apps.Passkey.UnitTests.Models { [TestClass] public sealed class ServiceUnitTests @@ -32,17 +33,30 @@ public void Case01_AddServiceUpdateSaved() expectedLogs.Push($"Information : Service {oldServiceName} has been added to User {username}"); // Then + databaseCreated.User.HasChanged().Should().BeTrue(); + service.HasChanged().Should().BeFalse(); _ = databaseCreated.User.Services.Length.Should().Be(1); // When service.ServiceName = newServiceName; + service.ServiceName = newServiceName; expectedLogs.Push($"Warning : Service {oldServiceName}'s service name has been set to {newServiceName}"); expectedLogWarnings.Push($"Warning : Service {oldServiceName}'s service name has been set to {newServiceName}"); service.Url = url; + service.Url = url; expectedLogs.Push($"Information : Service {newServiceName}'s url has been set to {url}"); service.Notes = notes; + service.Notes = notes; expectedLogs.Push($"Information : Service {newServiceName}'s notes has been set to {notes}"); + // Then + databaseCreated.User.HasChanged().Should().BeTrue(); + service.HasChanged().Should().BeTrue(); + service.HasChanged(nameof(service.ServiceName)).Should().BeTrue(); + service.HasChanged(nameof(service.Url)).Should().BeTrue(); + service.HasChanged(nameof(service.Notes)).Should().BeTrue(); + + // When databaseCreated.Save(); expectedLogs.Push($"Information : User {username}'s database saved"); databaseCreated.Close(); @@ -76,7 +90,7 @@ public void Case01_AddServiceUpdateSaved() /* * User.AddService adds the new service, * Then updating the service without saving will create the autosave file, - * Then Database.Open with AutoSaveMergeBehavior.MergeThenRemoveAutoSaveFile loads correctly the updated database file with the updated service. + * Then Database.Open with AutoSaveMergeBehavior.MergeAndSaveThenRemoveAutoSaveFile loads correctly the updated database file with the updated service. */ public void Case02_AddServiceUpdateAutoSave() { @@ -101,11 +115,14 @@ public void Case02_AddServiceUpdateAutoSave() // When service.ServiceName = newServiceName; + service.ServiceName = newServiceName; expectedLogs.Push($"Warning : Service {oldServiceName}'s service name has been set to {newServiceName}"); expectedLogWarnings.Push($"Warning : Service {oldServiceName}'s service name has been set to {newServiceName}"); service.Url = url; + service.Url = url; expectedLogs.Push($"Information : Service {newServiceName}'s url has been set to {url}"); service.Notes = notes; + service.Notes = notes; expectedLogs.Push($"Information : Service {newServiceName}'s notes has been set to {notes}"); databaseCreated.Close(); @@ -113,11 +130,11 @@ public void Case02_AddServiceUpdateAutoSave() expectedLogWarnings.Push($"Warning : User {username} logged out without saving"); expectedLogs.Push($"Information : User {username}'s database closed"); - IDatabase databaseLoaded = UnitTestsHelper.OpenTestDatabase(passkeys, out _, AutoSaveMergeBehavior.MergeThenRemoveAutoSaveFile); + IDatabase databaseLoaded = UnitTestsHelper.OpenTestDatabase(passkeys, out _, AutoSaveMergeBehavior.MergeAndSaveThenRemoveAutoSaveFile); expectedLogs.Push($"Information : User {username}'s database opened"); expectedLogs.Push($"Information : User {username} logged in"); - expectedLogs.Push($"Warning : User {username}'s autosave merged"); - expectedLogWarnings.Push($"Warning : User {username}'s autosave merged"); + expectedLogs.Push($"Warning : User {username}'s autosave merged and saved"); + expectedLogWarnings.Push($"Warning : User {username}'s autosave merged and saved"); // Then _ = databaseLoaded.User.Services.Length.Should().Be(1); @@ -193,7 +210,7 @@ public void Case03_DeleteServiceUpdateSaved() [TestMethod] /* * User.DeleteService adeletes the service, - * Then Database.Open with AutoSaveMergeBehavior.MergeThenRemoveAutoSaveFile loads correctly the updated database file with the updated service. + * Then Database.Open with AutoSaveMergeBehavior.MergeAndSaveThenRemoveAutoSaveFile loads correctly the updated database file with the updated service. */ public void Case04_DeleteServiceUpdateAutoSave() { @@ -208,7 +225,7 @@ public void Case04_DeleteServiceUpdateAutoSave() Stack expectedLogs = new(); Stack expectedLogWarnings = new(); - IDatabase databaseLoaded = UnitTestsHelper.OpenTestDatabase(passkeys, out _, AutoSaveMergeBehavior.MergeThenRemoveAutoSaveFile); + IDatabase databaseLoaded = UnitTestsHelper.OpenTestDatabase(passkeys, out _, AutoSaveMergeBehavior.MergeAndSaveThenRemoveAutoSaveFile); IService serviceLoaded = databaseLoaded.User.Services.First(); // When @@ -225,11 +242,11 @@ public void Case04_DeleteServiceUpdateAutoSave() expectedLogWarnings.Push($"Warning : User {username} logged out without saving"); expectedLogs.Push($"Information : User {username}'s database closed"); - databaseLoaded = UnitTestsHelper.OpenTestDatabase(passkeys, out _, AutoSaveMergeBehavior.MergeThenRemoveAutoSaveFile); + databaseLoaded = UnitTestsHelper.OpenTestDatabase(passkeys, out _, AutoSaveMergeBehavior.MergeAndSaveThenRemoveAutoSaveFile); expectedLogs.Push($"Information : User {username}'s database opened"); expectedLogs.Push($"Information : User {username} logged in"); - expectedLogs.Push($"Warning : User {username}'s autosave merged"); - expectedLogWarnings.Push($"Warning : User {username}'s autosave merged"); + expectedLogs.Push($"Warning : User {username}'s autosave merged and saved"); + expectedLogWarnings.Push($"Warning : User {username}'s autosave merged and saved"); // Then _ = databaseLoaded.User.Services.Length.Should().Be(0); diff --git a/UnitTests/Models/UserUnitTests.cs b/UnitTests/Models/UserUnitTests.cs index 91a7fc0..19bad4e 100644 --- a/UnitTests/Models/UserUnitTests.cs +++ b/UnitTests/Models/UserUnitTests.cs @@ -1,8 +1,10 @@ using FluentAssertions; -using Upsilon.Apps.PassKey.Core.Public.Enums; -using Upsilon.Apps.PassKey.Core.Public.Interfaces; +using Upsilon.Apps.Passkey.Core.Models; +using Upsilon.Apps.Passkey.Interfaces; +using Upsilon.Apps.Passkey.Interfaces.Enums; +using Upsilon.Apps.Passkey.Interfaces.Utils; -namespace Upsilon.Apps.PassKey.UnitTests.Models +namespace Upsilon.Apps.Passkey.UnitTests.Models { [TestClass] public sealed class UserUnitTests @@ -31,6 +33,15 @@ public void Case01_UserUpdateWithoutSaving() databaseLoaded.User.Passkeys = newPasskeys; databaseLoaded.User.LogoutTimeout = logoutTimeout; databaseLoaded.User.CleaningClipboardTimeout = cleaningClipboardTimeout; + + // Then + databaseLoaded.User.HasChanged().Should().BeTrue(); + databaseLoaded.User.HasChanged(nameof(databaseLoaded.User.Username)).Should().BeTrue(); + databaseLoaded.User.HasChanged(nameof(databaseLoaded.User.Passkeys)).Should().BeTrue(); + databaseLoaded.User.HasChanged(nameof(databaseLoaded.User.LogoutTimeout)).Should().BeTrue(); + databaseLoaded.User.HasChanged(nameof(databaseLoaded.User.CleaningClipboardTimeout)).Should().BeTrue(); + + // When databaseLoaded.Close(); // Then @@ -65,16 +76,21 @@ public void Case02_UserUpdateThenSaved() // When databaseCreated.User.Username = newUsername; + databaseCreated.User.Username = newUsername; expectedLogs.Push($"Warning : User {oldUsername}'s username has been set to {newUsername}"); expectedLogWarnings.Push($"Warning : User {oldUsername}'s username has been set to {newUsername}"); databaseCreated.User.Passkeys = newPasskeys; + databaseCreated.User.Passkeys = newPasskeys; expectedLogs.Push($"Warning : User {oldUsername}'s passkeys has been updated"); expectedLogWarnings.Push($"Warning : User {oldUsername}'s passkeys has been updated"); databaseCreated.User.LogoutTimeout = logoutTimeout; + databaseCreated.User.LogoutTimeout = logoutTimeout; expectedLogs.Push($"Information : User {oldUsername}'s logout timeout has been set to {logoutTimeout}"); databaseCreated.User.CleaningClipboardTimeout = cleaningClipboardTimeout; + databaseCreated.User.CleaningClipboardTimeout = cleaningClipboardTimeout; expectedLogs.Push($"Information : User {oldUsername}'s cleaning clipboard timeout has been set to {cleaningClipboardTimeout}"); databaseCreated.User.WarningsToNotify = WarningType.DuplicatedPasswordsWarning | WarningType.PasswordUpdateReminderWarning; + databaseCreated.User.WarningsToNotify = WarningType.DuplicatedPasswordsWarning | WarningType.PasswordUpdateReminderWarning; expectedLogs.Push($"Warning : User {oldUsername}'s warnings to notify has been set to {WarningType.DuplicatedPasswordsWarning | WarningType.PasswordUpdateReminderWarning}"); expectedLogWarnings.Push($"Warning : User {oldUsername}'s warnings to notify has been set to {WarningType.DuplicatedPasswordsWarning | WarningType.PasswordUpdateReminderWarning}"); @@ -92,9 +108,10 @@ public void Case02_UserUpdateThenSaved() _ = File.Exists(autoSaveFile).Should().BeFalse(); // When - IDatabase databaseLoaded = IDatabase.Open(UnitTestsHelper.CryptographicCenter, + IDatabase databaseLoaded = Database.Open(UnitTestsHelper.CryptographicCenter, UnitTestsHelper.SerializationCenter, UnitTestsHelper.PasswordFactory, + UnitTestsHelper.ClipboardManager, databaseFile, autoSaveFile, logFile, @@ -114,11 +131,11 @@ public void Case02_UserUpdateThenSaved() _ = File.Exists(autoSaveFile).Should().BeFalse(); - _ = databaseLoaded.Warnings.Should().NotBeEmpty(); - UnitTestsHelper.LastLogsShouldMatch(databaseLoaded, [.. expectedLogs]); UnitTestsHelper.LastLogWarningsShouldMatch(databaseLoaded, [.. expectedLogWarnings]); + _ = databaseLoaded.Warnings.Should().NotBeEmpty(); + // Finaly databaseLoaded.Close(); UnitTestsHelper.ClearTestEnvironment(); @@ -131,7 +148,7 @@ public void Case02_UserUpdateThenSaved() * Then HandleAutoSave updates the database object and the database file, * Then Database.Open loads correctly the updated database file. */ - public void Case03_UserUpdateButNotSaved() + public void Case03_UserUpdateButNotSaved_CaseMergeAndSave() { // Given UnitTestsHelper.ClearTestEnvironment(); @@ -150,16 +167,21 @@ public void Case03_UserUpdateButNotSaved() // When databaseCreated.User.Username = newUsername; + databaseCreated.User.Username = newUsername; expectedLogs.Push($"Warning : User {oldUsername}'s username has been set to {newUsername}"); expectedLogWarnings.Push($"Warning : User {oldUsername}'s username has been set to {newUsername}"); databaseCreated.User.Passkeys = newPasskeys; + databaseCreated.User.Passkeys = newPasskeys; expectedLogs.Push($"Warning : User {oldUsername}'s passkeys has been updated"); expectedLogWarnings.Push($"Warning : User {oldUsername}'s passkeys has been updated"); databaseCreated.User.LogoutTimeout = logoutTimeout; + databaseCreated.User.LogoutTimeout = logoutTimeout; expectedLogs.Push($"Information : User {oldUsername}'s logout timeout has been set to {logoutTimeout}"); databaseCreated.User.CleaningClipboardTimeout = cleaningClipboardTimeout; + databaseCreated.User.CleaningClipboardTimeout = cleaningClipboardTimeout; expectedLogs.Push($"Information : User {oldUsername}'s cleaning clipboard timeout has been set to {cleaningClipboardTimeout}"); databaseCreated.User.WarningsToNotify = WarningType.DuplicatedPasswordsWarning | WarningType.PasswordUpdateReminderWarning; + databaseCreated.User.WarningsToNotify = WarningType.DuplicatedPasswordsWarning | WarningType.PasswordUpdateReminderWarning; expectedLogs.Push($"Warning : User {oldUsername}'s warnings to notify has been set to {WarningType.DuplicatedPasswordsWarning | WarningType.PasswordUpdateReminderWarning}"); expectedLogWarnings.Push($"Warning : User {oldUsername}'s warnings to notify has been set to {WarningType.DuplicatedPasswordsWarning | WarningType.PasswordUpdateReminderWarning}"); @@ -172,20 +194,20 @@ public void Case03_UserUpdateButNotSaved() _ = File.Exists(autoSaveFile).Should().BeTrue(); // When - IDatabase databaseLoaded = UnitTestsHelper.OpenTestDatabase(oldPasskeys, out IWarning[] warnings, AutoSaveMergeBehavior.MergeThenRemoveAutoSaveFile); + IDatabase databaseLoaded = UnitTestsHelper.OpenTestDatabase(oldPasskeys, out IWarning[] warnings, AutoSaveMergeBehavior.MergeAndSaveThenRemoveAutoSaveFile); expectedLogs.Push($"Information : User {oldUsername}'s database opened"); expectedLogs.Push($"Information : User {oldUsername} logged in"); - expectedLogs.Push($"Warning : User {oldUsername}'s autosave merged"); - expectedLogWarnings.Push($"Warning : User {oldUsername}'s autosave merged"); + expectedLogs.Push($"Warning : User {oldUsername}'s autosave merged and saved"); + expectedLogWarnings.Push($"Warning : User {oldUsername}'s autosave merged and saved"); // Then _ = File.Exists(autoSaveFile).Should().BeFalse(); + _ = databaseLoaded.User.HasChanged().Should().BeFalse(); _ = databaseLoaded.User.Username.Should().Be(newUsername); _ = databaseLoaded.User.Passkeys.Should().BeEquivalentTo(newPasskeys); _ = databaseLoaded.User.LogoutTimeout.Should().Be(logoutTimeout); _ = databaseLoaded.User.CleaningClipboardTimeout.Should().Be(cleaningClipboardTimeout); - _ = databaseLoaded.Warnings.Should().NotBeEmpty(); _ = warnings.Should().BeEmpty(); // When @@ -193,9 +215,10 @@ public void Case03_UserUpdateButNotSaved() expectedLogs.Push($"Information : User {newUsername} logged out"); expectedLogs.Push($"Information : User {newUsername}'s database closed"); - databaseLoaded = IDatabase.Open(UnitTestsHelper.CryptographicCenter, + databaseLoaded = Database.Open(UnitTestsHelper.CryptographicCenter, UnitTestsHelper.SerializationCenter, UnitTestsHelper.PasswordFactory, + UnitTestsHelper.ClipboardManager, databaseFile, autoSaveFile, logFile, @@ -213,11 +236,124 @@ public void Case03_UserUpdateButNotSaved() _ = databaseLoaded.User.Passkeys.Should().BeEquivalentTo(newPasskeys); _ = databaseLoaded.User.LogoutTimeout.Should().Be(logoutTimeout); _ = databaseLoaded.User.CleaningClipboardTimeout.Should().Be(cleaningClipboardTimeout); + + UnitTestsHelper.LastLogsShouldMatch(databaseLoaded, [.. expectedLogs]); + UnitTestsHelper.LastLogWarningsShouldMatch(databaseLoaded, [.. expectedLogWarnings]); + _ = databaseLoaded.Warnings.Should().NotBeEmpty(); + // Finaly + databaseLoaded.Close(); + UnitTestsHelper.ClearTestEnvironment(); + } + + [TestMethod] + /* + * Updating User creates an autosave file, + * Then Database.Open loads the database file without the updated data, + * Then HandleAutoSave updates the database object but not save the database file, + * Then Database.Open loads correctly the updated database file but tag the data "unsaved". + */ + public void Case04_UserUpdateButNotSaved_CaseMergeWithoutSaving() + { + // Given + UnitTestsHelper.ClearTestEnvironment(); + string oldUsername = UnitTestsHelper.GetUsername(); + string[] oldPasskeys = UnitTestsHelper.GetRandomStringArray(); + string databaseFile = UnitTestsHelper.ComputeDatabaseFilePath(); + string autoSaveFile = UnitTestsHelper.ComputeAutoSaveFilePath(); + string logFile = UnitTestsHelper.ComputeLogFilePath(); + IDatabase databaseCreated = UnitTestsHelper.CreateTestDatabase(oldPasskeys); + string newUsername = "new_" + oldUsername; + string[] newPasskeys = UnitTestsHelper.GetRandomStringArray(); + int logoutTimeout = UnitTestsHelper.GetRandomInt(1, 60); + int cleaningClipboardTimeout = UnitTestsHelper.GetRandomInt(1, 60); + Stack expectedLogs = new(); + Stack expectedLogWarnings = new(); + + // When + databaseCreated.User.Username = newUsername; + databaseCreated.User.Username = newUsername; + expectedLogs.Push($"Warning : User {oldUsername}'s username has been set to {newUsername}"); + expectedLogWarnings.Push($"Warning : User {oldUsername}'s username has been set to {newUsername}"); + databaseCreated.User.Passkeys = newPasskeys; + databaseCreated.User.Passkeys = newPasskeys; + expectedLogs.Push($"Warning : User {oldUsername}'s passkeys has been updated"); + expectedLogWarnings.Push($"Warning : User {oldUsername}'s passkeys has been updated"); + databaseCreated.User.LogoutTimeout = logoutTimeout; + databaseCreated.User.LogoutTimeout = logoutTimeout; + expectedLogs.Push($"Information : User {oldUsername}'s logout timeout has been set to {logoutTimeout}"); + databaseCreated.User.CleaningClipboardTimeout = cleaningClipboardTimeout; + databaseCreated.User.CleaningClipboardTimeout = cleaningClipboardTimeout; + expectedLogs.Push($"Information : User {oldUsername}'s cleaning clipboard timeout has been set to {cleaningClipboardTimeout}"); + databaseCreated.User.WarningsToNotify = WarningType.DuplicatedPasswordsWarning | WarningType.PasswordUpdateReminderWarning; + databaseCreated.User.WarningsToNotify = WarningType.DuplicatedPasswordsWarning | WarningType.PasswordUpdateReminderWarning; + expectedLogs.Push($"Warning : User {oldUsername}'s warnings to notify has been set to {WarningType.DuplicatedPasswordsWarning | WarningType.PasswordUpdateReminderWarning}"); + expectedLogWarnings.Push($"Warning : User {oldUsername}'s warnings to notify has been set to {WarningType.DuplicatedPasswordsWarning | WarningType.PasswordUpdateReminderWarning}"); + + databaseCreated.Close(); + expectedLogs.Push($"Warning : User {oldUsername} logged out without saving"); + expectedLogWarnings.Push($"Warning : User {oldUsername} logged out without saving"); + expectedLogs.Push($"Information : User {oldUsername}'s database closed"); + + // Then + _ = File.Exists(autoSaveFile).Should().BeTrue(); + + // When + IDatabase databaseLoaded = UnitTestsHelper.OpenTestDatabase(oldPasskeys, out IWarning[] warnings, AutoSaveMergeBehavior.MergeWithoutSavingAndKeepAutoSaveFile); + expectedLogs.Push($"Information : User {oldUsername}'s database opened"); + expectedLogs.Push($"Information : User {oldUsername} logged in"); + expectedLogs.Push($"Warning : User {oldUsername}'s autosave merged without saving"); + expectedLogWarnings.Push($"Warning : User {oldUsername}'s autosave merged without saving"); + + // Then + _ = File.Exists(autoSaveFile).Should().BeTrue(); + _ = databaseLoaded.User.HasChanged().Should().BeTrue(); + _ = databaseLoaded.User.HasChanged(nameof(databaseLoaded.User.Username)).Should().BeTrue(); + _ = databaseLoaded.User.Username.Should().Be(newUsername); + _ = databaseLoaded.User.HasChanged(nameof(databaseLoaded.User.Passkeys)).Should().BeTrue(); + _ = databaseLoaded.User.Passkeys.Should().BeEquivalentTo(newPasskeys); + _ = databaseLoaded.User.HasChanged(nameof(databaseLoaded.User.LogoutTimeout)).Should().BeTrue(); + _ = databaseLoaded.User.LogoutTimeout.Should().Be(logoutTimeout); + _ = databaseLoaded.User.HasChanged(nameof(databaseLoaded.User.CleaningClipboardTimeout)).Should().BeTrue(); + _ = databaseLoaded.User.CleaningClipboardTimeout.Should().Be(cleaningClipboardTimeout); + + _ = warnings.Should().BeEmpty(); + + // When + databaseLoaded.Save(); + expectedLogs.Push($"Information : User {newUsername}'s database saved"); + databaseLoaded.Close(); + expectedLogs.Push($"Information : User {newUsername} logged out"); + expectedLogs.Push($"Information : User {newUsername}'s database closed"); + + databaseLoaded = Database.Open(UnitTestsHelper.CryptographicCenter, + UnitTestsHelper.SerializationCenter, + UnitTestsHelper.PasswordFactory, + UnitTestsHelper.ClipboardManager, + databaseFile, + autoSaveFile, + logFile, + newUsername); + expectedLogs.Push($"Information : User {newUsername}'s database opened"); + foreach (string passkey in newPasskeys) + { + _ = databaseLoaded.Login(passkey); + } + expectedLogs.Push($"Information : User {newUsername} logged in"); + + // Then + _ = File.Exists(autoSaveFile).Should().BeFalse(); + _ = databaseLoaded.User.Username.Should().Be(newUsername); + _ = databaseLoaded.User.Passkeys.Should().BeEquivalentTo(newPasskeys); + _ = databaseLoaded.User.LogoutTimeout.Should().Be(logoutTimeout); + _ = databaseLoaded.User.CleaningClipboardTimeout.Should().Be(cleaningClipboardTimeout); + UnitTestsHelper.LastLogsShouldMatch(databaseLoaded, [.. expectedLogs]); UnitTestsHelper.LastLogWarningsShouldMatch(databaseLoaded, [.. expectedLogWarnings]); + _ = databaseLoaded.Warnings.Should().NotBeEmpty(); + // Finaly databaseLoaded.Close(); UnitTestsHelper.ClearTestEnvironment(); diff --git a/UnitTests/TestFiles/import.csv b/UnitTests/TestFiles/import.csv new file mode 100644 index 0000000..0f7b27b --- /dev/null +++ b/UnitTests/TestFiles/import.csv @@ -0,0 +1,5 @@ +ServiceName ServiceUrl ServiceNotes AccountLabel Identifiers Password AccountNotes AccountOptions PasswordUpdateReminderDelay +"Service0" "www.service0.xyz" "Service0\u0027s notes" "Account0" "account0@service0.xyz|account0_backup@service0.xyz" "0000" "Service0\u0027s Account0\u0027s notes" "None" 3 +"Service0" "www.service0.xyz" "Service0\u0027s notes" "Account1" "account1@service0.xyz|account1_backup@service0.xyz" "1111" "Service0\u0027s Account1\u0027s notes" "None" 3 +"Service1" "www.service1.xyz" "Service1\u0027s notes" "Account0" "account0@service1.xyz|account0_backup@service1.xyz" "AAAA" "Service1\u0027s Account0\u0027s notes" "None" 3 +"Service1" "www.service1.xyz" "Service1\u0027s notes" "Account1" "account1@service1.xyz|account1_backup@service1.xyz" "BBBB" "Service1\u0027s Account1\u0027s notes" "None" 3 diff --git a/UnitTests/TestFiles/import.json b/UnitTests/TestFiles/import.json new file mode 100644 index 0000000..cbe5543 --- /dev/null +++ b/UnitTests/TestFiles/import.json @@ -0,0 +1,78 @@ +[ + { + "ItemId": "eXZ6qTu9LwbPIRGL0YgYZQ==7Fo5YgcgzaCQbBFmigk1WbNgUwU=37RLlpzi9VX85YalKXG0cA==sbuNCB\u002B7gEqqz55oh0i3U9mTDPs=", + "Accounts": [ + { + "ItemId": "eXZ6qTu9LwbPIRGL0YgYZQ==7Fo5YgcgzaCQbBFmigk1WbNgUwU=37RLlpzi9VX85YalKXG0cA==sbuNCB\u002B7gEqqz55oh0i3U9mTDPs=jHeUM0yDZ3766A6Q\u002BBuRGQ==b\u002B6nLTM-xoUZGm6mH8aLxJ5R1Ok=", + "Label": "Account0", + "Identifiers": [ + "account0@service0.xyz", + "account0_backup@service0.xyz" + ], + "Password": "0000", + "Passwords": { + "2025-11-28T14:48:28.6023277+03:00": "0000" + }, + "Notes": "Service0\u0027s Account0\u0027s notes", + "PasswordUpdateReminderDelay": 3, + "Options": "None" + }, + { + "ItemId": "eXZ6qTu9LwbPIRGL0YgYZQ==7Fo5YgcgzaCQbBFmigk1WbNgUwU=37RLlpzi9VX85YalKXG0cA==sbuNCB\u002B7gEqqz55oh0i3U9mTDPs=WA6YxI54pKPhOViHyC37RA==talAus-d2TCHuNMcKrWtpgSmsss=", + "Label": "Account1", + "Identifiers": [ + "account1@service0.xyz", + "account1_backup@service0.xyz" + ], + "Password": "1111", + "Passwords": { + "2025-11-28T14:48:28.9224604+03:00": "1111" + }, + "Notes": "Service0\u0027s Account1\u0027s notes", + "PasswordUpdateReminderDelay": 3, + "Options": "None" + } + ], + "ServiceName": "Service0", + "Url": "www.service0.xyz", + "Notes": "Service0\u0027s notes" + }, + { + "ItemId": "eXZ6qTu9LwbPIRGL0YgYZQ==7Fo5YgcgzaCQbBFmigk1WbNgUwU=jrxkc\u002BsJFH\u002BIFczozfFImQ==HjTPTnu9u2anbu8mhoHj2YSnGJY=", + "Accounts": [ + { + "ItemId": "eXZ6qTu9LwbPIRGL0YgYZQ==7Fo5YgcgzaCQbBFmigk1WbNgUwU=jrxkc\u002BsJFH\u002BIFczozfFImQ==HjTPTnu9u2anbu8mhoHj2YSnGJY=TPIjH-MHbiYdF0c3smzJHQ==4N-tDXIlas1bY47AMMGvU1GtZjw=", + "Label": "Account0", + "Identifiers": [ + "account0@service1.xyz", + "account0_backup@service1.xyz" + ], + "Password": "AAAA", + "Passwords": { + "2025-11-28T14:48:29.5464925+03:00": "AAAA" + }, + "Notes": "Service1\u0027s Account0\u0027s notes", + "PasswordUpdateReminderDelay": 3, + "Options": "None" + }, + { + "ItemId": "eXZ6qTu9LwbPIRGL0YgYZQ==7Fo5YgcgzaCQbBFmigk1WbNgUwU=jrxkc\u002BsJFH\u002BIFczozfFImQ==HjTPTnu9u2anbu8mhoHj2YSnGJY=34tP0ZDF29WU5eVpYJR0qw==ZBW4YjgfwYfRrpGaaTqmsLfcQNU=", + "Label": "Account1", + "Identifiers": [ + "account1@service1.xyz", + "account1_backup@service1.xyz" + ], + "Password": "BBBB", + "Passwords": { + "2025-11-28T14:48:30.0815746+03:00": "BBBB" + }, + "Notes": "Service1\u0027s Account1\u0027s notes", + "PasswordUpdateReminderDelay": 3, + "Options": "None" + } + ], + "ServiceName": "Service1", + "Url": "www.service1.xyz", + "Notes": "Service1\u0027s notes" + } +] \ No newline at end of file diff --git a/UnitTests/TestFiles/import_MissingCollumn.csv b/UnitTests/TestFiles/import_MissingCollumn.csv new file mode 100644 index 0000000..ec8e5db --- /dev/null +++ b/UnitTests/TestFiles/import_MissingCollumn.csv @@ -0,0 +1,5 @@ +ServiceName ServiceUrl ServiceNotes AccountLabel Identifiers Password AccountNotes AccountOptions PasswordUpdateReminderDelay +"Service0" "www.service0.xyz" "Service0's notes" "Account0" "account0@service0.xyz|account0_backup@service0.xyz" "0000" "Service0's Account0's notes" 0 3 +"Service0" "www.service0.xyz" "Service0's notes" "Account1" "account1@service0.xyz|account1_backup@service0.xyz" "1111" "Service0's Account1's notes" 0 3 +"Service1" "www.service1.xyz" "Service1's notes" "Account0" "account0@service1.xyz|account0_backup@service1.xyz" "AAAA" "Service1's Account0's notes" 3 +"Service1" "www.service1.xyz" "Service1's notes" "Account1" "account1@service1.xyz|account1_backup@service1.xyz" "BBBB" "Service1's Account1's notes" 0 3 \ No newline at end of file diff --git a/UnitTests/TestFiles/import_MissingHearder.csv b/UnitTests/TestFiles/import_MissingHearder.csv new file mode 100644 index 0000000..2c6a7fd --- /dev/null +++ b/UnitTests/TestFiles/import_MissingHearder.csv @@ -0,0 +1,5 @@ +ServiceName ServiceUrl ServiceNotes AccountLabel Identifiers AccountNotes AccountOptions PasswordUpdateReminderDelay +"Service0" "www.service0.xyz" "Service0\u0027s notes" "Account0" "account0@service0.xyz|account0_backup@service0.xyz" "Service0\u0027s Account0\u0027s notes" "None" 3 +"Service0" "www.service0.xyz" "Service0\u0027s notes" "Account1" "account1@service0.xyz|account1_backup@service0.xyz" "Service0\u0027s Account1\u0027s notes" "None" 3 +"Service1" "www.service1.xyz" "Service1\u0027s notes" "Account0" "account0@service1.xyz|account0_backup@service1.xyz" "Service1\u0027s Account0\u0027s notes" "None" 3 +"Service1" "www.service1.xyz" "Service1\u0027s notes" "Account1" "account1@service1.xyz|account1_backup@service1.xyz" "Service1\u0027s Account1\u0027s notes" "None" 3 diff --git a/UnitTests/TestFiles/import_WrongFormat.json b/UnitTests/TestFiles/import_WrongFormat.json new file mode 100644 index 0000000..44f28ca --- /dev/null +++ b/UnitTests/TestFiles/import_WrongFormat.json @@ -0,0 +1,52 @@ +[ + { + "ServiceName": "Service0", + "Url": "www.service0.xyz", + "Notes": "Service0\u0027s notes", + "Accounts": [ + { + "Label": "Account0", + "Identifiers": [ "account0@service0.xyz", "account0_backup@service0.xyz" ], + "Password": "0000", + "Passwords": { "2025-11-26T21:33:12.4829309+03:00": "0000" }, + "Notes": "Service0\u0027s Account0\u0027s notes", + "PasswordUpdateReminderDelay": 3, + "Options": "None" + } + { + "Label": "Account1", + "Identifiers": [ "account1@service0.xyz", "account1_backup@service0.xyz" ], + "Password": "1111", + "Passwords": { "2025-11-26T21:33:12.7380781+03:00": "1111" }, + "Notes": "Service0\u0027s Account1\u0027s notes", + "PasswordUpdateReminderDelay": 3, + "Options": "None" + } + ] + }, + { + "ServiceName": "Service1", + "Url": "www.service1.xyz", + "Notes": "Service1\u0027s notes", + "Accounts": [ + { + "Label": "Account0", + "Identifiers": [ "account0@service1.xyz", "account0_backup@service1.xyz" ], + "Password": "AAAA", + "Passwords": { "2025-11-26T21:33:13.1589472+03:00": "AAAA" }, + "Notes": "Service1\u0027s Account0\u0027s notes", + "PasswordUpdateReminderDelay": 3, + "Options": "None" + }, + { + "Label": "Account1", + "Identifiers": [ "account1@service1.xyz", "account1_backup@service1.xyz" ], + "Password": "BBBB", + "Passwords": { "2025-11-26T21:33:13.4643103+03:00": "BBBB" }, + "Notes": "Service1\u0027s Account1\u0027s notes", + "PasswordUpdateReminderDelay": 3, + "Options": "None" + } + ] + } +] \ No newline at end of file diff --git a/UnitTests/TestFiles/import_blanckService.csv b/UnitTests/TestFiles/import_blanckService.csv new file mode 100644 index 0000000..0207639 --- /dev/null +++ b/UnitTests/TestFiles/import_blanckService.csv @@ -0,0 +1,5 @@ +ServiceName ServiceUrl ServiceNotes AccountLabel Identifiers Password AccountNotes AccountOptions PasswordUpdateReminderDelay +"Service0" "www.service0.xyz" "Service0's notes" "Account0" "account0@service0.xyz|account0_backup@service0.xyz" "0000" "Service0's Account0's notes" 0 3 +"Service0" "www.service0.xyz" "Service0's notes" "Account1" "account1@service0.xyz|account1_backup@service0.xyz" "1111" "Service0's Account1's notes" 0 3 +"" "www.service1.xyz" "Service1's notes" "Account0" "account0@service1.xyz|account0_backup@service1.xyz" "AAAA" "Service1's Account0's notes" 0 3 +"" "www.service1.xyz" "Service1's notes" "Account1" "account1@service1.xyz|account1_backup@service1.xyz" "BBBB" "Service1's Account1's notes" 0 3 \ No newline at end of file diff --git a/UnitTests/TestFiles/import_noData.csv b/UnitTests/TestFiles/import_noData.csv new file mode 100644 index 0000000..6bf25b9 --- /dev/null +++ b/UnitTests/TestFiles/import_noData.csv @@ -0,0 +1 @@ +ServiceName ServiceUrl ServiceNotes AccountLabel Identifiers Password AccountNotes AccountOptions PasswordUpdateReminderDelay \ No newline at end of file diff --git a/UnitTests/UnitTests.csproj b/UnitTests/UnitTests.csproj index 7f61773..c5088d6 100644 --- a/UnitTests/UnitTests.csproj +++ b/UnitTests/UnitTests.csproj @@ -1,12 +1,22 @@ - + - net8.0-windows10.0.18362.0 + net10.0-windows10.0.18362.0 latest enable enable + + + 0 + + + + + 0 + + @@ -21,4 +31,10 @@ + + + Always + + + diff --git a/UnitTests/UnitTestsHelper.cs b/UnitTests/UnitTestsHelper.cs index cc085b3..b9334ee 100644 --- a/UnitTests/UnitTestsHelper.cs +++ b/UnitTests/UnitTestsHelper.cs @@ -1,11 +1,12 @@ -using System.Runtime.CompilerServices; +using FluentAssertions; +using System.Runtime.CompilerServices; using System.Security.Cryptography; -using FluentAssertions; -using Upsilon.Apps.PassKey.Core.Public.Enums; -using Upsilon.Apps.PassKey.Core.Public.Interfaces; -using Upsilon.Apps.PassKey.Core.Public.Utils; +using Upsilon.Apps.Passkey.Core.Models; +using Upsilon.Apps.Passkey.Core.Utils; +using Upsilon.Apps.Passkey.Interfaces; +using Upsilon.Apps.Passkey.Interfaces.Enums; -namespace Upsilon.Apps.PassKey.UnitTests +namespace Upsilon.Apps.Passkey.UnitTests { internal static class UnitTestsHelper { @@ -14,11 +15,32 @@ internal static class UnitTestsHelper public static readonly ICryptographyCenter CryptographicCenter = new CryptographyCenter(); public static readonly ISerializationCenter SerializationCenter = new JsonSerializationCenter(); public static readonly IPasswordFactory PasswordFactory = new PasswordFactory(); + public static readonly IClipboardManager ClipboardManager = new ClipboardManager(); - public static string ComputeDatabaseFileDirectory([CallerMemberName] string username = "") => $"./TestFiles/{username}"; - public static string ComputeDatabaseFilePath([CallerMemberName] string username = "") => $"{ComputeDatabaseFileDirectory(username)}/{username}.pku"; - public static string ComputeAutoSaveFilePath([CallerMemberName] string username = "") => $"{ComputeDatabaseFileDirectory(username)}/{username}.pka"; - public static string ComputeLogFilePath([CallerMemberName] string username = "") => $"{ComputeDatabaseFileDirectory(username)}/{username}.pkl"; + public static string ComputeTestDirectory([CallerMemberName] string username = "") => $"./TestFiles/{username}"; + public static string ComputeDatabaseFileDirectory([CallerMemberName] string username = "") => $"{ComputeTestDirectory(username)}/{CryptographicCenter.GetHash(username)}"; + public static string ComputeDatabaseFilePath([CallerMemberName] string username = "") => $"{ComputeDatabaseFileDirectory(username)}/{CryptographicCenter.GetHash(username)}.pku"; + public static string ComputeAutoSaveFilePath([CallerMemberName] string username = "") => $"{ComputeDatabaseFileDirectory(username)}/{CryptographicCenter.GetHash(username)}.pka"; + public static string ComputeLogFilePath([CallerMemberName] string username = "") => $"{ComputeDatabaseFileDirectory(username)}/{CryptographicCenter.GetHash(username)}.pkl"; + + public static string GetTestFilePath(string fileName, bool createIfNotExists = false) + { + string filePath = $"./TestFiles/{fileName}"; + + if (!File.Exists(filePath) + && createIfNotExists) + { + string fileDirectory = Path.GetDirectoryName(filePath); + if (!Directory.Exists(fileDirectory)) + { + Directory.CreateDirectory(fileDirectory); + } + + File.Create(filePath).Close(); + } + + return filePath; + } public static IDatabase CreateTestDatabase(string[] passkeys = null, [CallerMemberName] string username = "") { @@ -28,20 +50,16 @@ public static IDatabase CreateTestDatabase(string[] passkeys = null, [CallerMemb passkeys ??= GetRandomStringArray(); - IDatabase database = IDatabase.Create(CryptographicCenter, + IDatabase database = Database.Create(CryptographicCenter, SerializationCenter, PasswordFactory, + ClipboardManager, databaseFile, autoSaveFile, logFile, username, passkeys); - foreach (string passkey in passkeys) - { - _ = database.Login(passkey); - } - return database; } @@ -53,9 +71,10 @@ public static IDatabase OpenTestDatabase(string[] passkeys, out IWarning[] detec IWarning[] warnings = []; - IDatabase database = IDatabase.Open(CryptographicCenter, + IDatabase database = Database.Open(CryptographicCenter, SerializationCenter, PasswordFactory, + ClipboardManager, databaseFile, autoSaveFile, logFile, @@ -76,7 +95,7 @@ public static IDatabase OpenTestDatabase(string[] passkeys, out IWarning[] detec public static void ClearTestEnvironment([CallerMemberName] string username = "") { - string directory = ComputeDatabaseFileDirectory(username); + string directory = ComputeTestDirectory(username); if (Directory.Exists(directory)) { @@ -144,6 +163,11 @@ public static void LastLogsShouldMatch(IDatabase database, string[] expectedLogs public static void LastLogWarningsShouldMatch(IDatabase database, string[] expectedLogs) { + while (database.Warnings == null) + { + Thread.Sleep(200); + } + IWarning logWarning = database.Warnings.First(x => x.WarningType == WarningType.LogReviewWarning); string[] actualLogs = logWarning.Logs diff --git a/UnitTests/Utils/CryptographyCenterUnitTexts.cs b/UnitTests/Utils/CryptographyCenterUnitTexts.cs index 67a1c09..45597c2 100644 --- a/UnitTests/Utils/CryptographyCenterUnitTexts.cs +++ b/UnitTests/Utils/CryptographyCenterUnitTexts.cs @@ -1,7 +1,8 @@ using FluentAssertions; -using Upsilon.Apps.PassKey.Core.Public.Utils; +using System.Diagnostics; +using Upsilon.Apps.Passkey.Interfaces.Utils; -namespace Upsilon.Apps.PassKey.UnitTests.Utils +namespace Upsilon.Apps.Passkey.UnitTests.Utils { [TestClass] public sealed class CryptographyCenterUnitTexts @@ -11,7 +12,44 @@ public sealed class CryptographyCenterUnitTexts * Signing an empty string returns the hash code of that empty string, * Then checking the signature returns the empty string. */ - public void Case01_SignEmptyString() + public void Case01_SlowHash() + { + // Given + Stopwatch _stopwatch = Stopwatch.StartNew(); + + // When + _ = UnitTestsHelper.CryptographicCenter.GetSlowHash(string.Empty); + _stopwatch.Stop(); + + // Then + _ = _stopwatch.ElapsedMilliseconds.Should().BeGreaterThan(500); + } + + [TestMethod] + /* + * The length of any should be constantly equal to `HashLength`. + */ + public void Case02_HashLength() + { + for (int i = 0; i < UnitTestsHelper.RANDOMIZED_TESTS_LOOP; i++) + { + // Given + string source = UnitTestsHelper.GetRandomString(); + + // When + string hash = UnitTestsHelper.CryptographicCenter.GetHash(source); + + // Then + _ = hash.Length.Should().Be(UnitTestsHelper.CryptographicCenter.HashLength); + } + } + + [TestMethod] + /* + * Signing an empty string returns the hash code of that empty string, + * Then checking the signature returns the empty string. + */ + public void Case03_SignEmptyString() { // Given string source = string.Empty; @@ -35,7 +73,7 @@ public void Case01_SignEmptyString() /* * Signing a random string then check the sign should rise no error. */ - public void Case02_SignRandomString() + public void Case04_SignRandomString() { for (int i = 0; i < UnitTestsHelper.RANDOMIZED_TESTS_LOOP; i++) { @@ -58,7 +96,7 @@ public void Case02_SignRandomString() * Encrypting symmetrically a random string then decrypting it should rise no error, * Then the decrypted string should be the same as the source. */ - public void Case03_SymmetricEncryptionRandomString() + public void Case05_SymmetricEncryptionRandomString() { for (int i = 0; i < UnitTestsHelper.RANDOMIZED_TESTS_LOOP; i++) { @@ -79,7 +117,7 @@ public void Case03_SymmetricEncryptionRandomString() /* * Decrypting symmetrically a corrupted string should rise an error. */ - public void Case04_SymmetricEncryptionDecryptingCorruptedRandomString() + public void Case06_SymmetricEncryptionDecryptingCorruptedRandomString() { for (int i = 0; i < UnitTestsHelper.RANDOMIZED_TESTS_LOOP; i++) { @@ -114,7 +152,7 @@ public void Case04_SymmetricEncryptionDecryptingCorruptedRandomString() /* * Decrypting symmetrically a random string with a wrong passkey should rise an error. */ - public void Case05_SymmetricEncryptionDecryptingRandomStringWithWrongPasskey() + public void Case07_SymmetricEncryptionDecryptingRandomStringWithWrongPasskey() { for (int i = 0; i < UnitTestsHelper.RANDOMIZED_TESTS_LOOP; i++) { @@ -152,7 +190,7 @@ public void Case05_SymmetricEncryptionDecryptingRandomStringWithWrongPasskey() * Encrypting a random string then decrypting it should rise no error, * Then the decrypted string should be the same as the source. */ - public void Case06_AsymmetricEncryptionRandomString() + public void Case08_AsymmetricEncryptionRandomString() { for (int i = 0; i < UnitTestsHelper.RANDOMIZED_TESTS_LOOP; i++) { @@ -173,7 +211,7 @@ public void Case06_AsymmetricEncryptionRandomString() /* * Decrypting a corrupted string should rise an error. */ - public void Case07_AsymmetricEncryptionDecryptingCorruptedRandomString() + public void Case09_AsymmetricEncryptionDecryptingCorruptedRandomString() { for (int i = 0; i < UnitTestsHelper.RANDOMIZED_TESTS_LOOP; i++) { @@ -208,7 +246,7 @@ public void Case07_AsymmetricEncryptionDecryptingCorruptedRandomString() /* * Decrypting a random string with a wrong passkey should rise an error. */ - public void Case08_AsymmetricEncryptionDecryptingRandomStringWithWrongPasskey() + public void Case10_AsymmetricEncryptionDecryptingRandomStringWithWrongPasskey() { for (int i = 0; i < UnitTestsHelper.RANDOMIZED_TESTS_LOOP; i++) { diff --git a/Upsilon.Apps.Passkey.Linux.slnx b/Upsilon.Apps.Passkey.Linux.slnx new file mode 100644 index 0000000..561bc69 --- /dev/null +++ b/Upsilon.Apps.Passkey.Linux.slnx @@ -0,0 +1,4 @@ + + + + diff --git a/Upsilon.Apps.Passkey.Windows.slnx b/Upsilon.Apps.Passkey.Windows.slnx new file mode 100644 index 0000000..8ac21f0 --- /dev/null +++ b/Upsilon.Apps.Passkey.Windows.slnx @@ -0,0 +1,5 @@ + + + + + diff --git a/Upsilon.Apps.Passkey.sln b/Upsilon.Apps.Passkey.sln deleted file mode 100644 index 5d4ab3b..0000000 --- a/Upsilon.Apps.Passkey.sln +++ /dev/null @@ -1,31 +0,0 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.14.36429.23 d17.14 -MinimumVisualStudioVersion = 10.0.40219.1 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Upsilon.Apps.Passkey.Core", "Core\Upsilon.Apps.Passkey.Core.csproj", "{E8918B8B-3457-14F3-B592-18B9C1C7AB82}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "UnitTests", "UnitTests\UnitTests.csproj", "{BAE0DFAB-D0D6-4020-B9B1-73CCF5CD3678}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {E8918B8B-3457-14F3-B592-18B9C1C7AB82}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {E8918B8B-3457-14F3-B592-18B9C1C7AB82}.Debug|Any CPU.Build.0 = Debug|Any CPU - {E8918B8B-3457-14F3-B592-18B9C1C7AB82}.Release|Any CPU.ActiveCfg = Release|Any CPU - {E8918B8B-3457-14F3-B592-18B9C1C7AB82}.Release|Any CPU.Build.0 = Release|Any CPU - {BAE0DFAB-D0D6-4020-B9B1-73CCF5CD3678}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {BAE0DFAB-D0D6-4020-B9B1-73CCF5CD3678}.Debug|Any CPU.Build.0 = Debug|Any CPU - {BAE0DFAB-D0D6-4020-B9B1-73CCF5CD3678}.Release|Any CPU.ActiveCfg = Release|Any CPU - {BAE0DFAB-D0D6-4020-B9B1-73CCF5CD3678}.Release|Any CPU.Build.0 = Release|Any CPU - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection - GlobalSection(ExtensibilityGlobals) = postSolution - SolutionGuid = {E52E3DBD-718B-4004-B585-42D919FFD3C8} - EndGlobalSection -EndGlobal