Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 37 additions & 8 deletions BuildMonitor.Test/GitHubRequestOutcomeTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/// <summary>
/// Tests whether one GitHub API request reports having succeeded.
/// </summary>
Expand All @@ -32,6 +36,26 @@ public sealed class GitHubRequestOutcomeTests
{
private const string RequestName = "test/request";

private CredentialCache Cache { get; set; } = null!;

/// <summary>
/// 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.
/// </summary>
[TestInitialize]
public void SetUp()
{
Cache = new CredentialCache(new InMemoryCredentialStore());
TokenStorage.UseCache(Cache);
}

[TestCleanup]
public void TearDown()
{
TokenStorage.UseCache(null);
Cache.Dispose();
}

/// <summary>
/// A response built to order. Octokit's own <c>Response</c> is internal, and the provider reads
/// only the status code and the headers off it.
Expand Down Expand Up @@ -127,11 +151,16 @@ public async Task AConnectionErrorReportsFailure()
Assert.AreEqual(ProviderStatus.Error, provider.Status);
}

private static Owner OwnerWithToken() => new()
/// <summary>
/// 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.
/// </summary>
private static Owner OwnerWithToken(GitHub provider)
{
Name = OwnerName.Create<OwnerName>("alpha"),
Token = BuildProviderToken.Create<BuildProviderToken>("alpha-pat"),
};
Owner owner = provider.CreateOwner(OwnerName.Create<OwnerName>("alpha"));
owner.Token = BuildProviderToken.Create<BuildProviderToken>("alpha-pat");
return owner;
}

/// <summary>
/// The workflow actions themselves: a refused request must come back as a failure, not as the
Expand All @@ -151,7 +180,7 @@ public async Task AWorkflowActionRefusedByTheApiReportsFailure()
new Dictionary<string, string> { ["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);
Expand All @@ -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);
}
Expand All @@ -178,7 +207,7 @@ public async Task AWorkflowActionOnAMissingRunReportsFailure()
NotFoundException missing = new(new FakeResponse(HttpStatusCode.NotFound, new Dictionary<string, string>()));

bool succeeded = await provider.RunWorkflowActionAsync(
OwnerWithToken(), RequestName, () => Task.FromException(missing)).ConfigureAwait(false);
OwnerWithToken(provider), RequestName, () => Task.FromException(missing)).ConfigureAwait(false);

Assert.IsFalse(succeeded);
}
Expand All @@ -190,7 +219,7 @@ public async Task AWorkflowActionWithoutCredentialsReportsFailureWithoutCalling(
bool called = false;

bool succeeded = await provider.RunWorkflowActionAsync(
new Owner { Name = OwnerName.Create<OwnerName>("beta") },
provider.CreateOwner(OwnerName.Create<OwnerName>("beta")),
RequestName,
() =>
{
Expand Down
37 changes: 37 additions & 0 deletions BuildMonitor.Test/TokenStorageTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,25 @@ public void Save(PersonaGUID persona, Credential credential) =>
public bool Remove(PersonaGUID persona) => throw new DllNotFoundException("libsecret-1.so.0");
}

/// <summary>
/// The same missing library as <see cref="UnavailableCredentialStore"/>, 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 <see cref="TypeInitializationException"/>.
/// </summary>
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"));
}

/// <summary>
/// Mirrors how <see cref="ktsu.AppDataStorage"/> writes the app data file: references preserved,
/// and semantic strings round-tripped as plain strings rather than as char arrays.
Expand Down Expand Up @@ -339,6 +358,24 @@ public void ReadingWithoutASecretStoreIsEmptyRatherThanFatal()
Assert.IsFalse(TokenStorage.Write(provider.TokenPersona, "ghp_x".As<BuildProviderToken>()));
}

/// <summary>
/// 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.
/// </summary>
[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<BuildProviderToken>()));
}

/// <summary>
/// Two owners under one provider do not share a token.
/// </summary>
Expand Down
9 changes: 8 additions & 1 deletion BuildMonitor/TokenStorage.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
/// </summary>
/// <remarks>
/// A native library first reached from a static constructor surfaces as a
/// <see cref="TypeInitializationException"/> 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.
/// </remarks>
private static bool IsUnavailable(Exception exception) =>
exception is PlatformNotSupportedException
or DllNotFoundException
or EntryPointNotFoundException
or CredentialStoreException;
or CredentialStoreException
|| (exception is TypeInitializationException { InnerException: { } inner } && IsUnavailable(inner));

/// <summary>
/// Logs the unavailable store once per process. Tokens are read on request paths that run every
Expand Down
Loading