feat(secrets-cli): recent-targets store for the interactive console
This commit is contained in:
@@ -0,0 +1,92 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.Secrets.Cli.Interactive;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Persists the operator's recently-used store targets (names + appsettings paths ONLY — never
|
||||||
|
/// key material or secret values) so the interactive console can offer quick re-selection.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class RecentTargetsStore
|
||||||
|
{
|
||||||
|
private const int MaxEntries = 10;
|
||||||
|
|
||||||
|
private static readonly JsonSerializerOptions SerializerOptions = new()
|
||||||
|
{
|
||||||
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||||
|
WriteIndented = true,
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly string _filePath;
|
||||||
|
private readonly TimeProvider _timeProvider;
|
||||||
|
|
||||||
|
/// <summary>Creates the store bound to a JSON file path, using an optional injected clock.</summary>
|
||||||
|
/// <param name="filePath">The JSON file to load from and save to.</param>
|
||||||
|
/// <param name="timeProvider">
|
||||||
|
/// Clock used to stamp <see cref="RecentTarget.LastUsedUtc"/>. Defaults to
|
||||||
|
/// <see cref="TimeProvider.System"/> when omitted.
|
||||||
|
/// </param>
|
||||||
|
public RecentTargetsStore(string filePath, TimeProvider? timeProvider = null)
|
||||||
|
{
|
||||||
|
_filePath = filePath;
|
||||||
|
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>One remembered target: a display name, its appsettings.json path, and when it was last used.</summary>
|
||||||
|
/// <param name="Name">Display name for the target (e.g. the app name).</param>
|
||||||
|
/// <param name="AppSettingsPath">Filesystem path to the target's appsettings.json.</param>
|
||||||
|
/// <param name="LastUsedUtc">Timestamp of the most recent selection of this target.</param>
|
||||||
|
public sealed record RecentTarget(string Name, string AppSettingsPath, DateTimeOffset LastUsedUtc);
|
||||||
|
|
||||||
|
/// <summary>Loads the recents list, newest first. A missing or unreadable/corrupt file yields an empty list.</summary>
|
||||||
|
public IReadOnlyList<RecentTarget> Load()
|
||||||
|
{
|
||||||
|
if (!File.Exists(_filePath))
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var json = File.ReadAllText(_filePath);
|
||||||
|
var entries = JsonSerializer.Deserialize<List<RecentTarget>>(json, SerializerOptions);
|
||||||
|
return entries ?? [];
|
||||||
|
}
|
||||||
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
catch (IOException)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Upserts a target by path, moves it to the front, stamps now, caps the list at 10, and saves.</summary>
|
||||||
|
/// <param name="name">Display name for the target.</param>
|
||||||
|
/// <param name="appSettingsPath">Filesystem path to the target's appsettings.json — the dedup key.</param>
|
||||||
|
public void Touch(string name, string appSettingsPath)
|
||||||
|
{
|
||||||
|
var existing = Load();
|
||||||
|
var remaining = existing.Where(e => !string.Equals(e.AppSettingsPath, appSettingsPath, StringComparison.Ordinal));
|
||||||
|
var touched = new RecentTarget(name, appSettingsPath, _timeProvider.GetUtcNow());
|
||||||
|
var updated = new List<RecentTarget> { touched };
|
||||||
|
updated.AddRange(remaining.Take(MaxEntries - 1));
|
||||||
|
|
||||||
|
Save(updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Save(IReadOnlyList<RecentTarget> entries)
|
||||||
|
{
|
||||||
|
var directory = Path.GetDirectoryName(_filePath);
|
||||||
|
if (!string.IsNullOrEmpty(directory))
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(directory);
|
||||||
|
}
|
||||||
|
|
||||||
|
var json = JsonSerializer.Serialize(entries, SerializerOptions);
|
||||||
|
var tmpPath = _filePath + ".tmp";
|
||||||
|
File.WriteAllText(tmpPath, json);
|
||||||
|
File.Move(tmpPath, _filePath, overwrite: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
+127
@@ -0,0 +1,127 @@
|
|||||||
|
using ZB.MOM.WW.Secrets.Cli.Interactive;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.Secrets.Tests.Cli.Interactive;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Exercises <see cref="RecentTargetsStore"/> against a real temp file, verifying round-trip
|
||||||
|
/// persistence, ordering/dedup/cap behavior, corrupt-file tolerance, and — the hard security
|
||||||
|
/// invariant — that the persisted JSON never carries anything beyond name/path/timestamp.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class RecentTargetsStoreTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly string _dir =
|
||||||
|
Path.Combine(Path.GetTempPath(), $"zb-secrets-recent-{Guid.NewGuid():N}");
|
||||||
|
|
||||||
|
private string FilePath => Path.Combine(_dir, "recent-targets.json");
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (Directory.Exists(_dir))
|
||||||
|
{
|
||||||
|
try { Directory.Delete(_dir, recursive: true); } catch (IOException) { /* best-effort temp cleanup */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A minimal, manually-advanceable <see cref="TimeProvider"/> fake for deterministic ordering assertions.</summary>
|
||||||
|
private sealed class FakeTimeProvider : TimeProvider
|
||||||
|
{
|
||||||
|
private DateTimeOffset _now;
|
||||||
|
|
||||||
|
public FakeTimeProvider(DateTimeOffset start) => _now = start;
|
||||||
|
|
||||||
|
public void Advance(TimeSpan by) => _now += by;
|
||||||
|
|
||||||
|
public override DateTimeOffset GetUtcNow() => _now;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Load_returns_empty_when_file_missing()
|
||||||
|
{
|
||||||
|
var sut = new RecentTargetsStore(FilePath);
|
||||||
|
|
||||||
|
var result = sut.Load();
|
||||||
|
|
||||||
|
Assert.Empty(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Touch_then_load_roundtrips_name_and_path()
|
||||||
|
{
|
||||||
|
var sut = new RecentTargetsStore(FilePath);
|
||||||
|
|
||||||
|
sut.Touch("OtOpcUa", "/apps/otopcua/appsettings.json");
|
||||||
|
var result = sut.Load();
|
||||||
|
|
||||||
|
var entry = Assert.Single(result);
|
||||||
|
Assert.Equal("OtOpcUa", entry.Name);
|
||||||
|
Assert.Equal("/apps/otopcua/appsettings.json", entry.AppSettingsPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Touch_existing_path_moves_it_first_and_updates_stamp()
|
||||||
|
{
|
||||||
|
var time = new FakeTimeProvider(new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero));
|
||||||
|
var sut = new RecentTargetsStore(FilePath, time);
|
||||||
|
|
||||||
|
sut.Touch("OtOpcUa", "/apps/otopcua/appsettings.json");
|
||||||
|
time.Advance(TimeSpan.FromMinutes(1));
|
||||||
|
sut.Touch("ScadaBridge", "/apps/scadabridge/appsettings.json");
|
||||||
|
time.Advance(TimeSpan.FromMinutes(1));
|
||||||
|
var beforeRetouch = time.GetUtcNow();
|
||||||
|
sut.Touch("OtOpcUa", "/apps/otopcua/appsettings.json");
|
||||||
|
|
||||||
|
var result = sut.Load();
|
||||||
|
|
||||||
|
Assert.Equal(2, result.Count);
|
||||||
|
Assert.Equal("OtOpcUa", result[0].Name);
|
||||||
|
Assert.Equal("/apps/otopcua/appsettings.json", result[0].AppSettingsPath);
|
||||||
|
Assert.Equal(beforeRetouch, result[0].LastUsedUtc);
|
||||||
|
Assert.Equal("ScadaBridge", result[1].Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Load_tolerates_corrupt_json_by_returning_empty()
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(_dir);
|
||||||
|
File.WriteAllText(FilePath, "{ this is not valid json ][");
|
||||||
|
var sut = new RecentTargetsStore(FilePath);
|
||||||
|
|
||||||
|
var result = sut.Load();
|
||||||
|
|
||||||
|
Assert.Empty(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Touch_caps_list_at_ten_entries()
|
||||||
|
{
|
||||||
|
var sut = new RecentTargetsStore(FilePath);
|
||||||
|
|
||||||
|
for (var i = 0; i < 12; i++)
|
||||||
|
{
|
||||||
|
sut.Touch($"App{i}", $"/apps/app{i}/appsettings.json");
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = sut.Load();
|
||||||
|
|
||||||
|
Assert.Equal(10, result.Count);
|
||||||
|
Assert.Equal("App11", result[0].Name);
|
||||||
|
Assert.Equal("App2", result[9].Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Saved_json_contains_no_key_material_fields()
|
||||||
|
{
|
||||||
|
var sut = new RecentTargetsStore(FilePath);
|
||||||
|
|
||||||
|
sut.Touch("OtOpcUa", "/apps/otopcua/appsettings.json");
|
||||||
|
var json = File.ReadAllText(FilePath);
|
||||||
|
|
||||||
|
Assert.Contains("name", json, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("appSettingsPath", json, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("lastUsedUtc", json, StringComparison.Ordinal);
|
||||||
|
Assert.DoesNotContain("key", json, StringComparison.OrdinalIgnoreCase);
|
||||||
|
Assert.DoesNotContain("kek", json, StringComparison.OrdinalIgnoreCase);
|
||||||
|
Assert.DoesNotContain("secret", json, StringComparison.OrdinalIgnoreCase);
|
||||||
|
Assert.DoesNotContain("password", json, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user