Files
scadaproj/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Cli/Interactive/Flows/CrudFlowTests.cs
T
2026-07-19 09:37:46 -04:00

221 lines
8.9 KiB
C#

using System.Security.Cryptography;
using Spectre.Console;
using Spectre.Console.Testing;
using ZB.MOM.WW.Secrets.Abstractions;
using ZB.MOM.WW.Secrets.Cli.Interactive;
using ZB.MOM.WW.Secrets.Cli.Interactive.Flows;
using ZB.MOM.WW.Secrets.MasterKey;
namespace ZB.MOM.WW.Secrets.Tests.Cli.Interactive.Flows;
/// <summary>
/// Drives the four CRUD flows (<see cref="ListSecretsFlow"/>, <see cref="SetSecretFlow"/>,
/// <see cref="RevealSecretFlow"/>, <see cref="DeleteSecretFlow"/>) directly against a real temp-SQLite
/// full session (KEK supplied as a <see cref="LiteralMasterKeyProvider"/> override). Each flow renders to
/// a scripted <see cref="TestConsole"/>, so the tests assert on rendered <see cref="TestConsole.Output"/>
/// and on the resulting store state — the invariant under test is that value plaintext only ever reaches
/// the screen through the reveal flow's explicit confirmation.
/// </summary>
public sealed class CrudFlowTests : IDisposable
{
private readonly string _dir =
Path.Combine(Path.GetTempPath(), $"zb-secrets-crud-{Guid.NewGuid():N}");
private string DbPath => Path.Combine(_dir, "secrets.db");
public CrudFlowTests() => Directory.CreateDirectory(_dir);
public void Dispose()
{
if (Directory.Exists(_dir))
{
try { Directory.Delete(_dir, recursive: true); } catch (IOException) { /* best-effort temp cleanup */ }
}
}
private static TestConsole NewConsole()
{
var console = (TestConsole)new TestConsole().Interactive();
console.Profile.Width = 240; // wide enough that the metadata table never truncates a cell
return console;
}
// A full (KEK-capable) session on a fresh temp SQLite store, keyed by an operator-override KEK so the
// test never depends on ambient environment state.
private async Task<SecretsSession> NewFullSessionAsync()
{
byte[] key = new byte[32];
RandomNumberGenerator.Fill(key);
var kek = new LiteralMasterKeyProvider(Convert.ToBase64String(key));
StoreTarget target = new TargetConfigReader().Manual(DbPath, new MasterKeyOptions
{
Source = MasterKeySource.Environment,
EnvVarName = $"ZB_TEST_UNSET_{Guid.NewGuid():N}",
});
return await new SecretsSessionFactory().OpenAsync(target, kek, CancellationToken.None);
}
private static async Task SeedAsync(
SecretsSession session, string name, string value, SecretContentType contentType = SecretContentType.Text)
{
var secretName = new SecretName(name);
StoredSecret row = session.Cipher!.Encrypt(secretName, value, contentType);
await session.Store.UpsertAsync(row, CancellationToken.None);
}
private static int CountOccurrences(string haystack, string needle)
{
int count = 0;
for (int i = haystack.IndexOf(needle, StringComparison.Ordinal); i >= 0;
i = haystack.IndexOf(needle, i + needle.Length, StringComparison.Ordinal))
{
count++;
}
return count;
}
// --- List ---
[Fact]
public async Task Renders_metadata_table_never_values()
{
SecretsSession session = await NewFullSessionAsync();
await SeedAsync(session, "db/conn", "SENTINEL-CONN-VALUE", SecretContentType.ConnectionString);
await SeedAsync(session, "api/key", "SENTINEL-API-VALUE");
TestConsole console = NewConsole();
await new ListSecretsFlow().RunAsync(console, session, CancellationToken.None);
string output = console.Output;
Assert.Contains("db/conn", output, StringComparison.Ordinal);
Assert.Contains("api/key", output, StringComparison.Ordinal);
Assert.Contains("ConnectionString", output, StringComparison.Ordinal);
Assert.Contains(session.MasterKey!.KekId, output, StringComparison.Ordinal); // kek id column
Assert.DoesNotContain("SENTINEL-CONN-VALUE", output, StringComparison.Ordinal);
Assert.DoesNotContain("SENTINEL-API-VALUE", output, StringComparison.Ordinal);
Assert.False(new ListSecretsFlow().RequiresKek);
}
// --- Set / rotate ---
[Fact]
public async Task Masked_prompt_seals_and_upserts()
{
SecretsSession session = await NewFullSessionAsync();
TestConsole console = NewConsole();
console.Input.PushTextWithEnter("app/token"); // name
console.Input.PushTextWithEnter("TOPSECRET-PLAINTEXT"); // value (masked)
console.Input.PushKey(ConsoleKey.Enter); // content-type: first choice (Text)
console.Input.PushKey(ConsoleKey.Enter); // description: empty
await new SetSecretFlow().RunAsync(console, session, CancellationToken.None);
StoredSecret? row = await session.Store.GetAsync(new SecretName("app/token"), CancellationToken.None);
Assert.NotNull(row);
Assert.Equal("TOPSECRET-PLAINTEXT", session.Cipher!.Decrypt(row!));
Assert.DoesNotContain("TOPSECRET-PLAINTEXT", console.Output, StringComparison.Ordinal); // never echoed
}
[Fact]
public async Task Existing_name_confirms_overwrite()
{
SecretsSession session = await NewFullSessionAsync();
await SeedAsync(session, "shared/api", "OLD-VALUE");
// Decline: the value must be left unchanged.
TestConsole declineConsole = NewConsole();
declineConsole.Input.PushTextWithEnter("shared/api"); // name (exists)
declineConsole.Input.PushTextWithEnter("n"); // overwrite? no
await new SetSecretFlow().RunAsync(declineConsole, session, CancellationToken.None);
StoredSecret? afterDecline = await session.Store.GetAsync(new SecretName("shared/api"), CancellationToken.None);
Assert.Equal("OLD-VALUE", session.Cipher!.Decrypt(afterDecline!));
// Accept: the value is overwritten in place.
TestConsole acceptConsole = NewConsole();
acceptConsole.Input.PushTextWithEnter("shared/api"); // name (exists)
acceptConsole.Input.PushTextWithEnter("y"); // overwrite? yes
acceptConsole.Input.PushTextWithEnter("NEW-VALUE"); // value (masked)
acceptConsole.Input.PushKey(ConsoleKey.Enter); // content-type: Text
acceptConsole.Input.PushKey(ConsoleKey.Enter); // description: empty
await new SetSecretFlow().RunAsync(acceptConsole, session, CancellationToken.None);
StoredSecret? afterAccept = await session.Store.GetAsync(new SecretName("shared/api"), CancellationToken.None);
Assert.Equal("NEW-VALUE", session.Cipher!.Decrypt(afterAccept!));
}
// --- Reveal ---
[Fact]
public async Task Confirm_then_prints_value_once()
{
SecretsSession session = await NewFullSessionAsync();
await SeedAsync(session, "reveal/me", "REVEAL-SENTINEL-XYZ");
TestConsole console = NewConsole();
console.Input.PushTextWithEnter("reveal/me"); // name
console.Input.PushTextWithEnter("y"); // reveal on screen? yes
await new RevealSecretFlow().RunAsync(console, session, CancellationToken.None);
Assert.Equal(1, CountOccurrences(console.Output, "REVEAL-SENTINEL-XYZ"));
}
[Fact]
public async Task Decline_prints_nothing()
{
SecretsSession session = await NewFullSessionAsync();
await SeedAsync(session, "reveal/me", "REVEAL-SENTINEL-XYZ");
TestConsole console = NewConsole();
console.Input.PushTextWithEnter("reveal/me"); // name
console.Input.PushTextWithEnter("n"); // reveal on screen? no
await new RevealSecretFlow().RunAsync(console, session, CancellationToken.None);
Assert.DoesNotContain("REVEAL-SENTINEL-XYZ", console.Output, StringComparison.Ordinal);
}
// --- Delete ---
[Fact]
public async Task Confirm_tombstones_row()
{
SecretsSession session = await NewFullSessionAsync();
await SeedAsync(session, "del/me", "GONE");
TestConsole console = NewConsole();
console.Input.PushTextWithEnter("del/me"); // name
console.Input.PushTextWithEnter("y"); // delete? yes
await new DeleteSecretFlow().RunAsync(console, session, CancellationToken.None);
StoredSecret? row = await session.Store.GetAsync(new SecretName("del/me"), CancellationToken.None);
Assert.NotNull(row);
Assert.True(row!.IsDeleted);
}
[Fact]
public async Task Decline_leaves_row()
{
SecretsSession session = await NewFullSessionAsync();
await SeedAsync(session, "del/me", "STAYS");
TestConsole console = NewConsole();
console.Input.PushTextWithEnter("del/me"); // name
console.Input.PushTextWithEnter("n"); // delete? no
await new DeleteSecretFlow().RunAsync(console, session, CancellationToken.None);
StoredSecret? row = await session.Store.GetAsync(new SecretName("del/me"), CancellationToken.None);
Assert.NotNull(row);
Assert.False(row!.IsDeleted);
}
}