using DodoSSH.Contracts;
using DodoSSH.Crypto;
namespace DodoSSH.Client.Storage.Tests;
///
/// A migrated, unlocked cache for one test.
///
///
/// Each harness gets its own in-memory database, so tests cannot interfere and can run in parallel.
/// The Argon2id cost is deliberately far below the shipped profile — 8 MiB and one pass rather than
/// 256 MiB and four. The stretching is what makes a stolen wrap expensive to attack, and none of these
/// tests attack one; paying 320 ms per test to prove nothing would only encourage sharing state
/// between them.
///
internal sealed class CacheHarness : IDisposable
{
private static readonly Argon2Profile CheapProfile =
Argon2Profile.FromStoredParameters(memoryKibibytes: 8 * 1024, passes: 1, parallelism: 1);
private readonly MasterKey master;
private CacheHarness(ClientCacheFactory factory, MasterKey master, LocalCacheProtector protector)
{
Factory = factory;
this.master = master;
Protector = protector;
Items = new ItemStore(factory, protector);
Outbox = new OutboxStore(factory, protector, TimeProvider.System);
Vaults = new VaultStore(factory, TimeProvider.System);
Unlock = new UnlockStore(factory, TimeProvider.System);
SyncState = new SyncStateStore(factory);
Conflicts = new ConflictStore(factory, protector, TimeProvider.System);
}
internal static Guid VaultId { get; } = Guid.Parse("0192f0c8-aaaa-7c3d-8e4f-5a6b7c8d9e0f");
internal static Guid UserId { get; } = Guid.Parse("0192f0c8-bbbb-7c3d-8e4f-5a6b7c8d9e0f");
internal ClientCacheFactory Factory { get; }
internal LocalCacheProtector Protector { get; }
internal ItemStore Items { get; }
internal OutboxStore Outbox { get; }
internal VaultStore Vaults { get; }
internal UnlockStore Unlock { get; }
internal SyncStateStore SyncState { get; }
internal ConflictStore Conflicts { get; }
internal static async Task CreateAsync(
string passphrase = "correct horse battery staple")
{
var factory = ClientCacheFactory.ForMemory($"cache-{Guid.CreateVersion7():N}");
try
{
await factory.MigrateAsync(TestContext.Current.CancellationToken);
var salt = new byte[CryptoSpec.SaltSize];
var derived = MasterKey.Derive(passphrase, salt, CheapProfile);
return new CacheHarness(factory, derived, LocalCacheProtector.From(derived));
}
catch
{
factory.Dispose();
throw;
}
}
///
public void Dispose()
{
Protector.Dispose();
master.Dispose();
Factory.Dispose();
}
// ---- Builders ----
internal static EncryptedPayload Payload(byte seed = 1, uint keyGeneration = 1) =>
new(
Envelope: [seed, (byte)(seed + 1), (byte)(seed + 2)],
WrappedDataKey: [(byte)(seed + 10), (byte)(seed + 11)],
DataKeyId: Guid.Parse($"0192f0c8-cccc-7c3d-8e4f-5a6b7c8d9e{seed:x2}"),
KeyGeneration: keyGeneration,
AadVersion: CryptoSpec.CurrentAadVersion);
internal static StoredItem Item(
Guid entityId,
int version = 1,
long changeSequence = 1,
byte seed = 1,
bool deleted = false,
SyncPlaintextFields? fields = null) =>
new(
VaultId,
SyncEntityType.Host,
entityId,
version,
changeSequence,
deleted ? null : Payload(seed),
deleted ? null : fields ?? new SyncPlaintextFields(),
deleted,
DateTimeOffset.FromUnixTimeSeconds(1_750_000_000 + changeSequence));
internal static QueuedChange Change(
Guid entityId,
SyncOperation operation = SyncOperation.Upsert,
int? expectedVersion = null,
byte seed = 1,
StoredAncestor? ancestor = null,
SyncPlaintextFields? fields = null) =>
new(
VaultId,
SyncEntityType.Host,
entityId,
operation,
expectedVersion,
operation == SyncOperation.Delete ? null : Payload(seed),
operation == SyncOperation.Delete ? null : fields ?? new SyncPlaintextFields(),
ancestor);
}