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