using DodoSSH.Client.Domain; using DodoSSH.Client.Storage; using DodoSSH.Crypto; namespace DodoSSH.Client.Session.Tests; /// /// What a vault stops keeping, and when. /// /// /// Retention is not housekeeping here the way it is for a local log file: these entries sync, so every one /// kept costs every machine in the vault. That is the price of the decision that made them auditable, and /// this is what bounds it. /// public sealed class LogRetentionTests : IAsyncLifetime { private const string Passphrase = "correct horse battery staple"; private const string ServerUrl = "https://dodossh.example"; private static readonly Argon2Profile CheapProfile = Argon2Profile.FromStoredParameters(memoryKibibytes: 8 * 1024, passes: 1, parallelism: 1); private static readonly DateTimeOffset Now = new(2026, 7, 31, 12, 0, 0, TimeSpan.Zero); private readonly FakeAccountServer server = new(); private readonly StubKeyBinding keyBinding = new(); private ClientCacheFactory caches = null!; private static CancellationToken Token => TestContext.Current.CancellationToken; /// public async ValueTask InitializeAsync() { caches = ClientCacheFactory.ForMemory($"retention-{Guid.CreateVersion7():N}"); await caches.MigrateAsync(Token); await new AccountProvisioner(server, keyBinding, caches, TimeProvider.System, CheapProfile) .EnrollAsync(ServerUrl, Passphrase, "laptop", "Personal", Token); } /// public ValueTask DisposeAsync() { caches.Dispose(); return ValueTask.CompletedTask; } /// /// Age is read from the entry rather than from the item id. The two are close and not the same: the id /// records when the entry was written, which for a connection is when it ended — so a shell /// left open across the retention boundary would be pruned by the wrong clock. /// [Fact] public async Task EntriesOlderThanTheAgeLimit_Go() { await using var session = await UnlockAsync(); await WriteConnectionAsync(session, Now.AddDays(-91)); await WriteConnectionAsync(session, Now.AddDays(-89)); await WriteConnectionAsync(session, Now.AddHours(-1)); var result = await LogPruner.PruneAsync(session, LogRetention.Default, Now, Token); result.Connections.ShouldBe(1); var kept = await session.ConnectionLog.ListAsync(session.ActiveVaultId, Token); kept.Items.Count.ShouldBe(2); kept.Items.ShouldAllBe(item => item.Secret.StartedAt > Now.AddDays(-90)); } /// /// The other limit, and it is the one that binds for somebody who connects all day. Whichever bites /// first wins: an age alone lets a busy vault grow without bound, and a count alone loses a quiet /// month's history to one busy afternoon. /// [Fact] public async Task EntriesPastTheCountLimit_GoOldestFirst() { await using var session = await UnlockAsync(); for (var i = 0; i < 5; i++) { await WriteConnectionAsync(session, Now.AddMinutes(-i), $"host-{i}"); } var result = await LogPruner.PruneAsync( session, new LogRetention(TimeSpan.FromDays(90), MaxEntries: 3), Now, Token); result.Connections.ShouldBe(2); var kept = await session.ConnectionLog.ListAsync(session.ActiveVaultId, Token); // The newest three survive: entry i was written i minutes ago, so 0, 1 and 2 are the recent ones. kept.Items.Select(item => item.Secret.HostLabel).Order(StringComparer.Ordinal) .ShouldBe(["host-0", "host-1", "host-2"]); } [Fact] public async Task PruningTouchesBothLogsAndSaysWhatItRemoved() { await using var session = await UnlockAsync(); await WriteConnectionAsync(session, Now.AddDays(-100)); await session.ActivityLog.CreateAsync( session.ActiveVaultId, new ActivityLogSecret { ItemKind = "Host", ItemId = Guid.CreateVersion7(), ItemLabel = "prod-db", Operation = ActivityOperation.Created, At = Now.AddDays(-100), DeviceName = "laptop", }, Token); var result = await LogPruner.PruneAsync(session, LogRetention.Default, Now, Token); result.Connections.ShouldBe(1); result.Activity.ShouldBe(1); result.RemovedAnything.ShouldBeTrue(); } [Fact] public async Task AVaultInsideItsLimits_LosesNothing() { await using var session = await UnlockAsync(); await WriteConnectionAsync(session, Now.AddDays(-1)); var result = await LogPruner.PruneAsync(session, LogRetention.Default, Now, Token); result.RemovedAnything.ShouldBeFalse(); (await session.ConnectionLog.ListAsync(session.ActiveVaultId, Token)).Items.ShouldHaveSingleItem(); } private static Task WriteConnectionAsync( VaultSession session, DateTimeOffset startedAt, string label = "prod-db") => session.ConnectionLog.CreateAsync( session.ActiveVaultId, new ConnectionLogSecret { HostLabel = label, Address = "deploy@db.internal:22", StartedAt = startedAt, Duration = TimeSpan.FromMinutes(4), Outcome = ConnectionOutcome.Closed, DeviceName = "laptop", }, Token); private async Task UnlockAsync() { var outcome = await new SessionOpener(caches, TimeProvider.System).UnlockAsync(Passphrase, Token); outcome.IsUnlocked.ShouldBeTrue(outcome.Message); return outcome.Session!; } }