save progress

This commit is contained in:
StellaOps Bot
2026-01-03 00:47:24 +02:00
parent 3f197814c5
commit ca578801fd
319 changed files with 32478 additions and 2202 deletions

View File

@@ -0,0 +1,15 @@
using StellaOps.Authority.Plugin.Saml.Credentials;
using Xunit;
namespace StellaOps.Authority.Plugin.Saml.Tests.Credentials;
public sealed class SamlCredentialStoreTests
{
[Fact]
public void BuildSessionCacheKey_IncludesPluginName()
{
var key = SamlCredentialStore.BuildSessionCacheKey("saml-test", "subject-1");
Assert.Equal("saml:saml-test:session:subject-1", key);
}
}

View File

@@ -0,0 +1,146 @@
using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Authority.Plugin.Saml;
using StellaOps.Authority.Plugin.Saml.Claims;
using StellaOps.Authority.Plugin.Saml.Credentials;
using StellaOps.Authority.Plugins.Abstractions;
using Xunit;
namespace StellaOps.Authority.Plugin.Saml.Tests;
public sealed class SamlIdentityProviderPluginTests
{
[Fact]
public async Task CheckHealthAsync_ReturnsHealthy_WhenMetadataOk()
{
var plugin = CreatePlugin(HttpStatusCode.OK);
var result = await plugin.CheckHealthAsync(CancellationToken.None);
Assert.Equal(AuthorityPluginHealthStatus.Healthy, result.Status);
}
[Fact]
public async Task CheckHealthAsync_ReturnsDegraded_WhenMetadataNotOk()
{
var plugin = CreatePlugin(HttpStatusCode.ServiceUnavailable);
var result = await plugin.CheckHealthAsync(CancellationToken.None);
Assert.Equal(AuthorityPluginHealthStatus.Degraded, result.Status);
}
private static SamlIdentityProviderPlugin CreatePlugin(HttpStatusCode statusCode)
{
var pluginName = "saml-test";
var options = new SamlPluginOptions
{
EntityId = "urn:stellaops:sp",
IdpEntityId = "urn:idp:test",
IdpMetadataUrl = "https://idp.example.com/metadata",
ValidateSignature = false,
SignAuthenticationRequests = false,
SignLogoutRequests = false
};
var optionsMonitor = new StaticOptionsMonitor(options, pluginName);
var handler = new FixedResponseHandler(statusCode);
var httpClientFactory = new TestHttpClientFactory(handler);
var cache = new MemoryCache(new MemoryCacheOptions());
var credentialStore = new SamlCredentialStore(
pluginName,
optionsMonitor,
cache,
NullLogger<SamlCredentialStore>.Instance,
httpClientFactory);
var claimsEnricher = new SamlClaimsEnricher(
pluginName,
optionsMonitor,
NullLogger<SamlClaimsEnricher>.Instance);
var manifest = new AuthorityPluginManifest(
Name: pluginName,
Type: SamlPluginRegistrar.PluginType,
Enabled: true,
AssemblyName: null,
AssemblyPath: null,
Capabilities: new[] { AuthorityPluginCapabilities.Password },
Metadata: new Dictionary<string, string?>(),
ConfigPath: "saml.yaml");
var context = new AuthorityPluginContext(manifest, new ConfigurationBuilder().Build());
return new SamlIdentityProviderPlugin(
context,
credentialStore,
claimsEnricher,
optionsMonitor,
NullLogger<SamlIdentityProviderPlugin>.Instance,
httpClientFactory);
}
private sealed class FixedResponseHandler : HttpMessageHandler
{
private readonly HttpStatusCode statusCode;
public FixedResponseHandler(HttpStatusCode statusCode)
{
this.statusCode = statusCode;
}
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
=> Task.FromResult(new HttpResponseMessage(statusCode)
{
Content = new StringContent("{}")
});
}
private sealed class TestHttpClientFactory : IHttpClientFactory
{
private readonly HttpMessageHandler handler;
public TestHttpClientFactory(HttpMessageHandler handler)
{
this.handler = handler;
}
public HttpClient CreateClient(string name)
=> new(handler, disposeHandler: false);
}
private sealed class StaticOptionsMonitor : IOptionsMonitor<SamlPluginOptions>
{
private readonly SamlPluginOptions options;
private readonly string pluginName;
public StaticOptionsMonitor(SamlPluginOptions options, string pluginName)
{
this.options = options;
this.pluginName = pluginName;
}
public SamlPluginOptions CurrentValue => options;
public SamlPluginOptions Get(string name)
=> string.Equals(name, pluginName, StringComparison.Ordinal) ? options : options;
public IDisposable OnChange(Action<SamlPluginOptions, string> listener)
=> new NoopDisposable();
private sealed class NoopDisposable : IDisposable
{
public void Dispose()
{
}
}
}
}

View File

@@ -0,0 +1,40 @@
using System;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using StellaOps.Authority.Plugin.Saml;
using Xunit;
namespace StellaOps.Authority.Plugin.Saml.Tests;
public sealed class SamlMetadataParserTests
{
[Fact]
public void TryExtractSigningCertificate_ReturnsCertificate()
{
using var rsa = RSA.Create(2048);
var request = new CertificateRequest("CN=Test", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
var notBefore = new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero);
var cert = request.CreateSelfSigned(notBefore, notBefore.AddDays(30));
var base64 = Convert.ToBase64String(cert.Export(X509ContentType.Cert));
var metadata = $"""
<EntityDescriptor xmlns="urn:oasis:names:tc:SAML:2.0:metadata" entityID="urn:idp:test">
<IDPSSODescriptor>
<KeyDescriptor>
<KeyInfo xmlns="http://www.w3.org/2000/09/xmldsig#">
<X509Data>
<X509Certificate>{base64}</X509Certificate>
</X509Data>
</KeyInfo>
</KeyDescriptor>
</IDPSSODescriptor>
</EntityDescriptor>
""";
var result = SamlMetadataParser.TryExtractSigningCertificate(metadata, out var extracted);
Assert.True(result);
Assert.NotNull(extracted);
Assert.Equal(cert.Thumbprint, extracted.Thumbprint);
}
}

View File

@@ -0,0 +1,59 @@
using System;
using StellaOps.Authority.Plugin.Saml;
using Xunit;
namespace StellaOps.Authority.Plugin.Saml.Tests;
public sealed class SamlPluginOptionsTests
{
[Fact]
public void Validate_Throws_WhenEncryptedAssertionsEnabled()
{
var options = CreateOptions();
options.RequireEncryptedAssertions = true;
var ex = Assert.Throws<InvalidOperationException>(() => options.Validate());
Assert.Contains("encrypted assertions", ex.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void Validate_Throws_WhenRequestSigningEnabled()
{
var options = CreateOptions();
options.SignAuthenticationRequests = true;
var ex = Assert.Throws<InvalidOperationException>(() => options.Validate());
Assert.Contains("request signing", ex.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void Validate_Throws_WhenMetadataUrlNotHttps()
{
var options = CreateOptions();
options.IdpMetadataUrl = "http://idp.example.com/metadata";
var ex = Assert.Throws<InvalidOperationException>(() => options.Validate());
Assert.Contains("metadata URL", ex.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void Validate_Throws_WhenMetadataTimeoutNonPositive()
{
var options = CreateOptions();
options.MetadataTimeoutSeconds = 0;
var ex = Assert.Throws<InvalidOperationException>(() => options.Validate());
Assert.Contains("MetadataTimeoutSeconds", ex.Message, StringComparison.OrdinalIgnoreCase);
}
private static SamlPluginOptions CreateOptions()
=> new()
{
EntityId = "urn:stellaops:sp",
IdpEntityId = "urn:idp:test",
IdpMetadataUrl = "https://idp.example.com/metadata",
ValidateSignature = false,
SignAuthenticationRequests = false,
SignLogoutRequests = false
};
}