feat: initial cli implementation

This commit is contained in:
Stevan Freeborn
2026-04-17 15:28:59 -05:00
parent ab57ac5a13
commit ba927c0e18
41 changed files with 1124 additions and 17 deletions
@@ -0,0 +1,52 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="AwesomeAssertions" Version="9.4.0" />
<PackageReference Include="coverlet.collector" Version="8.0.1">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="coverlet.msbuild" Version="8.0.1">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.5" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.4.0" />
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
<PropertyGroup>
<CollectCoverage>true</CollectCoverage>
<CoverletOutput>./TestResults/Coverage/</CoverletOutput>
<CoverletOutputFormat>cobertura</CoverletOutputFormat>
<Include>[*]StevanFreeborn.Extensions.Configuration.Secure.Cli*</Include>
<ExcludeByFile>**/Program.cs</ExcludeByFile>
</PropertyGroup>
<Target Name="GenerateHtmlCoverageReport" AfterTargets="GenerateCoverageResultAfterTest">
<Exec Command="reportgenerator -reports:./TestResults/Coverage/*.xml -targetdir:./TestResults/Coverage/Report/ -reporttypes:Html_Dark" />
</Target>
<ItemGroup>
<Using Include="Xunit" />
<Using Include="AwesomeAssertions" />
</ItemGroup>
<ItemGroup>
<ProjectReference
Include="..\..\src\StevanFreeborn.Extensions.Configuration.Secure.Cli\StevanFreeborn.Extensions.Configuration.Secure.Cli.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,36 @@
using System.CommandLine;
using Moq;
using StevanFreeborn.Extensions.Configuration.Secure.Cli.Commands;
using StevanFreeborn.Extensions.Configuration.Secure.Configuration;
namespace StevanFreeborn.Extensions.Configuration.Secure.Cli.Tests.Unit.Commands;
public class DeleteCommandFactoryTests
{
private readonly Mock<ISecureConfig> _mockSecureConfig = new();
private readonly Mock<ITerminal> _mockTerminal = new();
private readonly DeleteCommandFactory _sut;
public DeleteCommandFactoryTests()
{
_sut = new(_mockSecureConfig.Object, _mockTerminal.Object);
}
[Fact]
public void Create_WhenCalled_ItShouldReturnExpectedCommand()
{
var expectedCommand = new Command("delete", "Delete the value for the given key")
{
new Argument<string>("key")
{
Description = "The key whose value you want to remove."
},
};
var result = _sut.Create();
result.Should().BeEquivalentTo(expectedCommand, static o => o.IgnoringCyclicReferences());
}
}
@@ -0,0 +1,52 @@
using System.CommandLine;
using Moq;
using StevanFreeborn.Extensions.Configuration.Secure.Cli.Commands;
using StevanFreeborn.Extensions.Configuration.Secure.Configuration;
namespace StevanFreeborn.Extensions.Configuration.Secure.Cli.Tests.Unit.Commands;
public class DeleteCommandTests
{
private readonly Mock<ISecureConfig> _mockSecureConfig = new();
private readonly Mock<ITerminal> _mockTerminal = new();
private readonly Command _sut;
public DeleteCommandTests()
{
_sut = new DeleteCommandFactory(_mockSecureConfig.Object, _mockTerminal.Object).Create();
}
[Fact]
public async Task Delete_WhenCalledWithoutKey_ItShouldReturnNonZeroExitCode()
{
var result = await _sut.Parse([]).InvokeAsync();
result.Should().NotBe(0);
}
[Fact]
public async Task Delete_WhenCalledWithNonExistentKey_ItShouldReturnNonZeroExitCode()
{
var expectedKey = "key";
_mockSecureConfig.Setup(m => m.DeleteAsync(expectedKey, It.IsAny<CancellationToken>())).ReturnsAsync(false);
var result = await _sut.Parse([expectedKey]).InvokeAsync();
result.Should().NotBe(0);
}
[Fact]
public async Task Delete_WhenCalledWithExistingKey_ItShouldReturnZeroExitCode()
{
var expectedKey = "key";
_mockSecureConfig.Setup(m => m.DeleteAsync(expectedKey, It.IsAny<CancellationToken>())).ReturnsAsync(true);
var result = await _sut.Parse([expectedKey]).InvokeAsync();
result.Should().Be(0);
}
}
@@ -0,0 +1,41 @@
using System.CommandLine;
using Moq;
using StevanFreeborn.Extensions.Configuration.Secure.Cli.Commands;
using StevanFreeborn.Extensions.Configuration.Secure.Configuration;
namespace StevanFreeborn.Extensions.Configuration.Secure.Cli.Tests.Unit.Commands;
public class GetCommandFactoryTests
{
private readonly Mock<ISecureConfig> _mockSecureConfig = new();
private readonly Mock<ITerminal> _mockTerminal = new();
private readonly GetCommandFactory _sut;
public GetCommandFactoryTests()
{
_sut = new(_mockSecureConfig.Object, _mockTerminal.Object);
}
[Fact]
public void Create_WhenCalled_ItShouldReturnExpectedCommand()
{
var expectedCommand = new Command("get", "Retrieves the value for the given key")
{
new Argument<string>("key")
{
Description = "The key whose value you want to retrieve."
},
new Option<bool>("--pretty", "-p")
{
Description = "Indicates whether the value - if JSON - should be pretty printed or not"
},
};
var result = _sut.Create();
result.Should().BeEquivalentTo(expectedCommand, static o => o.IgnoringCyclicReferences());
result.Action.Should().NotBeNull();
}
}
@@ -0,0 +1,96 @@
using System.CommandLine;
using System.Text.Json;
using Moq;
using StevanFreeborn.Extensions.Configuration.Secure.Cli.Commands;
using StevanFreeborn.Extensions.Configuration.Secure.Configuration;
namespace StevanFreeborn.Extensions.Configuration.Secure.Cli.Tests.Unit.Commands;
public class GetCommandTests
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
WriteIndented = true,
};
private readonly Mock<ISecureConfig> _mockSecureConfig = new();
private readonly Mock<ITerminal> _mockTerminal = new();
private readonly Command _sut;
public GetCommandTests()
{
_sut = new GetCommandFactory(_mockSecureConfig.Object, _mockTerminal.Object).Create();
}
[Fact]
public async Task Get_WhenCalledWithoutKeyArgument_ItShouldReturnNonZeroExitCode()
{
var result = await _sut.Parse([]).InvokeAsync();
result.Should().NotBe(0);
}
[Fact]
public async Task Get_WhenCalledWithNonExistentKey_ItShouldReturnNonZeroExitCode()
{
var expectedKey = "key";
_mockSecureConfig
.Setup(m => m.GetAsync<string>(expectedKey, It.IsAny<CancellationToken>()))
.ReturnsAsync((string?)null);
var result = await _sut.Parse([expectedKey]).InvokeAsync();
result.Should().NotBe(0);
}
[Fact]
public async Task Get_WhenCalledWithKeyForNonJsonValue_ItShouldReturnZeroExitCode()
{
var expectedKey = "key";
var expectedValue = "value";
_mockSecureConfig
.Setup(m => m.GetAsync<string>(expectedKey, It.IsAny<CancellationToken>()))
.ReturnsAsync(expectedValue);
var result = await _sut.Parse([expectedKey]).InvokeAsync();
result.Should().Be(0);
}
[Fact]
public async Task Get_WhenCalledWithKeyForJsonValue_ItShouldReturnZeroExitCode()
{
var expectedKey = "key";
var expectedValue = JsonSerializer.Serialize(new { ApiKey = "Key" });
_mockSecureConfig
.Setup(m => m.GetAsync<string>(expectedKey, It.IsAny<CancellationToken>()))
.ReturnsAsync(expectedValue);
var result = await _sut.Parse([expectedKey]).InvokeAsync();
result.Should().Be(0);
}
[Fact]
public async Task Get_WhenCalledWithKeyForJsonValueAndPrettyPrint_ItShouldReturnZeroExitCode()
{
var expectedKey = "key";
var testValue = new { ApiKey = "Key" };
var expectedValue = JsonSerializer.Serialize(testValue);
var expectedPrintedValue = JsonSerializer.Serialize(testValue, JsonOptions);
_mockSecureConfig
.Setup(m => m.GetAsync<string>(expectedKey, It.IsAny<CancellationToken>()))
.ReturnsAsync(expectedValue);
var result = await _sut.Parse([expectedKey, "-p"]).InvokeAsync();
result.Should().Be(0);
_mockTerminal.Verify(m => m.WriteLine(expectedPrintedValue), Times.Once());
}
}
@@ -0,0 +1,41 @@
using System.CommandLine;
using Moq;
using StevanFreeborn.Extensions.Configuration.Secure.Cli.Commands;
using StevanFreeborn.Extensions.Configuration.Secure.Configuration;
namespace StevanFreeborn.Extensions.Configuration.Secure.Cli.Tests.Unit.Commands;
public class SetCommandFactoryTests
{
private readonly Mock<ISecureConfig> _mockSecureConfig = new();
private readonly Mock<ITerminal> _mockTerminal = new();
private readonly SetCommandFactory _sut;
public SetCommandFactoryTests()
{
_sut = new(_mockSecureConfig.Object, _mockTerminal.Object);
}
[Fact]
public void Create_WhenCalled_ItShouldReturnExpectedCommand()
{
var expectedCommand = new Command("set", "Sets the value for the given key")
{
new Argument<string>("key")
{
Description = "The key whose value you want to set.",
},
new Argument<string>("value")
{
Description = "The value that you want to set for the key."
},
};
var result = _sut.Create();
result.Should().BeEquivalentTo(expectedCommand, static o => o.IgnoringCyclicReferences());
result.Action.Should().NotBeNull();
}
}
@@ -0,0 +1,63 @@
using System.CommandLine;
using System.Text.Json;
using Moq;
using StevanFreeborn.Extensions.Configuration.Secure.Cli.Commands;
using StevanFreeborn.Extensions.Configuration.Secure.Configuration;
namespace StevanFreeborn.Extensions.Configuration.Secure.Cli.Tests.Unit.Commands;
public class SetCommandTests
{
private readonly Mock<ISecureConfig> _mockSecureConfig = new();
private readonly Mock<ITerminal> _mockTerminal = new();
private readonly Command _sut;
public SetCommandTests()
{
_sut = new SetCommandFactory(_mockSecureConfig.Object, _mockTerminal.Object).Create();
}
[Fact]
public async Task Set_WhenCalledWithoutKey_ItShouldReturnNonZeroExitCode()
{
var result = await _sut.Parse([]).InvokeAsync();
result.Should().NotBe(0);
}
[Fact]
public async Task Set_WhenCalledWithoutValue_ItShouldReturnNonZeroExitCode()
{
var result = await _sut.Parse(["key"]).InvokeAsync();
result.Should().NotBe(0);
}
[Fact]
public async Task Set_WhenCalledWithKeyAndValue_ItShouldReturnZeroExitCode()
{
var expectedKey = "key";
var expectedValue = "value";
var result = await _sut.Parse([expectedKey, expectedValue]).InvokeAsync();
result.Should().Be(0);
_mockSecureConfig.Verify(m => m.SetAsync(expectedKey, expectedValue, It.IsAny<CancellationToken>()), Times.Once());
}
[Fact]
public async Task Set_WhenCalledWithKeyAndJsonValue_ItShouldReturnZeroExitCode()
{
var expectedKey = "key";
var expectedValue = JsonSerializer.Serialize(new { ApiKey = "ApiKey" });
var result = await _sut.Parse([expectedKey, expectedValue]).InvokeAsync();
result.Should().Be(0);
_mockSecureConfig.Verify(m => m.SetAsync(expectedKey, expectedValue, It.IsAny<CancellationToken>()), Times.Once());
}
}
@@ -0,0 +1,36 @@
using StevanFreeborn.Extensions.Configuration.Secure.Cli.Commands;
namespace StevanFreeborn.Extensions.Configuration.Secure.Cli.Tests.Unit.Commands;
public class TerminalTests : IDisposable
{
private readonly StringWriter _stringWriter = new();
private readonly Terminal _sut = new();
public TerminalTests()
{
Console.SetOut(_stringWriter);
}
[Fact]
public void WriteLine_WhenCalled_ItShouldWriteToConsole()
{
var message = "test message";
_sut.WriteLine(message);
_stringWriter.ToString().Should().Be($"test message{Environment.NewLine}");
}
public void Dispose()
{
var standardOutput = new StreamWriter(Console.OpenStandardOutput())
{
AutoFlush = true
};
Console.SetOut(standardOutput);
GC.SuppressFinalize(this);
}
}
@@ -0,0 +1,125 @@
using Moq;
using StevanFreeborn.Extensions.Configuration.Secure.Cli.Configuration;
using StevanFreeborn.Extensions.Configuration.Secure.Configuration;
namespace StevanFreeborn.Extensions.Configuration.Secure.Cli.Tests.Unit.Configuration;
public class SecureConfigBuilderExtensionsTests
{
private readonly Mock<ISecureConfigBuilder> _mockBuilder = new();
[Fact]
public void ApplyProfile_WhenCalledWithValidOptions_ItShouldConfigureBuilder()
{
_mockBuilder.Setup(static m => m.UseJsonFileStorage(It.IsAny<Action<Storage.JsonStorageOptions>>()))
.Callback<Action<Storage.JsonStorageOptions>>(static a => a(new Storage.JsonStorageOptions()))
.Returns(_mockBuilder.Object);
_mockBuilder.Setup(static m => m.WithMachineIdKey()).Returns(_mockBuilder.Object);
_mockBuilder.Setup(static m => m.WithBase64EncryptionKey(It.IsAny<string>())).Returns(_mockBuilder.Object);
_mockBuilder.Setup(static m => m.WithAesCryptoProvider()).Returns(_mockBuilder.Object);
var profile = new SecureConfigProfile
{
Storage = new StorageProviderOptions
{
Type = "json",
Json = new JsonStorageOptions { DirectoryPath = "dir", FileName = "file.json" }
},
Key = new KeyProviderOptions
{
Type = "machineid"
},
Crypto = new CryptoProviderOptions
{
Type = "aes"
}
};
var result = _mockBuilder.Object.ApplyProfile(profile);
result.Should().Be(_mockBuilder.Object);
_mockBuilder.Verify(static m => m.UseJsonFileStorage(It.IsAny<Action<Storage.JsonStorageOptions>>()), Times.Once());
_mockBuilder.Verify(static m => m.WithMachineIdKey(), Times.Once());
_mockBuilder.Verify(static m => m.WithAesCryptoProvider(), Times.Once());
}
[Fact]
public void ApplyProfile_WhenCalledWithBase64Key_ItShouldConfigureBuilder()
{
var builderMock = new Mock<ISecureConfigBuilder>();
var profile = new SecureConfigProfile
{
Storage = new StorageProviderOptions { Type = "json", Json = new Cli.Configuration.JsonStorageOptions() },
Key = new KeyProviderOptions { Type = "base64", Base64 = new Base64KeyOptions { Key = "test-key" } },
Crypto = new CryptoProviderOptions { Type = "aes" }
};
builderMock.Object.ApplyProfile(profile);
builderMock.Verify(m => m.WithBase64EncryptionKey("test-key"), Times.Once());
}
[Fact]
public void ApplyProfile_WhenUnsupportedStorage_ItShouldThrowNotSupportedException()
{
var builderMock = new Mock<ISecureConfigBuilder>();
var profile = new SecureConfigProfile
{
Storage = new StorageProviderOptions { Type = "unsupported" },
Key = new KeyProviderOptions { Type = "machineid" },
Crypto = new CryptoProviderOptions { Type = "aes" }
};
Action act = () => builderMock.Object.ApplyProfile(profile);
act.Should().Throw<NotSupportedException>().WithMessage("Storage provider type 'unsupported' is not supported.");
}
[Fact]
public void ApplyProfile_WhenUnsupportedKey_ItShouldThrowNotSupportedException()
{
var builderMock = new Mock<ISecureConfigBuilder>();
var profile = new SecureConfigProfile
{
Storage = new StorageProviderOptions { Type = "json", Json = new Cli.Configuration.JsonStorageOptions() },
Key = new KeyProviderOptions { Type = "unsupported" },
Crypto = new CryptoProviderOptions { Type = "aes" }
};
Action act = () => builderMock.Object.ApplyProfile(profile);
act.Should().Throw<NotSupportedException>().WithMessage("Key provider type 'unsupported' is not supported.");
}
[Fact]
public void ApplyProfile_WhenUnsupportedCrypto_ItShouldThrowNotSupportedException()
{
var builderMock = new Mock<ISecureConfigBuilder>();
var profile = new SecureConfigProfile
{
Storage = new StorageProviderOptions { Type = "json", Json = new Cli.Configuration.JsonStorageOptions() },
Key = new KeyProviderOptions { Type = "machineid" },
Crypto = new CryptoProviderOptions { Type = "unsupported" }
};
Action act = () => builderMock.Object.ApplyProfile(profile);
act.Should().Throw<NotSupportedException>().WithMessage("Crypto provider type 'unsupported' is not supported.");
}
[Fact]
public void SecureConfigCliOptions_ShouldBeCovered()
{
var options = new SecureConfigCliOptions
{
DefaultProfile = "default",
Profiles = []
};
options.DefaultProfile.Should().Be("default");
options.Profiles.Should().NotBeNull();
}
}
@@ -0,0 +1,51 @@
using System.CommandLine;
using Microsoft.Extensions.Hosting;
using Moq;
using StevanFreeborn.Extensions.Configuration.Secure.Cli.Commands;
namespace StevanFreeborn.Extensions.Configuration.Secure.Cli.Tests.Unit;
public class SecureConfigCliTests
{
private readonly Mock<IHostApplicationLifetime> _mockLifetime = new();
private readonly Mock<ICommandFactory> _mockCommandFactory = new();
public SecureConfigCliTests()
{
_mockCommandFactory.Setup(m => m.Create()).Returns(new Command("test-command"));
}
[Fact]
public void Constructor_WhenCalled_ItShouldCallCreateOnAllFactoriesPassedIn()
{
var sut = new SecureConfigCli(["secure-config"], _mockLifetime.Object, [_mockCommandFactory.Object]);
_mockCommandFactory.Verify(static m => m.Create(), Times.Once());
}
[Fact]
public async Task StartAsync_WhenCalled_ItShouldRunTheCLI()
{
// Arrange
var sut = new SecureConfigCli(["secure-config", "--help"], _mockLifetime.Object, [_mockCommandFactory.Object]);
// Act
await sut.StartAsync(CancellationToken.None);
// Assert
_mockLifetime.Verify(m => m.StopApplication(), Times.Once());
}
[Fact]
public async Task StopAsync_WhenCalled_ItShouldNotThrowException()
{
var sut = new SecureConfigCli(["secure-config"], _mockLifetime.Object, [_mockCommandFactory.Object]);
var act = async () => await sut.StopAsync(CancellationToken.None);
await act.Should().NotThrowAsync();
}
}