save progress
This commit is contained in:
@@ -0,0 +1,210 @@
|
||||
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.ServerIntegration;
|
||||
using Xunit;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Auth.ServerIntegration.Tests;
|
||||
|
||||
public class StellaOpsAuthorityConfigurationManagerTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetConfigurationAsync_UsesCacheUntilExpiry()
|
||||
{
|
||||
var now = DateTimeOffset.Parse("2025-01-01T00:00:00Z");
|
||||
var timeProvider = new FakeTimeProvider(now);
|
||||
var handler = new RecordingHandler();
|
||||
handler.EnqueueMetadataResponse(CreateJsonResponse(DiscoveryDocument));
|
||||
handler.EnqueueJwksResponse(CreateJsonResponse("{\"keys\":[]}"));
|
||||
|
||||
var options = CreateOptions("https://authority.test");
|
||||
var optionsMonitor = new MutableOptionsMonitor<StellaOpsResourceServerOptions>(options);
|
||||
var manager = new StellaOpsAuthorityConfigurationManager(
|
||||
new TestHttpClientFactory(new HttpClient(handler)),
|
||||
optionsMonitor,
|
||||
timeProvider,
|
||||
NullLogger<StellaOpsAuthorityConfigurationManager>.Instance);
|
||||
|
||||
var first = await manager.GetConfigurationAsync(CancellationToken.None);
|
||||
var second = await manager.GetConfigurationAsync(CancellationToken.None);
|
||||
|
||||
Assert.Same(first, second);
|
||||
Assert.Equal(1, handler.MetadataRequests);
|
||||
Assert.Equal(1, handler.JwksRequests);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetConfigurationAsync_UsesOfflineFallbackWhenRefreshFails()
|
||||
{
|
||||
var now = DateTimeOffset.Parse("2025-01-01T00:00:00Z");
|
||||
var timeProvider = new FakeTimeProvider(now);
|
||||
var handler = new RecordingHandler();
|
||||
handler.EnqueueMetadataResponse(CreateJsonResponse(DiscoveryDocument));
|
||||
handler.EnqueueJwksResponse(CreateJsonResponse("{\"keys\":[]}"));
|
||||
handler.EnqueueMetadataResponse(_ => throw new HttpRequestException("offline"));
|
||||
|
||||
var options = CreateOptions("https://authority.test");
|
||||
options.MetadataCacheLifetime = TimeSpan.FromMinutes(1);
|
||||
options.OfflineCacheTolerance = TimeSpan.FromMinutes(5);
|
||||
options.Validate();
|
||||
|
||||
var optionsMonitor = new MutableOptionsMonitor<StellaOpsResourceServerOptions>(options);
|
||||
var manager = new StellaOpsAuthorityConfigurationManager(
|
||||
new TestHttpClientFactory(new HttpClient(handler)),
|
||||
optionsMonitor,
|
||||
timeProvider,
|
||||
NullLogger<StellaOpsAuthorityConfigurationManager>.Instance);
|
||||
|
||||
var first = await manager.GetConfigurationAsync(CancellationToken.None);
|
||||
|
||||
timeProvider.Advance(TimeSpan.FromMinutes(2));
|
||||
|
||||
var second = await manager.GetConfigurationAsync(CancellationToken.None);
|
||||
|
||||
Assert.Same(first, second);
|
||||
Assert.Equal(2, handler.MetadataRequests);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetConfigurationAsync_RefreshesWhenAuthorityChanges()
|
||||
{
|
||||
var now = DateTimeOffset.Parse("2025-01-01T00:00:00Z");
|
||||
var timeProvider = new FakeTimeProvider(now);
|
||||
var handler = new RecordingHandler();
|
||||
handler.EnqueueMetadataResponse(CreateJsonResponse(DiscoveryDocument));
|
||||
handler.EnqueueJwksResponse(CreateJsonResponse("{\"keys\":[]}"));
|
||||
handler.EnqueueMetadataResponse(CreateJsonResponse(DiscoveryDocument));
|
||||
handler.EnqueueJwksResponse(CreateJsonResponse("{\"keys\":[]}"));
|
||||
|
||||
var options = CreateOptions("https://authority.test");
|
||||
var optionsMonitor = new MutableOptionsMonitor<StellaOpsResourceServerOptions>(options);
|
||||
var manager = new StellaOpsAuthorityConfigurationManager(
|
||||
new TestHttpClientFactory(new HttpClient(handler)),
|
||||
optionsMonitor,
|
||||
timeProvider,
|
||||
NullLogger<StellaOpsAuthorityConfigurationManager>.Instance);
|
||||
|
||||
await manager.GetConfigurationAsync(CancellationToken.None);
|
||||
|
||||
var updated = CreateOptions("https://authority2.test");
|
||||
optionsMonitor.Set(updated);
|
||||
|
||||
await manager.GetConfigurationAsync(CancellationToken.None);
|
||||
|
||||
Assert.Equal(2, handler.MetadataRequests);
|
||||
}
|
||||
|
||||
private static StellaOpsResourceServerOptions CreateOptions(string authority)
|
||||
{
|
||||
var options = new StellaOpsResourceServerOptions
|
||||
{
|
||||
Authority = authority,
|
||||
MetadataCacheLifetime = TimeSpan.FromMinutes(5),
|
||||
OfflineCacheTolerance = TimeSpan.FromMinutes(10),
|
||||
AllowOfflineCacheFallback = true
|
||||
};
|
||||
options.Validate();
|
||||
return options;
|
||||
}
|
||||
|
||||
private static HttpResponseMessage CreateJsonResponse(string json)
|
||||
{
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(json)
|
||||
{
|
||||
Headers = { ContentType = new MediaTypeHeaderValue("application/json") }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class RecordingHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly Queue<Func<HttpRequestMessage, HttpResponseMessage>> metadataResponses = new();
|
||||
private readonly Queue<Func<HttpRequestMessage, HttpResponseMessage>> jwksResponses = new();
|
||||
|
||||
public int MetadataRequests { get; private set; }
|
||||
public int JwksRequests { get; private set; }
|
||||
|
||||
public void EnqueueMetadataResponse(HttpResponseMessage response)
|
||||
=> metadataResponses.Enqueue(_ => response);
|
||||
|
||||
public void EnqueueMetadataResponse(Func<HttpRequestMessage, HttpResponseMessage> factory)
|
||||
=> metadataResponses.Enqueue(factory);
|
||||
|
||||
public void EnqueueJwksResponse(HttpResponseMessage response)
|
||||
=> jwksResponses.Enqueue(_ => response);
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
var uri = request.RequestUri?.AbsoluteUri ?? string.Empty;
|
||||
|
||||
if (uri.Contains("openid-configuration", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
MetadataRequests++;
|
||||
return Task.FromResult(metadataResponses.Dequeue().Invoke(request));
|
||||
}
|
||||
|
||||
if (uri.Contains("jwks", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
JwksRequests++;
|
||||
return Task.FromResult(jwksResponses.Dequeue().Invoke(request));
|
||||
}
|
||||
|
||||
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound));
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TestHttpClientFactory : IHttpClientFactory
|
||||
{
|
||||
private readonly HttpClient client;
|
||||
|
||||
public TestHttpClientFactory(HttpClient client)
|
||||
{
|
||||
this.client = client;
|
||||
}
|
||||
|
||||
public HttpClient CreateClient(string name) => client;
|
||||
}
|
||||
|
||||
private sealed class MutableOptionsMonitor<T> : IOptionsMonitor<T>
|
||||
where T : class
|
||||
{
|
||||
private T value;
|
||||
|
||||
public MutableOptionsMonitor(T value)
|
||||
{
|
||||
this.value = value;
|
||||
}
|
||||
|
||||
public T CurrentValue => value;
|
||||
|
||||
public T Get(string? name) => value;
|
||||
|
||||
public IDisposable OnChange(Action<T, string> listener) => NullDisposable.Instance;
|
||||
|
||||
public void Set(T newValue) => value = newValue;
|
||||
|
||||
private sealed class NullDisposable : IDisposable
|
||||
{
|
||||
public static NullDisposable Instance { get; } = new();
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const string DiscoveryDocument =
|
||||
"{\"issuer\":\"https://authority.test\",\"authorization_endpoint\":\"https://authority.test/connect/authorize\",\"token_endpoint\":\"https://authority.test/connect/token\",\"jwks_uri\":\"https://authority.test/jwks\"}";
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Net;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Auth.ServerIntegration;
|
||||
using Xunit;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Auth.ServerIntegration.Tests;
|
||||
|
||||
public class StellaOpsBypassEvaluatorTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ShouldBypass_ReturnsFalse_WhenRemoteIpMissing()
|
||||
{
|
||||
var optionsMonitor = CreateOptionsMonitor(options =>
|
||||
{
|
||||
options.Authority = "https://authority.test";
|
||||
options.BypassNetworks.Add("127.0.0.1/32");
|
||||
options.Validate();
|
||||
});
|
||||
|
||||
var evaluator = new StellaOpsBypassEvaluator(optionsMonitor, NullLogger<StellaOpsBypassEvaluator>.Instance);
|
||||
var context = new DefaultHttpContext();
|
||||
|
||||
var result = evaluator.ShouldBypass(context, new List<string> { "scope" });
|
||||
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ShouldBypass_ReturnsFalse_WhenAuthorizationHeaderPresent()
|
||||
{
|
||||
var optionsMonitor = CreateOptionsMonitor(options =>
|
||||
{
|
||||
options.Authority = "https://authority.test";
|
||||
options.BypassNetworks.Add("127.0.0.1/32");
|
||||
options.Validate();
|
||||
});
|
||||
|
||||
var evaluator = new StellaOpsBypassEvaluator(optionsMonitor, NullLogger<StellaOpsBypassEvaluator>.Instance);
|
||||
var context = new DefaultHttpContext();
|
||||
context.Connection.RemoteIpAddress = IPAddress.Parse("127.0.0.1");
|
||||
context.Request.Headers["Authorization"] = "Bearer token";
|
||||
|
||||
var result = evaluator.ShouldBypass(context, new List<string> { "scope" });
|
||||
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
private static IOptionsMonitor<StellaOpsResourceServerOptions> CreateOptionsMonitor(Action<StellaOpsResourceServerOptions> configure)
|
||||
=> new TestOptionsMonitor<StellaOpsResourceServerOptions>(configure);
|
||||
|
||||
private sealed class TestOptionsMonitor<TOptions> : IOptionsMonitor<TOptions>
|
||||
where TOptions : class, new()
|
||||
{
|
||||
private readonly TOptions value;
|
||||
|
||||
public TestOptionsMonitor(Action<TOptions> configure)
|
||||
{
|
||||
value = new TOptions();
|
||||
configure(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()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -55,4 +55,19 @@ public class StellaOpsResourceServerOptionsTests
|
||||
|
||||
Assert.Contains("Authority", exception.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Validate_Throws_When_OfflineCacheToleranceInvalid()
|
||||
{
|
||||
var options = new StellaOpsResourceServerOptions
|
||||
{
|
||||
Authority = "https://authority.stella-ops.test",
|
||||
OfflineCacheTolerance = TimeSpan.FromDays(2)
|
||||
};
|
||||
|
||||
var exception = Assert.Throws<InvalidOperationException>(() => options.Validate());
|
||||
|
||||
Assert.Contains("offline cache tolerance", exception.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,6 +54,56 @@ public class StellaOpsScopeAuthorizationHandlerTests
|
||||
Assert.False(string.IsNullOrWhiteSpace(record.CorrelationId));
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task HandleRequirement_Succeeds_WhenScopeItemIsNormalized()
|
||||
{
|
||||
var optionsMonitor = CreateOptionsMonitor(options =>
|
||||
{
|
||||
options.Authority = "https://authority.example";
|
||||
options.Validate();
|
||||
});
|
||||
|
||||
var (handler, accessor, sink) = CreateHandler(optionsMonitor, remoteAddress: IPAddress.Parse("10.0.0.2"));
|
||||
var requirement = new StellaOpsScopeRequirement(new[] { StellaOpsScopes.PolicyRun });
|
||||
var principal = new StellaOpsPrincipalBuilder()
|
||||
.WithSubject("user-2")
|
||||
.AddClaim(StellaOpsClaimTypes.ScopeItem, " POLICY:RUN ")
|
||||
.Build();
|
||||
|
||||
var context = new AuthorizationHandlerContext(new[] { requirement }, principal, accessor.HttpContext);
|
||||
|
||||
await handler.HandleAsync(context);
|
||||
|
||||
Assert.True(context.HasSucceeded);
|
||||
Assert.Single(sink.Records);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task HandleRequirement_Succeeds_WhenScopeItemVulnReadMapsToVulnView()
|
||||
{
|
||||
var optionsMonitor = CreateOptionsMonitor(options =>
|
||||
{
|
||||
options.Authority = "https://authority.example";
|
||||
options.Validate();
|
||||
});
|
||||
|
||||
var (handler, accessor, sink) = CreateHandler(optionsMonitor, remoteAddress: IPAddress.Parse("10.0.0.3"));
|
||||
var requirement = new StellaOpsScopeRequirement(new[] { StellaOpsScopes.VulnView });
|
||||
var principal = new StellaOpsPrincipalBuilder()
|
||||
.WithSubject("user-3")
|
||||
.AddClaim(StellaOpsClaimTypes.ScopeItem, "VULN:READ")
|
||||
.Build();
|
||||
|
||||
var context = new AuthorizationHandlerContext(new[] { requirement }, principal, accessor.HttpContext);
|
||||
|
||||
await handler.HandleAsync(context);
|
||||
|
||||
Assert.True(context.HasSucceeded);
|
||||
Assert.Single(sink.Records);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task HandleRequirement_Fails_WhenTenantMismatch()
|
||||
|
||||
Reference in New Issue
Block a user