Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions src/AppLens.Backend/AuditSnapshotStore.cs
Original file line number Diff line number Diff line change
@@ -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<AuditSnapshot?> 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<AuditSnapshot?> 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<AuditSnapshot>(json, SerializerOptions);
}
catch (JsonException exception)
{
throw new InvalidOperationException("Latest audit snapshot could not be deserialized.", exception);
}
}
}

7 changes: 7 additions & 0 deletions src/AppLens.Backend/RuntimeStorage.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand All @@ -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);
Expand Down
54 changes: 54 additions & 0 deletions tests/AppLens.Backend.Tests/AuditSnapshotStoreTests.cs
Original file line number Diff line number Diff line change
@@ -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);
}
}

2 changes: 2 additions & 0 deletions tests/AppLens.Backend.Tests/RuntimeStorageTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
Loading