Restructure solution layout by module
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user