diff --git a/src/Files.Platform.Linux/Secrets/SecretServiceStore.cs b/src/Files.Platform.Linux/Secrets/SecretServiceStore.cs index b971f05ac684..dd647dcbe701 100644 --- a/src/Files.Platform.Linux/Secrets/SecretServiceStore.cs +++ b/src/Files.Platform.Linux/Secrets/SecretServiceStore.cs @@ -8,6 +8,7 @@ using System.Collections.Concurrent; using System.Linq; using System.Text; +using System.Threading; using System.Threading.Tasks; using Tmds.DBus.Protocol; @@ -15,7 +16,8 @@ namespace Files.Platform.Linux.Secrets { /// /// on the freedesktop Secret Service (libsecret's backend: GNOME Keyring, KWallet, KeePassXC) over D-Bus. - /// When no service is reachable or the collection is locked, secrets are kept in memory for the lifetime of the process. + /// A locked collection is unlocked through the desktop's own keyring prompt. When no service is reachable or the unlock is dismissed, fails or times out, + /// secrets are kept in memory for the lifetime of the process. /// public sealed class SecretServiceStore : ISecretStore { @@ -25,11 +27,18 @@ public sealed class SecretServiceStore : ISecretStore private const string ServiceInterface = "org.freedesktop.Secret.Service"; private const string CollectionInterface = "org.freedesktop.Secret.Collection"; private const string ItemInterface = "org.freedesktop.Secret.Item"; + private const string PromptInterface = "org.freedesktop.Secret.Prompt"; private const string LabelProperty = "org.freedesktop.Secret.Item.Label"; private const string AttributesProperty = "org.freedesktop.Secret.Item.Attributes"; private static readonly TimeSpan CallTimeout = TimeSpan.FromSeconds(3); + private static readonly TimeSpan DefaultUnlockTimeout = TimeSpan.FromSeconds(45); + private static readonly TimeSpan DefaultPromptCooldown = TimeSpan.FromMinutes(2); private readonly string? busAddress; + private readonly TimeSpan unlockTimeout; + private readonly TimeSpan promptCooldown; + private readonly SemaphoreSlim unlockGate = new(1, 1); + private long promptBlockedUntilTicks; private readonly ConcurrentDictionary<(string, string), string> memory = new(); private readonly ConcurrentDictionary<(string, string), byte> tombstones = new(); private readonly ConcurrentDictionary<(string, string), object> locks = new(); @@ -38,8 +47,19 @@ public sealed class SecretServiceStore : ISecretStore /// Creates the store. null means the user's session bus. /// public SecretServiceStore(string? busAddress = null) + : this(busAddress, DefaultUnlockTimeout, DefaultPromptCooldown) + { + } + + /// + /// bounds the wait for the user to answer the unlock prompt. After a prompt is dismissed or times out, + /// no new prompt is shown for so a refusing user is not asked on every save. + /// + internal SecretServiceStore(string? busAddress, TimeSpan unlockTimeout, TimeSpan promptCooldown) { this.busAddress = busAddress; + this.unlockTimeout = unlockTimeout; + this.promptCooldown = promptCooldown; } /// @@ -53,7 +73,6 @@ public void Save(string resource, string account, string secret) { memory[key] = secret; tombstones.TryRemove(key, out _); - // LINUX-TODO(secrets): prompts to unlock a locked keyring are not shown; a locked collection falls back to memory only. // Get prefers the in-memory value, so a failed update never lets the stale persisted secret win in this process. IsPersistent = Run(c => SaveAsync(c, resource, account, secret)); } @@ -111,12 +130,15 @@ private string RunDelete(string resource, string account, out bool reachable) private bool Run(Func> action) => Run(async c => await action(c).ConfigureAwait(false) ? "1" : null, out _) is not null; + // The budget must outlast the worst sequence so the prompt Dismiss runs before the connection is disposed. Save: OpenSession + CreateItem, + // then Unlock + AddMatch + Prompt, then OpenSession + CreateItem again (7 calls); Get/Delete are shorter, and a dismissed or timed-out prompt + // ends with Dismiss instead of the retry. Waiting for the unlock gate and for the user's answer each take up to unlockTimeout. private T? Run(Func> action, out bool reachable) where T : class { var wasReachable = false; try { - return RunAsync(action, () => wasReachable = true).GetAwaiter().GetResult(); + return RunAsync(action, () => wasReachable = true, CallTimeout * 8 + unlockTimeout * 2).GetAwaiter().GetResult(); } catch (Exception ex) when (ex is TimeoutException or DBusExceptionBase or ObjectDisposedException or InvalidOperationException or System.IO.IOException) { @@ -128,7 +150,7 @@ private bool Run(Func> action) } } - private async Task RunAsync(Func> action, Action markReachable) where T : class + private async Task RunAsync(Func> action, Action markReachable, TimeSpan budget) where T : class { var connection = await DBusSession.TryConnectAsync(busAddress, CallTimeout).ConfigureAwait(false); if (connection is null) @@ -137,7 +159,7 @@ private bool Run(Func> action) try { markReachable(); - return await action(connection).WaitAsync(CallTimeout * 2).ConfigureAwait(false); + return await action(connection).WaitAsync(budget).ConfigureAwait(false); } finally { @@ -171,7 +193,7 @@ private static void WriteAttributes(ref MessageWriter writer, string resource, s writer.WriteDictionaryEnd(start); } - private static async Task FindItemAsync(DBusConnection bus, string resource, string account) + private static async Task<(string Path, bool Locked)?> FindItemAsync(DBusConnection bus, string resource, string account) { MessageBuffer message; { @@ -196,13 +218,18 @@ private static void WriteAttributes(ref MessageWriter writer, string resource, s return (unlockedItems, lockedItems); }, null).ConfigureAwait(false); - return unlocked.FirstOrDefault() ?? locked.FirstOrDefault(); + if (unlocked.FirstOrDefault() is { } open) + return (open, false); + + return locked.FirstOrDefault() is { } closed ? (closed, true) : null; } - private static async Task GetAsync(DBusConnection bus, string resource, string account) + private async Task GetAsync(DBusConnection bus, string resource, string account) { - var item = await FindItemAsync(bus, resource, account).ConfigureAwait(false); - if (item is null) + if (await FindItemAsync(bus, resource, account).ConfigureAwait(false) is not var (item, locked)) + return null; + + if (locked && !await UnlockAsync(bus, item).ConfigureAwait(false)) return null; var session = await OpenSessionAsync(bus).ConfigureAwait(false); @@ -229,7 +256,17 @@ private static void WriteAttributes(ref MessageWriter writer, string resource, s return Encoding.UTF8.GetString(bytes); } - private static async Task SaveAsync(DBusConnection bus, string resource, string account, string secret) + private async Task SaveAsync(DBusConnection bus, string resource, string account, string secret) + { + if (await CreateItemAsync(bus, resource, account, secret).ConfigureAwait(false)) + return true; + + // A prompt means the collection is locked: ask the user to unlock it, then retry once + return await UnlockAsync(bus, DefaultCollection).ConfigureAwait(false) + && await CreateItemAsync(bus, resource, account, secret).ConfigureAwait(false); + } + + private static async Task CreateItemAsync(DBusConnection bus, string resource, string account, string secret) { var session = await OpenSessionAsync(bus).ConfigureAwait(false); @@ -272,25 +309,27 @@ private static async Task SaveAsync(DBusConnection bus, string resource, s return reader.ReadObjectPathAsString(); }, null).ConfigureAwait(false); - // A prompt means the collection is locked and needs the user to unlock it return prompt == "/"; } - private static async Task DeleteAsync(DBusConnection bus, string resource, string account) + private async Task DeleteAsync(DBusConnection bus, string resource, string account) { - string? item; + (string Path, bool Locked)? found; try { - item = await FindItemAsync(bus, resource, account).ConfigureAwait(false); + found = await FindItemAsync(bus, resource, account).ConfigureAwait(false); } catch (DBusErrorReplyException ex) when (ex.ErrorName is "org.freedesktop.DBus.Error.ServiceUnknown" or "org.freedesktop.DBus.Error.NameHasNoOwner") { return "unavailable"; // no Secret Service answered: the delete is unconfirmed } - if (item is null) + if (found is not var (item, locked)) return "absent"; // a live service answered: there is no such item + if (locked && !await UnlockAsync(bus, item).ConfigureAwait(false)) + return "failed"; + MessageBuffer message; using (var writer = bus.GetMessageWriter()) { @@ -301,6 +340,138 @@ private static async Task SaveAsync(DBusConnection bus, string resource, s var prompt = await bus.CallTracedAsync("dbus.secrets", message, static (Message m, object? _) => m.GetBodyReader().ReadObjectPathAsString(), null).ConfigureAwait(false); return prompt == "/" ? "deleted" : "failed"; } + + /// + /// Unlocks (a collection or item). When the service needs the user, shows the desktop's keyring prompt and waits for it. + /// Returns false when the unlock is dismissed, denied, fails or times out; a refused prompt is not repeated until the cooldown passes. + /// + private async Task UnlockAsync(DBusConnection bus, string objectPath) + { + if (!await unlockGate.WaitAsync(unlockTimeout).ConfigureAwait(false)) + return false; + + try + { + if (DateTime.UtcNow.Ticks < Interlocked.Read(ref promptBlockedUntilTicks)) + return false; + + string prompt; + try + { + prompt = await CallUnlockAsync(bus, objectPath).ConfigureAwait(false); + } + catch (Exception ex) when (ex is DBusExceptionBase or TimeoutException) + { + return false; + } + + if (prompt == "/") + return true; + + var unlocked = await RunPromptAsync(bus, prompt).ConfigureAwait(false); + if (!unlocked) + Interlocked.Exchange(ref promptBlockedUntilTicks, (DateTime.UtcNow + promptCooldown).Ticks); + return unlocked; + } + finally + { + unlockGate.Release(); + } + } + + private static async Task CallUnlockAsync(DBusConnection bus, string objectPath) + { + MessageBuffer message; + using (var writer = bus.GetMessageWriter()) + { + writer.WriteMethodCallHeader(Service, ServicePath, ServiceInterface, "Unlock", "ao"); + writer.WriteArray(new[] { new ObjectPath(objectPath) }); + message = writer.CreateMessage(); + } + + return await bus.CallTracedAsync("dbus.secrets", message, static (Message m, object? _) => + { + var reader = m.GetBodyReader(); + reader.ReadArrayOfObjectPath(); + return reader.ReadObjectPathAsString(); + }, null).WaitAsync(CallTimeout).ConfigureAwait(false); + } + + private async Task RunPromptAsync(DBusConnection bus, string prompt) + { + var completed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var rule = new MatchRule + { + Type = MessageType.Signal, + Path = prompt, + Interface = PromptInterface, + Member = "Completed", + }; + + // Subscribe before Prompt so a prompt that finishes immediately cannot be missed + IDisposable? subscription = null; + try + { + subscription = await bus.AddMatchAsync(rule, static (Message m, object? _) => + { + var reader = m.GetBodyReader(); + return !reader.ReadBool(); + }, static (Exception? ex, bool accepted, object? _, object? state) => + { + var source = (TaskCompletionSource)state!; + if (ex is not null) + source.TrySetResult(false); + else + source.TrySetResult(accepted); + }, null, completed, false, ObserverFlags.None).AsTask().WaitAsync(CallTimeout).ConfigureAwait(false); + + await CallPromptMethodAsync(bus, prompt, "Prompt", "s").WaitAsync(CallTimeout).ConfigureAwait(false); + + try + { + return await completed.Task.WaitAsync(unlockTimeout).ConfigureAwait(false); + } + catch (TimeoutException) + { + await DismissAsync(bus, prompt).ConfigureAwait(false); + return false; + } + } + catch (Exception ex) when (ex is DBusExceptionBase or TimeoutException) + { + return false; + } + finally + { + subscription?.Dispose(); + } + } + + private static Task CallPromptMethodAsync(DBusConnection bus, string prompt, string member, string signature) + { + MessageBuffer message; + using (var writer = bus.GetMessageWriter()) + { + writer.WriteMethodCallHeader(Service, prompt, PromptInterface, member, signature); + if (signature.Length > 0) + writer.WriteString(string.Empty); + message = writer.CreateMessage(); + } + + return bus.CallTracedAsync("dbus.secrets", message); + } + + private static async Task DismissAsync(DBusConnection bus, string prompt) + { + try + { + await CallPromptMethodAsync(bus, prompt, "Dismiss", string.Empty).WaitAsync(CallTimeout).ConfigureAwait(false); + } + catch (Exception ex) when (ex is DBusExceptionBase or TimeoutException) + { + // Best effort: the prompt may already be gone + } + } } /// diff --git a/tests/Files.Platform.Tests/SystemIntegration/SecretServiceUnlockTests.cs b/tests/Files.Platform.Tests/SystemIntegration/SecretServiceUnlockTests.cs new file mode 100644 index 000000000000..084b784bfd32 --- /dev/null +++ b/tests/Files.Platform.Tests/SystemIntegration/SecretServiceUnlockTests.cs @@ -0,0 +1,392 @@ +// Copyright (c) Files Community +// Licensed under the MIT License. + +using Files.Platform.Linux.DBus; +using Files.Platform.Linux.Secrets; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System; +using System.Collections.Generic; +using System.Text; +using System.Threading.Tasks; +using Tmds.DBus.Protocol; + +namespace Files.Platform.Tests.SystemIntegration +{ + /// + /// Drives against a fake Secret Service on a private bus; no real keyring or prompt is involved. + /// + [TestClass] + public sealed class SecretServiceUnlockTests + { + private enum UnlockMode + { + Silent, + PromptAccept, + PromptDismiss, + PromptNever, + PromptImmediate, + Error, + } + + private sealed class FakeSecretService : IPathMethodHandler, IDisposable + { + private const string PromptPath = "/org/freedesktop/secrets/prompt/u1"; + private const string ItemPrefix = "/org/freedesktop/secrets/collection/default/"; + + private readonly DBusConnection connection; + private readonly Dictionary<(string Resource, string Account), string> items = new(); + private readonly object gate = new(); + + public bool Locked { get; set; } + + public UnlockMode Mode { get; set; } + + public int UnlockCalls { get; private set; } + + public int PromptCalls { get; private set; } + + public int DismissCalls { get; private set; } + + public string Path => "/org/freedesktop/secrets"; + + public bool HandlesChildPaths => true; + + private FakeSecretService(DBusConnection connection) => this.connection = connection; + + public static async Task StartAsync(string address) + { + var bus = await DBusSession.TryConnectAsync(address, TimeSpan.FromSeconds(5)) ?? throw new InvalidOperationException("no bus"); + var fake = new FakeSecretService(bus); + bus.AddMethodHandler(fake); + await bus.TryRequestNameAsync("org.freedesktop.secrets", RequestNameOptions.None); + return fake; + } + + public void Seed(string resource, string account, string secret) + { + lock (gate) + items[(resource, account)] = secret; + } + + public ValueTask HandleMethodAsync(MethodContext context) + { + var request = context.Request; + if (context.IsDBusIntrospectRequest) + { + context.ReplyIntrospectXml([Encoding.UTF8.GetBytes("")]); + return ValueTask.CompletedTask; + } + + lock (gate) + { + switch (request.MemberAsString) + { + case "OpenSession": + { + using var reply = context.CreateReplyWriter("vo"); + reply.WriteVariantString(string.Empty); + reply.WriteObjectPath("/org/freedesktop/secrets/session/s1"); + context.Reply(reply.CreateMessage()); + break; + } + case "Unlock": + Unlock(context); + break; + case "SearchItems": + Search(context); + break; + case "CreateItem": + CreateItem(context); + break; + case "GetSecret": + { + var found = items.GetValueOrDefault(Decode(request.PathAsString)); + using var reply = context.CreateReplyWriter("(oayays)"); + reply.WriteStructureStart(); + reply.WriteObjectPath("/org/freedesktop/secrets/session/s1"); + reply.WriteArray(Array.Empty()); + reply.WriteArray(Encoding.UTF8.GetBytes(found ?? string.Empty)); + reply.WriteString("text/plain"); + context.Reply(reply.CreateMessage()); + break; + } + case "Delete": + { + items.Remove(Decode(request.PathAsString)); + using var reply = context.CreateReplyWriter("o"); + reply.WriteObjectPath("/"); + context.Reply(reply.CreateMessage()); + break; + } + case "Prompt": + PromptCalls++; + context.Reply(context.CreateReplyWriter(null).CreateMessage()); + CompletePrompt(); + break; + case "Dismiss": + DismissCalls++; + context.Reply(context.CreateReplyWriter(null).CreateMessage()); + break; + default: + context.ReplyError("org.freedesktop.DBus.Error.UnknownMethod", "unknown"); + break; + } + } + + return ValueTask.CompletedTask; + } + + private static (string, string) Decode(string? path) + { + var parts = path![ItemPrefix.Length..].Split('_'); + return (parts[0], parts[1]); + } + + private void Unlock(MethodContext context) + { + UnlockCalls++; + if (Mode == UnlockMode.Error) + { + context.ReplyError("org.freedesktop.Secret.Error.IsLocked", "fake failure"); + return; + } + + var silent = Mode == UnlockMode.Silent; + if (silent) + Locked = false; + + using var reply = context.CreateReplyWriter("aoo"); + reply.WriteArray(Array.Empty()); + reply.WriteObjectPath(silent ? "/" : PromptPath); + context.Reply(reply.CreateMessage()); + } + + private void CompletePrompt() + { + if (Mode == UnlockMode.PromptNever) + return; + + var immediate = Mode == UnlockMode.PromptImmediate; + var dismissed = Mode is not (UnlockMode.PromptAccept or UnlockMode.PromptImmediate); + if (!dismissed) + Locked = false; + + if (immediate) + { + SendCompleted(dismissed); + return; + } + + _ = Task.Run(async () => + { + await Task.Delay(50); + SendCompleted(dismissed); + }); + } + + private void SendCompleted(bool dismissed) + { + using var writer = connection.GetMessageWriter(); + writer.WriteSignalHeader(null, PromptPath, "org.freedesktop.Secret.Prompt", "Completed", "bv"); + writer.WriteBool(dismissed); + writer.WriteVariantString(string.Empty); + connection.TrySendMessage(writer.CreateMessage()); + } + + private void Search(MethodContext context) + { + var reader = context.Request.GetBodyReader(); + var attributes = new Dictionary(); + var end = reader.ReadDictionaryStart(); + while (reader.HasNext(end)) + attributes[reader.ReadString()] = reader.ReadString(); + var key = (Resource: attributes["resource"], Account: attributes["account"]); + var hits = items.ContainsKey(key) ? new[] { new ObjectPath(ItemPrefix + key.Resource + "_" + key.Account) } : Array.Empty(); + + using var reply = context.CreateReplyWriter("aoao"); + reply.WriteArray(Locked ? Array.Empty() : hits); + reply.WriteArray(Locked ? hits : Array.Empty()); + context.Reply(reply.CreateMessage()); + } + + private void CreateItem(MethodContext context) + { + string? prompt = "/"; + if (Locked) + { + prompt = PromptPath; + } + else + { + var reader = context.Request.GetBodyReader(); + var properties = reader.ReadDictionaryOfStringToVariantValue(); + // The label is "Files: {resource} ({account})"; the fake keys items by it instead of decoding the attribute variant + var label = properties["org.freedesktop.Secret.Item.Label"].GetString(); + var open = label.LastIndexOf(" (", StringComparison.Ordinal); + var resource = label["Files: ".Length..open]; + var account = label[(open + 2)..^1]; + reader.AlignStruct(); + reader.ReadObjectPath(); + reader.ReadArrayOfByte(); + var secret = Encoding.UTF8.GetString(reader.ReadArrayOfByte()); + items[(resource, account)] = secret; + } + + using var reply = context.CreateReplyWriter("oo"); + reply.WriteObjectPath("/"); + reply.WriteObjectPath(prompt); + context.Reply(reply.CreateMessage()); + } + + public void Dispose() => connection.Dispose(); + } + + // The store blocks its caller, so it is driven off the test thread to mirror the app's use from a worker + private static SecretServiceStore NewStore(PrivateBus bus, int unlockTimeoutMs = 5000) + => new(bus.Address, TimeSpan.FromMilliseconds(unlockTimeoutMs), TimeSpan.FromMinutes(5)); + + [TestMethod] + public async Task Unlocked_SavesWithoutAnyPrompt() + { + using var bus = PrivateBus.Start(); + using var service = await FakeSecretService.StartAsync(bus.Address); + + var store = NewStore(bus); + await Task.Run(() => store.Save("r", "a", "s3cret")); + + Assert.IsTrue(store.IsPersistent); + Assert.AreEqual(0, service.UnlockCalls); + Assert.AreEqual(0, service.PromptCalls); + Assert.AreEqual("s3cret", await Task.Run(() => NewStore(bus).Get("r", "a"))); + } + + [TestMethod] + public async Task Locked_UnlockWithoutPrompt_RetriesAndPersists() + { + using var bus = PrivateBus.Start(); + using var service = await FakeSecretService.StartAsync(bus.Address); + service.Locked = true; + service.Mode = UnlockMode.Silent; + + var store = NewStore(bus); + await Task.Run(() => store.Save("r", "a", "s3cret")); + + Assert.IsTrue(store.IsPersistent); + Assert.AreEqual(1, service.UnlockCalls); + Assert.AreEqual(0, service.PromptCalls); + Assert.AreEqual("s3cret", await Task.Run(() => NewStore(bus).Get("r", "a"))); + } + + [TestMethod] + public async Task Locked_PromptCompleted_RetriesAndPersists() + { + using var bus = PrivateBus.Start(); + using var service = await FakeSecretService.StartAsync(bus.Address); + service.Locked = true; + service.Mode = UnlockMode.PromptAccept; + + var store = NewStore(bus); + await Task.Run(() => store.Save("r", "a", "s3cret")); + + Assert.IsTrue(store.IsPersistent); + Assert.AreEqual(1, service.PromptCalls); + Assert.AreEqual("s3cret", await Task.Run(() => NewStore(bus).Get("r", "a"))); + } + + [TestMethod] + public async Task Locked_PromptDismissed_FallsBackToMemoryAndDoesNotNag() + { + using var bus = PrivateBus.Start(); + using var service = await FakeSecretService.StartAsync(bus.Address); + service.Locked = true; + service.Mode = UnlockMode.PromptDismiss; + + var store = NewStore(bus); + await Task.Run(() => store.Save("r", "a", "s3cret")); + + Assert.IsFalse(store.IsPersistent); + Assert.AreEqual("s3cret", await Task.Run(() => store.Get("r", "a"))); + Assert.AreEqual(1, service.PromptCalls); + + await Task.Run(() => store.Save("r", "b", "other")); + Assert.IsFalse(store.IsPersistent); + Assert.AreEqual(1, service.PromptCalls, "A refused prompt must not be repeated during the cooldown."); + } + + [TestMethod] + public async Task Locked_UnlockFails_FallsBackToMemory() + { + using var bus = PrivateBus.Start(); + using var service = await FakeSecretService.StartAsync(bus.Address); + service.Locked = true; + service.Mode = UnlockMode.Error; + + var store = NewStore(bus); + await Task.Run(() => store.Save("r", "a", "s3cret")); + + Assert.IsFalse(store.IsPersistent); + Assert.AreEqual("s3cret", await Task.Run(() => store.Get("r", "a"))); + Assert.AreEqual(0, service.PromptCalls); + } + + [TestMethod] + public async Task Locked_PromptNeverAnswered_TimesOutDismissesAndFallsBack() + { + using var bus = PrivateBus.Start(); + using var service = await FakeSecretService.StartAsync(bus.Address); + service.Locked = true; + service.Mode = UnlockMode.PromptNever; + + var store = NewStore(bus, unlockTimeoutMs: 400); + await Task.Run(() => store.Save("r", "a", "s3cret")); + + Assert.IsFalse(store.IsPersistent); + Assert.AreEqual("s3cret", await Task.Run(() => store.Get("r", "a"))); + Assert.AreEqual(1, service.PromptCalls); + Assert.AreEqual(1, service.DismissCalls); + } + + [TestMethod] + public async Task LockedItem_GetPromptsThenReadsSecret() + { + using var bus = PrivateBus.Start(); + using var service = await FakeSecretService.StartAsync(bus.Address); + service.Seed("r", "a", "stored"); + service.Locked = true; + service.Mode = UnlockMode.PromptAccept; + + Assert.AreEqual("stored", await Task.Run(() => NewStore(bus).Get("r", "a"))); + Assert.AreEqual(1, service.PromptCalls); + } + + [TestMethod] + public async Task Locked_PromptCompletesImmediately_StillUnlocks() + { + using var bus = PrivateBus.Start(); + using var service = await FakeSecretService.StartAsync(bus.Address); + service.Locked = true; + service.Mode = UnlockMode.PromptImmediate; + + var store = NewStore(bus); + await Task.Run(() => store.Save("r", "a", "s3cret")); + + Assert.IsTrue(store.IsPersistent); + Assert.AreEqual(1, service.PromptCalls); + Assert.AreEqual("s3cret", await Task.Run(() => NewStore(bus).Get("r", "a"))); + } + + [TestMethod] + public async Task LockedItem_DeletePromptsThenRemoves() + { + using var bus = PrivateBus.Start(); + using var service = await FakeSecretService.StartAsync(bus.Address); + service.Seed("r", "a", "stored"); + service.Locked = true; + service.Mode = UnlockMode.PromptAccept; + + Assert.IsTrue(await Task.Run(() => NewStore(bus).Delete("r", "a"))); + Assert.AreEqual(1, service.PromptCalls); + Assert.IsNull(await Task.Run(() => NewStore(bus).Get("r", "a"))); + } + } +}