feat(secrets-cli): set/get/list/rm/rotate commands
This commit is contained in:
@@ -22,6 +22,7 @@
|
|||||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.7" />
|
<PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.7" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.Configuration" Version="10.0.7" />
|
<PackageVersion Include="Microsoft.Extensions.Configuration" Version="10.0.7" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.7" />
|
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.7" />
|
||||||
|
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="10.0.7" />
|
||||||
|
|
||||||
<!-- ASP.NET Core Authentication / Authorization -->
|
<!-- ASP.NET Core Authentication / Authorization -->
|
||||||
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.7" />
|
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.7" />
|
||||||
|
|||||||
@@ -1,3 +1,152 @@
|
|||||||
// Placeholder entry point for the ZB.MOM.WW.Secrets CLI. Real commands are added
|
using Microsoft.Extensions.Configuration;
|
||||||
// in a later task; for now it is a no-op that exits successfully.
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
return 0;
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using ZB.MOM.WW.Secrets.Abstractions;
|
||||||
|
using ZB.MOM.WW.Secrets.Cli;
|
||||||
|
using ZB.MOM.WW.Secrets.DependencyInjection;
|
||||||
|
using ZB.MOM.WW.Secrets.Sqlite;
|
||||||
|
|
||||||
|
// Headless `secret` CLI: set / get / list / rm / rotate over the ZB.MOM.WW secrets store.
|
||||||
|
// The heavy lifting lives in SecretCommands; Program only builds the host, runs the schema
|
||||||
|
// migration, parses the verb + positional args, and dispatches.
|
||||||
|
|
||||||
|
HostApplicationBuilder builder = Host.CreateApplicationBuilder(args);
|
||||||
|
builder.Configuration
|
||||||
|
.AddJsonFile("appsettings.json", optional: true)
|
||||||
|
.AddEnvironmentVariables();
|
||||||
|
builder.Services.AddZbSecrets(builder.Configuration, "Secrets");
|
||||||
|
|
||||||
|
using IHost host = builder.Build();
|
||||||
|
|
||||||
|
// Ensure the schema exists before any store operation.
|
||||||
|
await host.Services.GetRequiredService<SqliteSecretsStoreMigrator>()
|
||||||
|
.MigrateAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
var commands = new SecretCommands(
|
||||||
|
host.Services.GetRequiredService<ISecretStore>(),
|
||||||
|
host.Services.GetRequiredService<ISecretCipher>(),
|
||||||
|
host.Services.GetRequiredService<ISecretResolver>(),
|
||||||
|
Console.Out);
|
||||||
|
|
||||||
|
string actor = string.IsNullOrWhiteSpace(Environment.UserName) ? "cli" : Environment.UserName;
|
||||||
|
CancellationToken ct = CancellationToken.None;
|
||||||
|
|
||||||
|
if (args.Length == 0)
|
||||||
|
{
|
||||||
|
return Usage();
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
switch (args[0])
|
||||||
|
{
|
||||||
|
case "set":
|
||||||
|
{
|
||||||
|
if (args.Length < 3)
|
||||||
|
return Usage("set requires <name> and <value>.");
|
||||||
|
(SecretContentType contentType, string? description) = ParseSetOptions(args, startIndex: 3);
|
||||||
|
return await commands.SetAsync(args[1], args[2], contentType, description, actor, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
case "rotate":
|
||||||
|
{
|
||||||
|
if (args.Length < 3)
|
||||||
|
return Usage("rotate requires <name> and <value>.");
|
||||||
|
(SecretContentType contentType, string? description) = ParseSetOptions(args, startIndex: 3);
|
||||||
|
return await commands.RotateAsync(args[1], args[2], contentType, description, actor, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
case "get":
|
||||||
|
{
|
||||||
|
if (args.Length < 2)
|
||||||
|
return Usage("get requires <name>.");
|
||||||
|
return await commands.GetAsync(args[1], ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
case "list":
|
||||||
|
{
|
||||||
|
bool includeDeleted = args.Skip(1).Any(a =>
|
||||||
|
string.Equals(a, "--include-deleted", StringComparison.Ordinal));
|
||||||
|
return await commands.ListAsync(includeDeleted, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
case "rm":
|
||||||
|
{
|
||||||
|
if (args.Length < 2)
|
||||||
|
return Usage("rm requires <name>.");
|
||||||
|
return await commands.RemoveAsync(args[1], actor, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
return Usage($"Unknown command '{args[0]}'.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (ArgumentException ex)
|
||||||
|
{
|
||||||
|
// Invalid secret name, bad --content-type value, unknown option: report without a stack trace.
|
||||||
|
Console.Error.WriteLine(ex.Message);
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- helpers ---------------------------------------------------------------
|
||||||
|
|
||||||
|
// Parses the optional flags shared by `set` and `rotate`:
|
||||||
|
// [--content-type text|connection-string|json|binary-base64] [--description "..."]
|
||||||
|
static (SecretContentType contentType, string? description) ParseSetOptions(string[] args, int startIndex)
|
||||||
|
{
|
||||||
|
SecretContentType contentType = SecretContentType.Text;
|
||||||
|
string? description = null;
|
||||||
|
|
||||||
|
for (int i = startIndex; i < args.Length; i++)
|
||||||
|
{
|
||||||
|
switch (args[i])
|
||||||
|
{
|
||||||
|
case "--content-type":
|
||||||
|
if (i + 1 >= args.Length)
|
||||||
|
throw new ArgumentException("--content-type requires a value.");
|
||||||
|
contentType = ParseContentType(args[++i]);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "--description":
|
||||||
|
if (i + 1 >= args.Length)
|
||||||
|
throw new ArgumentException("--description requires a value.");
|
||||||
|
description = args[++i];
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
throw new ArgumentException($"Unknown option '{args[i]}'.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (contentType, description);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Maps the CLI content-type token (kebab-case) to the enum.
|
||||||
|
static SecretContentType ParseContentType(string value) => value switch
|
||||||
|
{
|
||||||
|
"text" => SecretContentType.Text,
|
||||||
|
"connection-string" => SecretContentType.ConnectionString,
|
||||||
|
"json" => SecretContentType.Json,
|
||||||
|
"binary-base64" => SecretContentType.BinaryBase64,
|
||||||
|
_ => throw new ArgumentException(
|
||||||
|
$"Unknown --content-type '{value}'; expected text|connection-string|json|binary-base64."),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Prints usage (optionally preceded by an error line) and returns the standard usage exit code.
|
||||||
|
int Usage(string? error = null)
|
||||||
|
{
|
||||||
|
if (error is not null)
|
||||||
|
Console.Error.WriteLine(error);
|
||||||
|
|
||||||
|
Console.Error.WriteLine(
|
||||||
|
"""
|
||||||
|
Usage: secret <command> [args]
|
||||||
|
|
||||||
|
set <name> <value> [--content-type text|connection-string|json|binary-base64] [--description "..."]
|
||||||
|
rotate <name> <value> [--content-type ...] [--description "..."]
|
||||||
|
get <name>
|
||||||
|
list [--include-deleted]
|
||||||
|
rm <name>
|
||||||
|
""");
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,174 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using ZB.MOM.WW.Secrets.Abstractions;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.Secrets.Cli;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The thin, front-end-agnostic command layer behind the <c>secret</c> CLI verbs
|
||||||
|
/// (<c>set</c> / <c>get</c> / <c>list</c> / <c>rm</c> / <c>rotate</c>). Each verb is an
|
||||||
|
/// <see cref="int"/>-returning task (0 on success, non-zero on error) and writes ALL output to the
|
||||||
|
/// injected <see cref="TextWriter"/> — never to <see cref="Console"/> directly — so the layer is
|
||||||
|
/// fully testable and can be hosted under any front-end. Confirmation and listing output is JSON
|
||||||
|
/// metadata only and NEVER carries a secret value; the sole value-exposing surface is
|
||||||
|
/// <see cref="GetAsync"/>, which intentionally prints the decrypted plaintext (see its remarks).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SecretCommands
|
||||||
|
{
|
||||||
|
private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = false };
|
||||||
|
|
||||||
|
private readonly ISecretStore _store;
|
||||||
|
private readonly ISecretCipher _cipher;
|
||||||
|
private readonly ISecretResolver _resolver;
|
||||||
|
private readonly TextWriter _output;
|
||||||
|
|
||||||
|
/// <summary>Creates the command set over the supplied core seams and output sink.</summary>
|
||||||
|
/// <param name="store">The encrypted store (upsert / delete / list).</param>
|
||||||
|
/// <param name="cipher">The envelope cipher used to seal values on set / rotate.</param>
|
||||||
|
/// <param name="resolver">The read seam used by <c>get</c> to resolve current plaintext.</param>
|
||||||
|
/// <param name="output">Sink for all command output (JSON confirmations, listings, values).</param>
|
||||||
|
public SecretCommands(ISecretStore store, ISecretCipher cipher, ISecretResolver resolver, TextWriter output)
|
||||||
|
{
|
||||||
|
_store = store ?? throw new ArgumentNullException(nameof(store));
|
||||||
|
_cipher = cipher ?? throw new ArgumentNullException(nameof(cipher));
|
||||||
|
_resolver = resolver ?? throw new ArgumentNullException(nameof(resolver));
|
||||||
|
_output = output ?? throw new ArgumentNullException(nameof(output));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// set: seals <paramref name="value"/> under a fresh per-secret DEK and upserts it (create or
|
||||||
|
/// overwrite-in-place). Prints a JSON confirmation carrying only the name and action — never the
|
||||||
|
/// value.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="name">The secret name.</param>
|
||||||
|
/// <param name="value">The plaintext to seal.</param>
|
||||||
|
/// <param name="contentType">How consumers should interpret the plaintext.</param>
|
||||||
|
/// <param name="description">Optional human-readable description.</param>
|
||||||
|
/// <param name="actor">Principal recorded as created_by / updated_by, if known.</param>
|
||||||
|
/// <param name="ct">A token to cancel the operation.</param>
|
||||||
|
/// <returns>0 on success.</returns>
|
||||||
|
public async Task<int> SetAsync(
|
||||||
|
string name, string value, SecretContentType contentType, string? description, string? actor, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var secretName = new SecretName(name);
|
||||||
|
await UpsertSealedAsync(secretName, value, contentType, description, actor, ct).ConfigureAwait(false);
|
||||||
|
WriteJson(new { name = secretName.Value, action = "set" });
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// rotate: like <see cref="SetAsync"/>, but REQUIRES the secret to already exist (and not be
|
||||||
|
/// tombstoned). Rotating a missing secret prints a not-found error and returns non-zero without
|
||||||
|
/// creating anything. The confirmation carries only the name and action — never the value.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="name">The existing secret to rotate.</param>
|
||||||
|
/// <param name="value">The new plaintext to seal in place.</param>
|
||||||
|
/// <param name="contentType">How consumers should interpret the plaintext.</param>
|
||||||
|
/// <param name="description">Optional human-readable description.</param>
|
||||||
|
/// <param name="actor">Principal recorded as updated_by, if known.</param>
|
||||||
|
/// <param name="ct">A token to cancel the operation.</param>
|
||||||
|
/// <returns>0 on success; non-zero if the secret does not exist.</returns>
|
||||||
|
public async Task<int> RotateAsync(
|
||||||
|
string name, string value, SecretContentType contentType, string? description, string? actor, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var secretName = new SecretName(name);
|
||||||
|
|
||||||
|
StoredSecret? existing = await _store.GetAsync(secretName, ct).ConfigureAwait(false);
|
||||||
|
if (existing is null || existing.IsDeleted)
|
||||||
|
{
|
||||||
|
WriteJson(new { name = secretName.Value, error = "not-found" });
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
await UpsertSealedAsync(secretName, value, contentType, description, actor, ct).ConfigureAwait(false);
|
||||||
|
WriteJson(new { name = secretName.Value, action = "rotate" });
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// get: resolves and prints the current DECRYPTED plaintext for <paramref name="name"/> to the
|
||||||
|
/// output sink.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>This deliberately exposes the secret value.</b> It is the one command whose whole purpose
|
||||||
|
/// is to surface plaintext (to stdout), so an operator can pipe or capture it. Every other
|
||||||
|
/// command emits metadata only. A missing or tombstoned secret prints a JSON <c>not-found</c>
|
||||||
|
/// error and returns non-zero.
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="name">The secret to resolve.</param>
|
||||||
|
/// <param name="ct">A token to cancel the operation.</param>
|
||||||
|
/// <returns>0 when the value was printed; non-zero when the secret is absent.</returns>
|
||||||
|
public async Task<int> GetAsync(string name, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var secretName = new SecretName(name);
|
||||||
|
|
||||||
|
string? value = await _resolver.GetAsync(secretName, ct).ConfigureAwait(false);
|
||||||
|
if (value is null)
|
||||||
|
{
|
||||||
|
WriteJson(new { error = "not-found" });
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
_output.WriteLine(value);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// list: prints a JSON array of the safe metadata projection for every secret — name,
|
||||||
|
/// description, content type, KEK id, revision, tombstone flag, and update stamps. NEVER a value.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="includeDeleted">When <see langword="true"/>, tombstoned rows are included.</param>
|
||||||
|
/// <param name="ct">A token to cancel the operation.</param>
|
||||||
|
/// <returns>0 on success.</returns>
|
||||||
|
public async Task<int> ListAsync(bool includeDeleted, CancellationToken ct)
|
||||||
|
{
|
||||||
|
IReadOnlyList<SecretMetadata> items = await _store.ListAsync(includeDeleted, ct).ConfigureAwait(false);
|
||||||
|
|
||||||
|
var projection = items.Select(m => new
|
||||||
|
{
|
||||||
|
name = m.Name.Value,
|
||||||
|
description = m.Description,
|
||||||
|
contentType = m.ContentType.ToString(),
|
||||||
|
kekId = m.KekId,
|
||||||
|
revision = m.Revision,
|
||||||
|
isDeleted = m.IsDeleted,
|
||||||
|
updatedUtc = m.UpdatedUtc,
|
||||||
|
updatedBy = m.UpdatedBy,
|
||||||
|
});
|
||||||
|
|
||||||
|
WriteJson(projection);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// rm: soft-deletes (tombstones) <paramref name="name"/> and prints a JSON confirmation with a
|
||||||
|
/// <c>found</c> flag.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="name">The secret to tombstone.</param>
|
||||||
|
/// <param name="actor">Principal recorded as updated_by, if known.</param>
|
||||||
|
/// <param name="ct">A token to cancel the operation.</param>
|
||||||
|
/// <returns>0 if a row was tombstoned; non-zero if no such row existed.</returns>
|
||||||
|
public async Task<int> RemoveAsync(string name, string? actor, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var secretName = new SecretName(name);
|
||||||
|
|
||||||
|
bool removed = await _store.DeleteAsync(secretName, actor, ct).ConfigureAwait(false);
|
||||||
|
WriteJson(new { name = secretName.Value, action = "removed", found = removed });
|
||||||
|
return removed ? 0 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Seals a value under the cipher and upserts it, carrying description + actor stamps.</summary>
|
||||||
|
private async Task UpsertSealedAsync(
|
||||||
|
SecretName name, string value, SecretContentType contentType, string? description, string? actor, CancellationToken ct)
|
||||||
|
{
|
||||||
|
StoredSecret row = _cipher.Encrypt(name, value, contentType) with
|
||||||
|
{
|
||||||
|
Description = description,
|
||||||
|
CreatedBy = actor,
|
||||||
|
UpdatedBy = actor,
|
||||||
|
};
|
||||||
|
await _store.UpsertAsync(row, ct).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Serializes <paramref name="value"/> to compact JSON on its own line.</summary>
|
||||||
|
private void WriteJson(object value) => _output.WriteLine(JsonSerializer.Serialize(value, JsonOptions));
|
||||||
|
}
|
||||||
@@ -8,8 +8,18 @@
|
|||||||
<IsPackable>false</IsPackable>
|
<IsPackable>false</IsPackable>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Hosting" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\ZB.MOM.WW.Secrets\ZB.MOM.WW.Secrets.csproj" />
|
<ProjectReference Include="..\ZB.MOM.WW.Secrets\ZB.MOM.WW.Secrets.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<None Update="appsettings.json">
|
||||||
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
|
</None>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"Secrets": {
|
||||||
|
"SqlitePath": "secrets.db",
|
||||||
|
"MasterKey": {
|
||||||
|
"Source": "Environment",
|
||||||
|
"EnvVarName": "ZB_SECRETS_MASTER_KEY"
|
||||||
|
},
|
||||||
|
"RunMigrationsOnStartup": true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
using ZB.MOM.WW.Audit;
|
||||||
|
using ZB.MOM.WW.Secrets.Abstractions;
|
||||||
|
using ZB.MOM.WW.Secrets.Cli;
|
||||||
|
using ZB.MOM.WW.Secrets.Crypto;
|
||||||
|
using ZB.MOM.WW.Secrets.Sqlite;
|
||||||
|
using ZB.MOM.WW.Secrets.Tests.Fakes;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.Secrets.Tests.Cli;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Exercises the headless <see cref="SecretCommands"/> layer over a real migrated SQLite store,
|
||||||
|
/// the real envelope cipher, and the real resolver — asserting round-trips, exit codes, and the
|
||||||
|
/// hard invariant that list/set output never carries a plaintext value.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SecretCommandsTests : IAsyncLifetime, IDisposable
|
||||||
|
{
|
||||||
|
private readonly string _dbPath =
|
||||||
|
Path.Combine(Path.GetTempPath(), $"zb-secrets-cli-{Guid.NewGuid():N}.db");
|
||||||
|
|
||||||
|
private readonly SecretsSqliteConnectionFactory _factory;
|
||||||
|
private readonly SqliteSecretStore _store;
|
||||||
|
private readonly AesGcmEnvelopeCipher _cipher;
|
||||||
|
private readonly DefaultSecretResolver _resolver;
|
||||||
|
|
||||||
|
public SecretCommandsTests()
|
||||||
|
{
|
||||||
|
_factory = new SecretsSqliteConnectionFactory(_dbPath);
|
||||||
|
_store = new SqliteSecretStore(_factory);
|
||||||
|
_cipher = new AesGcmEnvelopeCipher(new FakeMasterKeyProvider("kek-cli"));
|
||||||
|
// A near-zero TTL keeps the resolver from serving a stale plaintext across a rotate/remove.
|
||||||
|
_resolver = new DefaultSecretResolver(
|
||||||
|
_store, _cipher, NoOpAuditWriter.Instance, TimeSpan.Zero);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task InitializeAsync() =>
|
||||||
|
await new SqliteSecretsStoreMigrator(_factory).MigrateAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
public Task DisposeAsync() => Task.CompletedTask;
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (File.Exists(_dbPath))
|
||||||
|
{
|
||||||
|
try { File.Delete(_dbPath); } catch (IOException) { /* best-effort temp cleanup */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private (SecretCommands cmd, StringWriter output) NewSut()
|
||||||
|
{
|
||||||
|
var output = new StringWriter();
|
||||||
|
return (new SecretCommands(_store, _cipher, _resolver, output), output);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CancellationToken Ct => CancellationToken.None;
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Set_Then_Get_RoundTrips()
|
||||||
|
{
|
||||||
|
var (cmd, setOut) = NewSut();
|
||||||
|
|
||||||
|
int setRc = await cmd.SetAsync(
|
||||||
|
"sql/db-conn", "hunter2", SecretContentType.ConnectionString, "prod db", "alice", Ct);
|
||||||
|
|
||||||
|
Assert.Equal(0, setRc);
|
||||||
|
// set confirmation must NOT leak the value.
|
||||||
|
Assert.DoesNotContain("hunter2", setOut.ToString());
|
||||||
|
Assert.Contains("\"action\":\"set\"", setOut.ToString());
|
||||||
|
Assert.Contains("sql/db-conn", setOut.ToString());
|
||||||
|
|
||||||
|
// A fresh writer for get so we can assert exactly the plaintext came back.
|
||||||
|
var getOut = new StringWriter();
|
||||||
|
var getCmd = new SecretCommands(_store, _cipher, _resolver, getOut);
|
||||||
|
int getRc = await getCmd.GetAsync("sql/db-conn", Ct);
|
||||||
|
|
||||||
|
Assert.Equal(0, getRc);
|
||||||
|
Assert.Equal("hunter2", getOut.ToString().TrimEnd('\r', '\n'));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task List_PrintsMetadata_NoValues()
|
||||||
|
{
|
||||||
|
var (cmd, _) = NewSut();
|
||||||
|
await cmd.SetAsync("app/one", "value-one", SecretContentType.Text, null, "alice", Ct);
|
||||||
|
await cmd.SetAsync("app/two", "value-two", SecretContentType.Json, "second", "alice", Ct);
|
||||||
|
|
||||||
|
var listOut = new StringWriter();
|
||||||
|
var listCmd = new SecretCommands(_store, _cipher, _resolver, listOut);
|
||||||
|
int rc = await listCmd.ListAsync(includeDeleted: false, Ct);
|
||||||
|
|
||||||
|
string json = listOut.ToString();
|
||||||
|
Assert.Equal(0, rc);
|
||||||
|
Assert.Contains("app/one", json);
|
||||||
|
Assert.Contains("app/two", json);
|
||||||
|
Assert.DoesNotContain("value-one", json);
|
||||||
|
Assert.DoesNotContain("value-two", json);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Rm_Tombstones()
|
||||||
|
{
|
||||||
|
var (cmd, _) = NewSut();
|
||||||
|
await cmd.SetAsync("app/gone", "bye", SecretContentType.Text, null, "alice", Ct);
|
||||||
|
|
||||||
|
var rm1Out = new StringWriter();
|
||||||
|
int rc1 = await new SecretCommands(_store, _cipher, _resolver, rm1Out).RemoveAsync("app/gone", "alice", Ct);
|
||||||
|
Assert.Equal(0, rc1);
|
||||||
|
Assert.Contains("\"found\":true", rm1Out.ToString());
|
||||||
|
|
||||||
|
// Second remove: already tombstoned → not found.
|
||||||
|
var rm2Out = new StringWriter();
|
||||||
|
int rc2 = await new SecretCommands(_store, _cipher, _resolver, rm2Out).RemoveAsync("app/gone", "alice", Ct);
|
||||||
|
Assert.NotEqual(0, rc2);
|
||||||
|
Assert.Contains("\"found\":false", rm2Out.ToString());
|
||||||
|
|
||||||
|
// Get after removal → not-found.
|
||||||
|
var getOut = new StringWriter();
|
||||||
|
int getRc = await new SecretCommands(_store, _cipher, _resolver, getOut).GetAsync("app/gone", Ct);
|
||||||
|
Assert.NotEqual(0, getRc);
|
||||||
|
Assert.Contains("not-found", getOut.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Rotate_RequiresExisting_OverwritesInPlace()
|
||||||
|
{
|
||||||
|
// Rotate on a missing secret → non-zero, nothing created.
|
||||||
|
var missingOut = new StringWriter();
|
||||||
|
int missingRc = await new SecretCommands(_store, _cipher, _resolver, missingOut)
|
||||||
|
.RotateAsync("app/rot", "v1", SecretContentType.Text, null, "alice", Ct);
|
||||||
|
Assert.NotEqual(0, missingRc);
|
||||||
|
|
||||||
|
// After a set, rotate to a new value → 0, and get returns the new value.
|
||||||
|
await new SecretCommands(_store, _cipher, _resolver, new StringWriter())
|
||||||
|
.SetAsync("app/rot", "v1", SecretContentType.Text, null, "alice", Ct);
|
||||||
|
|
||||||
|
var rotateOut = new StringWriter();
|
||||||
|
int rotateRc = await new SecretCommands(_store, _cipher, _resolver, rotateOut)
|
||||||
|
.RotateAsync("app/rot", "v2", SecretContentType.Text, null, "bob", Ct);
|
||||||
|
Assert.Equal(0, rotateRc);
|
||||||
|
Assert.Contains("\"action\":\"rotate\"", rotateOut.ToString());
|
||||||
|
Assert.DoesNotContain("v2", rotateOut.ToString());
|
||||||
|
|
||||||
|
var getOut = new StringWriter();
|
||||||
|
await new SecretCommands(_store, _cipher, _resolver, getOut).GetAsync("app/rot", Ct);
|
||||||
|
Assert.Equal("v2", getOut.ToString().TrimEnd('\r', '\n'));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Get_MissingSecret_ReturnsNonZero()
|
||||||
|
{
|
||||||
|
var (cmd, output) = NewSut();
|
||||||
|
int rc = await cmd.GetAsync("nope/missing", Ct);
|
||||||
|
|
||||||
|
Assert.NotEqual(0, rc);
|
||||||
|
Assert.Contains("not-found", output.ToString());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\..\src\ZB.MOM.WW.Secrets\ZB.MOM.WW.Secrets.csproj" />
|
<ProjectReference Include="..\..\src\ZB.MOM.WW.Secrets\ZB.MOM.WW.Secrets.csproj" />
|
||||||
<ProjectReference Include="..\..\src\ZB.MOM.WW.Secrets.Abstractions\ZB.MOM.WW.Secrets.Abstractions.csproj" />
|
<ProjectReference Include="..\..\src\ZB.MOM.WW.Secrets.Abstractions\ZB.MOM.WW.Secrets.Abstractions.csproj" />
|
||||||
|
<ProjectReference Include="..\..\src\ZB.MOM.WW.Secrets.Cli\ZB.MOM.WW.Secrets.Cli.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
Reference in New Issue
Block a user