Restructure solution layout by module

This commit is contained in:
master
2025-10-28 15:10:40 +02:00
parent 95daa159c4
commit d870da18ce
4103 changed files with 192899 additions and 187024 deletions

View File

@@ -0,0 +1,548 @@
using System.Diagnostics.Metrics;
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.IdentityModel.JsonWebTokens;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
using Microsoft.IdentityModel.Tokens;
using Polly.Utilities;
using StellaOps.Auth.Client;
using StellaOps.Auth.Abstractions;
using StellaOps.Policy.Gateway.Clients;
using StellaOps.Policy.Gateway.Contracts;
using StellaOps.Policy.Gateway.Options;
using StellaOps.Policy.Gateway.Services;
using Xunit;
using Xunit.Sdk;
namespace StellaOps.Policy.Gateway.Tests;
public sealed class GatewayActivationTests
{
[Fact]
public async Task ActivateRevision_UsesServiceTokenFallback_And_RecordsMetrics()
{
await using var factory = new PolicyGatewayWebApplicationFactory();
var tokenClient = factory.Services.GetRequiredService<StubTokenClient>();
tokenClient.Reset();
var recordingHandler = factory.Services.GetRequiredService<RecordingPolicyEngineHandler>();
recordingHandler.Reset();
using var listener = new MeterListener();
var activationMeasurements = new List<(long Value, string Outcome, string Source)>();
var latencyMeasurements = new List<(double Value, string Outcome, string Source)>();
listener.InstrumentPublished += (instrument, meterListener) =>
{
if (instrument.Meter.Name != "StellaOps.Policy.Gateway")
{
return;
}
meterListener.EnableMeasurementEvents(instrument);
};
listener.SetMeasurementEventCallback<long>((instrument, value, tags, _) =>
{
if (instrument.Name != "policy_gateway_activation_requests_total")
{
return;
}
activationMeasurements.Add((value, GetTag(tags, "outcome"), GetTag(tags, "source")));
});
listener.SetMeasurementEventCallback<double>((instrument, value, tags, _) =>
{
if (instrument.Name != "policy_gateway_activation_latency_ms")
{
return;
}
latencyMeasurements.Add((value, GetTag(tags, "outcome"), GetTag(tags, "source")));
});
listener.Start();
using var client = factory.CreateClient();
var response = await client.PostAsJsonAsync(
"/api/policy/packs/example/revisions/5:activate",
new ActivatePolicyRevisionRequest("rollout window start"));
listener.Dispose();
var forwardedRequest = recordingHandler.LastRequest;
var issuedTokens = tokenClient.RequestCount;
var responseBody = await response.Content.ReadAsStringAsync();
if (!response.IsSuccessStatusCode)
{
throw new Xunit.Sdk.XunitException(
$"Gateway response was {(int)response.StatusCode} {response.StatusCode}. " +
$"Body: {responseBody}. IssuedTokens: {issuedTokens}. Forwarded: { (forwardedRequest is null ? "no" : "yes") }.");
}
Assert.Equal(1, tokenClient.RequestCount);
Assert.NotNull(forwardedRequest);
Assert.Equal(HttpMethod.Post, forwardedRequest!.Method);
Assert.Equal("https://policy-engine.test/api/policy/packs/example/revisions/5:activate", forwardedRequest.RequestUri!.ToString());
Assert.Equal("Bearer", forwardedRequest.Headers.Authorization?.Scheme);
Assert.Equal("service-token", forwardedRequest.Headers.Authorization?.Parameter);
Assert.False(forwardedRequest.Headers.TryGetValues("DPoP", out _), "Expected no DPoP header when DPoP is disabled.");
Assert.Contains(activationMeasurements, measurement =>
measurement.Value == 1 &&
measurement.Outcome == "activated" &&
measurement.Source == "service");
Assert.Contains(latencyMeasurements, measurement =>
measurement.Outcome == "activated" &&
measurement.Source == "service");
}
[Fact]
public async Task ActivateRevision_RecordsMetrics_WhenUpstreamReturnsUnauthorized()
{
await using var factory = new PolicyGatewayWebApplicationFactory();
var tokenClient = factory.Services.GetRequiredService<StubTokenClient>();
tokenClient.Reset();
var recordingHandler = factory.Services.GetRequiredService<RecordingPolicyEngineHandler>();
recordingHandler.Reset();
recordingHandler.SetResponseFactory(_ =>
{
var problem = new ProblemDetails
{
Title = "Unauthorized",
Detail = "Caller token rejected.",
Status = StatusCodes.Status401Unauthorized
};
return new HttpResponseMessage(HttpStatusCode.Unauthorized)
{
Content = JsonContent.Create(problem)
};
});
using var listener = new MeterListener();
var activationMeasurements = new List<(long Value, string Outcome, string Source)>();
var latencyMeasurements = new List<(double Value, string Outcome, string Source)>();
listener.InstrumentPublished += (instrument, meterListener) =>
{
if (instrument.Meter.Name != "StellaOps.Policy.Gateway")
{
return;
}
meterListener.EnableMeasurementEvents(instrument);
};
listener.SetMeasurementEventCallback<long>((instrument, value, tags, _) =>
{
if (instrument.Name != "policy_gateway_activation_requests_total")
{
return;
}
activationMeasurements.Add((value, GetTag(tags, "outcome"), GetTag(tags, "source")));
});
listener.SetMeasurementEventCallback<double>((instrument, value, tags, _) =>
{
if (instrument.Name != "policy_gateway_activation_latency_ms")
{
return;
}
latencyMeasurements.Add((value, GetTag(tags, "outcome"), GetTag(tags, "source")));
});
listener.Start();
using var client = factory.CreateClient();
var response = await client.PostAsJsonAsync(
"/api/policy/packs/example/revisions/2:activate",
new ActivatePolicyRevisionRequest("failure path"));
listener.Dispose();
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
Assert.Equal(1, tokenClient.RequestCount);
var forwardedRequest = recordingHandler.LastRequest;
Assert.NotNull(forwardedRequest);
Assert.Equal("service-token", forwardedRequest!.Headers.Authorization?.Parameter);
Assert.Contains(activationMeasurements, measurement =>
measurement.Value == 1 &&
measurement.Outcome == "unauthorized" &&
measurement.Source == "service");
Assert.Contains(latencyMeasurements, measurement =>
measurement.Outcome == "unauthorized" &&
measurement.Source == "service");
}
[Fact]
public async Task ActivateRevision_RecordsMetrics_WhenUpstreamReturnsBadGateway()
{
await using var factory = new PolicyGatewayWebApplicationFactory();
var tokenClient = factory.Services.GetRequiredService<StubTokenClient>();
tokenClient.Reset();
var recordingHandler = factory.Services.GetRequiredService<RecordingPolicyEngineHandler>();
recordingHandler.Reset();
recordingHandler.SetResponseFactory(_ =>
{
var problem = new ProblemDetails
{
Title = "Upstream error",
Detail = "Policy Engine returned 502.",
Status = StatusCodes.Status502BadGateway
};
return new HttpResponseMessage(HttpStatusCode.BadGateway)
{
Content = JsonContent.Create(problem)
};
});
using var listener = new MeterListener();
var activationMeasurements = new List<(long Value, string Outcome, string Source)>();
var latencyMeasurements = new List<(double Value, string Outcome, string Source)>();
listener.InstrumentPublished += (instrument, meterListener) =>
{
if (instrument.Meter.Name != "StellaOps.Policy.Gateway")
{
return;
}
meterListener.EnableMeasurementEvents(instrument);
};
listener.SetMeasurementEventCallback<long>((instrument, value, tags, _) =>
{
if (instrument.Name != "policy_gateway_activation_requests_total")
{
return;
}
activationMeasurements.Add((value, GetTag(tags, "outcome"), GetTag(tags, "source")));
});
listener.SetMeasurementEventCallback<double>((instrument, value, tags, _) =>
{
if (instrument.Name != "policy_gateway_activation_latency_ms")
{
return;
}
latencyMeasurements.Add((value, GetTag(tags, "outcome"), GetTag(tags, "source")));
});
listener.Start();
using var client = factory.CreateClient();
var response = await client.PostAsJsonAsync(
"/api/policy/packs/example/revisions/3:activate",
new ActivatePolicyRevisionRequest("upstream failure"));
listener.Dispose();
Assert.Equal(HttpStatusCode.BadGateway, response.StatusCode);
Assert.Equal(1, tokenClient.RequestCount);
var forwardedRequest = recordingHandler.LastRequest;
Assert.NotNull(forwardedRequest);
Assert.Equal("service-token", forwardedRequest!.Headers.Authorization?.Parameter);
Assert.Contains(activationMeasurements, measurement =>
measurement.Value == 1 &&
measurement.Outcome == "error" &&
measurement.Source == "service");
Assert.Contains(latencyMeasurements, measurement =>
measurement.Outcome == "error" &&
measurement.Source == "service");
}
[Fact]
public async Task ActivateRevision_RetriesOnTooManyRequests()
{
await using var factory = new PolicyGatewayWebApplicationFactory();
var recordedDelays = new List<TimeSpan>();
var originalSleep = SystemClock.SleepAsync;
SystemClock.SleepAsync = (delay, cancellationToken) =>
{
recordedDelays.Add(delay);
return Task.CompletedTask;
};
var tokenClient = factory.Services.GetRequiredService<StubTokenClient>();
tokenClient.Reset();
var recordingHandler = factory.Services.GetRequiredService<RecordingPolicyEngineHandler>();
recordingHandler.Reset();
recordingHandler.SetResponseSequence(new[]
{
CreateThrottleResponse(),
CreateThrottleResponse(),
RecordingPolicyEngineHandler.CreateSuccessResponse()
});
using var client = factory.CreateClient();
try
{
var response = await client.PostAsJsonAsync(
"/api/policy/packs/example/revisions/7:activate",
new ActivatePolicyRevisionRequest("retry after throttle"));
Assert.True(response.IsSuccessStatusCode, "Gateway should succeed after retrying throttled upstream responses.");
Assert.Equal(1, tokenClient.RequestCount);
Assert.Equal(3, recordingHandler.RequestCount);
}
finally
{
SystemClock.SleepAsync = originalSleep;
}
Assert.Equal(new[] { TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(4) }, recordedDelays);
}
private static HttpResponseMessage CreateThrottleResponse()
{
var problem = new ProblemDetails
{
Title = "Too many requests",
Detail = "Slow down.",
Status = StatusCodes.Status429TooManyRequests
};
var response = new HttpResponseMessage((HttpStatusCode)StatusCodes.Status429TooManyRequests)
{
Content = JsonContent.Create(problem)
};
response.Headers.RetryAfter = new RetryConditionHeaderValue(TimeSpan.FromMilliseconds(10));
return response;
}
private static string GetTag(ReadOnlySpan<KeyValuePair<string, object?>> tags, string key)
{
foreach (var tag in tags)
{
if (string.Equals(tag.Key, key, StringComparison.Ordinal))
{
return tag.Value?.ToString() ?? string.Empty;
}
}
return string.Empty;
}
private sealed class PolicyGatewayWebApplicationFactory : WebApplicationFactory<Program>
{
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.UseEnvironment("Development");
builder.ConfigureAppConfiguration((_, configurationBuilder) =>
{
var settings = new Dictionary<string, string?>
{
["PolicyGateway:Telemetry:MinimumLogLevel"] = "Warning",
["PolicyGateway:ResourceServer:Authority"] = "https://authority.test",
["PolicyGateway:ResourceServer:RequireHttpsMetadata"] = "false",
["PolicyGateway:ResourceServer:BypassNetworks:0"] = "127.0.0.1/32",
["PolicyGateway:ResourceServer:BypassNetworks:1"] = "::1/128",
["PolicyGateway:PolicyEngine:BaseAddress"] = "https://policy-engine.test/",
["PolicyGateway:PolicyEngine:ClientCredentials:Enabled"] = "true",
["PolicyGateway:PolicyEngine:ClientCredentials:ClientId"] = "policy-gateway",
["PolicyGateway:PolicyEngine:ClientCredentials:ClientSecret"] = "secret",
["PolicyGateway:PolicyEngine:ClientCredentials:Scopes:0"] = "policy:activate",
["PolicyGateway:PolicyEngine:Dpop:Enabled"] = "false"
};
configurationBuilder.AddInMemoryCollection(settings);
});
builder.ConfigureServices(services =>
{
services.RemoveAll<IStellaOpsTokenClient>();
services.AddSingleton<StubTokenClient>();
services.AddSingleton<IStellaOpsTokenClient>(sp => sp.GetRequiredService<StubTokenClient>());
services.RemoveAll<PolicyEngineClient>();
services.RemoveAll<IPolicyEngineClient>();
services.AddSingleton<RecordingPolicyEngineHandler>();
services.AddHttpClient<IPolicyEngineClient, PolicyEngineClient>()
.ConfigureHttpClient(client =>
{
client.BaseAddress = new Uri("https://policy-engine.test/");
})
.ConfigurePrimaryHttpMessageHandler(sp => sp.GetRequiredService<RecordingPolicyEngineHandler>());
services.AddSingleton<IStartupFilter>(new RemoteIpStartupFilter());
services.PostConfigure<JwtBearerOptions>(StellaOpsAuthenticationDefaults.AuthenticationScheme, options =>
{
options.RequireHttpsMetadata = false;
options.Configuration = new OpenIdConnectConfiguration
{
Issuer = "https://authority.test",
TokenEndpoint = "https://authority.test/token"
};
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = false,
ValidateAudience = false,
ValidateIssuerSigningKey = false,
SignatureValidator = (token, parameters) => new JsonWebToken(token)
};
options.BackchannelHttpHandler = new NoOpBackchannelHandler();
});
});
}
}
private sealed class RemoteIpStartupFilter : IStartupFilter
{
public Action<IApplicationBuilder> Configure(Action<IApplicationBuilder> next)
{
return app =>
{
app.Use(async (context, innerNext) =>
{
context.Connection.RemoteIpAddress ??= IPAddress.Loopback;
await innerNext().ConfigureAwait(false);
});
next(app);
};
}
}
private sealed class RecordingPolicyEngineHandler : HttpMessageHandler
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
public HttpRequestMessage? LastRequest { get; private set; }
public int RequestCount { get; private set; }
private Func<HttpRequestMessage, HttpResponseMessage>? responseFactory;
private Queue<HttpResponseMessage>? responseQueue;
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
LastRequest = request;
RequestCount++;
if (responseQueue is { Count: > 0 })
{
return Task.FromResult(responseQueue.Dequeue());
}
var response = responseFactory is not null
? responseFactory(request)
: CreateSuccessResponse();
return Task.FromResult(response);
}
public void Reset()
{
LastRequest = null;
RequestCount = 0;
responseFactory = null;
responseQueue?.Clear();
responseQueue = null;
}
public void SetResponseFactory(Func<HttpRequestMessage, HttpResponseMessage>? factory)
{
responseFactory = factory;
}
public void SetResponseSequence(IEnumerable<HttpResponseMessage> responses)
{
responseQueue = new Queue<HttpResponseMessage>(responses ?? Array.Empty<HttpResponseMessage>());
}
public static HttpResponseMessage CreateSuccessResponse()
{
var now = DateTimeOffset.UtcNow;
var payload = new PolicyRevisionActivationDto(
"activated",
new PolicyRevisionDto(
5,
"activated",
false,
now,
now,
Array.Empty<PolicyActivationApprovalDto>()));
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = JsonContent.Create(payload, options: SerializerOptions)
};
}
}
private sealed class NoOpBackchannelHandler : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
=> Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK));
}
private sealed class StubTokenClient : IStellaOpsTokenClient
{
public int RequestCount { get; private set; }
public void Reset()
{
RequestCount = 0;
}
public Task<StellaOpsTokenResult> RequestClientCredentialsTokenAsync(string? scope = null, IReadOnlyDictionary<string, string>? additionalParameters = null, CancellationToken cancellationToken = default)
{
RequestCount++;
var expiresAt = DateTimeOffset.UtcNow.AddMinutes(5);
return Task.FromResult(new StellaOpsTokenResult("service-token", "Bearer", expiresAt, Array.Empty<string>()));
}
public Task<StellaOpsTokenResult> RequestPasswordTokenAsync(string username, string password, string? scope = null, IReadOnlyDictionary<string, string>? additionalParameters = null, CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
public Task<JsonWebKeySet> GetJsonWebKeySetAsync(CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
public ValueTask<StellaOpsTokenCacheEntry?> GetCachedTokenAsync(string key, CancellationToken cancellationToken = default)
=> ValueTask.FromResult<StellaOpsTokenCacheEntry?>(null);
public ValueTask CacheTokenAsync(string key, StellaOpsTokenCacheEntry entry, CancellationToken cancellationToken = default)
=> ValueTask.CompletedTask;
public ValueTask ClearCachedTokenAsync(string key, CancellationToken cancellationToken = default)
=> ValueTask.CompletedTask;
}
}

View File

@@ -0,0 +1,212 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.Metrics;
using System.Net;
using System.Net.Http;
using System.Security.Claims;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.FileProviders;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Hosting;
using Microsoft.IdentityModel.Tokens;
using StellaOps.Auth.Client;
using StellaOps.Policy.Gateway.Clients;
using StellaOps.Policy.Gateway.Contracts;
using StellaOps.Policy.Gateway.Options;
using StellaOps.Policy.Gateway.Services;
using Xunit;
namespace StellaOps.Policy.Gateway.Tests;
public class PolicyEngineClientTests
{
[Fact]
public async Task ActivateRevision_UsesServiceTokenWhenForwardingContextMissing()
{
var options = CreateGatewayOptions();
options.PolicyEngine.ClientCredentials.Enabled = true;
options.PolicyEngine.ClientCredentials.ClientId = "policy-gateway";
options.PolicyEngine.ClientCredentials.ClientSecret = "secret";
options.PolicyEngine.ClientCredentials.Scopes.Clear();
options.PolicyEngine.ClientCredentials.Scopes.Add("policy:activate");
options.PolicyEngine.BaseAddress = "https://policy-engine.test/";
var optionsMonitor = new TestOptionsMonitor(options);
var tokenClient = new StubTokenClient();
var dpopGenerator = new PolicyGatewayDpopProofGenerator(new StubHostEnvironment(), optionsMonitor, TimeProvider.System, NullLogger<PolicyGatewayDpopProofGenerator>.Instance);
var tokenProvider = new PolicyEngineTokenProvider(tokenClient, optionsMonitor, dpopGenerator, TimeProvider.System, NullLogger<PolicyEngineTokenProvider>.Instance);
using var recordingHandler = new RecordingHandler();
using var httpClient = new HttpClient(recordingHandler)
{
BaseAddress = new Uri(options.PolicyEngine.BaseAddress)
};
var client = new PolicyEngineClient(httpClient, Microsoft.Extensions.Options.Options.Create(options), tokenProvider, NullLogger<PolicyEngineClient>.Instance);
var request = new ActivatePolicyRevisionRequest("comment");
var result = await client.ActivatePolicyRevisionAsync(null, "pack-123", 7, request, CancellationToken.None);
Assert.True(result.IsSuccess);
Assert.NotNull(recordingHandler.LastRequest);
var authorization = recordingHandler.LastRequest!.Headers.Authorization;
Assert.NotNull(authorization);
Assert.Equal("Bearer", authorization!.Scheme);
Assert.Equal("service-token", authorization.Parameter);
Assert.Equal(1, tokenClient.RequestCount);
}
[Fact]
public void Metrics_RecordActivation_EmitsExpectedTags()
{
using var metrics = new PolicyGatewayMetrics();
using var listener = new MeterListener();
var measurements = new List<(long Value, string Outcome, string Source)>();
var latencies = new List<(double Value, string Outcome, string Source)>();
listener.InstrumentPublished += (instrument, meterListener) =>
{
if (!string.Equals(instrument.Meter.Name, "StellaOps.Policy.Gateway", StringComparison.Ordinal))
{
return;
}
meterListener.EnableMeasurementEvents(instrument);
};
listener.SetMeasurementEventCallback<long>((instrument, value, tags, state) =>
{
if (instrument.Name != "policy_gateway_activation_requests_total")
{
return;
}
measurements.Add((value, GetTag(tags, "outcome"), GetTag(tags, "source")));
});
listener.SetMeasurementEventCallback<double>((instrument, value, tags, state) =>
{
if (instrument.Name != "policy_gateway_activation_latency_ms")
{
return;
}
latencies.Add((value, GetTag(tags, "outcome"), GetTag(tags, "source")));
});
listener.Start();
metrics.RecordActivation("activated", "service", 42.5);
listener.Dispose();
Assert.Contains(measurements, entry => entry.Value == 1 && entry.Outcome == "activated" && entry.Source == "service");
Assert.Contains(latencies, entry => entry.Outcome == "activated" && entry.Source == "service" && entry.Value == 42.5);
}
private static string GetTag(ReadOnlySpan<KeyValuePair<string, object?>> tags, string key)
{
foreach (var tag in tags)
{
if (string.Equals(tag.Key, key, StringComparison.Ordinal))
{
return tag.Value?.ToString() ?? string.Empty;
}
}
return string.Empty;
}
private static PolicyGatewayOptions CreateGatewayOptions()
{
return new PolicyGatewayOptions
{
PolicyEngine =
{
BaseAddress = "https://policy-engine.test/"
}
};
}
private sealed class TestOptionsMonitor : IOptionsMonitor<PolicyGatewayOptions>
{
public TestOptionsMonitor(PolicyGatewayOptions current)
{
CurrentValue = current;
}
public PolicyGatewayOptions CurrentValue { get; }
public PolicyGatewayOptions Get(string? name) => CurrentValue;
public IDisposable OnChange(Action<PolicyGatewayOptions, string?> listener) => EmptyDisposable.Instance;
private sealed class EmptyDisposable : IDisposable
{
public static readonly EmptyDisposable Instance = new();
public void Dispose()
{
}
}
}
private sealed class StubTokenClient : IStellaOpsTokenClient
{
public int RequestCount { get; private set; }
public IReadOnlyDictionary<string, string>? LastAdditionalParameters { get; private set; }
public Task<StellaOpsTokenResult> RequestClientCredentialsTokenAsync(string? scope = null, IReadOnlyDictionary<string, string>? additionalParameters = null, CancellationToken cancellationToken = default)
{
RequestCount++;
LastAdditionalParameters = additionalParameters;
return Task.FromResult(new StellaOpsTokenResult("service-token", "Bearer", DateTimeOffset.UtcNow.AddMinutes(5), Array.Empty<string>()));
}
public Task<StellaOpsTokenResult> RequestPasswordTokenAsync(string username, string password, string? scope = null, IReadOnlyDictionary<string, string>? additionalParameters = null, CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
public Task<JsonWebKeySet> GetJsonWebKeySetAsync(CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
public ValueTask<StellaOpsTokenCacheEntry?> GetCachedTokenAsync(string key, CancellationToken cancellationToken = default)
=> ValueTask.FromResult<StellaOpsTokenCacheEntry?>(null);
public ValueTask CacheTokenAsync(string key, StellaOpsTokenCacheEntry entry, CancellationToken cancellationToken = default)
=> ValueTask.CompletedTask;
public ValueTask ClearCachedTokenAsync(string key, CancellationToken cancellationToken = default)
=> ValueTask.CompletedTask;
}
private sealed class RecordingHandler : HttpMessageHandler
{
public HttpRequestMessage? LastRequest { get; private set; }
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
LastRequest = request;
var payload = JsonSerializer.Serialize(new PolicyRevisionActivationDto("activated", new PolicyRevisionDto(7, "Activated", false, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, Array.Empty<PolicyActivationApprovalDto>())));
var response = new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(payload, Encoding.UTF8, "application/json")
};
return Task.FromResult(response);
}
}
private sealed class StubHostEnvironment : IHostEnvironment
{
public string EnvironmentName { get; set; } = "Development";
public string ApplicationName { get; set; } = "PolicyGatewayTests";
public string ContentRootPath { get; set; } = AppContext.BaseDirectory;
public IFileProvider ContentRootFileProvider { get; set; } = new NullFileProvider();
}
}

View File

@@ -0,0 +1,167 @@
using System.Globalization;
using System.IdentityModel.Tokens.Jwt;
using System.Net.Http;
using System.Security.Cryptography;
using System.Text;
using Microsoft.Extensions.FileProviders;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using StellaOps.Policy.Gateway.Options;
using StellaOps.Policy.Gateway.Services;
using Xunit;
namespace StellaOps.Policy.Gateway.Tests;
public sealed class PolicyGatewayDpopProofGeneratorTests
{
[Fact]
public void CreateProof_Throws_WhenDpopDisabled()
{
var options = CreateGatewayOptions();
options.PolicyEngine.Dpop.Enabled = false;
using var generator = new PolicyGatewayDpopProofGenerator(
new StubHostEnvironment(AppContext.BaseDirectory),
new TestOptionsMonitor(options),
TimeProvider.System,
NullLogger<PolicyGatewayDpopProofGenerator>.Instance);
var exception = Assert.Throws<InvalidOperationException>(() =>
generator.CreateProof(HttpMethod.Get, new Uri("https://policy-engine.example/api"), null));
Assert.Equal("DPoP proof requested while DPoP is disabled.", exception.Message);
}
[Fact]
public void CreateProof_Throws_WhenKeyFileMissing()
{
var tempRoot = Directory.CreateTempSubdirectory();
try
{
var options = CreateGatewayOptions();
options.PolicyEngine.Dpop.Enabled = true;
options.PolicyEngine.Dpop.KeyPath = "missing-key.pem";
using var generator = new PolicyGatewayDpopProofGenerator(
new StubHostEnvironment(tempRoot.FullName),
new TestOptionsMonitor(options),
TimeProvider.System,
NullLogger<PolicyGatewayDpopProofGenerator>.Instance);
var exception = Assert.Throws<FileNotFoundException>(() =>
generator.CreateProof(HttpMethod.Post, new Uri("https://policy-engine.example/token"), null));
Assert.Contains("missing-key.pem", exception.FileName, StringComparison.Ordinal);
}
finally
{
tempRoot.Delete(recursive: true);
}
}
[Fact]
public void CreateProof_UsesConfiguredAlgorithmAndEmbedsTokenHash()
{
var tempRoot = Directory.CreateTempSubdirectory();
try
{
var keyPath = CreateEcKey(tempRoot, ECCurve.NamedCurves.nistP384);
var options = CreateGatewayOptions();
options.PolicyEngine.Dpop.Enabled = true;
options.PolicyEngine.Dpop.KeyPath = keyPath;
options.PolicyEngine.Dpop.Algorithm = "ES384";
using var generator = new PolicyGatewayDpopProofGenerator(
new StubHostEnvironment(tempRoot.FullName),
new TestOptionsMonitor(options),
TimeProvider.System,
NullLogger<PolicyGatewayDpopProofGenerator>.Instance);
const string accessToken = "sample-access-token";
var proof = generator.CreateProof(HttpMethod.Delete, new Uri("https://policy-engine.example/api/resource"), accessToken);
var token = new JwtSecurityTokenHandler().ReadJwtToken(proof);
Assert.Equal("dpop+jwt", token.Header.Typ);
Assert.Equal("ES384", token.Header.Alg);
Assert.Equal("DELETE", token.Payload.TryGetValue("htm", out var method) ? method?.ToString() : null);
Assert.Equal("https://policy-engine.example/api/resource", token.Payload.TryGetValue("htu", out var uri) ? uri?.ToString() : null);
Assert.True(token.Payload.TryGetValue("iat", out var issuedAt));
Assert.True(long.TryParse(Convert.ToString(issuedAt, CultureInfo.InvariantCulture), out var epoch));
Assert.True(epoch > 0);
Assert.True(token.Payload.TryGetValue("jti", out var jti));
Assert.False(string.IsNullOrWhiteSpace(Convert.ToString(jti, CultureInfo.InvariantCulture)));
Assert.True(token.Payload.TryGetValue("ath", out var ath));
var expectedHash = Base64UrlEncoder.Encode(SHA256.HashData(Encoding.UTF8.GetBytes(accessToken)));
Assert.Equal(expectedHash, ath?.ToString());
}
finally
{
tempRoot.Delete(recursive: true);
}
}
private static PolicyGatewayOptions CreateGatewayOptions()
{
return new PolicyGatewayOptions
{
PolicyEngine =
{
BaseAddress = "https://policy-engine.example"
}
};
}
private static string CreateEcKey(DirectoryInfo directory, ECCurve curve)
{
using var ecdsa = ECDsa.Create(curve);
var privateKey = ecdsa.ExportPkcs8PrivateKey();
var pem = PemEncoding.Write("PRIVATE KEY", privateKey);
var path = Path.Combine(directory.FullName, "policy-gateway-dpop.pem");
File.WriteAllText(path, pem);
return path;
}
private sealed class StubHostEnvironment : IHostEnvironment
{
public StubHostEnvironment(string contentRootPath)
{
ContentRootPath = contentRootPath;
}
public string ApplicationName { get; set; } = "PolicyGatewayTests";
public IFileProvider ContentRootFileProvider { get; set; } = new NullFileProvider();
public string ContentRootPath { get; set; }
public string EnvironmentName { get; set; } = Environments.Development;
}
private sealed class TestOptionsMonitor : IOptionsMonitor<PolicyGatewayOptions>
{
public TestOptionsMonitor(PolicyGatewayOptions current)
{
CurrentValue = current;
}
public PolicyGatewayOptions CurrentValue { get; }
public PolicyGatewayOptions Get(string? name) => CurrentValue;
public IDisposable OnChange(Action<PolicyGatewayOptions, string?> listener) => EmptyDisposable.Instance;
private sealed class EmptyDisposable : IDisposable
{
public static readonly EmptyDisposable Instance = new();
public void Dispose()
{
}
}
}
}

View File

@@ -0,0 +1,12 @@
<?xml version='1.0' encoding='utf-8'?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../../StellaOps.Policy.Gateway/StellaOps.Policy.Gateway.csproj" />
</ItemGroup>
</Project>