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); } }