feat(configmanager): integrate SecureStore for credential management
Add SecureStore integration to ConfigManager for secure handling of connection strings and sensitive configuration values. Includes store/secret management UI, encrypted .store file support, and comprehensive test coverage.
This commit is contained in:
@@ -1,8 +1,15 @@
|
||||
<Application xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:converters="clr-namespace:JdeScoping.ConfigManager.Converters"
|
||||
x:Class="JdeScoping.ConfigManager.App"
|
||||
RequestedThemeVariant="Dark">
|
||||
<Application.Styles>
|
||||
<FluentTheme />
|
||||
</Application.Styles>
|
||||
<Application.Resources>
|
||||
<ResourceDictionary>
|
||||
<!-- Converters -->
|
||||
<converters:StringToBoolConverter x:Key="StringToBool" />
|
||||
</ResourceDictionary>
|
||||
</Application.Resources>
|
||||
</Application>
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
using Avalonia.Input.Platform;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using JdeScoping.ConfigManager.Application;
|
||||
using JdeScoping.ConfigManager.Services;
|
||||
using JdeScoping.ConfigManager.Services.SecureStore;
|
||||
using JdeScoping.ConfigManager.ViewModels;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace JdeScoping.ConfigManager;
|
||||
|
||||
public partial class App : Application
|
||||
public partial class App : Avalonia.Application
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the dependency injection service provider for the application.
|
||||
@@ -60,6 +63,13 @@ public partial class App : Application
|
||||
// Platform Services
|
||||
services.AddSingleton<IDialogService>(sp =>
|
||||
new AvaloniaDialogService(GetMainWindow));
|
||||
services.AddSingleton<IClipboardService>(sp =>
|
||||
new AvaloniaClipboardService(GetClipboard));
|
||||
|
||||
// SecureStore Services
|
||||
services.AddSingleton<ISecureStoreManager, SecureStoreManager>();
|
||||
services.AddSingleton<StoreUseCases>();
|
||||
services.AddSingleton<SecretUseCases>();
|
||||
|
||||
// ViewModels
|
||||
services.AddTransient<MainWindowViewModel>();
|
||||
@@ -69,4 +79,9 @@ public partial class App : Application
|
||||
{
|
||||
return (ApplicationLifetime as IClassicDesktopStyleApplicationLifetime)?.MainWindow;
|
||||
}
|
||||
|
||||
private IClipboard? GetClipboard()
|
||||
{
|
||||
return GetMainWindow()?.Clipboard;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using JdeScoping.ConfigManager.Services.SecureStore;
|
||||
|
||||
namespace JdeScoping.ConfigManager.Application;
|
||||
|
||||
/// <summary>
|
||||
/// Secret CRUD use-case operations with logging.
|
||||
/// </summary>
|
||||
public class SecretUseCases
|
||||
{
|
||||
private readonly ISecureStoreManager _storeManager;
|
||||
private readonly ILogger<SecretUseCases> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SecretUseCases"/> class.
|
||||
/// </summary>
|
||||
/// <param name="storeManager">The secure store manager.</param>
|
||||
/// <param name="logger">The logger instance.</param>
|
||||
public SecretUseCases(ISecureStoreManager storeManager, ILogger<SecretUseCases> logger)
|
||||
{
|
||||
_storeManager = storeManager ?? throw new ArgumentNullException(nameof(storeManager));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets a secret with the given key and value.
|
||||
/// </summary>
|
||||
/// <param name="key">The secret key.</param>
|
||||
/// <param name="value">The secret value.</param>
|
||||
public void SetSecret(string key, string value)
|
||||
{
|
||||
_logger.LogInformation("Setting secret {Key}", key);
|
||||
_storeManager.SetSecret(key, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes a secret by key.
|
||||
/// </summary>
|
||||
/// <param name="key">The secret key to remove.</param>
|
||||
public void RemoveSecret(string key)
|
||||
{
|
||||
_logger.LogInformation("Removing secret {Key}", key);
|
||||
_storeManager.RemoveSecret(key);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all keys in the current store.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> GetKeys()
|
||||
{
|
||||
return _storeManager.GetKeys();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the value of a secret by key.
|
||||
/// </summary>
|
||||
/// <param name="key">The secret key.</param>
|
||||
/// <returns>The secret value.</returns>
|
||||
public string GetSecret(string key)
|
||||
{
|
||||
return _storeManager.GetSecret(key);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using JdeScoping.ConfigManager.Services.SecureStore;
|
||||
|
||||
namespace JdeScoping.ConfigManager.Application;
|
||||
|
||||
/// <summary>
|
||||
/// Store lifecycle use-case operations with logging.
|
||||
/// </summary>
|
||||
public class StoreUseCases
|
||||
{
|
||||
private readonly ISecureStoreManager _storeManager;
|
||||
private readonly ILogger<StoreUseCases> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="StoreUseCases"/> class.
|
||||
/// </summary>
|
||||
/// <param name="storeManager">The secure store manager instance.</param>
|
||||
/// <param name="logger">The logger instance.</param>
|
||||
public StoreUseCases(ISecureStoreManager storeManager, ILogger<StoreUseCases> logger)
|
||||
{
|
||||
_storeManager = storeManager ?? throw new ArgumentNullException(nameof(storeManager));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new store with either key file or password authentication.
|
||||
/// </summary>
|
||||
/// <param name="storePath">The path where the store will be created.</param>
|
||||
/// <param name="keyFilePath">The path to the key file, or null for password-based authentication.</param>
|
||||
/// <param name="password">The password for authentication, or null for key file-based authentication.</param>
|
||||
public void CreateStore(string storePath, string? keyFilePath, string? password)
|
||||
{
|
||||
_logger.LogInformation("Creating store at {StorePath}", storePath);
|
||||
|
||||
if (!string.IsNullOrEmpty(keyFilePath))
|
||||
{
|
||||
_storeManager.CreateStore(storePath, keyFilePath);
|
||||
_logger.LogInformation("Store created with key file: {KeyFilePath}", keyFilePath);
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(password))
|
||||
{
|
||||
_storeManager.CreateStoreWithPassword(storePath, password);
|
||||
_logger.LogInformation("Password-protected store created");
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new ArgumentException("Either key file path or password must be provided.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Opens an existing store with either key file or password authentication.
|
||||
/// </summary>
|
||||
/// <param name="storePath">The path to the existing store.</param>
|
||||
/// <param name="keyFilePath">The path to the key file, or null for password-based authentication.</param>
|
||||
/// <param name="password">The password for authentication, or null for key file-based authentication.</param>
|
||||
public void OpenStore(string storePath, string? keyFilePath, string? password)
|
||||
{
|
||||
_logger.LogInformation("Opening store at {StorePath}", storePath);
|
||||
|
||||
if (!string.IsNullOrEmpty(keyFilePath))
|
||||
{
|
||||
_storeManager.OpenStore(storePath, keyFilePath);
|
||||
_logger.LogDebug("Store opened with key file");
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(password))
|
||||
{
|
||||
_storeManager.OpenStoreWithPassword(storePath, password);
|
||||
_logger.LogDebug("Store opened with password");
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new ArgumentException("Either key file path or password must be provided.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Closes the currently open store.
|
||||
/// </summary>
|
||||
public void CloseStore()
|
||||
{
|
||||
_logger.LogInformation("Closing store");
|
||||
_storeManager.CloseStore();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Saves changes to the current store.
|
||||
/// </summary>
|
||||
public void Save()
|
||||
{
|
||||
_logger.LogInformation("Saving store");
|
||||
_storeManager.Save();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates a new key file at the specified path.
|
||||
/// </summary>
|
||||
/// <param name="path">The path where the key file will be generated.</param>
|
||||
public void GenerateKeyFile(string path)
|
||||
{
|
||||
_logger.LogInformation("Generating key file at {Path}", path);
|
||||
_storeManager.GenerateKeyFile(path);
|
||||
_logger.LogInformation("Key file generated successfully");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exports the current store's key to a file.
|
||||
/// </summary>
|
||||
/// <param name="path">The path where the key will be exported.</param>
|
||||
public void ExportKey(string path)
|
||||
{
|
||||
_logger.LogInformation("Exporting key to {Path}", path);
|
||||
_storeManager.ExportKey(path);
|
||||
_logger.LogInformation("Key exported successfully");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
namespace JdeScoping.ConfigManager.Constants;
|
||||
|
||||
/// <summary>
|
||||
/// Centralized constants for secure store file extensions and patterns used in file dialogs.
|
||||
/// </summary>
|
||||
public static class SecureStoreFileExtensions
|
||||
{
|
||||
// SecureStore files
|
||||
public const string StorePattern = "*.json";
|
||||
public const string StoreExtension = ".json";
|
||||
public const string StoreTypeName = "SecureStore Files";
|
||||
|
||||
// Key files
|
||||
public const string KeyPattern = "*.key";
|
||||
public const string KeyExtension = ".key";
|
||||
public const string KeyTypeName = "Key Files";
|
||||
|
||||
// All files
|
||||
public const string AllFilesPattern = "*.*";
|
||||
public const string AllFilesTypeName = "All Files";
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
namespace JdeScoping.ConfigManager.Constants;
|
||||
|
||||
/// <summary>
|
||||
/// Centralized string constants for secure store dialog titles, messages, and validation errors.
|
||||
/// </summary>
|
||||
public static class SecureStoreStrings
|
||||
{
|
||||
// Dialog Titles
|
||||
public const string UnsavedChangesTitle = "Unsaved Changes";
|
||||
public const string ConfirmDeleteTitle = "Confirm Delete";
|
||||
public const string ValidationErrorTitle = "Validation Error";
|
||||
public const string ErrorTitle = "Error";
|
||||
public const string KeyGeneratedTitle = "Key Generated";
|
||||
public const string KeyExportedTitle = "Key Exported";
|
||||
|
||||
// Messages
|
||||
public const string UnsavedChangesMessage = "You have unsaved changes. Do you want to save before continuing?";
|
||||
public const string ConfirmDeleteFormat = "Are you sure you want to delete the secret '{0}'?\n\nThis action cannot be undone.";
|
||||
public const string DefaultValidationError = "Please fill in all required fields.";
|
||||
|
||||
// Validation Messages
|
||||
public const string StorePathRequired = "Store path is required.";
|
||||
public const string KeyFilePathRequired = "Key file path is required.";
|
||||
public const string PasswordRequired = "Password is required.";
|
||||
public const string PasswordsDoNotMatch = "Passwords do not match.";
|
||||
public const string KeyRequired = "Key is required.";
|
||||
public const string StoreFileNotFound = "Store file does not exist.";
|
||||
public const string KeyFileNotFound = "Key file does not exist.";
|
||||
|
||||
// File Dialog Titles
|
||||
public const string ChooseStoreLocation = "Choose Store Location";
|
||||
public const string ChooseKeyFileLocation = "Choose Key File Location";
|
||||
public const string SelectStoreFile = "Select Store File";
|
||||
public const string SelectKeyFile = "Select Key File";
|
||||
public const string GenerateKeyFileTitle = "Generate Key File";
|
||||
public const string ExportKeyTitle = "Export Key";
|
||||
|
||||
// Success Message Formats
|
||||
public const string KeyFileGeneratedFormat = "Key file generated successfully:\n\n{0}";
|
||||
public const string KeyExportedFormat = "Key exported successfully:\n\n{0}";
|
||||
|
||||
// Error Message Formats
|
||||
public const string FailedToCreateStoreFormat = "Failed to create store:\n\n{0}";
|
||||
public const string FailedToOpenStoreFormat = "Failed to open store:\n\n{0}";
|
||||
public const string FailedToSaveStoreFormat = "Failed to save store:\n\n{0}";
|
||||
public const string FailedToSaveSecretFormat = "Failed to save secret:\n\n{0}";
|
||||
public const string FailedToDeleteSecretFormat = "Failed to delete secret:\n\n{0}";
|
||||
public const string FailedToGenerateKeyFormat = "Failed to generate key file:\n\n{0}";
|
||||
public const string FailedToExportKeyFormat = "Failed to export key:\n\n{0}";
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
using System.Globalization;
|
||||
using Avalonia.Data.Converters;
|
||||
|
||||
namespace JdeScoping.ConfigManager.Converters;
|
||||
|
||||
/// <summary>
|
||||
/// Converts a string to bool (empty/null = false, not empty = true).
|
||||
/// Used for visibility bindings based on validation error messages.
|
||||
/// </summary>
|
||||
public class StringToBoolConverter : IValueConverter
|
||||
{
|
||||
/// <summary>
|
||||
/// Converts a string to a boolean based on whether it's empty or null.
|
||||
/// </summary>
|
||||
/// <param name="value">The string value to check.</param>
|
||||
/// <param name="targetType">The target type (ignored).</param>
|
||||
/// <param name="parameter">An optional parameter (ignored).</param>
|
||||
/// <param name="culture">The culture information (ignored).</param>
|
||||
/// <returns>True if string is not null or whitespace, false otherwise.</returns>
|
||||
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
{
|
||||
if (value is string str)
|
||||
{
|
||||
return !string.IsNullOrWhiteSpace(str);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts a value back (not implemented for string checks).
|
||||
/// </summary>
|
||||
/// <param name="value">The value to convert back (ignored).</param>
|
||||
/// <param name="targetType">The target type (ignored).</param>
|
||||
/// <param name="parameter">An optional parameter (ignored).</param>
|
||||
/// <param name="culture">The culture information (ignored).</param>
|
||||
/// <returns>Not implemented.</returns>
|
||||
/// <exception cref="NotImplementedException">Always thrown.</exception>
|
||||
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@
|
||||
<PackageReference Include="Microsoft.Extensions.Logging" Version="9.0.*" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Console" Version="9.0.*" />
|
||||
<PackageReference Include="Serilog.Extensions.Logging" Version="8.0.*" />
|
||||
<PackageReference Include="SecureStore" Version="1.2.0" />
|
||||
<PackageReference Include="Serilog.Sinks.File" Version="6.0.*" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
using Avalonia.Input.Platform;
|
||||
|
||||
namespace JdeScoping.ConfigManager.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Avalonia implementation of IClipboardService.
|
||||
/// </summary>
|
||||
public class AvaloniaClipboardService : IClipboardService
|
||||
{
|
||||
private readonly Func<IClipboard?> _getClipboard;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new instance of AvaloniaClipboardService.
|
||||
/// </summary>
|
||||
/// <param name="getClipboard">Factory function to get the clipboard instance.</param>
|
||||
public AvaloniaClipboardService(Func<IClipboard?> getClipboard)
|
||||
{
|
||||
_getClipboard = getClipboard ?? throw new ArgumentNullException(nameof(getClipboard));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the clipboard text asynchronously.
|
||||
/// </summary>
|
||||
/// <param name="text">The text to set on the clipboard.</param>
|
||||
public async Task SetTextAsync(string text)
|
||||
{
|
||||
var clipboard = _getClipboard();
|
||||
if (clipboard != null)
|
||||
{
|
||||
await clipboard.SetTextAsync(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace JdeScoping.ConfigManager.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Abstraction for platform-specific clipboard operations.
|
||||
/// Enables unit testing of view models that need clipboard access.
|
||||
/// </summary>
|
||||
public interface IClipboardService
|
||||
{
|
||||
/// <summary>
|
||||
/// Copies text to the system clipboard.
|
||||
/// </summary>
|
||||
/// <param name="text">The text to copy.</param>
|
||||
Task SetTextAsync(string text);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
namespace JdeScoping.ConfigManager.Services.SecureStore;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for managing SecureStore encrypted secret stores.
|
||||
/// </summary>
|
||||
public interface ISecureStoreManager
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets whether a store is currently open.
|
||||
/// </summary>
|
||||
bool IsStoreOpen { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the path to the currently open store, or null if no store is open.
|
||||
/// </summary>
|
||||
string? CurrentStorePath { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether there are unsaved changes to the current store.
|
||||
/// </summary>
|
||||
bool HasUnsavedChanges { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new store secured with a key file.
|
||||
/// </summary>
|
||||
/// <param name="storePath">Path for the new store file (.json).</param>
|
||||
/// <param name="keyFilePath">Path for the key file (.key).</param>
|
||||
void CreateStore(string storePath, string keyFilePath);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new store secured with a password.
|
||||
/// </summary>
|
||||
/// <param name="storePath">Path for the new store file (.json).</param>
|
||||
/// <param name="password">Password to encrypt the store.</param>
|
||||
void CreateStoreWithPassword(string storePath, string password);
|
||||
|
||||
/// <summary>
|
||||
/// Opens an existing store using a key file.
|
||||
/// </summary>
|
||||
/// <param name="storePath">Path to the store file (.json).</param>
|
||||
/// <param name="keyFilePath">Path to the key file (.key).</param>
|
||||
void OpenStore(string storePath, string keyFilePath);
|
||||
|
||||
/// <summary>
|
||||
/// Opens an existing store using a password.
|
||||
/// </summary>
|
||||
/// <param name="storePath">Path to the store file (.json).</param>
|
||||
/// <param name="password">Password to decrypt the store.</param>
|
||||
void OpenStoreWithPassword(string storePath, string password);
|
||||
|
||||
/// <summary>
|
||||
/// Closes the currently open store without saving.
|
||||
/// </summary>
|
||||
void CloseStore();
|
||||
|
||||
/// <summary>
|
||||
/// Saves changes to the currently open store.
|
||||
/// </summary>
|
||||
void Save();
|
||||
|
||||
/// <summary>
|
||||
/// Gets all secret keys in the current store.
|
||||
/// </summary>
|
||||
/// <returns>Collection of secret key names.</returns>
|
||||
IReadOnlyList<string> GetKeys();
|
||||
|
||||
/// <summary>
|
||||
/// Gets the value of a secret.
|
||||
/// </summary>
|
||||
/// <param name="key">The secret key.</param>
|
||||
/// <returns>The decrypted secret value.</returns>
|
||||
string GetSecret(string key);
|
||||
|
||||
/// <summary>
|
||||
/// Sets or updates a secret value.
|
||||
/// </summary>
|
||||
/// <param name="key">The secret key.</param>
|
||||
/// <param name="value">The value to encrypt and store.</param>
|
||||
void SetSecret(string key, string value);
|
||||
|
||||
/// <summary>
|
||||
/// Removes a secret from the store.
|
||||
/// </summary>
|
||||
/// <param name="key">The secret key to remove.</param>
|
||||
void RemoveSecret(string key);
|
||||
|
||||
/// <summary>
|
||||
/// Generates a new key file for use with store encryption.
|
||||
/// </summary>
|
||||
/// <param name="path">Path where the key file will be created.</param>
|
||||
void GenerateKeyFile(string path);
|
||||
|
||||
/// <summary>
|
||||
/// Exports the current store's key to a file (for key file-based stores).
|
||||
/// </summary>
|
||||
/// <param name="path">Path where the key will be exported.</param>
|
||||
void ExportKey(string path);
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NeoSmart.SecureStore;
|
||||
|
||||
namespace JdeScoping.ConfigManager.Services.SecureStore;
|
||||
|
||||
/// <summary>
|
||||
/// Manages SecureStore encrypted secret stores for the Avalonia application.
|
||||
/// </summary>
|
||||
public class SecureStoreManager : ISecureStoreManager, IDisposable
|
||||
{
|
||||
private readonly ILogger<SecureStoreManager> _logger;
|
||||
private SecretsManager? _secretsManager;
|
||||
private string? _currentStorePath;
|
||||
private readonly HashSet<string> _keys = new();
|
||||
private bool _hasUnsavedChanges;
|
||||
private bool _disposed;
|
||||
|
||||
private const string KeysMetadataKey = "__keys__";
|
||||
|
||||
private static readonly HashSet<string> ReservedKeys = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
KeysMetadataKey
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new SecureStoreManager with no logging.
|
||||
/// </summary>
|
||||
public SecureStoreManager() : this(NullLogger<SecureStoreManager>.Instance)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new SecureStoreManager with the specified logger.
|
||||
/// </summary>
|
||||
/// <param name="logger">Logger instance for diagnostic output.</param>
|
||||
public SecureStoreManager(ILogger<SecureStoreManager> logger)
|
||||
{
|
||||
_logger = logger ?? NullLogger<SecureStoreManager>.Instance;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsStoreOpen => _secretsManager != null;
|
||||
|
||||
/// <inheritdoc />
|
||||
public string? CurrentStorePath => _currentStorePath;
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool HasUnsavedChanges => _hasUnsavedChanges;
|
||||
|
||||
/// <inheritdoc />
|
||||
public void CreateStore(string storePath, string keyFilePath)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
_logger.LogInformation("Creating new store at {StorePath}", storePath);
|
||||
CloseStoreInternal();
|
||||
|
||||
EnsureDirectory(storePath);
|
||||
EnsureDirectory(keyFilePath);
|
||||
|
||||
_secretsManager = SecretsManager.CreateStore();
|
||||
_secretsManager.GenerateKey();
|
||||
_secretsManager.ExportKey(keyFilePath);
|
||||
|
||||
_currentStorePath = storePath;
|
||||
_keys.Clear();
|
||||
_hasUnsavedChanges = true;
|
||||
|
||||
Save();
|
||||
_logger.LogInformation("Store created with key file: {KeyFilePath}", keyFilePath);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void CreateStoreWithPassword(string storePath, string password)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
_logger.LogInformation("Creating password-protected store at {StorePath}", storePath);
|
||||
CloseStoreInternal();
|
||||
|
||||
if (string.IsNullOrEmpty(password))
|
||||
throw new ArgumentException("Password cannot be empty.", nameof(password));
|
||||
|
||||
EnsureDirectory(storePath);
|
||||
|
||||
_secretsManager = SecretsManager.CreateStore();
|
||||
_secretsManager.LoadKeyFromPassword(password);
|
||||
|
||||
_currentStorePath = storePath;
|
||||
_keys.Clear();
|
||||
_hasUnsavedChanges = true;
|
||||
|
||||
Save();
|
||||
_logger.LogInformation("Password-protected store created");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void OpenStore(string storePath, string keyFilePath)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
_logger.LogInformation("Opening store at {StorePath}", storePath);
|
||||
CloseStoreInternal();
|
||||
|
||||
if (!File.Exists(storePath))
|
||||
throw new FileNotFoundException("Store file not found.", storePath);
|
||||
|
||||
if (!File.Exists(keyFilePath))
|
||||
throw new FileNotFoundException("Key file not found.", keyFilePath);
|
||||
|
||||
_secretsManager = SecretsManager.LoadStore(storePath);
|
||||
_secretsManager.LoadKeyFromFile(keyFilePath);
|
||||
|
||||
_currentStorePath = storePath;
|
||||
LoadKeysMetadata();
|
||||
_hasUnsavedChanges = false;
|
||||
_logger.LogDebug("Store opened with key file, contains {KeyCount} keys", _keys.Count);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void OpenStoreWithPassword(string storePath, string password)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
_logger.LogInformation("Opening store at {StorePath} with password", storePath);
|
||||
CloseStoreInternal();
|
||||
|
||||
if (!File.Exists(storePath))
|
||||
throw new FileNotFoundException("Store file not found.", storePath);
|
||||
|
||||
if (string.IsNullOrEmpty(password))
|
||||
throw new ArgumentException("Password cannot be empty.", nameof(password));
|
||||
|
||||
_secretsManager = SecretsManager.LoadStore(storePath);
|
||||
_secretsManager.LoadKeyFromPassword(password);
|
||||
|
||||
_currentStorePath = storePath;
|
||||
LoadKeysMetadata();
|
||||
_hasUnsavedChanges = false;
|
||||
_logger.LogDebug("Store opened with password, contains {KeyCount} keys", _keys.Count);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void CloseStore()
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
_logger.LogInformation("Closing store");
|
||||
CloseStoreInternal();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Save()
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
|
||||
if (_secretsManager == null || _currentStorePath == null)
|
||||
throw new InvalidOperationException("No store is currently open.");
|
||||
|
||||
_logger.LogInformation("Saving store changes");
|
||||
SaveKeysMetadata();
|
||||
_secretsManager.SaveStore(_currentStorePath);
|
||||
_hasUnsavedChanges = false;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<string> GetKeys()
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
|
||||
if (_secretsManager == null)
|
||||
throw new InvalidOperationException("No store is currently open.");
|
||||
|
||||
return _keys.Where(k => k != KeysMetadataKey).ToList().AsReadOnly();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string GetSecret(string key)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
|
||||
if (_secretsManager == null)
|
||||
throw new InvalidOperationException("No store is currently open.");
|
||||
|
||||
if (string.IsNullOrEmpty(key))
|
||||
throw new ArgumentException("Key cannot be empty.", nameof(key));
|
||||
|
||||
if (!_keys.Contains(key))
|
||||
throw new KeyNotFoundException($"Secret '{key}' not found.");
|
||||
|
||||
return _secretsManager.Get(key);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void SetSecret(string key, string value)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
|
||||
if (_secretsManager == null)
|
||||
throw new InvalidOperationException("No store is currently open.");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(key))
|
||||
throw new ArgumentException("Key cannot be null or whitespace.", nameof(key));
|
||||
|
||||
if (ReservedKeys.Contains(key))
|
||||
{
|
||||
_logger.LogWarning("Attempted to access reserved key {Key}", key);
|
||||
throw new ArgumentException($"The key '{key}' is reserved for internal use.", nameof(key));
|
||||
}
|
||||
|
||||
_logger.LogDebug("Setting secret for key {Key}", key);
|
||||
_secretsManager.Set(key, value ?? string.Empty);
|
||||
_keys.Add(key);
|
||||
_hasUnsavedChanges = true;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void RemoveSecret(string key)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
|
||||
if (_secretsManager == null)
|
||||
throw new InvalidOperationException("No store is currently open.");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(key))
|
||||
throw new ArgumentException("Key cannot be null or whitespace.", nameof(key));
|
||||
|
||||
if (ReservedKeys.Contains(key))
|
||||
{
|
||||
_logger.LogWarning("Attempted to access reserved key {Key}", key);
|
||||
throw new ArgumentException($"The key '{key}' is reserved for internal use.", nameof(key));
|
||||
}
|
||||
|
||||
if (!_keys.Remove(key))
|
||||
throw new KeyNotFoundException($"Secret '{key}' not found.");
|
||||
|
||||
_logger.LogInformation("Removing secret for key {Key}", key);
|
||||
_secretsManager.Delete(key);
|
||||
_hasUnsavedChanges = true;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void GenerateKeyFile(string path)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
|
||||
if (string.IsNullOrEmpty(path))
|
||||
throw new ArgumentException("Path cannot be empty.", nameof(path));
|
||||
|
||||
EnsureDirectory(path);
|
||||
|
||||
using var tempManager = SecretsManager.CreateStore();
|
||||
tempManager.GenerateKey();
|
||||
tempManager.ExportKey(path);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void ExportKey(string path)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
|
||||
if (_secretsManager == null)
|
||||
throw new InvalidOperationException("No store is currently open.");
|
||||
|
||||
if (string.IsNullOrEmpty(path))
|
||||
throw new ArgumentException("Path cannot be empty.", nameof(path));
|
||||
|
||||
EnsureDirectory(path);
|
||||
_secretsManager.ExportKey(path);
|
||||
}
|
||||
|
||||
private void LoadKeysMetadata()
|
||||
{
|
||||
_keys.Clear();
|
||||
|
||||
try
|
||||
{
|
||||
var keysJson = _secretsManager!.Get(KeysMetadataKey);
|
||||
if (!string.IsNullOrEmpty(keysJson))
|
||||
{
|
||||
var keys = JsonSerializer.Deserialize<string[]>(keysJson);
|
||||
if (keys != null)
|
||||
{
|
||||
foreach (var key in keys)
|
||||
_keys.Add(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (KeyNotFoundException)
|
||||
{
|
||||
// No keys metadata yet
|
||||
}
|
||||
}
|
||||
|
||||
private void SaveKeysMetadata()
|
||||
{
|
||||
var keys = _keys.Where(k => k != KeysMetadataKey).ToArray();
|
||||
var keysJson = JsonSerializer.Serialize(keys);
|
||||
_secretsManager!.Set(KeysMetadataKey, keysJson);
|
||||
_keys.Add(KeysMetadataKey);
|
||||
}
|
||||
|
||||
private void CloseStoreInternal()
|
||||
{
|
||||
_secretsManager?.Dispose();
|
||||
_secretsManager = null;
|
||||
_currentStorePath = null;
|
||||
_keys.Clear();
|
||||
_hasUnsavedChanges = false;
|
||||
}
|
||||
|
||||
private static void EnsureDirectory(string filePath)
|
||||
{
|
||||
var directory = Path.GetDirectoryName(filePath);
|
||||
if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
}
|
||||
}
|
||||
|
||||
private void ThrowIfDisposed()
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Releases the resources used by the <see cref="SecureStoreManager"/>.
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
|
||||
_secretsManager?.Dispose();
|
||||
_secretsManager = null;
|
||||
_disposed = true;
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
using System.Windows.Input;
|
||||
using JdeScoping.ConfigManager.Constants;
|
||||
|
||||
namespace JdeScoping.ConfigManager.ViewModels.Dialogs;
|
||||
|
||||
/// <summary>
|
||||
/// View model for creating a new secure store.
|
||||
/// </summary>
|
||||
public class NewStoreDialogViewModel : ViewModelBase
|
||||
{
|
||||
private string _storePath = string.Empty;
|
||||
private string _keyFilePath = string.Empty;
|
||||
private string _password = string.Empty;
|
||||
private string _confirmPassword = string.Empty;
|
||||
private bool _useKeyFile = true;
|
||||
private bool _usePassword;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="NewStoreDialogViewModel"/> class.
|
||||
/// </summary>
|
||||
public NewStoreDialogViewModel()
|
||||
{
|
||||
BrowseStorePathCommand = new RelayCommand(BrowseStorePath);
|
||||
BrowseKeyFilePathCommand = new RelayCommand(BrowseKeyFilePath);
|
||||
GenerateKeyFileCommand = new AsyncRelayCommand(GenerateKeyFileAsync);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the path to the store file to create.
|
||||
/// </summary>
|
||||
public string StorePath
|
||||
{
|
||||
get => _storePath;
|
||||
set
|
||||
{
|
||||
if (SetProperty(ref _storePath, value))
|
||||
NotifyValidationChanged();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the path to the key file for encryption.
|
||||
/// </summary>
|
||||
public string KeyFilePath
|
||||
{
|
||||
get => _keyFilePath;
|
||||
set
|
||||
{
|
||||
if (SetProperty(ref _keyFilePath, value))
|
||||
NotifyValidationChanged();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the password for store encryption.
|
||||
/// </summary>
|
||||
public string Password
|
||||
{
|
||||
get => _password;
|
||||
set
|
||||
{
|
||||
if (SetProperty(ref _password, value))
|
||||
NotifyValidationChanged();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the password confirmation value.
|
||||
/// </summary>
|
||||
public string ConfirmPassword
|
||||
{
|
||||
get => _confirmPassword;
|
||||
set
|
||||
{
|
||||
if (SetProperty(ref _confirmPassword, value))
|
||||
NotifyValidationChanged();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether to use key file for store encryption.
|
||||
/// </summary>
|
||||
public bool UseKeyFile
|
||||
{
|
||||
get => _useKeyFile;
|
||||
set
|
||||
{
|
||||
if (SetProperty(ref _useKeyFile, value))
|
||||
{
|
||||
if (value) UsePassword = false;
|
||||
NotifyValidationChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether to use password for store encryption.
|
||||
/// </summary>
|
||||
public bool UsePassword
|
||||
{
|
||||
get => _usePassword;
|
||||
set
|
||||
{
|
||||
if (SetProperty(ref _usePassword, value))
|
||||
{
|
||||
if (value) UseKeyFile = false;
|
||||
NotifyValidationChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the command to browse for store path location.
|
||||
/// </summary>
|
||||
public ICommand BrowseStorePathCommand { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the command to browse for key file path location.
|
||||
/// </summary>
|
||||
public ICommand BrowseKeyFilePathCommand { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the command to generate a new key file.
|
||||
/// </summary>
|
||||
public ICommand GenerateKeyFileCommand { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the dialog input is valid.
|
||||
/// </summary>
|
||||
public bool IsValid
|
||||
{
|
||||
get
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(StorePath))
|
||||
return false;
|
||||
|
||||
if (UseKeyFile)
|
||||
return !string.IsNullOrWhiteSpace(KeyFilePath);
|
||||
|
||||
if (UsePassword)
|
||||
return !string.IsNullOrWhiteSpace(Password) && Password == ConfirmPassword;
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the validation error message, or null if valid.
|
||||
/// </summary>
|
||||
public string? ValidationError
|
||||
{
|
||||
get
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(StorePath))
|
||||
return SecureStoreStrings.StorePathRequired;
|
||||
|
||||
if (UseKeyFile && string.IsNullOrWhiteSpace(KeyFilePath))
|
||||
return SecureStoreStrings.KeyFilePathRequired;
|
||||
|
||||
if (UsePassword)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(Password))
|
||||
return SecureStoreStrings.PasswordRequired;
|
||||
|
||||
if (Password != ConfirmPassword)
|
||||
return SecureStoreStrings.PasswordsDoNotMatch;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Event raised to request save file dialog for store path.
|
||||
/// Parameters: title, fileTypeName, pattern, defaultExtension
|
||||
/// Returns: selected file path or null
|
||||
/// </summary>
|
||||
public event Func<string, string, string, string, Task<string?>>? OnShowSaveFileDialog;
|
||||
|
||||
/// <summary>
|
||||
/// Event raised to request key file generation.
|
||||
/// Parameters: title, fileTypeName, pattern, defaultExtension
|
||||
/// Returns: generated key file path or null
|
||||
/// </summary>
|
||||
public event Func<string, string, string, string, Task<string?>>? OnGenerateKeyFile;
|
||||
|
||||
private void NotifyValidationChanged()
|
||||
{
|
||||
OnPropertyChanged(nameof(IsValid));
|
||||
OnPropertyChanged(nameof(ValidationError));
|
||||
}
|
||||
|
||||
private async void BrowseStorePath()
|
||||
{
|
||||
if (OnShowSaveFileDialog == null)
|
||||
return;
|
||||
|
||||
var path = await OnShowSaveFileDialog(
|
||||
SecureStoreStrings.ChooseStoreLocation,
|
||||
SecureStoreFileExtensions.StoreTypeName,
|
||||
SecureStoreFileExtensions.StorePattern,
|
||||
SecureStoreFileExtensions.StoreExtension);
|
||||
|
||||
if (!string.IsNullOrEmpty(path))
|
||||
{
|
||||
StorePath = path;
|
||||
}
|
||||
}
|
||||
|
||||
private async void BrowseKeyFilePath()
|
||||
{
|
||||
if (OnShowSaveFileDialog == null)
|
||||
return;
|
||||
|
||||
var path = await OnShowSaveFileDialog(
|
||||
SecureStoreStrings.ChooseKeyFileLocation,
|
||||
SecureStoreFileExtensions.KeyTypeName,
|
||||
SecureStoreFileExtensions.KeyPattern,
|
||||
SecureStoreFileExtensions.KeyExtension);
|
||||
|
||||
if (!string.IsNullOrEmpty(path))
|
||||
{
|
||||
KeyFilePath = path;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task GenerateKeyFileAsync()
|
||||
{
|
||||
if (OnGenerateKeyFile == null)
|
||||
return;
|
||||
|
||||
var path = await OnGenerateKeyFile(
|
||||
SecureStoreStrings.GenerateKeyFileTitle,
|
||||
SecureStoreFileExtensions.KeyTypeName,
|
||||
SecureStoreFileExtensions.KeyPattern,
|
||||
SecureStoreFileExtensions.KeyExtension);
|
||||
|
||||
if (!string.IsNullOrEmpty(path))
|
||||
{
|
||||
KeyFilePath = path;
|
||||
}
|
||||
}
|
||||
}
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
using JdeScoping.ConfigManager.Constants;
|
||||
|
||||
namespace JdeScoping.ConfigManager.ViewModels.Dialogs;
|
||||
|
||||
/// <summary>
|
||||
/// View model for adding or editing a secret in the secure store.
|
||||
/// </summary>
|
||||
public class SecretEditDialogViewModel : ViewModelBase
|
||||
{
|
||||
private string _key = string.Empty;
|
||||
private string _value = string.Empty;
|
||||
private bool _isNewSecret = true;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SecretEditDialogViewModel"/> class for adding a new secret.
|
||||
/// </summary>
|
||||
public SecretEditDialogViewModel()
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SecretEditDialogViewModel"/> class for editing an existing secret.
|
||||
/// </summary>
|
||||
/// <param name="key">The secret key (read-only when editing).</param>
|
||||
/// <param name="value">The current secret value.</param>
|
||||
public SecretEditDialogViewModel(string key, string value)
|
||||
{
|
||||
_key = key;
|
||||
_value = value;
|
||||
_isNewSecret = false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the secret key.
|
||||
/// </summary>
|
||||
public string Key
|
||||
{
|
||||
get => _key;
|
||||
set
|
||||
{
|
||||
if (SetProperty(ref _key, value))
|
||||
NotifyValidationChanged();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the secret value.
|
||||
/// </summary>
|
||||
public string Value
|
||||
{
|
||||
get => _value;
|
||||
set => SetProperty(ref _value, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether this is a new secret being added.
|
||||
/// </summary>
|
||||
public bool IsNewSecret
|
||||
{
|
||||
get => _isNewSecret;
|
||||
set
|
||||
{
|
||||
if (SetProperty(ref _isNewSecret, value))
|
||||
{
|
||||
OnPropertyChanged(nameof(IsKeyEditable));
|
||||
OnPropertyChanged(nameof(DialogTitle));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the key field is editable.
|
||||
/// The key is only editable when adding a new secret.
|
||||
/// </summary>
|
||||
public bool IsKeyEditable => _isNewSecret;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the dialog title based on whether this is a new secret or edit.
|
||||
/// </summary>
|
||||
public string DialogTitle => _isNewSecret ? "Add Secret" : "Edit Secret";
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the dialog input is valid.
|
||||
/// </summary>
|
||||
public bool IsValid => !string.IsNullOrWhiteSpace(Key);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the validation error message, or null if valid.
|
||||
/// </summary>
|
||||
public string? ValidationError
|
||||
{
|
||||
get
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(Key))
|
||||
return SecureStoreStrings.KeyRequired;
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private void NotifyValidationChanged()
|
||||
{
|
||||
OnPropertyChanged(nameof(IsValid));
|
||||
OnPropertyChanged(nameof(ValidationError));
|
||||
}
|
||||
}
|
||||
+169
@@ -0,0 +1,169 @@
|
||||
using System.Windows.Input;
|
||||
using JdeScoping.ConfigManager.Constants;
|
||||
|
||||
namespace JdeScoping.ConfigManager.ViewModels.Dialogs;
|
||||
|
||||
/// <summary>
|
||||
/// View model for unlocking an existing secure store.
|
||||
/// </summary>
|
||||
public class UnlockStoreDialogViewModel : ViewModelBase
|
||||
{
|
||||
private readonly string _storePath;
|
||||
private string _keyFilePath = string.Empty;
|
||||
private string _password = string.Empty;
|
||||
private bool _useKeyFile = true;
|
||||
private bool _usePassword;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="UnlockStoreDialogViewModel"/> class.
|
||||
/// </summary>
|
||||
/// <param name="storePath">The path to the store file to unlock.</param>
|
||||
public UnlockStoreDialogViewModel(string storePath)
|
||||
{
|
||||
_storePath = storePath ?? throw new ArgumentNullException(nameof(storePath));
|
||||
BrowseKeyFilePathCommand = new RelayCommand(BrowseKeyFilePath);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the path to the store file to unlock (read-only).
|
||||
/// </summary>
|
||||
public string StorePath => _storePath;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the path to the key file for decryption.
|
||||
/// </summary>
|
||||
public string KeyFilePath
|
||||
{
|
||||
get => _keyFilePath;
|
||||
set
|
||||
{
|
||||
if (SetProperty(ref _keyFilePath, value))
|
||||
NotifyValidationChanged();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the password for store decryption.
|
||||
/// </summary>
|
||||
public string Password
|
||||
{
|
||||
get => _password;
|
||||
set
|
||||
{
|
||||
if (SetProperty(ref _password, value))
|
||||
NotifyValidationChanged();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether to use key file for store decryption.
|
||||
/// </summary>
|
||||
public bool UseKeyFile
|
||||
{
|
||||
get => _useKeyFile;
|
||||
set
|
||||
{
|
||||
if (SetProperty(ref _useKeyFile, value))
|
||||
{
|
||||
if (value) UsePassword = false;
|
||||
NotifyValidationChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether to use password for store decryption.
|
||||
/// </summary>
|
||||
public bool UsePassword
|
||||
{
|
||||
get => _usePassword;
|
||||
set
|
||||
{
|
||||
if (SetProperty(ref _usePassword, value))
|
||||
{
|
||||
if (value) UseKeyFile = false;
|
||||
NotifyValidationChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the command to browse for key file path location.
|
||||
/// </summary>
|
||||
public ICommand BrowseKeyFilePathCommand { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the dialog input is valid.
|
||||
/// </summary>
|
||||
public bool IsValid
|
||||
{
|
||||
get
|
||||
{
|
||||
if (UseKeyFile)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(KeyFilePath))
|
||||
return false;
|
||||
|
||||
if (!System.IO.File.Exists(KeyFilePath))
|
||||
return false;
|
||||
}
|
||||
|
||||
if (UsePassword)
|
||||
return !string.IsNullOrWhiteSpace(Password);
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the validation error message, or null if valid.
|
||||
/// </summary>
|
||||
public string? ValidationError
|
||||
{
|
||||
get
|
||||
{
|
||||
if (UseKeyFile)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(KeyFilePath))
|
||||
return SecureStoreStrings.KeyFilePathRequired;
|
||||
|
||||
if (!System.IO.File.Exists(KeyFilePath))
|
||||
return SecureStoreStrings.KeyFileNotFound;
|
||||
}
|
||||
|
||||
if (UsePassword && string.IsNullOrWhiteSpace(Password))
|
||||
return SecureStoreStrings.PasswordRequired;
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Event raised to request open file dialog for key file path.
|
||||
/// Parameters: title, fileTypeName, pattern
|
||||
/// Returns: selected file path or null
|
||||
/// </summary>
|
||||
public event Func<string, string, string, Task<string?>>? OnShowOpenFileDialog;
|
||||
|
||||
private void NotifyValidationChanged()
|
||||
{
|
||||
OnPropertyChanged(nameof(IsValid));
|
||||
OnPropertyChanged(nameof(ValidationError));
|
||||
}
|
||||
|
||||
private async void BrowseKeyFilePath()
|
||||
{
|
||||
if (OnShowOpenFileDialog == null)
|
||||
return;
|
||||
|
||||
var path = await OnShowOpenFileDialog(
|
||||
SecureStoreStrings.SelectKeyFile,
|
||||
SecureStoreFileExtensions.KeyTypeName,
|
||||
SecureStoreFileExtensions.KeyPattern);
|
||||
|
||||
if (!string.IsNullOrEmpty(path))
|
||||
{
|
||||
KeyFilePath = path;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
using System.Windows.Input;
|
||||
using JdeScoping.ConfigManager.Services;
|
||||
|
||||
namespace JdeScoping.ConfigManager.ViewModels.Forms;
|
||||
|
||||
/// <summary>
|
||||
/// ViewModel for displaying and editing a secret.
|
||||
/// </summary>
|
||||
public class SecretFormViewModel : ViewModelBase
|
||||
{
|
||||
private readonly IClipboardService _clipboardService;
|
||||
private readonly Action<string> _onValueChanged;
|
||||
private readonly Action _onDeleteRequested;
|
||||
private string _value;
|
||||
private bool _isValueVisible;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SecretFormViewModel"/> class.
|
||||
/// </summary>
|
||||
/// <param name="key">The secret key (read-only).</param>
|
||||
/// <param name="value">The initial secret value.</param>
|
||||
/// <param name="clipboardService">The clipboard service for copying values.</param>
|
||||
/// <param name="onValueChanged">The action to invoke when the value changes.</param>
|
||||
/// <param name="onDeleteRequested">The action to invoke when deletion is requested.</param>
|
||||
public SecretFormViewModel(
|
||||
string key,
|
||||
string value,
|
||||
IClipboardService clipboardService,
|
||||
Action<string> onValueChanged,
|
||||
Action onDeleteRequested)
|
||||
{
|
||||
Key = key ?? throw new ArgumentNullException(nameof(key));
|
||||
_value = value ?? string.Empty;
|
||||
_clipboardService = clipboardService ?? throw new ArgumentNullException(nameof(clipboardService));
|
||||
_onValueChanged = onValueChanged ?? throw new ArgumentNullException(nameof(onValueChanged));
|
||||
_onDeleteRequested = onDeleteRequested ?? throw new ArgumentNullException(nameof(onDeleteRequested));
|
||||
|
||||
ToggleVisibilityCommand = new RelayCommand(() => IsValueVisible = !IsValueVisible);
|
||||
CopyToClipboardCommand = new AsyncRelayCommand(CopyToClipboardAsync);
|
||||
DeleteCommand = new RelayCommand(_onDeleteRequested);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the secret key (read-only).
|
||||
/// </summary>
|
||||
public string Key { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the secret value.
|
||||
/// </summary>
|
||||
public string Value
|
||||
{
|
||||
get => _value;
|
||||
set
|
||||
{
|
||||
if (SetProperty(ref _value, value))
|
||||
{
|
||||
_onValueChanged(value);
|
||||
OnPropertyChanged(nameof(DisplayValue));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether the value is visible (unmasked).
|
||||
/// </summary>
|
||||
public bool IsValueVisible
|
||||
{
|
||||
get => _isValueVisible;
|
||||
set
|
||||
{
|
||||
if (SetProperty(ref _isValueVisible, value))
|
||||
{
|
||||
OnPropertyChanged(nameof(DisplayValue));
|
||||
OnPropertyChanged(nameof(VisibilityButtonText));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the display value (masked or unmasked based on visibility).
|
||||
/// </summary>
|
||||
public string DisplayValue => IsValueVisible ? Value : new string('\u2022', Math.Min(Value?.Length ?? 0, 20));
|
||||
|
||||
/// <summary>
|
||||
/// Gets the text for the visibility toggle button.
|
||||
/// </summary>
|
||||
public string VisibilityButtonText => IsValueVisible ? "Hide" : "Show";
|
||||
|
||||
/// <summary>
|
||||
/// Gets the command to toggle value visibility.
|
||||
/// </summary>
|
||||
public ICommand ToggleVisibilityCommand { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the command to copy the value to clipboard.
|
||||
/// </summary>
|
||||
public ICommand CopyToClipboardCommand { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the command to delete this secret.
|
||||
/// </summary>
|
||||
public ICommand DeleteCommand { get; }
|
||||
|
||||
private async Task CopyToClipboardAsync()
|
||||
{
|
||||
await _clipboardService.SetTextAsync(Value);
|
||||
}
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
using System.Windows.Input;
|
||||
|
||||
namespace JdeScoping.ConfigManager.ViewModels.Forms;
|
||||
|
||||
/// <summary>
|
||||
/// ViewModel for displaying a locked secure store with unlock capability.
|
||||
/// </summary>
|
||||
public class SecureStoreLockedFormViewModel : ViewModelBase
|
||||
{
|
||||
private readonly Action _onUnlockRequested;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SecureStoreLockedFormViewModel"/> class.
|
||||
/// </summary>
|
||||
/// <param name="storeName">The name of the secure store.</param>
|
||||
/// <param name="storePath">The full path to the secure store file.</param>
|
||||
/// <param name="lastModified">The last modified date of the store file.</param>
|
||||
/// <param name="onUnlockRequested">The action to invoke when unlock is requested.</param>
|
||||
public SecureStoreLockedFormViewModel(
|
||||
string storeName,
|
||||
string storePath,
|
||||
DateTime? lastModified,
|
||||
Action onUnlockRequested)
|
||||
{
|
||||
StoreName = storeName ?? throw new ArgumentNullException(nameof(storeName));
|
||||
StorePath = storePath ?? throw new ArgumentNullException(nameof(storePath));
|
||||
LastModified = lastModified;
|
||||
_onUnlockRequested = onUnlockRequested ?? throw new ArgumentNullException(nameof(onUnlockRequested));
|
||||
|
||||
UnlockCommand = new RelayCommand(_onUnlockRequested);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the name of the secure store.
|
||||
/// </summary>
|
||||
public string StoreName { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the full path to the secure store file.
|
||||
/// </summary>
|
||||
public string StorePath { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the last modified date of the store file.
|
||||
/// </summary>
|
||||
public DateTime? LastModified { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the command to unlock the store.
|
||||
/// </summary>
|
||||
public ICommand UnlockCommand { get; }
|
||||
}
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
using System.Windows.Input;
|
||||
|
||||
namespace JdeScoping.ConfigManager.ViewModels.Forms;
|
||||
|
||||
/// <summary>
|
||||
/// ViewModel for displaying an unlocked secure store.
|
||||
/// </summary>
|
||||
public class SecureStoreUnlockedFormViewModel : ViewModelBase
|
||||
{
|
||||
private readonly Action _onLockRequested;
|
||||
private readonly Action _onAddSecretRequested;
|
||||
private readonly Action _onSaveRequested;
|
||||
private bool _hasUnsavedChanges;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SecureStoreUnlockedFormViewModel"/> class.
|
||||
/// </summary>
|
||||
/// <param name="storeName">The name of the secure store.</param>
|
||||
/// <param name="storePath">The full path to the secure store file.</param>
|
||||
/// <param name="secretCount">The number of secrets in the store.</param>
|
||||
/// <param name="hasUnsavedChanges">Whether the store has unsaved changes.</param>
|
||||
/// <param name="onLockRequested">The action to invoke when lock is requested.</param>
|
||||
/// <param name="onAddSecretRequested">The action to invoke when adding a new secret is requested.</param>
|
||||
/// <param name="onSaveRequested">The action to invoke when save is requested.</param>
|
||||
public SecureStoreUnlockedFormViewModel(
|
||||
string storeName,
|
||||
string storePath,
|
||||
int secretCount,
|
||||
bool hasUnsavedChanges,
|
||||
Action onLockRequested,
|
||||
Action onAddSecretRequested,
|
||||
Action onSaveRequested)
|
||||
{
|
||||
StoreName = storeName ?? throw new ArgumentNullException(nameof(storeName));
|
||||
StorePath = storePath ?? throw new ArgumentNullException(nameof(storePath));
|
||||
SecretCount = secretCount;
|
||||
_hasUnsavedChanges = hasUnsavedChanges;
|
||||
_onLockRequested = onLockRequested ?? throw new ArgumentNullException(nameof(onLockRequested));
|
||||
_onAddSecretRequested = onAddSecretRequested ?? throw new ArgumentNullException(nameof(onAddSecretRequested));
|
||||
_onSaveRequested = onSaveRequested ?? throw new ArgumentNullException(nameof(onSaveRequested));
|
||||
|
||||
LockCommand = new RelayCommand(_onLockRequested);
|
||||
AddSecretCommand = new RelayCommand(_onAddSecretRequested);
|
||||
SaveCommand = new RelayCommand(_onSaveRequested, () => HasUnsavedChanges);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the name of the secure store.
|
||||
/// </summary>
|
||||
public string StoreName { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the full path to the secure store file.
|
||||
/// </summary>
|
||||
public string StorePath { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of secrets in the store.
|
||||
/// </summary>
|
||||
public int SecretCount { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether the store has unsaved changes.
|
||||
/// </summary>
|
||||
public bool HasUnsavedChanges
|
||||
{
|
||||
get => _hasUnsavedChanges;
|
||||
set
|
||||
{
|
||||
if (SetProperty(ref _hasUnsavedChanges, value))
|
||||
{
|
||||
// Notify that SaveCommand's CanExecute may have changed
|
||||
(SaveCommand as RelayCommand)?.RaiseCanExecuteChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the command to lock the store.
|
||||
/// </summary>
|
||||
public ICommand LockCommand { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the command to add a new secret.
|
||||
/// </summary>
|
||||
public ICommand AddSecretCommand { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the command to save the store.
|
||||
/// </summary>
|
||||
public ICommand SaveCommand { get; }
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Windows.Input;
|
||||
using Avalonia.Media;
|
||||
using JdeScoping.ConfigManager.Constants;
|
||||
using JdeScoping.ConfigManager.Models;
|
||||
using JdeScoping.ConfigManager.Services;
|
||||
using JdeScoping.ConfigManager.Services.SecureStore;
|
||||
using JdeScoping.ConfigManager.ViewModels.Dialogs;
|
||||
using JdeScoping.ConfigManager.ViewModels.Forms;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
@@ -19,6 +22,8 @@ public class MainWindowViewModel : ViewModelBase
|
||||
private readonly IBackupService _backupService;
|
||||
private readonly IAutoDiscoveryService _autoDiscoveryService;
|
||||
private readonly IDialogService? _dialogService;
|
||||
private readonly ISecureStoreManager _secureStoreManager;
|
||||
private readonly IClipboardService _clipboardService;
|
||||
private readonly ILogger<MainWindowViewModel>? _logger;
|
||||
|
||||
private string _configFolderPath = "No folder selected";
|
||||
@@ -31,6 +36,10 @@ public class MainWindowViewModel : ViewModelBase
|
||||
private ConfigModel? _appSettings;
|
||||
private PipelinesConfigModel? _pipelines;
|
||||
|
||||
// SecureStore state tracking
|
||||
private readonly Dictionary<string, TreeNodeViewModel> _openStores = new();
|
||||
private TreeNodeViewModel? _selectedStoreNode;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the currently loaded configuration folder path.
|
||||
/// </summary>
|
||||
@@ -129,6 +138,51 @@ public class MainWindowViewModel : ViewModelBase
|
||||
/// </summary>
|
||||
public ICommand TestConnectionCommand { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the command for creating a new secure store.
|
||||
/// </summary>
|
||||
public ICommand NewStoreCommand { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the command for adding an existing secure store.
|
||||
/// </summary>
|
||||
public ICommand AddExistingStoreCommand { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the command for unlocking a secure store.
|
||||
/// </summary>
|
||||
public ICommand UnlockStoreCommand { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the command for locking a secure store.
|
||||
/// </summary>
|
||||
public ICommand LockStoreCommand { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the command for saving a secure store.
|
||||
/// </summary>
|
||||
public ICommand SaveStoreCommand { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the command for locking all open secure stores.
|
||||
/// </summary>
|
||||
public ICommand LockAllStoresCommand { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the command for generating a new key file.
|
||||
/// </summary>
|
||||
public ICommand GenerateKeyFileCommand { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the command for adding a secret to the current store.
|
||||
/// </summary>
|
||||
public ICommand AddSecretCommand { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the command for deleting a secret from the current store.
|
||||
/// </summary>
|
||||
public ICommand DeleteSecretCommand { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="MainWindowViewModel"/> class.
|
||||
/// </summary>
|
||||
@@ -138,6 +192,8 @@ public class MainWindowViewModel : ViewModelBase
|
||||
/// <param name="backupService">Service for creating configuration backups.</param>
|
||||
/// <param name="autoDiscoveryService">Service for discovering configuration folder locations.</param>
|
||||
/// <param name="dialogService">Service for showing platform dialogs.</param>
|
||||
/// <param name="secureStoreManager">Service for managing encrypted secret stores.</param>
|
||||
/// <param name="clipboardService">Service for clipboard operations.</param>
|
||||
/// <param name="logger">Optional logger for recording view model activities.</param>
|
||||
public MainWindowViewModel(
|
||||
IFileSystem fileSystem,
|
||||
@@ -146,6 +202,8 @@ public class MainWindowViewModel : ViewModelBase
|
||||
IBackupService backupService,
|
||||
IAutoDiscoveryService autoDiscoveryService,
|
||||
IDialogService? dialogService,
|
||||
ISecureStoreManager secureStoreManager,
|
||||
IClipboardService clipboardService,
|
||||
ILogger<MainWindowViewModel>? logger)
|
||||
{
|
||||
_fileSystem = fileSystem;
|
||||
@@ -154,6 +212,8 @@ public class MainWindowViewModel : ViewModelBase
|
||||
_backupService = backupService;
|
||||
_autoDiscoveryService = autoDiscoveryService;
|
||||
_dialogService = dialogService;
|
||||
_secureStoreManager = secureStoreManager;
|
||||
_clipboardService = clipboardService;
|
||||
_logger = logger;
|
||||
|
||||
OpenFolderCommand = new AsyncRelayCommand(OpenFolderAsync);
|
||||
@@ -164,6 +224,17 @@ public class MainWindowViewModel : ViewModelBase
|
||||
ValidateCommand = new RelayCommand(Validate);
|
||||
TestConnectionCommand = new AsyncRelayCommand(TestConnectionAsync);
|
||||
|
||||
// SecureStore commands
|
||||
NewStoreCommand = new AsyncRelayCommand(NewStoreAsync);
|
||||
AddExistingStoreCommand = new AsyncRelayCommand(AddExistingStoreAsync);
|
||||
UnlockStoreCommand = new AsyncRelayCommand(UnlockStoreAsync, CanUnlockStore);
|
||||
LockStoreCommand = new RelayCommand(LockStore, CanLockStore);
|
||||
SaveStoreCommand = new AsyncRelayCommand(SaveStoreAsync, CanSaveStore);
|
||||
LockAllStoresCommand = new RelayCommand(LockAllStores, () => _openStores.Count > 0);
|
||||
GenerateKeyFileCommand = new AsyncRelayCommand(GenerateKeyFileAsync);
|
||||
AddSecretCommand = new AsyncRelayCommand(AddSecretAsync, CanAddSecret);
|
||||
DeleteSecretCommand = new AsyncRelayCommand(DeleteSecretAsync, CanDeleteSecret);
|
||||
|
||||
_ = InitializeAsync();
|
||||
}
|
||||
|
||||
@@ -177,10 +248,20 @@ public class MainWindowViewModel : ViewModelBase
|
||||
new BackupService(new FileSystem()),
|
||||
new AutoDiscoveryService(new FileSystem()),
|
||||
null,
|
||||
new SecureStoreManager(),
|
||||
new NullClipboardService(),
|
||||
null)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Null implementation of clipboard service for design-time.
|
||||
/// </summary>
|
||||
private class NullClipboardService : IClipboardService
|
||||
{
|
||||
public Task SetTextAsync(string text) => Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes the view model by auto-discovering and loading configuration.
|
||||
/// </summary>
|
||||
@@ -269,6 +350,49 @@ public class MainWindowViewModel : ViewModelBase
|
||||
}
|
||||
}
|
||||
TreeNodes.Add(pipelinesFolder);
|
||||
|
||||
// Secure Stores folder
|
||||
var secureStoresFolder = new TreeNodeViewModel("Secure Stores", "key", TreeNodeType.SecureStoresFolder) { IsExpanded = true };
|
||||
DiscoverSecureStores(secureStoresFolder);
|
||||
TreeNodes.Add(secureStoresFolder);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Discovers existing secure store files in the configuration folder.
|
||||
/// </summary>
|
||||
/// <param name="parentNode">The parent tree node to add discovered stores to.</param>
|
||||
private void DiscoverSecureStores(TreeNodeViewModel parentNode)
|
||||
{
|
||||
if (string.IsNullOrEmpty(ConfigFolderPath) || ConfigFolderPath == "No folder selected")
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
// Look for *.secrets.json files in the config folder
|
||||
var secretsFiles = Directory.GetFiles(ConfigFolderPath, "*.secrets.json", SearchOption.TopDirectoryOnly);
|
||||
|
||||
foreach (var filePath in secretsFiles)
|
||||
{
|
||||
var fileName = Path.GetFileName(filePath);
|
||||
var storeName = Path.GetFileNameWithoutExtension(fileName);
|
||||
if (storeName.EndsWith(".secrets"))
|
||||
storeName = storeName[..^8]; // Remove ".secrets" suffix for display
|
||||
|
||||
var storeNode = new TreeNodeViewModel(storeName, "lock", TreeNodeType.SecureStore)
|
||||
{
|
||||
StorePath = filePath,
|
||||
SectionKey = filePath,
|
||||
IsUnlocked = false
|
||||
};
|
||||
parentNode.Children.Add(storeNode);
|
||||
}
|
||||
|
||||
_logger?.LogDebug("Discovered {Count} secure store files", secretsFiles.Length);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger?.LogWarning(ex, "Failed to discover secure store files");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -276,12 +400,51 @@ public class MainWindowViewModel : ViewModelBase
|
||||
/// </summary>
|
||||
private void OnSelectedNodeChanged()
|
||||
{
|
||||
if (_selectedNode == null || _appSettings == null)
|
||||
if (_selectedNode == null)
|
||||
{
|
||||
SelectedFormViewModel = null;
|
||||
_selectedStoreNode = null;
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle SecureStore-related node types first
|
||||
switch (_selectedNode.NodeType)
|
||||
{
|
||||
case TreeNodeType.SecureStoresFolder:
|
||||
SelectedFormViewModel = null; // Show empty state or instructions
|
||||
_selectedStoreNode = null;
|
||||
return;
|
||||
|
||||
case TreeNodeType.SecureStore:
|
||||
_selectedStoreNode = _selectedNode;
|
||||
if (_selectedNode.IsUnlocked)
|
||||
{
|
||||
SelectedFormViewModel = CreateUnlockedStoreFormViewModel(_selectedNode);
|
||||
}
|
||||
else
|
||||
{
|
||||
SelectedFormViewModel = CreateLockedStoreFormViewModel(_selectedNode);
|
||||
}
|
||||
RaiseSecureStoreCommandsCanExecuteChanged();
|
||||
return;
|
||||
|
||||
case TreeNodeType.Secret:
|
||||
// Find the parent store node
|
||||
_selectedStoreNode = FindParentStoreNode(_selectedNode);
|
||||
SelectedFormViewModel = CreateSecretFormViewModel(_selectedNode);
|
||||
RaiseSecureStoreCommandsCanExecuteChanged();
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle standard configuration sections
|
||||
if (_appSettings == null)
|
||||
{
|
||||
SelectedFormViewModel = null;
|
||||
_selectedStoreNode = null;
|
||||
return;
|
||||
}
|
||||
|
||||
_selectedStoreNode = null;
|
||||
SelectedFormViewModel = _selectedNode.SectionKey switch
|
||||
{
|
||||
"DataSync" => new DataSyncFormViewModel(_appSettings.DataSync, MarkAsChanged),
|
||||
@@ -296,6 +459,116 @@ public class MainWindowViewModel : ViewModelBase
|
||||
: null,
|
||||
_ => null
|
||||
};
|
||||
RaiseSecureStoreCommandsCanExecuteChanged();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a form view model for a locked secure store.
|
||||
/// </summary>
|
||||
private SecureStoreLockedFormViewModel CreateLockedStoreFormViewModel(TreeNodeViewModel storeNode)
|
||||
{
|
||||
DateTime? lastModified = null;
|
||||
if (!string.IsNullOrEmpty(storeNode.StorePath) && File.Exists(storeNode.StorePath))
|
||||
{
|
||||
lastModified = File.GetLastWriteTime(storeNode.StorePath);
|
||||
}
|
||||
|
||||
return new SecureStoreLockedFormViewModel(
|
||||
storeNode.Name,
|
||||
storeNode.StorePath ?? string.Empty,
|
||||
lastModified,
|
||||
() => _ = UnlockStoreAsync());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a form view model for an unlocked secure store.
|
||||
/// </summary>
|
||||
private SecureStoreUnlockedFormViewModel CreateUnlockedStoreFormViewModel(TreeNodeViewModel storeNode)
|
||||
{
|
||||
var secretCount = storeNode.Children.Count;
|
||||
var hasUnsavedChanges = _secureStoreManager.HasUnsavedChanges;
|
||||
|
||||
return new SecureStoreUnlockedFormViewModel(
|
||||
storeNode.Name,
|
||||
storeNode.StorePath ?? string.Empty,
|
||||
secretCount,
|
||||
hasUnsavedChanges,
|
||||
() => LockStore(),
|
||||
() => _ = AddSecretAsync(),
|
||||
() => _ = SaveStoreAsync());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a form view model for a secret.
|
||||
/// </summary>
|
||||
private SecretFormViewModel? CreateSecretFormViewModel(TreeNodeViewModel secretNode)
|
||||
{
|
||||
if (string.IsNullOrEmpty(secretNode.SecretKey))
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
var value = _secureStoreManager.GetSecret(secretNode.SecretKey);
|
||||
return new SecretFormViewModel(
|
||||
secretNode.SecretKey,
|
||||
value,
|
||||
_clipboardService,
|
||||
newValue => OnSecretValueChanged(secretNode.SecretKey, newValue),
|
||||
() => _ = DeleteSecretAsync());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger?.LogError(ex, "Failed to get secret value for key {Key}", secretNode.SecretKey);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called when a secret value changes in the form.
|
||||
/// </summary>
|
||||
private void OnSecretValueChanged(string key, string newValue)
|
||||
{
|
||||
try
|
||||
{
|
||||
_secureStoreManager.SetSecret(key, newValue);
|
||||
_logger?.LogDebug("Secret {Key} value updated", key);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger?.LogError(ex, "Failed to update secret {Key}", key);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finds the parent store node for a secret node.
|
||||
/// </summary>
|
||||
private TreeNodeViewModel? FindParentStoreNode(TreeNodeViewModel secretNode)
|
||||
{
|
||||
foreach (var rootNode in TreeNodes)
|
||||
{
|
||||
if (rootNode.NodeType == TreeNodeType.SecureStoresFolder)
|
||||
{
|
||||
foreach (var storeNode in rootNode.Children)
|
||||
{
|
||||
if (storeNode.Children.Contains(secretNode))
|
||||
return storeNode;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Raises CanExecuteChanged for all SecureStore commands.
|
||||
/// </summary>
|
||||
private void RaiseSecureStoreCommandsCanExecuteChanged()
|
||||
{
|
||||
(UnlockStoreCommand as AsyncRelayCommand)?.RaiseCanExecuteChanged();
|
||||
(LockStoreCommand as RelayCommand)?.RaiseCanExecuteChanged();
|
||||
(SaveStoreCommand as AsyncRelayCommand)?.RaiseCanExecuteChanged();
|
||||
(LockAllStoresCommand as RelayCommand)?.RaiseCanExecuteChanged();
|
||||
(AddSecretCommand as AsyncRelayCommand)?.RaiseCanExecuteChanged();
|
||||
(DeleteSecretCommand as AsyncRelayCommand)?.RaiseCanExecuteChanged();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -408,4 +681,354 @@ public class MainWindowViewModel : ViewModelBase
|
||||
_logger?.LogInformation("Test connection requested");
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
#region SecureStore Commands
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether a store can be unlocked.
|
||||
/// </summary>
|
||||
private bool CanUnlockStore()
|
||||
{
|
||||
return _selectedStoreNode != null
|
||||
&& _selectedStoreNode.NodeType == TreeNodeType.SecureStore
|
||||
&& !_selectedStoreNode.IsUnlocked;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether a store can be locked.
|
||||
/// </summary>
|
||||
private bool CanLockStore()
|
||||
{
|
||||
return _selectedStoreNode != null
|
||||
&& _selectedStoreNode.NodeType == TreeNodeType.SecureStore
|
||||
&& _selectedStoreNode.IsUnlocked;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether the current store can be saved.
|
||||
/// </summary>
|
||||
private bool CanSaveStore()
|
||||
{
|
||||
return _selectedStoreNode != null
|
||||
&& _selectedStoreNode.IsUnlocked
|
||||
&& _secureStoreManager.HasUnsavedChanges;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether a secret can be added.
|
||||
/// </summary>
|
||||
private bool CanAddSecret()
|
||||
{
|
||||
return _selectedStoreNode != null
|
||||
&& _selectedStoreNode.IsUnlocked;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether a secret can be deleted.
|
||||
/// </summary>
|
||||
private bool CanDeleteSecret()
|
||||
{
|
||||
return _selectedNode != null
|
||||
&& _selectedNode.NodeType == TreeNodeType.Secret
|
||||
&& _selectedStoreNode != null
|
||||
&& _selectedStoreNode.IsUnlocked;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new secure store.
|
||||
/// </summary>
|
||||
private async Task NewStoreAsync()
|
||||
{
|
||||
if (_dialogService == null)
|
||||
{
|
||||
_logger?.LogWarning("Dialog service is not available");
|
||||
return;
|
||||
}
|
||||
|
||||
// Note: In a full implementation, this would show the NewStoreDialog
|
||||
// For now, we use a simple message dialog to inform the user
|
||||
// A proper implementation would wire up the NewStoreDialogViewModel events
|
||||
await _dialogService.ShowMessageAsync(
|
||||
"New Store",
|
||||
"New store creation dialog not yet implemented. Use Add Existing Store instead.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds an existing secure store to the tree.
|
||||
/// </summary>
|
||||
private async Task AddExistingStoreAsync()
|
||||
{
|
||||
if (_dialogService == null)
|
||||
{
|
||||
_logger?.LogWarning("Dialog service is not available");
|
||||
return;
|
||||
}
|
||||
|
||||
// Note: In a full implementation, this would show a file picker
|
||||
await _dialogService.ShowMessageAsync(
|
||||
"Add Existing Store",
|
||||
"File picker for existing stores not yet implemented.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Unlocks the currently selected secure store.
|
||||
/// </summary>
|
||||
private async Task UnlockStoreAsync()
|
||||
{
|
||||
if (_selectedStoreNode == null || string.IsNullOrEmpty(_selectedStoreNode.StorePath))
|
||||
return;
|
||||
|
||||
if (_dialogService == null)
|
||||
{
|
||||
_logger?.LogWarning("Dialog service is not available");
|
||||
return;
|
||||
}
|
||||
|
||||
// Note: In a full implementation, this would show the UnlockStoreDialog
|
||||
// For now, we simulate with a simple confirmation
|
||||
var confirmed = await _dialogService.ShowConfirmationAsync(
|
||||
"Unlock Store",
|
||||
$"Enter credentials to unlock '{_selectedStoreNode.Name}'.\n\n(Dialog not yet implemented - this is a placeholder)");
|
||||
|
||||
if (!confirmed)
|
||||
return;
|
||||
|
||||
// In a real implementation, we would get the key file path or password from the dialog
|
||||
// For now, we look for a .key file with the same name
|
||||
var keyFilePath = Path.ChangeExtension(_selectedStoreNode.StorePath, ".key");
|
||||
if (!File.Exists(keyFilePath))
|
||||
{
|
||||
// Try alternate pattern: storename.secrets.key
|
||||
keyFilePath = _selectedStoreNode.StorePath.Replace(".secrets.json", ".secrets.key");
|
||||
}
|
||||
|
||||
if (!File.Exists(keyFilePath))
|
||||
{
|
||||
await _dialogService.ShowMessageAsync(
|
||||
SecureStoreStrings.ErrorTitle,
|
||||
$"Key file not found. Expected at:\n{keyFilePath}");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_secureStoreManager.OpenStore(_selectedStoreNode.StorePath, keyFilePath);
|
||||
_selectedStoreNode.IsUnlocked = true;
|
||||
_openStores[_selectedStoreNode.StorePath] = _selectedStoreNode;
|
||||
|
||||
// Populate secret children
|
||||
RefreshStoreChildren(_selectedStoreNode);
|
||||
_selectedStoreNode.IsExpanded = true;
|
||||
|
||||
// Refresh the form view
|
||||
OnSelectedNodeChanged();
|
||||
|
||||
_logger?.LogInformation("Store unlocked: {StorePath}", _selectedStoreNode.StorePath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger?.LogError(ex, "Failed to unlock store: {StorePath}", _selectedStoreNode.StorePath);
|
||||
await _dialogService.ShowMessageAsync(
|
||||
SecureStoreStrings.ErrorTitle,
|
||||
string.Format(SecureStoreStrings.FailedToOpenStoreFormat, ex.Message));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Locks the currently selected secure store.
|
||||
/// </summary>
|
||||
private void LockStore()
|
||||
{
|
||||
if (_selectedStoreNode == null || !_selectedStoreNode.IsUnlocked)
|
||||
return;
|
||||
|
||||
LockStoreInternal(_selectedStoreNode);
|
||||
OnSelectedNodeChanged();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Internal method to lock a store and clean up.
|
||||
/// </summary>
|
||||
private void LockStoreInternal(TreeNodeViewModel storeNode)
|
||||
{
|
||||
if (storeNode.StorePath != null)
|
||||
{
|
||||
_openStores.Remove(storeNode.StorePath);
|
||||
}
|
||||
|
||||
// Check if this is the currently open store in the manager
|
||||
if (_secureStoreManager.IsStoreOpen &&
|
||||
_secureStoreManager.CurrentStorePath == storeNode.StorePath)
|
||||
{
|
||||
_secureStoreManager.CloseStore();
|
||||
}
|
||||
|
||||
storeNode.IsUnlocked = false;
|
||||
storeNode.Children.Clear();
|
||||
|
||||
_logger?.LogInformation("Store locked: {StorePath}", storeNode.StorePath);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Saves the currently open secure store.
|
||||
/// </summary>
|
||||
private async Task SaveStoreAsync()
|
||||
{
|
||||
if (!_secureStoreManager.IsStoreOpen)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
_secureStoreManager.Save();
|
||||
|
||||
// Refresh the form to update the HasUnsavedChanges state
|
||||
OnSelectedNodeChanged();
|
||||
RaiseSecureStoreCommandsCanExecuteChanged();
|
||||
|
||||
_logger?.LogInformation("Store saved: {StorePath}", _secureStoreManager.CurrentStorePath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger?.LogError(ex, "Failed to save store");
|
||||
if (_dialogService != null)
|
||||
{
|
||||
await _dialogService.ShowMessageAsync(
|
||||
SecureStoreStrings.ErrorTitle,
|
||||
string.Format(SecureStoreStrings.FailedToSaveStoreFormat, ex.Message));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Locks all open secure stores.
|
||||
/// </summary>
|
||||
private void LockAllStores()
|
||||
{
|
||||
var openStoresCopy = _openStores.Values.ToList();
|
||||
foreach (var storeNode in openStoresCopy)
|
||||
{
|
||||
LockStoreInternal(storeNode);
|
||||
}
|
||||
|
||||
OnSelectedNodeChanged();
|
||||
_logger?.LogInformation("All stores locked");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates a new key file.
|
||||
/// </summary>
|
||||
private async Task GenerateKeyFileAsync()
|
||||
{
|
||||
if (_dialogService == null)
|
||||
{
|
||||
_logger?.LogWarning("Dialog service is not available");
|
||||
return;
|
||||
}
|
||||
|
||||
// Note: In a full implementation, this would show a save file dialog
|
||||
await _dialogService.ShowMessageAsync(
|
||||
SecureStoreStrings.GenerateKeyFileTitle,
|
||||
"Key file generation dialog not yet implemented.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a new secret to the current store.
|
||||
/// </summary>
|
||||
private async Task AddSecretAsync()
|
||||
{
|
||||
if (_selectedStoreNode == null || !_selectedStoreNode.IsUnlocked)
|
||||
return;
|
||||
|
||||
if (_dialogService == null)
|
||||
{
|
||||
_logger?.LogWarning("Dialog service is not available");
|
||||
return;
|
||||
}
|
||||
|
||||
// Note: In a full implementation, this would show the SecretEditDialog
|
||||
// For demonstration, we'll show a message
|
||||
await _dialogService.ShowMessageAsync(
|
||||
"Add Secret",
|
||||
"Secret edit dialog not yet implemented.\n\nTo add secrets, the SecretEditDialog must be wired up.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes the currently selected secret.
|
||||
/// </summary>
|
||||
private async Task DeleteSecretAsync()
|
||||
{
|
||||
if (_selectedNode == null ||
|
||||
_selectedNode.NodeType != TreeNodeType.Secret ||
|
||||
string.IsNullOrEmpty(_selectedNode.SecretKey))
|
||||
return;
|
||||
|
||||
if (_dialogService == null)
|
||||
{
|
||||
_logger?.LogWarning("Dialog service is not available");
|
||||
return;
|
||||
}
|
||||
|
||||
var confirmed = await _dialogService.ShowConfirmationAsync(
|
||||
SecureStoreStrings.ConfirmDeleteTitle,
|
||||
string.Format(SecureStoreStrings.ConfirmDeleteFormat, _selectedNode.SecretKey));
|
||||
|
||||
if (!confirmed)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
_secureStoreManager.RemoveSecret(_selectedNode.SecretKey);
|
||||
|
||||
// Remove from tree
|
||||
if (_selectedStoreNode != null)
|
||||
{
|
||||
_selectedStoreNode.Children.Remove(_selectedNode);
|
||||
|
||||
// Select the parent store node
|
||||
SelectedNode = _selectedStoreNode;
|
||||
}
|
||||
|
||||
_logger?.LogInformation("Secret deleted: {Key}", _selectedNode.SecretKey);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger?.LogError(ex, "Failed to delete secret: {Key}", _selectedNode.SecretKey);
|
||||
await _dialogService.ShowMessageAsync(
|
||||
SecureStoreStrings.ErrorTitle,
|
||||
string.Format(SecureStoreStrings.FailedToDeleteSecretFormat, ex.Message));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Refreshes the secret children of a store node.
|
||||
/// </summary>
|
||||
private void RefreshStoreChildren(TreeNodeViewModel storeNode)
|
||||
{
|
||||
storeNode.Children.Clear();
|
||||
|
||||
if (!_secureStoreManager.IsStoreOpen)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
var keys = _secureStoreManager.GetKeys();
|
||||
foreach (var key in keys.OrderBy(k => k))
|
||||
{
|
||||
var secretNode = new TreeNodeViewModel(key, "key", TreeNodeType.Secret)
|
||||
{
|
||||
SecretKey = key,
|
||||
SectionKey = key
|
||||
};
|
||||
storeNode.Children.Add(secretNode);
|
||||
}
|
||||
|
||||
_logger?.LogDebug("Refreshed {Count} secrets for store", keys.Count);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger?.LogError(ex, "Failed to refresh store children");
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
@@ -6,7 +6,10 @@ public enum TreeNodeType
|
||||
{
|
||||
Folder,
|
||||
SettingsSection,
|
||||
Pipeline
|
||||
Pipeline,
|
||||
SecureStoresFolder, // The "Secure Stores" folder
|
||||
SecureStore, // Individual store files
|
||||
Secret // Individual secrets within a store
|
||||
}
|
||||
|
||||
public enum ValidationState
|
||||
@@ -26,6 +29,7 @@ public class TreeNodeViewModel : ViewModelBase
|
||||
private bool _isExpanded;
|
||||
private bool _isSelected;
|
||||
private ValidationState _validationState = ValidationState.Unknown;
|
||||
private bool _isUnlocked;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the name of the tree node.
|
||||
@@ -47,6 +51,45 @@ public class TreeNodeViewModel : ViewModelBase
|
||||
/// </summary>
|
||||
public string? SectionKey { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether this secure store is currently unlocked.
|
||||
/// Only applicable for SecureStore node types.
|
||||
/// </summary>
|
||||
public bool IsUnlocked
|
||||
{
|
||||
get => _isUnlocked;
|
||||
set
|
||||
{
|
||||
if (SetProperty(ref _isUnlocked, value))
|
||||
{
|
||||
OnPropertyChanged(nameof(LockIcon));
|
||||
OnPropertyChanged(nameof(IsLocked));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether this secure store is locked.
|
||||
/// </summary>
|
||||
public bool IsLocked => !IsUnlocked;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the lock icon for secure store nodes.
|
||||
/// </summary>
|
||||
public string LockIcon => IsUnlocked ? "🔓" : "🔒";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the full path to the secure store file.
|
||||
/// Only applicable for SecureStore node types.
|
||||
/// </summary>
|
||||
public string? StorePath { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the secret key name.
|
||||
/// Only applicable for Secret node types.
|
||||
/// </summary>
|
||||
public string? SecretKey { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the collection of child nodes.
|
||||
/// </summary>
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:JdeScoping.ConfigManager.ViewModels.Dialogs"
|
||||
x:Class="JdeScoping.ConfigManager.Views.Dialogs.NewStoreDialog"
|
||||
x:DataType="vm:NewStoreDialogViewModel"
|
||||
Title="Create New Secure Store"
|
||||
Width="550" Height="480"
|
||||
MinWidth="450" MinHeight="400"
|
||||
Background="#151920"
|
||||
WindowStartupLocation="CenterOwner"
|
||||
CanResize="False"
|
||||
ShowInTaskbar="False">
|
||||
|
||||
<DockPanel>
|
||||
<!-- Header -->
|
||||
<Border DockPanel.Dock="Top" Background="#1C2128" Padding="24,16"
|
||||
BorderBrush="#2D3540" BorderThickness="0,0,0,1">
|
||||
<TextBlock Text="Create New Secure Store"
|
||||
Foreground="#E6EDF5" FontSize="18" FontWeight="SemiBold"/>
|
||||
</Border>
|
||||
|
||||
<!-- Footer -->
|
||||
<Border DockPanel.Dock="Bottom" Background="#1C2128" Padding="24,16"
|
||||
BorderBrush="#2D3540" BorderThickness="0,1,0,0">
|
||||
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Spacing="8">
|
||||
<Button Content="Cancel" Click="CancelButton_Click"
|
||||
Background="Transparent" BorderBrush="#3D4550"
|
||||
Foreground="#9BA8B8" Padding="16,8" MinWidth="80"/>
|
||||
<Button Content="Create" Click="CreateButton_Click"
|
||||
IsEnabled="{Binding IsValid}"
|
||||
Background="#5C9AFF" Foreground="#0D0F12"
|
||||
Padding="16,8" FontWeight="Medium" MinWidth="80"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Content -->
|
||||
<ScrollViewer Background="#151920" Padding="24,16">
|
||||
<StackPanel Spacing="16">
|
||||
<!-- Store Path -->
|
||||
<Border Background="#1C2128" BorderBrush="#2D3540" BorderThickness="1"
|
||||
CornerRadius="4" Padding="16">
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Text="Store Location" Foreground="#E6EDF5"
|
||||
FontWeight="SemiBold" FontSize="14"/>
|
||||
<Grid ColumnDefinitions="*,Auto" Margin="0,4,0,0">
|
||||
<TextBox Grid.Column="0"
|
||||
Text="{Binding StorePath, Mode=TwoWay}"
|
||||
Watermark="Path to new secure store file..."
|
||||
Background="#0D0F12" Foreground="#E6EDF5"
|
||||
BorderBrush="#3D4550" Padding="8"/>
|
||||
<Button Grid.Column="1" Content="Browse..."
|
||||
Command="{Binding BrowseStorePathCommand}"
|
||||
Background="#2D3540" Foreground="#E6EDF5"
|
||||
Margin="8,0,0,0" Padding="12,8"/>
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Encryption Method -->
|
||||
<Border Background="#1C2128" BorderBrush="#2D3540" BorderThickness="1"
|
||||
CornerRadius="4" Padding="16">
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Text="Encryption Method" Foreground="#E6EDF5"
|
||||
FontWeight="SemiBold" FontSize="14"/>
|
||||
<RadioButton Content="Use Key File (recommended for production)"
|
||||
IsChecked="{Binding UseKeyFile, Mode=TwoWay}"
|
||||
Foreground="#9BA8B8" Margin="0,4"/>
|
||||
<RadioButton Content="Use Password"
|
||||
IsChecked="{Binding UsePassword, Mode=TwoWay}"
|
||||
Foreground="#9BA8B8" Margin="0,4"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Key File Settings -->
|
||||
<Border Background="#1C2128" BorderBrush="#2D3540" BorderThickness="1"
|
||||
CornerRadius="4" Padding="16"
|
||||
IsVisible="{Binding UseKeyFile}">
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Text="Key File" Foreground="#E6EDF5"
|
||||
FontWeight="SemiBold" FontSize="14"/>
|
||||
<Grid ColumnDefinitions="*,Auto,Auto" Margin="0,4,0,0">
|
||||
<TextBox Grid.Column="0"
|
||||
Text="{Binding KeyFilePath, Mode=TwoWay}"
|
||||
Watermark="Path to key file..."
|
||||
Background="#0D0F12" Foreground="#E6EDF5"
|
||||
BorderBrush="#3D4550" Padding="8"/>
|
||||
<Button Grid.Column="1" Content="Browse..."
|
||||
Command="{Binding BrowseKeyFilePathCommand}"
|
||||
Background="#2D3540" Foreground="#E6EDF5"
|
||||
Margin="8,0,0,0" Padding="12,8"/>
|
||||
<Button Grid.Column="2" Content="Generate"
|
||||
Command="{Binding GenerateKeyFileCommand}"
|
||||
Background="#3D8C40" Foreground="#E6EDF5"
|
||||
Margin="8,0,0,0" Padding="12,8"/>
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Password Settings -->
|
||||
<Border Background="#1C2128" BorderBrush="#2D3540" BorderThickness="1"
|
||||
CornerRadius="4" Padding="16"
|
||||
IsVisible="{Binding UsePassword}">
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Text="Password" Foreground="#E6EDF5"
|
||||
FontWeight="SemiBold" FontSize="14"/>
|
||||
<Grid ColumnDefinitions="120,*" RowDefinitions="Auto,Auto" Margin="0,4,0,0">
|
||||
<TextBlock Grid.Row="0" Grid.Column="0" Text="Password:"
|
||||
Foreground="#9BA8B8" VerticalAlignment="Center" Margin="0,8"/>
|
||||
<TextBox Grid.Row="0" Grid.Column="1"
|
||||
PasswordChar="*"
|
||||
Text="{Binding Password, Mode=TwoWay}"
|
||||
Background="#0D0F12" Foreground="#E6EDF5"
|
||||
BorderBrush="#3D4550" Padding="8" Margin="0,4"/>
|
||||
<TextBlock Grid.Row="1" Grid.Column="0" Text="Confirm:"
|
||||
Foreground="#9BA8B8" VerticalAlignment="Center" Margin="0,8"/>
|
||||
<TextBox Grid.Row="1" Grid.Column="1"
|
||||
PasswordChar="*"
|
||||
Text="{Binding ConfirmPassword, Mode=TwoWay}"
|
||||
Background="#0D0F12" Foreground="#E6EDF5"
|
||||
BorderBrush="#3D4550" Padding="8" Margin="0,4"/>
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Validation Error -->
|
||||
<TextBlock Text="{Binding ValidationError}"
|
||||
Foreground="#FF6B6B" FontSize="12"
|
||||
IsVisible="{Binding ValidationError, Converter={StaticResource StringToBool}}"
|
||||
Margin="0,4,0,0"/>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</DockPanel>
|
||||
</Window>
|
||||
@@ -0,0 +1,129 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Platform.Storage;
|
||||
using JdeScoping.ConfigManager.Constants;
|
||||
using JdeScoping.ConfigManager.Services.SecureStore;
|
||||
using JdeScoping.ConfigManager.ViewModels.Dialogs;
|
||||
using MsBox.Avalonia;
|
||||
using MsBox.Avalonia.Enums;
|
||||
|
||||
namespace JdeScoping.ConfigManager.Views.Dialogs;
|
||||
|
||||
/// <summary>
|
||||
/// Dialog for creating a new secure store.
|
||||
/// </summary>
|
||||
public partial class NewStoreDialog : Window
|
||||
{
|
||||
private readonly ISecureStoreManager _secureStoreManager;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the view model for this dialog.
|
||||
/// </summary>
|
||||
public NewStoreDialogViewModel ViewModel => (NewStoreDialogViewModel)DataContext!;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="NewStoreDialog"/> class.
|
||||
/// </summary>
|
||||
public NewStoreDialog() : this(new SecureStoreManager())
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="NewStoreDialog"/> class with the specified secure store manager.
|
||||
/// </summary>
|
||||
/// <param name="secureStoreManager">The secure store manager for key generation.</param>
|
||||
public NewStoreDialog(ISecureStoreManager secureStoreManager)
|
||||
{
|
||||
_secureStoreManager = secureStoreManager ?? throw new ArgumentNullException(nameof(secureStoreManager));
|
||||
InitializeComponent();
|
||||
DataContext = new NewStoreDialogViewModel();
|
||||
Loaded += NewStoreDialog_Loaded;
|
||||
}
|
||||
|
||||
private void NewStoreDialog_Loaded(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
ViewModel.OnShowSaveFileDialog += ShowSaveFileDialogAsync;
|
||||
ViewModel.OnGenerateKeyFile += GenerateKeyFileAsync;
|
||||
}
|
||||
|
||||
private async Task<string?> ShowSaveFileDialogAsync(string title, string fileTypeName, string pattern, string defaultExtension)
|
||||
{
|
||||
var file = await StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions
|
||||
{
|
||||
Title = title,
|
||||
DefaultExtension = defaultExtension,
|
||||
FileTypeChoices = new[]
|
||||
{
|
||||
new FilePickerFileType(fileTypeName) { Patterns = new[] { pattern } },
|
||||
new FilePickerFileType(SecureStoreFileExtensions.AllFilesTypeName) { Patterns = new[] { SecureStoreFileExtensions.AllFilesPattern } }
|
||||
}
|
||||
});
|
||||
|
||||
return file?.Path.LocalPath;
|
||||
}
|
||||
|
||||
private async Task<string?> GenerateKeyFileAsync(string title, string fileTypeName, string pattern, string defaultExtension)
|
||||
{
|
||||
var file = await StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions
|
||||
{
|
||||
Title = title,
|
||||
DefaultExtension = defaultExtension,
|
||||
FileTypeChoices = new[]
|
||||
{
|
||||
new FilePickerFileType(fileTypeName) { Patterns = new[] { pattern } },
|
||||
new FilePickerFileType(SecureStoreFileExtensions.AllFilesTypeName) { Patterns = new[] { SecureStoreFileExtensions.AllFilesPattern } }
|
||||
}
|
||||
});
|
||||
|
||||
if (file == null)
|
||||
return null;
|
||||
|
||||
var path = file.Path.LocalPath;
|
||||
|
||||
try
|
||||
{
|
||||
// Generate a new key file using the local SecureStoreManager
|
||||
_secureStoreManager.GenerateKeyFile(path);
|
||||
|
||||
var box = MessageBoxManager.GetMessageBoxStandard(
|
||||
SecureStoreStrings.KeyGeneratedTitle,
|
||||
string.Format(SecureStoreStrings.KeyFileGeneratedFormat, path),
|
||||
ButtonEnum.Ok,
|
||||
MsBox.Avalonia.Enums.Icon.Info);
|
||||
await box.ShowWindowDialogAsync(this);
|
||||
|
||||
return path;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var box = MessageBoxManager.GetMessageBoxStandard(
|
||||
SecureStoreStrings.ErrorTitle,
|
||||
string.Format(SecureStoreStrings.FailedToGenerateKeyFormat, ex.Message),
|
||||
ButtonEnum.Ok,
|
||||
MsBox.Avalonia.Enums.Icon.Error);
|
||||
await box.ShowWindowDialogAsync(this);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async void CreateButton_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (!ViewModel.IsValid)
|
||||
{
|
||||
var box = MessageBoxManager.GetMessageBoxStandard(
|
||||
SecureStoreStrings.ValidationErrorTitle,
|
||||
ViewModel.ValidationError ?? SecureStoreStrings.DefaultValidationError,
|
||||
ButtonEnum.Ok,
|
||||
MsBox.Avalonia.Enums.Icon.Warning);
|
||||
await box.ShowWindowDialogAsync(this);
|
||||
return;
|
||||
}
|
||||
|
||||
Close(true);
|
||||
}
|
||||
|
||||
private void CancelButton_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
Close(false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:JdeScoping.ConfigManager.ViewModels.Dialogs"
|
||||
x:Class="JdeScoping.ConfigManager.Views.Dialogs.SecretEditDialog"
|
||||
x:DataType="vm:SecretEditDialogViewModel"
|
||||
Title="{Binding DialogTitle}"
|
||||
Width="500" Height="320"
|
||||
MinWidth="400" MinHeight="280"
|
||||
Background="#151920"
|
||||
WindowStartupLocation="CenterOwner"
|
||||
CanResize="False"
|
||||
ShowInTaskbar="False">
|
||||
|
||||
<DockPanel>
|
||||
<!-- Header -->
|
||||
<Border DockPanel.Dock="Top" Background="#1C2128" Padding="24,16"
|
||||
BorderBrush="#2D3540" BorderThickness="0,0,0,1">
|
||||
<TextBlock Text="{Binding DialogTitle}"
|
||||
Foreground="#E6EDF5" FontSize="18" FontWeight="SemiBold"/>
|
||||
</Border>
|
||||
|
||||
<!-- Footer -->
|
||||
<Border DockPanel.Dock="Bottom" Background="#1C2128" Padding="24,16"
|
||||
BorderBrush="#2D3540" BorderThickness="0,1,0,0">
|
||||
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Spacing="8">
|
||||
<Button Content="Cancel" Click="CancelButton_Click"
|
||||
Background="Transparent" BorderBrush="#3D4550"
|
||||
Foreground="#9BA8B8" Padding="16,8" MinWidth="80"/>
|
||||
<Button Content="Save" Click="SaveButton_Click"
|
||||
IsEnabled="{Binding IsValid}"
|
||||
Background="#5C9AFF" Foreground="#0D0F12"
|
||||
Padding="16,8" FontWeight="Medium" MinWidth="80"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Content -->
|
||||
<Grid Background="#151920" Margin="24,16" RowDefinitions="Auto,*,Auto">
|
||||
<!-- Key -->
|
||||
<Border Grid.Row="0" Background="#1C2128" BorderBrush="#2D3540" BorderThickness="1"
|
||||
CornerRadius="4" Padding="16" Margin="0,0,0,16">
|
||||
<Grid ColumnDefinitions="80,*">
|
||||
<TextBlock Text="Key:" Foreground="#9BA8B8"
|
||||
VerticalAlignment="Center" Margin="0,0,16,0"/>
|
||||
<TextBox Grid.Column="1"
|
||||
Text="{Binding Key, Mode=TwoWay}"
|
||||
IsEnabled="{Binding IsKeyEditable}"
|
||||
Watermark="Enter secret key..."
|
||||
Background="#0D0F12" Foreground="#E6EDF5"
|
||||
BorderBrush="#3D4550" Padding="8"/>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- Value -->
|
||||
<Border Grid.Row="1" Background="#1C2128" BorderBrush="#2D3540" BorderThickness="1"
|
||||
CornerRadius="4" Padding="16">
|
||||
<Grid ColumnDefinitions="80,*">
|
||||
<TextBlock Text="Value:" Foreground="#9BA8B8"
|
||||
VerticalAlignment="Top" Margin="0,8,16,0"/>
|
||||
<TextBox Grid.Column="1"
|
||||
Text="{Binding Value, Mode=TwoWay}"
|
||||
TextWrapping="Wrap"
|
||||
AcceptsReturn="True"
|
||||
Watermark="Enter secret value..."
|
||||
Background="#0D0F12" Foreground="#E6EDF5"
|
||||
BorderBrush="#3D4550" Padding="8"
|
||||
MinHeight="80"/>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- Validation Error -->
|
||||
<TextBlock Grid.Row="2"
|
||||
Text="{Binding ValidationError}"
|
||||
Foreground="#FF6B6B" FontSize="12"
|
||||
IsVisible="{Binding ValidationError, Converter={StaticResource StringToBool}}"
|
||||
Margin="0,8,0,0"/>
|
||||
</Grid>
|
||||
</DockPanel>
|
||||
</Window>
|
||||
@@ -0,0 +1,60 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using JdeScoping.ConfigManager.Constants;
|
||||
using JdeScoping.ConfigManager.ViewModels.Dialogs;
|
||||
using MsBox.Avalonia;
|
||||
using MsBox.Avalonia.Enums;
|
||||
|
||||
namespace JdeScoping.ConfigManager.Views.Dialogs;
|
||||
|
||||
/// <summary>
|
||||
/// Dialog for adding or editing a secret in the secure store.
|
||||
/// </summary>
|
||||
public partial class SecretEditDialog : Window
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the view model for this dialog.
|
||||
/// </summary>
|
||||
public SecretEditDialogViewModel ViewModel => (SecretEditDialogViewModel)DataContext!;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SecretEditDialog"/> class for creating a new secret.
|
||||
/// </summary>
|
||||
public SecretEditDialog()
|
||||
{
|
||||
InitializeComponent();
|
||||
DataContext = new SecretEditDialogViewModel();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SecretEditDialog"/> class for editing an existing secret.
|
||||
/// </summary>
|
||||
/// <param name="key">The secret key.</param>
|
||||
/// <param name="value">The secret value.</param>
|
||||
public SecretEditDialog(string key, string value)
|
||||
{
|
||||
InitializeComponent();
|
||||
DataContext = new SecretEditDialogViewModel(key, value);
|
||||
}
|
||||
|
||||
private async void SaveButton_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (!ViewModel.IsValid)
|
||||
{
|
||||
var box = MessageBoxManager.GetMessageBoxStandard(
|
||||
SecureStoreStrings.ValidationErrorTitle,
|
||||
ViewModel.ValidationError ?? SecureStoreStrings.DefaultValidationError,
|
||||
ButtonEnum.Ok,
|
||||
MsBox.Avalonia.Enums.Icon.Warning);
|
||||
await box.ShowWindowDialogAsync(this);
|
||||
return;
|
||||
}
|
||||
|
||||
Close(true);
|
||||
}
|
||||
|
||||
private void CancelButton_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
Close(false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:JdeScoping.ConfigManager.ViewModels.Dialogs"
|
||||
x:Class="JdeScoping.ConfigManager.Views.Dialogs.UnlockStoreDialog"
|
||||
x:DataType="vm:UnlockStoreDialogViewModel"
|
||||
Title="Unlock Secure Store"
|
||||
Width="500" Height="400"
|
||||
MinWidth="400" MinHeight="350"
|
||||
Background="#151920"
|
||||
WindowStartupLocation="CenterOwner"
|
||||
CanResize="False"
|
||||
ShowInTaskbar="False">
|
||||
|
||||
<DockPanel>
|
||||
<!-- Header -->
|
||||
<Border DockPanel.Dock="Top" Background="#1C2128" Padding="24,16"
|
||||
BorderBrush="#2D3540" BorderThickness="0,0,0,1">
|
||||
<TextBlock Text="Unlock Secure Store"
|
||||
Foreground="#E6EDF5" FontSize="18" FontWeight="SemiBold"/>
|
||||
</Border>
|
||||
|
||||
<!-- Footer -->
|
||||
<Border DockPanel.Dock="Bottom" Background="#1C2128" Padding="24,16"
|
||||
BorderBrush="#2D3540" BorderThickness="0,1,0,0">
|
||||
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Spacing="8">
|
||||
<Button Content="Cancel" Click="CancelButton_Click"
|
||||
Background="Transparent" BorderBrush="#3D4550"
|
||||
Foreground="#9BA8B8" Padding="16,8" MinWidth="80"/>
|
||||
<Button Content="Unlock" Click="UnlockButton_Click"
|
||||
IsEnabled="{Binding IsValid}"
|
||||
Background="#5C9AFF" Foreground="#0D0F12"
|
||||
Padding="16,8" FontWeight="Medium" MinWidth="80"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Content -->
|
||||
<ScrollViewer Background="#151920" Padding="24,16">
|
||||
<StackPanel Spacing="16">
|
||||
<!-- Store Path (Read-Only) -->
|
||||
<Border Background="#1C2128" BorderBrush="#2D3540" BorderThickness="1"
|
||||
CornerRadius="4" Padding="16">
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Text="Store File" Foreground="#E6EDF5"
|
||||
FontWeight="SemiBold" FontSize="14"/>
|
||||
<TextBox Text="{Binding StorePath}"
|
||||
IsReadOnly="True"
|
||||
Background="#0D0F12" Foreground="#9BA8B8"
|
||||
BorderBrush="#3D4550" Padding="8" Margin="0,4,0,0"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Decryption Method -->
|
||||
<Border Background="#1C2128" BorderBrush="#2D3540" BorderThickness="1"
|
||||
CornerRadius="4" Padding="16">
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Text="Decryption Method" Foreground="#E6EDF5"
|
||||
FontWeight="SemiBold" FontSize="14"/>
|
||||
<RadioButton Content="Use Key File"
|
||||
IsChecked="{Binding UseKeyFile, Mode=TwoWay}"
|
||||
Foreground="#9BA8B8" Margin="0,4"/>
|
||||
<RadioButton Content="Use Password"
|
||||
IsChecked="{Binding UsePassword, Mode=TwoWay}"
|
||||
Foreground="#9BA8B8" Margin="0,4"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Key File Settings -->
|
||||
<Border Background="#1C2128" BorderBrush="#2D3540" BorderThickness="1"
|
||||
CornerRadius="4" Padding="16"
|
||||
IsVisible="{Binding UseKeyFile}">
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Text="Key File" Foreground="#E6EDF5"
|
||||
FontWeight="SemiBold" FontSize="14"/>
|
||||
<Grid ColumnDefinitions="*,Auto" Margin="0,4,0,0">
|
||||
<TextBox Grid.Column="0"
|
||||
Text="{Binding KeyFilePath, Mode=TwoWay}"
|
||||
Watermark="Path to key file..."
|
||||
Background="#0D0F12" Foreground="#E6EDF5"
|
||||
BorderBrush="#3D4550" Padding="8"/>
|
||||
<Button Grid.Column="1" Content="Browse..."
|
||||
Command="{Binding BrowseKeyFilePathCommand}"
|
||||
Background="#2D3540" Foreground="#E6EDF5"
|
||||
Margin="8,0,0,0" Padding="12,8"/>
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Password Settings -->
|
||||
<Border Background="#1C2128" BorderBrush="#2D3540" BorderThickness="1"
|
||||
CornerRadius="4" Padding="16"
|
||||
IsVisible="{Binding UsePassword}">
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Text="Password" Foreground="#E6EDF5"
|
||||
FontWeight="SemiBold" FontSize="14"/>
|
||||
<Grid ColumnDefinitions="100,*" Margin="0,4,0,0">
|
||||
<TextBlock Text="Password:" Foreground="#9BA8B8"
|
||||
VerticalAlignment="Center"/>
|
||||
<TextBox Grid.Column="1"
|
||||
PasswordChar="*"
|
||||
Text="{Binding Password, Mode=TwoWay}"
|
||||
Background="#0D0F12" Foreground="#E6EDF5"
|
||||
BorderBrush="#3D4550" Padding="8" Margin="0,4"/>
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Validation Error -->
|
||||
<TextBlock Text="{Binding ValidationError}"
|
||||
Foreground="#FF6B6B" FontSize="12"
|
||||
IsVisible="{Binding ValidationError, Converter={StaticResource StringToBool}}"
|
||||
Margin="0,4,0,0"/>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</DockPanel>
|
||||
</Window>
|
||||
@@ -0,0 +1,80 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Platform.Storage;
|
||||
using JdeScoping.ConfigManager.Constants;
|
||||
using JdeScoping.ConfigManager.ViewModels.Dialogs;
|
||||
using MsBox.Avalonia;
|
||||
using MsBox.Avalonia.Enums;
|
||||
|
||||
namespace JdeScoping.ConfigManager.Views.Dialogs;
|
||||
|
||||
/// <summary>
|
||||
/// Dialog for unlocking an existing secure store.
|
||||
/// </summary>
|
||||
public partial class UnlockStoreDialog : Window
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the view model for this dialog.
|
||||
/// </summary>
|
||||
public UnlockStoreDialogViewModel ViewModel => (UnlockStoreDialogViewModel)DataContext!;
|
||||
|
||||
/// <summary>
|
||||
/// Design-time constructor. Required for XAML previewer.
|
||||
/// </summary>
|
||||
public UnlockStoreDialog() : this(string.Empty)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="UnlockStoreDialog"/> class.
|
||||
/// </summary>
|
||||
/// <param name="storePath">The path to the store file to unlock.</param>
|
||||
public UnlockStoreDialog(string storePath)
|
||||
{
|
||||
InitializeComponent();
|
||||
DataContext = new UnlockStoreDialogViewModel(storePath);
|
||||
Loaded += UnlockStoreDialog_Loaded;
|
||||
}
|
||||
|
||||
private void UnlockStoreDialog_Loaded(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
ViewModel.OnShowOpenFileDialog += ShowOpenFileDialogAsync;
|
||||
}
|
||||
|
||||
private async Task<string?> ShowOpenFileDialogAsync(string title, string fileTypeName, string pattern)
|
||||
{
|
||||
var files = await StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
|
||||
{
|
||||
Title = title,
|
||||
AllowMultiple = false,
|
||||
FileTypeFilter = new[]
|
||||
{
|
||||
new FilePickerFileType(fileTypeName) { Patterns = new[] { pattern } },
|
||||
new FilePickerFileType(SecureStoreFileExtensions.AllFilesTypeName) { Patterns = new[] { SecureStoreFileExtensions.AllFilesPattern } }
|
||||
}
|
||||
});
|
||||
|
||||
return files.Count > 0 ? files[0].Path.LocalPath : null;
|
||||
}
|
||||
|
||||
private async void UnlockButton_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (!ViewModel.IsValid)
|
||||
{
|
||||
var box = MessageBoxManager.GetMessageBoxStandard(
|
||||
SecureStoreStrings.ValidationErrorTitle,
|
||||
ViewModel.ValidationError ?? SecureStoreStrings.DefaultValidationError,
|
||||
ButtonEnum.Ok,
|
||||
MsBox.Avalonia.Enums.Icon.Warning);
|
||||
await box.ShowWindowDialogAsync(this);
|
||||
return;
|
||||
}
|
||||
|
||||
Close(true);
|
||||
}
|
||||
|
||||
private void CancelButton_Click(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
Close(false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:JdeScoping.ConfigManager.ViewModels.Forms"
|
||||
x:Class="JdeScoping.ConfigManager.Views.Forms.SecretFormView"
|
||||
x:DataType="vm:SecretFormViewModel">
|
||||
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||
<StackPanel Spacing="24" MaxWidth="600">
|
||||
<!-- Header -->
|
||||
<StackPanel>
|
||||
<StackPanel Orientation="Horizontal" Spacing="12">
|
||||
<PathIcon Data="M12 1L3 5V11C3 16.55 6.84 21.74 12 23C17.16 21.74 21 16.55 21 11V5L12 1M12 7C13.4 7 14.8 8.1 14.8 9.5V11C15.4 11 16 11.6 16 12.3V15.8C16 16.4 15.4 17 14.7 17H9.2C8.6 17 8 16.4 8 15.7V12.2C8 11.6 8.6 11 9.2 11V9.5C9.2 8.1 10.6 7 12 7M12 8.2C11.2 8.2 10.5 8.7 10.5 9.5V11H13.5V9.5C13.5 8.7 12.8 8.2 12 8.2Z"
|
||||
Width="24" Height="24" Foreground="#3B82F6"/>
|
||||
<TextBlock Text="Secret Settings"
|
||||
Foreground="#E6EDF5" FontSize="18" FontWeight="SemiBold"
|
||||
VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
<Border Height="1" Background="#2D3540" Margin="0,12,0,0"/>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Secret Details Card -->
|
||||
<Border Background="#0D0F12" BorderBrush="#2D3540" BorderThickness="1"
|
||||
CornerRadius="6" Padding="16">
|
||||
<StackPanel Spacing="16">
|
||||
<TextBlock Text="Secret Details" Foreground="#E6EDF5"
|
||||
FontWeight="SemiBold" FontSize="14"/>
|
||||
|
||||
<!-- Key (Read-only) -->
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Text="Key"
|
||||
Foreground="#9BA8B8" FontSize="12" FontWeight="Medium"/>
|
||||
<TextBox Text="{Binding Key}"
|
||||
Background="#1A1F26" Foreground="#9BA8B8"
|
||||
BorderBrush="#2D3540" Height="36"
|
||||
FontFamily="JetBrains Mono"
|
||||
IsReadOnly="True"/>
|
||||
<TextBlock Text="The unique identifier for this secret (cannot be changed)"
|
||||
Foreground="#5C6A7A" FontSize="11"/>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Value with Show/Hide -->
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Text="Value"
|
||||
Foreground="#9BA8B8" FontSize="12" FontWeight="Medium"/>
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<!-- Masked value display when hidden -->
|
||||
<TextBox Grid.Column="0"
|
||||
Text="{Binding Value}"
|
||||
Background="#232A35" Foreground="#E6EDF5"
|
||||
BorderBrush="#3D4550" Height="36"
|
||||
FontFamily="JetBrains Mono"
|
||||
PasswordChar="*"
|
||||
RevealPassword="{Binding IsValueVisible}"
|
||||
Watermark="Enter secret value..."/>
|
||||
<Button Grid.Column="1"
|
||||
Command="{Binding ToggleVisibilityCommand}"
|
||||
Background="#3D4550" Foreground="#E6EDF5"
|
||||
Padding="12,8" Margin="8,0,0,0"
|
||||
CornerRadius="4" Height="36"
|
||||
VerticalAlignment="Top">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<!-- Eye icon for Show -->
|
||||
<PathIcon Data="M12 9C10.34 9 9 10.34 9 12C9 13.66 10.34 15 12 15C13.66 15 15 13.66 15 12C15 10.34 13.66 9 12 9M12 17C9.24 17 7 14.76 7 12C7 9.24 9.24 7 12 7C14.76 7 17 9.24 17 12C17 14.76 14.76 17 12 17M12 4.5C7 4.5 2.73 7.61 1 12C2.73 16.39 7 19.5 12 19.5C17 19.5 21.27 16.39 23 12C21.27 7.61 17 4.5 12 4.5Z"
|
||||
Width="14" Height="14" Foreground="#E6EDF5"
|
||||
IsVisible="{Binding !IsValueVisible}"/>
|
||||
<!-- Eye-off icon for Hide -->
|
||||
<PathIcon Data="M11.83 9L15 12.16V12C15 10.34 13.66 9 12 9H11.83M7.53 9.8L9.08 11.35C9.03 11.56 9 11.77 9 12C9 13.66 10.34 15 12 15C12.22 15 12.44 14.97 12.65 14.92L14.2 16.47C13.53 16.8 12.79 17 12 17C9.24 17 7 14.76 7 12C7 11.21 7.2 10.47 7.53 9.8M2 4.27L4.28 6.55L4.73 7C3.08 8.3 1.78 10 1 12C2.73 16.39 7 19.5 12 19.5C13.55 19.5 15.03 19.2 16.38 18.66L16.81 19.08L19.73 22L21 20.73L3.27 3M12 7C14.76 7 17 9.24 17 12C17 12.64 16.87 13.26 16.64 13.82L19.57 16.75C21.07 15.5 22.27 13.86 23 12C21.27 7.61 17 4.5 12 4.5C10.6 4.5 9.26 4.75 8 5.2L10.17 7.35C10.74 7.13 11.35 7 12 7Z"
|
||||
Width="14" Height="14" Foreground="#E6EDF5"
|
||||
IsVisible="{Binding IsValueVisible}"/>
|
||||
<TextBlock Text="{Binding VisibilityButtonText}" FontSize="12"/>
|
||||
</StackPanel>
|
||||
</Button>
|
||||
</Grid>
|
||||
<TextBlock Text="The secret value (encrypted at rest)"
|
||||
Foreground="#5C6A7A" FontSize="11"/>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Actions Card -->
|
||||
<Border Background="#0D0F12" BorderBrush="#2D3540" BorderThickness="1"
|
||||
CornerRadius="6" Padding="16">
|
||||
<StackPanel Spacing="16">
|
||||
<TextBlock Text="Actions" Foreground="#E6EDF5"
|
||||
FontWeight="SemiBold" FontSize="14"/>
|
||||
|
||||
<StackPanel Orientation="Horizontal" Spacing="12">
|
||||
<!-- Copy to Clipboard Button -->
|
||||
<Button Command="{Binding CopyToClipboardCommand}"
|
||||
Background="#3B82F6" Foreground="#FFFFFF"
|
||||
Padding="16,10" CornerRadius="6">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<PathIcon Data="M19 21H8V7H19M19 5H8C6.9 5 6 5.9 6 7V21C6 22.1 6.9 23 8 23H19C20.1 23 21 22.1 21 21V7C21 5.9 20.1 5 19 5M16 1H4C2.9 1 2 1.9 2 3V17H4V3H16V1Z"
|
||||
Width="16" Height="16" Foreground="#FFFFFF"/>
|
||||
<TextBlock Text="Copy to Clipboard"/>
|
||||
</StackPanel>
|
||||
</Button>
|
||||
|
||||
<!-- Delete Button -->
|
||||
<Button Command="{Binding DeleteCommand}"
|
||||
Background="#DC2626" Foreground="#FFFFFF"
|
||||
Padding="16,10" CornerRadius="6">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<PathIcon Data="M19 4H15.5L14.5 3H9.5L8.5 4H5V6H19M6 19C6 20.1 6.9 21 8 21H16C17.1 21 18 20.1 18 19V7H6V19Z"
|
||||
Width="16" Height="16" Foreground="#FFFFFF"/>
|
||||
<TextBlock Text="Delete"/>
|
||||
</StackPanel>
|
||||
</Button>
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock Text="Warning: Deleting a secret cannot be undone if the store is saved."
|
||||
Foreground="#FF6B6B" FontSize="11" TextWrapping="Wrap"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,11 @@
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace JdeScoping.ConfigManager.Views.Forms;
|
||||
|
||||
public partial class SecretFormView : UserControl
|
||||
{
|
||||
public SecretFormView()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:JdeScoping.ConfigManager.ViewModels.Forms"
|
||||
x:Class="JdeScoping.ConfigManager.Views.Forms.SecureStoreLockedFormView"
|
||||
x:DataType="vm:SecureStoreLockedFormViewModel">
|
||||
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||
<StackPanel Spacing="24" MaxWidth="600">
|
||||
<!-- Header with Lock Icon -->
|
||||
<StackPanel>
|
||||
<StackPanel Orientation="Horizontal" Spacing="12">
|
||||
<PathIcon Data="M12 17C10.89 17 10 16.1 10 15C10 13.89 10.89 13 12 13C13.11 13 14 13.89 14 15C14 16.1 13.11 17 12 17M18 8C19.1 8 20 8.9 20 10V20C20 21.1 19.1 22 18 22H6C4.9 22 4 21.1 4 20V10C4 8.9 4.9 8 6 8H7V6C7 3.24 9.24 1 12 1C14.76 1 17 3.24 17 6V8H18M12 3C10.34 3 9 4.34 9 6V8H15V6C15 4.34 13.66 3 12 3Z"
|
||||
Width="24" Height="24" Foreground="#FFB84D"/>
|
||||
<TextBlock Text="{Binding StoreName}"
|
||||
Foreground="#E6EDF5" FontSize="18" FontWeight="SemiBold"
|
||||
VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
<Border Height="1" Background="#2D3540" Margin="0,12,0,0"/>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Lock Status Card -->
|
||||
<Border Background="#0D0F12" BorderBrush="#2D3540" BorderThickness="1"
|
||||
CornerRadius="6" Padding="16">
|
||||
<StackPanel Spacing="16">
|
||||
<!-- Status Message -->
|
||||
<StackPanel Orientation="Horizontal" Spacing="12" HorizontalAlignment="Center">
|
||||
<PathIcon Data="M12 17C10.89 17 10 16.1 10 15C10 13.89 10.89 13 12 13C13.11 13 14 13.89 14 15C14 16.1 13.11 17 12 17M18 8C19.1 8 20 8.9 20 10V20C20 21.1 19.1 22 18 22H6C4.9 22 4 21.1 4 20V10C4 8.9 4.9 8 6 8H7V6C7 3.24 9.24 1 12 1C14.76 1 17 3.24 17 6V8H18M12 3C10.34 3 9 4.34 9 6V8H15V6C15 4.34 13.66 3 12 3Z"
|
||||
Width="48" Height="48" Foreground="#5C6A7A"/>
|
||||
</StackPanel>
|
||||
<TextBlock Text="This store is locked"
|
||||
Foreground="#9BA8B8" FontSize="14" FontWeight="Medium"
|
||||
HorizontalAlignment="Center"/>
|
||||
<TextBlock Text="Enter your password to unlock and view secrets"
|
||||
Foreground="#5C6A7A" FontSize="12"
|
||||
HorizontalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Store Information Card -->
|
||||
<Border Background="#0D0F12" BorderBrush="#2D3540" BorderThickness="1"
|
||||
CornerRadius="6" Padding="16">
|
||||
<StackPanel Spacing="16">
|
||||
<TextBlock Text="Store Information" Foreground="#E6EDF5"
|
||||
FontWeight="SemiBold" FontSize="14"/>
|
||||
|
||||
<!-- Store Path -->
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Text="Store Path"
|
||||
Foreground="#9BA8B8" FontSize="12" FontWeight="Medium"/>
|
||||
<TextBox Text="{Binding StorePath}"
|
||||
Background="#232A35" Foreground="#E6EDF5"
|
||||
BorderBrush="#3D4550" Height="36"
|
||||
FontFamily="JetBrains Mono" FontSize="11"
|
||||
IsReadOnly="True"/>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Last Modified -->
|
||||
<StackPanel Spacing="4" IsVisible="{Binding LastModified, Converter={x:Static ObjectConverters.IsNotNull}}">
|
||||
<TextBlock Text="Last Modified"
|
||||
Foreground="#9BA8B8" FontSize="12" FontWeight="Medium"/>
|
||||
<TextBlock Text="{Binding LastModified, StringFormat='{}{0:yyyy-MM-dd HH:mm:ss}'}"
|
||||
Foreground="#E6EDF5" FontSize="13"
|
||||
FontFamily="JetBrains Mono"/>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Unlock Button -->
|
||||
<Button Command="{Binding UnlockCommand}"
|
||||
HorizontalAlignment="Center"
|
||||
Background="#3B82F6" Foreground="#FFFFFF"
|
||||
Padding="24,12" CornerRadius="6"
|
||||
FontWeight="SemiBold">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<PathIcon Data="M18 8C19.1 8 20 8.9 20 10V20C20 21.1 19.1 22 18 22H6C4.9 22 4 21.1 4 20V10C4 8.9 4.9 8 6 8H17V6C17 3.24 14.76 1 12 1C10.07 1 8.39 2.11 7.53 3.71L6.14 2.43C7.34 0.88 9.54 0 12 0C15.87 0 19 3.13 19 7V8H18M12 17C13.11 17 14 16.1 14 15C14 13.89 13.11 13 12 13C10.89 13 10 13.89 10 15C10 16.1 10.89 17 12 17Z"
|
||||
Width="16" Height="16" Foreground="#FFFFFF"/>
|
||||
<TextBlock Text="Unlock Store..."/>
|
||||
</StackPanel>
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,11 @@
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace JdeScoping.ConfigManager.Views.Forms;
|
||||
|
||||
public partial class SecureStoreLockedFormView : UserControl
|
||||
{
|
||||
public SecureStoreLockedFormView()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:JdeScoping.ConfigManager.ViewModels.Forms"
|
||||
x:Class="JdeScoping.ConfigManager.Views.Forms.SecureStoreUnlockedFormView"
|
||||
x:DataType="vm:SecureStoreUnlockedFormViewModel">
|
||||
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||
<StackPanel Spacing="24" MaxWidth="600">
|
||||
<!-- Header with Unlock Icon -->
|
||||
<StackPanel>
|
||||
<StackPanel Orientation="Horizontal" Spacing="12">
|
||||
<PathIcon Data="M18 8C19.1 8 20 8.9 20 10V20C20 21.1 19.1 22 18 22H6C4.9 22 4 21.1 4 20V10C4 8.9 4.9 8 6 8H17V6C17 3.24 14.76 1 12 1C10.07 1 8.39 2.11 7.53 3.71L5.71 2.39C6.83 0.92 9.15 0 12 0C15.87 0 19 3.13 19 7V8H18M12 17C13.11 17 14 16.1 14 15C14 13.89 13.11 13 12 13C10.89 13 10 13.89 10 15C10 16.1 10.89 17 12 17Z"
|
||||
Width="24" Height="24" Foreground="#22C55E"/>
|
||||
<TextBlock Text="{Binding StoreName}"
|
||||
Foreground="#E6EDF5" FontSize="18" FontWeight="SemiBold"
|
||||
VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
<Border Height="1" Background="#2D3540" Margin="0,12,0,0"/>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Store Status Card -->
|
||||
<Border Background="#0D0F12" BorderBrush="#2D3540" BorderThickness="1"
|
||||
CornerRadius="6" Padding="16">
|
||||
<StackPanel Spacing="16">
|
||||
<TextBlock Text="Store Status" Foreground="#E6EDF5"
|
||||
FontWeight="SemiBold" FontSize="14"/>
|
||||
|
||||
<!-- Store Path -->
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Text="Store Path"
|
||||
Foreground="#9BA8B8" FontSize="12" FontWeight="Medium"/>
|
||||
<TextBox Text="{Binding StorePath}"
|
||||
Background="#232A35" Foreground="#E6EDF5"
|
||||
BorderBrush="#3D4550" Height="36"
|
||||
FontFamily="JetBrains Mono" FontSize="11"
|
||||
IsReadOnly="True"/>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Secret Count -->
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Text="Secrets"
|
||||
Foreground="#9BA8B8" FontSize="12" FontWeight="Medium"/>
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<TextBlock Text="{Binding SecretCount}"
|
||||
Foreground="#E6EDF5" FontSize="24" FontWeight="Bold"
|
||||
VerticalAlignment="Center"/>
|
||||
<TextBlock Text="secret(s) stored"
|
||||
Foreground="#5C6A7A" FontSize="13"
|
||||
VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Unsaved Changes Status -->
|
||||
<Border Background="#151920" BorderBrush="#2D3540" BorderThickness="1"
|
||||
CornerRadius="4" Padding="12">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<!-- Status indicator when no unsaved changes -->
|
||||
<PathIcon Data="M12 2C6.48 2 2 6.48 2 12C2 17.52 6.48 22 12 22C17.52 22 22 17.52 22 12C22 6.48 17.52 2 12 2M10 17L5 12L6.41 10.59L10 14.17L17.59 6.58L19 8L10 17Z"
|
||||
Width="20" Height="20" Foreground="#22C55E"
|
||||
IsVisible="{Binding !HasUnsavedChanges}"/>
|
||||
<TextBlock Text="All changes saved"
|
||||
Foreground="#22C55E" FontSize="13" FontWeight="Medium"
|
||||
VerticalAlignment="Center"
|
||||
IsVisible="{Binding !HasUnsavedChanges}"/>
|
||||
|
||||
<!-- Status indicator when there are unsaved changes -->
|
||||
<PathIcon Data="M12 2C6.48 2 2 6.48 2 12C2 17.52 6.48 22 12 22C17.52 22 22 17.52 22 12C22 6.48 17.52 2 12 2M13 17H11V15H13V17M13 13H11V7H13V13Z"
|
||||
Width="20" Height="20" Foreground="#FFB84D"
|
||||
IsVisible="{Binding HasUnsavedChanges}"/>
|
||||
<TextBlock Text="Unsaved changes"
|
||||
Foreground="#FFB84D" FontSize="13" FontWeight="Medium"
|
||||
VerticalAlignment="Center"
|
||||
IsVisible="{Binding HasUnsavedChanges}"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Actions Card -->
|
||||
<Border Background="#0D0F12" BorderBrush="#2D3540" BorderThickness="1"
|
||||
CornerRadius="6" Padding="16">
|
||||
<StackPanel Spacing="16">
|
||||
<TextBlock Text="Actions" Foreground="#E6EDF5"
|
||||
FontWeight="SemiBold" FontSize="14"/>
|
||||
|
||||
<StackPanel Orientation="Horizontal" Spacing="12">
|
||||
<!-- Lock Store Button -->
|
||||
<Button Command="{Binding LockCommand}"
|
||||
Background="#3D4550" Foreground="#E6EDF5"
|
||||
Padding="16,10" CornerRadius="6">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<PathIcon Data="M12 17C10.89 17 10 16.1 10 15C10 13.89 10.89 13 12 13C13.11 13 14 13.89 14 15C14 16.1 13.11 17 12 17M18 8C19.1 8 20 8.9 20 10V20C20 21.1 19.1 22 18 22H6C4.9 22 4 21.1 4 20V10C4 8.9 4.9 8 6 8H7V6C7 3.24 9.24 1 12 1C14.76 1 17 3.24 17 6V8H18M12 3C10.34 3 9 4.34 9 6V8H15V6C15 4.34 13.66 3 12 3Z"
|
||||
Width="16" Height="16" Foreground="#E6EDF5"/>
|
||||
<TextBlock Text="Lock Store"/>
|
||||
</StackPanel>
|
||||
</Button>
|
||||
|
||||
<!-- Add Secret Button -->
|
||||
<Button Command="{Binding AddSecretCommand}"
|
||||
Background="#22C55E" Foreground="#FFFFFF"
|
||||
Padding="16,10" CornerRadius="6">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<PathIcon Data="M19 13H13V19H11V13H5V11H11V5H13V11H19V13Z"
|
||||
Width="16" Height="16" Foreground="#FFFFFF"/>
|
||||
<TextBlock Text="Add Secret"/>
|
||||
</StackPanel>
|
||||
</Button>
|
||||
|
||||
<!-- Save Button -->
|
||||
<Button Command="{Binding SaveCommand}"
|
||||
Background="#3B82F6" Foreground="#FFFFFF"
|
||||
Padding="16,10" CornerRadius="6"
|
||||
IsEnabled="{Binding HasUnsavedChanges}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<PathIcon Data="M15 9H5V5H15M12 19C10.34 19 9 17.66 9 16C9 14.34 10.34 13 12 13C13.66 13 15 14.34 15 16C15 17.66 13.66 19 12 19M17 3H5C3.89 3 3 3.9 3 5V19C3 20.1 3.89 21 5 21H19C20.1 21 21 20.1 21 19V7L17 3Z"
|
||||
Width="16" Height="16" Foreground="#FFFFFF"/>
|
||||
<TextBlock Text="Save"/>
|
||||
</StackPanel>
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</UserControl>
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace JdeScoping.ConfigManager.Views.Forms;
|
||||
|
||||
public partial class SecureStoreUnlockedFormView : UserControl
|
||||
{
|
||||
public SecureStoreUnlockedFormView()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,15 @@
|
||||
<DataTemplate DataType="{x:Type forms:PipelineFormViewModel}">
|
||||
<views:PipelineFormView/>
|
||||
</DataTemplate>
|
||||
<DataTemplate DataType="{x:Type forms:SecureStoreLockedFormViewModel}">
|
||||
<views:SecureStoreLockedFormView/>
|
||||
</DataTemplate>
|
||||
<DataTemplate DataType="{x:Type forms:SecureStoreUnlockedFormViewModel}">
|
||||
<views:SecureStoreUnlockedFormView/>
|
||||
</DataTemplate>
|
||||
<DataTemplate DataType="{x:Type forms:SecretFormViewModel}">
|
||||
<views:SecretFormView/>
|
||||
</DataTemplate>
|
||||
</Window.DataTemplates>
|
||||
|
||||
<DockPanel>
|
||||
@@ -57,6 +66,35 @@
|
||||
<Separator/>
|
||||
<MenuItem Header="View _Backups..."/>
|
||||
</MenuItem>
|
||||
<MenuItem Header="_Secure Stores">
|
||||
<MenuItem Header="_New Store..." Command="{Binding NewStoreCommand}" InputGesture="Ctrl+Shift+N">
|
||||
<MenuItem.Icon>
|
||||
<TextBlock Text="+" FontSize="14" FontWeight="Bold"/>
|
||||
</MenuItem.Icon>
|
||||
</MenuItem>
|
||||
<MenuItem Header="_Add Existing Store..." Command="{Binding AddExistingStoreCommand}" InputGesture="Ctrl+Shift+O">
|
||||
<MenuItem.Icon>
|
||||
<TextBlock Text="..." FontSize="14"/>
|
||||
</MenuItem.Icon>
|
||||
</MenuItem>
|
||||
<Separator/>
|
||||
<MenuItem Header="_Save Store" Command="{Binding SaveStoreCommand}" InputGesture="Ctrl+Shift+S">
|
||||
<MenuItem.Icon>
|
||||
<TextBlock Text="S" FontSize="12" FontWeight="Bold"/>
|
||||
</MenuItem.Icon>
|
||||
</MenuItem>
|
||||
<MenuItem Header="_Lock All Stores" Command="{Binding LockAllStoresCommand}">
|
||||
<MenuItem.Icon>
|
||||
<TextBlock Text="L" FontSize="12" FontWeight="Bold"/>
|
||||
</MenuItem.Icon>
|
||||
</MenuItem>
|
||||
<Separator/>
|
||||
<MenuItem Header="_Generate Key File..." Command="{Binding GenerateKeyFileCommand}">
|
||||
<MenuItem.Icon>
|
||||
<TextBlock Text="K" FontSize="12" FontWeight="Bold"/>
|
||||
</MenuItem.Icon>
|
||||
</MenuItem>
|
||||
</MenuItem>
|
||||
<MenuItem Header="_Help">
|
||||
<MenuItem Header="_About ConfigManager"/>
|
||||
</MenuItem>
|
||||
@@ -74,6 +112,9 @@
|
||||
<Border Width="1" Height="20" Background="#2D3540" Margin="4,0"/>
|
||||
<Button Content="Test" Command="{Binding TestConnectionCommand}" Classes="toolbar"/>
|
||||
<Button Content="Validate" Command="{Binding ValidateCommand}" Classes="toolbar"/>
|
||||
<Border Width="1" Height="20" Background="#2D3540" Margin="4,0"/>
|
||||
<Button Content="Unlock" Command="{Binding UnlockStoreCommand}" ToolTip.Tip="Unlock/Lock Store" Classes="toolbar"/>
|
||||
<Button Content="+ Secret" Command="{Binding AddSecretCommand}" ToolTip.Tip="Add Secret" Classes="toolbar"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
@@ -123,6 +164,17 @@
|
||||
SelectedItem="{Binding SelectedNode}"
|
||||
Background="Transparent"
|
||||
Margin="8">
|
||||
<TreeView.ContextMenu>
|
||||
<ContextMenu>
|
||||
<MenuItem Header="Unlock Store..." Command="{Binding UnlockStoreCommand}"/>
|
||||
<MenuItem Header="Lock Store" Command="{Binding LockStoreCommand}"/>
|
||||
<Separator/>
|
||||
<MenuItem Header="Add Secret..." Command="{Binding AddSecretCommand}"/>
|
||||
<MenuItem Header="Delete Secret" Command="{Binding DeleteSecretCommand}"/>
|
||||
<Separator/>
|
||||
<MenuItem Header="Save Store" Command="{Binding SaveStoreCommand}"/>
|
||||
</ContextMenu>
|
||||
</TreeView.ContextMenu>
|
||||
<TreeView.ItemTemplate>
|
||||
<TreeDataTemplate ItemsSource="{Binding Children}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Avalonia.Headless.XUnit" Version="11.2.*" />
|
||||
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.2.*" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
|
||||
<PackageReference Include="NSubstitute" Version="5.3.0" />
|
||||
<PackageReference Include="Shouldly" Version="4.2.1" />
|
||||
|
||||
+338
@@ -0,0 +1,338 @@
|
||||
using System.IO;
|
||||
using JdeScoping.ConfigManager.Services.SecureStore;
|
||||
|
||||
namespace JdeScoping.ConfigManager.Tests.Services.SecureStore;
|
||||
|
||||
public class SecureStoreManagerTests : IDisposable
|
||||
{
|
||||
private readonly string _testDirectory;
|
||||
private readonly SecureStoreManager _sut;
|
||||
|
||||
public SecureStoreManagerTests()
|
||||
{
|
||||
_testDirectory = Path.Combine(Path.GetTempPath(), $"SecureStoreTests_{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(_testDirectory);
|
||||
_sut = new SecureStoreManager();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_sut.Dispose();
|
||||
if (Directory.Exists(_testDirectory))
|
||||
{
|
||||
Directory.Delete(_testDirectory, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsStoreOpen_WhenNoStoreOpen_ReturnsFalse()
|
||||
{
|
||||
_sut.IsStoreOpen.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CurrentStorePath_WhenNoStoreOpen_ReturnsNull()
|
||||
{
|
||||
_sut.CurrentStorePath.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasUnsavedChanges_WhenNoStoreOpen_ReturnsFalse()
|
||||
{
|
||||
_sut.HasUnsavedChanges.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateStore_WithKeyFile_CreatesStoreAndKeyFile()
|
||||
{
|
||||
// Arrange
|
||||
var storePath = Path.Combine(_testDirectory, "test.json");
|
||||
var keyPath = Path.Combine(_testDirectory, "test.key");
|
||||
|
||||
// Act
|
||||
_sut.CreateStore(storePath, keyPath);
|
||||
|
||||
// Assert
|
||||
_sut.IsStoreOpen.ShouldBeTrue();
|
||||
_sut.CurrentStorePath.ShouldBe(storePath);
|
||||
File.Exists(storePath).ShouldBeTrue();
|
||||
File.Exists(keyPath).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateStoreWithPassword_CreatesStore()
|
||||
{
|
||||
// Arrange
|
||||
var storePath = Path.Combine(_testDirectory, "test.json");
|
||||
|
||||
// Act
|
||||
_sut.CreateStoreWithPassword(storePath, "testpassword123");
|
||||
|
||||
// Assert
|
||||
_sut.IsStoreOpen.ShouldBeTrue();
|
||||
_sut.CurrentStorePath.ShouldBe(storePath);
|
||||
File.Exists(storePath).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateStoreWithPassword_WithEmptyPassword_ThrowsArgumentException()
|
||||
{
|
||||
// Arrange
|
||||
var storePath = Path.Combine(_testDirectory, "test.json");
|
||||
|
||||
// Act & Assert
|
||||
Should.Throw<ArgumentException>(() => _sut.CreateStoreWithPassword(storePath, ""));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OpenStore_WithValidKeyFile_OpensStore()
|
||||
{
|
||||
// Arrange
|
||||
var storePath = Path.Combine(_testDirectory, "test.json");
|
||||
var keyPath = Path.Combine(_testDirectory, "test.key");
|
||||
_sut.CreateStore(storePath, keyPath);
|
||||
_sut.CloseStore();
|
||||
|
||||
// Act
|
||||
_sut.OpenStore(storePath, keyPath);
|
||||
|
||||
// Assert
|
||||
_sut.IsStoreOpen.ShouldBeTrue();
|
||||
_sut.CurrentStorePath.ShouldBe(storePath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OpenStore_WithNonExistentStore_ThrowsFileNotFoundException()
|
||||
{
|
||||
// Arrange
|
||||
var storePath = Path.Combine(_testDirectory, "nonexistent.json");
|
||||
var keyPath = Path.Combine(_testDirectory, "test.key");
|
||||
|
||||
// Act & Assert
|
||||
Should.Throw<FileNotFoundException>(() => _sut.OpenStore(storePath, keyPath));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OpenStoreWithPassword_OpensStore()
|
||||
{
|
||||
// Arrange
|
||||
var storePath = Path.Combine(_testDirectory, "test.json");
|
||||
var password = "testpassword123";
|
||||
_sut.CreateStoreWithPassword(storePath, password);
|
||||
_sut.CloseStore();
|
||||
|
||||
// Act
|
||||
_sut.OpenStoreWithPassword(storePath, password);
|
||||
|
||||
// Assert
|
||||
_sut.IsStoreOpen.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CloseStore_ClosesOpenStore()
|
||||
{
|
||||
// Arrange
|
||||
var storePath = Path.Combine(_testDirectory, "test.json");
|
||||
var keyPath = Path.Combine(_testDirectory, "test.key");
|
||||
_sut.CreateStore(storePath, keyPath);
|
||||
|
||||
// Act
|
||||
_sut.CloseStore();
|
||||
|
||||
// Assert
|
||||
_sut.IsStoreOpen.ShouldBeFalse();
|
||||
_sut.CurrentStorePath.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetSecret_AddsSecretAndMarksUnsaved()
|
||||
{
|
||||
// Arrange
|
||||
var storePath = Path.Combine(_testDirectory, "test.json");
|
||||
var keyPath = Path.Combine(_testDirectory, "test.key");
|
||||
_sut.CreateStore(storePath, keyPath);
|
||||
_sut.Save(); // Save to clear unsaved flag
|
||||
|
||||
// Act
|
||||
_sut.SetSecret("testKey", "testValue");
|
||||
|
||||
// Assert
|
||||
_sut.HasUnsavedChanges.ShouldBeTrue();
|
||||
_sut.GetKeys().ShouldContain("testKey");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetSecret_ReturnsCorrectValue()
|
||||
{
|
||||
// Arrange
|
||||
var storePath = Path.Combine(_testDirectory, "test.json");
|
||||
var keyPath = Path.Combine(_testDirectory, "test.key");
|
||||
_sut.CreateStore(storePath, keyPath);
|
||||
_sut.SetSecret("testKey", "testValue");
|
||||
|
||||
// Act
|
||||
var value = _sut.GetSecret("testKey");
|
||||
|
||||
// Assert
|
||||
value.ShouldBe("testValue");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetSecret_WhenKeyNotFound_ThrowsKeyNotFoundException()
|
||||
{
|
||||
// Arrange
|
||||
var storePath = Path.Combine(_testDirectory, "test.json");
|
||||
var keyPath = Path.Combine(_testDirectory, "test.key");
|
||||
_sut.CreateStore(storePath, keyPath);
|
||||
|
||||
// Act & Assert
|
||||
Should.Throw<KeyNotFoundException>(() => _sut.GetSecret("nonexistent"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemoveSecret_RemovesSecretAndMarksUnsaved()
|
||||
{
|
||||
// Arrange
|
||||
var storePath = Path.Combine(_testDirectory, "test.json");
|
||||
var keyPath = Path.Combine(_testDirectory, "test.key");
|
||||
_sut.CreateStore(storePath, keyPath);
|
||||
_sut.SetSecret("testKey", "testValue");
|
||||
_sut.Save();
|
||||
|
||||
// Act
|
||||
_sut.RemoveSecret("testKey");
|
||||
|
||||
// Assert
|
||||
_sut.HasUnsavedChanges.ShouldBeTrue();
|
||||
_sut.GetKeys().ShouldNotContain("testKey");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemoveSecret_WhenKeyNotFound_ThrowsKeyNotFoundException()
|
||||
{
|
||||
// Arrange
|
||||
var storePath = Path.Combine(_testDirectory, "test.json");
|
||||
var keyPath = Path.Combine(_testDirectory, "test.key");
|
||||
_sut.CreateStore(storePath, keyPath);
|
||||
|
||||
// Act & Assert
|
||||
Should.Throw<KeyNotFoundException>(() => _sut.RemoveSecret("nonexistent"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Save_PersistsSecretsToStore()
|
||||
{
|
||||
// Arrange
|
||||
var storePath = Path.Combine(_testDirectory, "test.json");
|
||||
var keyPath = Path.Combine(_testDirectory, "test.key");
|
||||
_sut.CreateStore(storePath, keyPath);
|
||||
_sut.SetSecret("testKey", "testValue");
|
||||
|
||||
// Act
|
||||
_sut.Save();
|
||||
_sut.CloseStore();
|
||||
_sut.OpenStore(storePath, keyPath);
|
||||
|
||||
// Assert
|
||||
_sut.GetKeys().ShouldContain("testKey");
|
||||
_sut.GetSecret("testKey").ShouldBe("testValue");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Save_ClearsUnsavedChangesFlag()
|
||||
{
|
||||
// Arrange
|
||||
var storePath = Path.Combine(_testDirectory, "test.json");
|
||||
var keyPath = Path.Combine(_testDirectory, "test.key");
|
||||
_sut.CreateStore(storePath, keyPath);
|
||||
_sut.SetSecret("testKey", "testValue");
|
||||
_sut.HasUnsavedChanges.ShouldBeTrue();
|
||||
|
||||
// Act
|
||||
_sut.Save();
|
||||
|
||||
// Assert
|
||||
_sut.HasUnsavedChanges.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetKeys_ReturnsAllSecretKeys()
|
||||
{
|
||||
// Arrange
|
||||
var storePath = Path.Combine(_testDirectory, "test.json");
|
||||
var keyPath = Path.Combine(_testDirectory, "test.key");
|
||||
_sut.CreateStore(storePath, keyPath);
|
||||
_sut.SetSecret("key1", "value1");
|
||||
_sut.SetSecret("key2", "value2");
|
||||
_sut.SetSecret("key3", "value3");
|
||||
|
||||
// Act
|
||||
var keys = _sut.GetKeys();
|
||||
|
||||
// Assert
|
||||
keys.Count.ShouldBe(3);
|
||||
keys.ShouldContain("key1");
|
||||
keys.ShouldContain("key2");
|
||||
keys.ShouldContain("key3");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateKeyFile_CreatesNewKeyFile()
|
||||
{
|
||||
// Arrange
|
||||
var keyPath = Path.Combine(_testDirectory, "generated.key");
|
||||
|
||||
// Act
|
||||
_sut.GenerateKeyFile(keyPath);
|
||||
|
||||
// Assert
|
||||
File.Exists(keyPath).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExportKey_WhenNoStoreOpen_ThrowsInvalidOperationException()
|
||||
{
|
||||
// Arrange
|
||||
var keyPath = Path.Combine(_testDirectory, "export.key");
|
||||
|
||||
// Act & Assert
|
||||
Should.Throw<InvalidOperationException>(() => _sut.ExportKey(keyPath));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExportKey_WhenStoreOpen_ExportsKey()
|
||||
{
|
||||
// Arrange
|
||||
var storePath = Path.Combine(_testDirectory, "test.json");
|
||||
var keyPath = Path.Combine(_testDirectory, "test.key");
|
||||
var exportPath = Path.Combine(_testDirectory, "export.key");
|
||||
_sut.CreateStore(storePath, keyPath);
|
||||
|
||||
// Act
|
||||
_sut.ExportKey(exportPath);
|
||||
|
||||
// Assert
|
||||
File.Exists(exportPath).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetKeys_WhenNoStoreOpen_ThrowsInvalidOperationException()
|
||||
{
|
||||
// Act & Assert
|
||||
Should.Throw<InvalidOperationException>(() => _sut.GetKeys());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetSecret_WhenNoStoreOpen_ThrowsInvalidOperationException()
|
||||
{
|
||||
// Act & Assert
|
||||
Should.Throw<InvalidOperationException>(() => _sut.SetSecret("key", "value"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetSecret_WhenNoStoreOpen_ThrowsInvalidOperationException()
|
||||
{
|
||||
// Act & Assert
|
||||
Should.Throw<InvalidOperationException>(() => _sut.GetSecret("key"));
|
||||
}
|
||||
}
|
||||
+399
@@ -0,0 +1,399 @@
|
||||
using JdeScoping.ConfigManager.Constants;
|
||||
using JdeScoping.ConfigManager.ViewModels.Dialogs;
|
||||
|
||||
namespace JdeScoping.ConfigManager.Tests.ViewModels.Dialogs;
|
||||
|
||||
public class NewStoreDialogViewModelTests
|
||||
{
|
||||
[Fact]
|
||||
public void Constructor_DefaultsToUseKeyFile()
|
||||
{
|
||||
// Arrange & Act
|
||||
var sut = new NewStoreDialogViewModel();
|
||||
|
||||
// Assert
|
||||
sut.UseKeyFile.ShouldBeTrue();
|
||||
sut.UsePassword.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StorePath_WhenEmpty_IsValidReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var sut = new NewStoreDialogViewModel
|
||||
{
|
||||
StorePath = "",
|
||||
KeyFilePath = "/path/to/key.key"
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
sut.IsValid.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StorePath_WhenEmpty_ValidationErrorReturnsStorePathRequired()
|
||||
{
|
||||
// Arrange
|
||||
var sut = new NewStoreDialogViewModel
|
||||
{
|
||||
StorePath = "",
|
||||
KeyFilePath = "/path/to/key.key"
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
sut.ValidationError.ShouldBe(SecureStoreStrings.StorePathRequired);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StorePath_WhenWhitespace_IsValidReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var sut = new NewStoreDialogViewModel
|
||||
{
|
||||
StorePath = " ",
|
||||
KeyFilePath = "/path/to/key.key"
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
sut.IsValid.ShouldBeFalse();
|
||||
sut.ValidationError.ShouldBe(SecureStoreStrings.StorePathRequired);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void KeyFilePath_WhenRequiredAndEmpty_IsValidReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var sut = new NewStoreDialogViewModel
|
||||
{
|
||||
StorePath = "/path/to/store.secure",
|
||||
UseKeyFile = true,
|
||||
KeyFilePath = ""
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
sut.IsValid.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void KeyFilePath_WhenRequiredAndEmpty_ValidationErrorReturnsKeyFilePathRequired()
|
||||
{
|
||||
// Arrange
|
||||
var sut = new NewStoreDialogViewModel
|
||||
{
|
||||
StorePath = "/path/to/store.secure",
|
||||
UseKeyFile = true,
|
||||
KeyFilePath = ""
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
sut.ValidationError.ShouldBe(SecureStoreStrings.KeyFilePathRequired);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void KeyFilePath_WhenRequiredAndProvided_IsValidReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var sut = new NewStoreDialogViewModel
|
||||
{
|
||||
StorePath = "/path/to/store.secure",
|
||||
UseKeyFile = true,
|
||||
KeyFilePath = "/path/to/key.key"
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
sut.IsValid.ShouldBeTrue();
|
||||
sut.ValidationError.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Password_WhenRequiredAndEmpty_IsValidReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var sut = new NewStoreDialogViewModel
|
||||
{
|
||||
StorePath = "/path/to/store.secure",
|
||||
UsePassword = true,
|
||||
Password = ""
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
sut.IsValid.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Password_WhenRequiredAndEmpty_ValidationErrorReturnsPasswordRequired()
|
||||
{
|
||||
// Arrange
|
||||
var sut = new NewStoreDialogViewModel
|
||||
{
|
||||
StorePath = "/path/to/store.secure",
|
||||
UsePassword = true,
|
||||
Password = ""
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
sut.ValidationError.ShouldBe(SecureStoreStrings.PasswordRequired);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConfirmPassword_WhenRequiredAndDoesNotMatch_IsValidReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var sut = new NewStoreDialogViewModel
|
||||
{
|
||||
StorePath = "/path/to/store.secure",
|
||||
UsePassword = true,
|
||||
Password = "password123",
|
||||
ConfirmPassword = "differentPassword"
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
sut.IsValid.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConfirmPassword_WhenRequiredAndDoesNotMatch_ValidationErrorReturnsPasswordsDoNotMatch()
|
||||
{
|
||||
// Arrange
|
||||
var sut = new NewStoreDialogViewModel
|
||||
{
|
||||
StorePath = "/path/to/store.secure",
|
||||
UsePassword = true,
|
||||
Password = "password123",
|
||||
ConfirmPassword = "differentPassword"
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
sut.ValidationError.ShouldBe(SecureStoreStrings.PasswordsDoNotMatch);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Password_WhenRequiredAndMatchesConfirmPassword_IsValidReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var sut = new NewStoreDialogViewModel
|
||||
{
|
||||
StorePath = "/path/to/store.secure",
|
||||
UsePassword = true,
|
||||
Password = "password123",
|
||||
ConfirmPassword = "password123"
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
sut.IsValid.ShouldBeTrue();
|
||||
sut.ValidationError.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UseKeyFile_WhenSetToTrue_SetsUsePasswordToFalse()
|
||||
{
|
||||
// Arrange
|
||||
var sut = new NewStoreDialogViewModel
|
||||
{
|
||||
UsePassword = true
|
||||
};
|
||||
|
||||
// Act
|
||||
sut.UseKeyFile = true;
|
||||
|
||||
// Assert
|
||||
sut.UseKeyFile.ShouldBeTrue();
|
||||
sut.UsePassword.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UsePassword_WhenSetToTrue_SetsUseKeyFileToFalse()
|
||||
{
|
||||
// Arrange
|
||||
var sut = new NewStoreDialogViewModel
|
||||
{
|
||||
UseKeyFile = true
|
||||
};
|
||||
|
||||
// Act
|
||||
sut.UsePassword = true;
|
||||
|
||||
// Assert
|
||||
sut.UsePassword.ShouldBeTrue();
|
||||
sut.UseKeyFile.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsValid_WhenNeitherKeyFileNorPasswordSelected_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var sut = new NewStoreDialogViewModel
|
||||
{
|
||||
StorePath = "/path/to/store.secure"
|
||||
};
|
||||
// Manually set both to false (shouldn't happen in UI, but test edge case)
|
||||
sut.UseKeyFile = false;
|
||||
|
||||
// Act & Assert
|
||||
sut.IsValid.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StorePath_WhenChanged_RaisesPropertyChanged()
|
||||
{
|
||||
// Arrange
|
||||
var sut = new NewStoreDialogViewModel();
|
||||
var propertyChangedRaised = false;
|
||||
sut.PropertyChanged += (s, e) =>
|
||||
{
|
||||
if (e.PropertyName == nameof(NewStoreDialogViewModel.StorePath))
|
||||
propertyChangedRaised = true;
|
||||
};
|
||||
|
||||
// Act
|
||||
sut.StorePath = "/new/path";
|
||||
|
||||
// Assert
|
||||
propertyChangedRaised.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StorePath_WhenChanged_RaisesIsValidPropertyChanged()
|
||||
{
|
||||
// Arrange
|
||||
var sut = new NewStoreDialogViewModel();
|
||||
var isValidPropertyChangedRaised = false;
|
||||
sut.PropertyChanged += (s, e) =>
|
||||
{
|
||||
if (e.PropertyName == nameof(NewStoreDialogViewModel.IsValid))
|
||||
isValidPropertyChangedRaised = true;
|
||||
};
|
||||
|
||||
// Act
|
||||
sut.StorePath = "/new/path";
|
||||
|
||||
// Assert
|
||||
isValidPropertyChangedRaised.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StorePath_WhenChanged_RaisesValidationErrorPropertyChanged()
|
||||
{
|
||||
// Arrange
|
||||
var sut = new NewStoreDialogViewModel();
|
||||
var validationErrorPropertyChangedRaised = false;
|
||||
sut.PropertyChanged += (s, e) =>
|
||||
{
|
||||
if (e.PropertyName == nameof(NewStoreDialogViewModel.ValidationError))
|
||||
validationErrorPropertyChangedRaised = true;
|
||||
};
|
||||
|
||||
// Act
|
||||
sut.StorePath = "/new/path";
|
||||
|
||||
// Assert
|
||||
validationErrorPropertyChangedRaised.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void KeyFilePath_WhenChanged_RaisesPropertyChanged()
|
||||
{
|
||||
// Arrange
|
||||
var sut = new NewStoreDialogViewModel();
|
||||
var propertyChangedRaised = false;
|
||||
sut.PropertyChanged += (s, e) =>
|
||||
{
|
||||
if (e.PropertyName == nameof(NewStoreDialogViewModel.KeyFilePath))
|
||||
propertyChangedRaised = true;
|
||||
};
|
||||
|
||||
// Act
|
||||
sut.KeyFilePath = "/new/key/path";
|
||||
|
||||
// Assert
|
||||
propertyChangedRaised.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Password_WhenChanged_RaisesPropertyChanged()
|
||||
{
|
||||
// Arrange
|
||||
var sut = new NewStoreDialogViewModel();
|
||||
var propertyChangedRaised = false;
|
||||
sut.PropertyChanged += (s, e) =>
|
||||
{
|
||||
if (e.PropertyName == nameof(NewStoreDialogViewModel.Password))
|
||||
propertyChangedRaised = true;
|
||||
};
|
||||
|
||||
// Act
|
||||
sut.Password = "newpassword";
|
||||
|
||||
// Assert
|
||||
propertyChangedRaised.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConfirmPassword_WhenChanged_RaisesPropertyChanged()
|
||||
{
|
||||
// Arrange
|
||||
var sut = new NewStoreDialogViewModel();
|
||||
var propertyChangedRaised = false;
|
||||
sut.PropertyChanged += (s, e) =>
|
||||
{
|
||||
if (e.PropertyName == nameof(NewStoreDialogViewModel.ConfirmPassword))
|
||||
propertyChangedRaised = true;
|
||||
};
|
||||
|
||||
// Act
|
||||
sut.ConfirmPassword = "newpassword";
|
||||
|
||||
// Assert
|
||||
propertyChangedRaised.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UseKeyFile_WhenChanged_RaisesPropertyChanged()
|
||||
{
|
||||
// Arrange
|
||||
var sut = new NewStoreDialogViewModel { UseKeyFile = false };
|
||||
var propertyChangedRaised = false;
|
||||
sut.PropertyChanged += (s, e) =>
|
||||
{
|
||||
if (e.PropertyName == nameof(NewStoreDialogViewModel.UseKeyFile))
|
||||
propertyChangedRaised = true;
|
||||
};
|
||||
|
||||
// Act
|
||||
sut.UseKeyFile = true;
|
||||
|
||||
// Assert
|
||||
propertyChangedRaised.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UsePassword_WhenChanged_RaisesPropertyChanged()
|
||||
{
|
||||
// Arrange
|
||||
var sut = new NewStoreDialogViewModel();
|
||||
var propertyChangedRaised = false;
|
||||
sut.PropertyChanged += (s, e) =>
|
||||
{
|
||||
if (e.PropertyName == nameof(NewStoreDialogViewModel.UsePassword))
|
||||
propertyChangedRaised = true;
|
||||
};
|
||||
|
||||
// Act
|
||||
sut.UsePassword = true;
|
||||
|
||||
// Assert
|
||||
propertyChangedRaised.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Commands_AreInitialized()
|
||||
{
|
||||
// Arrange & Act
|
||||
var sut = new NewStoreDialogViewModel();
|
||||
|
||||
// Assert
|
||||
sut.BrowseStorePathCommand.ShouldNotBeNull();
|
||||
sut.BrowseKeyFilePathCommand.ShouldNotBeNull();
|
||||
sut.GenerateKeyFileCommand.ShouldNotBeNull();
|
||||
}
|
||||
}
|
||||
+343
@@ -0,0 +1,343 @@
|
||||
using JdeScoping.ConfigManager.Constants;
|
||||
using JdeScoping.ConfigManager.ViewModels.Dialogs;
|
||||
|
||||
namespace JdeScoping.ConfigManager.Tests.ViewModels.Dialogs;
|
||||
|
||||
public class SecretEditDialogViewModelTests
|
||||
{
|
||||
[Fact]
|
||||
public void DefaultConstructor_SetsIsNewSecretToTrue()
|
||||
{
|
||||
// Arrange & Act
|
||||
var sut = new SecretEditDialogViewModel();
|
||||
|
||||
// Assert
|
||||
sut.IsNewSecret.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DefaultConstructor_SetsKeyToEmpty()
|
||||
{
|
||||
// Arrange & Act
|
||||
var sut = new SecretEditDialogViewModel();
|
||||
|
||||
// Assert
|
||||
sut.Key.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DefaultConstructor_SetsValueToEmpty()
|
||||
{
|
||||
// Arrange & Act
|
||||
var sut = new SecretEditDialogViewModel();
|
||||
|
||||
// Assert
|
||||
sut.Value.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EditConstructor_SetsKeyFromParameter()
|
||||
{
|
||||
// Arrange & Act
|
||||
var sut = new SecretEditDialogViewModel("myKey", "myValue");
|
||||
|
||||
// Assert
|
||||
sut.Key.ShouldBe("myKey");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EditConstructor_SetsValueFromParameter()
|
||||
{
|
||||
// Arrange & Act
|
||||
var sut = new SecretEditDialogViewModel("myKey", "myValue");
|
||||
|
||||
// Assert
|
||||
sut.Value.ShouldBe("myValue");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EditConstructor_SetsIsNewSecretToFalse()
|
||||
{
|
||||
// Arrange & Act
|
||||
var sut = new SecretEditDialogViewModel("myKey", "myValue");
|
||||
|
||||
// Assert
|
||||
sut.IsNewSecret.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Key_WhenEmpty_IsValidReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var sut = new SecretEditDialogViewModel
|
||||
{
|
||||
Key = ""
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
sut.IsValid.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Key_WhenEmpty_ValidationErrorReturnsKeyRequired()
|
||||
{
|
||||
// Arrange
|
||||
var sut = new SecretEditDialogViewModel
|
||||
{
|
||||
Key = ""
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
sut.ValidationError.ShouldBe(SecureStoreStrings.KeyRequired);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Key_WhenWhitespace_IsValidReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var sut = new SecretEditDialogViewModel
|
||||
{
|
||||
Key = " "
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
sut.IsValid.ShouldBeFalse();
|
||||
sut.ValidationError.ShouldBe(SecureStoreStrings.KeyRequired);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Key_WhenProvided_IsValidReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var sut = new SecretEditDialogViewModel
|
||||
{
|
||||
Key = "validKey"
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
sut.IsValid.ShouldBeTrue();
|
||||
sut.ValidationError.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsKeyEditable_WhenIsNewSecretIsTrue_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var sut = new SecretEditDialogViewModel();
|
||||
|
||||
// Act & Assert
|
||||
sut.IsKeyEditable.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsKeyEditable_WhenIsNewSecretIsFalse_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var sut = new SecretEditDialogViewModel("existingKey", "existingValue");
|
||||
|
||||
// Act & Assert
|
||||
sut.IsKeyEditable.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DialogTitle_WhenIsNewSecretIsTrue_ReturnsAddSecret()
|
||||
{
|
||||
// Arrange
|
||||
var sut = new SecretEditDialogViewModel();
|
||||
|
||||
// Act & Assert
|
||||
sut.DialogTitle.ShouldBe("Add Secret");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DialogTitle_WhenIsNewSecretIsFalse_ReturnsEditSecret()
|
||||
{
|
||||
// Arrange
|
||||
var sut = new SecretEditDialogViewModel("existingKey", "existingValue");
|
||||
|
||||
// Act & Assert
|
||||
sut.DialogTitle.ShouldBe("Edit Secret");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsNewSecret_WhenChanged_RaisesIsKeyEditablePropertyChanged()
|
||||
{
|
||||
// Arrange
|
||||
var sut = new SecretEditDialogViewModel();
|
||||
var isKeyEditablePropertyChangedRaised = false;
|
||||
sut.PropertyChanged += (s, e) =>
|
||||
{
|
||||
if (e.PropertyName == nameof(SecretEditDialogViewModel.IsKeyEditable))
|
||||
isKeyEditablePropertyChangedRaised = true;
|
||||
};
|
||||
|
||||
// Act
|
||||
sut.IsNewSecret = false;
|
||||
|
||||
// Assert
|
||||
isKeyEditablePropertyChangedRaised.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsNewSecret_WhenChanged_RaisesDialogTitlePropertyChanged()
|
||||
{
|
||||
// Arrange
|
||||
var sut = new SecretEditDialogViewModel();
|
||||
var dialogTitlePropertyChangedRaised = false;
|
||||
sut.PropertyChanged += (s, e) =>
|
||||
{
|
||||
if (e.PropertyName == nameof(SecretEditDialogViewModel.DialogTitle))
|
||||
dialogTitlePropertyChangedRaised = true;
|
||||
};
|
||||
|
||||
// Act
|
||||
sut.IsNewSecret = false;
|
||||
|
||||
// Assert
|
||||
dialogTitlePropertyChangedRaised.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Key_WhenChanged_RaisesPropertyChanged()
|
||||
{
|
||||
// Arrange
|
||||
var sut = new SecretEditDialogViewModel();
|
||||
var propertyChangedRaised = false;
|
||||
sut.PropertyChanged += (s, e) =>
|
||||
{
|
||||
if (e.PropertyName == nameof(SecretEditDialogViewModel.Key))
|
||||
propertyChangedRaised = true;
|
||||
};
|
||||
|
||||
// Act
|
||||
sut.Key = "newKey";
|
||||
|
||||
// Assert
|
||||
propertyChangedRaised.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Key_WhenChanged_RaisesIsValidPropertyChanged()
|
||||
{
|
||||
// Arrange
|
||||
var sut = new SecretEditDialogViewModel();
|
||||
var isValidPropertyChangedRaised = false;
|
||||
sut.PropertyChanged += (s, e) =>
|
||||
{
|
||||
if (e.PropertyName == nameof(SecretEditDialogViewModel.IsValid))
|
||||
isValidPropertyChangedRaised = true;
|
||||
};
|
||||
|
||||
// Act
|
||||
sut.Key = "newKey";
|
||||
|
||||
// Assert
|
||||
isValidPropertyChangedRaised.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Key_WhenChanged_RaisesValidationErrorPropertyChanged()
|
||||
{
|
||||
// Arrange
|
||||
var sut = new SecretEditDialogViewModel();
|
||||
var validationErrorPropertyChangedRaised = false;
|
||||
sut.PropertyChanged += (s, e) =>
|
||||
{
|
||||
if (e.PropertyName == nameof(SecretEditDialogViewModel.ValidationError))
|
||||
validationErrorPropertyChangedRaised = true;
|
||||
};
|
||||
|
||||
// Act
|
||||
sut.Key = "newKey";
|
||||
|
||||
// Assert
|
||||
validationErrorPropertyChangedRaised.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Value_WhenChanged_RaisesPropertyChanged()
|
||||
{
|
||||
// Arrange
|
||||
var sut = new SecretEditDialogViewModel();
|
||||
var propertyChangedRaised = false;
|
||||
sut.PropertyChanged += (s, e) =>
|
||||
{
|
||||
if (e.PropertyName == nameof(SecretEditDialogViewModel.Value))
|
||||
propertyChangedRaised = true;
|
||||
};
|
||||
|
||||
// Act
|
||||
sut.Value = "newValue";
|
||||
|
||||
// Assert
|
||||
propertyChangedRaised.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Value_CanBeEmpty_IsValidStillReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var sut = new SecretEditDialogViewModel
|
||||
{
|
||||
Key = "validKey",
|
||||
Value = ""
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
sut.IsValid.ShouldBeTrue();
|
||||
sut.ValidationError.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Value_CanBeNull_IsValidStillReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var sut = new SecretEditDialogViewModel
|
||||
{
|
||||
Key = "validKey",
|
||||
Value = null!
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
sut.IsValid.ShouldBeTrue();
|
||||
sut.ValidationError.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsNewSecret_WhenSetToSameValue_DoesNotRaisePropertyChanged()
|
||||
{
|
||||
// Arrange
|
||||
var sut = new SecretEditDialogViewModel();
|
||||
var propertyChangedCount = 0;
|
||||
sut.PropertyChanged += (s, e) =>
|
||||
{
|
||||
if (e.PropertyName == nameof(SecretEditDialogViewModel.IsNewSecret))
|
||||
propertyChangedCount++;
|
||||
};
|
||||
|
||||
// Act
|
||||
sut.IsNewSecret = true; // Same value as default
|
||||
|
||||
// Assert
|
||||
propertyChangedCount.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Key_WhenSetToSameValue_DoesNotRaisePropertyChanged()
|
||||
{
|
||||
// Arrange
|
||||
var sut = new SecretEditDialogViewModel { Key = "testKey" };
|
||||
var propertyChangedCount = 0;
|
||||
sut.PropertyChanged += (s, e) =>
|
||||
{
|
||||
if (e.PropertyName == nameof(SecretEditDialogViewModel.Key))
|
||||
propertyChangedCount++;
|
||||
};
|
||||
|
||||
// Act
|
||||
sut.Key = "testKey"; // Same value
|
||||
|
||||
// Assert
|
||||
propertyChangedCount.ShouldBe(0);
|
||||
}
|
||||
}
|
||||
+347
@@ -0,0 +1,347 @@
|
||||
using JdeScoping.ConfigManager.Constants;
|
||||
using JdeScoping.ConfigManager.ViewModels.Dialogs;
|
||||
|
||||
namespace JdeScoping.ConfigManager.Tests.ViewModels.Dialogs;
|
||||
|
||||
public class UnlockStoreDialogViewModelTests
|
||||
{
|
||||
[Fact]
|
||||
public void Constructor_SetsStorePathFromParameter()
|
||||
{
|
||||
// Arrange
|
||||
var storePath = "/path/to/store.secure";
|
||||
|
||||
// Act
|
||||
var sut = new UnlockStoreDialogViewModel(storePath);
|
||||
|
||||
// Assert
|
||||
sut.StorePath.ShouldBe(storePath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_ThrowsOnNullStorePath()
|
||||
{
|
||||
// Act & Assert
|
||||
Should.Throw<ArgumentNullException>(() => new UnlockStoreDialogViewModel(null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_DefaultsToUseKeyFile()
|
||||
{
|
||||
// Arrange & Act
|
||||
var sut = new UnlockStoreDialogViewModel("/path/to/store.secure");
|
||||
|
||||
// Assert
|
||||
sut.UseKeyFile.ShouldBeTrue();
|
||||
sut.UsePassword.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StorePath_IsReadOnly()
|
||||
{
|
||||
// Arrange
|
||||
var sut = new UnlockStoreDialogViewModel("/path/to/store.secure");
|
||||
|
||||
// Assert - StorePath property has no setter, verified by checking it returns what was passed
|
||||
sut.StorePath.ShouldBe("/path/to/store.secure");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void KeyFilePath_WhenRequiredAndEmpty_IsValidReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var sut = new UnlockStoreDialogViewModel("/path/to/store.secure")
|
||||
{
|
||||
UseKeyFile = true,
|
||||
KeyFilePath = ""
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
sut.IsValid.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void KeyFilePath_WhenRequiredAndEmpty_ValidationErrorReturnsKeyFilePathRequired()
|
||||
{
|
||||
// Arrange
|
||||
var sut = new UnlockStoreDialogViewModel("/path/to/store.secure")
|
||||
{
|
||||
UseKeyFile = true,
|
||||
KeyFilePath = ""
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
sut.ValidationError.ShouldBe(SecureStoreStrings.KeyFilePathRequired);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void KeyFilePath_WhenRequiredAndWhitespace_IsValidReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var sut = new UnlockStoreDialogViewModel("/path/to/store.secure")
|
||||
{
|
||||
UseKeyFile = true,
|
||||
KeyFilePath = " "
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
sut.IsValid.ShouldBeFalse();
|
||||
sut.ValidationError.ShouldBe(SecureStoreStrings.KeyFilePathRequired);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void KeyFilePath_WhenRequiredAndFileDoesNotExist_IsValidReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var sut = new UnlockStoreDialogViewModel("/path/to/store.secure")
|
||||
{
|
||||
UseKeyFile = true,
|
||||
KeyFilePath = "/nonexistent/path/to/key.key"
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
sut.IsValid.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void KeyFilePath_WhenRequiredAndFileDoesNotExist_ValidationErrorReturnsKeyFileNotFound()
|
||||
{
|
||||
// Arrange
|
||||
var sut = new UnlockStoreDialogViewModel("/path/to/store.secure")
|
||||
{
|
||||
UseKeyFile = true,
|
||||
KeyFilePath = "/nonexistent/path/to/key.key"
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
sut.ValidationError.ShouldBe(SecureStoreStrings.KeyFileNotFound);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Password_WhenRequiredAndEmpty_IsValidReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var sut = new UnlockStoreDialogViewModel("/path/to/store.secure")
|
||||
{
|
||||
UsePassword = true,
|
||||
Password = ""
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
sut.IsValid.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Password_WhenRequiredAndEmpty_ValidationErrorReturnsPasswordRequired()
|
||||
{
|
||||
// Arrange
|
||||
var sut = new UnlockStoreDialogViewModel("/path/to/store.secure")
|
||||
{
|
||||
UsePassword = true,
|
||||
Password = ""
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
sut.ValidationError.ShouldBe(SecureStoreStrings.PasswordRequired);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Password_WhenRequiredAndWhitespace_IsValidReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var sut = new UnlockStoreDialogViewModel("/path/to/store.secure")
|
||||
{
|
||||
UsePassword = true,
|
||||
Password = " "
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
sut.IsValid.ShouldBeFalse();
|
||||
sut.ValidationError.ShouldBe(SecureStoreStrings.PasswordRequired);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Password_WhenRequiredAndProvided_IsValidReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var sut = new UnlockStoreDialogViewModel("/path/to/store.secure")
|
||||
{
|
||||
UsePassword = true,
|
||||
Password = "password123"
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
sut.IsValid.ShouldBeTrue();
|
||||
sut.ValidationError.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UseKeyFile_WhenSetToTrue_SetsUsePasswordToFalse()
|
||||
{
|
||||
// Arrange
|
||||
var sut = new UnlockStoreDialogViewModel("/path/to/store.secure")
|
||||
{
|
||||
UsePassword = true
|
||||
};
|
||||
|
||||
// Act
|
||||
sut.UseKeyFile = true;
|
||||
|
||||
// Assert
|
||||
sut.UseKeyFile.ShouldBeTrue();
|
||||
sut.UsePassword.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UsePassword_WhenSetToTrue_SetsUseKeyFileToFalse()
|
||||
{
|
||||
// Arrange
|
||||
var sut = new UnlockStoreDialogViewModel("/path/to/store.secure")
|
||||
{
|
||||
UseKeyFile = true
|
||||
};
|
||||
|
||||
// Act
|
||||
sut.UsePassword = true;
|
||||
|
||||
// Assert
|
||||
sut.UsePassword.ShouldBeTrue();
|
||||
sut.UseKeyFile.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsValid_WhenNeitherKeyFileNorPasswordSelected_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var sut = new UnlockStoreDialogViewModel("/path/to/store.secure");
|
||||
// Manually set both to false (shouldn't happen in UI, but test edge case)
|
||||
sut.UseKeyFile = false;
|
||||
|
||||
// Act & Assert
|
||||
sut.IsValid.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void KeyFilePath_WhenChanged_RaisesPropertyChanged()
|
||||
{
|
||||
// Arrange
|
||||
var sut = new UnlockStoreDialogViewModel("/path/to/store.secure");
|
||||
var propertyChangedRaised = false;
|
||||
sut.PropertyChanged += (s, e) =>
|
||||
{
|
||||
if (e.PropertyName == nameof(UnlockStoreDialogViewModel.KeyFilePath))
|
||||
propertyChangedRaised = true;
|
||||
};
|
||||
|
||||
// Act
|
||||
sut.KeyFilePath = "/new/key/path";
|
||||
|
||||
// Assert
|
||||
propertyChangedRaised.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void KeyFilePath_WhenChanged_RaisesIsValidPropertyChanged()
|
||||
{
|
||||
// Arrange
|
||||
var sut = new UnlockStoreDialogViewModel("/path/to/store.secure");
|
||||
var isValidPropertyChangedRaised = false;
|
||||
sut.PropertyChanged += (s, e) =>
|
||||
{
|
||||
if (e.PropertyName == nameof(UnlockStoreDialogViewModel.IsValid))
|
||||
isValidPropertyChangedRaised = true;
|
||||
};
|
||||
|
||||
// Act
|
||||
sut.KeyFilePath = "/new/key/path";
|
||||
|
||||
// Assert
|
||||
isValidPropertyChangedRaised.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void KeyFilePath_WhenChanged_RaisesValidationErrorPropertyChanged()
|
||||
{
|
||||
// Arrange
|
||||
var sut = new UnlockStoreDialogViewModel("/path/to/store.secure");
|
||||
var validationErrorPropertyChangedRaised = false;
|
||||
sut.PropertyChanged += (s, e) =>
|
||||
{
|
||||
if (e.PropertyName == nameof(UnlockStoreDialogViewModel.ValidationError))
|
||||
validationErrorPropertyChangedRaised = true;
|
||||
};
|
||||
|
||||
// Act
|
||||
sut.KeyFilePath = "/new/key/path";
|
||||
|
||||
// Assert
|
||||
validationErrorPropertyChangedRaised.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Password_WhenChanged_RaisesPropertyChanged()
|
||||
{
|
||||
// Arrange
|
||||
var sut = new UnlockStoreDialogViewModel("/path/to/store.secure");
|
||||
var propertyChangedRaised = false;
|
||||
sut.PropertyChanged += (s, e) =>
|
||||
{
|
||||
if (e.PropertyName == nameof(UnlockStoreDialogViewModel.Password))
|
||||
propertyChangedRaised = true;
|
||||
};
|
||||
|
||||
// Act
|
||||
sut.Password = "newpassword";
|
||||
|
||||
// Assert
|
||||
propertyChangedRaised.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UseKeyFile_WhenChanged_RaisesPropertyChanged()
|
||||
{
|
||||
// Arrange
|
||||
var sut = new UnlockStoreDialogViewModel("/path/to/store.secure") { UseKeyFile = false };
|
||||
var propertyChangedRaised = false;
|
||||
sut.PropertyChanged += (s, e) =>
|
||||
{
|
||||
if (e.PropertyName == nameof(UnlockStoreDialogViewModel.UseKeyFile))
|
||||
propertyChangedRaised = true;
|
||||
};
|
||||
|
||||
// Act
|
||||
sut.UseKeyFile = true;
|
||||
|
||||
// Assert
|
||||
propertyChangedRaised.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UsePassword_WhenChanged_RaisesPropertyChanged()
|
||||
{
|
||||
// Arrange
|
||||
var sut = new UnlockStoreDialogViewModel("/path/to/store.secure");
|
||||
var propertyChangedRaised = false;
|
||||
sut.PropertyChanged += (s, e) =>
|
||||
{
|
||||
if (e.PropertyName == nameof(UnlockStoreDialogViewModel.UsePassword))
|
||||
propertyChangedRaised = true;
|
||||
};
|
||||
|
||||
// Act
|
||||
sut.UsePassword = true;
|
||||
|
||||
// Assert
|
||||
propertyChangedRaised.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BrowseKeyFilePathCommand_IsInitialized()
|
||||
{
|
||||
// Arrange & Act
|
||||
var sut = new UnlockStoreDialogViewModel("/path/to/store.secure");
|
||||
|
||||
// Assert
|
||||
sut.BrowseKeyFilePathCommand.ShouldNotBeNull();
|
||||
}
|
||||
}
|
||||
+412
@@ -0,0 +1,412 @@
|
||||
using JdeScoping.ConfigManager.Services;
|
||||
using JdeScoping.ConfigManager.ViewModels.Forms;
|
||||
|
||||
namespace JdeScoping.ConfigManager.Tests.ViewModels.Forms;
|
||||
|
||||
public class SecretFormViewModelTests
|
||||
{
|
||||
private readonly IClipboardService _clipboardService;
|
||||
|
||||
public SecretFormViewModelTests()
|
||||
{
|
||||
_clipboardService = Substitute.For<IClipboardService>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_SetsPropertiesCorrectly()
|
||||
{
|
||||
// Arrange & Act
|
||||
var sut = new SecretFormViewModel(
|
||||
"API_KEY",
|
||||
"secret-value-123",
|
||||
_clipboardService,
|
||||
_ => { },
|
||||
() => { });
|
||||
|
||||
// Assert
|
||||
sut.Key.ShouldBe("API_KEY");
|
||||
sut.Value.ShouldBe("secret-value-123");
|
||||
sut.IsValueVisible.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_WithNullValue_SetsEmptyString()
|
||||
{
|
||||
// Arrange & Act
|
||||
var sut = new SecretFormViewModel(
|
||||
"API_KEY",
|
||||
null!,
|
||||
_clipboardService,
|
||||
_ => { },
|
||||
() => { });
|
||||
|
||||
// Assert
|
||||
sut.Value.ShouldBe(string.Empty);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Value_Setter_InvokesCallback()
|
||||
{
|
||||
// Arrange
|
||||
string? changedValue = null;
|
||||
var sut = new SecretFormViewModel(
|
||||
"API_KEY",
|
||||
"initial",
|
||||
_clipboardService,
|
||||
v => changedValue = v,
|
||||
() => { });
|
||||
|
||||
// Act
|
||||
sut.Value = "new-value";
|
||||
|
||||
// Assert
|
||||
changedValue.ShouldBe("new-value");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Value_Setter_RaisesPropertyChanged()
|
||||
{
|
||||
// Arrange
|
||||
var sut = new SecretFormViewModel(
|
||||
"API_KEY",
|
||||
"initial",
|
||||
_clipboardService,
|
||||
_ => { },
|
||||
() => { });
|
||||
|
||||
var propertyChangedRaised = false;
|
||||
sut.PropertyChanged += (s, e) =>
|
||||
{
|
||||
if (e.PropertyName == nameof(SecretFormViewModel.Value))
|
||||
propertyChangedRaised = true;
|
||||
};
|
||||
|
||||
// Act
|
||||
sut.Value = "new-value";
|
||||
|
||||
// Assert
|
||||
propertyChangedRaised.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Value_Setter_RaisesDisplayValuePropertyChanged()
|
||||
{
|
||||
// Arrange
|
||||
var sut = new SecretFormViewModel(
|
||||
"API_KEY",
|
||||
"initial",
|
||||
_clipboardService,
|
||||
_ => { },
|
||||
() => { });
|
||||
|
||||
var displayValueChangedRaised = false;
|
||||
sut.PropertyChanged += (s, e) =>
|
||||
{
|
||||
if (e.PropertyName == nameof(SecretFormViewModel.DisplayValue))
|
||||
displayValueChangedRaised = true;
|
||||
};
|
||||
|
||||
// Act
|
||||
sut.Value = "new-value";
|
||||
|
||||
// Assert
|
||||
displayValueChangedRaised.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsValueVisible_Toggle_Works()
|
||||
{
|
||||
// Arrange
|
||||
var sut = new SecretFormViewModel(
|
||||
"API_KEY",
|
||||
"secret",
|
||||
_clipboardService,
|
||||
_ => { },
|
||||
() => { });
|
||||
|
||||
// Act & Assert - Initial state
|
||||
sut.IsValueVisible.ShouldBeFalse();
|
||||
|
||||
// Act - Toggle on
|
||||
sut.IsValueVisible = true;
|
||||
sut.IsValueVisible.ShouldBeTrue();
|
||||
|
||||
// Act - Toggle off
|
||||
sut.IsValueVisible = false;
|
||||
sut.IsValueVisible.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsValueVisible_RaisesPropertyChanged()
|
||||
{
|
||||
// Arrange
|
||||
var sut = new SecretFormViewModel(
|
||||
"API_KEY",
|
||||
"secret",
|
||||
_clipboardService,
|
||||
_ => { },
|
||||
() => { });
|
||||
|
||||
var propertyChangedRaised = false;
|
||||
sut.PropertyChanged += (s, e) =>
|
||||
{
|
||||
if (e.PropertyName == nameof(SecretFormViewModel.IsValueVisible))
|
||||
propertyChangedRaised = true;
|
||||
};
|
||||
|
||||
// Act
|
||||
sut.IsValueVisible = true;
|
||||
|
||||
// Assert
|
||||
propertyChangedRaised.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsValueVisible_RaisesDisplayValueAndVisibilityButtonTextPropertyChanged()
|
||||
{
|
||||
// Arrange
|
||||
var sut = new SecretFormViewModel(
|
||||
"API_KEY",
|
||||
"secret",
|
||||
_clipboardService,
|
||||
_ => { },
|
||||
() => { });
|
||||
|
||||
var displayValueChangedRaised = false;
|
||||
var visibilityButtonTextChangedRaised = false;
|
||||
sut.PropertyChanged += (s, e) =>
|
||||
{
|
||||
if (e.PropertyName == nameof(SecretFormViewModel.DisplayValue))
|
||||
displayValueChangedRaised = true;
|
||||
if (e.PropertyName == nameof(SecretFormViewModel.VisibilityButtonText))
|
||||
visibilityButtonTextChangedRaised = true;
|
||||
};
|
||||
|
||||
// Act
|
||||
sut.IsValueVisible = true;
|
||||
|
||||
// Assert
|
||||
displayValueChangedRaised.ShouldBeTrue();
|
||||
visibilityButtonTextChangedRaised.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DisplayValue_ShowsMaskedValue_WhenNotVisible()
|
||||
{
|
||||
// Arrange
|
||||
var sut = new SecretFormViewModel(
|
||||
"API_KEY",
|
||||
"secret123",
|
||||
_clipboardService,
|
||||
_ => { },
|
||||
() => { });
|
||||
|
||||
// Act & Assert
|
||||
sut.IsValueVisible.ShouldBeFalse();
|
||||
sut.DisplayValue.ShouldBe("\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022"); // 9 bullet points
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DisplayValue_ShowsActualValue_WhenVisible()
|
||||
{
|
||||
// Arrange
|
||||
var sut = new SecretFormViewModel(
|
||||
"API_KEY",
|
||||
"secret123",
|
||||
_clipboardService,
|
||||
_ => { },
|
||||
() => { });
|
||||
|
||||
// Act
|
||||
sut.IsValueVisible = true;
|
||||
|
||||
// Assert
|
||||
sut.DisplayValue.ShouldBe("secret123");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DisplayValue_LimitsMaskedLength_ToTwentyCharacters()
|
||||
{
|
||||
// Arrange
|
||||
var longValue = new string('x', 50);
|
||||
var sut = new SecretFormViewModel(
|
||||
"API_KEY",
|
||||
longValue,
|
||||
_clipboardService,
|
||||
_ => { },
|
||||
() => { });
|
||||
|
||||
// Act & Assert
|
||||
sut.DisplayValue.Length.ShouldBe(20);
|
||||
sut.DisplayValue.ShouldBe(new string('\u2022', 20));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DisplayValue_HandlesEmptyValue()
|
||||
{
|
||||
// Arrange
|
||||
var sut = new SecretFormViewModel(
|
||||
"API_KEY",
|
||||
"",
|
||||
_clipboardService,
|
||||
_ => { },
|
||||
() => { });
|
||||
|
||||
// Act & Assert
|
||||
sut.DisplayValue.ShouldBe("");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VisibilityButtonText_ShowsShow_WhenHidden()
|
||||
{
|
||||
// Arrange
|
||||
var sut = new SecretFormViewModel(
|
||||
"API_KEY",
|
||||
"secret",
|
||||
_clipboardService,
|
||||
_ => { },
|
||||
() => { });
|
||||
|
||||
// Act & Assert
|
||||
sut.VisibilityButtonText.ShouldBe("Show");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VisibilityButtonText_ShowsHide_WhenVisible()
|
||||
{
|
||||
// Arrange
|
||||
var sut = new SecretFormViewModel(
|
||||
"API_KEY",
|
||||
"secret",
|
||||
_clipboardService,
|
||||
_ => { },
|
||||
() => { });
|
||||
|
||||
// Act
|
||||
sut.IsValueVisible = true;
|
||||
|
||||
// Assert
|
||||
sut.VisibilityButtonText.ShouldBe("Hide");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToggleVisibilityCommand_TogglesVisibility()
|
||||
{
|
||||
// Arrange
|
||||
var sut = new SecretFormViewModel(
|
||||
"API_KEY",
|
||||
"secret",
|
||||
_clipboardService,
|
||||
_ => { },
|
||||
() => { });
|
||||
|
||||
// Act & Assert - Toggle on
|
||||
sut.ToggleVisibilityCommand.Execute(null);
|
||||
sut.IsValueVisible.ShouldBeTrue();
|
||||
|
||||
// Act & Assert - Toggle off
|
||||
sut.ToggleVisibilityCommand.Execute(null);
|
||||
sut.IsValueVisible.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CopyToClipboardCommand_CopiesValueToClipboard()
|
||||
{
|
||||
// Arrange
|
||||
_clipboardService.SetTextAsync(Arg.Any<string>()).Returns(Task.CompletedTask);
|
||||
|
||||
var sut = new SecretFormViewModel(
|
||||
"API_KEY",
|
||||
"secret-to-copy",
|
||||
_clipboardService,
|
||||
_ => { },
|
||||
() => { });
|
||||
|
||||
// Act
|
||||
sut.CopyToClipboardCommand.Execute(null);
|
||||
|
||||
// Small delay to allow async command to complete
|
||||
await Task.Delay(50);
|
||||
|
||||
// Assert
|
||||
await _clipboardService.Received(1).SetTextAsync("secret-to-copy");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeleteCommand_InvokesCallback()
|
||||
{
|
||||
// Arrange
|
||||
var deleteCalled = false;
|
||||
var sut = new SecretFormViewModel(
|
||||
"API_KEY",
|
||||
"secret",
|
||||
_clipboardService,
|
||||
_ => { },
|
||||
() => deleteCalled = true);
|
||||
|
||||
// Act
|
||||
sut.DeleteCommand.Execute(null);
|
||||
|
||||
// Assert
|
||||
deleteCalled.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_ThrowsOnNullKey()
|
||||
{
|
||||
// Act & Assert
|
||||
Should.Throw<ArgumentNullException>(() =>
|
||||
new SecretFormViewModel(null!, "value", _clipboardService, _ => { }, () => { }));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_ThrowsOnNullClipboardService()
|
||||
{
|
||||
// Act & Assert
|
||||
Should.Throw<ArgumentNullException>(() =>
|
||||
new SecretFormViewModel("key", "value", null!, _ => { }, () => { }));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_ThrowsOnNullValueChangedCallback()
|
||||
{
|
||||
// Act & Assert
|
||||
Should.Throw<ArgumentNullException>(() =>
|
||||
new SecretFormViewModel("key", "value", _clipboardService, null!, () => { }));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_ThrowsOnNullDeleteCallback()
|
||||
{
|
||||
// Act & Assert
|
||||
Should.Throw<ArgumentNullException>(() =>
|
||||
new SecretFormViewModel("key", "value", _clipboardService, _ => { }, null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Key_IsReadOnly()
|
||||
{
|
||||
// Assert - Verify Key property is get-only
|
||||
var keyProperty = typeof(SecretFormViewModel).GetProperty(nameof(SecretFormViewModel.Key));
|
||||
keyProperty!.CanWrite.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Value_DoesNotInvokeCallback_WhenValueUnchanged()
|
||||
{
|
||||
// Arrange
|
||||
var callbackCount = 0;
|
||||
var sut = new SecretFormViewModel(
|
||||
"API_KEY",
|
||||
"same-value",
|
||||
_clipboardService,
|
||||
_ => callbackCount++,
|
||||
() => { });
|
||||
|
||||
// Act
|
||||
sut.Value = "same-value"; // Same as initial value
|
||||
|
||||
// Assert
|
||||
callbackCount.ShouldBe(0);
|
||||
}
|
||||
}
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
using JdeScoping.ConfigManager.ViewModels.Forms;
|
||||
|
||||
namespace JdeScoping.ConfigManager.Tests.ViewModels.Forms;
|
||||
|
||||
public class SecureStoreLockedFormViewModelTests
|
||||
{
|
||||
[Fact]
|
||||
public void Constructor_SetsPropertiesCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var lastModified = DateTime.Now;
|
||||
|
||||
// Act
|
||||
var sut = new SecureStoreLockedFormViewModel(
|
||||
"test.secrets",
|
||||
"/path/to/test.secrets",
|
||||
lastModified,
|
||||
() => { });
|
||||
|
||||
// Assert
|
||||
sut.StoreName.ShouldBe("test.secrets");
|
||||
sut.StorePath.ShouldBe("/path/to/test.secrets");
|
||||
sut.LastModified.ShouldBe(lastModified);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_WithNullLastModified_SetsNullLastModified()
|
||||
{
|
||||
// Arrange & Act
|
||||
var sut = new SecureStoreLockedFormViewModel(
|
||||
"test.secrets",
|
||||
"/path/to/test.secrets",
|
||||
null,
|
||||
() => { });
|
||||
|
||||
// Assert
|
||||
sut.LastModified.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnlockCommand_InvokesCallback()
|
||||
{
|
||||
// Arrange
|
||||
var unlockCalled = false;
|
||||
var sut = new SecureStoreLockedFormViewModel(
|
||||
"test.secrets",
|
||||
"/path/to/test.secrets",
|
||||
null,
|
||||
() => unlockCalled = true);
|
||||
|
||||
// Act
|
||||
sut.UnlockCommand.Execute(null);
|
||||
|
||||
// Assert
|
||||
unlockCalled.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnlockCommand_CanExecute_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var sut = new SecureStoreLockedFormViewModel(
|
||||
"test.secrets",
|
||||
"/path/to/test.secrets",
|
||||
null,
|
||||
() => { });
|
||||
|
||||
// Act & Assert
|
||||
sut.UnlockCommand.CanExecute(null).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_ThrowsOnNullStoreName()
|
||||
{
|
||||
// Act & Assert
|
||||
Should.Throw<ArgumentNullException>(() =>
|
||||
new SecureStoreLockedFormViewModel(null!, "/path", null, () => { }));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_ThrowsOnNullStorePath()
|
||||
{
|
||||
// Act & Assert
|
||||
Should.Throw<ArgumentNullException>(() =>
|
||||
new SecureStoreLockedFormViewModel("test", null!, null, () => { }));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_ThrowsOnNullUnlockCallback()
|
||||
{
|
||||
// Act & Assert
|
||||
Should.Throw<ArgumentNullException>(() =>
|
||||
new SecureStoreLockedFormViewModel("test", "/path", null, null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Properties_AreReadOnly()
|
||||
{
|
||||
// Arrange
|
||||
var sut = new SecureStoreLockedFormViewModel(
|
||||
"test.secrets",
|
||||
"/path/to/test.secrets",
|
||||
DateTime.Now,
|
||||
() => { });
|
||||
|
||||
// Assert - Verify properties are get-only (no setters)
|
||||
var storeNameProperty = typeof(SecureStoreLockedFormViewModel).GetProperty(nameof(SecureStoreLockedFormViewModel.StoreName));
|
||||
var storePathProperty = typeof(SecureStoreLockedFormViewModel).GetProperty(nameof(SecureStoreLockedFormViewModel.StorePath));
|
||||
var lastModifiedProperty = typeof(SecureStoreLockedFormViewModel).GetProperty(nameof(SecureStoreLockedFormViewModel.LastModified));
|
||||
|
||||
storeNameProperty!.CanWrite.ShouldBeFalse();
|
||||
storePathProperty!.CanWrite.ShouldBeFalse();
|
||||
lastModifiedProperty!.CanWrite.ShouldBeFalse();
|
||||
}
|
||||
}
|
||||
+327
@@ -0,0 +1,327 @@
|
||||
using JdeScoping.ConfigManager.ViewModels.Forms;
|
||||
|
||||
namespace JdeScoping.ConfigManager.Tests.ViewModels.Forms;
|
||||
|
||||
public class SecureStoreUnlockedFormViewModelTests
|
||||
{
|
||||
[Fact]
|
||||
public void Constructor_SetsPropertiesCorrectly()
|
||||
{
|
||||
// Arrange & Act
|
||||
var sut = new SecureStoreUnlockedFormViewModel(
|
||||
"test.secrets",
|
||||
"/path/to/test.secrets",
|
||||
5,
|
||||
false,
|
||||
() => { },
|
||||
() => { },
|
||||
() => { });
|
||||
|
||||
// Assert
|
||||
sut.StoreName.ShouldBe("test.secrets");
|
||||
sut.StorePath.ShouldBe("/path/to/test.secrets");
|
||||
sut.SecretCount.ShouldBe(5);
|
||||
sut.HasUnsavedChanges.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_WithUnsavedChanges_SetsHasUnsavedChanges()
|
||||
{
|
||||
// Arrange & Act
|
||||
var sut = new SecureStoreUnlockedFormViewModel(
|
||||
"test.secrets",
|
||||
"/path/to/test.secrets",
|
||||
3,
|
||||
true,
|
||||
() => { },
|
||||
() => { },
|
||||
() => { });
|
||||
|
||||
// Assert
|
||||
sut.HasUnsavedChanges.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasUnsavedChanges_RaisesPropertyChanged()
|
||||
{
|
||||
// Arrange
|
||||
var sut = new SecureStoreUnlockedFormViewModel(
|
||||
"test.secrets",
|
||||
"/path/to/test.secrets",
|
||||
0,
|
||||
false,
|
||||
() => { },
|
||||
() => { },
|
||||
() => { });
|
||||
|
||||
var propertyChangedRaised = false;
|
||||
sut.PropertyChanged += (s, e) =>
|
||||
{
|
||||
if (e.PropertyName == nameof(SecureStoreUnlockedFormViewModel.HasUnsavedChanges))
|
||||
propertyChangedRaised = true;
|
||||
};
|
||||
|
||||
// Act
|
||||
sut.HasUnsavedChanges = true;
|
||||
|
||||
// Assert
|
||||
propertyChangedRaised.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasUnsavedChanges_DoesNotRaisePropertyChanged_WhenValueUnchanged()
|
||||
{
|
||||
// Arrange
|
||||
var sut = new SecureStoreUnlockedFormViewModel(
|
||||
"test.secrets",
|
||||
"/path/to/test.secrets",
|
||||
0,
|
||||
false,
|
||||
() => { },
|
||||
() => { },
|
||||
() => { });
|
||||
|
||||
var propertyChangedRaised = false;
|
||||
sut.PropertyChanged += (s, e) =>
|
||||
{
|
||||
if (e.PropertyName == nameof(SecureStoreUnlockedFormViewModel.HasUnsavedChanges))
|
||||
propertyChangedRaised = true;
|
||||
};
|
||||
|
||||
// Act
|
||||
sut.HasUnsavedChanges = false; // Same as initial value
|
||||
|
||||
// Assert
|
||||
propertyChangedRaised.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LockCommand_InvokesCallback()
|
||||
{
|
||||
// Arrange
|
||||
var lockCalled = false;
|
||||
var sut = new SecureStoreUnlockedFormViewModel(
|
||||
"test.secrets",
|
||||
"/path/to/test.secrets",
|
||||
0,
|
||||
false,
|
||||
() => lockCalled = true,
|
||||
() => { },
|
||||
() => { });
|
||||
|
||||
// Act
|
||||
sut.LockCommand.Execute(null);
|
||||
|
||||
// Assert
|
||||
lockCalled.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddSecretCommand_InvokesCallback()
|
||||
{
|
||||
// Arrange
|
||||
var addSecretCalled = false;
|
||||
var sut = new SecureStoreUnlockedFormViewModel(
|
||||
"test.secrets",
|
||||
"/path/to/test.secrets",
|
||||
0,
|
||||
false,
|
||||
() => { },
|
||||
() => addSecretCalled = true,
|
||||
() => { });
|
||||
|
||||
// Act
|
||||
sut.AddSecretCommand.Execute(null);
|
||||
|
||||
// Assert
|
||||
addSecretCalled.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SaveCommand_InvokesCallback()
|
||||
{
|
||||
// Arrange
|
||||
var saveCalled = false;
|
||||
var sut = new SecureStoreUnlockedFormViewModel(
|
||||
"test.secrets",
|
||||
"/path/to/test.secrets",
|
||||
0,
|
||||
true, // Must have unsaved changes for save to be enabled
|
||||
() => { },
|
||||
() => { },
|
||||
() => saveCalled = true);
|
||||
|
||||
// Act
|
||||
sut.SaveCommand.Execute(null);
|
||||
|
||||
// Assert
|
||||
saveCalled.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SaveCommand_CanExecute_ReturnsFalse_WhenNoUnsavedChanges()
|
||||
{
|
||||
// Arrange
|
||||
var sut = new SecureStoreUnlockedFormViewModel(
|
||||
"test.secrets",
|
||||
"/path/to/test.secrets",
|
||||
0,
|
||||
false,
|
||||
() => { },
|
||||
() => { },
|
||||
() => { });
|
||||
|
||||
// Act & Assert
|
||||
sut.SaveCommand.CanExecute(null).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SaveCommand_CanExecute_ReturnsTrue_WhenHasUnsavedChanges()
|
||||
{
|
||||
// Arrange
|
||||
var sut = new SecureStoreUnlockedFormViewModel(
|
||||
"test.secrets",
|
||||
"/path/to/test.secrets",
|
||||
0,
|
||||
true,
|
||||
() => { },
|
||||
() => { },
|
||||
() => { });
|
||||
|
||||
// Act & Assert
|
||||
sut.SaveCommand.CanExecute(null).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SaveCommand_CanExecute_Updates_WhenHasUnsavedChangesChanges()
|
||||
{
|
||||
// Arrange
|
||||
var sut = new SecureStoreUnlockedFormViewModel(
|
||||
"test.secrets",
|
||||
"/path/to/test.secrets",
|
||||
0,
|
||||
false,
|
||||
() => { },
|
||||
() => { },
|
||||
() => { });
|
||||
|
||||
// Initial state - can't save
|
||||
sut.SaveCommand.CanExecute(null).ShouldBeFalse();
|
||||
|
||||
// Act
|
||||
sut.HasUnsavedChanges = true;
|
||||
|
||||
// Assert
|
||||
sut.SaveCommand.CanExecute(null).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SaveCommand_RaisesCanExecuteChanged_WhenHasUnsavedChangesChanges()
|
||||
{
|
||||
// Arrange
|
||||
var sut = new SecureStoreUnlockedFormViewModel(
|
||||
"test.secrets",
|
||||
"/path/to/test.secrets",
|
||||
0,
|
||||
false,
|
||||
() => { },
|
||||
() => { },
|
||||
() => { });
|
||||
|
||||
var canExecuteChangedRaised = false;
|
||||
sut.SaveCommand.CanExecuteChanged += (s, e) => canExecuteChangedRaised = true;
|
||||
|
||||
// Act
|
||||
sut.HasUnsavedChanges = true;
|
||||
|
||||
// Assert
|
||||
canExecuteChangedRaised.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LockCommand_CanExecute_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var sut = new SecureStoreUnlockedFormViewModel(
|
||||
"test.secrets",
|
||||
"/path/to/test.secrets",
|
||||
0,
|
||||
false,
|
||||
() => { },
|
||||
() => { },
|
||||
() => { });
|
||||
|
||||
// Act & Assert
|
||||
sut.LockCommand.CanExecute(null).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddSecretCommand_CanExecute_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var sut = new SecureStoreUnlockedFormViewModel(
|
||||
"test.secrets",
|
||||
"/path/to/test.secrets",
|
||||
0,
|
||||
false,
|
||||
() => { },
|
||||
() => { },
|
||||
() => { });
|
||||
|
||||
// Act & Assert
|
||||
sut.AddSecretCommand.CanExecute(null).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_ThrowsOnNullStoreName()
|
||||
{
|
||||
// Act & Assert
|
||||
Should.Throw<ArgumentNullException>(() =>
|
||||
new SecureStoreUnlockedFormViewModel(null!, "/path", 0, false, () => { }, () => { }, () => { }));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_ThrowsOnNullStorePath()
|
||||
{
|
||||
// Act & Assert
|
||||
Should.Throw<ArgumentNullException>(() =>
|
||||
new SecureStoreUnlockedFormViewModel("test", null!, 0, false, () => { }, () => { }, () => { }));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_ThrowsOnNullLockCallback()
|
||||
{
|
||||
// Act & Assert
|
||||
Should.Throw<ArgumentNullException>(() =>
|
||||
new SecureStoreUnlockedFormViewModel("test", "/path", 0, false, null!, () => { }, () => { }));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_ThrowsOnNullAddSecretCallback()
|
||||
{
|
||||
// Act & Assert
|
||||
Should.Throw<ArgumentNullException>(() =>
|
||||
new SecureStoreUnlockedFormViewModel("test", "/path", 0, false, () => { }, null!, () => { }));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_ThrowsOnNullSaveCallback()
|
||||
{
|
||||
// Act & Assert
|
||||
Should.Throw<ArgumentNullException>(() =>
|
||||
new SecureStoreUnlockedFormViewModel("test", "/path", 0, false, () => { }, () => { }, null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReadOnlyProperties_CannotBeModified()
|
||||
{
|
||||
// Assert - Verify StoreName, StorePath, and SecretCount are get-only
|
||||
var storeNameProperty = typeof(SecureStoreUnlockedFormViewModel).GetProperty(nameof(SecureStoreUnlockedFormViewModel.StoreName));
|
||||
var storePathProperty = typeof(SecureStoreUnlockedFormViewModel).GetProperty(nameof(SecureStoreUnlockedFormViewModel.StorePath));
|
||||
var secretCountProperty = typeof(SecureStoreUnlockedFormViewModel).GetProperty(nameof(SecureStoreUnlockedFormViewModel.SecretCount));
|
||||
|
||||
storeNameProperty!.CanWrite.ShouldBeFalse();
|
||||
storePathProperty!.CanWrite.ShouldBeFalse();
|
||||
secretCountProperty!.CanWrite.ShouldBeFalse();
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
using JdeScoping.ConfigManager.Models;
|
||||
using JdeScoping.ConfigManager.Services;
|
||||
using JdeScoping.ConfigManager.Services.SecureStore;
|
||||
using JdeScoping.ConfigManager.ViewModels;
|
||||
using JdeScoping.ConfigManager.ViewModels.Forms;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -15,6 +16,8 @@ public class MainWindowViewModelTests
|
||||
private readonly IBackupService _backupService;
|
||||
private readonly IAutoDiscoveryService _autoDiscoveryService;
|
||||
private readonly IDialogService _dialogService;
|
||||
private readonly ISecureStoreManager _secureStoreManager;
|
||||
private readonly IClipboardService _clipboardService;
|
||||
private readonly ILogger<MainWindowViewModel> _logger;
|
||||
|
||||
public MainWindowViewModelTests()
|
||||
@@ -25,6 +28,8 @@ public class MainWindowViewModelTests
|
||||
_backupService = Substitute.For<IBackupService>();
|
||||
_autoDiscoveryService = Substitute.For<IAutoDiscoveryService>();
|
||||
_dialogService = Substitute.For<IDialogService>();
|
||||
_secureStoreManager = Substitute.For<ISecureStoreManager>();
|
||||
_clipboardService = Substitute.For<IClipboardService>();
|
||||
_logger = Substitute.For<ILogger<MainWindowViewModel>>();
|
||||
|
||||
_validationService.ValidateAppSettings(Arg.Any<ConfigModel>())
|
||||
@@ -274,7 +279,7 @@ public class MainWindowViewModelTests
|
||||
sut.LoadConfigForTesting(config, null);
|
||||
|
||||
// Assert
|
||||
sut.TreeNodes.Count.ShouldBe(2); // Settings and Pipelines folders
|
||||
sut.TreeNodes.Count.ShouldBe(3); // Settings, Pipelines, and Secure Stores folders
|
||||
sut.TreeNodes[0].Name.ShouldBe("Settings");
|
||||
sut.TreeNodes[0].Children.Count.ShouldBe(6); // DataSync, DataAccess, Auth, Ldap, Search, ExcelExport
|
||||
}
|
||||
@@ -311,6 +316,8 @@ public class MainWindowViewModelTests
|
||||
_backupService,
|
||||
_autoDiscoveryService,
|
||||
_dialogService,
|
||||
_secureStoreManager,
|
||||
_clipboardService,
|
||||
_logger);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -218,4 +218,180 @@ public class TreeNodeViewModelTests
|
||||
// Assert
|
||||
propertyChangedRaised.ShouldBeFalse();
|
||||
}
|
||||
|
||||
#region SecureStore Node Type Tests
|
||||
|
||||
[Theory]
|
||||
[InlineData(TreeNodeType.SecureStoresFolder)]
|
||||
[InlineData(TreeNodeType.SecureStore)]
|
||||
[InlineData(TreeNodeType.Secret)]
|
||||
public void Constructor_WithSecureStoreNodeTypes_SetsNodeTypeCorrectly(TreeNodeType nodeType)
|
||||
{
|
||||
// Arrange & Act
|
||||
var sut = new TreeNodeViewModel("Test", "icon", nodeType);
|
||||
|
||||
// Assert
|
||||
sut.NodeType.ShouldBe(nodeType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsUnlocked_DefaultsToFalse()
|
||||
{
|
||||
// Arrange & Act
|
||||
var sut = new TreeNodeViewModel("Store", "🔒", TreeNodeType.SecureStore);
|
||||
|
||||
// Assert
|
||||
sut.IsUnlocked.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsUnlocked_WhenSet_RaisesPropertyChanged()
|
||||
{
|
||||
// Arrange
|
||||
var sut = new TreeNodeViewModel("Store", "🔒", TreeNodeType.SecureStore);
|
||||
var propertyChangedRaised = false;
|
||||
sut.PropertyChanged += (s, e) =>
|
||||
{
|
||||
if (e.PropertyName == nameof(TreeNodeViewModel.IsUnlocked))
|
||||
propertyChangedRaised = true;
|
||||
};
|
||||
|
||||
// Act
|
||||
sut.IsUnlocked = true;
|
||||
|
||||
// Assert
|
||||
propertyChangedRaised.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsUnlocked_WhenSet_RaisesPropertyChangedForLockIcon()
|
||||
{
|
||||
// Arrange
|
||||
var sut = new TreeNodeViewModel("Store", "🔒", TreeNodeType.SecureStore);
|
||||
var lockIconChanged = false;
|
||||
sut.PropertyChanged += (s, e) =>
|
||||
{
|
||||
if (e.PropertyName == nameof(TreeNodeViewModel.LockIcon))
|
||||
lockIconChanged = true;
|
||||
};
|
||||
|
||||
// Act
|
||||
sut.IsUnlocked = true;
|
||||
|
||||
// Assert
|
||||
lockIconChanged.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LockIcon_WhenLocked_ReturnsLockedIcon()
|
||||
{
|
||||
// Arrange & Act
|
||||
var sut = new TreeNodeViewModel("Store", "🔒", TreeNodeType.SecureStore);
|
||||
|
||||
// Assert
|
||||
sut.LockIcon.ShouldBe("🔒");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LockIcon_WhenUnlocked_ReturnsUnlockedIcon()
|
||||
{
|
||||
// Arrange
|
||||
var sut = new TreeNodeViewModel("Store", "🔒", TreeNodeType.SecureStore);
|
||||
|
||||
// Act
|
||||
sut.IsUnlocked = true;
|
||||
|
||||
// Assert
|
||||
sut.LockIcon.ShouldBe("🔓");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsLocked_WhenUnlocked_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var sut = new TreeNodeViewModel("Store", "🔒", TreeNodeType.SecureStore);
|
||||
|
||||
// Act
|
||||
sut.IsUnlocked = true;
|
||||
|
||||
// Assert
|
||||
sut.IsLocked.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsLocked_WhenLocked_ReturnsTrue()
|
||||
{
|
||||
// Arrange & Act
|
||||
var sut = new TreeNodeViewModel("Store", "🔒", TreeNodeType.SecureStore);
|
||||
|
||||
// Assert
|
||||
sut.IsLocked.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsUnlocked_WhenSet_RaisesPropertyChangedForIsLocked()
|
||||
{
|
||||
// Arrange
|
||||
var sut = new TreeNodeViewModel("Store", "🔒", TreeNodeType.SecureStore);
|
||||
var isLockedChanged = false;
|
||||
sut.PropertyChanged += (s, e) =>
|
||||
{
|
||||
if (e.PropertyName == nameof(TreeNodeViewModel.IsLocked))
|
||||
isLockedChanged = true;
|
||||
};
|
||||
|
||||
// Act
|
||||
sut.IsUnlocked = true;
|
||||
|
||||
// Assert
|
||||
isLockedChanged.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StorePath_CanBeSetViaInitializer()
|
||||
{
|
||||
// Arrange & Act
|
||||
var sut = new TreeNodeViewModel("Store", "🔒", TreeNodeType.SecureStore)
|
||||
{
|
||||
StorePath = "/path/to/store.secrets"
|
||||
};
|
||||
|
||||
// Assert
|
||||
sut.StorePath.ShouldBe("/path/to/store.secrets");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StorePath_DefaultsToNull()
|
||||
{
|
||||
// Arrange & Act
|
||||
var sut = new TreeNodeViewModel("Store", "🔒", TreeNodeType.SecureStore);
|
||||
|
||||
// Assert
|
||||
sut.StorePath.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SecretKey_CanBeSetViaInitializer()
|
||||
{
|
||||
// Arrange & Act
|
||||
var sut = new TreeNodeViewModel("MySecret", "🔑", TreeNodeType.Secret)
|
||||
{
|
||||
SecretKey = "ConnectionStrings:Database"
|
||||
};
|
||||
|
||||
// Assert
|
||||
sut.SecretKey.ShouldBe("ConnectionStrings:Database");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SecretKey_DefaultsToNull()
|
||||
{
|
||||
// Arrange & Act
|
||||
var sut = new TreeNodeViewModel("MySecret", "🔑", TreeNodeType.Secret);
|
||||
|
||||
// Assert
|
||||
sut.SecretKey.ShouldBeNull();
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
@@ -0,0 +1,513 @@
|
||||
# SecureStoreManager Integration into ConfigManager
|
||||
|
||||
**Date:** 2026-01-20
|
||||
**Status:** Ready for Implementation
|
||||
**Estimated Phases:** 9
|
||||
|
||||
## Overview
|
||||
|
||||
Merge the complete functionality of SecureStoreManager into ConfigManager, creating a unified configuration and secrets management application. This integration adds encrypted secret store management to the existing configuration editing capabilities.
|
||||
|
||||
## Design Decisions
|
||||
|
||||
| Decision | Choice | Rationale |
|
||||
|----------|--------|-----------|
|
||||
| Integration model | Tree node with explicit auth | Security: secrets require conscious authentication |
|
||||
| Store discovery | Hybrid (auto-discover + manual add) | Convenience for co-located stores, flexibility for external |
|
||||
| Credential caching | Prompt every time (session cache opt-in) | Default to most secure behavior |
|
||||
| Save behavior | Separate saves (config vs stores) | Different security domains shouldn't couple |
|
||||
| Service architecture | Use cases layer | Matches existing pattern, separation of concerns |
|
||||
|
||||
## Architecture
|
||||
|
||||
### Tree Structure
|
||||
```
|
||||
📁 Configuration
|
||||
├── DataSync
|
||||
├── DataAccess
|
||||
├── Auth
|
||||
├── LDAP
|
||||
├── Search
|
||||
└── Excel Export
|
||||
📁 Pipelines
|
||||
└── (pipeline nodes)
|
||||
🛡️ Secure Stores
|
||||
├── 🔒 production.secrets (locked)
|
||||
└── 🔓 development.secrets (unlocked)
|
||||
├── 🔑 ConnectionStrings:JDE
|
||||
└── 🔑 LdapPassword
|
||||
```
|
||||
|
||||
### Service Layer
|
||||
```
|
||||
MainWindowViewModel
|
||||
├── IConfigFileService (existing - config operations)
|
||||
├── IStoreUseCases (new - secure store operations)
|
||||
└── ISecretUseCases (new - secret operations)
|
||||
└── ISecureStoreManager (new - low-level encryption)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Project Setup & Dependencies
|
||||
|
||||
### 1.1 Add SecureStore NuGet Package
|
||||
**File:** `NEW/src/Utils/JdeScoping.ConfigManager/JdeScoping.ConfigManager.csproj`
|
||||
|
||||
Add:
|
||||
```xml
|
||||
<PackageReference Include="SecureStore" Version="1.2.0" />
|
||||
```
|
||||
|
||||
### 1.2 Add Avalonia.Headless.XUnit to Test Project
|
||||
**File:** `NEW/tests/JdeScoping.ConfigManager.Tests/JdeScoping.ConfigManager.Tests.csproj`
|
||||
|
||||
Add:
|
||||
```xml
|
||||
<PackageReference Include="Avalonia.Headless.XUnit" Version="11.2.*" />
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Copy Service Layer
|
||||
|
||||
### 2.1 Copy Core Service Files
|
||||
Copy from `SecureStoreManager/Services/` to `ConfigManager/Services/SecureStore/`:
|
||||
|
||||
| Source | Destination |
|
||||
|--------|-------------|
|
||||
| `ISecureStoreManager.cs` | `Services/SecureStore/ISecureStoreManager.cs` |
|
||||
| `SecureStoreManager.cs` | `Services/SecureStore/SecureStoreManager.cs` |
|
||||
|
||||
Update namespace: `JdeScoping.SecureStoreManager.Services` → `JdeScoping.ConfigManager.Services.SecureStore`
|
||||
|
||||
### 2.2 Copy Use Cases
|
||||
Copy from `SecureStoreManager/Application/` to `ConfigManager/Application/`:
|
||||
|
||||
| Source | Destination |
|
||||
|--------|-------------|
|
||||
| `StoreUseCases.cs` | `Application/StoreUseCases.cs` |
|
||||
| `SecretUseCases.cs` | `Application/SecretUseCases.cs` |
|
||||
|
||||
Update namespaces accordingly.
|
||||
|
||||
### 2.3 Copy Constants
|
||||
Copy from `SecureStoreManager/Constants/` to `ConfigManager/Constants/`:
|
||||
|
||||
| Source | Destination |
|
||||
|--------|-------------|
|
||||
| `DialogStrings.cs` | `Constants/SecureStoreStrings.cs` (rename to avoid conflicts) |
|
||||
| `FileExtensions.cs` | `Constants/SecureStoreFileExtensions.cs` |
|
||||
|
||||
### 2.4 Copy Clipboard Service
|
||||
Copy from `SecureStoreManager/Services/` to `ConfigManager/Services/`:
|
||||
|
||||
| Source | Destination |
|
||||
|--------|-------------|
|
||||
| `IClipboardService.cs` | `Services/IClipboardService.cs` |
|
||||
| `AvaloniaClipboardService.cs` | `Services/AvaloniaClipboardService.cs` |
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Extend Tree Node Model
|
||||
|
||||
### 3.1 Update TreeNodeType Enum
|
||||
**File:** `ViewModels/TreeNodeViewModel.cs`
|
||||
|
||||
```csharp
|
||||
public enum TreeNodeType
|
||||
{
|
||||
Folder,
|
||||
SettingsSection,
|
||||
Pipeline,
|
||||
SecureStoresFolder, // New
|
||||
SecureStore, // New
|
||||
Secret // New
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 Add SecureStore-Specific Properties
|
||||
**File:** `ViewModels/TreeNodeViewModel.cs`
|
||||
|
||||
Add to `TreeNodeViewModel`:
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// Gets or sets whether this secure store is currently unlocked.
|
||||
/// Only applicable for SecureStore node types.
|
||||
/// </summary>
|
||||
public bool IsUnlocked
|
||||
{
|
||||
get => _isUnlocked;
|
||||
set
|
||||
{
|
||||
if (SetProperty(ref _isUnlocked, value))
|
||||
{
|
||||
OnPropertyChanged(nameof(LockIcon));
|
||||
OnPropertyChanged(nameof(IsLocked));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether this secure store is locked.
|
||||
/// </summary>
|
||||
public bool IsLocked => !IsUnlocked;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the lock icon for secure store nodes.
|
||||
/// </summary>
|
||||
public string LockIcon => IsUnlocked ? "🔓" : "🔒";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the full path to the secure store file.
|
||||
/// Only applicable for SecureStore node types.
|
||||
/// </summary>
|
||||
public string? StorePath { get; init; }
|
||||
```
|
||||
|
||||
### 3.3 Update Icon Property Logic
|
||||
Update the `Icon` property or add computed property for dynamic icons based on node type and lock state.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Create New ViewModels
|
||||
|
||||
### 4.1 SecureStoreFormViewModel (Locked State)
|
||||
**File:** `ViewModels/Forms/SecureStoreLockedFormViewModel.cs`
|
||||
|
||||
Displays when a locked store is selected:
|
||||
- Store name and path
|
||||
- "This store is locked" message
|
||||
- "Unlock Store..." button
|
||||
- Last modified date (from file system)
|
||||
|
||||
### 4.2 SecureStoreFormViewModel (Unlocked State)
|
||||
**File:** `ViewModels/Forms/SecureStoreUnlockedFormViewModel.cs`
|
||||
|
||||
Displays when an unlocked store is selected:
|
||||
- Store name and path
|
||||
- Secret count
|
||||
- "Lock Store" button
|
||||
- "Add Secret" button
|
||||
- List of secrets (or indicate to select a secret from tree)
|
||||
|
||||
### 4.3 SecretFormViewModel
|
||||
**File:** `ViewModels/Forms/SecretFormViewModel.cs`
|
||||
|
||||
Displays when a secret is selected:
|
||||
- Key (read-only)
|
||||
- Value (password-masked by default)
|
||||
- Show/Hide toggle button
|
||||
- Copy to Clipboard button
|
||||
- Delete button
|
||||
|
||||
Properties:
|
||||
```csharp
|
||||
public string Key { get; }
|
||||
public string Value { get; set; }
|
||||
public bool IsValueVisible { get; set; }
|
||||
public string DisplayValue => IsValueVisible ? Value : new string('*', 8);
|
||||
public ICommand ToggleVisibilityCommand { get; }
|
||||
public ICommand CopyToClipboardCommand { get; }
|
||||
```
|
||||
|
||||
### 4.4 Dialog ViewModels
|
||||
Copy and adapt from SecureStoreManager:
|
||||
|
||||
| Source | Destination |
|
||||
|--------|-------------|
|
||||
| `NewStoreDialogViewModel.cs` | `ViewModels/Dialogs/NewStoreDialogViewModel.cs` |
|
||||
| `OpenStoreDialogViewModel.cs` | `ViewModels/Dialogs/UnlockStoreDialogViewModel.cs` (rename) |
|
||||
| `SecretEditDialogViewModel.cs` | `ViewModels/Dialogs/SecretEditDialogViewModel.cs` |
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Create New Views
|
||||
|
||||
### 5.1 Form Views
|
||||
**Directory:** `Views/Forms/`
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `SecureStoreLockedFormView.axaml` | Shows unlock button for locked stores |
|
||||
| `SecureStoreUnlockedFormView.axaml` | Shows store info when unlocked |
|
||||
| `SecretFormView.axaml` | Secret key/value editor with masking |
|
||||
|
||||
### 5.2 Dialog Views
|
||||
**Directory:** `Views/Dialogs/`
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `NewStoreDialog.axaml` | Create new secure store |
|
||||
| `UnlockStoreDialog.axaml` | Enter credentials to unlock |
|
||||
| `SecretEditDialog.axaml` | Add/edit secret key-value |
|
||||
|
||||
### 5.3 Update DataTemplates
|
||||
**File:** `App.axaml`
|
||||
|
||||
Add DataTemplates for automatic form selection:
|
||||
```xml
|
||||
<DataTemplate DataType="{x:Type vm:SecureStoreLockedFormViewModel}">
|
||||
<views:SecureStoreLockedFormView />
|
||||
</DataTemplate>
|
||||
<DataTemplate DataType="{x:Type vm:SecureStoreUnlockedFormViewModel}">
|
||||
<views:SecureStoreUnlockedFormView />
|
||||
</DataTemplate>
|
||||
<DataTemplate DataType="{x:Type vm:SecretFormViewModel}">
|
||||
<views:SecretFormView />
|
||||
</DataTemplate>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Update MainWindowViewModel
|
||||
|
||||
### 6.1 Add Dependencies
|
||||
```csharp
|
||||
private readonly IStoreUseCases _storeUseCases;
|
||||
private readonly ISecretUseCases _secretUseCases;
|
||||
private readonly IClipboardService _clipboardService;
|
||||
```
|
||||
|
||||
### 6.2 Add Secure Store Commands
|
||||
```csharp
|
||||
// Store commands
|
||||
public ICommand NewStoreCommand { get; }
|
||||
public ICommand AddExistingStoreCommand { get; }
|
||||
public ICommand UnlockStoreCommand { get; }
|
||||
public ICommand LockStoreCommand { get; }
|
||||
public ICommand SaveStoreCommand { get; }
|
||||
public ICommand LockAllStoresCommand { get; }
|
||||
public ICommand GenerateKeyFileCommand { get; }
|
||||
|
||||
// Secret commands
|
||||
public ICommand AddSecretCommand { get; }
|
||||
public ICommand EditSecretCommand { get; }
|
||||
public ICommand DeleteSecretCommand { get; }
|
||||
public ICommand CopySecretCommand { get; }
|
||||
```
|
||||
|
||||
### 6.3 Add Store State Tracking
|
||||
```csharp
|
||||
// Track open stores (store path -> credentials for session caching if enabled)
|
||||
private readonly Dictionary<string, SecureStoreState> _openStores = new();
|
||||
|
||||
private class SecureStoreState
|
||||
{
|
||||
public bool IsUnlocked { get; set; }
|
||||
public TreeNodeViewModel TreeNode { get; set; }
|
||||
}
|
||||
```
|
||||
|
||||
### 6.4 Update BuildTreeNodes Method
|
||||
Add logic to:
|
||||
1. Create "Secure Stores" folder node
|
||||
2. Discover `*.secrets.json` files in config folder
|
||||
3. Create SecureStore nodes for each discovered file
|
||||
4. Add manual store tracking
|
||||
|
||||
### 6.5 Update Form Selection Logic
|
||||
Extend `OnSelectedNodeChanged` to handle:
|
||||
- `SecureStoresFolder` → Show instructions/empty state
|
||||
- `SecureStore` (locked) → Show `SecureStoreLockedFormViewModel`
|
||||
- `SecureStore` (unlocked) → Show `SecureStoreUnlockedFormViewModel`
|
||||
- `Secret` → Show `SecretFormViewModel`
|
||||
|
||||
---
|
||||
|
||||
## Phase 7: Update Menu Bar and Toolbar
|
||||
|
||||
### 7.1 Add Secure Stores Menu
|
||||
**File:** `Views/MainWindow.axaml`
|
||||
|
||||
Add new menu between "Tools" and "Help":
|
||||
```xml
|
||||
<MenuItem Header="Secure Stores">
|
||||
<MenuItem Header="New Store..." Command="{Binding NewStoreCommand}" InputGesture="Ctrl+Shift+N" />
|
||||
<MenuItem Header="Add Existing Store..." Command="{Binding AddExistingStoreCommand}" InputGesture="Ctrl+Shift+O" />
|
||||
<Separator />
|
||||
<MenuItem Header="Save Store" Command="{Binding SaveStoreCommand}" InputGesture="Ctrl+Shift+S" />
|
||||
<MenuItem Header="Lock All Stores" Command="{Binding LockAllStoresCommand}" />
|
||||
<Separator />
|
||||
<MenuItem Header="Generate Key File..." Command="{Binding GenerateKeyFileCommand}" />
|
||||
</MenuItem>
|
||||
```
|
||||
|
||||
### 7.2 Add Toolbar Buttons
|
||||
Add after existing buttons with separator:
|
||||
```xml
|
||||
<Separator />
|
||||
<Button Command="{Binding UnlockStoreCommand}" ToolTip.Tip="Unlock/Lock Store">
|
||||
<TextBlock Text="🔓" />
|
||||
</Button>
|
||||
<Button Command="{Binding AddSecretCommand}" ToolTip.Tip="Add Secret">
|
||||
<TextBlock Text="🔑+" />
|
||||
</Button>
|
||||
```
|
||||
|
||||
### 7.3 Add Context Menus
|
||||
Add context menu to TreeView for right-click actions on nodes.
|
||||
|
||||
---
|
||||
|
||||
## Phase 8: Register Services
|
||||
|
||||
### 8.1 Update App.axaml.cs
|
||||
**File:** `App.axaml.cs`
|
||||
|
||||
Add to `ConfigureServices`:
|
||||
```csharp
|
||||
// SecureStore Services
|
||||
services.AddSingleton<ISecureStoreManager, SecureStoreManager>();
|
||||
services.AddSingleton<IClipboardService>(sp =>
|
||||
new AvaloniaClipboardService(GetMainWindow));
|
||||
|
||||
// SecureStore Use Cases
|
||||
services.AddSingleton<IStoreUseCases, StoreUseCases>();
|
||||
services.AddSingleton<ISecretUseCases, SecretUseCases>();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 9: Migrate Tests
|
||||
|
||||
### 9.1 Copy Service Tests
|
||||
Copy from `SecureStoreManager.Tests/Services/` to `ConfigManager.Tests/Services/SecureStore/`:
|
||||
|
||||
| Source | Destination |
|
||||
|--------|-------------|
|
||||
| `SecureStoreManagerTests.cs` | `Services/SecureStore/SecureStoreManagerTests.cs` |
|
||||
|
||||
Update namespaces.
|
||||
|
||||
### 9.2 Create New ViewModel Tests
|
||||
**Directory:** `ConfigManager.Tests/ViewModels/Forms/`
|
||||
|
||||
| File | Tests |
|
||||
|------|-------|
|
||||
| `SecureStoreLockedFormViewModelTests.cs` | Locked state display, unlock command |
|
||||
| `SecureStoreUnlockedFormViewModelTests.cs` | Unlocked state, secret list, lock command |
|
||||
| `SecretFormViewModelTests.cs` | Value masking, visibility toggle, copy |
|
||||
|
||||
### 9.3 Create Dialog ViewModel Tests
|
||||
**Directory:** `ConfigManager.Tests/ViewModels/Dialogs/`
|
||||
|
||||
| File | Tests |
|
||||
|------|-------|
|
||||
| `NewStoreDialogViewModelTests.cs` | Validation, path selection |
|
||||
| `UnlockStoreDialogViewModelTests.cs` | Credential validation |
|
||||
| `SecretEditDialogViewModelTests.cs` | Key/value validation |
|
||||
|
||||
### 9.4 Extend MainWindowViewModelTests
|
||||
Add tests for:
|
||||
- Secure store tree node creation
|
||||
- Store unlock/lock workflow
|
||||
- Secret CRUD operations
|
||||
- Form selection for store/secret nodes
|
||||
|
||||
### 9.5 Extend TreeNodeViewModelTests
|
||||
Add tests for:
|
||||
- New node types (SecureStoresFolder, SecureStore, Secret)
|
||||
- Lock state properties
|
||||
- Icon computation for store nodes
|
||||
|
||||
### 9.6 Create UI Tests (Optional)
|
||||
If UI testing is desired, copy patterns from `SecureStoreManager.Tests/Views/`:
|
||||
- Test dialog visual structure
|
||||
- Test form view elements
|
||||
|
||||
---
|
||||
|
||||
## File Inventory
|
||||
|
||||
### New Files to Create
|
||||
|
||||
**Services (5 files):**
|
||||
- `Services/SecureStore/ISecureStoreManager.cs`
|
||||
- `Services/SecureStore/SecureStoreManager.cs`
|
||||
- `Services/IClipboardService.cs`
|
||||
- `Services/AvaloniaClipboardService.cs`
|
||||
|
||||
**Application (2 files):**
|
||||
- `Application/StoreUseCases.cs`
|
||||
- `Application/SecretUseCases.cs`
|
||||
|
||||
**Constants (2 files):**
|
||||
- `Constants/SecureStoreStrings.cs`
|
||||
- `Constants/SecureStoreFileExtensions.cs`
|
||||
|
||||
**ViewModels (6 files):**
|
||||
- `ViewModels/Forms/SecureStoreLockedFormViewModel.cs`
|
||||
- `ViewModels/Forms/SecureStoreUnlockedFormViewModel.cs`
|
||||
- `ViewModels/Forms/SecretFormViewModel.cs`
|
||||
- `ViewModels/Dialogs/NewStoreDialogViewModel.cs`
|
||||
- `ViewModels/Dialogs/UnlockStoreDialogViewModel.cs`
|
||||
- `ViewModels/Dialogs/SecretEditDialogViewModel.cs`
|
||||
|
||||
**Views (6 files):**
|
||||
- `Views/Forms/SecureStoreLockedFormView.axaml` + `.cs`
|
||||
- `Views/Forms/SecureStoreUnlockedFormView.axaml` + `.cs`
|
||||
- `Views/Forms/SecretFormView.axaml` + `.cs`
|
||||
- `Views/Dialogs/NewStoreDialog.axaml` + `.cs`
|
||||
- `Views/Dialogs/UnlockStoreDialog.axaml` + `.cs`
|
||||
- `Views/Dialogs/SecretEditDialog.axaml` + `.cs`
|
||||
|
||||
**Tests (8+ files):**
|
||||
- `Services/SecureStore/SecureStoreManagerTests.cs`
|
||||
- `ViewModels/Forms/SecureStoreLockedFormViewModelTests.cs`
|
||||
- `ViewModels/Forms/SecureStoreUnlockedFormViewModelTests.cs`
|
||||
- `ViewModels/Forms/SecretFormViewModelTests.cs`
|
||||
- `ViewModels/Dialogs/NewStoreDialogViewModelTests.cs`
|
||||
- `ViewModels/Dialogs/UnlockStoreDialogViewModelTests.cs`
|
||||
- `ViewModels/Dialogs/SecretEditDialogViewModelTests.cs`
|
||||
|
||||
### Files to Modify
|
||||
|
||||
- `JdeScoping.ConfigManager.csproj` (add SecureStore package)
|
||||
- `App.axaml.cs` (register services)
|
||||
- `App.axaml` (add DataTemplates)
|
||||
- `ViewModels/TreeNodeViewModel.cs` (add enum values, properties)
|
||||
- `ViewModels/MainWindowViewModel.cs` (add commands, store handling)
|
||||
- `Views/MainWindow.axaml` (add menu, toolbar, context menu)
|
||||
- `JdeScoping.ConfigManager.Tests.csproj` (add Avalonia.Headless.XUnit)
|
||||
|
||||
---
|
||||
|
||||
## Implementation Order
|
||||
|
||||
1. **Phase 1** - Project setup (dependencies)
|
||||
2. **Phase 2** - Copy service layer (foundation)
|
||||
3. **Phase 3** - Extend tree model (data structure)
|
||||
4. **Phase 4** - Create ViewModels (business logic)
|
||||
5. **Phase 5** - Create Views (UI)
|
||||
6. **Phase 6** - Update MainWindowViewModel (orchestration)
|
||||
7. **Phase 7** - Update menus/toolbar (discoverability)
|
||||
8. **Phase 8** - Register services (wiring)
|
||||
9. **Phase 9** - Migrate tests (verification)
|
||||
|
||||
---
|
||||
|
||||
## Verification Checklist
|
||||
|
||||
After implementation, verify:
|
||||
|
||||
- [ ] Can create new secure store with key file
|
||||
- [ ] Can create new secure store with password
|
||||
- [ ] Can add existing store to tree
|
||||
- [ ] Stores appear locked by default
|
||||
- [ ] Double-click unlocks store (shows dialog)
|
||||
- [ ] Unlock button in form works
|
||||
- [ ] Unlocked store shows secrets in tree
|
||||
- [ ] Can add new secret
|
||||
- [ ] Can edit existing secret
|
||||
- [ ] Can delete secret
|
||||
- [ ] Secret value is masked by default
|
||||
- [ ] Show/Hide toggle works
|
||||
- [ ] Copy to clipboard works
|
||||
- [ ] Can lock store manually
|
||||
- [ ] Lock All Stores works
|
||||
- [ ] Save Store saves only that store
|
||||
- [ ] Save (Ctrl+S) only saves config
|
||||
- [ ] Unsaved changes indicator works for stores
|
||||
- [ ] Generate Key File works
|
||||
- [ ] All tests pass
|
||||
Reference in New Issue
Block a user