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,184 @@
using System;
using System.IO;
using System.Linq;
using StellaOps.Authority.Plugins.Abstractions;
using StellaOps.Configuration;
using Xunit;
namespace StellaOps.Configuration.Tests;
public class AuthorityPluginConfigurationLoaderTests : IDisposable
{
private readonly string tempRoot;
public AuthorityPluginConfigurationLoaderTests()
{
tempRoot = Path.Combine(Path.GetTempPath(), "authority-plugin-tests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(tempRoot);
}
[Fact]
public void Load_ReturnsConfiguration_ForEnabledPlugin()
{
var pluginDir = Path.Combine(tempRoot, "etc", "authority.plugins");
Directory.CreateDirectory(pluginDir);
var standardConfigPath = Path.Combine(pluginDir, "standard.yaml");
File.WriteAllText(standardConfigPath, "secretKey: value");
var options = CreateOptions();
options.Plugins.ConfigurationDirectory = "etc/authority.plugins";
options.Plugins.Descriptors["standard"] = new AuthorityPluginDescriptorOptions
{
AssemblyName = "StellaOps.Authority.Plugin.Standard",
Enabled = true
};
options.Validate();
var contexts = AuthorityPluginConfigurationLoader.Load(options, tempRoot);
var context = Assert.Single(contexts);
Assert.Equal("standard", context.Manifest.Name);
Assert.Equal("value", context.Configuration["secretKey"]);
Assert.True(context.Manifest.Enabled);
}
[Fact]
public void Load_Throws_WhenEnabledConfigMissing()
{
var options = CreateOptions();
options.Plugins.ConfigurationDirectory = "etc/authority.plugins";
options.Plugins.Descriptors["standard"] = new AuthorityPluginDescriptorOptions
{
AssemblyName = "StellaOps.Authority.Plugin.Standard",
Enabled = true
};
options.Validate();
var ex = Assert.Throws<FileNotFoundException>(() =>
AuthorityPluginConfigurationLoader.Load(options, tempRoot));
Assert.Contains("standard.yaml", ex.FileName, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void Load_SkipsMissingFile_ForDisabledPlugin()
{
var options = CreateOptions();
options.Plugins.ConfigurationDirectory = "etc/authority.plugins";
options.Plugins.Descriptors["ldap"] = new AuthorityPluginDescriptorOptions
{
AssemblyName = "StellaOps.Authority.Plugin.Ldap",
Enabled = false,
ConfigFile = "ldap.yaml"
};
options.Validate();
var contexts = AuthorityPluginConfigurationLoader.Load(options, tempRoot);
var context = Assert.Single(contexts);
Assert.False(context.Manifest.Enabled);
Assert.Equal("ldap", context.Manifest.Name);
Assert.Null(context.Configuration["connection:host"]);
}
[Fact]
public void Validate_ThrowsForUnknownCapability()
{
var options = CreateOptions();
options.Plugins.Descriptors["standard"] = new AuthorityPluginDescriptorOptions
{
AssemblyName = "StellaOps.Authority.Plugin.Standard",
Enabled = true
};
options.Plugins.Descriptors["standard"].Capabilities.Add("custom-flow");
var ex = Assert.Throws<InvalidOperationException>(() => options.Validate());
Assert.Contains("unknown capability", ex.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void Analyze_ReturnsWarning_WhenStandardPasswordPolicyWeaker()
{
var pluginDir = Path.Combine(tempRoot, "etc", "authority.plugins");
Directory.CreateDirectory(pluginDir);
var standardConfigPath = Path.Combine(pluginDir, "standard.yaml");
File.WriteAllText(standardConfigPath, "passwordPolicy:\n minimumLength: 8\n requireSymbol: false\n");
var options = CreateOptions();
options.Plugins.ConfigurationDirectory = "etc/authority.plugins";
options.Plugins.Descriptors["standard"] = new AuthorityPluginDescriptorOptions
{
AssemblyName = "StellaOps.Authority.Plugin.Standard",
Enabled = true
};
options.Validate();
var contexts = AuthorityPluginConfigurationLoader.Load(options, tempRoot);
var diagnostics = AuthorityPluginConfigurationAnalyzer.Analyze(contexts);
var diagnostic = Assert.Single(diagnostics);
Assert.Equal(AuthorityConfigurationDiagnosticSeverity.Warning, diagnostic.Severity);
Assert.Equal("standard", diagnostic.PluginName);
Assert.Contains("minimum length 8", diagnostic.Message, StringComparison.OrdinalIgnoreCase);
Assert.Contains("symbol requirement disabled", diagnostic.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void Analyze_ReturnsNoDiagnostics_WhenPasswordPolicyMatchesBaseline()
{
var pluginDir = Path.Combine(tempRoot, "etc", "authority.plugins");
Directory.CreateDirectory(pluginDir);
var standardConfigPath = Path.Combine(pluginDir, "standard.yaml");
// Baseline configuration (no overrides)
File.WriteAllText(standardConfigPath, "bootstrapUser:\n username: bootstrap\n password: Bootstrap1!\n");
var options = CreateOptions();
options.Plugins.ConfigurationDirectory = "etc/authority.plugins";
options.Plugins.Descriptors["standard"] = new AuthorityPluginDescriptorOptions
{
AssemblyName = "StellaOps.Authority.Plugin.Standard",
Enabled = true
};
options.Validate();
var contexts = AuthorityPluginConfigurationLoader.Load(options, tempRoot);
var diagnostics = AuthorityPluginConfigurationAnalyzer.Analyze(contexts);
Assert.Empty(diagnostics);
}
public void Dispose()
{
try
{
if (Directory.Exists(tempRoot))
{
Directory.Delete(tempRoot, recursive: true);
}
}
catch
{
// ignore cleanup failures in test environment
}
}
private static StellaOpsAuthorityOptions CreateOptions()
{
var options = new StellaOpsAuthorityOptions
{
Issuer = new Uri("https://authority.stella-ops.test"),
SchemaVersion = 1
};
options.Storage.ConnectionString = "mongodb://localhost:27017/authority_test";
options.Signing.ActiveKeyId = "test-key";
options.Signing.KeyPath = "/tmp/authority-test-key.pem";
return options;
}
}

View File

@@ -0,0 +1,24 @@
using StellaOps.Auth;
using Xunit;
namespace StellaOps.Configuration.Tests;
public class AuthorityTelemetryTests
{
[Fact]
public void ServiceName_AndNamespace_MatchExpectations()
{
Assert.Equal("stellaops-authority", AuthorityTelemetry.ServiceName);
Assert.Equal("stellaops", AuthorityTelemetry.ServiceNamespace);
}
[Fact]
public void BuildDefaultResourceAttributes_ContainsExpectedKeys()
{
var attributes = AuthorityTelemetry.BuildDefaultResourceAttributes();
Assert.Equal("stellaops-authority", attributes["service.name"]);
Assert.Equal("stellaops", attributes["service.namespace"]);
Assert.False(string.IsNullOrWhiteSpace(attributes["service.version"]?.ToString()));
}
}

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.Configuration/StellaOps.Configuration.csproj" />
<ProjectReference Include="../../../Authority/StellaOps.Authority/StellaOps.Auth.Abstractions/StellaOps.Auth.Abstractions.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,222 @@
using System;
using System.Collections.Generic;
using Microsoft.Extensions.Configuration;
using StellaOps.Configuration;
using Xunit;
namespace StellaOps.Configuration.Tests;
public class StellaOpsAuthorityOptionsTests
{
[Fact]
public void Validate_Throws_When_IssuerMissing()
{
var options = new StellaOpsAuthorityOptions();
var exception = Assert.Throws<InvalidOperationException>(() => options.Validate());
Assert.Contains("issuer", exception.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void Validate_Normalises_Collections()
{
var options = new StellaOpsAuthorityOptions
{
Issuer = new Uri("https://authority.stella-ops.test"),
SchemaVersion = 1
};
options.Storage.ConnectionString = "mongodb://localhost:27017/authority";
options.Signing.ActiveKeyId = "test-key";
options.Signing.KeyPath = "/tmp/test-key.pem";
options.PluginDirectories.Add(" ./plugins ");
options.PluginDirectories.Add("./plugins");
options.PluginDirectories.Add("./other");
options.BypassNetworks.Add(" 10.0.0.0/24 ");
options.BypassNetworks.Add("10.0.0.0/24");
options.BypassNetworks.Add("192.168.0.0/16");
options.Validate();
Assert.Equal(new[] { "./plugins", "./other" }, options.PluginDirectories);
Assert.Equal(new[] { "10.0.0.0/24", "192.168.0.0/16" }, options.BypassNetworks);
}
[Fact]
public void Validate_Normalises_PluginDescriptors()
{
var options = new StellaOpsAuthorityOptions
{
Issuer = new Uri("https://authority.stella-ops.test"),
SchemaVersion = 1
};
options.Storage.ConnectionString = "mongodb://localhost:27017/authority";
options.Signing.ActiveKeyId = "test-key";
options.Signing.KeyPath = "/tmp/test-key.pem";
var descriptor = new AuthorityPluginDescriptorOptions
{
AssemblyName = "StellaOps.Authority.Plugin.Standard",
ConfigFile = " standard.yaml ",
Enabled = true
};
descriptor.Capabilities.Add("password");
descriptor.Capabilities.Add("PASSWORD");
options.Plugins.Descriptors["standard"] = descriptor;
options.Validate();
var normalized = options.Plugins.Descriptors["standard"];
Assert.Equal("standard.yaml", normalized.ConfigFile);
Assert.Single(normalized.Capabilities);
Assert.Equal("password", normalized.Capabilities[0]);
}
[Fact]
public void Validate_Throws_When_StorageConnectionStringMissing()
{
var options = new StellaOpsAuthorityOptions
{
Issuer = new Uri("https://authority.stella-ops.test"),
SchemaVersion = 1
};
options.Signing.ActiveKeyId = "test-key";
options.Signing.KeyPath = "/tmp/test-key.pem";
var exception = Assert.Throws<InvalidOperationException>(() => options.Validate());
Assert.Contains("Mongo connection string", exception.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void Build_Binds_From_Configuration()
{
var context = StellaOpsAuthorityConfiguration.Build(options =>
{
options.ConfigureBuilder = builder =>
{
builder.AddInMemoryCollection(new Dictionary<string, string?>
{
["Authority:SchemaVersion"] = "2",
["Authority:Issuer"] = "https://authority.internal",
["Authority:AccessTokenLifetime"] = "00:30:00",
["Authority:RefreshTokenLifetime"] = "30.00:00:00",
["Authority:Storage:ConnectionString"] = "mongodb://example/stellaops",
["Authority:Storage:DatabaseName"] = "overrideDb",
["Authority:Storage:CommandTimeout"] = "00:01:30",
["Authority:PluginDirectories:0"] = "/var/lib/stellaops/plugins",
["Authority:BypassNetworks:0"] = "127.0.0.1/32",
["Authority:Security:RateLimiting:Token:PermitLimit"] = "25",
["Authority:Security:RateLimiting:Token:Window"] = "00:00:30",
["Authority:Security:RateLimiting:Authorize:Enabled"] = "true",
["Authority:Security:RateLimiting:Internal:Enabled"] = "true",
["Authority:Security:RateLimiting:Internal:PermitLimit"] = "3",
["Authority:Signing:Enabled"] = "true",
["Authority:Signing:ActiveKeyId"] = "authority-signing-dev",
["Authority:Signing:KeyPath"] = "../certificates/authority-signing-dev.pem",
["Authority:Signing:KeySource"] = "file"
});
};
});
var options = context.Options;
Assert.Equal(2, options.SchemaVersion);
Assert.Equal(new Uri("https://authority.internal"), options.Issuer);
Assert.Equal(TimeSpan.FromMinutes(30), options.AccessTokenLifetime);
Assert.Equal(TimeSpan.FromDays(30), options.RefreshTokenLifetime);
Assert.Equal(new[] { "/var/lib/stellaops/plugins" }, options.PluginDirectories);
Assert.Equal(new[] { "127.0.0.1/32" }, options.BypassNetworks);
Assert.Equal("mongodb://example/stellaops", options.Storage.ConnectionString);
Assert.Equal("overrideDb", options.Storage.DatabaseName);
Assert.Equal(TimeSpan.FromMinutes(1.5), options.Storage.CommandTimeout);
Assert.Equal(25, options.Security.RateLimiting.Token.PermitLimit);
Assert.Equal(TimeSpan.FromSeconds(30), options.Security.RateLimiting.Token.Window);
Assert.True(options.Security.RateLimiting.Authorize.Enabled);
Assert.True(options.Security.RateLimiting.Internal.Enabled);
Assert.Equal(3, options.Security.RateLimiting.Internal.PermitLimit);
Assert.True(options.Signing.Enabled);
Assert.Equal("authority-signing-dev", options.Signing.ActiveKeyId);
Assert.Equal("../certificates/authority-signing-dev.pem", options.Signing.KeyPath);
Assert.Equal("file", options.Signing.KeySource);
}
[Fact]
public void Validate_Normalises_ExceptionRoutingTemplates()
{
var options = new StellaOpsAuthorityOptions
{
Issuer = new Uri("https://authority.stella-ops.test"),
SchemaVersion = 1
};
options.Storage.ConnectionString = "mongodb://localhost:27017/authority";
options.Signing.ActiveKeyId = "test-key";
options.Signing.KeyPath = "/tmp/test-key.pem";
options.Exceptions.RoutingTemplates.Add(new AuthorityExceptionRoutingTemplateOptions
{
Id = " SecOps ",
AuthorityRouteId = " approvals/secops ",
RequireMfa = true,
Description = " Security approvals "
});
options.Validate();
Assert.True(options.Exceptions.RequiresMfaForApprovals);
var template = Assert.Single(options.Exceptions.NormalizedRoutingTemplates);
Assert.Equal("SecOps", template.Key);
Assert.Equal("SecOps", template.Value.Id);
Assert.Equal("approvals/secops", template.Value.AuthorityRouteId);
Assert.Equal("Security approvals", template.Value.Description);
Assert.True(template.Value.RequireMfa);
}
[Fact]
public void Validate_Throws_When_ExceptionRoutingTemplatesDuplicate()
{
var options = new StellaOpsAuthorityOptions
{
Issuer = new Uri("https://authority.stella-ops.test"),
SchemaVersion = 1
};
options.Storage.ConnectionString = "mongodb://localhost:27017/authority";
options.Signing.ActiveKeyId = "test-key";
options.Signing.KeyPath = "/tmp/test-key.pem";
options.Exceptions.RoutingTemplates.Add(new AuthorityExceptionRoutingTemplateOptions
{
Id = "secops",
AuthorityRouteId = "route/a"
});
options.Exceptions.RoutingTemplates.Add(new AuthorityExceptionRoutingTemplateOptions
{
Id = "SecOps",
AuthorityRouteId = "route/b"
});
var exception = Assert.Throws<InvalidOperationException>(() => options.Validate());
Assert.Contains("secops", exception.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void Validate_Throws_When_RateLimitingInvalid()
{
var options = new StellaOpsAuthorityOptions
{
Issuer = new Uri("https://authority.stella-ops.test"),
SchemaVersion = 1
};
options.Storage.ConnectionString = "mongodb://localhost:27017/authority";
options.Security.RateLimiting.Token.PermitLimit = 0;
options.Signing.ActiveKeyId = "test-key";
options.Signing.KeyPath = "/tmp/test-key.pem";
var exception = Assert.Throws<InvalidOperationException>(() => options.Validate());
Assert.Contains("permitLimit", exception.Message, StringComparison.OrdinalIgnoreCase);
}
}

View File

@@ -0,0 +1,41 @@
using System;
using StellaOps.Cryptography;
using Xunit;
namespace StellaOps.Cryptography.Tests;
public class Argon2idPasswordHasherTests
{
private readonly Argon2idPasswordHasher hasher = new();
[Fact]
public void Hash_ProducesPhcEncodedString()
{
var options = new PasswordHashOptions();
var encoded = hasher.Hash("s3cret", options);
Assert.StartsWith("$argon2id$", encoded, StringComparison.Ordinal);
}
[Fact]
public void Verify_ReturnsTrue_ForCorrectPassword()
{
var options = new PasswordHashOptions();
var encoded = hasher.Hash("s3cret", options);
Assert.True(hasher.Verify("s3cret", encoded));
Assert.False(hasher.Verify("wrong", encoded));
}
[Fact]
public void NeedsRehash_ReturnsTrue_WhenParametersChange()
{
var options = new PasswordHashOptions();
var encoded = hasher.Hash("s3cret", options);
var updated = options with { Iterations = options.Iterations + 1 };
Assert.True(hasher.NeedsRehash(encoded, updated));
Assert.False(hasher.NeedsRehash(encoded, options));
}
}

View File

@@ -0,0 +1,57 @@
using System;
using StellaOps.Cryptography.Audit;
namespace StellaOps.Cryptography.Tests.Audit;
public class AuthEventRecordTests
{
[Fact]
public void AuthEventRecord_InitializesCollections()
{
var record = new AuthEventRecord
{
EventType = "authority.test",
Outcome = AuthEventOutcome.Success
};
Assert.NotNull(record.Scopes);
Assert.Empty(record.Scopes);
Assert.NotNull(record.Properties);
Assert.Empty(record.Properties);
Assert.False(record.Tenant.HasValue);
Assert.False(record.Project.HasValue);
}
[Fact]
public void ClassifiedString_NormalizesWhitespace()
{
var value = ClassifiedString.Personal(" ");
Assert.Null(value.Value);
Assert.False(value.HasValue);
Assert.Equal(AuthEventDataClassification.Personal, value.Classification);
}
[Fact]
public void Subject_DefaultsToEmptyCollections()
{
var subject = new AuthEventSubject();
Assert.NotNull(subject.Attributes);
Assert.Empty(subject.Attributes);
}
[Fact]
public void Record_AssignsTimestamp_WhenNotProvided()
{
var record = new AuthEventRecord
{
EventType = "authority.test",
Outcome = AuthEventOutcome.Success
};
Assert.NotEqual(default, record.OccurredAt);
Assert.InRange(
record.OccurredAt,
DateTimeOffset.UtcNow.AddSeconds(-5),
DateTimeOffset.UtcNow.AddSeconds(5));
}
}

View File

@@ -0,0 +1,52 @@
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Cryptography;
using StellaOps.Cryptography.DependencyInjection;
using StellaOps.Cryptography.Plugin.BouncyCastle;
using Xunit;
namespace StellaOps.Cryptography.Tests;
public sealed class BouncyCastleEd25519CryptoProviderTests
{
[Fact]
public async Task SignAndVerify_WithBouncyCastleProvider_Succeeds()
{
var services = new ServiceCollection();
services.AddStellaOpsCrypto();
services.AddBouncyCastleEd25519Provider();
using var provider = services.BuildServiceProvider();
var registry = provider.GetRequiredService<ICryptoProviderRegistry>();
var bcProvider = provider.GetServices<ICryptoProvider>()
.OfType<BouncyCastleEd25519CryptoProvider>()
.Single();
var keyId = "ed25519-unit-test";
var privateKeyBytes = Enumerable.Range(0, 32).Select(i => (byte)(i + 1)).ToArray();
var keyReference = new CryptoKeyReference(keyId, bcProvider.Name);
var signingKey = new CryptoSigningKey(
keyReference,
SignatureAlgorithms.Ed25519,
privateKeyBytes,
createdAt: DateTimeOffset.UtcNow);
bcProvider.UpsertSigningKey(signingKey);
var resolution = registry.ResolveSigner(
CryptoCapability.Signing,
SignatureAlgorithms.Ed25519,
keyReference,
bcProvider.Name);
var payload = new byte[] { 0x01, 0x02, 0x03, 0x04 };
var signature = await resolution.Signer.SignAsync(payload);
Assert.True(await resolution.Signer.VerifyAsync(payload, signature));
var jwk = resolution.Signer.ExportPublicJsonWebKey();
Assert.Equal("OKP", jwk.Kty);
Assert.Equal("Ed25519", jwk.Crv);
Assert.Equal(SignatureAlgorithms.EdDsa, jwk.Alg);
Assert.Equal(keyId, jwk.Kid);
}
}

View File

@@ -0,0 +1,156 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.IdentityModel.Tokens;
using StellaOps.Cryptography;
using Xunit;
namespace StellaOps.Cryptography.Tests;
public class CryptoProviderRegistryTests
{
[Fact]
public void ResolveOrThrow_RespectsPreferredProviderOrder()
{
var providerA = new FakeCryptoProvider("providerA")
.WithSupport(CryptoCapability.Signing, SignatureAlgorithms.Es256)
.WithSigner(SignatureAlgorithms.Es256, "key-a");
var providerB = new FakeCryptoProvider("providerB")
.WithSupport(CryptoCapability.Signing, SignatureAlgorithms.Es256)
.WithSigner(SignatureAlgorithms.Es256, "key-b");
var registry = new CryptoProviderRegistry(new[] { providerA, providerB }, new[] { "providerB" });
var resolved = registry.ResolveOrThrow(CryptoCapability.Signing, SignatureAlgorithms.Es256);
Assert.Same(providerB, resolved);
}
[Fact]
public void ResolveSigner_UsesPreferredProviderHint()
{
var providerA = new FakeCryptoProvider("providerA")
.WithSupport(CryptoCapability.Signing, SignatureAlgorithms.Es256)
.WithSigner(SignatureAlgorithms.Es256, "key-a");
var providerB = new FakeCryptoProvider("providerB")
.WithSupport(CryptoCapability.Signing, SignatureAlgorithms.Es256)
.WithSigner(SignatureAlgorithms.Es256, "key-b");
var registry = new CryptoProviderRegistry(new[] { providerA, providerB }, Array.Empty<string>());
var hintResolution = registry.ResolveSigner(
CryptoCapability.Signing,
SignatureAlgorithms.Es256,
new CryptoKeyReference("key-b"),
preferredProvider: "providerB");
Assert.Equal("providerB", hintResolution.ProviderName);
Assert.Equal("key-b", hintResolution.Signer.KeyId);
var fallbackResolution = registry.ResolveSigner(
CryptoCapability.Signing,
SignatureAlgorithms.Es256,
new CryptoKeyReference("key-a"));
Assert.Equal("providerA", fallbackResolution.ProviderName);
Assert.Equal("key-a", fallbackResolution.Signer.KeyId);
}
private sealed class FakeCryptoProvider : ICryptoProvider
{
private readonly Dictionary<string, FakeSigner> signers = new(StringComparer.Ordinal);
private readonly HashSet<(CryptoCapability Capability, string Algorithm)> supported;
public FakeCryptoProvider(string name)
{
Name = name;
supported = new HashSet<(CryptoCapability, string)>(new CapabilityAlgorithmComparer());
}
public string Name { get; }
public FakeCryptoProvider WithSupport(CryptoCapability capability, string algorithm)
{
supported.Add((capability, algorithm));
return this;
}
public FakeCryptoProvider WithSigner(string algorithm, string keyId)
{
WithSupport(CryptoCapability.Signing, algorithm);
var signer = new FakeSigner(Name, keyId, algorithm);
signers[keyId] = signer;
return this;
}
public bool Supports(CryptoCapability capability, string algorithmId)
=> supported.Contains((capability, algorithmId));
public IPasswordHasher GetPasswordHasher(string algorithmId)
=> throw new NotSupportedException();
public ICryptoSigner GetSigner(string algorithmId, CryptoKeyReference keyReference)
{
if (!signers.TryGetValue(keyReference.KeyId, out var signer))
{
throw new KeyNotFoundException();
}
if (!string.Equals(signer.AlgorithmId, algorithmId, StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException("Signer algorithm mismatch.");
}
return signer;
}
public void UpsertSigningKey(CryptoSigningKey signingKey)
=> signers[signingKey.Reference.KeyId] = new FakeSigner(Name, signingKey.Reference.KeyId, signingKey.AlgorithmId);
public bool RemoveSigningKey(string keyId) => signers.Remove(keyId);
public IReadOnlyCollection<CryptoSigningKey> GetSigningKeys() => Array.Empty<CryptoSigningKey>();
private sealed class CapabilityAlgorithmComparer : IEqualityComparer<(CryptoCapability Capability, string Algorithm)>
{
public bool Equals((CryptoCapability Capability, string Algorithm) x, (CryptoCapability Capability, string Algorithm) y)
=> x.Capability == y.Capability && string.Equals(x.Algorithm, y.Algorithm, StringComparison.OrdinalIgnoreCase);
public int GetHashCode((CryptoCapability Capability, string Algorithm) obj)
=> HashCode.Combine(obj.Capability, obj.Algorithm.ToUpperInvariant());
}
}
private sealed class FakeSigner : ICryptoSigner
{
public FakeSigner(string provider, string keyId, string algorithmId)
{
Provider = provider;
KeyId = keyId;
AlgorithmId = algorithmId;
}
public string Provider { get; }
public string KeyId { get; }
public string AlgorithmId { get; }
public ValueTask<byte[]> SignAsync(ReadOnlyMemory<byte> data, CancellationToken cancellationToken = default)
=> ValueTask.FromResult(Array.Empty<byte>());
public ValueTask<bool> VerifyAsync(ReadOnlyMemory<byte> data, ReadOnlyMemory<byte> signature, CancellationToken cancellationToken = default)
=> ValueTask.FromResult(true);
public JsonWebKey ExportPublicJsonWebKey() => new()
{
Kid = KeyId,
Alg = AlgorithmId,
Kty = JsonWebAlgorithmsKeyTypes.Octet,
Use = JsonWebKeyUseNames.Sig
};
}
}

View File

@@ -0,0 +1,68 @@
using System;
using System.Collections.Generic;
using System.Security.Cryptography;
using System.Text;
using System.Threading.Tasks;
using Microsoft.IdentityModel.Tokens;
using StellaOps.Cryptography;
using Xunit;
namespace StellaOps.Cryptography.Tests;
public class DefaultCryptoProviderSigningTests
{
[Fact]
public async Task UpsertSigningKey_AllowsSignAndVerifyEs256()
{
var provider = new DefaultCryptoProvider();
using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
var parameters = ecdsa.ExportParameters(includePrivateParameters: true);
var signingKey = new CryptoSigningKey(
new CryptoKeyReference("revocation-key"),
SignatureAlgorithms.Es256,
privateParameters: in parameters,
createdAt: DateTimeOffset.UtcNow);
provider.UpsertSigningKey(signingKey);
var signer = provider.GetSigner(SignatureAlgorithms.Es256, signingKey.Reference);
var payload = Encoding.UTF8.GetBytes("hello-world");
var signature = await signer.SignAsync(payload);
Assert.NotNull(signature);
Assert.True(signature.Length > 0);
var verified = await signer.VerifyAsync(payload, signature);
Assert.True(verified);
var jwk = signer.ExportPublicJsonWebKey();
Assert.Equal(signingKey.Reference.KeyId, jwk.Kid);
Assert.Equal(SignatureAlgorithms.Es256, jwk.Alg);
Assert.Equal(JsonWebAlgorithmsKeyTypes.EllipticCurve, jwk.Kty);
Assert.Equal(JsonWebKeyUseNames.Sig, jwk.Use);
Assert.Equal(JsonWebKeyECTypes.P256, jwk.Crv);
Assert.False(string.IsNullOrWhiteSpace(jwk.X));
Assert.False(string.IsNullOrWhiteSpace(jwk.Y));
var tampered = (byte[])signature.Clone();
tampered[^1] ^= 0xFF;
var tamperedResult = await signer.VerifyAsync(payload, tampered);
Assert.False(tamperedResult);
}
[Fact]
public void RemoveSigningKey_PreventsRetrieval()
{
var provider = new DefaultCryptoProvider();
using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
var parameters = ecdsa.ExportParameters(true);
var signingKey = new CryptoSigningKey(new CryptoKeyReference("key-to-remove"), SignatureAlgorithms.Es256, in parameters, DateTimeOffset.UtcNow);
provider.UpsertSigningKey(signingKey);
Assert.True(provider.RemoveSigningKey(signingKey.Reference.KeyId));
Assert.Throws<KeyNotFoundException>(() => provider.GetSigner(SignatureAlgorithms.Es256, signingKey.Reference));
}
}

View File

@@ -0,0 +1,38 @@
#if STELLAOPS_CRYPTO_SODIUM
using System;
using System.Security.Cryptography;
using System.Text;
using System.Threading.Tasks;
using Xunit;
namespace StellaOps.Cryptography.Tests;
public class LibsodiumCryptoProviderTests
{
[Fact]
public async Task LibsodiumProvider_SignsAndVerifiesEs256()
{
var provider = new LibsodiumCryptoProvider();
using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
var parameters = ecdsa.ExportParameters(includePrivateParameters: true);
var signingKey = new CryptoSigningKey(
new CryptoKeyReference("libsodium-key"),
SignatureAlgorithms.Es256,
privateParameters: in parameters,
createdAt: DateTimeOffset.UtcNow);
provider.UpsertSigningKey(signingKey);
var signer = provider.GetSigner(SignatureAlgorithms.Es256, signingKey.Reference);
var payload = Encoding.UTF8.GetBytes("libsodium-test");
var signature = await signer.SignAsync(payload);
Assert.True(signature.Length > 0);
var verified = await signer.VerifyAsync(payload, signature);
Assert.True(verified);
}
}
#endif

View File

@@ -0,0 +1,24 @@
using StellaOps.Cryptography;
namespace StellaOps.Cryptography.Tests;
public class PasswordHashOptionsTests
{
[Fact]
public void Validate_DoesNotThrow_ForDefaults()
{
var options = new PasswordHashOptions();
options.Validate();
}
[Fact]
public void Validate_Throws_WhenMemoryInvalid()
{
var options = new PasswordHashOptions
{
MemorySizeInKib = 0
};
Assert.Throws<InvalidOperationException>(options.Validate);
}
}

View File

@@ -0,0 +1,56 @@
using System;
using StellaOps.Cryptography;
using Xunit;
namespace StellaOps.Cryptography.Tests;
public class Pbkdf2PasswordHasherTests
{
private readonly Pbkdf2PasswordHasher hasher = new();
[Fact]
public void Hash_ProducesLegacyFormat()
{
var options = new PasswordHashOptions
{
Algorithm = PasswordHashAlgorithm.Pbkdf2,
Iterations = 210_000
};
var encoded = hasher.Hash("s3cret", options);
Assert.StartsWith("PBKDF2.", encoded, StringComparison.Ordinal);
}
[Fact]
public void Verify_Succeeds_ForCorrectPassword()
{
var options = new PasswordHashOptions
{
Algorithm = PasswordHashAlgorithm.Pbkdf2,
Iterations = 210_000
};
var encoded = hasher.Hash("s3cret", options);
Assert.True(hasher.Verify("s3cret", encoded));
Assert.False(hasher.Verify("other", encoded));
}
[Fact]
public void NeedsRehash_DetectsIterationChange()
{
var options = new PasswordHashOptions
{
Algorithm = PasswordHashAlgorithm.Pbkdf2,
Iterations = 100_000
};
var encoded = hasher.Hash("s3cret", options);
var higher = options with { Iterations = 150_000 };
Assert.True(hasher.NeedsRehash(encoded, higher));
Assert.False(hasher.NeedsRehash(encoded, options));
}
}

View File

@@ -0,0 +1,17 @@
<?xml version='1.0' encoding='utf-8'?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
</PropertyGroup>
<PropertyGroup Condition="'$(StellaOpsCryptoSodium)' == 'true'">
<DefineConstants>$(DefineConstants);STELLAOPS_CRYPTO_SODIUM</DefineConstants>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../../StellaOps.Cryptography/StellaOps.Cryptography.csproj" />
<ProjectReference Include="../../StellaOps.Cryptography.DependencyInjection/StellaOps.Cryptography.DependencyInjection.csproj" />
<ProjectReference Include="../../StellaOps.Cryptography.Plugin.BouncyCastle/StellaOps.Cryptography.Plugin.BouncyCastle.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,176 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.DependencyInjection;
using StellaOps.Plugin.DependencyInjection;
using StellaOps.Plugin.Hosting;
using Xunit;
namespace StellaOps.Plugin.Tests.DependencyInjection;
public sealed class PluginDependencyInjectionExtensionsTests
{
[Fact]
public void RegisterPluginRoutines_RegistersServiceBindingsAndHonoursLifetimes()
{
const string source = """
using Microsoft.Extensions.DependencyInjection;
using StellaOps.DependencyInjection;
namespace SamplePlugin;
public interface IScopedExample {}
public interface ISingletonExample {}
[ServiceBinding(typeof(IScopedExample), ServiceLifetime.Scoped, RegisterAsSelf = true)]
public sealed class ScopedExample : IScopedExample {}
[ServiceBinding(typeof(ISingletonExample), ServiceLifetime.Singleton)]
public sealed class SingletonExample : ISingletonExample {}
""";
using var plugin = TestPluginAssembly.Create(source);
var configuration = new ConfigurationBuilder().Build();
var services = new ServiceCollection();
services.RegisterPluginRoutines(configuration, plugin.Options, NullLogger.Instance);
var scopedDescriptor = Assert.Single(
services,
static d => d.ServiceType.FullName == "SamplePlugin.IScopedExample");
Assert.Equal(ServiceLifetime.Scoped, scopedDescriptor.Lifetime);
Assert.Equal("SamplePlugin.ScopedExample", scopedDescriptor.ImplementationType?.FullName);
var scopedSelfDescriptor = Assert.Single(
services,
static d => d.ServiceType.FullName == "SamplePlugin.ScopedExample");
Assert.Equal(ServiceLifetime.Scoped, scopedSelfDescriptor.Lifetime);
var singletonDescriptor = Assert.Single(
services,
static d => d.ServiceType.FullName == "SamplePlugin.ISingletonExample");
Assert.Equal(ServiceLifetime.Singleton, singletonDescriptor.Lifetime);
using var provider = services.BuildServiceProvider();
object firstScopeInstance;
using (var scope = provider.CreateScope())
{
var resolvedFirst = ServiceProviderServiceExtensions.GetRequiredService(scope.ServiceProvider, scopedDescriptor.ServiceType);
var resolvedSecond = ServiceProviderServiceExtensions.GetRequiredService(scope.ServiceProvider, scopedDescriptor.ServiceType);
Assert.Same(resolvedFirst, resolvedSecond);
firstScopeInstance = resolvedFirst;
}
using (var scope = provider.CreateScope())
{
var resolved = ServiceProviderServiceExtensions.GetRequiredService(scope.ServiceProvider, scopedDescriptor.ServiceType);
Assert.NotSame(firstScopeInstance, resolved);
}
var singletonFirst = ServiceProviderServiceExtensions.GetRequiredService(provider, singletonDescriptor.ServiceType);
var singletonSecond = ServiceProviderServiceExtensions.GetRequiredService(provider, singletonDescriptor.ServiceType);
Assert.Same(singletonFirst, singletonSecond);
services.RegisterPluginRoutines(configuration, plugin.Options, NullLogger.Instance);
var scopedRegistrations = services.Count(d =>
d.ServiceType.FullName == "SamplePlugin.IScopedExample" &&
d.ImplementationType?.FullName == "SamplePlugin.ScopedExample");
Assert.Equal(1, scopedRegistrations);
}
private sealed class TestPluginAssembly : IDisposable
{
private TestPluginAssembly(string directoryPath, string assemblyPath)
{
DirectoryPath = directoryPath;
AssemblyPath = assemblyPath;
Options = new PluginHostOptions
{
PluginsDirectory = directoryPath,
EnsureDirectoryExists = false,
RecursiveSearch = false,
};
Options.SearchPatterns.Add(Path.GetFileName(assemblyPath));
}
public string DirectoryPath { get; }
public string AssemblyPath { get; }
public PluginHostOptions Options { get; }
public static TestPluginAssembly Create(string source)
{
var directoryPath = Path.Combine(Path.GetTempPath(), "stellaops-plugin-tests-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(directoryPath);
var assemblyName = "SamplePlugin" + Guid.NewGuid().ToString("N");
var assemblyPath = Path.Combine(directoryPath, assemblyName + ".dll");
var syntaxTree = CSharpSyntaxTree.ParseText(source);
var references = CollectMetadataReferences();
var compilation = CSharpCompilation.Create(
assemblyName,
new[] { syntaxTree },
references,
new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary, optimizationLevel: OptimizationLevel.Release));
var emitResult = compilation.Emit(assemblyPath);
if (!emitResult.Success)
{
var diagnostics = string.Join(Environment.NewLine, emitResult.Diagnostics);
throw new InvalidOperationException("Failed to compile plugin assembly:" + Environment.NewLine + diagnostics);
}
return new TestPluginAssembly(directoryPath, assemblyPath);
}
public void Dispose()
{
try
{
if (Directory.Exists(DirectoryPath))
{
Directory.Delete(DirectoryPath, recursive: true);
}
}
catch
{
// Ignore cleanup failures plugin load contexts may keep files locked on Windows.
}
}
private static IReadOnlyCollection<MetadataReference> CollectMetadataReferences()
{
var referencePaths = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
if (AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES") is string tpa)
{
foreach (var path in tpa.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries))
{
referencePaths.Add(path);
}
}
referencePaths.Add(typeof(object).Assembly.Location);
referencePaths.Add(typeof(ServiceBindingAttribute).Assembly.Location);
referencePaths.Add(typeof(IDependencyInjectionRoutine).Assembly.Location);
referencePaths.Add(typeof(ServiceLifetime).Assembly.Location);
return referencePaths
.Select(path => MetadataReference.CreateFromFile(path))
.ToArray();
}
}
}

View File

@@ -0,0 +1,112 @@
using System.Linq;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.DependencyInjection;
using StellaOps.Plugin.DependencyInjection;
using Xunit;
namespace StellaOps.Plugin.Tests.DependencyInjection;
public sealed class PluginServiceRegistrationTests
{
[Fact]
public void RegisterAssemblyMetadata_RegistersScopedDescriptor()
{
var services = new ServiceCollection();
PluginServiceRegistration.RegisterAssemblyMetadata(
services,
typeof(ScopedTestService).Assembly,
NullLogger.Instance);
var descriptor = Assert.Single(services, static d => d.ServiceType == typeof(IScopedService));
Assert.Equal(ServiceLifetime.Scoped, descriptor.Lifetime);
Assert.Equal(typeof(ScopedTestService), descriptor.ImplementationType);
}
[Fact]
public void RegisterAssemblyMetadata_HonoursRegisterAsSelf()
{
var services = new ServiceCollection();
PluginServiceRegistration.RegisterAssemblyMetadata(
services,
typeof(SelfRegisteringService).Assembly,
NullLogger.Instance);
Assert.Contains(services, static d =>
d.ServiceType == typeof(SelfRegisteringService) &&
d.ImplementationType == typeof(SelfRegisteringService));
}
[Fact]
public void RegisterAssemblyMetadata_ReplacesExistingDescriptorsWhenRequested()
{
var services = new ServiceCollection();
services.AddSingleton<IReplacementService, ExistingReplacementService>();
PluginServiceRegistration.RegisterAssemblyMetadata(
services,
typeof(ReplacementService).Assembly,
NullLogger.Instance);
var descriptor = Assert.Single(
services,
static d => d.ServiceType == typeof(IReplacementService) &&
d.ImplementationType == typeof(ReplacementService));
Assert.Equal(ServiceLifetime.Transient, descriptor.Lifetime);
}
[Fact]
public void RegisterAssemblyMetadata_SkipsInvalidAssignments()
{
var services = new ServiceCollection();
PluginServiceRegistration.RegisterAssemblyMetadata(
services,
typeof(InvalidServiceBinding).Assembly,
NullLogger.Instance);
Assert.DoesNotContain(services, static d => d.ServiceType == typeof(IAnotherService));
}
private interface IScopedService
{
}
private interface ISelfContract
{
}
private interface IReplacementService
{
}
private interface IAnotherService
{
}
private sealed class ExistingReplacementService : IReplacementService
{
}
[ServiceBinding(typeof(IScopedService), ServiceLifetime.Scoped)]
private sealed class ScopedTestService : IScopedService
{
}
[ServiceBinding(typeof(ISelfContract), ServiceLifetime.Singleton, RegisterAsSelf = true)]
private sealed class SelfRegisteringService : ISelfContract
{
}
[ServiceBinding(typeof(IReplacementService), ServiceLifetime.Transient, ReplaceExisting = true)]
private sealed class ReplacementService : IReplacementService
{
}
[ServiceBinding(typeof(IAnotherService), ServiceLifetime.Singleton)]
private sealed class InvalidServiceBinding
{
}
}

View File

@@ -0,0 +1,22 @@
<?xml version='1.0' encoding='utf-8'?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<UseConcelierTestInfra>false</UseConcelierTestInfra>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../../StellaOps.DependencyInjection/StellaOps.DependencyInjection.csproj" />
<ProjectReference Include="../../StellaOps.Plugin/StellaOps.Plugin.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.14.0" />
<PackageReference Include="Microsoft.Extensions.Configuration" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,138 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Net;
using System.Net.Http.Json;
using System.Text;
using System.Threading.Tasks;
using MongoDB.Driver;
using StellaOps.Signals.Models;
using StellaOps.Signals.Tests.TestInfrastructure;
using Xunit;
namespace StellaOps.Signals.Tests;
public class CallgraphIngestionTests : IClassFixture<SignalsTestFactory>
{
private readonly SignalsTestFactory factory;
public CallgraphIngestionTests(SignalsTestFactory factory)
{
this.factory = factory;
}
[Theory]
[InlineData("java")]
[InlineData("nodejs")]
[InlineData("python")]
[InlineData("go")]
public async Task Ingest_Callgraph_PersistsDocumentAndArtifact(string language)
{
using var client = factory.CreateClient();
client.DefaultRequestHeaders.Add("X-Scopes", "signals:write");
var component = $"demo-{language}";
var request = CreateRequest(language, component: component);
var response = await client.PostAsJsonAsync("/signals/callgraphs", request);
Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<CallgraphIngestResponse>();
Assert.NotNull(body);
var database = new MongoClient(factory.MongoRunner.ConnectionString).GetDatabase("signals-tests");
var collection = database.GetCollection<CallgraphDocument>("callgraphs");
var doc = await collection.Find(d => d.Id == body!.CallgraphId).FirstOrDefaultAsync();
Assert.NotNull(doc);
Assert.Equal(language, doc!.Language);
Assert.Equal(component, doc.Component);
Assert.Equal("1.0.0", doc.Version);
Assert.Equal(2, doc.Nodes.Count);
Assert.Equal(1, doc.Edges.Count);
var artifactPath = Path.Combine(factory.StoragePath, body.ArtifactPath);
Assert.True(File.Exists(artifactPath));
Assert.False(string.IsNullOrWhiteSpace(body.ArtifactHash));
}
[Fact]
public async Task Ingest_UnsupportedLanguage_ReturnsBadRequest()
{
using var client = factory.CreateClient();
client.DefaultRequestHeaders.Add("X-Scopes", "signals:write");
var request = CreateRequest("ruby");
var response = await client.PostAsJsonAsync("/signals/callgraphs", request);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task Ingest_InvalidArtifactContent_ReturnsBadRequest()
{
using var client = factory.CreateClient();
client.DefaultRequestHeaders.Add("X-Scopes", "signals:write");
var request = CreateRequest("java") with { ArtifactContentBase64 = "not-base64" };
var response = await client.PostAsJsonAsync("/signals/callgraphs", request);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task Ingest_InvalidGraphStructure_ReturnsUnprocessableEntity()
{
using var client = factory.CreateClient();
client.DefaultRequestHeaders.Add("X-Scopes", "signals:write");
var json = "{\"formatVersion\":\"1.0\",\"graph\":{}}";
var request = CreateRequest("java", json);
var response = await client.PostAsJsonAsync("/signals/callgraphs", request);
Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode);
}
[Fact]
public async Task Ingest_SameComponentUpsertsDocument()
{
using var client = factory.CreateClient();
client.DefaultRequestHeaders.Add("X-Scopes", "signals:write");
var firstRequest = CreateRequest("python");
var secondJson = "{\"graph\":{\"nodes\":[{\"id\":\"module.entry\",\"name\":\"module.entry\"}],\"edges\":[]}}";
var secondRequest = CreateRequest("python", secondJson);
var firstResponse = await client.PostAsJsonAsync("/signals/callgraphs", firstRequest);
var secondResponse = await client.PostAsJsonAsync("/signals/callgraphs", secondRequest);
Assert.Equal(HttpStatusCode.Accepted, firstResponse.StatusCode);
Assert.Equal(HttpStatusCode.Accepted, secondResponse.StatusCode);
var database = new MongoClient(factory.MongoRunner.ConnectionString).GetDatabase("signals-tests");
var collection = database.GetCollection<CallgraphDocument>("callgraphs");
var count = await collection.CountDocumentsAsync(FilterDefinition<CallgraphDocument>.Empty);
Assert.Equal(1, count);
var doc = await collection.Find(_ => true).FirstAsync();
Assert.Single(doc.Nodes);
Assert.Equal("python", doc.Language);
}
private static CallgraphIngestRequest CreateRequest(string language, string? customJson = null, string component = "demo")
{
var json = customJson ?? "{\"formatVersion\":\"1.0\",\"graph\":{\"nodes\":[{\"id\":\"main.entry\",\"name\":\"main.entry\",\"kind\":\"function\",\"file\":\"main\",\"line\":1},{\"id\":\"helper.run\",\"name\":\"helper.run\",\"kind\":\"function\",\"file\":\"helper\",\"line\":2}],\"edges\":[{\"source\":\"main.entry\",\"target\":\"helper.run\",\"type\":\"call\"}]}}";
var base64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(json));
return new CallgraphIngestRequest(
Language: language,
Component: component,
Version: "1.0.0",
ArtifactContentType: "application/json",
ArtifactFileName: $"{language}-callgraph.json",
ArtifactContentBase64: base64,
Metadata: new Dictionary<string, string?>
{
["source"] = "unit-test"
});
}
}

View File

@@ -0,0 +1,112 @@
using System.Collections.Generic;
using System.Net;
using System.Net.Http.Json;
using System.Threading.Tasks;
using Microsoft.Extensions.Configuration;
using StellaOps.Signals.Tests.TestInfrastructure;
using Xunit;
namespace StellaOps.Signals.Tests;
public class SignalsApiTests : IClassFixture<SignalsTestFactory>
{
private readonly SignalsTestFactory factory;
public SignalsApiTests(SignalsTestFactory factory)
{
this.factory = factory;
}
[Fact]
public async Task Healthz_ReturnsOk()
{
using var client = factory.CreateClient();
var response = await client.GetAsync("/healthz");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
[Fact]
public async Task Readyz_ReturnsOk()
{
using var client = factory.CreateClient();
var response = await client.GetAsync("/readyz");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var payload = await response.Content.ReadFromJsonAsync<Dictionary<string, string>>();
Assert.NotNull(payload);
Assert.Equal("ready", payload!["status"]);
}
[Fact]
public async Task Ping_WithoutScopeHeader_ReturnsUnauthorized()
{
using var client = factory.CreateClient();
var response = await client.GetAsync("/signals/ping");
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
[Fact]
public async Task Ping_WithMissingScope_ReturnsForbidden()
{
using var client = factory.CreateClient();
client.DefaultRequestHeaders.Add("X-Scopes", "signals:write");
var response = await client.GetAsync("/signals/ping");
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
}
[Fact]
public async Task Ping_WithReadScope_ReturnsNoContent()
{
using var client = factory.CreateClient();
client.DefaultRequestHeaders.Add("X-Scopes", "signals:read");
var response = await client.GetAsync("/signals/ping");
Assert.Equal(HttpStatusCode.NoContent, response.StatusCode);
}
[Fact]
public async Task Ping_WithFallbackDisabled_ReturnsUnauthorized()
{
using var app = factory.WithWebHostBuilder(builder =>
{
builder.ConfigureAppConfiguration((_, configuration) =>
{
configuration.AddInMemoryCollection(new Dictionary<string, string?>
{
["Signals:Authority:AllowAnonymousFallback"] = "false"
});
});
});
using var client = app.CreateClient();
var response = await client.GetAsync("/signals/ping");
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
[Fact]
public async Task Status_WithReadScope_ReturnsOk()
{
using var client = factory.CreateClient();
client.DefaultRequestHeaders.Add("X-Scopes", "signals:read");
var response = await client.GetAsync("/signals/status");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var payload = await response.Content.ReadFromJsonAsync<Dictionary<string, string>>();
Assert.NotNull(payload);
Assert.Equal("signals", payload!["service"]);
}
[Fact]
public async Task Status_WithMissingScope_ReturnsForbidden()
{
using var client = factory.CreateClient();
client.DefaultRequestHeaders.Add("X-Scopes", "signals:write");
var response = await client.GetAsync("/signals/status");
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
}
}

View File

@@ -0,0 +1,22 @@
<?xml version='1.0' encoding='utf-8'?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
<PackageReference Include="Mongo2Go" Version="4.1.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
<PackageReference Include="coverlet.collector" Version="6.0.4" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../../Signals/StellaOps.Signals/StellaOps.Signals.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,64 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Configuration;
using Mongo2Go;
namespace StellaOps.Signals.Tests.TestInfrastructure;
internal sealed class SignalsTestFactory : WebApplicationFactory<Program>, IAsyncLifetime
{
private readonly MongoDbRunner mongoRunner;
private readonly string storagePath;
public SignalsTestFactory()
{
mongoRunner = MongoDbRunner.Start(singleNodeReplSet: true);
storagePath = Path.Combine(Path.GetTempPath(), "signals-tests", Guid.NewGuid().ToString());
Directory.CreateDirectory(storagePath);
}
public string StoragePath => storagePath;
public MongoDbRunner MongoRunner => mongoRunner;
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.ConfigureAppConfiguration((context, configuration) =>
{
var settings = new Dictionary<string, string?>
{
["Signals:Authority:Enabled"] = "false",
["Signals:Authority:AllowAnonymousFallback"] = "true",
["Signals:Mongo:ConnectionString"] = mongoRunner.ConnectionString,
["Signals:Mongo:Database"] = "signals-tests",
["Signals:Mongo:CallgraphsCollection"] = "callgraphs",
["Signals:Storage:RootPath"] = storagePath
};
configuration.AddInMemoryCollection(settings);
});
}
public Task InitializeAsync() => Task.CompletedTask;
public async Task DisposeAsync()
{
await Task.Run(() => mongoRunner.Dispose());
try
{
if (Directory.Exists(storagePath))
{
Directory.Delete(storagePath, recursive: true);
}
}
catch
{
// best effort cleanup.
}
}
}