Add `user-secret init` command for adding a UserSecretsId to a project file (#500)

This commit is contained in:
Liam Dawson 2018-11-06 05:44:50 +11:00 committed by Nate McMaster
parent 2af66e4eba
commit 9de04520e0
7 changed files with 370 additions and 82 deletions

View File

@ -50,6 +50,7 @@ namespace Microsoft.Extensions.SecretManager.Tools
app.Command("remove", c => RemoveCommand.Configure(c, options)); app.Command("remove", c => RemoveCommand.Configure(c, options));
app.Command("list", c => ListCommand.Configure(c, options)); app.Command("list", c => ListCommand.Configure(c, options));
app.Command("clear", c => ClearCommand.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. // Show help information if no subcommand/option was specified.
app.OnExecute(() => app.ShowHelp()); app.OnExecute(() => app.ShowHelp());

View File

@ -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);
}
}
}

View File

@ -71,6 +71,12 @@ namespace Microsoft.Extensions.SecretManager.Tools
var reporter = CreateReporter(options.IsVerbose); var reporter = CreateReporter(options.IsVerbose);
if (options.Command is InitCommandFactory initCmd)
{
initCmd.Execute(new CommandContext(null, reporter, _console), _workingDirectory);
return 0;
}
string userSecretsId; string userSecretsId;
try try
{ {

View File

@ -15,16 +15,14 @@ namespace Microsoft.Extensions.SecretManager.Tools
/// </summary> /// </summary>
internal static string Error_Command_Failed internal static string Error_Command_Failed
{ {
get { return GetString("Error_Command_Failed"); } get => GetString("Error_Command_Failed");
} }
/// <summary> /// <summary>
/// Command failed : {message} /// Command failed : {message}
/// </summary> /// </summary>
internal static string FormatError_Command_Failed(object message) internal static string FormatError_Command_Failed(object message)
{ => string.Format(CultureInfo.CurrentCulture, GetString("Error_Command_Failed", "message"), message);
return string.Format(CultureInfo.CurrentCulture, GetString("Error_Command_Failed", "message"), message);
}
/// <summary> /// <summary>
/// Missing parameter value for '{name}'. /// Missing parameter value for '{name}'.
@ -32,7 +30,7 @@ namespace Microsoft.Extensions.SecretManager.Tools
/// </summary> /// </summary>
internal static string Error_MissingArgument internal static string Error_MissingArgument
{ {
get { return GetString("Error_MissingArgument"); } get => GetString("Error_MissingArgument");
} }
/// <summary> /// <summary>
@ -40,202 +38,218 @@ namespace Microsoft.Extensions.SecretManager.Tools
/// Use the '--help' flag to see info. /// Use the '--help' flag to see info.
/// </summary> /// </summary>
internal static string FormatError_MissingArgument(object name) internal static string FormatError_MissingArgument(object name)
{ => string.Format(CultureInfo.CurrentCulture, GetString("Error_MissingArgument", "name"), name);
return string.Format(CultureInfo.CurrentCulture, GetString("Error_MissingArgument", "name"), name);
}
/// <summary> /// <summary>
/// Cannot find '{key}' in the secret store. /// Cannot find '{key}' in the secret store.
/// </summary> /// </summary>
internal static string Error_Missing_Secret internal static string Error_Missing_Secret
{ {
get { return GetString("Error_Missing_Secret"); } get => GetString("Error_Missing_Secret");
} }
/// <summary> /// <summary>
/// Cannot find '{key}' in the secret store. /// Cannot find '{key}' in the secret store.
/// </summary> /// </summary>
internal static string FormatError_Missing_Secret(object key) internal static string FormatError_Missing_Secret(object key)
{ => string.Format(CultureInfo.CurrentCulture, GetString("Error_Missing_Secret", "key"), key);
return string.Format(CultureInfo.CurrentCulture, GetString("Error_Missing_Secret", "key"), key);
}
/// <summary> /// <summary>
/// Multiple MSBuild project files found in '{projectPath}'. Specify which to use with the --project option. /// Multiple MSBuild project files found in '{projectPath}'. Specify which to use with the --project option.
/// </summary> /// </summary>
internal static string Error_MultipleProjectsFound internal static string Error_MultipleProjectsFound
{ {
get { return GetString("Error_MultipleProjectsFound"); } get => GetString("Error_MultipleProjectsFound");
} }
/// <summary> /// <summary>
/// Multiple MSBuild project files found in '{projectPath}'. Specify which to use with the --project option. /// Multiple MSBuild project files found in '{projectPath}'. Specify which to use with the --project option.
/// </summary> /// </summary>
internal static string FormatError_MultipleProjectsFound(object projectPath) internal static string FormatError_MultipleProjectsFound(object projectPath)
{ => string.Format(CultureInfo.CurrentCulture, GetString("Error_MultipleProjectsFound", "projectPath"), projectPath);
return string.Format(CultureInfo.CurrentCulture, GetString("Error_MultipleProjectsFound", "projectPath"), projectPath);
}
/// <summary> /// <summary>
/// No secrets configured for this application. /// No secrets configured for this application.
/// </summary> /// </summary>
internal static string Error_No_Secrets_Found internal static string Error_No_Secrets_Found
{ {
get { return GetString("Error_No_Secrets_Found"); } get => GetString("Error_No_Secrets_Found");
} }
/// <summary> /// <summary>
/// No secrets configured for this application. /// No secrets configured for this application.
/// </summary> /// </summary>
internal static string FormatError_No_Secrets_Found() internal static string FormatError_No_Secrets_Found()
{ => GetString("Error_No_Secrets_Found");
return GetString("Error_No_Secrets_Found");
}
/// <summary> /// <summary>
/// Could not find a MSBuild project file in '{projectPath}'. Specify which project to use with the --project option. /// Could not find a MSBuild project file in '{projectPath}'. Specify which project to use with the --project option.
/// </summary> /// </summary>
internal static string Error_NoProjectsFound internal static string Error_NoProjectsFound
{ {
get { return GetString("Error_NoProjectsFound"); } get => GetString("Error_NoProjectsFound");
} }
/// <summary> /// <summary>
/// Could not find a MSBuild project file in '{projectPath}'. Specify which project to use with the --project option. /// Could not find a MSBuild project file in '{projectPath}'. Specify which project to use with the --project option.
/// </summary> /// </summary>
internal static string FormatError_NoProjectsFound(object projectPath) internal static string FormatError_NoProjectsFound(object projectPath)
{ => string.Format(CultureInfo.CurrentCulture, GetString("Error_NoProjectsFound", "projectPath"), projectPath);
return string.Format(CultureInfo.CurrentCulture, GetString("Error_NoProjectsFound", "projectPath"), projectPath);
}
/// <summary> /// <summary>
/// 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. /// 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.
/// </summary> /// </summary>
internal static string Error_ProjectMissingId internal static string Error_ProjectMissingId
{ {
get { return GetString("Error_ProjectMissingId"); } get => GetString("Error_ProjectMissingId");
} }
/// <summary> /// <summary>
/// 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. /// 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.
/// </summary> /// </summary>
internal static string FormatError_ProjectMissingId(object project) internal static string FormatError_ProjectMissingId(object project)
{ => string.Format(CultureInfo.CurrentCulture, GetString("Error_ProjectMissingId", "project"), project);
return string.Format(CultureInfo.CurrentCulture, GetString("Error_ProjectMissingId", "project"), project);
}
/// <summary> /// <summary>
/// The project file '{path}' does not exist. /// The project file '{path}' does not exist.
/// </summary> /// </summary>
internal static string Error_ProjectPath_NotFound internal static string Error_ProjectPath_NotFound
{ {
get { return GetString("Error_ProjectPath_NotFound"); } get => GetString("Error_ProjectPath_NotFound");
} }
/// <summary> /// <summary>
/// The project file '{path}' does not exist. /// The project file '{path}' does not exist.
/// </summary> /// </summary>
internal static string FormatError_ProjectPath_NotFound(object path) internal static string FormatError_ProjectPath_NotFound(object path)
{ => string.Format(CultureInfo.CurrentCulture, GetString("Error_ProjectPath_NotFound", "path"), path);
return string.Format(CultureInfo.CurrentCulture, GetString("Error_ProjectPath_NotFound", "path"), path);
}
/// <summary> /// <summary>
/// Could not load the MSBuild project '{project}'. /// Could not load the MSBuild project '{project}'.
/// </summary> /// </summary>
internal static string Error_ProjectFailedToLoad internal static string Error_ProjectFailedToLoad
{ {
get { return GetString("Error_ProjectFailedToLoad"); } get => GetString("Error_ProjectFailedToLoad");
} }
/// <summary> /// <summary>
/// Could not load the MSBuild project '{project}'. /// Could not load the MSBuild project '{project}'.
/// </summary> /// </summary>
internal static string FormatError_ProjectFailedToLoad(object project) internal static string FormatError_ProjectFailedToLoad(object project)
{ => string.Format(CultureInfo.CurrentCulture, GetString("Error_ProjectFailedToLoad", "project"), project);
return string.Format(CultureInfo.CurrentCulture, GetString("Error_ProjectFailedToLoad", "project"), project);
}
/// <summary> /// <summary>
/// Project file path {project}. /// Project file path {project}.
/// </summary> /// </summary>
internal static string Message_Project_File_Path internal static string Message_Project_File_Path
{ {
get { return GetString("Message_Project_File_Path"); } get => GetString("Message_Project_File_Path");
} }
/// <summary> /// <summary>
/// Project file path {project}. /// Project file path {project}.
/// </summary> /// </summary>
internal static string FormatMessage_Project_File_Path(object project) internal static string FormatMessage_Project_File_Path(object project)
{ => string.Format(CultureInfo.CurrentCulture, GetString("Message_Project_File_Path", "project"), project);
return string.Format(CultureInfo.CurrentCulture, GetString("Message_Project_File_Path", "project"), project);
}
/// <summary> /// <summary>
/// Successfully saved {key} = {value} to the secret store. /// Successfully saved {key} = {value} to the secret store.
/// </summary> /// </summary>
internal static string Message_Saved_Secret internal static string Message_Saved_Secret
{ {
get { return GetString("Message_Saved_Secret"); } get => GetString("Message_Saved_Secret");
} }
/// <summary> /// <summary>
/// Successfully saved {key} = {value} to the secret store. /// Successfully saved {key} = {value} to the secret store.
/// </summary> /// </summary>
internal static string FormatMessage_Saved_Secret(object key, object value) internal static string FormatMessage_Saved_Secret(object key, object value)
{ => string.Format(CultureInfo.CurrentCulture, GetString("Message_Saved_Secret", "key", "value"), key, value);
return string.Format(CultureInfo.CurrentCulture, GetString("Message_Saved_Secret", "key", "value"), key, value);
}
/// <summary> /// <summary>
/// Successfully saved {number} secrets to the secret store. /// Successfully saved {number} secrets to the secret store.
/// </summary> /// </summary>
internal static string Message_Saved_Secrets internal static string Message_Saved_Secrets
{ {
get { return GetString("Message_Saved_Secrets"); } get => GetString("Message_Saved_Secrets");
} }
/// <summary> /// <summary>
/// Successfully saved {number} secrets to the secret store. /// Successfully saved {number} secrets to the secret store.
/// </summary> /// </summary>
internal static string FormatMessage_Saved_Secrets(object number) internal static string FormatMessage_Saved_Secrets(object number)
{ => string.Format(CultureInfo.CurrentCulture, GetString("Message_Saved_Secrets", "number"), number);
return string.Format(CultureInfo.CurrentCulture, GetString("Message_Saved_Secrets", "number"), number);
}
/// <summary> /// <summary>
/// Secrets file path {secretsFilePath}. /// Secrets file path {secretsFilePath}.
/// </summary> /// </summary>
internal static string Message_Secret_File_Path internal static string Message_Secret_File_Path
{ {
get { return GetString("Message_Secret_File_Path"); } get => GetString("Message_Secret_File_Path");
} }
/// <summary> /// <summary>
/// Secrets file path {secretsFilePath}. /// Secrets file path {secretsFilePath}.
/// </summary> /// </summary>
internal static string FormatMessage_Secret_File_Path(object secretsFilePath) internal static string FormatMessage_Secret_File_Path(object secretsFilePath)
{ => string.Format(CultureInfo.CurrentCulture, GetString("Message_Secret_File_Path", "secretsFilePath"), secretsFilePath);
return string.Format(CultureInfo.CurrentCulture, GetString("Message_Secret_File_Path", "secretsFilePath"), secretsFilePath);
}
/// <summary> /// <summary>
/// {key} = {value} /// {key} = {value}
/// </summary> /// </summary>
internal static string Message_Secret_Value_Format internal static string Message_Secret_Value_Format
{ {
get { return GetString("Message_Secret_Value_Format"); } get => GetString("Message_Secret_Value_Format");
} }
/// <summary> /// <summary>
/// {key} = {value} /// {key} = {value}
/// </summary> /// </summary>
internal static string FormatMessage_Secret_Value_Format(object key, object 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);
/// <summary>
/// The UserSecretsId '{userSecretsId}' cannot contain any characters that cannot be used in a file path.
/// </summary>
internal static string Error_InvalidSecretsId
{ {
return string.Format(CultureInfo.CurrentCulture, GetString("Message_Secret_Value_Format", "key", "value"), key, value); get => GetString("Error_InvalidSecretsId");
} }
/// <summary>
/// The UserSecretsId '{userSecretsId}' cannot contain any characters that cannot be used in a file path.
/// </summary>
internal static string FormatError_InvalidSecretsId(object userSecretsId)
=> string.Format(CultureInfo.CurrentCulture, GetString("Error_InvalidSecretsId", "userSecretsId"), userSecretsId);
/// <summary>
/// The MSBuild project '{project}' has already been initialized with a UserSecretsId.
/// </summary>
internal static string Message_ProjectAlreadyInitialized
{
get => GetString("Message_ProjectAlreadyInitialized");
}
/// <summary>
/// The MSBuild project '{project}' has already been initialized with a UserSecretsId.
/// </summary>
internal static string FormatMessage_ProjectAlreadyInitialized(object project)
=> string.Format(CultureInfo.CurrentCulture, GetString("Message_ProjectAlreadyInitialized", "project"), project);
/// <summary>
/// Set UserSecretsId to '{userSecretsId}' for MSBuild project '{project}'.
/// </summary>
internal static string Message_SetUserSecretsIdForProject
{
get => GetString("Message_SetUserSecretsIdForProject");
}
/// <summary>
/// Set UserSecretsId to '{userSecretsId}' for MSBuild project '{project}'.
/// </summary>
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) private static string GetString(string name, params string[] formatterNames)
{ {
var value = _resourceManager.GetString(name); var value = _resourceManager.GetString(name);

View File

@ -160,4 +160,13 @@ Use the '--help' flag to see info.</value>
<data name="Message_Secret_Value_Format" xml:space="preserve"> <data name="Message_Secret_Value_Format" xml:space="preserve">
<value>{key} = {value}</value> <value>{key} = {value}</value>
</data> </data>
<data name="Error_InvalidSecretsId" xml:space="preserve">
<value>The UserSecretsId '{userSecretsId}' cannot contain any characters that cannot be used in a file path.</value>
</data>
<data name="Message_ProjectAlreadyInitialized" xml:space="preserve">
<value>The MSBuild project '{project}' has already been initialized with a UserSecretsId.</value>
</data>
<data name="Message_SetUserSecretsIdForProject" xml:space="preserve">
<value>Set UserSecretsId to '{userSecretsId}' for MSBuild project '{project}'.</value>
</data>
</root> </root>

View File

@ -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<UserSecretsTestFixture>
{
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 = @"<lots of XML invalid values>&";
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<ArgumentException>(() =>
{
new InitCommand(secretId, null).Execute(MakeCommandContext(), projectDir);
});
}
}
}

View File

@ -323,5 +323,18 @@ namespace Microsoft.Extensions.SecretManager.Tools.Tests
secretManager.RunInternal(args); secretManager.RunInternal(args);
Assert.Contains(Resources.Error_No_Secrets_Found, _output.ToString()); 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());
}
} }
} }