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")));
+ }
+ }
+}