From 9de04520e05b9458e376e8c0780fdff4dd7bade4 Mon Sep 17 00:00:00 2001 From: Liam Dawson Date: Tue, 6 Nov 2018 05:44:50 +1100 Subject: [PATCH] Add `user-secret init` command for adding a UserSecretsId to a project file (#500) --- src/dotnet-user-secrets/CommandLineOptions.cs | 1 + .../Internal/InitCommand.cs | 126 ++++++++++++++++++ src/dotnet-user-secrets/Program.cs | 6 + .../Properties/Resources.Designer.cs | 122 +++++++++-------- src/dotnet-user-secrets/Resources.resx | 63 +++++---- .../InitCommandTest.cs | 119 +++++++++++++++++ .../SecretManagerTests.cs | 15 ++- 7 files changed, 370 insertions(+), 82 deletions(-) create mode 100644 src/dotnet-user-secrets/Internal/InitCommand.cs create mode 100644 test/dotnet-user-secrets.Tests/InitCommandTest.cs diff --git a/src/dotnet-user-secrets/CommandLineOptions.cs b/src/dotnet-user-secrets/CommandLineOptions.cs index 6ce543cc00..8495b6de9d 100644 --- a/src/dotnet-user-secrets/CommandLineOptions.cs +++ b/src/dotnet-user-secrets/CommandLineOptions.cs @@ -50,6 +50,7 @@ namespace Microsoft.Extensions.SecretManager.Tools app.Command("remove", c => RemoveCommand.Configure(c, options)); app.Command("list", c => ListCommand.Configure(c, options)); app.Command("clear", c => ClearCommand.Configure(c, options)); + app.Command("init", c => InitCommandFactory.Configure(c, options)); // Show help information if no subcommand/option was specified. app.OnExecute(() => app.ShowHelp()); diff --git a/src/dotnet-user-secrets/Internal/InitCommand.cs b/src/dotnet-user-secrets/Internal/InitCommand.cs new file mode 100644 index 0000000000..670da42c4e --- /dev/null +++ b/src/dotnet-user-secrets/Internal/InitCommand.cs @@ -0,0 +1,126 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.IO; +using System.Linq; +using System.Xml.Linq; +using System.Xml.XPath; +using Microsoft.Extensions.CommandLineUtils; + +namespace Microsoft.Extensions.SecretManager.Tools.Internal +{ + // Workaround used to handle the fact that the options have not been parsed at configuration time + public class InitCommandFactory : ICommand + { + public CommandLineOptions Options { get; } + + internal static void Configure(CommandLineApplication command, CommandLineOptions options) + { + command.Description = "Set a user secrets ID to enable secret storage"; + command.HelpOption(); + + command.OnExecute(() => + { + options.Command = new InitCommandFactory(options); + }); + } + + public InitCommandFactory(CommandLineOptions options) + { + Options = options; + } + + public void Execute(CommandContext context) + { + new InitCommand(Options.Id, Options.Project).Execute(context); + } + + public void Execute(CommandContext context, string workingDirectory) + { + new InitCommand(Options.Id, Options.Project).Execute(context, workingDirectory); + } + } + + public class InitCommand : ICommand + { + public string OverrideId { get; } + public string ProjectPath { get; } + public string WorkingDirectory { get; private set; } = Directory.GetCurrentDirectory(); + + public InitCommand(string id, string project) + { + OverrideId = id; + ProjectPath = project; + } + + public void Execute(CommandContext context, string workingDirectory) + { + WorkingDirectory = workingDirectory; + Execute(context); + } + + public void Execute(CommandContext context) + { + var projectPath = ResolveProjectPath(ProjectPath, WorkingDirectory); + + // Load the project file as XML + var projectDocument = XDocument.Load(projectPath); + + // Accept the `--id` CLI option to the main app + string newSecretsId = string.IsNullOrWhiteSpace(OverrideId) + ? Guid.NewGuid().ToString() + : OverrideId; + + // Confirm secret ID does not contain invalid characters + if (Path.GetInvalidPathChars().Any(invalidChar => newSecretsId.Contains(invalidChar))) + { + throw new ArgumentException(Resources.FormatError_InvalidSecretsId(newSecretsId)); + } + + var existingUserSecretsId = projectDocument.XPathSelectElements("//UserSecretsId").FirstOrDefault(); + + // Check if a UserSecretsId is already set + if (existingUserSecretsId != default) + { + // Only set the UserSecretsId if the user specified an explicit value + if (string.IsNullOrWhiteSpace(OverrideId)) + { + context.Reporter.Output(Resources.FormatMessage_ProjectAlreadyInitialized(projectPath)); + return; + } + + existingUserSecretsId.SetValue(newSecretsId); + } + else + { + // Find the first non-conditional PropertyGroup + var propertyGroup = projectDocument.Root.DescendantNodes() + .FirstOrDefault(node => node is XElement el + && el.Name == "PropertyGroup" + && el.Attributes().All(attr => + attr.Name != "Condition")) as XElement; + + // No valid property group, create a new one + if (propertyGroup == null) + { + propertyGroup = new XElement("PropertyGroup"); + projectDocument.Root.AddFirst(propertyGroup); + } + + // Add UserSecretsId element + propertyGroup.Add(new XElement("UserSecretsId", newSecretsId)); + } + + projectDocument.Save(projectPath); + + context.Reporter.Output(Resources.FormatMessage_SetUserSecretsIdForProject(newSecretsId, projectPath)); + } + + private static string ResolveProjectPath(string name, string path) + { + var finder = new MsBuildProjectFinder(path); + return finder.FindMsBuildProject(name); + } + } +} diff --git a/src/dotnet-user-secrets/Program.cs b/src/dotnet-user-secrets/Program.cs index 187e40128f..710756eb88 100644 --- a/src/dotnet-user-secrets/Program.cs +++ b/src/dotnet-user-secrets/Program.cs @@ -71,6 +71,12 @@ namespace Microsoft.Extensions.SecretManager.Tools var reporter = CreateReporter(options.IsVerbose); + if (options.Command is InitCommandFactory initCmd) + { + initCmd.Execute(new CommandContext(null, reporter, _console), _workingDirectory); + return 0; + } + string userSecretsId; try { diff --git a/src/dotnet-user-secrets/Properties/Resources.Designer.cs b/src/dotnet-user-secrets/Properties/Resources.Designer.cs index a75fc0108f..8ee2a8f38e 100644 --- a/src/dotnet-user-secrets/Properties/Resources.Designer.cs +++ b/src/dotnet-user-secrets/Properties/Resources.Designer.cs @@ -15,16 +15,14 @@ namespace Microsoft.Extensions.SecretManager.Tools /// internal static string Error_Command_Failed { - get { return GetString("Error_Command_Failed"); } + get => GetString("Error_Command_Failed"); } /// /// Command failed : {message} /// internal static string FormatError_Command_Failed(object message) - { - return string.Format(CultureInfo.CurrentCulture, GetString("Error_Command_Failed", "message"), message); - } + => string.Format(CultureInfo.CurrentCulture, GetString("Error_Command_Failed", "message"), message); /// /// Missing parameter value for '{name}'. @@ -32,7 +30,7 @@ namespace Microsoft.Extensions.SecretManager.Tools /// internal static string Error_MissingArgument { - get { return GetString("Error_MissingArgument"); } + get => GetString("Error_MissingArgument"); } /// @@ -40,202 +38,218 @@ namespace Microsoft.Extensions.SecretManager.Tools /// Use the '--help' flag to see info. /// internal static string FormatError_MissingArgument(object name) - { - return string.Format(CultureInfo.CurrentCulture, GetString("Error_MissingArgument", "name"), name); - } + => string.Format(CultureInfo.CurrentCulture, GetString("Error_MissingArgument", "name"), name); /// /// Cannot find '{key}' in the secret store. /// internal static string Error_Missing_Secret { - get { return GetString("Error_Missing_Secret"); } + get => GetString("Error_Missing_Secret"); } /// /// Cannot find '{key}' in the secret store. /// internal static string FormatError_Missing_Secret(object key) - { - return string.Format(CultureInfo.CurrentCulture, GetString("Error_Missing_Secret", "key"), key); - } + => string.Format(CultureInfo.CurrentCulture, GetString("Error_Missing_Secret", "key"), key); /// /// Multiple MSBuild project files found in '{projectPath}'. Specify which to use with the --project option. /// internal static string Error_MultipleProjectsFound { - get { return GetString("Error_MultipleProjectsFound"); } + get => GetString("Error_MultipleProjectsFound"); } /// /// Multiple MSBuild project files found in '{projectPath}'. Specify which to use with the --project option. /// internal static string FormatError_MultipleProjectsFound(object projectPath) - { - return string.Format(CultureInfo.CurrentCulture, GetString("Error_MultipleProjectsFound", "projectPath"), projectPath); - } + => string.Format(CultureInfo.CurrentCulture, GetString("Error_MultipleProjectsFound", "projectPath"), projectPath); /// /// No secrets configured for this application. /// internal static string Error_No_Secrets_Found { - get { return GetString("Error_No_Secrets_Found"); } + get => GetString("Error_No_Secrets_Found"); } /// /// No secrets configured for this application. /// internal static string FormatError_No_Secrets_Found() - { - return GetString("Error_No_Secrets_Found"); - } + => GetString("Error_No_Secrets_Found"); /// /// Could not find a MSBuild project file in '{projectPath}'. Specify which project to use with the --project option. /// internal static string Error_NoProjectsFound { - get { return GetString("Error_NoProjectsFound"); } + get => GetString("Error_NoProjectsFound"); } /// /// Could not find a MSBuild project file in '{projectPath}'. Specify which project to use with the --project option. /// internal static string FormatError_NoProjectsFound(object projectPath) - { - return string.Format(CultureInfo.CurrentCulture, GetString("Error_NoProjectsFound", "projectPath"), projectPath); - } + => string.Format(CultureInfo.CurrentCulture, GetString("Error_NoProjectsFound", "projectPath"), projectPath); /// /// Could not find the global property 'UserSecretsId' in MSBuild project '{project}'. Ensure this property is set in the project or use the '--id' command line option. /// internal static string Error_ProjectMissingId { - get { return GetString("Error_ProjectMissingId"); } + get => GetString("Error_ProjectMissingId"); } /// /// Could not find the global property 'UserSecretsId' in MSBuild project '{project}'. Ensure this property is set in the project or use the '--id' command line option. /// internal static string FormatError_ProjectMissingId(object project) - { - return string.Format(CultureInfo.CurrentCulture, GetString("Error_ProjectMissingId", "project"), project); - } + => string.Format(CultureInfo.CurrentCulture, GetString("Error_ProjectMissingId", "project"), project); /// /// The project file '{path}' does not exist. /// internal static string Error_ProjectPath_NotFound { - get { return GetString("Error_ProjectPath_NotFound"); } + get => GetString("Error_ProjectPath_NotFound"); } /// /// The project file '{path}' does not exist. /// internal static string FormatError_ProjectPath_NotFound(object path) - { - return string.Format(CultureInfo.CurrentCulture, GetString("Error_ProjectPath_NotFound", "path"), path); - } + => string.Format(CultureInfo.CurrentCulture, GetString("Error_ProjectPath_NotFound", "path"), path); /// /// Could not load the MSBuild project '{project}'. /// internal static string Error_ProjectFailedToLoad { - get { return GetString("Error_ProjectFailedToLoad"); } + get => GetString("Error_ProjectFailedToLoad"); } /// /// Could not load the MSBuild project '{project}'. /// internal static string FormatError_ProjectFailedToLoad(object project) - { - return string.Format(CultureInfo.CurrentCulture, GetString("Error_ProjectFailedToLoad", "project"), project); - } + => string.Format(CultureInfo.CurrentCulture, GetString("Error_ProjectFailedToLoad", "project"), project); /// /// Project file path {project}. /// internal static string Message_Project_File_Path { - get { return GetString("Message_Project_File_Path"); } + get => GetString("Message_Project_File_Path"); } /// /// Project file path {project}. /// internal static string FormatMessage_Project_File_Path(object project) - { - return string.Format(CultureInfo.CurrentCulture, GetString("Message_Project_File_Path", "project"), project); - } + => string.Format(CultureInfo.CurrentCulture, GetString("Message_Project_File_Path", "project"), project); /// /// Successfully saved {key} = {value} to the secret store. /// internal static string Message_Saved_Secret { - get { return GetString("Message_Saved_Secret"); } + get => GetString("Message_Saved_Secret"); } /// /// Successfully saved {key} = {value} to the secret store. /// internal static string FormatMessage_Saved_Secret(object key, object value) - { - return string.Format(CultureInfo.CurrentCulture, GetString("Message_Saved_Secret", "key", "value"), key, value); - } + => string.Format(CultureInfo.CurrentCulture, GetString("Message_Saved_Secret", "key", "value"), key, value); /// /// Successfully saved {number} secrets to the secret store. /// internal static string Message_Saved_Secrets { - get { return GetString("Message_Saved_Secrets"); } + get => GetString("Message_Saved_Secrets"); } /// /// Successfully saved {number} secrets to the secret store. /// internal static string FormatMessage_Saved_Secrets(object number) - { - return string.Format(CultureInfo.CurrentCulture, GetString("Message_Saved_Secrets", "number"), number); - } + => string.Format(CultureInfo.CurrentCulture, GetString("Message_Saved_Secrets", "number"), number); /// /// Secrets file path {secretsFilePath}. /// internal static string Message_Secret_File_Path { - get { return GetString("Message_Secret_File_Path"); } + get => GetString("Message_Secret_File_Path"); } /// /// Secrets file path {secretsFilePath}. /// internal static string FormatMessage_Secret_File_Path(object secretsFilePath) - { - return string.Format(CultureInfo.CurrentCulture, GetString("Message_Secret_File_Path", "secretsFilePath"), secretsFilePath); - } + => string.Format(CultureInfo.CurrentCulture, GetString("Message_Secret_File_Path", "secretsFilePath"), secretsFilePath); /// /// {key} = {value} /// internal static string Message_Secret_Value_Format { - get { return GetString("Message_Secret_Value_Format"); } + get => GetString("Message_Secret_Value_Format"); } /// /// {key} = {value} /// internal static string FormatMessage_Secret_Value_Format(object key, object value) + => string.Format(CultureInfo.CurrentCulture, GetString("Message_Secret_Value_Format", "key", "value"), key, value); + + /// + /// The UserSecretsId '{userSecretsId}' cannot contain any characters that cannot be used in a file path. + /// + internal static string Error_InvalidSecretsId { - return string.Format(CultureInfo.CurrentCulture, GetString("Message_Secret_Value_Format", "key", "value"), key, value); + get => GetString("Error_InvalidSecretsId"); } + /// + /// The UserSecretsId '{userSecretsId}' cannot contain any characters that cannot be used in a file path. + /// + internal static string FormatError_InvalidSecretsId(object userSecretsId) + => string.Format(CultureInfo.CurrentCulture, GetString("Error_InvalidSecretsId", "userSecretsId"), userSecretsId); + + /// + /// The MSBuild project '{project}' has already been initialized with a UserSecretsId. + /// + internal static string Message_ProjectAlreadyInitialized + { + get => GetString("Message_ProjectAlreadyInitialized"); + } + + /// + /// The MSBuild project '{project}' has already been initialized with a UserSecretsId. + /// + internal static string FormatMessage_ProjectAlreadyInitialized(object project) + => string.Format(CultureInfo.CurrentCulture, GetString("Message_ProjectAlreadyInitialized", "project"), project); + + /// + /// Set UserSecretsId to '{userSecretsId}' for MSBuild project '{project}'. + /// + internal static string Message_SetUserSecretsIdForProject + { + get => GetString("Message_SetUserSecretsIdForProject"); + } + + /// + /// Set UserSecretsId to '{userSecretsId}' for MSBuild project '{project}'. + /// + internal static string FormatMessage_SetUserSecretsIdForProject(object userSecretsId, object project) + => string.Format(CultureInfo.CurrentCulture, GetString("Message_SetUserSecretsIdForProject", "userSecretsId", "project"), userSecretsId, project); + private static string GetString(string name, params string[] formatterNames) { var value = _resourceManager.GetString(name); diff --git a/src/dotnet-user-secrets/Resources.resx b/src/dotnet-user-secrets/Resources.resx index c9222930fc..bfcdf99c2a 100644 --- a/src/dotnet-user-secrets/Resources.resx +++ b/src/dotnet-user-secrets/Resources.resx @@ -1,17 +1,17 @@  - @@ -160,4 +160,13 @@ Use the '--help' flag to see info. {key} = {value} + + The UserSecretsId '{userSecretsId}' cannot contain any characters that cannot be used in a file path. + + + The MSBuild project '{project}' has already been initialized with a UserSecretsId. + + + Set UserSecretsId to '{userSecretsId}' for MSBuild project '{project}'. + \ No newline at end of file diff --git a/test/dotnet-user-secrets.Tests/InitCommandTest.cs b/test/dotnet-user-secrets.Tests/InitCommandTest.cs new file mode 100644 index 0000000000..d1558e8811 --- /dev/null +++ b/test/dotnet-user-secrets.Tests/InitCommandTest.cs @@ -0,0 +1,119 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.IO; +using System.Text; +using Microsoft.Extensions.Configuration.UserSecrets.Tests; +using Microsoft.Extensions.SecretManager.Tools.Internal; +using Microsoft.Extensions.Tools.Internal; +using Xunit; +using Xunit.Abstractions; + +namespace Microsoft.Extensions.SecretManager.Tools.Tests +{ + public class InitCommandTests : IClassFixture + { + private UserSecretsTestFixture _fixture; + private ITestOutputHelper _output; + private TestConsole _console; + private StringBuilder _textOutput; + + public InitCommandTests(UserSecretsTestFixture fixture, ITestOutputHelper output) + { + _fixture = fixture; + _output = output; + _textOutput = new StringBuilder(); + + _console = new TestConsole(output) + { + Error = new StringWriter(_textOutput), + Out = new StringWriter(_textOutput), + }; + } + + private CommandContext MakeCommandContext() => new CommandContext(null, new TestReporter(_output), _console); + + [Fact] + public void AddsSecretIdToProject() + { + var projectDir = _fixture.CreateProject(null); + + new InitCommand(null, null).Execute(MakeCommandContext(), projectDir); + + var idResolver = new ProjectIdResolver(MakeCommandContext().Reporter, projectDir); + + Assert.False(string.IsNullOrWhiteSpace(idResolver.Resolve(null, null))); + } + + [Fact] + public void AddsSpecificSecretIdToProject() + { + const string SecretId = "TestSecretId"; + + var projectDir = _fixture.CreateProject(null); + + new InitCommand(SecretId, null).Execute(MakeCommandContext(), projectDir); + + var idResolver = new ProjectIdResolver(MakeCommandContext().Reporter, projectDir); + + Assert.Equal(SecretId, idResolver.Resolve(null, null)); + } + + [Fact] + public void AddsEscapedSpecificSecretIdToProject() + { + const string SecretId = @"&"; + + var projectDir = _fixture.CreateProject(null); + + new InitCommand(SecretId, null).Execute(MakeCommandContext(), projectDir); + + var idResolver = new ProjectIdResolver(MakeCommandContext().Reporter, projectDir); + + Assert.Equal(SecretId, idResolver.Resolve(null, null)); + } + + [Fact] + public void DoesNotGenerateIdForProjectWithSecretId() + { + const string SecretId = "AlreadyExists"; + + var projectDir = _fixture.CreateProject(SecretId); + + new InitCommand(null, null).Execute(MakeCommandContext(), projectDir); + + var idResolver = new ProjectIdResolver(MakeCommandContext().Reporter, projectDir); + + Assert.Equal(SecretId, idResolver.Resolve(null, null)); + } + + [Fact] + public void OverridesIdForProjectWithSecretId() + { + const string SecretId = "AlreadyExists"; + const string NewId = "TestValue"; + + var projectDir = _fixture.CreateProject(SecretId); + + new InitCommand(NewId, null).Execute(MakeCommandContext(), projectDir); + + var idResolver = new ProjectIdResolver(MakeCommandContext().Reporter, projectDir); + + Assert.Equal(NewId, idResolver.Resolve(null, null)); + } + + [Fact] + public void FailsForInvalidId() + { + string secretId = $"invalid{Path.GetInvalidPathChars()[0]}secret-id"; + + var projectDir = _fixture.CreateProject(null); + + Assert.Throws(() => + { + new InitCommand(secretId, null).Execute(MakeCommandContext(), projectDir); + }); + } + } +} diff --git a/test/dotnet-user-secrets.Tests/SecretManagerTests.cs b/test/dotnet-user-secrets.Tests/SecretManagerTests.cs index ee7ca55247..3a390d4439 100644 --- a/test/dotnet-user-secrets.Tests/SecretManagerTests.cs +++ b/test/dotnet-user-secrets.Tests/SecretManagerTests.cs @@ -323,5 +323,18 @@ namespace Microsoft.Extensions.SecretManager.Tools.Tests secretManager.RunInternal(args); Assert.Contains(Resources.Error_No_Secrets_Found, _output.ToString()); } + + [Fact] + public void Init_When_Project_Has_No_Secrets_Id() + { + var projectPath = _fixture.CreateProject(null); + var project = Path.Combine(projectPath, "TestProject.csproj"); + var secretManager = new Program(_console, projectPath); + + secretManager.RunInternal("init", "-p", project); + + Assert.DoesNotContain(Resources.FormatError_ProjectMissingId(project), _output.ToString()); + Assert.DoesNotContain("--help", _output.ToString()); + } } -} \ No newline at end of file +}