Initial commit (history squashed)
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Http;
|
||||
using StellaOps.Auth.Client;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Auth.Client.Tests;
|
||||
|
||||
public class ServiceCollectionExtensionsTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task AddStellaOpsAuthClient_ConfiguresRetryPolicy()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
|
||||
services.AddStellaOpsAuthClient(options =>
|
||||
{
|
||||
options.Authority = "https://authority.test";
|
||||
options.RetryDelays.Clear();
|
||||
options.RetryDelays.Add(TimeSpan.FromMilliseconds(1));
|
||||
options.DiscoveryCacheLifetime = TimeSpan.FromMinutes(1);
|
||||
options.JwksCacheLifetime = TimeSpan.FromMinutes(1);
|
||||
options.AllowOfflineCacheFallback = false;
|
||||
});
|
||||
|
||||
var recordedHandlers = new List<DelegatingHandler>();
|
||||
var attemptCount = 0;
|
||||
|
||||
services.AddHttpClient<StellaOpsDiscoveryCache>()
|
||||
.ConfigureHttpMessageHandlerBuilder(builder =>
|
||||
{
|
||||
recordedHandlers = new List<DelegatingHandler>(builder.AdditionalHandlers);
|
||||
|
||||
var responses = new Queue<Func<HttpResponseMessage>>(new[]
|
||||
{
|
||||
() => CreateResponse(HttpStatusCode.InternalServerError, "{}"),
|
||||
() => CreateResponse(HttpStatusCode.OK, "{\"token_endpoint\":\"https://authority.test/connect/token\",\"jwks_uri\":\"https://authority.test/jwks\"}")
|
||||
});
|
||||
|
||||
builder.PrimaryHandler = new LambdaHttpMessageHandler((_, _) =>
|
||||
{
|
||||
attemptCount++;
|
||||
|
||||
if (responses.Count == 0)
|
||||
{
|
||||
return Task.FromResult(CreateResponse(HttpStatusCode.OK, "{}"));
|
||||
}
|
||||
|
||||
var factory = responses.Dequeue();
|
||||
return Task.FromResult(factory());
|
||||
});
|
||||
});
|
||||
|
||||
using var provider = services.BuildServiceProvider();
|
||||
|
||||
var cache = provider.GetRequiredService<StellaOpsDiscoveryCache>();
|
||||
var configuration = await cache.GetAsync(CancellationToken.None);
|
||||
|
||||
Assert.Equal(new Uri("https://authority.test/connect/token"), configuration.TokenEndpoint);
|
||||
Assert.Equal(2, attemptCount);
|
||||
Assert.NotEmpty(recordedHandlers);
|
||||
Assert.Contains(recordedHandlers, handler => handler.GetType().Name.Contains("PolicyHttpMessageHandler", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
private static HttpResponseMessage CreateResponse(HttpStatusCode statusCode, string jsonContent)
|
||||
{
|
||||
return new HttpResponseMessage(statusCode)
|
||||
{
|
||||
Content = new StringContent(jsonContent)
|
||||
{
|
||||
Headers = { ContentType = new MediaTypeHeaderValue("application/json") }
|
||||
}
|
||||
};
|
||||
}
|
||||
private sealed class LambdaHttpMessageHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> responder;
|
||||
|
||||
public LambdaHttpMessageHandler(Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> responder)
|
||||
{
|
||||
this.responder = responder;
|
||||
}
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
=> responder(request, cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Auth.Client\StellaOps.Auth.Client.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Auth.Abstractions\StellaOps.Auth.Abstractions.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Http.Polly" Version="8.0.5" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="8.0.0" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,84 @@
|
||||
using System;
|
||||
using StellaOps.Auth.Client;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Auth.Client.Tests;
|
||||
|
||||
public class StellaOpsAuthClientOptionsTests
|
||||
{
|
||||
[Fact]
|
||||
public void Validate_NormalizesScopes()
|
||||
{
|
||||
var options = new StellaOpsAuthClientOptions
|
||||
{
|
||||
Authority = "https://authority.test",
|
||||
ClientId = "cli",
|
||||
HttpTimeout = TimeSpan.FromSeconds(15)
|
||||
};
|
||||
options.DefaultScopes.Add(" Feedser.Jobs.Trigger ");
|
||||
options.DefaultScopes.Add("feedser.jobs.trigger");
|
||||
options.DefaultScopes.Add("AUTHORITY.USERS.MANAGE");
|
||||
|
||||
options.Validate();
|
||||
|
||||
Assert.Equal(new[] { "authority.users.manage", "feedser.jobs.trigger" }, options.NormalizedScopes);
|
||||
Assert.Equal(new Uri("https://authority.test"), options.AuthorityUri);
|
||||
Assert.Equal<TimeSpan>(options.RetryDelays, options.NormalizedRetryDelays);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_Throws_When_AuthorityMissing()
|
||||
{
|
||||
var options = new StellaOpsAuthClientOptions();
|
||||
|
||||
var exception = Assert.Throws<InvalidOperationException>(() => options.Validate());
|
||||
|
||||
Assert.Contains("Authority", exception.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_NormalizesRetryDelays()
|
||||
{
|
||||
var options = new StellaOpsAuthClientOptions
|
||||
{
|
||||
Authority = "https://authority.test"
|
||||
};
|
||||
options.RetryDelays.Clear();
|
||||
options.RetryDelays.Add(TimeSpan.Zero);
|
||||
options.RetryDelays.Add(TimeSpan.FromSeconds(3));
|
||||
options.RetryDelays.Add(TimeSpan.FromMilliseconds(-1));
|
||||
|
||||
options.Validate();
|
||||
|
||||
Assert.Equal<TimeSpan>(new[] { TimeSpan.FromSeconds(3) }, options.NormalizedRetryDelays);
|
||||
Assert.Equal<TimeSpan>(options.NormalizedRetryDelays, options.RetryDelays);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_DisabledRetries_ProducesEmptyDelays()
|
||||
{
|
||||
var options = new StellaOpsAuthClientOptions
|
||||
{
|
||||
Authority = "https://authority.test",
|
||||
EnableRetries = false
|
||||
};
|
||||
|
||||
options.Validate();
|
||||
|
||||
Assert.Empty(options.NormalizedRetryDelays);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_Throws_When_OfflineToleranceNegative()
|
||||
{
|
||||
var options = new StellaOpsAuthClientOptions
|
||||
{
|
||||
Authority = "https://authority.test",
|
||||
OfflineCacheTolerance = TimeSpan.FromSeconds(-1)
|
||||
};
|
||||
|
||||
var exception = Assert.Throws<InvalidOperationException>(() => options.Validate());
|
||||
|
||||
Assert.Contains("Offline cache tolerance", exception.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
using System;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Auth.Client;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Auth.Client.Tests;
|
||||
|
||||
public class StellaOpsDiscoveryCacheTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task GetAsync_UsesOfflineFallbackWithinTolerance()
|
||||
{
|
||||
var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-01-01T00:00:00Z"));
|
||||
var callCount = 0;
|
||||
var handler = new StubHttpMessageHandler((request, _) =>
|
||||
{
|
||||
callCount++;
|
||||
|
||||
if (callCount == 1)
|
||||
{
|
||||
return Task.FromResult(CreateJsonResponse("{\"token_endpoint\":\"https://authority.test/connect/token\",\"jwks_uri\":\"https://authority.test/jwks\"}"));
|
||||
}
|
||||
|
||||
throw new HttpRequestException("offline");
|
||||
});
|
||||
|
||||
var httpClient = new HttpClient(handler);
|
||||
|
||||
var options = new StellaOpsAuthClientOptions
|
||||
{
|
||||
Authority = "https://authority.test",
|
||||
DiscoveryCacheLifetime = TimeSpan.FromMinutes(1),
|
||||
OfflineCacheTolerance = TimeSpan.FromMinutes(5),
|
||||
AllowOfflineCacheFallback = true
|
||||
};
|
||||
options.Validate();
|
||||
|
||||
var monitor = new TestOptionsMonitor<StellaOpsAuthClientOptions>(options);
|
||||
var cache = new StellaOpsDiscoveryCache(httpClient, monitor, timeProvider, NullLogger<StellaOpsDiscoveryCache>.Instance);
|
||||
|
||||
var configuration = await cache.GetAsync(CancellationToken.None);
|
||||
Assert.Equal(new Uri("https://authority.test/connect/token"), configuration.TokenEndpoint);
|
||||
|
||||
timeProvider.Advance(TimeSpan.FromMinutes(1) + TimeSpan.FromSeconds(5));
|
||||
|
||||
configuration = await cache.GetAsync(CancellationToken.None);
|
||||
Assert.Equal(new Uri("https://authority.test/connect/token"), configuration.TokenEndpoint);
|
||||
Assert.Equal(2, callCount);
|
||||
|
||||
var offlineExpiry = GetOfflineExpiry(cache);
|
||||
Assert.True(offlineExpiry > timeProvider.GetUtcNow());
|
||||
|
||||
timeProvider.Advance(options.OfflineCacheTolerance + TimeSpan.FromSeconds(1));
|
||||
|
||||
Assert.True(offlineExpiry < timeProvider.GetUtcNow());
|
||||
|
||||
HttpRequestException? exception = null;
|
||||
try
|
||||
{
|
||||
await cache.GetAsync(CancellationToken.None);
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
exception = ex;
|
||||
}
|
||||
|
||||
Assert.NotNull(exception);
|
||||
Assert.Equal(3, callCount);
|
||||
}
|
||||
|
||||
private static HttpResponseMessage CreateJsonResponse(string json)
|
||||
{
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(json)
|
||||
{
|
||||
Headers = { ContentType = new MediaTypeHeaderValue("application/json") }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class StubHttpMessageHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> responder;
|
||||
|
||||
public StubHttpMessageHandler(Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> responder)
|
||||
{
|
||||
this.responder = responder;
|
||||
}
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
=> responder(request, cancellationToken);
|
||||
}
|
||||
|
||||
private sealed class TestOptionsMonitor<T> : IOptionsMonitor<T>
|
||||
where T : class
|
||||
{
|
||||
private readonly T value;
|
||||
|
||||
public TestOptionsMonitor(T value)
|
||||
{
|
||||
this.value = value;
|
||||
}
|
||||
|
||||
public T CurrentValue => value;
|
||||
|
||||
public T Get(string? name) => value;
|
||||
|
||||
public IDisposable OnChange(Action<T, string> listener) => NullDisposable.Instance;
|
||||
|
||||
private sealed class NullDisposable : IDisposable
|
||||
{
|
||||
public static NullDisposable Instance { get; } = new();
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static DateTimeOffset GetOfflineExpiry(StellaOpsDiscoveryCache cache)
|
||||
{
|
||||
var field = typeof(StellaOpsDiscoveryCache).GetField("offlineExpiresAt", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
|
||||
Assert.NotNull(field);
|
||||
return (DateTimeOffset)field!.GetValue(cache)!;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Auth.Client;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Auth.Client.Tests;
|
||||
|
||||
public class StellaOpsTokenClientTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task RequestPasswordToken_ReturnsResultAndCaches()
|
||||
{
|
||||
var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-02-01T00:00:00Z"));
|
||||
var responses = new Queue<HttpResponseMessage>();
|
||||
responses.Enqueue(CreateJsonResponse("{\"token_endpoint\":\"https://authority.test/connect/token\",\"jwks_uri\":\"https://authority.test/jwks\"}"));
|
||||
responses.Enqueue(CreateJsonResponse("{\"access_token\":\"abc\",\"token_type\":\"Bearer\",\"expires_in\":120,\"scope\":\"feedser.jobs.trigger\"}"));
|
||||
responses.Enqueue(CreateJsonResponse("{\"keys\":[]}"));
|
||||
|
||||
var handler = new StubHttpMessageHandler((request, cancellationToken) =>
|
||||
{
|
||||
Assert.True(responses.Count > 0, $"Unexpected request {request.Method} {request.RequestUri}");
|
||||
return Task.FromResult(responses.Dequeue());
|
||||
});
|
||||
|
||||
var httpClient = new HttpClient(handler);
|
||||
|
||||
var options = new StellaOpsAuthClientOptions
|
||||
{
|
||||
Authority = "https://authority.test",
|
||||
ClientId = "cli"
|
||||
};
|
||||
options.DefaultScopes.Add("feedser.jobs.trigger");
|
||||
options.Validate();
|
||||
|
||||
var optionsMonitor = new TestOptionsMonitor<StellaOpsAuthClientOptions>(options);
|
||||
var cache = new InMemoryTokenCache(timeProvider, TimeSpan.FromSeconds(5));
|
||||
var discoveryCache = new StellaOpsDiscoveryCache(httpClient, optionsMonitor, timeProvider);
|
||||
var jwksCache = new StellaOpsJwksCache(httpClient, discoveryCache, optionsMonitor, timeProvider);
|
||||
var client = new StellaOpsTokenClient(httpClient, discoveryCache, jwksCache, optionsMonitor, cache, timeProvider, NullLogger<StellaOpsTokenClient>.Instance);
|
||||
|
||||
var result = await client.RequestPasswordTokenAsync("user", "pass");
|
||||
|
||||
Assert.Equal("abc", result.AccessToken);
|
||||
Assert.Contains("feedser.jobs.trigger", result.Scopes);
|
||||
|
||||
await client.CacheTokenAsync("key", result.ToCacheEntry());
|
||||
var cached = await client.GetCachedTokenAsync("key");
|
||||
Assert.NotNull(cached);
|
||||
Assert.Equal("abc", cached!.AccessToken);
|
||||
|
||||
var jwks = await client.GetJsonWebKeySetAsync();
|
||||
Assert.Empty(jwks.Keys);
|
||||
}
|
||||
|
||||
private static HttpResponseMessage CreateJsonResponse(string json)
|
||||
{
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(json)
|
||||
{
|
||||
Headers = { ContentType = new MediaTypeHeaderValue("application/json") }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class StubHttpMessageHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> responder;
|
||||
|
||||
public StubHttpMessageHandler(Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> responder)
|
||||
{
|
||||
this.responder = responder;
|
||||
}
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
=> responder(request, cancellationToken);
|
||||
}
|
||||
|
||||
private sealed class TestOptionsMonitor<TOptions> : IOptionsMonitor<TOptions>
|
||||
where TOptions : class
|
||||
{
|
||||
private readonly TOptions value;
|
||||
|
||||
public TestOptionsMonitor(TOptions value)
|
||||
{
|
||||
this.value = value;
|
||||
}
|
||||
|
||||
public TOptions CurrentValue => value;
|
||||
|
||||
public TOptions Get(string? name) => value;
|
||||
|
||||
public IDisposable OnChange(Action<TOptions, string> listener) => NullDisposable.Instance;
|
||||
|
||||
private sealed class NullDisposable : IDisposable
|
||||
{
|
||||
public static NullDisposable Instance { get; } = new();
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Net;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Auth.Client;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Auth.Client.Tests;
|
||||
|
||||
public class TokenCacheTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task InMemoryTokenCache_ExpiresEntries()
|
||||
{
|
||||
var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-01-01T00:00:00Z"));
|
||||
var cache = new InMemoryTokenCache(timeProvider, TimeSpan.FromSeconds(5));
|
||||
|
||||
var entry = new StellaOpsTokenCacheEntry("token", "Bearer", timeProvider.GetUtcNow() + TimeSpan.FromSeconds(10), new[] { "scope" });
|
||||
await cache.SetAsync("key", entry);
|
||||
|
||||
var retrieved = await cache.GetAsync("key");
|
||||
Assert.NotNull(retrieved);
|
||||
|
||||
timeProvider.Advance(TimeSpan.FromSeconds(12));
|
||||
|
||||
retrieved = await cache.GetAsync("key");
|
||||
Assert.Null(retrieved);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FileTokenCache_PersistsEntries()
|
||||
{
|
||||
var directory = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
|
||||
try
|
||||
{
|
||||
var timeProvider = new FakeTimeProvider(DateTimeOffset.UtcNow);
|
||||
var cache = new FileTokenCache(directory, timeProvider, TimeSpan.Zero);
|
||||
|
||||
var entry = new StellaOpsTokenCacheEntry("token", "Bearer", timeProvider.GetUtcNow() + TimeSpan.FromMinutes(5), new[] { "scope" });
|
||||
await cache.SetAsync("key", entry);
|
||||
|
||||
var retrieved = await cache.GetAsync("key");
|
||||
Assert.NotNull(retrieved);
|
||||
Assert.Equal("token", retrieved!.AccessToken);
|
||||
|
||||
await cache.RemoveAsync("key");
|
||||
retrieved = await cache.GetAsync("key");
|
||||
Assert.Null(retrieved);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (Directory.Exists(directory))
|
||||
{
|
||||
Directory.Delete(directory, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user