Public Access
541 lines
22 KiB
C#
541 lines
22 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);
|
|
}
|
|
|
|
/// <remarks>
|
|
/// A pull's page in one write. What it leaves has to be exactly what the same items saved one at a time
|
|
/// would have left: rows that existed replaced, new ones created, and an item given twice ending at the
|
|
/// later of the two — a page can carry several changes to the same item.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task SavingMany_LeavesWhatSavingThemOneAtATimeWould()
|
|
{
|
|
var existing = Guid.CreateVersion7();
|
|
var fresh = Guid.CreateVersion7();
|
|
var twice = Guid.CreateVersion7();
|
|
|
|
await harness.Items.SaveAsync(Item(existing, version: 1, seed: 1), Token);
|
|
|
|
await harness.Items.SaveManyAsync(
|
|
[
|
|
Item(existing, version: 2, changeSequence: 2, seed: 2),
|
|
Item(fresh, version: 1, changeSequence: 3, seed: 3),
|
|
Item(twice, version: 1, changeSequence: 4, seed: 4),
|
|
Item(twice, version: 2, changeSequence: 5, deleted: true),
|
|
],
|
|
Token);
|
|
|
|
(await harness.Items.FindAsync(VaultId, SyncEntityType.Host, existing, Token))
|
|
.ShouldNotBeNull().Version.ShouldBe(2);
|
|
|
|
(await harness.Items.FindAsync(VaultId, SyncEntityType.Host, fresh, Token))
|
|
.ShouldNotBeNull().Version.ShouldBe(1);
|
|
|
|
var last = (await harness.Items.FindAsync(VaultId, SyncEntityType.Host, twice, Token)).ShouldNotBeNull();
|
|
last.Version.ShouldBe(2);
|
|
last.IsDeleted.ShouldBeTrue();
|
|
|
|
(await harness.Items.ListAsync(VaultId, SyncEntityType.Host, true, Token)).Count.ShouldBe(3);
|
|
}
|
|
|
|
[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 });
|
|
}
|
|
|
|
/// <summary>
|
|
/// A conflict can be recorded against any kind of item, not only the kinds that were here first.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// Every other test in this section uses <see cref="SyncEntityType.Host"/>, and that is how three item
|
|
/// kinds shipped with no way to record a conflict at all: the two logs and the buckets were added to both
|
|
/// enums, to the reconciler registry and to the cipher pinning, while <c>AadResourceTypes.For</c> — which
|
|
/// <c>ConflictStore.RecordAsync</c> calls unconditionally — kept throwing for them. The two other callers
|
|
/// of that mapping only reach it when an item carries plaintext fields, which none of the three does, so
|
|
/// nothing else so much as touched the gap.
|
|
/// </para>
|
|
/// <para>
|
|
/// A theory over the types rather than one more <c>Host</c> case, because the failure was never about
|
|
/// conflicts and always about which types the layer below had been taught. Recording is asserted through
|
|
/// a read-back rather than by "it did not throw": an arm returning the wrong resource type would seal
|
|
/// under one AAD and open under another, which is a null detail rather than an exception.
|
|
/// </para>
|
|
/// </remarks>
|
|
[Theory]
|
|
[InlineData(SyncEntityType.Host)]
|
|
[InlineData(SyncEntityType.HostGroup)]
|
|
[InlineData(SyncEntityType.Snippet)]
|
|
[InlineData(SyncEntityType.ConnectionLogEntry)]
|
|
[InlineData(SyncEntityType.ActivityLogEntry)]
|
|
[InlineData(SyncEntityType.ObjectStore)]
|
|
[InlineData(SyncEntityType.SpelunkKey)]
|
|
public async Task AConflict_CanBeRecordedForEveryKindOfItem(SyncEntityType entityType)
|
|
{
|
|
var detail = System.Text.Encoding.UTF8.GetBytes($$"""{"kind":"{{entityType}}"}""");
|
|
|
|
var id = await harness.Conflicts.RecordAsync(
|
|
VaultId, entityType, Guid.CreateVersion7(), ConflictKind.FieldOverridden, detail, Token);
|
|
|
|
var listed = (await harness.Conflicts.ListAsync(VaultId, false, Token)).ShouldHaveSingleItem();
|
|
|
|
listed.Id.ShouldBe(id);
|
|
listed.EntityType.ShouldBe(entityType);
|
|
listed.Detail.ShouldBe(
|
|
detail,
|
|
"an empty detail here means the record was sealed under one resource type and opened under "
|
|
+ "another, which ListAsync reports as nothing rather than as a failure");
|
|
}
|
|
|
|
[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);
|
|
}
|
|
}
|