diff --git a/src/Tools/dotnet-user-secrets/src/Internal/ListCommand.cs b/src/Tools/dotnet-user-secrets/src/Internal/ListCommand.cs index e89a7e3be339..3c98060731e4 100644 --- a/src/Tools/dotnet-user-secrets/src/Internal/ListCommand.cs +++ b/src/Tools/dotnet-user-secrets/src/Internal/ListCommand.cs @@ -1,9 +1,9 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Collections.Generic; +using System.Text.Json; using Microsoft.Extensions.CommandLineUtils; -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; namespace Microsoft.Extensions.SecretManager.Tools.Internal; @@ -53,14 +53,14 @@ public void Execute(CommandContext context) private static void ReportJson(CommandContext context) { - var jObject = new JObject(); + var secrets = new Dictionary(); foreach (var item in context.SecretStore.AsEnumerable()) { - jObject[item.Key] = item.Value; + secrets[item.Key] = item.Value; } context.Reporter.Output("//BEGIN"); - context.Reporter.Output(jObject.ToString(Formatting.Indented)); + context.Reporter.Output(JsonSerializer.Serialize(secrets, SecretsStore.SerializerOptions)); context.Reporter.Output("//END"); } } diff --git a/src/Tools/dotnet-user-secrets/src/Internal/SecretsStore.cs b/src/Tools/dotnet-user-secrets/src/Internal/SecretsStore.cs index 44313122bd01..2ee65eed57c8 100644 --- a/src/Tools/dotnet-user-secrets/src/Internal/SecretsStore.cs +++ b/src/Tools/dotnet-user-secrets/src/Internal/SecretsStore.cs @@ -7,10 +7,10 @@ using System.Linq; using System.Runtime.InteropServices; using System.Text; +using System.Text.Json; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration.UserSecrets; using Microsoft.Extensions.Tools.Internal; -using Newtonsoft.Json.Linq; namespace Microsoft.Extensions.SecretManager.Tools.Internal; @@ -20,6 +20,11 @@ namespace Microsoft.Extensions.SecretManager.Tools.Internal; /// public class SecretsStore { + internal static readonly JsonSerializerOptions SerializerOptions = new JsonSerializerOptions + { + WriteIndented = true + }; + private readonly string _secretsFilePath; private readonly IDictionary _secrets; @@ -70,14 +75,7 @@ public virtual void Save() { Directory.CreateDirectory(Path.GetDirectoryName(_secretsFilePath)); - var contents = new JObject(); - if (_secrets != null) - { - foreach (var secret in _secrets.AsEnumerable()) - { - contents[secret.Key] = secret.Value; - } - } + var contents = JsonSerializer.Serialize(_secrets, SerializerOptions); // Create a temp file with the correct Unix file mode before moving it to the expected _filePath. if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) @@ -86,7 +84,7 @@ public virtual void Save() File.Move(tempFilename, _secretsFilePath, overwrite: true); } - File.WriteAllText(_secretsFilePath, contents.ToString(), Encoding.UTF8); + File.WriteAllText(_secretsFilePath, contents, Encoding.UTF8); } protected virtual IDictionary Load(string userSecretsId) diff --git a/src/Tools/dotnet-user-secrets/src/dotnet-user-secrets.csproj b/src/Tools/dotnet-user-secrets/src/dotnet-user-secrets.csproj index 249504b09fdc..6488e7503989 100644 --- a/src/Tools/dotnet-user-secrets/src/dotnet-user-secrets.csproj +++ b/src/Tools/dotnet-user-secrets/src/dotnet-user-secrets.csproj @@ -28,7 +28,6 @@ - diff --git a/src/Tools/dotnet-user-secrets/test/ListCommandTest.cs b/src/Tools/dotnet-user-secrets/test/ListCommandTest.cs new file mode 100644 index 000000000000..6598aa7aa5a3 --- /dev/null +++ b/src/Tools/dotnet-user-secrets/test/ListCommandTest.cs @@ -0,0 +1,166 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using Microsoft.Extensions.SecretManager.Tools.Internal; +using Microsoft.Extensions.Tools.Internal; +using Xunit; +using Xunit.Abstractions; + +namespace Microsoft.Extensions.SecretManager.Tools.Tests; + +public class ListCommandTest +{ + private readonly ITestOutputHelper _output; + + public ListCommandTest(ITestOutputHelper output) + { + _output = output; + } + + [Fact] + public void List_Json_OutputIsProperlyFormatted() + { + var secretStore = new TestSecretsStore(_output); + secretStore.Set("key1", "value1"); + var testConsole = new TestConsole(_output); + var reporter = new ConsoleReporter(testConsole); + var command = new ListCommand(jsonOutput: true); + + command.Execute(new CommandContext(secretStore, reporter, testConsole)); + + var output = testConsole.GetOutput(); + var jsonContent = ExtractJsonContent(output); + + Assert.Equal("{\n \"key1\": \"value1\"\n}", jsonContent, ignoreLineEndingDifferences: true); + } + + [Fact] + public void List_Json_EscapesNonAsciiCharacters() + { + var secretStore = new TestSecretsStore(_output); + secretStore.Set("AzureAd:ClientSecret", "abcd郩˙î"); + var testConsole = new TestConsole(_output); + var reporter = new ConsoleReporter(testConsole); + var command = new ListCommand(jsonOutput: true); + + command.Execute(new CommandContext(secretStore, reporter, testConsole)); + + var output = testConsole.GetOutput(); + var jsonContent = ExtractJsonContent(output); + + // Non-ASCII characters are Unicode-escaped using the default System.Text.Json encoding + Assert.Equal("{\n \"AzureAd:ClientSecret\": \"abcd\\u00E9\\u0192\\u00A9\\u02D9\\u00EE\"\n}", jsonContent, ignoreLineEndingDifferences: true); + } + + [Fact] + public void List_Json_HasBeginAndEndMarkers() + { + var secretStore = new TestSecretsStore(_output); + secretStore.Set("key1", "value1"); + var testConsole = new TestConsole(_output); + var reporter = new ConsoleReporter(testConsole); + var command = new ListCommand(jsonOutput: true); + + command.Execute(new CommandContext(secretStore, reporter, testConsole)); + + var output = testConsole.GetOutput(); + + Assert.Contains("//BEGIN", output); + Assert.Contains("//END", output); + var beginIndex = output.IndexOf("//BEGIN", StringComparison.Ordinal); + var endIndex = output.IndexOf("//END", StringComparison.Ordinal); + Assert.True(beginIndex < endIndex, "//BEGIN should appear before //END"); + } + + [Fact] + public void List_NonJson_OutputIsProperlyFormatted() + { + var secretStore = new TestSecretsStore(_output); + secretStore.Set("key1", "value1"); + secretStore.Set("AzureAd:ClientSecret", "someSecret"); + var testConsole = new TestConsole(_output); + var reporter = new ConsoleReporter(testConsole); + var command = new ListCommand(jsonOutput: false); + + command.Execute(new CommandContext(secretStore, reporter, testConsole)); + + var output = testConsole.GetOutput(); + Assert.Contains("key1 = value1", output); + Assert.Contains("AzureAd:ClientSecret = someSecret", output); + } + + [Fact] + public void List_NonJson_EmptyStore() + { + var secretStore = new TestSecretsStore(_output); + var testConsole = new TestConsole(_output); + var reporter = new ConsoleReporter(testConsole); + var command = new ListCommand(jsonOutput: false); + + command.Execute(new CommandContext(secretStore, reporter, testConsole)); + + Assert.Contains(Resources.Error_No_Secrets_Found, testConsole.GetOutput()); + } + + [Fact] + public void List_Json_EmptyStore() + { + var secretStore = new TestSecretsStore(_output); + var testConsole = new TestConsole(_output); + var reporter = new ConsoleReporter(testConsole); + var command = new ListCommand(jsonOutput: true); + + command.Execute(new CommandContext(secretStore, reporter, testConsole)); + + var output = testConsole.GetOutput(); + var jsonContent = ExtractJsonContent(output); + + Assert.Equal("{}", jsonContent, ignoreLineEndingDifferences: true); + } + + private static string ExtractJsonContent(string output) + { + const string beginMarker = "//BEGIN"; + const string endMarker = "//END"; + + var beginMarkerIndex = output.IndexOf(beginMarker, StringComparison.Ordinal); + var endMarkerIndex = output.IndexOf(endMarker, StringComparison.Ordinal); + + Assert.True( + beginMarkerIndex >= 0, + $"Expected output to contain '{beginMarker}' marker, but it was not found.{Environment.NewLine}Actual output:{Environment.NewLine}{output}"); + + Assert.True( + endMarkerIndex >= 0, + $"Expected output to contain '{endMarker}' marker, but it was not found.{Environment.NewLine}Actual output:{Environment.NewLine}{output}"); + + Assert.True( + beginMarkerIndex < endMarkerIndex, + $"Expected '{beginMarker}' marker to appear before '{endMarker}' marker.{Environment.NewLine}Actual output:{Environment.NewLine}{output}"); + + var contentStartIndex = beginMarkerIndex + beginMarker.Length; + var contentEndIndex = endMarkerIndex; + + return output[contentStartIndex..contentEndIndex].Trim(); + } + + private sealed class TestSecretsStore : SecretsStore + { + public TestSecretsStore(ITestOutputHelper output) + : base("xyz", new TestReporter(output)) + { + } + + protected override IDictionary Load(string userSecretsId) + { + return new Dictionary(); + } + + public override void Save() + { + // noop + } + } +} diff --git a/src/Tools/dotnet-user-secrets/test/SecretManagerTests.cs b/src/Tools/dotnet-user-secrets/test/SecretManagerTests.cs index 5b1e8592908f..cc882ce3ba74 100644 --- a/src/Tools/dotnet-user-secrets/test/SecretManagerTests.cs +++ b/src/Tools/dotnet-user-secrets/test/SecretManagerTests.cs @@ -235,6 +235,23 @@ public void Remove_Is_Case_Insensitive() Assert.Contains(Resources.Error_No_Secrets_Found, _console.GetOutput()); } + [Fact] + public void List_Json_OptionIsWired() + { + string id; + var projectPath = _fixture.GetTempSecretProject(out id); + var secretManager = CreateProgram(); + secretManager.RunInternal("set", "key1", "value1", "-p", projectPath); + + _console.ClearOutput(); + secretManager.RunInternal("list", "--id", id, "--json"); + + var stdout = _console.GetOutput(); + Assert.Contains("//BEGIN", stdout); + Assert.Contains("\"key1\": \"value1\"", stdout); + Assert.Contains("//END", stdout); + } + [Fact] public void List_Flattens_Nested_Objects() { @@ -242,26 +259,12 @@ public void List_Flattens_Nested_Objects() var projectPath = _fixture.GetTempSecretProject(out secretId); var secretsFile = PathHelper.GetSecretsPathFromSecretsId(secretId); Directory.CreateDirectory(Path.GetDirectoryName(secretsFile)); - File.WriteAllText(secretsFile, @"{ ""AzureAd"": { ""ClientSecret"": ""abcd郩˙î""} }", Encoding.UTF8); + File.WriteAllText(secretsFile, @"{ ""AzureAd"": { ""ClientSecret"": ""abc"" } }", Encoding.UTF8); var secretManager = CreateProgram(); - secretManager.RunInternal("list", "-p", projectPath, "--verbose"); - Assert.Contains("AzureAd:ClientSecret = abcd郩˙î", _console.GetOutput()); - } - [Fact] - public void List_Json() - { - string id; - var projectPath = _fixture.GetTempSecretProject(out id); - var secretsFile = PathHelper.GetSecretsPathFromSecretsId(id); - Directory.CreateDirectory(Path.GetDirectoryName(secretsFile)); - File.WriteAllText(secretsFile, @"{ ""AzureAd"": { ""ClientSecret"": ""abcd郩˙î""} }", Encoding.UTF8); - var secretManager = new Program(_console, Path.GetDirectoryName(projectPath)); - secretManager.RunInternal("list", "--id", id, "--json"); - var stdout = _console.GetOutput(); - Assert.Contains("//BEGIN", stdout); - Assert.Contains(@"""AzureAd:ClientSecret"": ""abcd郩˙î""", stdout); - Assert.Contains("//END", stdout); + secretManager.RunInternal("list", "-p", projectPath); + + Assert.Contains("AzureAd:ClientSecret = abc", _console.GetOutput()); } [Fact] @@ -279,20 +282,11 @@ public void Set_Flattens_Nested_Objects() Assert.Contains("AzureAd:ClientSecret = ¡™£¢∞", _console.GetOutput()); var fileContents = File.ReadAllText(secretsFile, Encoding.UTF8); Assert.Equal(@"{ - ""AzureAd:ClientSecret"": ""¡™£¢∞"" + ""AzureAd:ClientSecret"": ""\u00A1\u2122\u00A3\u00A2\u221E"" }", fileContents, ignoreLineEndingDifferences: true, ignoreWhiteSpaceDifferences: true); } - [Fact] - public void List_Empty_Secrets_File() - { - var projectPath = _fixture.GetTempSecretProject(); - var secretManager = CreateProgram(); - secretManager.RunInternal("list", "-p", projectPath, "--verbose"); - Assert.Contains(Resources.Error_No_Secrets_Found, _console.GetOutput()); - } - [Theory] [InlineData(false, true)] [InlineData(false, false)]