up
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled
Notify Smoke Test / Notify Unit Tests (push) Has been cancelled
Notify Smoke Test / Notifier Service Tests (push) Has been cancelled
Notify Smoke Test / Notification Smoke Test (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled
Notify Smoke Test / Notify Unit Tests (push) Has been cancelled
Notify Smoke Test / Notifier Service Tests (push) Has been cancelled
Notify Smoke Test / Notification Smoke Test (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled
This commit is contained in:
@@ -1,212 +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();
|
||||
}
|
||||
}
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,167 +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()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user