From fb54d65d958f99af67453796297f1cbdd9760c34 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 12 May 2026 12:08:48 +0000 Subject: [PATCH] Add native-store integration tests + split EnumerateKeys Follow-up to PR #59. Closes out the four items called out in the post-merge audit: 1. The native ICredentialStore implementations (Windows / macOS / Linux) were only compile-checked previously. Adds NativeCredentialStoreTests that exercise Save / TryLoad / Remove / cross-instance round-trip and an EnumerateKeys check against whichever store CredentialStoreFactory.CreateDefault returns for the host OS. Each test scopes itself to a unique service name (Guid.NewGuid()) so it can't collide with the user's real credentials. 2. EnumerateKeys was a silent no-op on macOS/Linux. Moved it onto a new ISearchableCredentialStore interface that only WindowsCredentialStore and InMemoryCredentialStore implement. Callers needing enumeration on other platforms now have to declare the dependency explicitly with a runtime cast, instead of getting an empty IEnumerable that looks like "no data". 3. AssertNativeStoreAvailableOrInconclusive turns "libsecret not installed" or "Secret Service unreachable" into Assert.Inconclusive rather than a red failure, so developers without a keyring set up don't see scary CI red. Detects via DllNotFoundException / CredentialStoreException, walking inner exceptions (libsecret schema init failures arrive as TypeInitializationException). 4. Cross-platform CI workflow now installs dbus + gnome-keyring on Linux and runs the test step inside dbus-run-session, unlocking gnome-keyring with an empty password first. Verified locally: 21/22 pass under that harness, the remaining one is the Windows-only EnumerateKeys test reported as inconclusive. VERSION.md bumped 1.3.0 -> 2.0.0 because removing EnumerateKeys from ICredentialStore (and the API rewrite in #59) is a SemVer-major break. https://claude.ai/code/session_017B9mN9F7C3pWGZRyoKBd6R --- .github/workflows/cross-platform.yml | 21 +- .../NativeCredentialStoreTests.cs | 197 ++++++++++++++++++ CredentialCache/Storage/ICredentialStore.cs | 5 - .../Storage/ISearchableCredentialStore.cs | 21 ++ .../Storage/InMemoryCredentialStore.cs | 7 +- .../LinuxSecretServiceCredentialStore.cs | 9 - .../Storage/MacOsCredentialStore.cs | 10 - .../Storage/WindowsCredentialStore.cs | 2 +- VERSION.md | 2 +- 9 files changed, 239 insertions(+), 35 deletions(-) create mode 100644 CredentialCache.Test/NativeCredentialStoreTests.cs create mode 100644 CredentialCache/Storage/ISearchableCredentialStore.cs diff --git a/.github/workflows/cross-platform.yml b/.github/workflows/cross-platform.yml index aa806e9..5c26bf4 100644 --- a/.github/workflows/cross-platform.yml +++ b/.github/workflows/cross-platform.yml @@ -40,11 +40,11 @@ jobs: with: dotnet-version: 10.0.x - - name: Install libsecret (Linux) + - name: Install libsecret + dbus + gnome-keyring (Linux) if: runner.os == 'Linux' run: | sudo apt-get update - sudo apt-get install -y libsecret-1-0 + sudo apt-get install -y libsecret-1-0 dbus dbus-x11 gnome-keyring - name: Restore run: dotnet restore CredentialCache.sln @@ -52,5 +52,20 @@ jobs: - name: Build run: dotnet build CredentialCache.sln --configuration Release --no-restore - - name: Test + - name: Test (non-Linux) + if: runner.os != 'Linux' run: dotnet test --project CredentialCache.Test/CredentialCache.Test.csproj --configuration Release --no-build + + - name: Test (Linux, under dbus session with gnome-keyring) + if: runner.os == 'Linux' + shell: bash + # The LinuxSecretServiceCredentialStore tests need a running Secret + # Service. We spin up a private dbus session, start gnome-keyring-daemon + # inside it, unlock with an empty password, then run the test runner + # within the same session so libsecret can reach the daemon. + run: | + dbus-run-session -- bash -c ' + printf "\n" | gnome-keyring-daemon --unlock --components=secrets >/dev/null 2>&1 & + sleep 1 + dotnet test --project CredentialCache.Test/CredentialCache.Test.csproj --configuration Release --no-build + ' diff --git a/CredentialCache.Test/NativeCredentialStoreTests.cs b/CredentialCache.Test/NativeCredentialStoreTests.cs new file mode 100644 index 0000000..8b3a923 --- /dev/null +++ b/CredentialCache.Test/NativeCredentialStoreTests.cs @@ -0,0 +1,197 @@ +// Copyright (c) ktsu.dev +// All rights reserved. +// Licensed under the MIT license. + +namespace ktsu.CredentialCache.Test; + +using System.Runtime.InteropServices; +using ktsu.CredentialCache.Storage; +using ktsu.Semantics.Strings; + +/// +/// Exercises the platform-native credential store returned by +/// on whatever OS +/// the test happens to be running on. The store is scoped to a per-run service +/// name so the tests don't collide with other applications' real credentials. +/// +/// On Linux these require a running Secret Service implementation (e.g. +/// gnome-keyring-daemon launched under dbus-run-session). The +/// cross-platform CI workflow provides one; locally they will fail-fast with +/// a clear if no daemon is available. +/// +[TestClass] +public class NativeCredentialStoreTests +{ + private static string UniqueServiceName() => + $"ktsu.CredentialCache.IntegrationTest.{Guid.NewGuid():N}"; + + private static ICredentialStore CreateNativeStore() => + CredentialStoreFactory.CreateDefault(UniqueServiceName()); + + /// + /// Performs a tiny no-op call against the native store to verify the platform + /// dependencies are actually present (e.g. libsecret loaded and a Secret + /// Service daemon is reachable on Linux). If not, the test is reported as + /// rather than failed, so a + /// developer without a keyring set up doesn't see scary red. + /// + private static void AssertNativeStoreAvailableOrInconclusive(ICredentialStore store) + { + PersonaGUID probe = CredentialCache.CreatePersonaGUID(); + try + { + // Removing a key that doesn't exist must not throw on a working backend. + _ = store.Remove(probe); + } + catch (Exception ex) when (IsMissingPlatformDependency(ex)) + { + Assert.Inconclusive($"Native credential store is not available in this environment: {ex.GetType().Name}: {ex.Message}"); + } + } + + private static bool IsMissingPlatformDependency(Exception ex) + { + // Walk the inner-exception chain - libsecret/Security.framework load + // failures surface inside a TypeInitializationException when triggered + // during a static cctor (e.g. the libsecret schema handle). + for (Exception? current = ex; current is not null; current = current.InnerException) + { + if (current is DllNotFoundException or CredentialStoreException) + { + return true; + } + } + return false; + } + + [TestMethod] + public void NativeStoreRoundTripsCredentialWithToken() + { + ICredentialStore store = CreateNativeStore(); + AssertNativeStoreAvailableOrInconclusive(store); + PersonaGUID persona = CredentialCache.CreatePersonaGUID(); + Credential original = new CredentialWithToken + { + Token = SemanticString.Create("native-test-token"), + }; + + try + { + store.Save(persona, original); + + Assert.IsTrue(store.TryLoad(persona, out Credential? loaded)); + CredentialWithToken? typed = loaded as CredentialWithToken; + Assert.IsNotNull(typed); + Assert.AreEqual("native-test-token", typed!.Token.ToString()); + } + finally + { + store.Remove(persona); + } + } + + [TestMethod] + public void NativeStoreSaveOverwritesExistingEntry() + { + ICredentialStore store = CreateNativeStore(); + AssertNativeStoreAvailableOrInconclusive(store); + PersonaGUID persona = CredentialCache.CreatePersonaGUID(); + + try + { + store.Save(persona, new CredentialWithToken + { + Token = SemanticString.Create("first"), + }); + store.Save(persona, new CredentialWithToken + { + Token = SemanticString.Create("second"), + }); + + Assert.IsTrue(store.TryLoad(persona, out Credential? loaded)); + Assert.AreEqual("second", ((CredentialWithToken)loaded!).Token.ToString()); + } + finally + { + store.Remove(persona); + } + } + + [TestMethod] + public void NativeStoreRemoveReturnsFalseForUnknownPersona() + { + ICredentialStore store = CreateNativeStore(); + AssertNativeStoreAvailableOrInconclusive(store); + PersonaGUID persona = CredentialCache.CreatePersonaGUID(); + + Assert.IsFalse(store.Remove(persona)); + Assert.IsFalse(store.TryLoad(persona, out _)); + } + + [TestMethod] + public void NativeStoreSurvivesAcrossStoreInstances() + { + string service = UniqueServiceName(); + PersonaGUID persona = CredentialCache.CreatePersonaGUID(); + Credential original = new CredentialWithUsernamePassword + { + Username = SemanticString.Create("bob"), + Password = SemanticString.Create("sekrit"), + }; + + ICredentialStore writer = CredentialStoreFactory.CreateDefault(service); + AssertNativeStoreAvailableOrInconclusive(writer); + try + { + writer.Save(persona, original); + + ICredentialStore reader = CredentialStoreFactory.CreateDefault(service); + Assert.IsTrue(reader.TryGet(persona, out Credential? loaded)); + CredentialWithUsernamePassword? typed = loaded as CredentialWithUsernamePassword; + Assert.IsNotNull(typed); + Assert.AreEqual("bob", typed!.Username.ToString()); + Assert.AreEqual("sekrit", typed.Password.ToString()); + } + finally + { + writer.Remove(persona); + } + } + + [TestMethod] + public void WindowsStoreEnumerateKeysReturnsWrittenPersonas() + { + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + Assert.Inconclusive("EnumerateKeys is only implemented on Windows Credential Manager."); + return; + } + + ICredentialStore store = CreateNativeStore(); + AssertNativeStoreAvailableOrInconclusive(store); + ISearchableCredentialStore? searchable = store as ISearchableCredentialStore; + Assert.IsNotNull(searchable, "Windows store should implement ISearchableCredentialStore."); + + PersonaGUID persona = CredentialCache.CreatePersonaGUID(); + try + { + searchable!.Save(persona, new CredentialWithNothing()); + IEnumerable keys = searchable.EnumerateKeys(); + Assert.IsTrue(keys.Any(k => string.Equals(k.ToString(), persona.ToString(), StringComparison.Ordinal))); + } + finally + { + searchable!.Remove(persona); + } + } +} + +/// +/// Small helper that wires TryLoad through as TryGet for symmetry with +/// CredentialCache's API in test assertions. Keeps the assertion sites readable. +/// +internal static class CredentialStoreTestExtensions +{ + public static bool TryGet(this ICredentialStore store, PersonaGUID persona, out Credential? credential) + => store.TryLoad(persona, out credential); +} diff --git a/CredentialCache/Storage/ICredentialStore.cs b/CredentialCache/Storage/ICredentialStore.cs index 14726e3..713d53b 100644 --- a/CredentialCache/Storage/ICredentialStore.cs +++ b/CredentialCache/Storage/ICredentialStore.cs @@ -31,9 +31,4 @@ public interface ICredentialStore /// Removes any credential associated with . /// public bool Remove(PersonaGUID persona); - - /// - /// Enumerates every persona key currently stored. - /// - public IEnumerable EnumerateKeys(); } diff --git a/CredentialCache/Storage/ISearchableCredentialStore.cs b/CredentialCache/Storage/ISearchableCredentialStore.cs new file mode 100644 index 0000000..166fe60 --- /dev/null +++ b/CredentialCache/Storage/ISearchableCredentialStore.cs @@ -0,0 +1,21 @@ +// Copyright (c) ktsu.dev +// All rights reserved. +// Licensed under the MIT license. + +namespace ktsu.CredentialCache.Storage; + +/// +/// An that can enumerate its persisted keys. +/// Only some backends support this efficiently - e.g. Windows Credential +/// Manager via CredEnumerate. The macOS and Linux native APIs require +/// substantial extra marshalling to enumerate, so they intentionally do not +/// implement this interface; callers needing enumeration on those platforms +/// should track values themselves. +/// +public interface ISearchableCredentialStore : ICredentialStore +{ + /// + /// Enumerates every persona key currently persisted in this store. + /// + public IEnumerable EnumerateKeys(); +} diff --git a/CredentialCache/Storage/InMemoryCredentialStore.cs b/CredentialCache/Storage/InMemoryCredentialStore.cs index 47bb57a..6410ec0 100644 --- a/CredentialCache/Storage/InMemoryCredentialStore.cs +++ b/CredentialCache/Storage/InMemoryCredentialStore.cs @@ -10,13 +10,11 @@ namespace ktsu.CredentialCache.Storage; /// A non-persistent credential store backed by an in-memory dictionary. Intended for /// tests and applications that explicitly opt out of platform-level persistence. /// -public sealed class InMemoryCredentialStore : ICredentialStore +public sealed class InMemoryCredentialStore : ISearchableCredentialStore { - private readonly ConcurrentDictionary _items = new(); /// - public string Name => "InMemory"; /// @@ -24,7 +22,6 @@ public bool TryLoad(PersonaGUID persona, out Credential? credential) { ArgumentNullException.ThrowIfNull(persona); return _items.TryGetValue(persona, out credential); - } /// @@ -33,7 +30,6 @@ public void Save(PersonaGUID persona, Credential credential) ArgumentNullException.ThrowIfNull(persona); ArgumentNullException.ThrowIfNull(credential); _items[persona] = credential; - } /// @@ -41,7 +37,6 @@ public bool Remove(PersonaGUID persona) { ArgumentNullException.ThrowIfNull(persona); return _items.TryRemove(persona, out _); - } /// diff --git a/CredentialCache/Storage/LinuxSecretServiceCredentialStore.cs b/CredentialCache/Storage/LinuxSecretServiceCredentialStore.cs index 291e43c..bcd9f9a 100644 --- a/CredentialCache/Storage/LinuxSecretServiceCredentialStore.cs +++ b/CredentialCache/Storage/LinuxSecretServiceCredentialStore.cs @@ -120,15 +120,6 @@ public bool Remove(PersonaGUID persona) return removed; } - /// - public IEnumerable EnumerateKeys() - { - // libsecret search_sync requires GLib list/hash-table marshalling. Callers - // needing enumeration can track keys themselves or rely on the in-memory - // snapshot maintained by . - yield break; - } - private static void ThrowIfError(IntPtr error, string operation) { if (error == IntPtr.Zero) diff --git a/CredentialCache/Storage/MacOsCredentialStore.cs b/CredentialCache/Storage/MacOsCredentialStore.cs index c40050a..1ee5404 100644 --- a/CredentialCache/Storage/MacOsCredentialStore.cs +++ b/CredentialCache/Storage/MacOsCredentialStore.cs @@ -183,16 +183,6 @@ public bool Remove(PersonaGUID persona) } } - /// - public IEnumerable EnumerateKeys() - { - // SecItemCopyMatching with a CFDictionary is required for enumeration. Implementing - // the CoreFoundation marshalling for an enumerate-only path adds substantial native - // surface; callers that need enumeration can track keys themselves or rely on the - // in-memory snapshot maintained by . - yield break; - } - private static class NativeMethods { internal const int errSecSuccess = 0; diff --git a/CredentialCache/Storage/WindowsCredentialStore.cs b/CredentialCache/Storage/WindowsCredentialStore.cs index 87e6946..b96ebc7 100644 --- a/CredentialCache/Storage/WindowsCredentialStore.cs +++ b/CredentialCache/Storage/WindowsCredentialStore.cs @@ -16,7 +16,7 @@ namespace ktsu.CredentialCache.Storage; /// representation of the . /// [SupportedOSPlatform("windows")] -internal sealed class WindowsCredentialStore : ICredentialStore +internal sealed class WindowsCredentialStore : ISearchableCredentialStore { internal const string DefaultServicePrefix = "ktsu.CredentialCache"; diff --git a/VERSION.md b/VERSION.md index f0bb29e..227cea2 100644 --- a/VERSION.md +++ b/VERSION.md @@ -1 +1 @@ -1.3.0 +2.0.0