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",
};
}