using System; using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; using MongoDB.Driver; using StellaOps.Authority.Plugins.Abstractions; using StellaOps.Authority.Plugin.Standard.Storage; using StellaOps.Authority.Storage.Mongo.Documents; using StellaOps.Authority.Storage.Mongo.Stores; using Xunit; namespace StellaOps.Authority.Plugin.Standard.Tests; public class StandardClientProvisioningStoreTests { [Fact] public async Task CreateOrUpdateAsync_HashesSecretAndPersistsDocument() { var store = new TrackingClientStore(); var revocations = new TrackingRevocationStore(); var provisioning = new StandardClientProvisioningStore("standard", store, revocations, TimeProvider.System); var registration = new AuthorityClientRegistration( clientId: "bootstrap-client", confidential: true, displayName: "Bootstrap", clientSecret: "SuperSecret1!", allowedGrantTypes: new[] { "client_credentials" }, allowedScopes: new[] { "scopeA" }); var result = await provisioning.CreateOrUpdateAsync(registration, CancellationToken.None); Assert.True(result.Succeeded); Assert.True(store.Documents.TryGetValue("bootstrap-client", out var document)); Assert.NotNull(document); Assert.Equal(AuthoritySecretHasher.ComputeHash("SuperSecret1!"), document!.SecretHash); Assert.Equal("standard", document.Plugin); var descriptor = await provisioning.FindByClientIdAsync("bootstrap-client", CancellationToken.None); Assert.NotNull(descriptor); Assert.Equal("bootstrap-client", descriptor!.ClientId); Assert.True(descriptor.Confidential); Assert.Contains("client_credentials", descriptor.AllowedGrantTypes); Assert.Contains("scopea", descriptor.AllowedScopes); } [Fact] [Fact] public async Task CreateOrUpdateAsync_NormalisesTenant() { var store = new TrackingClientStore(); var revocations = new TrackingRevocationStore(); var provisioning = new StandardClientProvisioningStore("standard", store, revocations, TimeProvider.System); var registration = new AuthorityClientRegistration( clientId: "tenant-client", confidential: false, displayName: "Tenant Client", clientSecret: null, allowedGrantTypes: new[] { "client_credentials" }, allowedScopes: new[] { "scopeA" }, tenant: " Tenant-Alpha " ); await provisioning.CreateOrUpdateAsync(registration, CancellationToken.None); Assert.True(store.Documents.TryGetValue("tenant-client", out var document)); Assert.NotNull(document); Assert.Equal("tenant-alpha", document!.Properties[AuthorityClientMetadataKeys.Tenant]); var descriptor = await provisioning.FindByClientIdAsync("tenant-client", CancellationToken.None); Assert.NotNull(descriptor); Assert.Equal("tenant-alpha", descriptor!.Tenant); } public async Task CreateOrUpdateAsync_StoresAudiences() { var store = new TrackingClientStore(); var revocations = new TrackingRevocationStore(); var provisioning = new StandardClientProvisioningStore("standard", store, revocations, TimeProvider.System); var registration = new AuthorityClientRegistration( clientId: "signer", confidential: false, displayName: "Signer", clientSecret: null, allowedGrantTypes: new[] { "client_credentials" }, allowedScopes: new[] { "signer.sign" }, allowedAudiences: new[] { "attestor", "signer" }); var result = await provisioning.CreateOrUpdateAsync(registration, CancellationToken.None); Assert.True(result.Succeeded); Assert.True(store.Documents.TryGetValue("signer", out var document)); Assert.NotNull(document); Assert.Equal("attestor signer", document!.Properties[AuthorityClientMetadataKeys.Audiences]); var descriptor = await provisioning.FindByClientIdAsync("signer", CancellationToken.None); Assert.NotNull(descriptor); Assert.Equal(new[] { "attestor", "signer" }, descriptor!.AllowedAudiences.OrderBy(value => value, StringComparer.Ordinal)); } [Fact] public async Task CreateOrUpdateAsync_MapsCertificateBindings() { var store = new TrackingClientStore(); var revocations = new TrackingRevocationStore(); var provisioning = new StandardClientProvisioningStore("standard", store, revocations, TimeProvider.System); var bindingRegistration = new AuthorityClientCertificateBindingRegistration( thumbprint: "aa:bb:cc:dd", serialNumber: "01ff", subject: "CN=mtls-client", issuer: "CN=test-ca", subjectAlternativeNames: new[] { "client.mtls.test", "spiffe://client" }, notBefore: DateTimeOffset.UtcNow.AddMinutes(-5), notAfter: DateTimeOffset.UtcNow.AddHours(1), label: "primary"); var registration = new AuthorityClientRegistration( clientId: "mtls-client", confidential: true, displayName: "MTLS Client", clientSecret: "secret", allowedGrantTypes: new[] { "client_credentials" }, allowedScopes: new[] { "signer.sign" }, allowedAudiences: new[] { "signer" }, certificateBindings: new[] { bindingRegistration }); await provisioning.CreateOrUpdateAsync(registration, CancellationToken.None); Assert.True(store.Documents.TryGetValue("mtls-client", out var document)); Assert.NotNull(document); var binding = Assert.Single(document!.CertificateBindings); Assert.Equal("AABBCCDD", binding.Thumbprint); Assert.Equal("01ff", binding.SerialNumber); Assert.Equal("CN=mtls-client", binding.Subject); Assert.Equal("CN=test-ca", binding.Issuer); Assert.Equal(new[] { "client.mtls.test", "spiffe://client" }, binding.SubjectAlternativeNames); Assert.Equal(bindingRegistration.NotBefore, binding.NotBefore); Assert.Equal(bindingRegistration.NotAfter, binding.NotAfter); Assert.Equal("primary", binding.Label); } private sealed class TrackingClientStore : IAuthorityClientStore { public Dictionary Documents { get; } = new(StringComparer.OrdinalIgnoreCase); public ValueTask FindByClientIdAsync(string clientId, CancellationToken cancellationToken, IClientSessionHandle? session = null) { Documents.TryGetValue(clientId, out var document); return ValueTask.FromResult(document); } public ValueTask UpsertAsync(AuthorityClientDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null) { Documents[document.ClientId] = document; return ValueTask.CompletedTask; } public ValueTask DeleteByClientIdAsync(string clientId, CancellationToken cancellationToken, IClientSessionHandle? session = null) { var removed = Documents.Remove(clientId); return ValueTask.FromResult(removed); } } private sealed class TrackingRevocationStore : IAuthorityRevocationStore { public List Upserts { get; } = new(); public ValueTask UpsertAsync(AuthorityRevocationDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null) { Upserts.Add(document); return ValueTask.CompletedTask; } public ValueTask RemoveAsync(string category, string revocationId, CancellationToken cancellationToken, IClientSessionHandle? session = null) => ValueTask.FromResult(true); public ValueTask> GetActiveAsync(DateTimeOffset asOf, CancellationToken cancellationToken, IClientSessionHandle? session = null) => ValueTask.FromResult>(Array.Empty()); } }