diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/StoreTarget.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/StoreTarget.cs new file mode 100644 index 0000000..9721b9e --- /dev/null +++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/StoreTarget.cs @@ -0,0 +1,17 @@ +using Microsoft.Extensions.Configuration; +using ZB.MOM.WW.Secrets.Replicator.SqlServer; + +namespace ZB.MOM.WW.Secrets.Cli.Interactive; + +/// A resolved deployment store target: the app's composed Secrets options + where they came from. +/// Display name (defaults to the appsettings parent directory name). +/// The base appsettings.json path, or for manual-entry targets. +/// The bound application secrets options, with already made absolute. +/// The bound SQL-Server hub options when a Secrets:SqlServer section is present; otherwise . +/// The full composed configuration, consumed later by the reference auditor. +public sealed record StoreTarget( + string Name, + string? AppSettingsPath, + SecretsOptions Secrets, + SqlServerSecretsOptions? SqlServer, + IConfigurationRoot Configuration); diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/TargetConfigReader.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/TargetConfigReader.cs new file mode 100644 index 0000000..50949a1 --- /dev/null +++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/TargetConfigReader.cs @@ -0,0 +1,85 @@ +using Microsoft.Extensions.Configuration; +using ZB.MOM.WW.Secrets.MasterKey; +using ZB.MOM.WW.Secrets.Replicator.SqlServer; + +namespace ZB.MOM.WW.Secrets.Cli.Interactive; + +/// +/// Composes a deployment target's Secrets configuration the same way the target app would at +/// startup — base appsettings.json, optional per-environment overlay, and environment +/// variables — so the interactive console operates on exactly the store the app reads. +/// +public sealed class TargetConfigReader +{ + /// + /// Loads the target app's config exactly the way the app would (base json + optional environment + /// overlay + environment variables) and binds the Secrets options out of it. + /// + /// Absolute or relative path to the target app's base appsettings.json. + /// + /// Optional ASP.NET environment name; when non-empty an appsettings.{environment}.json + /// overlay is layered on top of the base file (optional — absent overlays are ignored). + /// + /// The configuration section the Secrets options bind from. Defaults to Secrets. + /// The resolved , with the SQLite path made absolute against the app directory. + /// The base does not exist. + public StoreTarget Read(string appSettingsPath, string? environment = null, string sectionPath = "Secrets") + { + string fullPath = Path.GetFullPath(appSettingsPath); + if (!File.Exists(fullPath)) + { + throw new FileNotFoundException( + $"Target appsettings file not found: {fullPath}", fullPath); + } + + string dir = Path.GetDirectoryName(fullPath)!; + string fileName = Path.GetFileName(fullPath); + + ConfigurationBuilder builder = new(); + builder.SetBasePath(dir) + .AddJsonFile(fileName, optional: false); + + if (!string.IsNullOrEmpty(environment)) + { + builder.AddJsonFile($"appsettings.{environment}.json", optional: true); + } + + builder.AddEnvironmentVariables(); + + IConfigurationRoot config = builder.Build(); + + SecretsOptions secrets = config.GetSection(sectionPath).Get() ?? new SecretsOptions(); + secrets.SqlitePath = Path.GetFullPath(secrets.SqlitePath, dir); + + SqlServerSecretsOptions? sqlServer = null; + IConfigurationSection sqlSection = config.GetSection(sectionPath + ":SqlServer"); + if (sqlSection.GetChildren().Any()) + { + sqlServer = sqlSection.Get(); + } + + string name = new DirectoryInfo(dir).Name; + + return new StoreTarget(name, fullPath, secrets, sqlServer, config); + } + + /// + /// Builds a manual target from an explicit db path + master-key options, with no backing + /// appsettings file (for ad-hoc stores the console operates on directly). + /// + /// Path to the SQLite store; made absolute against the current directory. + /// The master-key resolution options for this manual store. + /// A named manual with an empty composed configuration and no SQL-Server hub. + public StoreTarget Manual(string sqlitePath, MasterKeyOptions masterKey) + { + SecretsOptions secrets = new() + { + SqlitePath = Path.GetFullPath(sqlitePath), + MasterKey = masterKey, + }; + + IConfigurationRoot config = new ConfigurationBuilder().Build(); + + return new StoreTarget("manual", AppSettingsPath: null, secrets, SqlServer: null, config); + } +} diff --git a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Cli/Interactive/TargetConfigReaderTests.cs b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Cli/Interactive/TargetConfigReaderTests.cs new file mode 100644 index 0000000..5682cca --- /dev/null +++ b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Cli/Interactive/TargetConfigReaderTests.cs @@ -0,0 +1,146 @@ +using ZB.MOM.WW.Secrets.Cli.Interactive; +using ZB.MOM.WW.Secrets.MasterKey; + +namespace ZB.MOM.WW.Secrets.Tests.Cli.Interactive; + +/// +/// Tests for : it must compose the target app's configuration +/// (base json + optional environment overlay + environment variables) exactly the way the app +/// would at startup, so fixes land in the store the app actually reads. +/// +public sealed class TargetConfigReaderTests +{ + private static string NewTempDir() + { + string dir = Path.Combine(Path.GetTempPath(), "zb-secrets-cfg-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(dir); + return dir; + } + + [Fact] + public void Reads_secrets_section_and_binds_options() + { + string dir = NewTempDir(); + string path = Path.Combine(dir, "appsettings.json"); + File.WriteAllText(path, """ + { + "Secrets": { + "SqlitePath": "custom.db", + "MasterKey": { + "Source": "Environment", + "EnvVarName": "MY_KEY" + } + } + } + """); + + StoreTarget target = new TargetConfigReader().Read(path); + + Assert.Equal(MasterKeySource.Environment, target.Secrets.MasterKey.Source); + Assert.Equal("MY_KEY", target.Secrets.MasterKey.EnvVarName); + Assert.Equal(Path.Combine(dir, "custom.db"), target.Secrets.SqlitePath); + } + + [Fact] + public void Environment_overlay_wins_over_base() + { + string dir = NewTempDir(); + File.WriteAllText(Path.Combine(dir, "appsettings.json"), """ + { + "Secrets": { "SqlitePath": "base.db" } + } + """); + File.WriteAllText(Path.Combine(dir, "appsettings.Production.json"), """ + { + "Secrets": { "SqlitePath": "prod.db" } + } + """); + + StoreTarget target = new TargetConfigReader().Read( + Path.Combine(dir, "appsettings.json"), environment: "Production"); + + Assert.Equal(Path.Combine(dir, "prod.db"), target.Secrets.SqlitePath); + } + + [Fact] + public void Binds_sqlserver_options_when_section_present() + { + string dir = NewTempDir(); + File.WriteAllText(Path.Combine(dir, "appsettings.json"), """ + { + "Secrets": { + "SqlitePath": "secrets.db", + "SqlServer": { + "ConnectionString": "Server=hub;Database=ZbSecrets;Trusted_Connection=True" + } + } + } + """); + + StoreTarget withSql = new TargetConfigReader().Read(Path.Combine(dir, "appsettings.json")); + + Assert.NotNull(withSql.SqlServer); + Assert.Equal( + "Server=hub;Database=ZbSecrets;Trusted_Connection=True", + withSql.SqlServer!.ConnectionString); + + string dir2 = NewTempDir(); + File.WriteAllText(Path.Combine(dir2, "appsettings.json"), """ + { + "Secrets": { "SqlitePath": "secrets.db" } + } + """); + + StoreTarget noSql = new TargetConfigReader().Read(Path.Combine(dir2, "appsettings.json")); + + Assert.Null(noSql.SqlServer); + } + + [Fact] + public void SqlitePath_is_resolved_relative_to_the_app_directory() + { + string dir = NewTempDir(); + File.WriteAllText(Path.Combine(dir, "appsettings.json"), """ + { + "Secrets": { "SqlitePath": "secrets.db" } + } + """); + + StoreTarget target = new TargetConfigReader().Read(Path.Combine(dir, "appsettings.json")); + + Assert.True(Path.IsPathRooted(target.Secrets.SqlitePath)); + Assert.Equal(Path.Combine(dir, "secrets.db"), target.Secrets.SqlitePath); + } + + [Fact] + public void Missing_file_throws_FileNotFoundException_with_path() + { + string dir = NewTempDir(); + string path = Path.Combine(dir, "appsettings.json"); + + FileNotFoundException ex = Assert.Throws( + () => new TargetConfigReader().Read(path)); + + Assert.Contains(path, ex.Message); + } + + [Fact] + public void Custom_section_path_is_honored() + { + string dir = NewTempDir(); + File.WriteAllText(Path.Combine(dir, "appsettings.json"), """ + { + "MySecrets": { + "SqlitePath": "elsewhere.db", + "MasterKey": { "EnvVarName": "OTHER_KEY" } + } + } + """); + + StoreTarget target = new TargetConfigReader().Read( + Path.Combine(dir, "appsettings.json"), sectionPath: "MySecrets"); + + Assert.Equal("OTHER_KEY", target.Secrets.MasterKey.EnvVarName); + Assert.Equal(Path.Combine(dir, "elsewhere.db"), target.Secrets.SqlitePath); + } +}