Files
git.stella-ops.org/src/StellaOps.Feedser.Exporter.Json.Tests/JsonExportSnapshotBuilderTests.cs
master b97fc7685a
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
Initial commit (history squashed)
2025-10-11 23:28:35 +03:00

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();
}
}
}
}
}