Initial commit (history squashed)

This commit is contained in:
master
2025-10-07 10:14:21 +03:00
commit 016c5a3fe7
1132 changed files with 117842 additions and 0 deletions

View File

@@ -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);
}
}

View File

@@ -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>

View File

@@ -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);
}
}

View File

@@ -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)!;
}
}

View File

@@ -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()
{
}
}
}
}

View File

@@ -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);
}
}
}
}