using System; using System.Collections.Generic; using System.Net; using System.Net.Http; using System.Security.Cryptography; using System.Text; using System.Text.Json; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Http; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using MongoDB.Bson; using StellaOps.Concelier.Connector.Common; using StellaOps.Concelier.Connector.Common.Fetch; using StellaOps.Concelier.Connector.Common.Testing; using StellaOps.Concelier.Connector.StellaOpsMirror.Settings; using StellaOps.Concelier.Storage.Mongo; using StellaOps.Concelier.Storage.Mongo.Documents; using StellaOps.Concelier.Testing; using StellaOps.Cryptography; using Xunit; namespace StellaOps.Concelier.Connector.StellaOpsMirror.Tests; [Collection("mongo-fixture")] public sealed class StellaOpsMirrorConnectorTests : IAsyncLifetime { private readonly MongoIntegrationFixture _fixture; private readonly CannedHttpMessageHandler _handler; public StellaOpsMirrorConnectorTests(MongoIntegrationFixture fixture) { _fixture = fixture; _handler = new CannedHttpMessageHandler(); } [Fact] public async Task FetchAsync_PersistsMirrorArtifacts() { var manifestContent = "{\"domain\":\"primary\",\"files\":[]}"; var bundleContent = "{\"advisories\":[{\"id\":\"CVE-2025-0001\"}]}"; var manifestDigest = ComputeDigest(manifestContent); var bundleDigest = ComputeDigest(bundleContent); var index = BuildIndex(manifestDigest, Encoding.UTF8.GetByteCount(manifestContent), bundleDigest, Encoding.UTF8.GetByteCount(bundleContent), includeSignature: false); await using var provider = await BuildServiceProviderAsync(); SeedResponses(index, manifestContent, bundleContent, signature: null); var connector = provider.GetRequiredService(); await connector.FetchAsync(provider, CancellationToken.None); var documentStore = provider.GetRequiredService(); var manifestUri = "https://mirror.test/mirror/primary/manifest.json"; var bundleUri = "https://mirror.test/mirror/primary/bundle.json"; var manifestDocument = await documentStore.FindBySourceAndUriAsync(StellaOpsMirrorConnector.Source, manifestUri, CancellationToken.None); Assert.NotNull(manifestDocument); Assert.Equal(DocumentStatuses.Mapped, manifestDocument!.Status); Assert.Equal(NormalizeDigest(manifestDigest), manifestDocument.Sha256); var bundleDocument = await documentStore.FindBySourceAndUriAsync(StellaOpsMirrorConnector.Source, bundleUri, CancellationToken.None); Assert.NotNull(bundleDocument); Assert.Equal(DocumentStatuses.PendingParse, bundleDocument!.Status); Assert.Equal(NormalizeDigest(bundleDigest), bundleDocument.Sha256); var rawStorage = provider.GetRequiredService(); Assert.NotNull(manifestDocument.GridFsId); Assert.NotNull(bundleDocument.GridFsId); var manifestBytes = await rawStorage.DownloadAsync(manifestDocument.GridFsId!.Value, CancellationToken.None); var bundleBytes = await rawStorage.DownloadAsync(bundleDocument.GridFsId!.Value, CancellationToken.None); Assert.Equal(manifestContent, Encoding.UTF8.GetString(manifestBytes)); Assert.Equal(bundleContent, Encoding.UTF8.GetString(bundleBytes)); var stateRepository = provider.GetRequiredService(); var state = await stateRepository.TryGetAsync(StellaOpsMirrorConnector.Source, CancellationToken.None); Assert.NotNull(state); var cursorDocument = state!.Cursor ?? new BsonDocument(); var digestValue = cursorDocument.TryGetValue("bundleDigest", out var digestBson) ? digestBson.AsString : string.Empty; Assert.Equal(NormalizeDigest(bundleDigest), NormalizeDigest(digestValue)); var pendingDocumentsArray = cursorDocument.TryGetValue("pendingDocuments", out var pendingDocsBson) && pendingDocsBson is BsonArray pendingArray ? pendingArray : new BsonArray(); Assert.Single(pendingDocumentsArray); var pendingDocumentId = Guid.Parse(pendingDocumentsArray[0].AsString); Assert.Equal(bundleDocument.Id, pendingDocumentId); var pendingMappingsArray = cursorDocument.TryGetValue("pendingMappings", out var pendingMappingsBson) && pendingMappingsBson is BsonArray mappingsArray ? mappingsArray : new BsonArray(); Assert.Empty(pendingMappingsArray); } [Fact] public async Task FetchAsync_TamperedSignatureThrows() { var manifestContent = "{\"domain\":\"primary\"}"; var bundleContent = "{\"advisories\":[{\"id\":\"CVE-2025-0002\"}]}"; var manifestDigest = ComputeDigest(manifestContent); var bundleDigest = ComputeDigest(bundleContent); var index = BuildIndex(manifestDigest, Encoding.UTF8.GetByteCount(manifestContent), bundleDigest, Encoding.UTF8.GetByteCount(bundleContent), includeSignature: true); await using var provider = await BuildServiceProviderAsync(options => { options.Signature.Enabled = true; options.Signature.KeyId = "mirror-key"; options.Signature.Provider = "default"; }); var defaultProvider = provider.GetRequiredService(); var signingKey = CreateSigningKey("mirror-key"); defaultProvider.UpsertSigningKey(signingKey); var (signatureValue, _) = CreateDetachedJws(signingKey, bundleContent); // Tamper with signature so verification fails. var tamperedSignature = signatureValue.Replace('a', 'b'); SeedResponses(index, manifestContent, bundleContent, tamperedSignature); var connector = provider.GetRequiredService(); await Assert.ThrowsAsync(() => connector.FetchAsync(provider, CancellationToken.None)); var stateRepository = provider.GetRequiredService(); var state = await stateRepository.TryGetAsync(StellaOpsMirrorConnector.Source, CancellationToken.None); Assert.NotNull(state); Assert.True(state!.FailCount >= 1); Assert.False(state.Cursor.TryGetValue("bundleDigest", out _)); } [Fact] public async Task FetchAsync_SignatureKeyMismatchThrows() { var manifestContent = "{\"domain\":\"primary\"}"; var bundleContent = "{\"advisories\":[{\"id\":\"CVE-2025-0003\"}]}"; var manifestDigest = ComputeDigest(manifestContent); var bundleDigest = ComputeDigest(bundleContent); var index = BuildIndex( manifestDigest, Encoding.UTF8.GetByteCount(manifestContent), bundleDigest, Encoding.UTF8.GetByteCount(bundleContent), includeSignature: true, signatureKeyId: "unexpected-key", signatureProvider: "default"); var signingKey = CreateSigningKey("unexpected-key"); var (signatureValue, _) = CreateDetachedJws(signingKey, bundleContent); await using var provider = await BuildServiceProviderAsync(options => { options.Signature.Enabled = true; options.Signature.KeyId = "mirror-key"; options.Signature.Provider = "default"; }); SeedResponses(index, manifestContent, bundleContent, signatureValue); var connector = provider.GetRequiredService(); await Assert.ThrowsAsync(() => connector.FetchAsync(provider, CancellationToken.None)); } public Task InitializeAsync() => Task.CompletedTask; public Task DisposeAsync() { _handler.Clear(); return Task.CompletedTask; } private async Task BuildServiceProviderAsync(Action? configureOptions = null) { await _fixture.Client.DropDatabaseAsync(_fixture.Database.DatabaseNamespace.DatabaseName); _handler.Clear(); var services = new ServiceCollection(); services.AddLogging(builder => builder.AddProvider(NullLoggerProvider.Instance)); services.AddSingleton(_handler); services.AddSingleton(TimeProvider.System); services.AddMongoStorage(options => { options.ConnectionString = _fixture.Runner.ConnectionString; options.DatabaseName = _fixture.Database.DatabaseNamespace.DatabaseName; options.CommandTimeout = TimeSpan.FromSeconds(5); }); services.AddSingleton(); services.AddSingleton(sp => sp.GetRequiredService()); services.AddSingleton(sp => new CryptoProviderRegistry(sp.GetServices())); var configuration = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { ["concelier:sources:stellaopsMirror:baseAddress"] = "https://mirror.test/", ["concelier:sources:stellaopsMirror:domainId"] = "primary", ["concelier:sources:stellaopsMirror:indexPath"] = "/concelier/exports/index.json", }) .Build(); var routine = new StellaOpsMirrorDependencyInjectionRoutine(); routine.Register(services, configuration); if (configureOptions is not null) { services.PostConfigure(configureOptions); } services.Configure("stellaops-mirror", builder => { builder.HttpMessageHandlerBuilderActions.Add(options => { options.PrimaryHandler = _handler; }); }); var provider = services.BuildServiceProvider(); var bootstrapper = provider.GetRequiredService(); await bootstrapper.InitializeAsync(CancellationToken.None); return provider; } private void SeedResponses(string indexJson, string manifestContent, string bundleContent, string? signature) { var baseUri = new Uri("https://mirror.test"); _handler.AddResponse(HttpMethod.Get, new Uri(baseUri, "/concelier/exports/index.json"), () => CreateJsonResponse(indexJson)); _handler.AddResponse(HttpMethod.Get, new Uri(baseUri, "mirror/primary/manifest.json"), () => CreateJsonResponse(manifestContent)); _handler.AddResponse(HttpMethod.Get, new Uri(baseUri, "mirror/primary/bundle.json"), () => CreateJsonResponse(bundleContent)); if (signature is not null) { _handler.AddResponse(HttpMethod.Get, new Uri(baseUri, "mirror/primary/bundle.json.jws"), () => new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(signature, Encoding.UTF8, "application/jose+json"), }); } } private static HttpResponseMessage CreateJsonResponse(string content) => new(HttpStatusCode.OK) { Content = new StringContent(content, Encoding.UTF8, "application/json"), }; private static string BuildIndex( string manifestDigest, int manifestBytes, string bundleDigest, int bundleBytes, bool includeSignature, string signatureKeyId = "mirror-key", string signatureProvider = "default") { var index = new { schemaVersion = 1, generatedAt = new DateTimeOffset(2025, 10, 19, 12, 0, 0, TimeSpan.Zero), targetRepository = "repo", domains = new[] { new { domainId = "primary", displayName = "Primary", advisoryCount = 1, manifest = new { path = "mirror/primary/manifest.json", sizeBytes = manifestBytes, digest = manifestDigest, signature = (object?)null, }, bundle = new { path = "mirror/primary/bundle.json", sizeBytes = bundleBytes, digest = bundleDigest, signature = includeSignature ? new { path = "mirror/primary/bundle.json.jws", algorithm = "ES256", keyId = signatureKeyId, provider = signatureProvider, signedAt = new DateTimeOffset(2025, 10, 19, 12, 0, 0, TimeSpan.Zero), } : null, }, sources = Array.Empty(), } } }; return JsonSerializer.Serialize(index, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, WriteIndented = false, }); } private static string ComputeDigest(string content) { var bytes = Encoding.UTF8.GetBytes(content); var hash = SHA256.HashData(bytes); return "sha256:" + Convert.ToHexString(hash).ToLowerInvariant(); } private static string NormalizeDigest(string digest) => digest.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase) ? digest[7..] : digest; private static CryptoSigningKey CreateSigningKey(string keyId) { using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256); var parameters = ecdsa.ExportParameters(includePrivateParameters: true); return new CryptoSigningKey(new CryptoKeyReference(keyId), SignatureAlgorithms.Es256, in parameters, DateTimeOffset.UtcNow); } private static (string Signature, DateTimeOffset SignedAt) CreateDetachedJws(CryptoSigningKey signingKey, string payload) { var provider = new DefaultCryptoProvider(); provider.UpsertSigningKey(signingKey); var signer = provider.GetSigner(SignatureAlgorithms.Es256, signingKey.Reference); var header = new Dictionary { ["alg"] = SignatureAlgorithms.Es256, ["kid"] = signingKey.Reference.KeyId, ["provider"] = provider.Name, ["typ"] = "application/vnd.stellaops.concelier.mirror-bundle+jws", ["b64"] = false, ["crit"] = new[] { "b64" } }; var headerJson = JsonSerializer.Serialize(header); var encodedHeader = Microsoft.IdentityModel.Tokens.Base64UrlEncoder.Encode(headerJson); var payloadBytes = Encoding.UTF8.GetBytes(payload); var signingInput = BuildSigningInput(encodedHeader, payloadBytes); var signatureBytes = signer.SignAsync(signingInput, CancellationToken.None).GetAwaiter().GetResult(); var encodedSignature = Microsoft.IdentityModel.Tokens.Base64UrlEncoder.Encode(signatureBytes); return (string.Concat(encodedHeader, "..", encodedSignature), DateTimeOffset.UtcNow); } private static ReadOnlyMemory BuildSigningInput(string encodedHeader, ReadOnlySpan payload) { var headerBytes = Encoding.ASCII.GetBytes(encodedHeader); var buffer = new byte[headerBytes.Length + 1 + payload.Length]; headerBytes.CopyTo(buffer, 0); buffer[headerBytes.Length] = (byte)'.'; payload.CopyTo(buffer.AsSpan(headerBytes.Length + 1)); return buffer; } }