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("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());

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);
if (options.Command is InitCommandFactory initCmd)
{
initCmd.Execute(new CommandContext(null, reporter, _console), _workingDirectory);
return 0;
}
string userSecretsId;
try
{

View File

@ -15,16 +15,14 @@ namespace Microsoft.Extensions.SecretManager.Tools
/// </summary>
internal static string Error_Command_Failed
{
get { return GetString("Error_Command_Failed"); }
get => GetString("Error_Command_Failed");
}
/// <summary>
/// Command failed : {message}
/// </summary>
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);
/// <summary>
/// Missing parameter value for '{name}'.
@ -32,7 +30,7 @@ namespace Microsoft.Extensions.SecretManager.Tools
/// </summary>
internal static string Error_MissingArgument
{
get { return GetString("Error_MissingArgument"); }
get => GetString("Error_MissingArgument");
}
/// <summary>
@ -40,202 +38,218 @@ namespace Microsoft.Extensions.SecretManager.Tools
/// Use the '--help' flag to see info.
/// </summary>
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);
/// <summary>
/// Cannot find '{key}' in the secret store.
/// </summary>
internal static string Error_Missing_Secret
{
get { return GetString("Error_Missing_Secret"); }
get => GetString("Error_Missing_Secret");
}
/// <summary>
/// Cannot find '{key}' in the secret store.
/// </summary>
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);
/// <summary>
/// Multiple MSBuild project files found in '{projectPath}'. Specify which to use with the --project option.
/// </summary>
internal static string Error_MultipleProjectsFound
{
get { return GetString("Error_MultipleProjectsFound"); }
get => GetString("Error_MultipleProjectsFound");
}
/// <summary>
/// Multiple MSBuild project files found in '{projectPath}'. Specify which to use with the --project option.
/// </summary>
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);
/// <summary>
/// No secrets configured for this application.
/// </summary>
internal static string Error_No_Secrets_Found
{
get { return GetString("Error_No_Secrets_Found"); }
get => GetString("Error_No_Secrets_Found");
}
/// <summary>
/// No secrets configured for this application.
/// </summary>
internal static string FormatError_No_Secrets_Found()
{
return GetString("Error_No_Secrets_Found");
}
=> GetString("Error_No_Secrets_Found");
/// <summary>
/// Could not find a MSBuild project file in '{projectPath}'. Specify which project to use with the --project option.
/// </summary>
internal static string Error_NoProjectsFound
{
get { return GetString("Error_NoProjectsFound"); }
get => GetString("Error_NoProjectsFound");
}
/// <summary>
/// Could not find a MSBuild project file in '{projectPath}'. Specify which project to use with the --project option.
/// </summary>
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);
/// <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.
/// </summary>
internal static string Error_ProjectMissingId
{
get { return GetString("Error_ProjectMissingId"); }
get => GetString("Error_ProjectMissingId");
}
/// <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.
/// </summary>
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);
/// <summary>
/// The project file '{path}' does not exist.
/// </summary>
internal static string Error_ProjectPath_NotFound
{
get { return GetString("Error_ProjectPath_NotFound"); }
get => GetString("Error_ProjectPath_NotFound");
}
/// <summary>
/// The project file '{path}' does not exist.
/// </summary>
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);
/// <summary>
/// Could not load the MSBuild project '{project}'.
/// </summary>
internal static string Error_ProjectFailedToLoad
{
get { return GetString("Error_ProjectFailedToLoad"); }
get => GetString("Error_ProjectFailedToLoad");
}
/// <summary>
/// Could not load the MSBuild project '{project}'.
/// </summary>
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);
/// <summary>
/// Project file path {project}.
/// </summary>
internal static string Message_Project_File_Path
{
get { return GetString("Message_Project_File_Path"); }
get => GetString("Message_Project_File_Path");
}
/// <summary>
/// Project file path {project}.
/// </summary>
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);
/// <summary>
/// Successfully saved {key} = {value} to the secret store.
/// </summary>
internal static string Message_Saved_Secret
{
get { return GetString("Message_Saved_Secret"); }
get => GetString("Message_Saved_Secret");
}
/// <summary>
/// Successfully saved {key} = {value} to the secret store.
/// </summary>
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);
/// <summary>
/// Successfully saved {number} secrets to the secret store.
/// </summary>
internal static string Message_Saved_Secrets
{
get { return GetString("Message_Saved_Secrets"); }
get => GetString("Message_Saved_Secrets");
}
/// <summary>
/// Successfully saved {number} secrets to the secret store.
/// </summary>
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);
/// <summary>
/// Secrets file path {secretsFilePath}.
/// </summary>
internal static string Message_Secret_File_Path
{
get { return GetString("Message_Secret_File_Path"); }
get => GetString("Message_Secret_File_Path");
}
/// <summary>
/// Secrets file path {secretsFilePath}.
/// </summary>
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);
/// <summary>
/// {key} = {value}
/// </summary>
internal static string Message_Secret_Value_Format
{
get { return GetString("Message_Secret_Value_Format"); }
get => GetString("Message_Secret_Value_Format");
}
/// <summary>
/// {key} = {value}
/// </summary>
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)
{
var value = _resourceManager.GetString(name);

View File

@ -1,17 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<!--
Microsoft ResX Schema
<!--
Microsoft ResX Schema
Version 2.0
The primary goals of this format is to allow a simple XML format
that is mostly human readable. The generation and parsing of the
various data types are done through the TypeConverter classes
The primary goals of this format is to allow a simple XML format
that is mostly human readable. The generation and parsing of the
various data types are done through the TypeConverter classes
associated with the data types.
Example:
... ado.net/XML headers & schema ...
<resheader name="resmimetype">text/microsoft-resx</resheader>
<resheader name="version">2.0</resheader>
@ -26,36 +26,36 @@
<value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
<comment>This is a comment</comment>
</data>
There are any number of "resheader" rows that contain simple
There are any number of "resheader" rows that contain simple
name/value pairs.
Each data row contains a name, and value. The row also contains a
type or mimetype. Type corresponds to a .NET class that support
text/value conversion through the TypeConverter architecture.
Classes that don't support this are serialized and stored with the
Each data row contains a name, and value. The row also contains a
type or mimetype. Type corresponds to a .NET class that support
text/value conversion through the TypeConverter architecture.
Classes that don't support this are serialized and stored with the
mimetype set.
The mimetype is used for serialized objects, and tells the
ResXResourceReader how to depersist the object. This is currently not
The mimetype is used for serialized objects, and tells the
ResXResourceReader how to depersist the object. This is currently not
extensible. For a given mimetype the value must be set accordingly:
Note - application/x-microsoft.net.object.binary.base64 is the format
that the ResXResourceWriter will generate, however the reader can
Note - application/x-microsoft.net.object.binary.base64 is the format
that the ResXResourceWriter will generate, however the reader can
read any of the formats listed below.
mimetype: application/x-microsoft.net.object.binary.base64
value : The object must be serialized with
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.soap.base64
value : The object must be serialized with
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.bytearray.base64
value : The object must be serialized into a byte array
value : The object must be serialized into a byte array
: using a System.ComponentModel.TypeConverter
: and then encoded with base64 encoding.
-->
@ -160,4 +160,13 @@ Use the '--help' flag to see info.</value>
<data name="Message_Secret_Value_Format" xml:space="preserve">
<value>{key} = {value}</value>
</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>

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