Files
DodoSSH/tests/DodoSSH.Client.Storage.Tests/CacheStoreTests.cs
T
jaap-jan 7b7fd7b2ef Make a vault the thing you create, and let a window set one aside
Everything a shared vault needs was already here and arranged the wrong way
round. A vault has to belong to a team, so creating one meant going to the teams
screen, founding an organisation, and only then adding a vault to it — which the
NEW VAULT button named after the team, so a team with three of them held three
vaults called the same thing and nothing told them apart. Somebody who wants to
share four servers with two colleagues is not asking to found anything.

So the form asks for a name and nothing else. The team is derived from it, slug
included, and created with this account as its owner; the vault goes inside; and
the members, roles, invitations and key holders that hang off a team are all on
screen the moment it exists. The tab strip's New vault entry lands there with the
new vault selected, which is where the next thing anybody wants to do already is.

That is two calls, and the first can succeed alone. When it does the team is
kept: the id is minted once into pendingVaultTeamId, so pressing CREATE again
resends the identical create — which the server treats as the same team — and
retries the vault, and the message says all of that rather than "creating the
vault failed". Archiving the orphan instead would be a client deleting something
on the user's behalf because a later step failed, which is the kind of tidying
that eventually archives a team somebody has just been added to. A slug taken by
somebody else is retried once with a disambiguated one and never in a loop; a
name with no a-z or 0-9 anywhere in it falls back to the team's own id rather
than to a refusal pointing at a field nobody was shown.

The other half is the caret beside Vaults. Being in four teams means four teams'
machines in front of you all day, and the answer is a switch per vault rather
than four sign-ins. Switching one off takes its hosts, groups, keys and pins off
the screens that list them and does nothing else: it still syncs, its key stays
in the keyring, it stays choosable as somewhere to file a new item, and a shown
host that authenticates with a key filed in it still connects. That last one is
what shaped the design. TryBuildAuthentication resolves a binding out of the
keychain's typed list and a cross-vault binding is legal, so filtering the reload
loops — the obvious implementation — would have turned a preference about reading
into an outage. Only the projections a person reads consult IsVaultShown; every
Reload*Async stays whole, including the dialled-endpoint set that decides which
pins are described as unused, because that is a hint which invites deleting
trust.

Snippets, logs and buckets needed no code and the comment says so out loud: all
three read ActiveVaultId alone, and the personal vault is drawn in the menu
ticked and cannot be switched off — it is the active vault, the group and tag
editors' target, and the save picker's fallback, so hiding it would empty half
the application rather than filter it.

The preference is a column on the cache's vault row, which is what makes it
survive both a relaunch and the /me refresh that runs every minute: Apply does
not touch it, deliberately, because the server has never been told which vaults
this machine is showing. It is in the encrypted cache rather than settings.json
because it is a list of vault ids and that file's own doc comment says what may
go in it. VaultSession cannot see the type at all — ReadableVaults is what the
sync loop walks, and a filter reaching it would be a vault that quietly stopped
syncing, found out weeks later from a host that was never there.

The strip's note refusing a MenuFlyout stands and is unchanged. This flyout
sidesteps the question rather than answering it: the handler selects the Vaults
tab first, which collapses the renderer, so nothing native is under the popup by
the time it opens — the move QuickConnect already makes. A headless test asserts
that ordering, which is as far as headless can go with no native window, and
manual check 1.6 is the other half.

The phone is out of scope on purpose: it has no tab strip and its teams screen's
vault section is read-only. The plumbing is in Client.Shell, so it can adopt this
later; until then nothing there is ever hidden, which is today's behaviour.

1514 tests pass. Fifteen are new in VaultVisibilityTests, and the ones worth
naming are the guards: a hidden vault still syncs, still holds keys that
authenticate hosts on screen, still appears in the save picker, and still counts
towards which pins nothing dials.

Not fixed, and noted here because it is next door: VaultGrantService's team-vault
create refuses a taken vault id rather than returning the existing vault, while
VaultSharing's own remark claims a create whose response was lost is safe to
resend. A lost 200 therefore leaves a vault whose key the client's catch already
zeroed, openable by nobody.
2026-08-03 21:52:27 +02:00

461 lines
18 KiB
C#

using DodoSSH.Contracts;
using DodoSSH.Crypto;
using Microsoft.EntityFrameworkCore;
using static DodoSSH.Client.Storage.Tests.CacheHarness;
namespace DodoSSH.Client.Storage.Tests;
/// <summary>
/// The item mirror, the vault list, the sync cursor, and the conflict log.
/// </summary>
public sealed class CacheStoreTests : IAsyncLifetime
{
private CacheHarness harness = null!;
/// <inheritdoc />
public async ValueTask InitializeAsync() => harness = await CreateAsync();
/// <inheritdoc />
public ValueTask DisposeAsync()
{
harness.Dispose();
return ValueTask.CompletedTask;
}
// ---- Items ----
[Fact]
public async Task AnItem_RoundTripsItsCiphertextByteForByte()
{
// Not "equivalent" — identical. The AAD binds the row, so re-encrypting locally would work but
// would throw away the ability to notice the server handing back bytes it should not have.
var entityId = Guid.CreateVersion7();
var item = Item(entityId, version: 4, changeSequence: 17, seed: 3);
await harness.Items.SaveAsync(item, Token);
var read = await harness.Items.FindAsync(VaultId, SyncEntityType.Host, entityId, Token);
read.ShouldNotBeNull();
read.Version.ShouldBe(4);
read.ChangeSequence.ShouldBe(17);
read.Payload.ShouldNotBeNull();
read.Payload.Envelope.ShouldBe(item.Payload!.Envelope);
read.Payload.WrappedDataKey.ShouldBe(item.Payload.WrappedDataKey);
read.Payload.DataKeyId.ShouldBe(item.Payload.DataKeyId);
read.Payload.KeyGeneration.ShouldBe(item.Payload.KeyGeneration);
read.Payload.AadVersion.ShouldBe(item.Payload.AadVersion);
}
[Fact]
public async Task ThePlaintextFields_RoundTripAndAreNotReadableInTheDatabase()
{
// The server has to hold a relay-enabled host's address in the clear because it resolves it.
// This machine already holds the key that opens the payload, so leaving the address readable in
// a file that ends up in a backup buys nothing.
var entityId = Guid.CreateVersion7();
var fields = new SyncPlaintextFields(
RelayEnabled: true, Hostname: "bastion.internal", Port: 2222);
await harness.Items.SaveAsync(Item(entityId, fields: fields), Token);
var read = await harness.Items.FindAsync(VaultId, SyncEntityType.Host, entityId, Token);
read!.Fields.ShouldBe(fields);
var stored = await ReadRawFieldsAsync(entityId);
stored.ShouldNotBeNull();
System.Text.Encoding.UTF8.GetString(stored)
.Contains("bastion.internal", StringComparison.Ordinal)
.ShouldBeFalse("the hostname is stored in the clear");
}
[Fact]
public async Task ACacheRecord_CannotBeMovedToAnotherRow()
{
// Why the record is bound to its own row rather than only to the user. Swapping two rows would
// otherwise point one host's connection at another host's address.
var mine = Guid.CreateVersion7();
var other = Guid.CreateVersion7();
var sealedFields = harness.Protector.Protect(
CryptoSpec.AadResourceType.Host,
mine,
PlaintextFieldsCodec.Encode(new SyncPlaintextFields(true, "mine.internal", 22)));
harness.Protector
.TryUnprotect(CryptoSpec.AadResourceType.Host, other, sealedFields)
.ShouldBeNull();
harness.Protector
.TryUnprotect(CryptoSpec.AadResourceType.Credential, mine, sealedFields)
.ShouldBeNull();
harness.Protector
.TryUnprotect(CryptoSpec.AadResourceType.Host, mine, sealedFields)
.ShouldNotBeNull();
}
[Fact]
public async Task ARecordSealedByAnotherIdentity_DoesNotOpen()
{
// Another *identity*, not another passphrase, and the distinction is the point. The cache key now
// derives from the secret bundle, so changing a passphrase deliberately keeps the cache readable —
// UserSecretBundleTests.TheLocalCacheKey_SurvivesAPassphraseChange pins that. What must still be
// unreadable is another user's cache, and that is what a second harness is.
var entityId = Guid.CreateVersion7();
using var stranger = await CreateAsync();
var sealedFields = stranger.Protector.Protect(
CryptoSpec.AadResourceType.Host, entityId, [1, 2, 3]);
harness.Protector
.TryUnprotect(CryptoSpec.AadResourceType.Host, entityId, sealedFields)
.ShouldBeNull();
}
[Fact]
public async Task SavingTwice_ReplacesRatherThanDuplicates()
{
var entityId = Guid.CreateVersion7();
await harness.Items.SaveAsync(Item(entityId, version: 1, seed: 1), Token);
await harness.Items.SaveAsync(Item(entityId, version: 2, seed: 9), Token);
var items = await harness.Items.ListAsync(VaultId, SyncEntityType.Host, false, Token);
items.ShouldHaveSingleItem().Version.ShouldBe(2);
}
[Fact]
public async Task ATombstone_IsHiddenFromTheListButStillFindable()
{
// The interface must not show a deleted host. The sync engine must still be able to tell a
// deleted item from one it has never seen — a row that simply vanished is indistinguishable
// from the latter, and would silently reappear.
var entityId = Guid.CreateVersion7();
await harness.Items.SaveAsync(Item(entityId, version: 1), Token);
await harness.Items.SaveAsync(Item(entityId, version: 2, deleted: true), Token);
(await harness.Items.ListAsync(VaultId, SyncEntityType.Host, false, Token)).ShouldBeEmpty();
var withDeleted = await harness.Items.ListAsync(VaultId, SyncEntityType.Host, true, Token);
withDeleted.ShouldHaveSingleItem().IsDeleted.ShouldBeTrue();
var found = await harness.Items.FindAsync(VaultId, SyncEntityType.Host, entityId, Token);
found.ShouldNotBeNull();
found.IsDeleted.ShouldBeTrue();
found.Payload.ShouldBeNull();
}
[Fact]
public async Task CollectingTombstones_LeavesLiveItemsAlone()
{
var live = Guid.CreateVersion7();
var dead = Guid.CreateVersion7();
await harness.Items.SaveAsync(Item(live, changeSequence: 1), Token);
await harness.Items.SaveAsync(Item(dead, changeSequence: 2, deleted: true), Token);
var cutoff = DateTimeOffset.FromUnixTimeSeconds(1_750_000_100);
var collected = await harness.Items.CollectTombstonesAsync(VaultId, cutoff, Token);
collected.ShouldBe(1);
var remaining = await harness.Items.ListAsync(VaultId, SyncEntityType.Host, true, Token);
remaining.ShouldHaveSingleItem().EntityId.ShouldBe(live);
}
// ---- Vaults ----
[Fact]
public async Task ReplacingTheVaultList_AddsUpdatesAndRemoves()
{
var keep = Guid.CreateVersion7();
var drop = Guid.CreateVersion7();
await harness.Vaults.ReplaceAllAsync(
[Vault(keep, "Personal", 1), Vault(drop, "Old team", 1)], Token);
await harness.Vaults.ReplaceAllAsync([Vault(keep, "Renamed", 2)], Token);
var vaults = await harness.Vaults.ListAsync(Token);
var only = vaults.ShouldHaveSingleItem();
only.VaultId.ShouldBe(keep);
only.Name.ShouldBe("Renamed");
only.KeyGeneration.ShouldBe(2u);
(await harness.Vaults.FindAsync(drop, Token)).ShouldBeNull();
}
[Fact]
public async Task AVaultsWrappedKey_IsCachedSoAnOfflineLaunchCanDecrypt()
{
// Without this, an offline start could unlock the identity bundle and still not open a single
// item.
var vaultId = Guid.CreateVersion7();
byte[] wrapped = [9, 9, 9, 9];
await harness.Vaults.ReplaceAllAsync(
[Vault(vaultId, "Personal", 1) with { WrappedVaultKey = wrapped }], Token);
var read = await harness.Vaults.FindAsync(vaultId, Token);
read!.WrappedVaultKey.ShouldBe(wrapped);
}
[Fact]
public async Task HidingAVault_SurvivesTheNextVaultListFromTheServer()
{
// The whole reason the flag is a column here rather than a field on StoredVault. /me is fetched
// once a minute; a refresh that carried this preference along would un-hide every vault within
// the minute, and the user would never work out what kept switching them back on.
var vaultId = Guid.CreateVersion7();
await harness.Vaults.ReplaceAllAsync([Vault(vaultId, "Ops", 1)], Token);
await harness.Vaults.SetHiddenAsync(vaultId, hidden: true, Token);
await harness.Vaults.ReplaceAllAsync([Vault(vaultId, "Ops renamed", 2)], Token);
(await harness.Vaults.ListHiddenAsync(Token)).ShouldBe([vaultId]);
}
[Fact]
public async Task TheServersVaultList_NeverHidesAVaultByItself()
{
// The other direction, and worth its own test: the server knows nothing about which vaults this
// machine is showing, so no answer it gives may switch one off.
var vaultId = Guid.CreateVersion7();
await harness.Vaults.ReplaceAllAsync([Vault(vaultId, "Ops", 1)], Token);
(await harness.Vaults.ListHiddenAsync(Token)).ShouldBeEmpty();
}
[Fact]
public async Task HidingAVaultThatIsNoLongerCached_DoesNothing()
{
// A grant withdrawn between the click and the write. There is nothing left to record a reading
// preference about, and the vault is already off every list the preference would have applied to.
await Should.NotThrowAsync(
() => harness.Vaults.SetHiddenAsync(Guid.CreateVersion7(), hidden: true, Token));
(await harness.Vaults.ListHiddenAsync(Token)).ShouldBeEmpty();
}
[Fact]
public async Task AVaultThatLosesItsGrantAndGetsItBack_ComesBackShowing()
{
// Absence from the server's list means access was lost, and the row goes with it. Being re-granted
// is a new vault as far as this machine is concerned, and a new vault is shown.
var vaultId = Guid.CreateVersion7();
await harness.Vaults.ReplaceAllAsync([Vault(vaultId, "Ops", 1)], Token);
await harness.Vaults.SetHiddenAsync(vaultId, hidden: true, Token);
await harness.Vaults.ReplaceAllAsync([], Token);
await harness.Vaults.ReplaceAllAsync([Vault(vaultId, "Ops", 1)], Token);
(await harness.Vaults.ListHiddenAsync(Token)).ShouldBeEmpty();
}
// ---- Unlock material ----
[Fact]
public async Task TheUnlockMaterial_SurvivesTheContextThatWroteIt()
{
// The offline unlock story, asserted rather than assumed: a fresh store over the same database
// reads back the salt and the wrapped bundle with no network involved.
var material = Material();
await harness.Unlock.SaveAsync(material, Token);
var reader = new UnlockStore(harness.Factory, TimeProvider.System);
var read = await reader.ReadAsync(Token);
read.ShouldNotBeNull();
read.UserId.ShouldBe(material.UserId);
read.WrappedPrivateKey.ShouldBe(material.WrappedPrivateKey);
read.KdfParameters.Salt.ShouldBe(material.KdfParameters.Salt);
read.KdfParameters.MemoryKibibytes.ShouldBe(material.KdfParameters.MemoryKibibytes);
read.KdfParameters.Passes.ShouldBe(material.KdfParameters.Passes);
}
[Fact]
public async Task SavingTheUnlockMaterialTwice_UpdatesTheSingleRow()
{
await harness.Unlock.SaveAsync(Material(), Token);
await harness.Unlock.SaveAsync(Material() with { Email = "changed@example.com" }, Token);
var read = await harness.Unlock.ReadAsync(Token);
read!.Email.ShouldBe("changed@example.com");
(await CountUnlockRowsAsync()).ShouldBe(1);
}
[Fact]
public async Task AnotherUsersMaterial_IsRefusedRatherThanMixedIn()
{
// Adopting it would offer an unlock prompt whose passphrase can never work, and would mix one
// user's items into another's vault list.
await harness.Unlock.SaveAsync(Material(), Token);
var other = Material() with { UserId = Guid.CreateVersion7() };
await Should.ThrowAsync<CacheIdentityMismatchException>(
async () => await harness.Unlock.SaveAsync(other, Token));
}
// ---- Sync state ----
[Fact]
public async Task AnUnknownVault_ReadsAsStartingFromTheBeginning()
{
// Not an error. A null cursor is exactly right for a vault this client has not synced, and is
// also the recovery path for a cache that had to be discarded.
var state = await harness.SyncState.ReadAsync(Guid.CreateVersion7(), Token);
state.Cursor.ShouldBeNull();
state.KeyGeneration.ShouldBe(0u);
}
[Fact]
public async Task TheCursor_IsStoredVerbatim()
{
// Opaque and integrity-tagged. A client that adjusted one could ask to resume from a position
// the server never granted; storing it untouched is the only correct handling.
const string Cursor = "v1.aGVsbG8gd29ybGQ.c2lnbmF0dXJl";
await harness.SyncState.SaveAsync(new StoredSyncState(VaultId, Cursor, 3), Token);
var read = await harness.SyncState.ReadAsync(VaultId, Token);
read.Cursor.ShouldBe(Cursor);
read.KeyGeneration.ShouldBe(3u);
}
[Fact]
public async Task ResettingAVault_DropsItsItemsButKeepsTheOutbox()
{
// Local unpushed changes are the only copy of the user's work. Clearing them along with the
// cache would turn a recoverable cache problem into lost work.
var entityId = Guid.CreateVersion7();
await harness.Items.SaveAsync(Item(entityId), Token);
await harness.Outbox.QueueAsync(Change(Guid.CreateVersion7()), Token);
await harness.SyncState.SaveAsync(new StoredSyncState(VaultId, "cursor", 1), Token);
await harness.SyncState.ResetAsync(VaultId, Token);
(await harness.Items.ListAsync(VaultId, SyncEntityType.Host, true, Token)).ShouldBeEmpty();
(await harness.SyncState.ReadAsync(VaultId, Token)).Cursor.ShouldBeNull();
(await harness.Outbox.TakeAsync(VaultId, 10, Token)).ShouldHaveSingleItem();
}
// ---- Conflicts ----
[Fact]
public async Task AConflictDetail_RoundTripsAndIsSealedAtRest()
{
// This is the one place the cache holds decrypted vault content on purpose: the value a merge
// displaced. It has to be readable to be useful, and it is as sensitive as the item it came
// from.
var entityId = Guid.CreateVersion7();
var detail = System.Text.Encoding.UTF8.GetBytes("""{"field":"Notes","discarded":"my secret"}""");
var id = await harness.Conflicts.RecordAsync(
VaultId, SyncEntityType.Host, entityId, ConflictKind.FieldOverridden, detail, Token);
var listed = (await harness.Conflicts.ListAsync(VaultId, false, Token)).ShouldHaveSingleItem();
listed.Id.ShouldBe(id);
listed.Kind.ShouldBe(ConflictKind.FieldOverridden);
listed.Detail.ShouldBe(detail);
var raw = await ReadRawConflictDetailAsync(id);
System.Text.Encoding.UTF8.GetString(raw!)
.Contains("my secret", StringComparison.Ordinal)
.ShouldBeFalse("the discarded value is stored in the clear");
}
[Fact]
public async Task AnAcknowledgedConflict_LeavesTheListButKeepsTheValue()
{
// Someone who dismisses a warning and realises a minute later that they wanted the other value
// should still be able to get it.
var id = await harness.Conflicts.RecordAsync(
VaultId, SyncEntityType.Host, Guid.CreateVersion7(), ConflictKind.FieldOverridden,
new byte[] { 1, 2, 3 }, Token);
(await harness.Conflicts.AcknowledgeAsync(id, Token)).ShouldBeTrue();
(await harness.Conflicts.ListAsync(VaultId, false, Token)).ShouldBeEmpty();
var all = (await harness.Conflicts.ListAsync(VaultId, true, Token)).ShouldHaveSingleItem();
all.Detail.ShouldBe(new byte[] { 1, 2, 3 });
}
[Fact]
public async Task AnUnacknowledgedConflict_CannotBeDiscarded()
{
var id = await harness.Conflicts.RecordAsync(
VaultId, SyncEntityType.Host, Guid.CreateVersion7(), ConflictKind.Undecryptable,
new byte[] { 1 }, Token);
(await harness.Conflicts.DiscardAsync(id, Token)).ShouldBeFalse();
await harness.Conflicts.AcknowledgeAsync(id, Token);
(await harness.Conflicts.DiscardAsync(id, Token)).ShouldBeTrue();
}
// ---- Helpers ----
private static CancellationToken Token => TestContext.Current.CancellationToken;
private static StoredVault Vault(Guid vaultId, string name, uint keyGeneration) =>
new(vaultId, name, IsPersonal: true, TeamId: null, keyGeneration, Permissions: 31,
WrappedVaultKey: [1, 2, 3], RekeyRequired: false);
private static StoredUnlockMaterial Material() =>
new(
"https://dodossh.example",
UserId,
"https://idp.example",
"alice",
"alice@example.com",
"Alice",
KeyGeneration: 1,
WrappedPrivateKey: [4, 5, 6, 7],
new KdfParameters("argon2id", [8, 9, 10, 11], 262144, 4, 1),
DateTimeOffset.FromUnixTimeSeconds(1_750_000_000));
private async Task<byte[]?> ReadRawFieldsAsync(Guid entityId)
{
var context = harness.Factory.CreateDbContext();
await using var scope = context.ConfigureAwait(false);
return await context.Set<CachedItemRow>()
.Where(row => row.EntityId == entityId)
.Select(row => row.ProtectedFields)
.SingleAsync(Token);
}
private async Task<byte[]?> ReadRawConflictDetailAsync(Guid id)
{
var context = harness.Factory.CreateDbContext();
await using var scope = context.ConfigureAwait(false);
return await context.Set<ConflictRow>()
.Where(row => row.Id == id)
.Select(row => row.Detail)
.SingleAsync(Token);
}
private async Task<int> CountUnlockRowsAsync()
{
var context = harness.Factory.CreateDbContext();
await using var scope = context.ConfigureAwait(false);
return await context.Set<UnlockMaterialRow>().CountAsync(Token);
}
}