diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/RecentTargetsStore.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/RecentTargetsStore.cs new file mode 100644 index 0000000..2df8cd1 --- /dev/null +++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/RecentTargetsStore.cs @@ -0,0 +1,92 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace ZB.MOM.WW.Secrets.Cli.Interactive; + +/// +/// 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. +/// +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; + + /// Creates the store bound to a JSON file path, using an optional injected clock. + /// The JSON file to load from and save to. + /// + /// Clock used to stamp . Defaults to + /// when omitted. + /// + public RecentTargetsStore(string filePath, TimeProvider? timeProvider = null) + { + _filePath = filePath; + _timeProvider = timeProvider ?? TimeProvider.System; + } + + /// One remembered target: a display name, its appsettings.json path, and when it was last used. + /// Display name for the target (e.g. the app name). + /// Filesystem path to the target's appsettings.json. + /// Timestamp of the most recent selection of this target. + public sealed record RecentTarget(string Name, string AppSettingsPath, DateTimeOffset LastUsedUtc); + + /// Loads the recents list, newest first. A missing or unreadable/corrupt file yields an empty list. + public IReadOnlyList Load() + { + if (!File.Exists(_filePath)) + { + return []; + } + + try + { + var json = File.ReadAllText(_filePath); + var entries = JsonSerializer.Deserialize>(json, SerializerOptions); + return entries ?? []; + } + catch (JsonException) + { + return []; + } + catch (IOException) + { + return []; + } + } + + /// Upserts a target by path, moves it to the front, stamps now, caps the list at 10, and saves. + /// Display name for the target. + /// Filesystem path to the target's appsettings.json — the dedup key. + 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 { touched }; + updated.AddRange(remaining.Take(MaxEntries - 1)); + + Save(updated); + } + + private void Save(IReadOnlyList 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); + } +} diff --git a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Cli/Interactive/RecentTargetsStoreTests.cs b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Cli/Interactive/RecentTargetsStoreTests.cs new file mode 100644 index 0000000..8da0722 --- /dev/null +++ b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Cli/Interactive/RecentTargetsStoreTests.cs @@ -0,0 +1,127 @@ +using ZB.MOM.WW.Secrets.Cli.Interactive; + +namespace ZB.MOM.WW.Secrets.Tests.Cli.Interactive; + +/// +/// Exercises 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. +/// +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 */ } + } + } + + /// A minimal, manually-advanceable fake for deterministic ordering assertions. + 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); + } +}