diff --git a/BuildMonitor.Test/GitHubRequestOutcomeTests.cs b/BuildMonitor.Test/GitHubRequestOutcomeTests.cs
index 005944c..178ca2a 100644
--- a/BuildMonitor.Test/GitHubRequestOutcomeTests.cs
+++ b/BuildMonitor.Test/GitHubRequestOutcomeTests.cs
@@ -8,10 +8,14 @@ namespace ktsu.BuildMonitor.Test;
using System.Net.Http;
using System.Threading.Tasks;
+using ktsu.CredentialCache.Storage;
+
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Octokit;
+using CredentialCache = ktsu.CredentialCache.CredentialCache;
+
///
/// Tests whether one GitHub API request reports having succeeded.
///
@@ -32,6 +36,26 @@ public sealed class GitHubRequestOutcomeTests
{
private const string RequestName = "test/request";
+ private CredentialCache Cache { get; set; } = null!;
+
+ ///
+ /// Tokens live in the OS secret store, and an authorization failure clears the provider's token,
+ /// so every test runs against an in-memory store rather than the developer's real credentials.
+ ///
+ [TestInitialize]
+ public void SetUp()
+ {
+ Cache = new CredentialCache(new InMemoryCredentialStore());
+ TokenStorage.UseCache(Cache);
+ }
+
+ [TestCleanup]
+ public void TearDown()
+ {
+ TokenStorage.UseCache(null);
+ Cache.Dispose();
+ }
+
///
/// A response built to order. Octokit's own Response is internal, and the provider reads
/// only the status code and the headers off it.
@@ -127,11 +151,16 @@ public async Task AConnectionErrorReportsFailure()
Assert.AreEqual(ProviderStatus.Error, provider.Status);
}
- private static Owner OwnerWithToken() => new()
+ ///
+ /// Builds an owner carrying its own token. The owner is made by the provider, because its token
+ /// is stored under a persona derived from the provider's name.
+ ///
+ private static Owner OwnerWithToken(GitHub provider)
{
- Name = OwnerName.Create("alpha"),
- Token = BuildProviderToken.Create("alpha-pat"),
- };
+ Owner owner = provider.CreateOwner(OwnerName.Create("alpha"));
+ owner.Token = BuildProviderToken.Create("alpha-pat");
+ return owner;
+ }
///
/// The workflow actions themselves: a refused request must come back as a failure, not as the
@@ -151,7 +180,7 @@ public async Task AWorkflowActionRefusedByTheApiReportsFailure()
new Dictionary { ["X-RateLimit-Remaining"] = "0" });
bool succeeded = await provider.RunWorkflowActionAsync(
- OwnerWithToken(), RequestName, () => Task.FromException(rateLimited)).ConfigureAwait(false);
+ OwnerWithToken(provider), RequestName, () => Task.FromException(rateLimited)).ConfigureAwait(false);
Assert.IsFalse(succeeded, "A cancel or re-run the API refused must not be reported as having worked.");
Assert.AreEqual(ProviderStatus.RateLimited, provider.Status);
@@ -163,7 +192,7 @@ public async Task AWorkflowActionThatSucceedsReportsSuccess()
GitHub provider = new();
bool succeeded = await provider.RunWorkflowActionAsync(
- OwnerWithToken(), RequestName, () => Task.CompletedTask).ConfigureAwait(false);
+ OwnerWithToken(provider), RequestName, () => Task.CompletedTask).ConfigureAwait(false);
Assert.IsTrue(succeeded);
}
@@ -178,7 +207,7 @@ public async Task AWorkflowActionOnAMissingRunReportsFailure()
NotFoundException missing = new(new FakeResponse(HttpStatusCode.NotFound, new Dictionary()));
bool succeeded = await provider.RunWorkflowActionAsync(
- OwnerWithToken(), RequestName, () => Task.FromException(missing)).ConfigureAwait(false);
+ OwnerWithToken(provider), RequestName, () => Task.FromException(missing)).ConfigureAwait(false);
Assert.IsFalse(succeeded);
}
@@ -190,7 +219,7 @@ public async Task AWorkflowActionWithoutCredentialsReportsFailureWithoutCalling(
bool called = false;
bool succeeded = await provider.RunWorkflowActionAsync(
- new Owner { Name = OwnerName.Create("beta") },
+ provider.CreateOwner(OwnerName.Create("beta")),
RequestName,
() =>
{
diff --git a/BuildMonitor.Test/TokenStorageTests.cs b/BuildMonitor.Test/TokenStorageTests.cs
index 1bf342a..9398d69 100644
--- a/BuildMonitor.Test/TokenStorageTests.cs
+++ b/BuildMonitor.Test/TokenStorageTests.cs
@@ -78,6 +78,25 @@ public void Save(PersonaGUID persona, Credential credential) =>
public bool Remove(PersonaGUID persona) => throw new DllNotFoundException("libsecret-1.so.0");
}
+ ///
+ /// The same missing library as , but in the form it
+ /// actually takes on Linux: the store first reaches libsecret from a static constructor, so the
+ /// failure arrives wrapped in a .
+ ///
+ private sealed class TypeInitializerFailureCredentialStore : ICredentialStore
+ {
+ public string Name => "TypeInitializerFailure";
+
+ public bool TryLoad(PersonaGUID persona, out Credential? credential) => throw Failure();
+
+ public void Save(PersonaGUID persona, Credential credential) => throw Failure();
+
+ public bool Remove(PersonaGUID persona) => throw Failure();
+
+ private static TypeInitializationException Failure() =>
+ new("ktsu.CredentialCache.Storage.LinuxSecretServiceCredentialStore+Schema", new DllNotFoundException("libsecret-1.so.0"));
+ }
+
///
/// Mirrors how writes the app data file: references preserved,
/// and semantic strings round-tripped as plain strings rather than as char arrays.
@@ -339,6 +358,24 @@ public void ReadingWithoutASecretStoreIsEmptyRatherThanFatal()
Assert.IsFalse(TokenStorage.Write(provider.TokenPersona, "ghp_x".As()));
}
+ ///
+ /// The Linux form of a missing secret library, wrapped in a type initializer failure, reads as
+ /// "no token" too, rather than escaping every token read.
+ ///
+ [TestMethod]
+ public void ReadingWhenTheSecretStoreTypeFailedToInitializeIsEmptyRatherThanFatal()
+ {
+ using CredentialCache unavailable = new(new TypeInitializerFailureCredentialStore());
+ TokenStorage.UseCache(unavailable);
+
+ TestProvider provider = NewProvider();
+ Owner owner = AddOwner(provider, "ktsu-dev");
+
+ Assert.IsTrue(provider.ReadToken().IsEmpty());
+ Assert.IsFalse(owner.HasToken);
+ Assert.IsFalse(TokenStorage.Write(provider.TokenPersona, "ghp_x".As()));
+ }
+
///
/// Two owners under one provider do not share a token.
///
diff --git a/BuildMonitor/TokenStorage.cs b/BuildMonitor/TokenStorage.cs
index f38363b..2b2ca9a 100644
--- a/BuildMonitor/TokenStorage.cs
+++ b/BuildMonitor/TokenStorage.cs
@@ -192,11 +192,18 @@ private static PersonaGUID DerivePersona(string seed)
/// Recognises a machine with no usable secret store: the factory refusing the platform, the
/// native library failing to resolve, or the store itself reporting a failure.
///
+ ///
+ /// A native library first reached from a static constructor surfaces as a
+ /// wrapping the real failure, and the runtime rethrows
+ /// that same exception on every later access. The Linux store reaches libsecret that way, so a
+ /// host without it would otherwise throw out of every token read instead of reading as empty.
+ ///
private static bool IsUnavailable(Exception exception) =>
exception is PlatformNotSupportedException
or DllNotFoundException
or EntryPointNotFoundException
- or CredentialStoreException;
+ or CredentialStoreException
+ || (exception is TypeInitializationException { InnerException: { } inner } && IsUnavailable(inner));
///
/// Logs the unavailable store once per process. Tokens are read on request paths that run every