From 051a3ff3832b9a39b1d1ae3716f5017b8e053571 Mon Sep 17 00:00:00 2001 From: Cody Date: Mon, 11 May 2026 19:38:30 -0700 Subject: [PATCH] feat: add audit snapshot store --- src/AppLens.Backend/AuditSnapshotStore.cs | 64 +++++++++++++++++++ src/AppLens.Backend/RuntimeStorage.cs | 7 ++ .../AuditSnapshotStoreTests.cs | 54 ++++++++++++++++ .../RuntimeStorageTests.cs | 2 + 4 files changed, 127 insertions(+) create mode 100644 src/AppLens.Backend/AuditSnapshotStore.cs create mode 100644 tests/AppLens.Backend.Tests/AuditSnapshotStoreTests.cs diff --git a/src/AppLens.Backend/AuditSnapshotStore.cs b/src/AppLens.Backend/AuditSnapshotStore.cs new file mode 100644 index 0000000..805fe3e --- /dev/null +++ b/src/AppLens.Backend/AuditSnapshotStore.cs @@ -0,0 +1,64 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace AppLens.Backend; + +public interface IAuditSnapshotStore +{ + Task SaveAsync(AuditSnapshot snapshot, CancellationToken cancellationToken = default); + + Task LoadLatestAsync(CancellationToken cancellationToken = default); +} + +public sealed class AuditSnapshotStore : IAuditSnapshotStore +{ + private static readonly JsonSerializerOptions SerializerOptions = new() + { + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + Converters = { new JsonStringEnumConverter() } + }; + + private readonly AppLensRuntimeStorage _storage; + + public AuditSnapshotStore(AppLensRuntimeStorage storage) + { + _storage = storage; + } + + public async Task SaveAsync(AuditSnapshot snapshot, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(snapshot); + + Directory.CreateDirectory(_storage.SnapshotsDirectory); + + var json = JsonSerializer.Serialize(snapshot, SerializerOptions); + + var stamp = snapshot.GeneratedAt.ToUniversalTime().ToString("yyyyMMdd-HHmmss"); + var archivePath = Path.Combine(_storage.SnapshotsDirectory, $"audit-{stamp}-{Guid.NewGuid():N}.json"); + await File.WriteAllTextAsync(archivePath, json, cancellationToken).ConfigureAwait(false); + + var tempPath = Path.Combine(_storage.SnapshotsDirectory, $"latest-{Guid.NewGuid():N}.tmp"); + await File.WriteAllTextAsync(tempPath, json, cancellationToken).ConfigureAwait(false); + File.Move(tempPath, _storage.LatestSnapshotJson, overwrite: true); + } + + public async Task LoadLatestAsync(CancellationToken cancellationToken = default) + { + if (!File.Exists(_storage.LatestSnapshotJson)) + { + return null; + } + + var json = await File.ReadAllTextAsync(_storage.LatestSnapshotJson, cancellationToken).ConfigureAwait(false); + try + { + return JsonSerializer.Deserialize(json, SerializerOptions); + } + catch (JsonException exception) + { + throw new InvalidOperationException("Latest audit snapshot could not be deserialized.", exception); + } + } +} + diff --git a/src/AppLens.Backend/RuntimeStorage.cs b/src/AppLens.Backend/RuntimeStorage.cs index 1acb357..6f255cc 100644 --- a/src/AppLens.Backend/RuntimeStorage.cs +++ b/src/AppLens.Backend/RuntimeStorage.cs @@ -8,6 +8,9 @@ private AppLensRuntimeStorage(string root) LedgerDirectory = Path.Combine(Root, "ledger"); EventsJsonl = Path.Combine(LedgerDirectory, "events.jsonl"); IndexSqlite = Path.Combine(LedgerDirectory, "index.sqlite"); + + SnapshotsDirectory = Path.Combine(Root, "snapshots"); + LatestSnapshotJson = Path.Combine(SnapshotsDirectory, "latest.json"); } public string Root { get; } @@ -18,6 +21,10 @@ private AppLensRuntimeStorage(string root) public string IndexSqlite { get; } + public string SnapshotsDirectory { get; } + + public string LatestSnapshotJson { get; } + public static AppLensRuntimeStorage Default() { var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); diff --git a/tests/AppLens.Backend.Tests/AuditSnapshotStoreTests.cs b/tests/AppLens.Backend.Tests/AuditSnapshotStoreTests.cs new file mode 100644 index 0000000..4125ce3 --- /dev/null +++ b/tests/AppLens.Backend.Tests/AuditSnapshotStoreTests.cs @@ -0,0 +1,54 @@ +namespace AppLens.Backend.Tests; + +public sealed class AuditSnapshotStoreTests +{ + [Fact] + public async Task Load_latest_returns_null_when_missing() + { + var root = Path.Combine(Path.GetTempPath(), "AppLens-AuditSnapshotStoreTests", Guid.NewGuid().ToString("N")); + var storage = AppLensRuntimeStorage.FromRoot(root); + var store = new AuditSnapshotStore(storage); + + var latest = await store.LoadLatestAsync(); + + Assert.Null(latest); + } + + [Fact] + public async Task Save_persists_latest_and_archives_snapshot() + { + var root = Path.Combine(Path.GetTempPath(), "AppLens-AuditSnapshotStoreTests", Guid.NewGuid().ToString("N")); + var storage = AppLensRuntimeStorage.FromRoot(root); + var store = new AuditSnapshotStore(storage); + + var snapshot = new AuditSnapshot + { + GeneratedAt = new DateTimeOffset(2026, 05, 11, 14, 50, 26, TimeSpan.Zero), + Machine = new MachineSummary { ComputerName = "TESTBOX", UserName = "tester" }, + Readiness = new ReadinessSummary { Score = 97, Rating = "Ready", Highlights = ["OK"] }, + Inventory = new InventorySummary + { + DesktopApplications = [new AppEntry { Name = "Foo", Version = "1.0", Publisher = "Bar", Source = "msi", UserInstalled = true }] + }, + TunePlan = [new TunePlanItem { Id = "startup-docker", Title = "Disable startup entry", Category = TunePlanCategory.Optional, Risk = TunePlanRisk.Low }] + }; + + await store.SaveAsync(snapshot); + + Assert.True(Directory.Exists(storage.SnapshotsDirectory)); + Assert.True(File.Exists(storage.LatestSnapshotJson)); + + var archives = Directory.GetFiles(storage.SnapshotsDirectory, "audit-*.json", SearchOption.TopDirectoryOnly); + Assert.NotEmpty(archives); + + var loaded = await store.LoadLatestAsync(); + + Assert.NotNull(loaded); + Assert.Equal(snapshot.GeneratedAt, loaded!.GeneratedAt); + Assert.Equal("TESTBOX", loaded.Machine.ComputerName); + Assert.Equal(97, loaded.Readiness.Score); + Assert.Equal("Foo", loaded.Inventory.DesktopApplications.Single().Name); + Assert.Equal("startup-docker", loaded.TunePlan.Single().Id); + } +} + diff --git a/tests/AppLens.Backend.Tests/RuntimeStorageTests.cs b/tests/AppLens.Backend.Tests/RuntimeStorageTests.cs index e9fd0f7..39b8eb5 100644 --- a/tests/AppLens.Backend.Tests/RuntimeStorageTests.cs +++ b/tests/AppLens.Backend.Tests/RuntimeStorageTests.cs @@ -13,5 +13,7 @@ public void Explicit_root_resolves_ledger_paths() Assert.Equal(Path.Combine(root, "ledger"), storage.LedgerDirectory); Assert.Equal(Path.Combine(root, "ledger", "events.jsonl"), storage.EventsJsonl); Assert.Equal(Path.Combine(root, "ledger", "index.sqlite"), storage.IndexSqlite); + Assert.Equal(Path.Combine(root, "snapshots"), storage.SnapshotsDirectory); + Assert.Equal(Path.Combine(root, "snapshots", "latest.json"), storage.LatestSnapshotJson); } }