Some checks failed
		
		
	
	Build Test Deploy / authority-container (push) Has been cancelled
				
			Build Test Deploy / docs (push) Has been cancelled
				
			Build Test Deploy / deploy (push) Has been cancelled
				
			Build Test Deploy / build-test (push) Has been cancelled
				
			Docs CI / lint-and-preview (push) Has been cancelled
				
			
		
			
				
	
	
		
			214 lines
		
	
	
		
			8.5 KiB
		
	
	
	
		
			C#
		
	
	
	
	
	
			
		
		
	
	
			214 lines
		
	
	
		
			8.5 KiB
		
	
	
	
		
			C#
		
	
	
	
	
	
| using System;
 | |
| using System.Collections.Generic;
 | |
| using System.Globalization;
 | |
| using System.IO;
 | |
| using System.Linq;
 | |
| using System.Security.Cryptography;
 | |
| using System.Runtime.CompilerServices;
 | |
| using System.Threading;
 | |
| using System.Threading.Tasks;
 | |
| using StellaOps.Feedser.Exporter.Json;
 | |
| using StellaOps.Feedser.Models;
 | |
| 
 | |
| namespace StellaOps.Feedser.Exporter.Json.Tests;
 | |
| 
 | |
| public sealed class JsonExportSnapshotBuilderTests : IDisposable
 | |
| {
 | |
|     private readonly string _root;
 | |
| 
 | |
|     public JsonExportSnapshotBuilderTests()
 | |
|     {
 | |
|         _root = Directory.CreateTempSubdirectory("feedser-json-export-tests").FullName;
 | |
|     }
 | |
| 
 | |
|     [Fact]
 | |
|     public async Task WritesDeterministicTree()
 | |
|     {
 | |
|         var options = new JsonExportOptions { OutputRoot = _root };
 | |
|         var builder = new JsonExportSnapshotBuilder(options, new VulnListJsonExportPathResolver());
 | |
|         var exportedAt = DateTimeOffset.Parse("2024-07-15T12:00:00Z", CultureInfo.InvariantCulture);
 | |
| 
 | |
|         var advisories = new[]
 | |
|         {
 | |
|             CreateAdvisory(
 | |
|                 advisoryKey: "CVE-2024-9999",
 | |
|                 aliases: new[] { "GHSA-zzzz-yyyy-xxxx", "CVE-2024-9999" },
 | |
|                 title: "Deterministic Snapshot",
 | |
|                 severity: "critical"),
 | |
|             CreateAdvisory(
 | |
|                 advisoryKey: "VENDOR-2024-42",
 | |
|                 aliases: new[] { "ALIAS-1", "ALIAS-2" },
 | |
|                 title: "Vendor Advisory",
 | |
|                 severity: "medium"),
 | |
|         };
 | |
| 
 | |
|         var result = await builder.WriteAsync(advisories, exportedAt, cancellationToken: CancellationToken.None);
 | |
| 
 | |
|         Assert.Equal(advisories.Length, result.AdvisoryCount);
 | |
|         Assert.Equal(exportedAt, result.ExportedAt);
 | |
| 
 | |
|         var expectedFiles = result.FilePaths.OrderBy(x => x, StringComparer.Ordinal).ToArray();
 | |
|         Assert.Contains("nvd/2024/CVE-2024-9999.json", expectedFiles);
 | |
|         Assert.Contains("misc/VENDOR-2024-42.json", expectedFiles);
 | |
| 
 | |
|         var cvePath = ResolvePath(result.ExportDirectory, "nvd/2024/CVE-2024-9999.json");
 | |
|         Assert.True(File.Exists(cvePath));
 | |
|         var actualJson = await File.ReadAllTextAsync(cvePath, CancellationToken.None);
 | |
|         Assert.Equal(SnapshotSerializer.ToSnapshot(advisories[0]), actualJson);
 | |
|     }
 | |
| 
 | |
|     [Fact]
 | |
|     public async Task ProducesIdenticalBytesAcrossRuns()
 | |
|     {
 | |
|         var options = new JsonExportOptions { OutputRoot = _root };
 | |
|         var builder = new JsonExportSnapshotBuilder(options, new VulnListJsonExportPathResolver());
 | |
|         var exportedAt = DateTimeOffset.Parse("2024-05-01T00:00:00Z", CultureInfo.InvariantCulture);
 | |
|         var advisories = new[]
 | |
|         {
 | |
|             CreateAdvisory("CVE-2024-1000", new[] { "CVE-2024-1000", "GHSA-aaaa-bbbb-cccc" }, "Snapshot Stable", "high"),
 | |
|         };
 | |
| 
 | |
|         var first = await builder.WriteAsync(advisories, exportedAt, exportName: "20240501T000000Z", CancellationToken.None);
 | |
|         var firstDigest = ComputeDigest(first);
 | |
| 
 | |
|         var second = await builder.WriteAsync(advisories, exportedAt, exportName: "20240501T000000Z", CancellationToken.None);
 | |
|         var secondDigest = ComputeDigest(second);
 | |
| 
 | |
|         Assert.Equal(Convert.ToHexString(firstDigest), Convert.ToHexString(secondDigest));
 | |
|     }
 | |
| 
 | |
|     [Fact]
 | |
|     public async Task WriteAsync_NormalizesInputOrdering()
 | |
|     {
 | |
|         var options = new JsonExportOptions { OutputRoot = _root };
 | |
|         var builder = new JsonExportSnapshotBuilder(options, new VulnListJsonExportPathResolver());
 | |
|         var exportedAt = DateTimeOffset.Parse("2024-06-01T00:00:00Z", CultureInfo.InvariantCulture);
 | |
| 
 | |
|         var advisoryA = CreateAdvisory("CVE-2024-1000", new[] { "CVE-2024-1000" }, "Alpha", "high");
 | |
|         var advisoryB = CreateAdvisory("VENDOR-0001", new[] { "VENDOR-0001" }, "Vendor Advisory", "medium");
 | |
| 
 | |
|         var result = await builder.WriteAsync(new[] { advisoryB, advisoryA }, exportedAt, cancellationToken: CancellationToken.None);
 | |
| 
 | |
|         var expectedOrder = result.FilePaths.OrderBy(path => path, StringComparer.Ordinal).ToArray();
 | |
|         Assert.Equal(expectedOrder, result.FilePaths.ToArray());
 | |
|     }
 | |
| 
 | |
|     [Fact]
 | |
|     public async Task WriteAsync_EnumeratesStreamOnlyOnce()
 | |
|     {
 | |
|         var options = new JsonExportOptions { OutputRoot = _root };
 | |
|         var builder = new JsonExportSnapshotBuilder(options, new VulnListJsonExportPathResolver());
 | |
|         var exportedAt = DateTimeOffset.Parse("2024-08-01T00:00:00Z", CultureInfo.InvariantCulture);
 | |
| 
 | |
|         var advisories = new[]
 | |
|         {
 | |
|             CreateAdvisory("CVE-2024-2000", new[] { "CVE-2024-2000" }, "Streaming One", "medium"),
 | |
|             CreateAdvisory("CVE-2024-2001", new[] { "CVE-2024-2001" }, "Streaming Two", "low"),
 | |
|         };
 | |
| 
 | |
|         var sequence = new SingleEnumerationAsyncSequence(advisories);
 | |
|         var result = await builder.WriteAsync(sequence, exportedAt, cancellationToken: CancellationToken.None);
 | |
| 
 | |
|         Assert.Equal(advisories.Length, result.AdvisoryCount);
 | |
|     }
 | |
| 
 | |
|     private static Advisory CreateAdvisory(string advisoryKey, string[] aliases, string title, string severity)
 | |
|     {
 | |
|         return new Advisory(
 | |
|             advisoryKey: advisoryKey,
 | |
|             title: title,
 | |
|             summary: null,
 | |
|             language: "EN",
 | |
|             published: DateTimeOffset.Parse("2024-01-01T00:00:00Z", CultureInfo.InvariantCulture),
 | |
|             modified: DateTimeOffset.Parse("2024-01-02T00:00:00Z", CultureInfo.InvariantCulture),
 | |
|             severity: severity,
 | |
|             exploitKnown: false,
 | |
|             aliases: aliases,
 | |
|             references: new[]
 | |
|             {
 | |
|                 new AdvisoryReference("https://example.com/advisory", "advisory", null, null, AdvisoryProvenance.Empty),
 | |
|             },
 | |
|             affectedPackages: new[]
 | |
|             {
 | |
|                 new AffectedPackage(
 | |
|                     AffectedPackageTypes.SemVer,
 | |
|                     "sample/package",
 | |
|                     platform: null,
 | |
|                     versionRanges: Array.Empty<AffectedVersionRange>(),
 | |
|                     statuses: Array.Empty<AffectedPackageStatus>(),
 | |
|                     provenance: Array.Empty<AdvisoryProvenance>()),
 | |
|             },
 | |
|             cvssMetrics: Array.Empty<CvssMetric>(),
 | |
|             provenance: new[]
 | |
|             {
 | |
|                 new AdvisoryProvenance("feedser", "normalized", "canonical", DateTimeOffset.Parse("2024-01-02T00:00:00Z", CultureInfo.InvariantCulture)),
 | |
|             });
 | |
|     }
 | |
| 
 | |
|     private static byte[] ComputeDigest(JsonExportResult result)
 | |
|     {
 | |
|         using var sha256 = SHA256.Create();
 | |
|         foreach (var relative in result.FilePaths.OrderBy(x => x, StringComparer.Ordinal))
 | |
|         {
 | |
|             var fullPath = ResolvePath(result.ExportDirectory, relative);
 | |
|             var bytes = File.ReadAllBytes(fullPath);
 | |
|             sha256.TransformBlock(bytes, 0, bytes.Length, null, 0);
 | |
|         }
 | |
| 
 | |
|         sha256.TransformFinalBlock(Array.Empty<byte>(), 0, 0);
 | |
|         return sha256.Hash ?? Array.Empty<byte>();
 | |
|     }
 | |
| 
 | |
|     private static string ResolvePath(string root, string relative)
 | |
|     {
 | |
|         var segments = relative.Split('/', StringSplitOptions.RemoveEmptyEntries);
 | |
|         return Path.Combine(new[] { root }.Concat(segments).ToArray());
 | |
|     }
 | |
| 
 | |
|     public void Dispose()
 | |
|     {
 | |
|         try
 | |
|         {
 | |
|             if (Directory.Exists(_root))
 | |
|             {
 | |
|                 Directory.Delete(_root, recursive: true);
 | |
|             }
 | |
|         }
 | |
|         catch
 | |
|         {
 | |
|             // best effort cleanup
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     private sealed class SingleEnumerationAsyncSequence : IAsyncEnumerable<Advisory>
 | |
|     {
 | |
|         private readonly IReadOnlyList<Advisory> _advisories;
 | |
|         private int _enumerated;
 | |
| 
 | |
|         public SingleEnumerationAsyncSequence(IReadOnlyList<Advisory> advisories)
 | |
|         {
 | |
|             _advisories = advisories ?? throw new ArgumentNullException(nameof(advisories));
 | |
|         }
 | |
| 
 | |
|         public IAsyncEnumerator<Advisory> GetAsyncEnumerator(CancellationToken cancellationToken = default)
 | |
|         {
 | |
|             if (Interlocked.Exchange(ref _enumerated, 1) == 1)
 | |
|             {
 | |
|                 throw new InvalidOperationException("Sequence was enumerated more than once.");
 | |
|             }
 | |
| 
 | |
|             return Enumerate(cancellationToken);
 | |
| 
 | |
|             async IAsyncEnumerator<Advisory> Enumerate([EnumeratorCancellation] CancellationToken ct)
 | |
|             {
 | |
|                 foreach (var advisory in _advisories)
 | |
|                 {
 | |
|                     ct.ThrowIfCancellationRequested();
 | |
|                     yield return advisory;
 | |
|                     await Task.Yield();
 | |
|                 }
 | |
|             }
 | |
|         }
 | |
|     }
 | |
| }
 |