using DodoSSH.Client.Domain; using DodoSSH.Client.Storage; using DodoSSH.Client.Sync; using DodoSSH.Crypto; namespace DodoSSH.Client.Session.Tests; /// /// What the activity log records when somebody changes something in the keychain. /// /// /// /// Written against a real unlocked vault rather than a stand-in, because the property under test is where /// the hook sits: it is in VaultItemRepository, the one generic funnel every kind's writes go /// through, and a test that called the sink directly would prove nothing about that. /// /// /// The recorder writes on a background task, so every assertion here waits for the entry rather than reading /// immediately. That is the design and not a testing inconvenience: a save the user is waiting on must not /// also wait for a log entry to be encrypted. /// /// public sealed class ActivityLogTests : 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 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($"activity-{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; } [Fact] public async Task CreatingAnItem_IsRecordedWithItsNameAndAnActor() { await using var session = await UnlockAsync(); var entityId = await session.Hosts.CreateAsync(session.ActiveVaultId, Host("prod-db"), Token); var entry = (await WaitForAsync(session, 1)).ShouldHaveSingleItem().Secret; entry.Operation.ShouldBe(ActivityOperation.Created); entry.ItemKind.ShouldBe("Host"); entry.ItemId.ShouldBe(entityId); entry.ItemLabel.ShouldBe("prod-db"); entry.ChangedFields.ShouldBeEmpty("a create changed everything, which is the same as nothing"); // An audit record with no actor is not an audit record. Both halves matter once the vault is shared: // which account, and which of that account's machines. entry.ActorUserId.ShouldNotBe(Guid.Empty); entry.DeviceName.ShouldNotBeNullOrWhiteSpace(); } /// /// The rule the whole payload is built around. "Username" is what somebody reviewing a keychain needs to /// see; the account name it was changed to belongs in the item, not in a log that syncs to everybody. /// [Fact] public async Task EditingAnItem_RecordsWhichFieldsChangedAndNeverTheirValues() { await using var session = await UnlockAsync(); var entityId = await session.Hosts.CreateAsync(session.ActiveVaultId, Host("prod-db"), Token); await session.Hosts.UpdateAsync( session.ActiveVaultId, entityId, Host("prod-db") with { Port = 2222, Username = "root" }, Token); var entries = await WaitForAsync(session, 2); var edit = entries.Select(item => item.Secret).Single(e => e.Operation is ActivityOperation.Updated); edit.ChangedFields.ShouldBe("Port, Username"); edit.ChangedFields.ShouldNotContain("2222", Case.Sensitive); edit.ChangedFields.ShouldNotContain("root", Case.Sensitive); } /// /// Two edits are two lines, where the outbox coalesces them into one pending row. The outbox describes /// what still has to be sent; this describes what somebody did, and those are different questions. /// [Fact] public async Task TwoEditsOfOneItem_AreTwoLines() { await using var session = await UnlockAsync(); var entityId = await session.Hosts.CreateAsync(session.ActiveVaultId, Host("prod-db"), Token); await session.Hosts.UpdateAsync( session.ActiveVaultId, entityId, Host("prod-db") with { Port = 2222 }, Token); await session.Hosts.UpdateAsync( session.ActiveVaultId, entityId, Host("renamed") with { Port = 2222 }, Token); var entries = await WaitForAsync(session, 3); entries.Count(item => item.Secret.Operation is ActivityOperation.Updated).ShouldBe(2); // And the second is measured against the first rather than against what the server last accepted, // which is why it reports the rename alone and not the port again. var latest = entries .Select(item => item.Secret) .Where(entry => entry.Operation is ActivityOperation.Updated) .OrderBy(entry => entry.At) .Last(); latest.ChangedFields.ShouldBe("Name"); } [Fact] public async Task DeletingAnItem_IsRecordedWithTheNameItHadWhenItWent() { await using var session = await UnlockAsync(); var entityId = await session.Hosts.CreateAsync(session.ActiveVaultId, Host("prod-db"), Token); await session.Hosts.DeleteAsync(session.ActiveVaultId, entityId, Token); var entries = await WaitForAsync(session, 2); var deletion = entries.Select(e => e.Secret).Single(e => e.Operation is ActivityOperation.Deleted); // Read before the delete destroyed it. A lookup afterwards resolves to nothing, which is exactly the // case where the name matters most. deletion.ItemLabel.ShouldBe("prod-db"); deletion.ItemId.ShouldBe(entityId); } /// /// /// The guard that keeps this feature from being a runaway. The hook lives in the one repository /// every kind writes through, so without IItemKind.IsAudited a log entry would produce a log /// entry, without end — and it would do so on a background task, quietly, filling a vault. /// /// /// Asserted by writing entries directly and then waiting long enough for a recursive write to have /// happened: the count has to stay where it was put. /// /// [Fact] public async Task WritingALogEntry_DoesNotProduceALogEntryAboutIt() { await using var session = await UnlockAsync(); await session.ConnectionLog.CreateAsync(session.ActiveVaultId, Connection(), Token); await session.ConnectionLog.CreateAsync(session.ActiveVaultId, Connection(), Token); // Long enough for a recursive write to have queued, been drained and stored several times over. await Task.Delay(TimeSpan.FromMilliseconds(300), Token); (await session.ActivityLog.ListAsync(session.ActiveVaultId, Token)).Items .ShouldBeEmpty("a log that logs itself never stops"); (await session.ConnectionLog.ListAsync(session.ActiveVaultId, Token)).Items.Count.ShouldBe(2); } /// /// A pin is written by the connect path rather than by a screen, which is precisely the write a hook /// placed in the view models would have missed — and trusting a host key is one of the more interesting /// things an audit trail can show. /// [Fact] public async Task APinWrittenByTheConnectPath_IsRecordedToo() { await using var session = await UnlockAsync(); var store = new VaultKnownHostStore(); await store.OpenAsync(session, Token); await store.TrustAsync( new Ssh.HostKeyPresentation("db.internal", 22, "ssh-ed25519", "SHA256:the-key"), Token); var entry = (await WaitForAsync(session, 1)).ShouldHaveSingleItem().Secret; entry.ItemKind.ShouldBe("KnownHostKey"); entry.Operation.ShouldBe(ActivityOperation.Created); store.Close(); } /// Waits for the background recorder to have written at least this many entries. /// /// Polled rather than awaited on a signal, because the recorder deliberately exposes none: its whole /// contract is that the caller does not wait for it. A timeout rather than a loop, so a hook that stopped /// firing fails as a test rather than as a hang. /// private static async Task>> WaitForAsync( VaultSession session, int count) { var deadline = TimeProvider.System.GetUtcNow().AddSeconds(10); while (true) { var listing = await session.ActivityLog.ListAsync(session.ActiveVaultId, Token); if (listing.Items.Count >= count) { return listing.Items; } if (TimeProvider.System.GetUtcNow() > deadline) { listing.Items.Count.ShouldBe(count, "the activity hook stopped firing"); return listing.Items; } await Task.Delay(TimeSpan.FromMilliseconds(20), Token); } } private async Task UnlockAsync() { var outcome = await new SessionOpener(caches, TimeProvider.System).UnlockAsync(Passphrase, Token); outcome.IsUnlocked.ShouldBeTrue(outcome.Message); return outcome.Session!; } private static HostSecret Host(string label) => new() { Label = label, Hostname = "db.internal", Port = 22, Username = "deploy", }; private static ConnectionLogSecret Connection() => new() { HostLabel = "prod-db", Address = "deploy@db.internal:22", StartedAt = DateTimeOffset.UnixEpoch, Duration = TimeSpan.FromMinutes(3), Outcome = ConnectionOutcome.Closed, DeviceName = "laptop", }; }